Herramientas de usuario

Herramientas del sitio


compilacion

Etapas 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 tipo 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 estándar 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).

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.

Por ejemplo supongamos que tenemos 2 archivos a.c y b.c:

/* Este es a.c */
int g(int x);
int a= 1;
float b;

int f() {
  b= 1.0;
  return g(a);
}

En a.c la referencia pendiente es g porque el ensamblador no puede determinar cual es la dirección de la función g.

/* Este es b.c */
#include <stdio.h>
extern int a;
float b;

int main() {
  int x= f();
  printf("%d %d %f\n", x, a, b);
  return x;
}

int g(int x) {
  a= 2;
  return x;
}

Aquí las referencias pendientes son a y f. Observe que a fue declarada como 'extern' lo que le dice al ensablador que no reserve espacio para ella porque se trata de una promesa de que otro archivo la va a declarar. Por otra parte b no es una referencia pendiente porque no lleva el atributo 'extern'.

La fase de link en Unix la realiza el comando ld, que viene de loader. Sin embargo este es un error histórico, porque su tarea no es la de un loader (cargardor). Normalmente el cargador es la componente del núcleo del sistema operativo que carga un archivo ejecutable en la memoria del computador para que sea ejecutado. De todas formas, el nombre se conserva quizás por compatibilidad. Gcc invoca el linker de esta forma:

% ld a.o b.o ... otros argumentos ...

¿Pero que pasa con b que aparece declarada en dos archivos? El linker resuelve el problema eliminando una de las declaraciones. La variable aparecerá una sola vez. Pero esto no se puede hacer si la variable se inicializa en los 2 archivos (aunque sea el mismo valor). En ese caso el linker reporta la variable como una definición múltiple.

Inconsistencia de tipos

Cuidado, el linker no realiza ninguna verificación de tipos. Por ejemplo consideremos que el archivo a.c contiene:

int a= 1;

Y el archivo b.c contiene:

#include <stdio.h>
extern float a;

int main() {
  printf("%f\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.

Static

Un error típico en grandes proyectos ocurre cuando 2 archivos implementan funciones con el mismo nombre. El linker no puede determinar cual usar, y reporta la función como una definición múltiple.

Para disminuir este tipo de errores se puede limitar la visibilidad de una funciona o variable global a solo el archivo en donde declara:

static int f() {
  ...
}

De esta forma si otra función f se declara en otro archivo, no habrá colisión de nombres. Observe que uso de static acá no tiene nada que ver con el atributo static de Java.

Las bibliotecas

Las bibliotecas son archivos con la extensión '.a' que empaquetan un sin número de archivos '.o' con el objeto de manejarlos como una unidad. Por ejemplo supongamos que tenemos los siguientes archivos:

/* bib1.c */
void g();

void f() {
  g();
}
/* bib2.c */
void g() {
}
/* bib3.c */
void h() {
}

Compilamos ambos archivos con:

% gcc -c bib1.c bib2.c bib3.c

Lo que genera los archivos bib1.o y bib2.o. Creamos la bibliloteca bib.a con:

% ar r bib.a bib1.o bib2.o bib3.o

Ahora podemos usar f o g desde otro programa:

/* a.c */
int main() {
  f();
}

Compilamos incluyendo la biblioteca con:

% gcc a.c bib.a

Como a.o incluye una referencia pendiente a f, el linker la busca en las bibliotecas y la encuentra en bib1.o, por lo tanto agrega bib1.o completo al binario. Pero ese a su vez tiene una referencia pendiente a g y la encuentra en bib2.o al binario. Como h no se referencia en ningún lado, el archivo bib3.o no se agrega al binario ejecutable.

Uno de los bugs más difíciles de diagnosticar que me ha tocado presenciar ocurrió cuando un función de biblioteca llamaba a otra función dentro de la misma biblioteca pero que casualmente se llamaba igual que una variable global de los fuentes. El linker no cargó la función de la biblioteca si no que rellenó la dirección con la de la variable global, que ni siquiera erá código ejecutable. ¡El programa se caía con un segmentation fault inexplicable!

Esto se puede reproducir cambiando a.c por:

/* a.c */
float g= 3.14;
int main() {
  f();
}

Ejercicio: Compile y ejecute para observar el segmentation fault. Cuando se invoca g, se redirige la ejecución hacia la variable en punto flotante g, como si ahí hubiesen instrucciones.

compilacion.txt · Última modificación: 2014/09/04 10:42 por lmateu