Usted escribe un programa en C, usa gcc para compilarlo y obtiene un ejecutable. Es bastante simple. ¿Verdad?
¿Alguna vez te has preguntado qué sucede durante el proceso de compilación y cómo se convierte el programa C en un ejecutable?
Hay cuatro etapas principales por las que pasa un código fuente para convertirse finalmente en un ejecutable.
Las cuatro etapas para que un programa en C se convierta en un ejecutable son las siguientes:
- Preprocesamiento
- Compilación
- Montaje
- Enlace
En la Parte I de esta serie de artículos, analizaremos los pasos que sigue el compilador gcc cuando el código fuente de un programa C se compila en un ejecutable.
Antes de continuar, echemos un vistazo rápido a cómo compilar y ejecutar un código 'C' usando gcc, usando un ejemplo simple de hola mundo.
$ vi print.c #include <stdio.h> #define STRING "Hello World" int main(void) { /* Using a macro to print 'Hello World'*/ printf(STRING); return 0; }
Ahora, ejecutemos el compilador gcc sobre este código fuente para crear el ejecutable.
$ gcc -Wall print.c -o print
En el comando anterior:
- gcc:invoca el compilador GNU C
- -Wall:bandera gcc que habilita todas las advertencias. -W significa advertencia, y estamos pasando "todos" a -W.
- print.c:programa de entrada C
- -o print:indica al compilador de C que cree el ejecutable de C como print. Si no especifica -o, por defecto el compilador de C creará el ejecutable con el nombre a.out
Finalmente, ejecute print que ejecutará el programa C y mostrará hola mundo.
$ ./print Hello World
Nota :Cuando esté trabajando en un gran proyecto que contenga varios programas en C, use la utilidad make para administrar la compilación de su programa en C, como comentamos anteriormente.
Ahora que tenemos una idea básica sobre cómo se usa gcc para convertir un código fuente en binario, revisaremos las 4 etapas por las que debe pasar un programa C para convertirse en un ejecutable.
1. PREPROCESAMIENTO
Esta es la primera etapa por la que pasa un código fuente. En esta etapa se realizan las siguientes tareas:
- Sustitución de macros
- Los comentarios se eliminan
- Expansión de los archivos incluidos
Para comprender mejor el preprocesamiento, puede compilar el programa 'print.c' anterior usando flag -E, que imprimirá la salida preprocesada en stdout.
$ gcc -Wall -E print.c
Aún mejor, puede usar la marca '-save-temps' como se muestra a continuación. El indicador '-save-temps' indica al compilador que almacene los archivos intermedios temporales utilizados por el compilador gcc en el directorio actual.
$ gcc -Wall -save-temps print.c -o print
Entonces, cuando compilamos el programa print.c con el indicador -save-temps, obtenemos los siguientes archivos intermedios en el directorio actual (junto con el ejecutable de impresión)
$ ls print.i print.s print.o
La salida preprocesada se almacena en el archivo temporal que tiene la extensión .i (es decir, 'print.i' en este ejemplo)
Ahora, abramos el archivo print.i y veamos el contenido.
$ vi print.i ...... ...... ...... ...... # 846 "/usr/include/stdio.h" 3 4 extern FILE *popen (__const char *__command, __const char *__modes) ; extern int pclose (FILE *__stream); extern char *ctermid (char *__s) __attribute__ ((__nothrow__)); # 886 "/usr/include/stdio.h" 3 4 extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__)); extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__)) ; extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__)); # 916 "/usr/include/stdio.h" 3 4 # 2 "print.c" 2 int main(void) { printf("Hello World"); return 0; }
En el resultado anterior, puede ver que el archivo fuente ahora está lleno de mucha, mucha información, pero aún al final podemos ver las líneas de código escritas por nosotros. Primero analicemos estas líneas de código.
- La primera observación es que el argumento de printf() ahora contiene directamente la cadena "Hello World" en lugar de la macro. De hecho, la definición y el uso de macros han desaparecido por completo. Esto prueba la primera tarea de que todas las macros se expanden en la etapa de preprocesamiento.
- La segunda observación es que el comentario que escribimos en nuestro código original no está ahí. Esto prueba que se eliminaron todos los comentarios.
- La tercera observación es que al lado de la línea "#include" falta y en su lugar vemos mucho código en su lugar. Por lo tanto, es seguro concluir que stdio.h se ha expandido y se ha incluido literalmente en nuestro archivo fuente. Por lo tanto, entendemos cómo el compilador puede ver la declaración de la función printf().
Cuando busqué el archivo print.i, encontré que la función printf se declara como:
extern int printf (__const char *__restrict __format, ...);
La palabra clave 'extern' indica que la función printf() no está definida aquí. Es externo a este archivo. Más adelante veremos cómo llega gcc a la definición de printf().
Puede usar gdb para depurar sus programas c. Ahora que tenemos una comprensión decente de lo que sucede durante la etapa de preprocesamiento. pasemos a la siguiente etapa.
2. COMPILACIÓN
Después de que el compilador termine con la etapa de preprocesador. El siguiente paso es tomar print.i como entrada, compilarlo y producir una salida compilada intermedia. El archivo de salida para esta etapa es 'print.s'. El resultado presente en print.s son instrucciones de nivel de ensamblaje.
Abra el archivo print.s en un editor y vea el contenido.
$ vi print.s .file "print.c" .section .rodata .LC0: .string "Hello World" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 movq %rsp, %rbp .cfi_offset 6, -16 .cfi_def_cfa_register 6 movl $.LC0, %eax movq %rax, %rdi movl $0, %eax call printf movl $0, %eax leave ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3" .section .note.GNU-stack,"",@progbits
Aunque no me gusta mucho la programación a nivel de ensamblador, una mirada rápida concluye que esta salida a nivel de ensamblador tiene algún tipo de instrucciones que el ensamblador puede entender y convertir a lenguaje de nivel de máquina.
3. MONTAJE
En esta etapa se toma como entrada el archivo print.s y se produce un archivo intermedio print.o. Este archivo también se conoce como archivo de objeto.
Este archivo es producido por el ensamblador que comprende y convierte un archivo '.s' con instrucciones de ensamblaje en un archivo de objeto '.o' que contiene instrucciones a nivel de máquina. En esta etapa, solo el código existente se convierte en lenguaje de máquina, las llamadas a funciones como printf() no se resuelven.
Dado que la salida de esta etapa es un archivo de nivel de máquina (print.o). Así que no podemos ver el contenido de la misma. Si aún intenta abrir print.o y verlo, verá algo que no se puede leer en absoluto.
$ vi print.o ^?ELF^B^A^A^@^@^@^@^@^@^@^@^@^A^@>^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@0^ ^@UH<89>å¸^@^@^@^@H<89>ǸHello World^@^@GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3^@^ T^@^@^@^@^@^@^@^AzR^@^Ax^P^A^[^L^G^H<90>^A^@^@^\^@^@]^@^@^@^@A^N^PC<86>^B^M^F ^@^@^@^@^@^@^@^@.symtab^@.strtab^@.shstrtab^@.rela.text^@.data^@.bss^@.rodata ^@.comment^@.note.GNU-stack^@.rela.eh_frame^@^@^@^@^@^@^@^@^@^@^@^ ... ... …
Lo único que podemos explicar mirando el archivo print.o es sobre la cadena ELF.
ELF significa formato ejecutable y enlazable.
Este es un formato relativamente nuevo para archivos de objetos a nivel de máquina y ejecutables producidos por gcc. Antes de esto, se usaba un formato conocido como a.out. Se dice que ELF es un formato más sofisticado que a.out (podríamos profundizar en el formato ELF en algún otro artículo futuro).
Nota:si compila su código sin especificar el nombre del archivo de salida, el archivo de salida producido tiene el nombre 'a.out' pero el formato ahora ha cambiado a ELF. Es solo que el nombre del archivo ejecutable predeterminado sigue siendo el mismo.
4. ENLACE
Esta es la etapa final en la que se realiza toda la vinculación de las llamadas a funciones con sus definiciones. Como se discutió anteriormente, hasta esta etapa gcc no conoce la definición de funciones como printf(). Hasta que el compilador sepa exactamente dónde se implementan todas estas funciones, simplemente usa un marcador de posición para la llamada de función. Es en esta etapa, se resuelve la definición de printf() y se conecta la dirección real de la función printf().
El enlazador entra en acción en esta etapa y realiza esta tarea.
El enlazador también realiza un trabajo extra; combina algún código adicional a nuestro programa que se requiere cuando el programa comienza y cuando finaliza. Por ejemplo, hay un código que es estándar para configurar el entorno de ejecución, como pasar argumentos de línea de comandos, pasar variables de entorno a cada programa. De manera similar, algún código estándar que se requiere para devolver el valor de retorno del programa al sistema.
Las tareas anteriores del compilador se pueden verificar mediante un pequeño experimento. Desde ahora ya sabemos que el enlazador convierte el archivo .o (print.o) en un archivo ejecutable (print).
Entonces, si comparamos los tamaños de archivo de los archivos print.o e print, veremos la diferencia.
$ size print.o text data bss dec hex filename 97 0 0 97 61 print.o $ size print text data bss dec hex filename 1181 520 16 1717 6b5 print
A través del comando de tamaño, obtenemos una idea aproximada de cómo aumenta el tamaño del archivo de salida de un archivo de objeto a un archivo ejecutable. Todo esto se debe a ese código estándar adicional que el enlazador combina con nuestro programa.
Ahora ya sabe lo que le sucede a un programa en C antes de que se convierta en un ejecutable. Ya conoce las etapas de preprocesamiento, compilación, ensamblaje y vinculación. Hay mucho más en la etapa de vinculación, que cubriremos en el próximo artículo de esta serie.