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*"
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.
Eax | ebx | ecx | edx |
---|
Número de llamada del sistema | arg1 | arg2 | arg3 |
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:
- 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. - 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.
- 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:
- 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. - 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:
- Guarde el puntero de registro base
ebp
del fotograma anterior escribiéndolo en la pila. - Ajuste
ebp
del cuadro anterior a actual (valor esp
actual). - 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
. - 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:
- Restaura los registros guardados sacándolos de la pila en orden inverso.
- Libere espacio en la pila asignada por la variable local en el paso 3, si es necesario: simplemente instalando
esp
en ebp - Restaure el puntero base
ebp
del fotograma anterior ebp
de la pila. - 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) {
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:Paso | Símbolo | Apilar antes | Apilar después |
---|
1 | 8 | [] | [8] |
2 | 4 | [8] | [8, 4] |
3 | / | [8, 4] | [2] |
4 4 | 3 | [2] | [2, 3] |
5 5 | + | [2, 3] | [5] |
6 6 | 6 | [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];
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_msg
a 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:- Devuelve un mensaje de error en lugar de segfault si el programa no recibe un argumento.
- Agregue soporte para espacios adicionales entre operandos y operadores en la entrada.
- Agregue soporte para operandos de múltiples bits.
- Permitir números negativos.
- Reemplace
_strlen
con una función de la biblioteca C estándar y _print_answer
reemplace con una llamada printf
.
Materiales adicionales