Lo que realmente puede comprender un computador es el lenguaje de bajo nivel, que está diseñado específicamente para controlar el hardware. El lenguaje ensamblador es un lenguaje de bajo nivel que describe y controla directamente la operación de la CPU. Si deseas entender qué hace exactamente la CPU y cómo se ejecutan los programas, es imprescindible estudiar lenguaje ensamblador.
El lenguaje ensamblador no es fácil de aprender, y encontrar una introducción clara y concisa resulta complicado. A continuación se presenta el tutorial más accesible sobre lenguaje ensamblador, explicando cómo la CPU ejecuta el código.
1. ¿Qué es el Lenguaje Ensamblador?
Como sabemos, la CPU únicamente realiza cálculos y no posee inteligencia por sí misma. Cuando se le proporciona una instrucción, la ejecuta una vez y luego se detiene, esperando la siguiente instrucción.
Estas instrucciones están en formato binario y se denominan códigos de operación (opcode). Por ejemplo, la instrucción de suma corresponde a 00000011. El compilador traduce los programas escritos en lenguajes de alto nivel a estos códigos de operación.
Para los seres humanos, los programas en formato binario son ilegibles; resulta imposible determinar qué operaciones realiza la máquina. Para resolver este problema de legibilidad, nació el lenguaje ensamblador.
El lenguaje ensamblador es la representación textual de las instrucciones binarias, manteniendo una correspondencia uno a uno. Por ejemplo, la instrucción binaria 00000011 se escribe en ensamblador como ADD. Una vez convertido de nuevo a binario, el lenguaje ensamblador puede ser ejecutado directamente por la CPU, por lo que constituye el lenguaje de nivel más bajo.
2. Origen
En los inicios de la programación, los programas se escribían manualmente en binario y se introducían en la computadora mediante interruptores. Posteriormente, se inventaron las perforationadoras de cinta de papel, que permitía introducir automáticamente las instrucciones binarias mediante perforaciones en la cinta.
Para resolver el problema de legibilidad de las instrucciones binarias, los ingenieros comenzaron a escribirlas en octal. La conversión de binario a octal era sencilla, pero el octal tampoco ofrecía buena legibilidad.
De manera natural, finalmente se optó por usar expresiones textuales: la instrucción de suma se escribía como ADD. Las direcciones de memoria ya no se referenciaban directamente, sino mediante etiquetas.
Esto introdujo un paso adicional: traducir estas instrucciones textuales a binario. Este proceso se denomina ensamblado (assembling), y el programa que lo realiza se llama ensamblador (assembler). El texto que procesa se denomina código ensamblador (assembly code). Una vez estandarizado, se conocería como lenguaje ensamblador (assembly language), abreviado como asm.
Cada tipo de CPU tiene su propio conjunto de instrucciones máquina, por lo que el lenguaje ensamblador correspondiente también difiere. Este artículo explica el lenguaje ensamblador x86, el más común en la actualidad, utilizado por las CPU de Intel.
3. Registros
Para aprender lenguaje ensamblador, es imprescindible comprender dos conceptos: registros y modelo de memoria.
Comenzemos con los registros. La CPU por sí sola solo realiza cálculos y no almacena datos. Los datos generalmente se almacenan en la memoria, y la CPU los lee y escribe cuando los necesita.
Sin embargo, la velocidad de cálculo de la CPU supera ampliamente la velocidad de lectura/escritura de la memoria. Para evitar verse ralentizada, la CPU incorpora caché de primer y segundo nivel. Básicamente, el caché de la CPU puede considerarse como una memoria de lectura/escritura más rápida.
Aun así, el caché de la CPU no es suficientemente rápido, y además las direcciones de los datos en el caché no son fijas. Cada operación de lectura o escritura de la CPU requiere寻址 (búsqueda de dirección), lo que también ralentiza el proceso.
Por lo tanto, además del caché, la CPU incorpora registros para almacenar los datos más utilizados. Es decir, aquellos datos que se leen y escriben con mayor frecuencia (como las variables de bucle) se mantienen en registros, y la CPU prioriza leer y escribir en ellos, intercambiando datos entre registros y memoria.
Los registros no identifican los datos mediante direcciones, sino mediante nombres. Cada registro tiene su propio nombre, y le indicamos a la CPU qué registro específico debe usar. Esta es la forma más rápida de acceder a datos. Algunos comparan los registros con el caché de nivel cero de la CPU.
4. Tipos de Registros
Las CPU x86 antiguas solo tenían 8 registros, cada uno con un propósito diferente. En la actualidad, existen más de 100 registros, y todos se han convertido en registros de uso general sin propósitos específicos asignados. Sin embargo, los nombres de los registros originales se han conservado:
- EAX
- EBX
- ECX
- EDX
- EDI
- ESI
- EBP
- ESP
De estos 8 registros, los siete primeros son de uso general. El registro ESP tiene un propósito específico: almacena la dirección actual de la Stack (pila).
Con frecuencia vemos referencias a CPU de 32 bits o 64 bits. Esto se refiere al tamaño de los registros: una CPU de 32 bits tiene registros de 4 bytes.
h3>5. Modelo de Memoria: Heap Los registros solo pueden almacenar pequeñas cantidades de datos. La mayor parte del tiempo, la CPU ordena a los registros intercambiar datos directamente con la memoria. Por lo tanto, además de los registros, debemos entender cómo almacena datos la memoria.
Cuando se ejecuta un programa, el sistema operativo le asigna una porción de memoria para almacenar el programa y los datos generados durante su ejecución. Esta memoria tiene una dirección inicial y una dirección final, por ejemplo, desde 0x1000 hasta 0x8000. La dirección inicial es la menor y la final la mayor.
Durante la ejecución del programa, para solicitudes dinámicas de memoria (como crear objetos o usar malloc
Por ejemplo, si el usuario solicita 10 bytes de memoria, se le asigna desde la dirección inicial 0x1000 hasta la dirección 0x100A. Si luego solicita 22 bytes adicionales, se asigna hasta 0x1020.
Esta área de memoria asignada debido a solicitudes activas del usuario se denomina Heap (montículo). Comienza desde la dirección inicial y crece desde direcciones bajas hacia altas. Una característica importante del Heap es que no desaparece automáticamente; debe liberarse manualmente o mediante un mecanismo de recolección de basura.
6. Modelo de Memoria: Stack
Además del Heap, las demás asignaciones de memoria se denominan Stack (pila). En términos simples, la Stack es un área de memoria ocupada temporalmente durante la ejecución de funciones.
Observemos el siguiente ejemplo:
int main() {
int x = 5;
int y = 8;
}
En el código anterior, cuando el sistema comienza a ejecutar la función main, crea un marco (frame) en la memoria. Todas las variables internas de main (como x e y) se almacenan en este marco. Cuando la función main termina de ejecutarse, el marco se recupera, liberando todas las variables internas y dejando de ocupar espacio.
¿Qué sucede si una función llama a otras funciones?
int main() {
int x = 5;
int y = 8;
return sumar(x, y);
}
En el código anterior, main llama a la función sumar. Al ejecutarse esta línea, el sistema también crea un nuevo marco para sumar para almacenar sus variables internas. Es decir, en ese momento coexisten dos marcos: main y sumar. Generalmente, cada nivel de la pila de llamadas corresponde a un marco.
Cuando sumar termina de ejecutarse, su marco se recupera y el sistema vuelve al punto donde se interrumpió la función main, continuando la ejecución. Este mecanismo permite el anidamiento de funciones, donde cada nivel puede utilizar sus propias variables locales.
Todos los marcos se almacenan en la Stack. Dado que los marcos se superponen unos sobre otros, se denomina "pila" (stack). Crear un nuevo marco se llama "empujar" (push), y recuperar la pila se llama "sacar" (pop). La característica de la Stack es que el último marco en entrar es el primero en salir (porque la función más interna termina de ejecutarse primero), lo que constituye una estructura de datos "Último en Entrar, Primero en Salir" (LIFO).
Cada vez que termina de ejecutarse una función, se libera automáticamente un marco. Cuando todas las funciones terminan de ejecutarse, toda la Stack se libera.
La Stack comienza desde la dirección final del área de memoria y se asigna desde direcciones altas hacia direcciones bajas. Por ejemplo, si la dirección final del área de memoria es 0x8000 y el primer marco ocupa 16 bytes, la siguiente dirección de asignación comenzará en 0x7FF0. Si el segundo marco necesita 64 bytes, la dirección se moverá a 0x7FB0.
7. Instrucciones de la CPU
7.1 Un Ejemplo
Una vez comprendidos los registros y el modelo de memoria, podemos ver qué es exactamente el lenguaje ensamblador. A continuación hay un programa simple ejemplo.c:
int sumar_valores(int a, int b) {
return a + b;
}
int main() {
return sumar_valores(5, 8);
}
gcc convierte este programa a lenguaje ensamblador:
$ gcc -S ejemplo.c
El comando anterior genera un archivo de texto ejemplo.s que contiene el lenguaje ensamblador, con decenas de instrucciones. Esto ilustra que una operación simple en un lenguaje de alto nivel puede requerir varias, o incluso decenas, de instrucciones de CPU. La CPU ejecuta estas instrucciones secuencialmente para completar la operación.
El archivo ejemplo.s, tras simplificación, tiene大致 el siguiente aspecto:
_sumar_valores:
push %ebx
mov %eax, [esp+8]
mov %ebx, [esp+12]
add %eax, %ebx
pop %ebx
ret
_main:
push 8
push 5
call _sumar_valores
add %esp, 8
ret
Como se puede observar, las dos funciones del programa original, sumar_valores y main, corresponden a dos etiquetas: _sumar_valores y _main. Dentro de cada etiqueta está el flujo de ejecución de la CPU convertido a partir de la función.
Cada línea representa una operación de la CPU. Se divide en dos partes. Tomemos una línea como ejemplo:
push %ebx
En esta línea, push es la instrucción de la CPU y %ebx es el operando utilizado por la instrucción. Una instrucción de CPU puede tener desde cero hasta varios operandos.
A continuación, explicaré línea por línea este programa ensamblador. Se recomienda al lector copiar el programa en otra ventana para facilitar la lectura.
7.2 Instrucciones push
Por convención, la ejecución del programa comienza en la etiqueta _main. En este momento, se crea un marco para main en la Stack, y la dirección que apunta la Stack se escribe en el registro ESP. Si hay datos que escribir en el marco main, se escribirán en la dirección almacenada en el registro ESP.
Luego comienza la ejecución de la primera línea de código:
push 8
La instrucción push se usa para colocar el operando en la Stack. En este caso, se escribe 8 en el marco main.
Aunque parece simple, la instrucción push en realidad tiene una operación previa. Primero obtiene la dirección almacenada en el registro ESP, le resta 4 bytes y luego escribe la nueva dirección en el registro ESP.
Se usa resta porque la Stack crece desde direcciones altas hacia direcciones bajas. Los 4 bytes se deben a que 8 es de tipo int y ocupa 4 bytes. Una vez obtenida la nueva dirección, 8 se escribe en los cuatro bytes comenzando desde esa dirección.
push 5
La segunda línea es similar: la instrucción push escribe 5 en el marco main, inmediatamente después del 8 escrito anteriormente. El registro ESP reste otros 4 bytes (totalizando 8 bytes restados).
7.3 Instrucciones call
La tercera línea, la instrucción call, se usa para llamar a funciones:
call _sumar_valores
El código anterior indica que se llama a la función sumar_valores. En este momento, el programa busca la etiqueta _sumar_valores y crea un nuevo marco para esa función.
A continuación comienza la ejecución del código de _sumar_valores:
push %ebx
Esta línea indica que el valor del registro EBX se escribe en el marco de _sumar_valores. Esto se debe a que más adelante se usará este registro, por lo que primero se extrae su valor para escribirlo y luego restaurarlo después de usarlo.
En este punto, la instrucción push también reste otros 4 bytes a la dirección en el registro ESP (totalizando 12 bytes restados).
7.4 Instrucciones mov
La instrucción mov se usa para escribir un valor en un registro:
mov %eax, [esp+8]
Esta línea de código indica que primero se suman 8 bytes a la dirección almacenada en el registro ESP, obteniendo una nueva dirección, y luego se extrae el dato de la Stack según esa dirección. Basándose en los pasos anteriores, se puede deducir que aquí se extrae 5, que se escribe en el registro EAX.
La siguiente línea de código hace lo mismo:
mov %ebx, [esp+12]
El código anterior suma 12 bytes al valor del registro ESP, y luego extrae el dato de la Stack según esa dirección. Esta vez extrae 8 y lo escribe en el registro EBX.
7.5 Instrucciones add
La instrucción add se usa para sumar dos operandos y escribir el resultado en el primer operando:
add %eax, %ebx
El código anterior suma el valor del registro EAX (es decir, 5) al valor del registro EBX (es decir, 8), obteniendo el resultado 13, y luego escribe este resultado en el registro EAX.
7.6 Instrucciones pop
La instrucción pop se usa para extraer el valor más recientemente escrito en la Stack (es decir, el valor en la dirección más baja) y escribir este valor en la posición especificada por el operando:
pop %ebx
El código anterior indica que se extrae el valor más recentemente escrito en la Stack (es decir, el valor original del registro EBX) y este valor se escribe de nuevo en el registro EBX (porque la suma ya se completó y el registro EBX ya no se necesita).
Nótese que la instrucción pop también suma 4 bytes a la dirección almacenada en el registro ESP, es decir, recupera 4 bytes.
7.7 Instrucciones ret
La instrucción ret se usa para terminar la ejecución de la función actual, devolviendo el control a la función llamadora. Es decir, el marco de la función actual será recuperado:
ret
Se puede observar que esta instrucción no tiene operandos.
Cuando la función sumar_valores termina de ejecutarse, el sistema vuelve al punto donde se interrumpió la función main, continuando la ejecución:
add %esp, 8
El código anterior indica que la dirección almacenada en el registro ESP se suma manualmente en 8 bytes y se vuelve a escribir en el registro ESP. Esto se debe a que el registro ESP contiene la dirección de inicio de escritura de la Stack. La operación pop anterior ya recuperó 4 bytes, y aquí se recuperan 8 bytes más, lo que equivale a recuperar todo.
ret
Finalmente, la función main termina de ejecutarse y la instrucción ret finaliza la ejecución del programa.