Entendiendo C aprendiendo ensamblado

La última vez, Alan mostró cómo usar GDB como herramienta para aprender C. Hoy quiero ir un paso más allá y usar GDB para ayudarnos a entender el ensamblado también.

Las capas de abstracción son excelentes herramientas para construir cosas, pero a veces pueden interferir en el aprendizaje. Mi objetivo en este post es convencerte de que para entender rigurosamente C, también debemos entender el ensamblado que genera nuestro compilador de C. Voy a hacer esto mostrándole cómo desmontar y leer un programa simple con GDB, y luego usaremos GDB y nuestro conocimiento de ensamblado para comprender cómo funcionan las variables locales estáticas en C.

Nota: Todo el código de esta publicación se compiló en una CPU x86_64 que ejecuta Mac OS X 10.8.1 usando Clang 4.0 con optimizaciones deshabilitadas (-O0).

Aprendizaje de ensamblado con GDB

Comencemos por desmontar un programa con GDB y aprender a leer la salida. Escriba el siguiente programa en un archivo de texto y guárdelo como simple.c:

int main(){ int a = 5; int b = a + 6; return 0;}

Ahora compílelo con símbolos de depuración y sin optimizaciones y luego ejecute GDB:1

$ CFLAGS="-g -O0" make simplecc -g -O0 simple.c -o simple$ gdb simple

Dentro de GDB, vamos a romper en main y ejecutar hasta que lleguemos a la declaración de retorno. Ponemos el número 2 después de next para especificar que queremos ejecutar next dos veces:

(gdb) break main(gdb) run(gdb) next 2

Ahora usemos el comando disassemble para mostrar las instrucciones de ensamblaje de la función actual. También puede pasar un nombre de función a disassemble para especificar una función diferente a examinar.

(gdb) disassembleDump of assembler code for function main:0x0000000100000f50 <main+0>: push %rbp0x0000000100000f51 <main+1>: mov %rsp,%rbp0x0000000100000f54 <main+4>: mov x0,%eax0x0000000100000f59 <main+9>: movl x0,-0x4(%rbp)0x0000000100000f60 <main+16>: movl x5,-0x8(%rbp)0x0000000100000f67 <main+23>: mov -0x8(%rbp),%ecx0x0000000100000f6a <main+26>: add x6,%ecx0x0000000100000f70 <main+32>: mov %ecx,-0xc(%rbp)0x0000000100000f73 <main+35>: pop %rbp0x0000000100000f74 <main+36>: retq End of assembler dump.

El comando disassemblemuestra por defecto instrucciones en la sintaxis AT& T, que es la misma sintaxis utilizada por el ensamblador de GNU.2 Las instrucciones de la sintaxis AT&T tienen el formato mnemonic source, destination. El mnemotécnico es un nombre legible por humanos para la instrucción. Origen y destino son operandos y pueden ser valores inmediatos, registros, direcciones de memoria o etiquetas. Los valores inmediatos son constantes, con el prefijo a $. Por ejemplo, x5 representa el número 5 en hexadecimal. Los nombres de registro llevan el prefijo a %.

Registros

Vale la pena tomar un desvío rápido para comprender los registros. Los registros son ubicaciones de almacenamiento de datos directamente en la CPU. Con algunas excepciones, el tamaño o el ancho de los registros de una CPU definen su arquitectura. Por lo tanto, si tiene una CPU de 64 bits, sus registros tendrán un ancho de 64 bits. Lo mismo ocurre con las CPU de 32 bits (registros de 32 bits), las CPU de 16 bits, etc.3 Registros son de acceso muy rápido y a menudo son los operandos para operaciones aritméticas y lógicas.

La familia x86 tiene una serie de registros de propósito general y especial. Los registros de propósito general se pueden usar para cualquier operación y su valor no tiene un significado particular para la CPU. Por otro lado, la CPU se basa en registros de propósito especial para su propio funcionamiento y los valores almacenados en ellos tienen un significado específico dependiendo del registro. En nuestro ejemplo anterior, %eax y %ecx son registros de propósito general, mientras que %rbp y %rsp son registros de propósito especial. %rbp es el puntero base, que apunta a la base del marco de pila actual, y %rsp es el puntero de pila, que apunta a la parte superior del marco de pila actual. %rbp siempre tiene un valor más alto que %rsp porque la pila comienza en una dirección de memoria alta y crece hacia abajo. Si no está familiarizado con la pila de llamadas, puede encontrar una buena introducción en Wikipedia.

Una peculiaridad de la familia x86 es que ha mantenido la compatibilidad con versiones anteriores hasta el procesador 8086 de 16 bits. A medida que x86 se movía de 16 bits a 32 bits a 64 bits, los registros se expandieron y se les dieron nuevos nombres para no romper la compatibilidad con el código escrito para CPU más antiguas y estrechas.

Tome el hacha de registro de uso general, que tiene 16 bits de ancho. Se puede acceder al byte alto con el nombre AH, y al byte bajo con el nombre AL. Cuando salió el 80386 de 32 bits, el registro AX Extendido, o EAX, se refería al registro de 32 bits, mientras que AX continuó refiriéndose a un registro de 16 bits que constituía la mitad inferior de EAX. De manera similar, cuando salió la arquitectura x86_64, se usó el prefijo «R» y EAX compuso la mitad inferior del registro RAX de 64 bits. He incluido un diagrama a continuación basado en un artículo de Wikipedia para ayudar a visualizar las relaciones que describí:

|__64__|__56__|__48__|__40__|__32__|__24__|__16__|__8___||__________________________RAX__________________________||xxxxxxxxxxxxxxxxxxxxxxxxxxx|____________EAX____________||xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|_____AX______||xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|__AH__|__AL__|

Volver al código

Esto debería ser suficiente información para comenzar a leer nuestro programa desmontado.

0x0000000100000f50 <main+0>: push %rbp0x0000000100000f51 <main+1>: mov %rsp,%rbp

Las dos primeras instrucciones se denominan prólogo o preámbulo de la función. Primero empujamos el puntero base viejo a la pila para guardarlo para más tarde. Luego copiamos el valor del puntero de pila al puntero base. Después de esto, %rbp apunta a la base del marco de pila de main.

0x0000000100000f54 <main+4>: mov x0,%eax

Esta instrucción copia del 0 al %eax. La convención de llamada a x86 dicta que el valor de retorno de una función se almacena en %eax, por lo que la instrucción anterior nos establece para devolver 0 al final de nuestra función.

0x0000000100000f59 <main+9>: movl x0,-0x4(%rbp)

Aquí tenemos algo que no hemos encontrado antes: -0x4(%rbp). Los paréntesis nos permiten saber que se trata de una dirección de memoria. Aquí, %rbp se llama el registro base, y -0x4 es el desplazamiento. Esto equivale a %rbp + -0x4. Debido a que la pila crece hacia abajo, restar 4 de la base del marco de pila actual nos mueve al marco actual en sí, donde se almacenan las variables locales. Esto significa que esta instrucción almacena 0 en %rbp - 4. Me llevó un tiempo averiguar para qué era esta línea, pero parece que clang asigna una variable local oculta para un valor de retorno implícito de main.

También notarás que la mnemotécnica tiene el sufijo l. Esto significa que los operandos serán l ong (32 bits para enteros). Otros sufijos válidos son byte, short, word, quad y ten. Si ve una instrucción que no tiene un sufijo, el tamaño de los operandos se infiere del tamaño del registro de origen o destino. Por ejemplo, en la línea anterior, %eax tiene 32 bits de ancho, por lo que se infiere que la instrucción mov es movl.

0x0000000100000f60 <main+16>: movl x5,-0x8(%rbp)

Ahora estamos entrando en la carne de nuestro programa de ejemplo! La primera línea de ensamblaje es la primera línea de C en main y almacena el número 5 en la siguiente ranura de variable local disponible (%rbp - 0x8), 4 bytes por debajo de nuestra última variable local. Esa es la ubicación de a. Podemos usar GDB para verificar esto:

(gdb) x &a0x7fff5fbff768: 0x00000005(gdb) x $rbp - 80x7fff5fbff768: 0x00000005

Tenga en cuenta que las direcciones de memoria son las mismas. Notará que GDB configura variables para nuestros registros, pero como todas las variables en GDB, lo anteponemos con un $ en lugar del % utilizado en el ensamblaje AT&T.

0x0000000100000f67 <main+23>: mov -0x8(%rbp),%ecx0x0000000100000f6a <main+26>: add x6,%ecx0x0000000100000f70 <main+32>: mov %ecx,-0xc(%rbp)

Luego movemos a a %ecx, uno de nuestros registros de propósito general, agregamos 6 y almacenamos el resultado en %rbp - 0xc. Esta es la segunda línea de C en main. Tal vez te hayas dado cuenta de que %rbp - 0xc es b, lo que podemos verificar en GDB:

(gdb) x &b0x7fff5fbff764: 0x0000000b(gdb) x $rbp - 0xc0x7fff5fbff764: 0x0000000b

El resto de main es solo limpieza, llamada la función epílogo:

0x0000000100000f73 <main+35>: pop %rbp0x0000000100000f74 <main+36>: retq 

pop el puntero base antiguo sale de la pila y lo almacena de nuevo en %rbp y luego retq salta de nuevo a nuestra dirección de retorno, que también se almacena en el marco de la pila.

Hasta ahora hemos usado GDB para desensamblar un programa corto en C, hemos repasado cómo leer la sintaxis de ensamblado EN&T, y hemos cubierto los registros y los operandos de direcciones de memoria. También hemos utilizado GDB para verificar dónde se almacenan nuestras variables locales en relación con %rbp. Ahora vamos a usar nuestras habilidades recién adquiridas para explicar cómo funcionan las variables locales estáticas.

Comprender las variables locales estáticas

Las variables locales estáticas son una característica muy interesante de C. En pocas palabras, son variables locales que solo se inicializan una vez y persisten sus valores en varias llamadas a la función donde se definen. Un caso de uso sencillo para variables locales estáticas es un generador de estilo Python. Aquí hay uno que genera todos los números naturales hasta INT_MAX:

/* static.c */#include <stdio.h>int natural_generator(){ int a = 1; static int b = -1; b += 1; return a + b;}int main(){ printf("%d\n", natural_generator()); printf("%d\n", natural_generator()); printf("%d\n", natural_generator()); return 0;}

Cuando se compila y ejecuta, este programa imprime los tres primeros números naturales:

$ CFLAGS="-g -O0" make staticcc -g -O0 static.c -o static$ ./static123

Pero, ¿cómo funciona esto? Para entender a los locales estáticos, vamos a saltar al BGF y ver la asamblea. He eliminado la información de dirección que GDB agrega al desmontaje para que todo quepa en la pantalla:

$ gdb static(gdb) break natural_generator(gdb) run(gdb) disassembleDump of assembler code for function natural_generator:push %rbpmov %rsp,%rbpmovl x1,-0x4(%rbp)mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>add x1,%eaxmov %eax,0x16c(%rip) # 0x100001018 <natural_generator.b>mov -0x4(%rbp),%eaxadd 0x163(%rip),%eax # 0x100001018 <natural_generator.b>pop %rbpretq End of assembler dump.

Lo primero que tenemos que hacer es averiguar en qué instrucción estamos. Podemos hacerlo examinando el puntero de instrucciones o el contador de programas. El puntero de instrucción es un registro que almacena la dirección de memoria de la siguiente instrucción. En x86_64, ese registro es %rip. Podemos acceder al puntero de instrucciones usando la variable $rip, o alternativamente podemos usar la arquitectura independiente $pc:

(gdb) x/i $pc0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)

El puntero de instrucción siempre contiene la dirección de la siguiente instrucción a ejecutar, lo que significa que la tercera instrucción aún no se ha ejecutado, pero está a punto de ejecutarse.

Porque sabiendo que la siguiente instrucción es útil, vamos a hacer que GDB nos muestre la siguiente instrucción cada vez que el programa se detenga. En GDB 7.0 o posterior, puede ejecutar set disassemble-next-line on, que muestra todas las instrucciones que componen la siguiente línea de código fuente, pero estamos utilizando Mac OS X, que solo se incluye con GDB 6.3, por lo que tendremos que recurrir al comando display. display es como x, excepto que evalúa su expresión cada vez que nuestro programa se detiene:

(gdb) display/i $pc1: x/i $pc 0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)

Ahora GDB está configurado para mostrarnos siempre la siguiente instrucción antes de mostrar su mensaje.

Ya hemos pasado el prólogo de la función, que cubrimos anteriormente, por lo que comenzaremos justo en la tercera instrucción. Esto corresponde a la primera línea fuente que asigna 1 a a. En lugar de next, que se mueve a la siguiente línea de origen, usaremos nexti, que se mueve a la siguiente instrucción de ensamblaje. Después examinaremos %rbp - 0x4 para verificar nuestra hipótesis de que a se almacena en %rbp - 0x4.

(gdb) nexti7 b += 1;1: x/i $pc mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>(gdb) x $rbp - 0x40x7fff5fbff78c: 0x00000001(gdb) x &a0x7fff5fbff78c: 0x00000001

Son los mismos, tal y como esperábamos. La siguiente instrucción es más interesante:

mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>

Aquí es donde esperaríamos encontrar la línea static int b = -1;, pero se ve sustancialmente diferente a cualquier cosa que hayamos visto antes. Por un lado, no hay referencia al marco de pila donde normalmente esperaríamos encontrar variables locales. ¡Ni siquiera hay un -0x1! En su lugar, tenemos una instrucción que carga 0x100001018, ubicada en algún lugar después del puntero de instrucción, en %eax. GDB nos da un comentario útil con el resultado del cálculo del operando de memoria y una pista que nos dice que natural_generator.b se almacena en esta dirección. Ejecutemos esta instrucción y averigüemos qué está pasando:

(gdb) nexti(gdb) p $rax = 4294967295(gdb) p/x $rax = 0xffffffff

A pesar de que el desmontaje muestra %eax como destino, imprimimos $rax, porque GDB solo configura variables para registros de ancho completo.

En esta situación, necesitamos recordar que mientras que las variables tienen tipos que especifican si están firmadas o sin firmar, los registros no lo hacen, por lo que GDB está imprimiendo el valor de %rax sin firmar. Vamos a intentarlo de nuevo, lanzando %rax a una firma int:

(gdb) p (int)$rax = -1

Parece que hemos encontrado b. Podemos verificar esto usando el comando x :

(gdb) x/d 0x1000010180x100001018 <natural_generator.b>: -1(gdb) x/d &b0x100001018 <natural_generator.b>: -1

Así que no solo se almacena b en una dirección de memoria baja fuera de la pila, sino que también se inicializa a -1 antes de que se llame a natural_generator. De hecho, incluso si desmontara todo el programa, no encontraría ningún código que establezca b en -1. Esto se debe a que el valor de b está codificado en una sección diferente del ejecutable sample, y el cargador del sistema operativo lo carga en la memoria junto con todo el código de máquina cuando se inicia el proceso.4

Con esto fuera del camino, las cosas empiezan a tener más sentido. Después de almacenar b en %eax, pasamos a la siguiente línea de origen donde incrementamos b. Esto corresponde a las dos instrucciones siguientes:

add x1,%eaxmov %eax,0x16c(%rip) # 0x100001018 <natural_generator.b>

Aquí agregamos 1 a %eax y almacenamos el resultado de nuevo en la memoria. Vamos a ejecutar estas instrucciones y comprobar el resultado:

(gdb) nexti 2(gdb) x/d &b0x100001018 <natural_generator.b>: 0(gdb) p (int)$rax = 0

Las siguientes dos instrucciones que nos regresa a + b:

mov -0x4(%rbp),%eaxadd 0x163(%rip),%eax # 0x100001018 <natural_generator.b>

Aquí se carga a en %eax y, a continuación, agregue b. En este punto, esperaríamos que %eax fuera 1. Vamos a verificar:

(gdb) nexti 2(gdb) p $rax = 1

%eax se utiliza para almacenar el valor de retorno de natural_generator, por lo que todos estamos configurados para el epílogo que limpia la pila y devuelve:

pop %rbpretq 

Ahora entendemos cómo se inicializa b, veamos qué sucede cuando ejecutamos natural_generator de nuevo:

(gdb) continueContinuing.1Breakpoint 1, natural_generator () at static.c:55 int a = 1;1: x/i $pc 0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)(gdb) x &b0x100001018 <natural_generator.b>: 0

Dado que b no se almacena en la pila con otras variables locales, sigue siendo cero cuando se vuelve a llamar a natural_generator. No importa cuántas veces se llame a nuestro generador, b siempre conservará su valor anterior. Esto se debe a que se almacena fuera de la pila y se inicializa cuando el cargador mueve el programa a la memoria, en lugar de hacerlo con cualquiera de nuestros códigos de máquina.

Conclusión

Comenzamos repasando cómo leer ensamblado y cómo desmontar un programa con GDB. Luego, cubrimos cómo funcionan las variables locales estáticas, lo que no podríamos haber hecho sin desmontar nuestro ejecutable.

Pasamos mucho tiempo alternando entre leer las instrucciones de montaje y verificar nuestras hipótesis en GDB. Puede parecer repetitivo, pero hay una razón muy importante para hacer las cosas de esta manera: la mejor manera de aprender algo abstracto es hacerlo más concreto, y una de las mejores maneras de hacer algo más concreto es usar herramientas que te permitan quitar capas de abstracción. La mejor manera de aprender estas herramientas es forzarte a usarlas hasta que sean naturales.

  1. Notarán que estamos usando Make para construir simple.c ‘ sin makefile. Podemos hacer esto porque Make tiene reglas implícitas para construir ejecutables a partir de archivos C. Puede encontrar más información sobre estas reglas en (http://www.gnu.org/software/make/manual/make.html#Implicit-Rules). ↩

  2. También puede tener sintaxis de Intel de salida GDB, que es utilizada por NASM, MASM y otros ensambladores, pero eso está fuera del alcance de esta publicación. ↩

  3. Los procesadores con conjuntos de instrucciones SIMD como MMX y SSE para x86 y AltiVec para PowerPC a menudo contendrán algunos registros que son más anchos que la arquitectura de la CPU.↩

  4. Es mejor guardar una discusión de formatos de objetos, cargadores y enlazadores para una publicación de blog futura.↩

Deja una respuesta

Tu dirección de correo electrónico no será publicada.