Guia do montador X86 para iniciantes

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*" # "(3+2)*6"    30 

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.

eaxebxecxedx
Número de chamada do sistemaarg1arg2arg3


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:

  1. 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.
  2. 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.
  3. Ligue para a sub-rotina.

Se possível, a função salvará o resultado em eax . Imediatamente após a call chamador deve:

  1. 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.
  2. 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:

  1. Salve o ponteiro do registro base ebp do quadro anterior, escrevendo-o na pilha.
  2. Ajuste o ebp do quadro anterior para o atual (valor atual do esp ).
  3. 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 .
  4. 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:

  1. Restaure os registros salvos, colocando-os na pilha na ordem inversa.
  2. Libere espaço na pilha alocada pela variável local na etapa 3, se necessário: simplesmente instalando esp no ebp
  3. Restaure o ponteiro base ebp do quadro anterior, retirando-o da pilha.
  4. 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) { //   length++; s++; } //   return length; } 

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:

EtapaSímboloEmpilhar antesEmpilhar depois
18[][8]
24[8][8, 4]
3/[8, 4][2]
43[2][2, 3]
5+[2, 3][5]
66[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]; // , 256      int stack_size = 0; int main(int argc, char *argv[]) { char *input = argv[0]; size_t input_length = strlen(input); for (int i = 0; i < input_length; i++) { char c = input[i]; if (c >= '0' && c <= '9') { //   —   push(c - '0'); //          } else { int b = pop(); int a = pop(); if (c == '+') { push(a+b); } else if (c == '-') { push(ab); } else if (c == '*') { push(a*b); } else if (c == '/') { push(a/b); } else { error("Invalid input\n"); exit(1); } } } if (stack_size != 1) { error("Invalid input\n"); exit(1); } print_answer(stack[0]); exit(0); } 

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:

  1. Retorne uma mensagem de erro em vez de segfault se o programa não receber um argumento.
  2. Adicione suporte para espaços extras entre operandos e operadores na entrada.
  3. Adicione suporte para operandos de vários bits.
  4. Permitir números negativos.
  5. Substitua _strlenpor uma função da biblioteca C padrão e _print_answersubstitua por uma chamada printf.

Materiais adicionais


Source: https://habr.com/ru/post/pt423077/


All Articles