Atualmente, raramente é necessário escrever em assembler puro, mas eu definitivamente recomendo isso para qualquer pessoa interessada em programação. Você verá as coisas de um ângulo diferente, e as habilidades serão úteis ao depurar o código em outros idiomas.
Neste artigo, escreveremos do zero uma calculadora de
notação polonesa reversa (RPN) no assembler x86 puro. Quando terminamos, podemos usá-lo assim:
$ ./calc "32+6*"
Todo o código do artigo está
aqui . É bastante comentado e pode servir como material educacional para quem já conhece montador.
Vamos começar escrevendo o programa básico
Hello world! para verificar as configurações do ambiente. Em seguida, passemos às chamadas do sistema, pilha de chamadas, quadros de pilha e a convenção de chamadas x86. Então, para praticar, escreveremos algumas funções básicas no assembler x86 - e começaremos a escrever uma calculadora RPN.
Supõe-se que o leitor tenha alguma experiência em programação em C e conhecimentos básicos de arquitetura de computadores (por exemplo, o que é um registro de processador). Como usaremos o Linux, você também deve poder usar a linha de comando do Linux.
Configuração do ambiente
Como já mencionado, usamos o Linux (64 bits ou 32 bits). O código acima não funciona no Windows ou no Mac OS X.
Para instalação, você só precisa do vinculador GNU
ld
do
binutils
, que é pré-instalado na maioria das distribuições, e do montador NASM. No Ubuntu e Debian, você pode instalar os dois com um comando:
$ sudo apt-get install binutils nasm
Eu também recomendaria manter
uma tabela ASCII à mão.
Olá mundo!
Para verificar o ambiente, salve o seguinte código no arquivo
calc.asm
:
; _start ; . global _start ; .rodata ( ) ; , section .rodata ; hello_world. NASM ; , , ; . 0xA = , 0x0 = hello_world: db "Hello world!", 0xA, 0x0 ; .text, section .text _start: mov eax, 0x04 ; 4 eax (0x04 = write()) mov ebx, 0x1 ; (1 = , 2 = ) mov ecx, hello_world ; mov edx, 14 ; int 0x80 ; 0x80, ; mov eax, 0x01 ; 0x01 = exit() mov ebx, 0 ; 0 = int 0x80
Comentários explicam a estrutura geral. Para obter uma lista de registros e instruções gerais, consulte o
Guia do Montador x86 da
Universidade da Virgínia . Com uma discussão mais aprofundada das chamadas do sistema, isso será ainda mais necessário.
Os seguintes comandos coletam o arquivo assembler em um arquivo de objeto e, em seguida, compilam o arquivo executável:
$ nasm -f elf_i386 calc.asm -o calc $ ld -m elf_i386 calc.o -o calc
Depois de iniciar, você deverá ver:
$ ./calc Hello world!
Makefile
Esta é uma parte opcional, mas você pode criar um
Makefile
para simplificar a construção e o layout no futuro. Salve-o no mesmo diretório que
calc.asm
:
CFLAGS= -f elf32 LFLAGS= -m elf_i386 all: calc calc: calc.o ld $(LFLAGS) calc.o -o calc calc.o: calc.asm nasm $(CFLAGS) calc.asm -o calc.o clean: rm -f calc.o calc .INTERMEDIATE: calc.o
Então, em vez das instruções acima, apenas execute make.
Chamadas do sistema
As chamadas do sistema Linux dizem ao sistema operacional para fazer algo por nós. Neste artigo, usamos apenas duas chamadas do sistema:
write()
para escrever uma linha em um arquivo ou fluxo (no nosso caso, este é um dispositivo de saída padrão e um erro padrão) e
exit()
para sair do programa:
syscall 0x01: exit(int error_code) error_code - 0 ( 1) syscall 0x04: write(int fd, char *string, int length) fd — 1 , 2 string — length —
As chamadas do sistema são configuradas armazenando o número de chamada do sistema no registro
eax
e, em seguida, seus argumentos em
ebx
,
ecx
,
edx
nessa ordem. Você pode notar que
exit()
apenas um argumento - nesse caso, ecx e edx não importam.
eax | ebx | ecx | edx |
---|
Número de chamada do sistema | arg1 | arg2 | arg3 |
Pilha de chamadas

Uma pilha de chamadas é uma estrutura de dados que armazena informações sobre cada chamada em uma função. Cada chamada tem sua própria seção na pilha - o "quadro". Ele armazena algumas informações sobre a chamada atual: as variáveis locais desta função e o endereço de retorno (para onde o programa deve ir após a execução da função).
Noto imediatamente uma coisa não óbvia: a pilha
diminui a memória. Quando você adiciona algo ao topo da pilha, ele é inserido em um endereço de memória menor que o item anterior. Em outras palavras, conforme a pilha cresce, o endereço de memória na parte superior da pilha diminui. Para evitar confusão, sempre lembrarei deste fato.
A instrução
push
algo no topo da pilha e exibe os dados a partir daí. Por exemplo,
push
aloca um lugar no topo da pilha e coloca o valor do registro
eax
ali, e o
pop
transfere todos os dados do topo da pilha para o
eax
e libera essa área de memória.
O objetivo do registro
esp
é apontar para o topo da pilha. Qualquer dado acima de
esp
considerado como não atingindo a pilha, são dados de lixo. A execução de uma instrução
push
(ou
pop
) move
esp
. Você pode manipular
esp
diretamente, se der um relatório às suas ações.
O registro
ebp
é semelhante ao
esp
, mas sempre aponta aproximadamente para o meio do quadro de pilha atual, imediatamente antes das variáveis locais da função atual (falaremos sobre isso mais adiante). No entanto, chamar outra função não move o
ebp
automaticamente; isso deve ser feito manualmente a cada vez.
Convenção de Chamada de Arquitetura X86
No x86, não existe um conceito interno de função como nas linguagens de alto nível. A
goto
call
goto
basicamente apenas
jmp
(
goto
) para outro endereço de memória. Para usar rotinas como funções em outros idiomas (que podem receber argumentos e retornar dados), você precisa seguir a convenção de chamada (existem muitas convenções, mas usamos o CDECL, a convenção mais popular para x86 entre compiladores C e programadores de assembler). Ele também garante que os registros de rotina não sejam confundidos ao chamar outra função.
Regras do chamador
Antes de chamar a função, o chamador deve:
- Salve os registros que o chamador precisa salvar na pilha. A função chamada pode alterar alguns registros: para não perder dados, o chamador deve salvá-lo na memória até ser empurrado para a pilha. Estes são os
edx
eax
, ecx
e edx
. Se você não usar nenhum deles, não poderá salvá-los. - Escreva argumentos de função na pilha na ordem inversa (primeiro último argumento, primeiro primeiro argumento no final). Essa ordem garante que a função chamada receba seus argumentos da pilha na ordem correta.
- Ligue para a sub-rotina.
Se possível, a função salvará o resultado em
eax
. Imediatamente após a
call
chamador deve:
- Remova os argumentos da função da pilha. Isso geralmente é feito simplesmente adicionando o número de bytes ao
esp
. Não esqueça que a pilha cresce, portanto, para remover da pilha, você deve adicionar bytes. - Restaure os registros salvos, colocando-os na pilha na ordem inversa. A função chamada não mudará nenhum outro registrador.
O exemplo a seguir demonstra como essas regras se aplicam. Suponha que a função
_subtract
dois argumentos inteiros (4 bytes) e retorne o primeiro argumento menos o segundo. Na sub-rotina
_mysubroutine
chame
_subtract
com os argumentos
10
e
2
:
_mysubroutine: ; ... ; - ; ... push ecx ; ( eax) push edx push 2 ; , push 10 call _subtract ; eax 10-2=8 add esp, 8 ; 8 ( 4 ) pop edx ; pop ecx ; ... ; - , eax ; ...
Regras da rotina chamada
Antes de ligar, a sub-rotina deve:
- Salve o ponteiro do registro base
ebp
do quadro anterior, escrevendo-o na pilha. - Ajuste o
ebp
do quadro anterior para o atual (valor atual do esp
). - Aloque mais espaço na pilha para variáveis locais, se necessário, mova o ponteiro
esp
. À medida que a pilha cresce, você precisa subtrair a memória ausente de esp
. - Salve os registros da rotina chamada na pilha. Estes são
ebx
, edi
e esi
. Não é necessário salvar registros que não estão planejados para serem alterados.
Pilha de chamadas após a etapa 1:

A pilha de chamadas após a etapa 2:

Pilha de chamadas após a etapa 4:

Nesses diagramas, um endereço de retorno é indicado em cada quadro de pilha. Ele é automaticamente colocado na pilha por uma declaração de
call
. A
ret
recupera o endereço da parte superior da pilha e pula para ele. Não precisamos dessa instrução, apenas mostrei por que as variáveis locais da função estão 4 bytes acima do
ebp
, mas os argumentos da função estão 8 bytes abaixo do
ebp
.
No último diagrama, você também pode observar que as variáveis locais da função sempre iniciam 4 bytes acima de
ebp
do endereço
ebp-4
(subtração aqui, porque estamos subindo a pilha), e os argumentos da função sempre começam 8 bytes abaixo de
ebp
do endereço
ebp+8
(além disso, porque estamos movendo a pilha para baixo). Se você seguir as regras desta convenção, será assim com as variáveis e argumentos de qualquer função.
Quando a função estiver concluída e você desejar retornar, primeiro defina
eax
como o valor de retorno da função, se necessário. Além disso, você precisa de:
- Restaure os registros salvos, colocando-os na pilha na ordem inversa.
- Libere espaço na pilha alocada pela variável local na etapa 3, se necessário: simplesmente instalando
esp
no ebp - Restaure o ponteiro base
ebp
do quadro anterior, retirando-o da pilha. - Retornar com
ret
Agora implementamos a função
_subtract
do nosso exemplo:
_subtract: push ebp ; mov ebp, esp ; ebp ; , ; , ; ; mov eax, [ebp+8] ; eax. ; ebp+8 sub eax, [ebp+12] ; ebp+12 ; ; , eax ; , ; , pop ebp ; ret
Entrada e saída
No exemplo acima, você pode notar que a função sempre executa da mesma maneira:
push ebp
,
mov ebp
,
esp
e alocação de memória para variáveis locais. O conjunto x86 possui uma instrução conveniente que faz tudo isso:
enter ab
, onde
a
é o número de bytes que você deseja alocar para variáveis locais,
b
é o "nível de aninhamento", que sempre definiremos como
0
. Além disso, a função sempre termina com as instruções
pop ebp
e
mov esp
,
ebp
(embora sejam necessárias apenas ao alocar memória para variáveis locais, mas, de qualquer forma, não causem danos). Isso também pode ser substituído por uma única instrução:
leave
. Fazemos alterações:
_subtract: enter 0, 0 ; ebp ; , ; ; mov eax, [ebp+8] ; eax. ; ebp+8 sub eax, [ebp+12] ; ebp+12 ; ; , eax ; , leave ; ret
Escrevendo algumas funções básicas
Depois de dominar a convenção de chamada, você pode começar a escrever algumas rotinas. Por que não generalizar o código que exibe "Olá, mundo!" Para
_print_msg
qualquer linha: a função
_print_msg
.
Aqui precisamos de outra função
_strlen
para contar o comprimento da string. Em C, pode ficar assim:
size_t strlen(char *s) { size_t length = 0; while (*s != 0) {
Em outras palavras, desde o início da linha, adicionamos 1 ao valor de retorno para cada caractere, exceto zero. Assim que o caractere nulo é percebido, retornamos o valor acumulado no loop. No assembler, isso também é bastante simples: você pode usar a função
_subtract
escrita anteriormente como base:
_strlen: enter 0, 0 ; ebp ; , ; ; mov eax, 0 ; length = 0 mov ecx, [ebp+8] ; ( ; ) ecx ( ; , ) _strlen_loop_start: ; , cmp byte [ecx], 0 ; . ; 32 (4 ). ; . ; ( ) je _strlen_loop_end ; inc eax ; , 1 add ecx, 1 ; jmp _strlen_loop_start ; _strlen_loop_end: ; , eax ; , leave ; ret
Já não é ruim, né? Escrever o código C primeiro pode ajudar, porque a maioria é convertida diretamente em assembler. Agora você pode usar esta função em
_print_msg
, onde aplicamos todo o conhecimento adquirido:
_print_msg: enter 0, 0 ; mov eax, 0x04 ; 0x04 = write() mov ebx, 0x1 ; 0x1 = mov ecx, [ebp+8] ; , ; edx . _strlen push eax ; ( edx) push ecx push dword [ebp+8] ; _strlen _print_msg. NASM ; , , , . ; dword (4 , 32 ) call _strlen ; eax mov edx, eax ; edx, add esp, 4 ; 4 ( 4- char*) pop ecx ; pop eax ; _strlen, int 0x80 leave ret
E veja os frutos do nosso trabalho duro, usando esta função no programa completo "Olá, mundo!".
_start: enter 0, 0 ; ( ) push hello_world ; _print_msg call _print_msg mov eax, 0x01 ; 0x01 = exit() mov ebx, 0 ; 0 = int 0x80
Acredite ou não, abordamos todos os principais tópicos necessários para escrever programas básicos do assembler x86! Agora, temos todo o material e teoria introdutórios; portanto, vamos nos concentrar completamente no código e aplicar o conhecimento adquirido para escrever nossa calculadora RPN. As funções serão muito mais longas e até usarão algumas variáveis locais. Se você deseja ver imediatamente o programa finalizado,
aqui está ele .
Para aqueles que não estão familiarizados com a notação polonesa reversa (às vezes chamada de notação polonesa reversa ou notação postfix), aqui as expressões são avaliadas usando a pilha. Portanto, você precisa criar uma pilha, bem como as
_push
e
_push
para manipular essa pilha. Você também
_print_answer
função
_print_answer
, que exibirá uma representação em sequência do resultado numérico no final do cálculo.
Criação de pilha
Primeiro, definimos o espaço na memória da nossa pilha, bem como a variável global
stack_size
. É aconselhável alterar essas variáveis para que não caiam na seção
.rodata
, mas em
.data
.
section .data stack_size: dd 0 ; dword (4 ) 0 stack: times 256 dd 0 ;
Agora você pode implementar as
_pop
e
_pop
:
_push: enter 0, 0 ; , push eax push edx mov eax, [stack_size] mov edx, [ebp+8] mov [stack + 4*eax], edx ; . ; dword inc dword [stack_size] ; 1 stack_size ; pop edx pop eax leave ret _pop: enter 0, 0 ; dec dword [stack_size] ; 1 stack_size mov eax, [stack_size] mov eax, [stack + 4*eax] ; eax ; , leave ret
Saída de número
_print_answer
muito mais complicado: você precisa converter números em strings e usar várias outras funções. Você
_putc
função
_putc
, que gera um caractere, a função
mod
para calcular o restante da divisão (módulo) dos dois argumentos e
_pow_10
para aumentar a potência de 10. Mais tarde, você entenderá por que eles são necessários. Isso é bem simples, aqui está o código:
_pow_10: enter 0, 0 mov ecx, [ebp+8] ; ecx ( ) ; mov eax, 1 ; 10 (10**0 = 1) _pow_10_loop_start: ; eax 10, ecx 0 cmp ecx, 0 je _pow_10_loop_end imul eax, 10 sub ecx, 1 jmp _pow_10_loop_start _pow_10_loop_end: leave ret _mod: enter 0, 0 push ebx mov edx, 0 ; mov eax, [ebp+8] mov ebx, [ebp+12] idiv ebx ; 64- [edx:eax] ebx. ; 32- eax, edx ; . ; eax, edx. , ; , ; . mov eax, edx ; () pop ebx leave ret _putc: enter 0, 0 mov eax, 0x04 ; write() mov ebx, 1 ; lea ecx, [ebp+8] ; mov edx, 1 ; 1 int 0x80 leave ret
Então, como derivamos números individuais em um número? Primeiro, observe que o último dígito do número é o restante da divisão por 10 (por exemplo,
123 % 10 = 3
) e o próximo dígito é o restante da divisão por 100, dividido por 10 (por exemplo,
(123 % 100)/10 = 2
). Em geral, você pode encontrar um dígito específico de um número (da direita para a esquerda) localizando
( % 10**n) / 10**(n-1)
, onde o número de unidades será
n = 1
, o número de dezenas é
n = 2
e assim por diante.
Usando esse conhecimento, você pode encontrar todos os dígitos de um número de
n = 1
a
n = 10
(esse é o número máximo de bits em um número inteiro de 4 bytes assinado). Mas é muito mais fácil ir da esquerda para a direita - para que possamos imprimir cada caractere assim que o encontrarmos e nos livrarmos dos zeros no lado esquerdo. Portanto, classificamos os números de
n = 10
n = 1
.
Em C, o programa será mais ou menos assim:
#define MAX_DIGITS 10 void print_answer(int a) { if (a < 0) { // putc('-'); // «» a = -a; // } int started = 0; for (int i = MAX_DIGITS; i > 0; i--) { int digit = (a % pow_10(i)) / pow_10(i-1); if (digit == 0 && started == 0) continue; // started = 1; putc(digit + '0'); } }
Agora você entende por que precisamos dessas três funções. Vamos implementar isso no assembler: %define MAX_DIGITS 10 _print_answer: enter 1, 0 ; 1 "started" C push ebx push edi push esi mov eax, [ebp+8] ; "a" cmp eax, 0 ; , ; jge _print_answer_negate_end ; call putc for '-' push eax push 0x2d ; '-' call _putc add esp, 4 pop eax neg eax ; _print_answer_negate_end: mov byte [ebp-4], 0 ; started = 0 mov ecx, MAX_DIGITS ; i _print_answer_loop_start: cmp ecx, 0 je _print_answer_loop_end ; pow_10 ecx. ebx "digit" C. ; edx = pow_10(i-1), ebx = pow_10(i) push eax push ecx dec ecx ; i-1 push ecx ; _pow_10 call _pow_10 mov edx, eax ; edx = pow_10(i-1) add esp, 4 pop ecx ; i ecx pop eax ; end pow_10 call mov ebx, edx ; digit = ebx = pow_10(i-1) imul ebx, 10 ; digit = ebx = pow_10(i) ; _mod (a % pow_10(i)), (eax mod ebx) push eax push ecx push edx push ebx ; arg2, ebx = digit = pow_10(i) push eax ; arg1, eax = a call _mod mov ebx, eax ; digit = ebx = a % pow_10(i+1), almost there add esp, 8 pop edx pop ecx pop eax ; mod ; ebx ( "digit" ) pow_10(i) (edx). ; , idiv edx, eax. ; edx , - ; push esi mov esi, edx push eax mov eax, ebx mov edx, 0 idiv esi ; eax () mov ebx, eax ; ebx = (a % pow_10(i)) / pow_10(i-1), "digit" C pop eax pop esi ; end division cmp ebx, 0 ; digit == 0 jne _print_answer_trailing_zeroes_check_end cmp byte [ebp-4], 0 ; started == 0 jne _print_answer_trailing_zeroes_check_end jmp _print_answer_loop_continue ; continue _print_answer_trailing_zeroes_check_end: mov byte [ebp-4], 1 ; started = 1 add ebx, 0x30 ; digit + '0' ; putc push eax push ecx push edx push ebx call _putc add esp, 4 pop edx pop ecx pop eax ; putc _print_answer_loop_continue: sub ecx, 1 jmp _print_answer_loop_start _print_answer_loop_end: pop esi pop edi pop ebx leave ret
Foi um teste difícil! Espero que os comentários ajudem a resolver o problema. Se você pensa agora: "Por que você não pode escrever printf("%d")
?", Então você vai gostar do final do artigo, onde substituímos a função por isso!Agora temos todas as funções necessárias, resta implementar a lógica básica _start
- e é tudo!Cálculo de notação reversa em polonês
Como já dissemos, a notação polonesa reversa é calculada usando a pilha. Ao ler, o número é empurrado para a pilha e, ao ler, o operador é aplicado a dois objetos na parte superior da pilha.Por exemplo, se quisermos calcular 84/3+6*
(esta expressão também pode ser escrita no formulário 6384/+*
), o processo é o seguinte:Etapa | Símbolo | Empilhar antes | Empilhar depois |
---|
1 | 8 | [] | [8] |
2 | 4 | [8] | [8, 4] |
3 | / | [8, 4] | [2] |
4 | 3 | [2] | [2, 3] |
5 | + | [2, 3] | [5] |
6 | 6 | [5] | [5, 6] |
7 | * | [5, 6] | [30] |
Se a entrada for uma expressão postfix válida, no final dos cálculos restará apenas um elemento na pilha - esta é a resposta, o resultado dos cálculos. No nosso caso, o número é 30.No assembler, você precisa implementar algo como este código em C: int stack[256];
Agora temos todas as funções necessárias para implementar isso, vamos começar. _start: ; _start , . ; esp argc ( ), ; esp+4 argv. , esp+4 ; , esp+8 - mov esi, [esp+8] ; esi = "input" = argv[0] ; _strlen push esi call _strlen mov ebx, eax ; ebx = input_length add esp, 4 ; end _strlen call mov ecx, 0 ; ecx = "i" _main_loop_start: cmp ecx, ebx ; (i >= input_length) jge _main_loop_end mov edx, 0 mov dl, [esi + ecx] ; ; edx. edx . ; edx = c = input[i] cmp edx, '0' jl _check_operator cmp edx, '9' jg _print_error sub edx, '0' mov eax, edx ; eax = c - '0' (, ) jmp _push_eax_and_continue _check_operator: ; _pop b edi, a b - eax push ecx push ebx call _pop mov edi, eax ; edi = b call _pop ; eax = a pop ebx pop ecx ; end call _pop cmp edx, '+' jne _subtract add eax, edi ; eax = a+b jmp _push_eax_and_continue _subtract: cmp edx, '-' jne _multiply sub eax, edi ; eax = ab jmp _push_eax_and_continue _multiply: cmp edx, '*' jne _divide imul eax, edi ; eax = a*b jmp _push_eax_and_continue _divide: cmp edx, '/' jne _print_error push edx ; edx, idiv mov edx, 0 idiv edi ; eax = a/b pop edx ; eax _push_eax_and_continue: ; _push push eax push ecx push edx push eax ; call _push add esp, 4 pop edx pop ecx pop eax ; call _push inc ecx jmp _main_loop_start _main_loop_end: cmp byte [stack_size], 1 ; (stack_size != 1), jne _print_error mov eax, [stack] push eax call _print_answer ; print a final newline push 0xA call _putc ; exit successfully mov eax, 0x01 ; 0x01 = exit() mov ebx, 0 ; 0 = int 0x80 ; _print_error: push error_msg call _print_msg mov eax, 0x01 mov ebx, 1 int 0x80
Você também precisará adicionar uma linha error_msg
à seção .rodata
: section .rodata ; error_msg. db NASM ; , ; . 0xA = , 0x0 = error_msg: db "Invalid input", 0xA, 0x0
E nós terminamos! Surpreenda todos os seus amigos se você os tiver. Espero que agora você reaja mais calorosamente às linguagens de alto nível, especialmente se você se lembrar de que muitos programas antigos foram escritos completa ou quase completamente em assembler, por exemplo, o RollerCoaster Tycoon original!Todo o código está aqui . Obrigado pela leitura! Eu posso continuar se você estiver interessado.Ações adicionais
Você pode praticar implementando várias funções adicionais:- Retorne uma mensagem de erro em vez de segfault se o programa não receber um argumento.
- Adicione suporte para espaços extras entre operandos e operadores na entrada.
- Adicione suporte para operandos de vários bits.
- Permitir números negativos.
- Substitua
_strlen
por uma função da biblioteca C padrão e _print_answer
substitua por uma chamada printf
.
Materiais adicionais