¡Esta es una revisión vieja del documento!
Tabla de Contenidos
Fases de la Compilación
La compilación de un programa en C pasa por varias etapas desde que se tienen los fuentes escritos en C hasta generar el archivo binario directamente ejecutable por la máquina.
Todas estas etapas son gatilladas directamente por el compilador (por ejemplo gcc), pero algunas de ellas implican lanzar otros procesos como el preprocesador, el ensamblador o el linker.
Hay que considerar que C fue diseñado para favorecer la compilación separada, es decir cada archivo se compila en una forma absolutamente independiente de los demás archivos.
A continuación estudiaremos la secuencia de pasos.
Preproceso
La primera etapa de la compilación y consisten en expandir las directivas del preprocesador. Ud. siempre puede examinar como se va a expandir un fuente '.c' usando la opción -E del preprocesador:
% gcc -E prog.c
Arroja a la salida estándar el resultado del preproceso. Ojo: este archivo puede ser muy grande cuando Ud. incluye archivos de encabezado como stdio.h.
Esta etapa la lleva a cabo un proceso independiente denominado cpp (que viene de C preprocessor).
A continuación se explican las directivas más usadas.
#include
Por ejemplo:
#include <stdio.h> #include "mis_definiciones.h"
En realidad se puede incluir cualquier archivo, no necesariamente tiene que tener la extensión '.h' pero por convención esa es la que usa. Aquí típicamente se encuentran:
- Las definiciones de tipos y estructuras compartidas por todos los archivos.
- Los encabezados de funciones compartidas por todos los archivos.
- Definiciones de macros
Recuerde que cada identificador que se usa en C debe haber sido declarado previamente, de otro modo se considera un error. El peligro que se corre es declarar una variable o función de un tipo en un archivo, pero de otro en otro archivo. El compilador no reclama en este caso porque simplemente no tiene la información para detectar el error. Por eso se denomina compilación separada.
Por esta razón entonces se declaran todas la variables y funciones compartidas en los archivos de encabezado. Si se requiere cambiar un tipo se cambia en el encabezado y así cambia en todas partes. Si por error el tipo declarado en el encabezado no coincide con el tipo en la definición de una función, el compilador sí reclama porque tiene la información. Pero esto es solo una convención: nada impide que Ud. declare un encabezado en cada archivo que usa la función.
#define
Se usa para definir macros que se expanden literalmente. Por ejemplo supongamos que el archivo prog.c contiene:
#define N 100 #define MAX(a,b) a>b ? a : b int main(int argc, char **argv) { char buf[N]; int k= 1+MAX(argc,N); ... }
Si Ud. usa:
% gcc -E prog.c
La salida estandar mostrará:
int main(int argc, char **argv) { char buf[100]; int k= 1+argc>N ? argc : N; ... }
¡Observe que la expresión resultante de la substitución de MAX no queda con la parentización intuitiva! 1+argc se compara con N.
Para evitar este error se recomienda siempre usar exceso de paréntesis en las macros. La forma más segura de definir MAX es la siguiente:
#define MAX(a,b) ((a)>(b)?(a):(b))
Aún así pueden ocurrir situaciones no esperadas como la siguiente:
int i,j; ... i= MAX(i++,j); /* Se expande a ((i++)>(j)?(i++):(j))
Es decir i se incrementa 2 veces. Por eso siempre recuerde: la expansión de las macros es literal. También considere que no existen las macros recursivas porque no habría forma de parar la recursión y el tamaño de lo expandido sería infinito.
#ifdef
Se usa para activar/desactivar código dependiendo de si alguna macro está definida. Por ejemplo:
#ifdef N char buf[N]; #else char buf[100]; #endif
La compilación
Esta etapa recibe el resultado del preproceso que consisten en un gran archivo sin directivas #. El archivo es grande porque se han incluido textualmente todos los archivos de encabezados con las declaraciones de funciones de biblioteca especificadas por los #include.
La etapa compila las funciones y produce código en el assembler específico de la plataforma. Este código es de muy bajo nivel pero todavía legible porque cada instrucción usa un nombre para identificarla. Este código no es directamente ejecutable por la máquina.
La opciónes más importantes de esta etapa son:
- -O instruye al compilador para generar un código más eficiente en tiempo de ejecución.
- -g genera tablas que permiten depurar el programa con un depurador como gdb. Esto es lo que permite por ejemplo que el depurador pueda determinar la posición de las variables locales dentro del registro de activación de una función. Normalmente no se usa esta opción en conjunto con -O.
- -S detiene gcc justo después de la compilación para poder estudiar el código en assembler (un archivo con la extensión '.s').
El ensamblaje
Esta etapa recibe el archivo en assembler (extensión '.s') y produce instrucciones ejecutables directamente por la máquina (archivo con extensión '.o'). Sin embargo el archivo todavía no es ejecutable porque contiene referencias pendientes a funciones y variables definidas en otros archivos.
Gcc admite la opción -c para para detener el proceso justo después del ensamblaje.
Esta etapa la lleva a cabo un proceso independiente denominado as (que viene de assembler).
Link
Esta etapa es la que se encarga de juntar todos los archivos '.o' y generar un solo gran archivo correspondiente al binario ejecutable. Si no se especifica un nombre, por omisión se usa 'a.out'. Esta etapa la realiza el comando ln (que viene de link). En ella se resuelven las referencias pendientes, es decir las llamadas a funciones definidas en otros archivos.
Cuidado, en esta fase no se realiza ninguna verificación de tipos. Por ejemplo consideremos que el archivo a.c contiene:
int a= 1;
Y el archivo b.c contiene:
extern float a; int main() { printf("%d\n", a); }
El resultado es impredescible y no hay ninguna advertencia del cambio de tipo en la variable. Por eso siempre se recomienda declarar funciones y variables compartidas en archivos de encabezados para evitar este tipo de errores.