Aprendendo o básico de assembly x86-64

Traduções: English (en)
Data de publicação: 25/02/2022
Categorias: assembly, x86-64

Recentemente eu decidi aprender assembly. Eu já tinha um entendimento razoável de como funciona graças a algumas aulas que tocavam no assunto na universidade, mas eu nunca tive a oportunidade de realmente escrever código em assembly.

Já que meu computador é uma máquina x86-64, eu decidi aprender assembly para essa arquitetura, assim eu não precisaria de uma máquina virtual. Eu comecei com apenas o desejo de botar a mão na massa com código em assembly, sem ter nenhum objetivo ou projeto em especial.

No começo eu estava alternando entre tentar coisas e pesquisar na internet só para entender o suficiente a ponto de conseguir fazer um arquivo mínimo em assembly que eu conseguisse montar e rodar. Eventualmente eu encontrei o livro que me guiaria: x86-64 Assembly Language Programming with Ubuntu.

Esse livro é grátis, recente e tinha o escopo perfeito para mim: é voltado para pessoas que já entendem bem de programação, mas que são novas em assembly x86-64, e ele expõe um pouco de teoria e conceitos, mas também tem bastante exercícios para aprender praticando.

Foi bem divertido completar esse livro, e funcionou muito bem para eu criar mais familiaridade com assembly x86-64. Com certeza ainda tem muito para eu aprender sobre o assunto, já que o livro apenas dá a base, mas já foi o suficiente para me ensinar algumas coisas interessantes.

Sinal de variáveis e complemento de dois

A maior lição para mim foi um melhor entendimento sobre o sinal de variáveis. Eu estou acostumado a ver int e unsigned int em C, e a tomar cuidado para usar o correto, mas não era claro para mim como isso funcionava no nível do assembly.

A primeira coisa a se ter em mente, é que o conceito de tipos presente em linguagens de alto nível como C (por exemplo se um número é com ou sem sinal) é completamente ausente no assembly. A memória do computador armazena apenas 0s e 1s, e cabe a você, que está programando, interpretar o que eles significam: 01011000 é o número 88, o caractere X, a instrução POP AX? Tendo apenas o byte, você não consegue nem ter certeza do tamanho: talvez seja na verdade 8 flags booleanas em um único byte, ou parte de um número de 4 bytes. Sem ter o contexto, é impossível dizer.

Se a mesma representação pode significar tanto um número com sinal quanto um sem sinal, dependendo do contexto, isso significa que quando estiver fazendo operações com esses números, você que está programando que precisa usar a variante correta da instrução para fornecer esse contexto para o computador.

Durante o livro, as instruções aritméticas a seguir foram apresentadas para números sem sinal:

  • add soma dois números
  • sub subtrai dois números
  • mul multiplica dois números
  • div divide dois números

E as instruções a seguir foram mostradas para comparação entre números sem sinal:

  • ja compara dois números e pula se o primeiro for maior que o segundo
  • jb compara dois números e pula se o primeiro for menor que o segundo

E claro, logo em seguida, as variantes dessas instruções para números com sinal também foram mostradas:

  • imul é a variante com sinal do mul
  • idiv é a variante com sinal do div
  • jg é a variante com sinal do ja
  • jl é a variante com sinal do jb

Mas calma, e o iadd e o isub? É aí que tá, o jeito que o x86-64 representa números negativos é usando o sistema de complemento de dois, que tem a útil propriedade de possibilitar que somas e subtrações sejam feitas exatamente da mesma forma tanto para números com sinal quanto para sem sinal.

Isso significa que só existe uma forma de somar, independente do sinal do número, e é usando add. Não existe iadd. Mesma coisa para subtração.

Então a conclusão interessante é que para adição e subtração não importa se você usa unsigned int ou int em variáveis em C. O propósito da palavra-chave unsigned é dar o contexto que falta para o compilador, para que ele use a instrução correta em operações com esse número no assembly gerado, e é crucial para a comparação entre números (ja vs jg, jb vs jl), multiplicação (mul vs imul) e divisão (div vs idiv). Mas graças ao complemento de dois, na soma e subtração não tem como errar 🙂.

Tangente: interessantemente, enquanto eu escrevia esse artigo, eu li na página da Wikipédia (em inglês) que o complemento de dois também funciona da mesma forma para multiplicação, mas só se o sinal dos operandos for primeiramente estendido. Isso me faz pensar que se a instrução mul sempre fizesse o passo de extensão do sinal, também não haveria necessidade para uma instrução imul, mas isso provavelmente aumentaria a complexidade (e custo) do circuito lógico.

Outros aprendizados interessantes

A outra coisa que mais me interessou foi perceber que variáveis locais não são nada mais do que adicionar mais espaço na pilha. E que isso é feito simplesmente subtraindo do registrador da pilha rsp o total de bytes necessário para todas as variáveis no começo de uma subrotina.

Também interessante foi aprender que existem convenções de chamada para padronizar:

  • quais registradores são usados para passar argumentos para subrotinas e em qual ordem;
  • quais registradores podem ser sobrescritos por uma subrotina e quais devem ser mantidos intactos. No caso do uso destes últimos, o valor do registrador deve ser primeiro empurrado para a pilha para que depois possa ser recuperado antes de retornar.

E quanto à função mágica main() que o compilador de C espera encontrar em todo programa em C? Assembly não requer compilação, então ela não é necessária, mas no final das contas uma outra label mágica é esperada pelo ligador: _start.

Outras coisas que foram interessantes de fazer em assembly:

  • Fazer syscalls
  • Tirar vantagem de um overflow de buffer na pilha
  • Interagir código assembly com código em C, e vice-versa.

Falta de uma boa GUI

Uma coisa que eu senti falta foi de uma boa aplicação GUI quando estava depurando os programas em assembly. Teria sido muito útil ter uma que mostrasse os valores de expressões em tooltips quando deixasse o mouse em cima, que pulasse para labels quando clicasse nelas, etc.

O livro recomenda usar o DDD, que é uma GUI, mas eu não achei ele muito agradável de usar e era claramente velho. Então eu acabei usando o GDB junto com o plugin peda, e funcionou razoavelmente bem, mas por ser uma CLI, cada inspeção requeria descobrir o comando certo, então levava mais tempo para se orientar.

Conclusão

Essa foi uma ótima experiência e eu espero no futuro aprofundar meu conhecimento além do nível básico de x86-64. Ver o que está acontecendo no nível do assembly realmente ajuda a entender melhor as linguagens de alto-nível, e a valorizar as complexidades que elas escondem!

Eu subi o código que eu escrevi para todos os exercícios do livro para esse repositório. Eu não acho que vai ser útil para ninguém porque são coisas simples, mas está lá de qualquer forma.

O único exercício que eu não consegui completar foi o último. Tinha muito pouca informação no livro sobre como realizá-lo, e durante a pesquisa sobre o tópico online eu acabei me desanimando e comecei a aprender sobre outros assuntos. Mas talvez um dia eu tente de novo. Se você souber como fazê-lo, entre em contato! 🙂

E apesar de eu não ter conseguido concluir esse último exercício, foi durante a pesquisa sobre ele que eu acabei aprendendo como usar a sintaxe do asm no GCC através desse guia, para escrever código assembly dentro de um arquivo em C, e também conheci o Compiler Explorer que parece uma ótima forma de aprender sobre assembly e C olhando qual assembly é gerado para um certo código em C, então estou considerando como uma vitória!