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.
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úmerossub
subtrai dois númerosmul
multiplica dois númerosdiv
divide dois númerosE 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 segundojb
compara dois números e pula se o primeiro for menor que o segundoE 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.
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:
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:
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.
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!