Atlasingeniería

Arquitectura y sistemas operativosArquitectura del procesadorTema 2

Lenguaje ensamblador

No para programar en él, sino para poder leerlo: entender qué generó el compilador es lo que permite explicar un perfilado, un core dump o una optimización que no ocurrió.

Para este tema conviene tener claro:Ciclo de instrucción y camino de datos

Nadie escribe aplicaciones en ensamblador, y sin embargo saber leerlo cambia cómo se depura. Es la única forma de ver qué hizo realmente el compilador con el código: qué eliminó, qué reordenó, qué guardó en un registro y qué fue a memoria.

Casi uno a uno con el código de máquina

El ensamblador es una correspondencia casi uno a uno con el código de máquina: cada línea es una instrucción, con un nombre legible en vez de bits. El ensamblador —el programa— traduce eso a binario y resuelve las etiquetas.

Es específico del repertorio de instrucciones: x86-64 y ARM son lenguajes distintos. Y hay dos sintaxis para x86, Intel y AT&T, que invierten el orden de los operandos, lo que explica mucha confusión al leer ejemplos de distintas fuentes.

Antes de seguir, predecí

Un bucle en C que suma un arreglo, compilado con optimizaciones. ¿Se parece al ensamblador que escribirías a mano?

Las pocas categorías de instrucciones

Las instrucciones caen en pocas categorías. Movimiento de datos entre registros y memoria; aritmética y lógica; comparaciones, que dejan banderas; saltos condicionales que leen esas banderas; y llamada y retorno de funciones.

El modelo de trabajo es: cargar de memoria a registros, operar en registros, guardar a memoria. Los registros son escasos —dieciséis de propósito general en x86-64— y esa escasez es lo que el compilador administra cuando asigna registros.

La línea a traducir es total = precio + envio. Los dos valores están en memoria y ningún registro tiene nada útil.

1 / 6
Mirá cuántas instrucciones hace falta para una línea de código. Y mirá dónde vive cada valor: todo lo que se opera pasa por un registro, sin excepción. «Cargar, operar, guardar» no es un consejo, es lo único que la máquina sabe hacer.

La pila, estructura central del tiempo de ejecución

La pila es la estructura central del tiempo de ejecución. Crece hacia direcciones bajas, y un registro dedicado apunta a su tope. Cada llamada empuja un marco: dirección de retorno, registros guardados, variables locales.

Ahí viven las variables locales que no entraron en registros, y ahí está la dirección de retorno que ret va a usar. Un desborde de buffer local que sobrescribe esa dirección es, textualmente, tomar el control del flujo: por eso existen los canarios de pila y la aleatorización de direcciones.

La convención de llamada, que es un acuerdo

Para que funciones compiladas por separado se entiendan hace falta un acuerdo: la convención de llamada. Define qué registros llevan los argumentos, dónde va el valor de retorno, y quién guarda cada registro.

Esa última parte divide los registros en dos: los que preserva quien llama y los que preserva la función llamada. Es lo que permite enlazar código de distintos lenguajes y compiladores, y es lo que hay que respetar para llamar a C desde otro lenguaje.

Leer la salida del compilador enseña más que escribirlo

Leer la salida del compilador enseña más que escribirlo. Con optimizaciones activadas aparecen cosas que sorprenden: ciclos desenrollados, funciones cortas insertadas en el llamador, cuentas resueltas en tiempo de compilación, variables que nunca tocan memoria.

También aparece lo que no hizo: una función que no pudo insertar por ser virtual, una lectura que no pudo eliminar porque la variable es volátil, un acceso a memoria repetido porque el compilador no pudo descartar que dos punteros apunten al mismo lugar. Esa última es la razón de ser de las palabras clave de aliasing.

Cuándo vale la pena mirarlo

Los casos donde vale la pena mirarlo son concretos: interpretar un perfilado que señala una función caliente, entender un volcado de memoria sin símbolos, verificar que una optimización esperada ocurrió, o trabajar con código sin fuente.

Escribirlo a mano queda para rincones muy chicos: rutinas criptográficas de tiempo constante, inicialización antes de que exista el runtime, instrucciones vectoriales que el compilador no aprovecha. En todos esos casos se escriben unas pocas líneas, no un programa.

Para qué sirve saber leerlo

SituaciónQué aporta el ensamblador
Un ciclo caliente que no rindever si el compilador vectorizó o no
Un comportamiento raro con optimizaciones activadasver qué eliminó el compilador
Depurar sin símboloses lo único que hay
Entender un aviso de seguridadlos desbordamientos se explican en la pila
Escribirlo a manocasi nunca: el compilador gana
La utilidad hoy es leerlo, no escribirlo. Mirar la salida del compilador es la forma más directa de saber qué hace realmente el código que uno escribió.

Cierre

Cargar, operar, guardar, con registros escasos y una pila que sostiene los marcos de llamada. La convención de llamada es lo que permite enlazar código ajeno, y leer la salida del compilador es la forma más directa de ver qué optimizó y qué no.

Autoevaluación

¿Lo entendiste?

¿Para qué sirve saber leer ensamblador si nadie escribe aplicaciones en él?
¿Cuál es el modelo de trabajo típico de las instrucciones?
Un mismo programa en x86-64 y en ARM, ¿tiene el mismo ensamblador?
¿Cómo se toma una decisión en ensamblador?