Guía de ensamblador X86 para principiantes

Hoy en día, rara vez es necesario escribir en ensamblador puro, pero definitivamente lo recomiendo a cualquier persona interesada en la programación. Verá las cosas desde un ángulo diferente, y las habilidades serán útiles al depurar código en otros idiomas.

En este artículo, escribiremos desde cero la calculadora de notación polaca inversa (RPN) en ensamblador puro x86. Cuando hayamos terminado, podemos usarlo así:

$ ./calc "32+6*" # "(3+2)*6"    30 

Todo el código para el artículo está aquí . Está muy comentado y puede servir como material educativo para aquellos que ya conocen al ensamblador.

¡Comencemos escribiendo el programa básico Hello world! para verificar la configuración del entorno. Luego pasemos a las llamadas del sistema, la pila de llamadas, los marcos de la pila y la convención de llamadas x86. Luego, para practicar, escribiremos algunas funciones básicas en el ensamblador x86 y comenzaremos a escribir una calculadora RPN.

Se supone que el lector tiene cierta experiencia en programación en C y conocimientos básicos de arquitectura informática (por ejemplo, qué es un registro de procesador). Como usaremos Linux, también deberías poder usar la línea de comandos de Linux.

Entorno


Como ya se mencionó, usamos Linux (64 bits o 32 bits). El código anterior no funciona en Windows o Mac OS X.

Para la instalación, solo necesita el enlazador GNU ld de binutils , que está preinstalado en la mayoría de las distribuciones, y el ensamblador NASM. En Ubuntu y Debian, puede instalar ambos con un solo comando:

 $ sudo apt-get install binutils nasm 

También recomendaría tener a mano una tabla ASCII .

Hola mundo


Para verificar el entorno, guarde el siguiente código en el archivo 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 

Los comentarios explican la estructura general. Para obtener una lista de registros e instrucciones generales, consulte la Guía del ensamblador x86 de la Universidad de Virginia . Con más discusión sobre las llamadas al sistema, esto será aún más necesario.

Los siguientes comandos recopilan el archivo ensamblador en un archivo objeto y luego compilan el archivo ejecutable:

 $ nasm -f elf_i386 calc.asm -o calc $ ld -m elf_i386 calc.o -o calc 

Después de comenzar, debería ver:

 $ ./calc Hello world! 

Makefile


Esta es una parte opcional, pero puede hacer un Makefile para simplificar la compilación y el diseño en el futuro. calc.asm en el mismo directorio 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 

Luego, en lugar de las instrucciones anteriores, simplemente ejecute make.

Sistema de llamadas


Las llamadas al sistema Linux le dicen al sistema operativo que haga algo por nosotros. En este artículo, usamos solo dos llamadas al sistema: write() para escribir una línea en un archivo o secuencia (en nuestro caso, este es un dispositivo de salida estándar y un error estándar) y exit() para salir del 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 

Las llamadas al sistema se configuran almacenando el número de llamada del sistema en el registro eax , y luego sus argumentos en ebx , ecx , edx en ese orden. Puede notar que exit() solo exit() un argumento, en este caso ecx y edx no importan.

Eaxebxecxedx
Número de llamada del sistemaarg1arg2arg3


Pila de llamadas




Una pila de llamadas es una estructura de datos que almacena información sobre cada llamada a una función. Cada llamada tiene su propia sección en la pila: el "marco". Almacena cierta información sobre la llamada actual: las variables locales de esta función y la dirección de retorno (donde debe ir el programa después de ejecutar la función).

Inmediatamente noto una cosa no obvia: la pila pierde memoria. Cuando agrega algo a la parte superior de la pila, se inserta en una dirección de memoria inferior al elemento anterior. En otras palabras, a medida que la pila crece, la dirección de memoria en la parte superior de la pila disminuye. Para evitar confusiones, siempre te recordaré este hecho.

La instrucción push algo en la parte superior de la pila, y pop muestra los datos desde allí. Por ejemplo, push asigna un lugar en la parte superior de la pila y coloca el valor del registro eax allí, y pop transfiere cualquier dato desde la parte superior de la pila a eax y libera esta área de memoria.

El propósito del registro esp es apuntar a la parte superior de la pila. Se considera que cualquier dato por encima de esp no llega a la pila, estos son datos basura. La ejecución de una declaración push (o pop ) mueve esp . Puede manipular esp directamente, si presenta un informe de sus acciones.

El registro ebp es similar a esp , solo que siempre apunta aproximadamente al centro del marco de la pila actual, inmediatamente antes de las variables locales de la función actual (hablaremos de esto más adelante). Sin embargo, llamar a otra función no mueve ebp automáticamente, debe hacerse manualmente cada vez.

Convención de llamada de arquitectura X86


En x86, no hay un concepto incorporado de función como en los lenguajes de alto nivel. La goto call goto básicamente solo jmp ( goto ) a otra dirección de memoria. Para usar rutinas como funciones en otros lenguajes (que pueden tomar argumentos y devolver datos), debe seguir la convención de llamada (hay muchas convenciones, pero usamos CDECL, la convención más popular para x86 entre los compiladores C y los programadores de ensamblador). También asegura que los registros de rutina no se confundan al llamar a otra función.

Reglas de llamadas


Antes de llamar a la función, la persona que llama debe:

  1. Guarde los registros que la persona que llama debe guardar en la pila. La función llamada puede cambiar algunos registros: para no perder datos, la persona que llama debe guardarlos en la memoria hasta que se coloque en la pila. Estos son los edx eax , ecx y edx . Si no utiliza ninguno de ellos, no podrá guardarlos.
  2. Escriba argumentos de función en la pila en orden inverso (primer último argumento, primer primer argumento al final). Este orden asegura que la función llamada reciba sus argumentos de la pila en el orden correcto.
  3. Llama a la subrutina.

Si es posible, la función guardará el resultado en eax . Inmediatamente después de la call persona que llama debe:

  1. Eliminar argumentos de la función de la pila. Esto generalmente se hace simplemente agregando el número de bytes a esp . No olvide que la pila crece, por lo que para eliminarla debe agregar bytes.
  2. Restaure los registros guardados sacándolos de la pila en el orden inverso. La función llamada no cambiará ningún otro registro.

El siguiente ejemplo demuestra cómo se aplican estas reglas. Suponga que la función _subtract toma dos argumentos enteros (4 bytes) y devuelve el primer argumento menos el segundo. En la subrutina _mysubroutine llame a _subtract con los argumentos 10 y 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 ; ... 

Reglas de la rutina llamada


Antes de llamar, la subrutina debe:

  1. Guarde el puntero de registro base ebp del fotograma anterior escribiéndolo en la pila.
  2. Ajuste ebp del cuadro anterior a actual (valor esp actual).
  3. Asigne más espacio en la pila para las variables locales, si es necesario, mueva el puntero esp . A medida que la pila crece, debe restar la memoria faltante de esp .
  4. Guarde los registros de la rutina llamada en la pila. Estos son ebx , edi y esi . No es necesario guardar registros que no están planeados para ser cambiados.

Pila de llamadas después del paso 1:



La pila de llamadas después del paso 2:



Pila de llamadas después del paso 4:



En estos diagramas, se indica una dirección de retorno en cada marco de pila. Se envía automáticamente a la pila mediante una declaración de call . La ret recupera la dirección desde la parte superior de la pila y salta a ella. No necesitamos esta instrucción, solo mostré por qué las variables locales de la función están a 4 bytes por encima de ebp , pero los argumentos de la función están a 8 bytes por debajo de ebp .

En el último diagrama, también puede observar que las variables locales de la función siempre comienzan 4 bytes por encima de ebp desde la dirección ebp-4 (resta aquí, porque estamos subiendo la pila), y los argumentos de la función siempre comienzan 8 bytes por debajo de ebp desde la dirección ebp+8 (además, porque nos estamos moviendo hacia abajo en la pila). Si sigue las reglas de esta convención, será así con las variables y argumentos de cualquier función.

Cuando la función está completa y desea regresar, primero debe establecer eax en el valor de retorno de la función, si es necesario. Además, necesitas:

  1. Restaura los registros guardados sacándolos de la pila en orden inverso.
  2. Libere espacio en la pila asignada por la variable local en el paso 3, si es necesario: simplemente instalando esp en ebp
  3. Restaure el puntero base ebp del fotograma anterior ebp de la pila.
  4. Regresar con ret

Ahora implementamos la función _subtract de nuestro ejemplo:

 _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 y salida


En el ejemplo anterior, puede observar que la función siempre se ejecuta de la misma manera: push ebp , mov ebp , esp y asignación de memoria para variables locales. El conjunto x86 tiene una instrucción conveniente que hace todo esto: enter ab , donde a es el número de bytes que desea asignar para las variables locales, b es el "nivel de anidación", que siempre estableceremos en 0 . Además, la función siempre termina con las instrucciones pop ebp y mov esp , ebp (aunque solo son necesarias cuando se ebp memoria para variables locales, pero en cualquier caso no dañan). Esto también se puede reemplazar con una sola declaración: leave . Hacemos cambios:

 _subtract: enter 0, 0 ;        ebp ;       ,     ;   ;    mov eax, [ebp+8] ;      eax.  ;       ebp+8 sub eax, [ebp+12] ;      ebp+12  ;   ;   , eax     ;     ,     leave ;      ret 

Escribir algunas funciones básicas


Una vez que domine la convención de llamadas, puede comenzar a escribir algunas rutinas. ¿Por qué no generalizar el código que muestra "Hola mundo!" Para generar líneas: la función _print_msg .

Aquí necesitamos otra función _strlen para contar la longitud de la cadena. En C, podría verse así:

 size_t strlen(char *s) { size_t length = 0; while (*s != 0) { //   length++; s++; } //   return length; } 

En otras palabras, desde el comienzo de la línea, agregamos 1 al valor de retorno para cada carácter excepto cero. Tan pronto como se observe el carácter nulo, devolveremos el valor acumulado en el bucle. En el ensamblador, esto también es bastante simple: puede usar la función _subtract previamente escrita 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 

Ya no está mal, ¿verdad? Escribir código C primero puede ayudar, porque la mayor parte se convierte directamente en ensamblador. Ahora puede usar esta función en _print_msg , donde aplicamos todos los conocimientos adquiridos:

 _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 

Y vea los frutos de nuestro arduo trabajo, utilizando esta función en el programa completo "¡Hola, mundo!".

 _start: enter 0, 0 ;     (    ) push hello_world ;    _print_msg call _print_msg mov eax, 0x01 ; 0x01 = exit() mov ebx, 0 ; 0 =   int 0x80 

¡Lo creas o no, hemos cubierto todos los temas principales necesarios para escribir programas básicos de ensamblador x86! Ahora tenemos todo el material introductorio y la teoría, por lo que nos concentraremos completamente en el código y aplicaremos los conocimientos adquiridos para escribir nuestra calculadora RPN. Las funciones serán mucho más largas e incluso utilizarán algunas variables locales. Si desea ver de inmediato el programa terminado, aquí está .

Para aquellos de ustedes que no están familiarizados con la notación polaca inversa (a veces llamada notación polaca inversa o notación postfix), aquí las expresiones se evalúan utilizando la pila. Por lo tanto, debe crear una pila, así como las _push _pop y _push para manipular esta pila. También _print_answer función _print_answer , que generará una representación de cadena del resultado numérico al final del cálculo.

Creación de pila


Primero, definimos el espacio en memoria para nuestra pila, así como la variable global stack_size . Es aconsejable cambiar estas variables para que no entren en la sección .rodata , sino en .data .

 section .data stack_size: dd 0 ;   dword (4 )   0 stack: times 256 dd 0 ;    

Ahora puede implementar las _pop _push y _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 

Salida de número


_print_answer mucho más complicado: tienes que convertir números en cadenas y usar varias otras funciones. _putc función _putc , que genera un carácter, la función mod para calcular el resto de la división (módulo) de los dos argumentos y _pow_10 para elevar a la potencia de 10. Más adelante comprenderá por qué son necesarios. Esto es bastante simple, aquí está el 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 

Entonces, ¿cómo derivamos números individuales en un número? Primero, tenga en cuenta que el último dígito del número es el resto de la división por 10 (por ejemplo, 123 % 10 = 3 ), y el siguiente dígito es el resto de la división por 100, dividido por 10 (por ejemplo, (123 % 100)/10 = 2 ). En general, puede encontrar un dígito específico de un número (de derecha a izquierda) al encontrar ( % 10**n) / 10**(n-1) , donde el número de unidades será n = 1 , el número de decenas es n = 2 y así sucesivamente.

Con este conocimiento, puede encontrar todos los dígitos de un número desde n = 1 hasta n = 10 (este es el número máximo de bits en un entero de 4 bytes con signo). Pero es mucho más fácil ir de izquierda a derecha, por lo que podemos imprimir cada carácter tan pronto como lo encontremos y eliminar los ceros en el lado izquierdo. Por lo tanto, clasificamos los números de n = 10 a n = 1 .

En C, el programa se verá así:

 #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'); } } 

Ahora entiendes por qué necesitamos estas tres funciones. Implementemos esto en ensamblador:

 %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 

¡Fue una prueba difícil! Espero que los comentarios ayuden a resolverlo. Si ahora piensa: "¿Por qué no puede simplemente escribir printf("%d")?", Entonces le gustará el final del artículo, donde reemplazamos la función con eso.

Ahora que tenemos todas las funciones necesarias, queda por implementar la lógica básica _start, ¡y eso es todo!

Cálculo de notación polaca inversa


Como ya dijimos, la notación polaca inversa se calcula utilizando la pila. Al leer, el número se inserta en la pila, y al leer, el operador se aplica a dos objetos en la parte superior de la pila.

Por ejemplo, si queremos calcular 84/3+6*(esta expresión también se puede escribir en el formulario 6384/+*), el proceso es el siguiente:

PasoSímboloApilar antesApilar después
18[][8]
24[8][8, 4]
3/[8, 4][2]
4 43[2][2, 3]
5 5+[2, 3][5]
6 66[5][5, 6]
7 7*[5, 6][30]

Si la entrada es una expresión de postfix válida, al final de los cálculos solo queda un elemento en la pila: esta es la respuesta, el resultado de los cálculos. En nuestro caso, el número es 30.

En ensamblador, debe implementar algo como este código en 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); } 

Ahora que tenemos todas las funciones necesarias para implementar esto, comencemos.

 _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 

También deberá agregar una línea error_msga la sección .rodata:

 section .rodata ;     error_msg.  db  NASM ;    ,     ; . 0xA =  , 0x0 =    error_msg: db "Invalid input", 0xA, 0x0 

Y hemos terminado! Sorprende a todos tus amigos si los tienes. Espero que ahora reaccione más cálidamente a los lenguajes de alto nivel, especialmente si recuerda que muchos programas antiguos se escribieron completa o casi completamente en ensamblador, por ejemplo, el RollerCoaster Tycoon original.

Todo el código está aquí . Gracias por leer! Puedo continuar si estás interesado.

Otras acciones


Puede practicar implementando varias funciones adicionales:

  1. Devuelve un mensaje de error en lugar de segfault si el programa no recibe un argumento.
  2. Agregue soporte para espacios adicionales entre operandos y operadores en la entrada.
  3. Agregue soporte para operandos de múltiples bits.
  4. Permitir números negativos.
  5. Reemplace _strlencon una función de la biblioteca C estándar y _print_answerreemplace con una llamada printf.

Materiales adicionales


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


All Articles