Al compilar un archivo de lenguaje C con la opción -v
podemos descubrir los detalles de las herramientas que son invocadas durante el proceso de construcción de un binario.
gcc -v hw.c
Algunas partes importantes:
El compilador se llama cc1
. El producto de esta etapa es un archivo de código assembly por cada archivo *.c:
/usr/lib/gcc/x86_64-linux-gnu/9/cc1 -quiet -v -imultiarch x86_64-linux-gnu hw.c -quiet -dumpbase hw.c -mtune=generic -march=x86-64 s
El ensamblador se llama as
. El producto de esta etapa es un object file por cada archivo de assembly:
as -v --64 -o /tmp/cceE5FNa.o /tmp/cclaDJ69.s
El programa collect2
se encarga del proceso de linking, i.e. combinar los object files en un archivo ejecutable:
/usr/lib/gcc/x86_64-linux-gnu/9/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/9/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-lo
Veamos partes de los productos intermedios y final del proceso de contrucción de binario.
(antes de compilar) El preprocesador
El preprocesador de C atiende los preprocesor directives (en C, las líneas que comienzan con #
). Esas directivas se pueden usar, entre otras cosas:
- para incluir partes de otros archivos en nuestro código fuente. Por ejemplo.
#include<stdio.h>
El preprocesador sustituye esa línea por el contenido completo del archivo stdio.h
.
- para definir substitución de texto o macros de preprocesador. Ejemplo:
#define CMXNPULGADA 2.54
#define mayor(a,b) a > b ? a : b
. . .
. . .
cm = CMXPULGADA * pulgadas;
x = mayor(p,q)
Preprocesador convierte en esto:
cm = 2.54 * pulgadas;
x = p > q ? p ; q;
- otras cosas que puedes leer en aquí
Compilación (producto = assembly code)
A continuación, parte del assembly generado para un Hello World. Nota el string "Hello World"
y más adelante las instrucciones que cargan la dirección de ese string al stack para luego invocar la subrutina printf@PLT
.
.LC0:
.string "Hello, World!"
...
...
leal .LC0@GOTOFF(%eax), %edx
pushl %edx
movl %eax, %ebx
call printf@PLT
Ensamblaje (producto = object file)
El ensamblador convierte las instrucciones de lenguaje de ensamblaje a lenguaje de máquina y genera un archivo binario (no ejecutable) donde organiza las instrucciones, datos y otras partes necesarias para que el archivo pueda ser combinado con otros y formar un ejecutable.
A continuación parte del object file que se creó para el programa hw.c
. Observa que en este punto, como no se sabe donde estará implementada la función printf
, solo se identifica su llamada con un número call 0x2a
. En esta etapa tampoco se sabe dónde quedarán los datos (como el string "Hello World") lea 0x0(%eax), %edx
.
...
20: 8d 90 00 00 00 00 lea 0x0(%eax),%edx <=(b)===
26: 52 push %edx
27: 89 c3 mov %eax,%ebx
29: e8 fc ff ff ff call 2a <main+0x2a> <=(a)===
2e: 83 c4 10 add $0x10,%esp
31: b8 00 00 00 00 mov $0x0,%eax
36: 8d 65 f8 lea -0x8(%ebp),%esp
...
Linker (producto = ejecutable)
....
80491b6: 8d 90 08 e0 ff ff lea -0x1ff8(%eax),%edx
80491bc: 52 push %edx
80491bd: 89 c3 mov %eax,%ebx
80491bf: e8 9c fe ff ff call 8049060 <printf@plt>
80491c4: 83 c4 10 add $0x10,%esp
80491c7: b8 00 00 00 00 mov $0x0,%eax
80491cc: 8d 65 f8 lea -0x8(%ebp),%esp
...
Cuando pides que se ejecute el binario, el programa loader del sistema operativo carga tu programa a memoria y lo prepara para ser ejecutado. Cosas como: (0) separar la memoria necesaria para el proceso (1) copiar las diferentes partes del ejecutable a la partes de la memoria que le serán asignadas al proceso, (1.5) resolver referencias que hace el programa a librerías externas (2) proveer los argumentos de línea de commando, (3) inicializar los registros en preparación para la ejecución (e.g. stack pointer y otros), (4) brincar al punto de entrada del ejecutable.
Para comenzar a enteder algunas de las cosas que hace el linker, fíjate en la abal de símbolos al final del archivo ejecutable de un programa Hello World.
...
51: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@@GLIBC_2.2.5
52: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@@GLIBC_
....
58: 0000000000004018 0 NOTYPE GLOBAL DEFAULT 26 _end
59: 0000000000001060 47 FUNC GLOBAL DEFAULT 16 _start
60: 0000000000004010 0 NOTYPE GLOBAL DEFAULT 26 __bss_start
61: 0000000000001149 32 FUNC GLOBAL DEFAULT 16 main
...
Observa como, entre otras, aparecen la función main
y la función printf@GLIBC_2.2.5
(aka printf
). La columna previa al nombre de estas dos funciones dice 16
para main
mientras que dice UND
para printf
:
- El
16
siginifica la sección donde se encuentra implementada esa función en el ejecutable. Si exploramos los bits de esa sección en el archivo ejecutable, encontramos el código de máquina correspondiente a la implementación demain
. - El
UND
que aparece en la columna deprintf
significa queprint
es una función que NO está implemetada dentro de este ejecutable, i.e. es una función externa que está implementada en una librería dinámica compartida.
Una de las labores que realiza el loader al cargar nuestro programa a memoría es resolver la dirección de printf
para que nuestro ejecutable sepa donde se encuentra la implementación de esa función en la librería compartida.