← Programación en C para Sistemas Operativos Bloque 1 · Del código fuente al programa · Tema 4 de 42

Tema 4

Del fuente al ejecutable

gcc hola.c parece una sola operación, pero son cuatro programas distintos que trabajan en cadena: el preprocesador, el compilador propiamente dicho, el ensamblador y el enlazador. Cada uno produce un archivo que el siguiente toma como entrada, y cada uno tiene sus propios errores. En este tema detenemos la cadena después de cada etapa para mirar qué produjo: el texto preprocesado, el código ensamblador, el archivo objeto con sus huecos sin llenar y el ejecutable final. Por el camino veremos qué hace la optimización, por qué un programa de quince líneas puede ocupar ochocientos kilobytes, y qué advertencias conviene pedirle al compilador.

1. Las cuatro etapas

En el tema 2 vimos dos tipos de errores, los de compilación y los de enlace, y dijimos que venían de programas distintos. En realidad son cuatro las etapas, y gcc no es ninguna de ellas: es un conductor (driver) que ejecuta los programas adecuados en el orden correcto, con las opciones correctas, y borra los archivos intermedios al terminar.

Las cuatro etapas: etapas.c, de 233 bytes, pasa por el preprocesador (gcc -E) y se convierte en etapas.i, de 15191 bytes; el compilador (gcc -S) lo convierte en etapas.s, de 904 bytes; el ensamblador (gcc -c) produce etapas.o, de 1560 bytes; y el enlazador, junto con los archivos de arranque y la biblioteca de C, produce el ejecutable etapas, de 15952 bytes etapas.c 233 B preprocesador gcc -E etapas.i 15 191 B compilador gcc -S etapas.s 904 B ensamblador gcc -c etapas.o 1 560 B enlazador gcc etapas 15 952 B Scrt1.o · crti.o · … libc.so.6 En verde, los programas; en gris, los archivos que pasan de una etapa a la siguiente.
El camino de etapas.c, con el tamaño real de cada archivo. Cada opción de gcc detiene la cadena después de una etapa.
EtapaProgramaEntrada → salidaQué haceParar aquí
Preprocesadocc1 -E.c → .iResuelve #include y #define, quita los comentarios. Texto a texto.-E
Compilacióncc1.i → .sAnaliza el C, lo comprueba, lo optimiza y lo traduce a ensamblador.-S
Ensambladoas.s → .oConvierte el ensamblador en código de máquina, con huecos para lo que no conoce.-c
Enlaceld.o → ejecutableUne los archivos objeto con el código de arranque y las bibliotecas, y llena los huecos.(ninguna)

En la práctica se habla de «compilar» para todo el proceso, y no es un error. Pero cuando algo falla, saber en qué etapa ocurrió es la mitad del diagnóstico. El ejemplo de este tema es un programa apenas más rico que hola.c, con una constante, un comentario y un bucle, para que cada etapa tenga algo que hacer:

etapas.c
#include <stdio.h>

#define LIMITE 10   /* hasta dónde sumar */

int main(void)
{
    int total = 0;

    for (int i = 1; i <= LIMITE; i++)
        total += i;
    printf("La suma de 1 a %d es %d\n", LIMITE, total);
    return 0;
}

2. Ver las etapas

La opción -save-temps le pide a gcc que no borre los archivos intermedios. Con ella, una compilación normal deja a la vista el producto de cada etapa:

terminal · los cuatro productos de una compilación
$ gcc -std=c17 -Wall -Wextra -save-temps -o etapas etapas.c && ./etapas
La suma de 1 a 10 es 55
$ ls -l etapas*
-rwxr-xr-x 1 ana ana 15952 oct 15 10:56 etapas
-rw-r--r-- 1 ana ana   233 oct 15 10:56 etapas.c
-rw-r--r-- 1 ana ana 15191 oct 15 10:56 etapas.i
-rw-r--r-- 1 ana ana  1560 oct 15 10:56 etapas.o
-rw-r--r-- 1 ana ana   904 oct 15 10:56 etapas.s
$ file etapas.i etapas.s etapas.o etapas
etapas.i: C source, ASCII text
etapas.s: assembler source, ASCII text
etapas.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
etapas:   ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, …

file resume la historia: los dos primeros productos son texto, C y ensamblador; los dos últimos son binarios ELF, pero de clases distintas, un objeto relocatable y un ejecutable. Los tamaños también cuentan algo: el .i es sesenta y cinco veces más grande que el fuente, por stdio.h; el .s y el .o son pequeños, porque solo contienen nuestro main; y el ejecutable vuelve a crecer, por todo lo que agrega el enlazador.

Para ver qué programas ejecuta gcc, y con qué opciones, está -v. Su salida es larga; estas son las tres líneas importantes, abreviadas:

terminal · gcc -v, las órdenes que ejecuta (abreviadas)
$ gcc -v -o etapas etapas.c
…
 /usr/libexec/gcc/x86_64-linux-gnu/15/cc1 -quiet -v -imultiarch x86_64-linux-gnu etapas.c … -o /tmp/ccUot3FR.s
…
 /usr/bin/x86_64-linux-gnu-as -v --64 -o /tmp/ccaqA9PP.o /tmp/ccUot3FR.s
…
 /usr/libexec/gcc/x86_64-linux-gnu/15/collect2 … -o etapas …/Scrt1.o …/crti.o …/crtbeginS.o
     /tmp/ccaqA9PP.o -lgcc -lgcc_s -lc -lgcc -lgcc_s …/crtendS.o …/crtn.o

Son tres programas, no cuatro: cc1 hace a la vez el preprocesado y la compilación, y escribe el ensamblador en un archivo temporal de /tmp; as lo ensambla en otro temporal; y collect2, un envoltorio de gcc, llama a ld, el enlazador, con nuestro objeto y una lista de archivos y bibliotecas que nunca mencionamos. Volveremos a esa lista en la sección 7.

3. El preprocesador: gcc -E

El preprocesador es el único que trabaja con el texto sin entender C. Hace tres cosas: reemplaza cada #include por el archivo incluido, reemplaza cada macro definida con #define por su valor, y cambia cada comentario por un espacio. Este es el final de etapas.i:

terminal · el final del texto preprocesado
$ gcc -E etapas.c | tail -n 10
# 5 "etapas.c"
int main(void)
{
    int total = 0;

    for (int i = 1; i <= 10; i++)
        total += i;
    printf("La suma de 1 a %d es %d\n", 10, total);
    return 0;
}

La línea #define y su comentario desaparecieron, y cada LIMITE se convirtió en 10. El compilador nunca verá la palabra LIMITE: para él, el programa siempre dijo 10. La marca # 5 "etapas.c" le avisa que la línea siguiente es la 5 del fuente original, para que sus mensajes señalen el lugar correcto.

Una macro también se puede definir desde la línea de órdenes, con -D, sin tocar el fuente. Y el compilador trae cientos de macros ya definidas que describen la plataforma, que -dM -E lista:

terminal · macros desde afuera y macros predefinidas
$ gcc -dM -E - < /dev/null | wc -l
414
$ gcc -dM -E - < /dev/null | grep -E '__linux__|__x86_64__|__SIZEOF_POINTER__|__INT_MAX__'
#define __SIZEOF_POINTER__ 8
#define __x86_64__ 1
#define __INT_MAX__ 0x7fffffff
#define __linux__ 1

Son las que usó entorno.c en el tema 2, como __GNUC__ y __STDC_VERSION__. Un programa puede preguntar por ellas con #ifdef __linux__ para compilar una parte solo en Linux: es la compilación condicional, que junto con las macros con parámetros y sus trampas ocupa el tema 14.

En cuanto a -D, hay una sutileza. Si etapas.c se compila con -DLIMITE=100, el resultado sigue siendo 10, y el compilador advierte "LIMITE" redefined: la definición de la línea de órdenes se procesa primero, y el #define del fuente la reemplaza. Para que una macro se pueda ajustar desde afuera, el fuente tiene que definirla solo si nadie lo hizo antes, con #ifndef LIMITE; también lo veremos en el tema 14.

4. El compilador: gcc -S

La segunda etapa es la que da nombre a todo el proceso. El compilador analiza el texto preprocesado, comprueba que sea C válido, con los tipos correctos, emite los errores y advertencias, optimiza, y traduce el resultado al lenguaje ensamblador del procesador: la forma legible de sus instrucciones de máquina. Para verlo sin ruido, conviene una función mínima:

cuadrado.c
int cuadrado(int x)
{
    return x * x;
}
terminal · el ensamblador, sin optimizar
$ gcc -std=c17 -S -O0 -fno-asynchronous-unwind-tables -o - cuadrado.c
	.file	"cuadrado.c"
	.text
	.globl	cuadrado
	.type	cuadrado, @function
cuadrado:
	endbr64
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%edi, -4(%rbp)
	movl	-4(%rbp), %eax
	imull	%eax, %eax
	popq	%rbp
	ret
	.size	cuadrado, .-cuadrado
…

-o - envía el resultado a la salida estándar en lugar de a cuadrado.s, y -fno-asynchronous-unwind-tables quita unas directivas .cfi que solo sirven a los depuradores. Lo que queda son dos tipos de líneas. Las que empiezan con punto son directivas para el ensamblador: .text dice que lo que sigue es código, y .globl cuadrado, que ese nombre debe ser visible desde otros archivos. Las demás son instrucciones del procesador x86-64, con la sintaxis de AT&T que usa GNU, donde el origen va primero y el destino después:

InstrucciónQué hace
cuadrado:Una etiqueta: da nombre a la dirección donde empieza la función.
endbr64Marca un destino válido de salto, una protección del procesador contra ciertos ataques.
pushq %rbp
movq %rsp, %rbp
Prepara el marco de pila de la función: guarda el valor anterior de %rbp y lo apunta a la cima de la pila. Es el tema 12.
movl %edi, -4(%rbp)Copia el parámetro x, que llega en el registro %edi, a la pila: x es una variable con su lugar en memoria.
movl -4(%rbp), %eaxLo vuelve a leer, ahora en el registro %eax.
imull %eax, %eaxMultiplica %eax por sí mismo: x * x.
popq %rbp
ret
Restaura %rbp y vuelve a quien llamó. El resultado viaja en %eax.

Que el argumento llegue en %edi y el resultado salga en %eax no es una decisión de este compilador: es la convención de llamada de Linux en x86-64, parte de la interfaz binaria de la que hablamos en el tema 1. Todos los compiladores la respetan, y por eso una función compilada con gcc se puede llamar desde código compilado con clang, o desde Rust. También explica el código de salida 10 de void main en el tema 3: %eax conservaba lo que había dejado printf.

No hace falta saber ensamblador para programar en C, y este curso no lo enseña. Pero leerlo de a ratos, con -S o con un sitio como Compiler Explorer (godbolt.org), es la forma más directa de responder preguntas como «¿esto es lento?» o «¿qué hizo el compilador con mi código?».

5. La optimización

El código anterior es ingenuo a propósito: con -O0, el nivel por omisión, el compilador traduce cada sentencia por separado y guarda cada variable en la memoria, lo que facilita la depuración. Con -O2, la misma función queda así:

terminal · cuadrado.c, con -O2
$ gcc -std=c17 -S -O2 -fno-asynchronous-unwind-tables -o - cuadrado.c | sed -n '/^cuadrado:/,/ret/p'
cuadrado:
	endbr64
	imull	%edi, %edi
	movl	%edi, %eax
	ret

Sin marco de pila y sin pasar por la memoria: multiplicar el registro del argumento por sí mismo y copiarlo al registro del resultado. Siete instrucciones se convirtieron en tres. Pero el ejemplo más elocuente es main en etapas.c. Sin optimizar, el bucle está ahí, con su comparación y su salto; optimizado, no queda nada de él:

terminal · el main de etapas.c, sin optimizar (con comentarios agregados) y con -O2
$ sed -n '/^main:/,/ret/p' etapas.s | grep -v cfi
main:
.LFB0:
	endbr64
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$16, %rsp
	movl	$0, -8(%rbp)          # total = 0
	movl	$1, -4(%rbp)          # i = 1
	jmp	.L2
.L3:
	movl	-4(%rbp), %eax
	addl	%eax, -8(%rbp)        # total += i
	addl	$1, -4(%rbp)          # i++
.L2:
	cmpl	$10, -4(%rbp)         # ¿i <= 10?
	jle	.L3
	…
	call	printf@PLT
	…
$ gcc -std=c17 -S -O2 -fno-asynchronous-unwind-tables -o - etapas.c | sed -n '/^main:/,/ret/p'
main:
	endbr64
	subq	$8, %rsp
	movl	$55, %ecx
	movl	$10, %edx
	xorl	%eax, %eax
	leaq	.LC0(%rip), %rsi
	movl	$2, %edi
	call	__printf_chk@PLT
	xorl	%eax, %eax
	addq	$8, %rsp
	ret

El compilador hizo la suma por su cuenta, mientras compilaba, y el programa optimizado se limita a pasarle a la función de impresión el número 55 ya calculado. Podía hacerlo porque todos los datos del bucle eran conocidos de antemano. (La función de impresión cambió de nombre, de printf a __printf_chk: es cosa del sistema, y la explica la sección 13.) Cuando los datos no se conocen, el bucle queda, pero más rápido. Este programa hace mil millones de vueltas de una cuenta que no se puede adelantar:

veloz.c
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    long n = argc > 1 ? atol(argv[1]) : 1000000000;
    unsigned long acumulado = 0;

    for (long i = 0; i < n; i++)
        acumulado = acumulado * 31 + (unsigned long) i;
    printf("%lu\n", acumulado);
    return 0;
}
terminal · el mismo programa, sin optimizar y con -O2
$ gcc -std=c17 -O0 -o veloz_O0 veloz.c
$ gcc -std=c17 -O2 -o veloz_O2 veloz.c
$ time ./veloz_O0
5339292628573971712

real	0m1.443s
user	0m1.443s
sys	0m0.000s
$ time ./veloz_O2
5339292628573971712

real	0m0.662s
user	0m0.658s
sys	0m0.004s

El mismo resultado en menos de la mitad del tiempo, sin cambiar una línea del programa. Los niveles de optimización que ofrece gcc son estos:

NivelQué haceCuándo usarlo
-O0Ninguna optimización: cada sentencia se traduce tal cual. Es el nivel por omisión.Al depurar paso a paso, cuando hace falta que el código siga al fuente.
-OgLas optimizaciones que no estorban a la depuración.Desarrollo diario con gdb.
-O1Optimizaciones baratas y seguras.Poco usado por sí mismo.
-O2Casi todas las optimizaciones que no agrandan el código.El nivel habitual de los programas que se distribuyen, y de las distribuciones de Linux.
-O3Además, optimizaciones agresivas que pueden agrandar el código.Cálculo intensivo, midiendo si realmente ayuda.
-OsComo -O2, pero prioriza el tamaño.Sistemas pequeños, con poca memoria.
La optimización y el comportamiento indefinido

El optimizador supone que el programa respeta las reglas del lenguaje, y saca conclusiones a partir de esa suposición. Si el programa tiene comportamiento indefinido, como el desbordamiento de un entero con signo del tema 1, el código optimizado puede comportarse distinto del que no lo está: una comprobación que «funcionaba» con -O0 desaparece con -O2. Cuando un programa funciona sin optimizar y falla al optimizar, el culpable casi nunca es el compilador: es un error del programa que la optimización dejó al descubierto.

Hay un efecto secundario útil: para optimizar, el compilador analiza con más detalle por dónde circulan los valores, y descubre problemas que sin optimizar no ve. Este programa usa valor sin inicializar cuando no recibe argumentos:

terminal · mu.c: valor se asigna solo si hay un argumento
$ gcc -std=c17 -Wall -Wextra -O0 -o mu mu.c
$ gcc -std=c17 -Wall -Wextra -O2 -o mu mu.c
…
warning: ‘valor’ may be used uninitialized [-Wmaybe-uninitialized]
mu.c: In function ‘main’:
mu.c:5:9: note: ‘valor’ was declared here
    5 |     int valor;
      |         ^~~~~

Sin optimizar, silencio; con -O2, la advertencia. Por eso conviene compilar de vez en cuando con -O2 -Wall -Wextra, aunque se desarrolle con -O0, como ya hicimos con fuera.c en el tema 1.

6. El ensamblador: gcc -c

El ensamblador traduce cada instrucción a los bytes que la representan, y produce un archivo objeto: código de máquina, pero todavía incompleto. Con -c, gcc se detiene ahí, y el resultado se llama igual que el fuente, con .o:

terminal · el archivo objeto
$ gcc -std=c17 -Wall -Wextra -c etapas.c
$ nm etapas.o
0000000000000000 T main
                 U printf

nm muestra los dos lados del objeto: main está definido aquí, en la sección de código (T, de text), en la dirección 0, porque todavía no sabe dónde terminará; y printf se usa pero no está (U, undefined). ¿Qué hay entonces en el lugar donde el código llama a printf? objdump -d desensambla el objeto, y -r muestra las reubicaciones, las notas que deja el ensamblador sobre los huecos que hay que llenar:

terminal · el código de main en el objeto, con sus reubicaciones
$ objdump -d -r etapas.o
…
  2f:	48 8d 0d 00 00 00 00 	lea    0x0(%rip),%rcx        # 36 <main+0x36>
			32: R_X86_64_PC32	.rodata-0x4
  36:	89 c2                	mov    %eax,%edx
  38:	be 0a 00 00 00       	mov    $0xa,%esi
  3d:	48 89 cf             	mov    %rcx,%rdi
  40:	b8 00 00 00 00       	mov    $0x0,%eax
  45:	e8 00 00 00 00       	call   4a <main+0x4a>
			46: R_X86_64_PLT32	printf-0x4
…

Cada instrucción aparece con su dirección dentro de main, sus bytes en hexadecimal y su forma legible. La llamada a printf es el byte e8, el código de la instrucción call, seguido de cuatro bytes que deberían decir adónde saltar, y que son ceros: el ensamblador no sabe dónde estará printf. Debajo, la reubicación R_X86_64_PLT32 printf le indica al enlazador que llene esos cuatro bytes, en la posición 0x46, con la distancia hasta printf. Lo mismo pasa dos líneas antes con la dirección de la cadena de formato, que está en otra sección, .rodata, cuya ubicación final tampoco se conoce. Un archivo objeto es, en esencia, código de máquina con una lista de huecos y de lo que va en cada uno.

7. El enlazador

La última etapa toma los archivos objeto, los ubica en direcciones definitivas, llena los huecos y produce el ejecutable. La orden que vimos con gcc -v muestra todo lo que recibe, además de nuestro objeto:

ArchivoViene deAporta
Scrt1.oglibc_start, el punto de entrada, que llama a __libc_start_main. La S es por la versión para ejecutables independientes de la posición.
crti.o, crtn.oglibcEl principio y el final de las funciones de inicialización y finalización, _init y _fini.
crtbeginS.o, crtendS.ogccEl soporte del compilador para constructores, destructores y excepciones.
/tmp/cc….onuestro fuentemain.
-lcglibcLa biblioteca de C: printf, __libc_start_main y todo lo demás.
-lgcc, -lgcc_sgccFunciones auxiliares que el compilador usa para operaciones que el procesador no hace solo.

Los nombres crt vienen de C runtime, el soporte de ejecución de C. Así se explica el error del tema 3 al escribir Main: undefined reference to `main', en la función _start de Scrt1.o. No es nuestro código el que necesita main: es el código de arranque. Después del enlace, los huecos del objeto están llenos:

terminal · el mismo main, ya en el ejecutable
$ objdump -d --no-show-raw-insn etapas | sed -n '/<main>:/,/ret/p' | grep -E 'lea|call'
    1178:	lea    0xe85(%rip),%rcx        # 2004 <_IO_stdin_used+0x4>
    118e:	call   1050 <printf@plt>
$ readelf -h etapas | grep -E 'Type|Entry'
  Type:                              DYN (Position-Independent Executable file)
  Entry point address:               0x1060
$ nm etapas | grep ' _start$'
0000000000001060 T _start

main tiene ahora una dirección, 0x1149 y siguientes; la cadena de formato, la 0x2004; y la llamada salta a printf@plt, en 0x1050. El ejecutable registra además su punto de entrada, la dirección donde el núcleo empezará a ejecutarlo: 0x1060, que es _start, como prometía el diagrama del tema 3. Estas direcciones son relativas: el ejecutable es independiente de la posición, y el sistema le suma una base al azar al cargarlo, que es el ASLR del tema 1.

El ejecutable organiza su contenido en secciones, cada una con un tipo de dato:

terminal · las secciones principales de etapas
$ size etapas
   text	   data	    bss	    dec	    hex	filename
   1440	    600	      8	   2048	    800	etapas
$ objdump -s -j .rodata etapas
Contents of section .rodata:
 2000 01000200 4c612073 756d6120 64652031  ....La suma de 1
 2010 20612025 64206573 2025640a 00         a %d es %d..
SecciónContieneEn etapas.c
.textEl código de máquina.main, y _start y compañía.
.rodataDatos constantes, de solo lectura.La cadena de formato, con su 0a (el \n) y su 00 final.
.dataVariables globales con valor inicial.Ninguna nuestra; algunas del código de arranque.
.bssVariables globales sin valor inicial, que empiezan en cero. No ocupan lugar en el archivo, solo su tamaño.Ninguna nuestra.

Las secciones se convertirán en las zonas de memoria del proceso cuando el programa se cargue: el mapa de memoria del tema 27. Y los símbolos, las reubicaciones y los errores de enlace son el tema del tema 31.

8. Enlace dinámico y estático

El enlazador no copió printf dentro del ejecutable. Dejó una referencia a libc.so.6, y la llamada pasa por printf@plt, una pequeña tabla que se completa al ejecutar el programa: el código de printf lo trae el cargador dinámico, ld-linux-x86-64.so.2, cuando el programa arranca. Es el enlace dinámico, el que se usa por omisión. La alternativa, -static, copia en el ejecutable todo lo que necesita de la biblioteca:

terminal · el mismo programa, enlazado de las dos formas
$ gcc -O2 -o din etapas.c
$ gcc -O2 -static -o est etapas.c
$ ls -l din est
-rwxr-xr-x 1 ana ana  15960 oct 15 10:57 din
-rwxr-xr-x 1 ana ana 816864 oct 15 10:57 est
$ ldd din
	linux-vdso.so.1 (0x00007ffe895f3000)
	libc.so.6 => /usr/lib/x86_64-linux-gnu/libc.so.6 (0x000073c35f800000)
	/lib64/ld-linux-x86-64.so.2 (0x000073c35fbf8000)
$ ldd est
	not a dynamic executable
$ size din est
   text	   data	    bss	    dec	    hex	filename
   1433	    600	      8	   2041	    7f9	din
 699403	  22824	  22592	 744819	  b5d73	est

El ejecutable estático pesa cincuenta veces más, porque lleva dentro printf y todo lo que printf usa a su vez: formateo de números, buffers, manejo de la configuración regional. A cambio, no depende de nada: funciona en cualquier Linux de la misma arquitectura, aunque tenga otra versión de glibc. Cada forma tiene su lugar:

Dinámico (por omisión)Estático (-static)
TamañoPequeño: solo el código propio.Grande: incluye la parte usada de cada biblioteca.
MemoriaTodos los procesos comparten una copia de libc.so.6.Cada programa carga su propia copia.
ActualizacionesCorregir un error en glibc corrige todos los programas.Hay que volver a enlazar cada programa.
DependenciasNecesita las bibliotecas correctas en el sistema donde se ejecuta.Ninguna: se copia y funciona.

Las bibliotecas estáticas y dinámicas propias, cómo se crean y cómo se buscan al ejecutar, ocupan los temas 32 y 33.

9. La compilación separada

Que el ensamblado y el enlace sean etapas distintas tiene una consecuencia práctica enorme: un programa puede repartirse en varios archivos .c, compilarse cada uno por separado hasta su .o, y unirse al final. Aquí, una función vive en un archivo y la usa otro:

sumar.c
int sumar_hasta(int n)
{
    int total = 0;

    for (int i = 1; i <= n; i++)
        total += i;
    return total;
}
principal.c
#include <stdio.h>

int sumar_hasta(int n);   /* definida en sumar.c */

int main(void)
{
    printf("La suma de 1 a 100 es %d\n", sumar_hasta(100));
    return 0;
}
terminal · compilar cada archivo y enlazarlos
$ gcc -std=c17 -Wall -Wextra -c sumar.c
$ gcc -std=c17 -Wall -Wextra -c principal.c
$ nm sumar.o; nm principal.o
0000000000000000 T sumar_hasta
0000000000000000 T main
                 U printf
                 U sumar_hasta
$ gcc -o principal principal.o sumar.o
$ ./principal
La suma de 1 a 100 es 5050

principal.c compila sin saber nada de sumar.c: le basta la declaración de sumar_hasta para comprobar la llamada y dejar el hueco. nm muestra el encaje: sumar.o define (T) lo que principal.o necesita (U), y el enlazador une las dos piezas. La última orden no lleva -c ni fuentes: gcc reconoce los .o y pasa directamente al enlace.

La ventaja aparece cuando el programa crece. Si cambia sumar.c, basta recompilar sumar.o y volver a enlazar; principal.o sigue sirviendo. En un proyecto como el núcleo Linux, con decenas de miles de archivos, es la diferencia entre esperar segundos u horas. Llevar la cuenta de qué recompilar es exactamente el trabajo de make, en los temas 35 y 36; escribir la declaración en un encabezado .h en lugar de repetirla a mano, el del tema 13.

10. Los errores de cada etapa

Cada etapa falla a su manera, y la forma del mensaje dice cuál fue. Estos son los típicos de las etapas que más fallan, el preprocesador y el enlazador:

terminal · un error del preprocesador
$ gcc -c nf.c
nf.c:1:10: fatal error: noexiste.h: No such file or directory
    1 | #include "noexiste.h"
      |          ^~~~~~~~~~~~
compilation terminated.
terminal · tres errores del enlazador
$ gcc -o principal principal.o
/usr/bin/x86_64-linux-gnu-ld.bfd: principal.o: in function `main':
principal.c:(.text+0xe): undefined reference to `sumar_hasta'
collect2: error: ld returned 1 exit status
$ gcc -o principal principal.o sumar.o sumar2.o
/usr/bin/x86_64-linux-gnu-ld.bfd: sumar2.o: in function `sumar_hasta':
sumar2.c:(.text+0x0): multiple definition of `sumar_hasta'; sumar.o:sumar.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status
$ gcc -o sumar sumar.o
/usr/bin/x86_64-linux-gnu-ld.bfd: …/Scrt1.o: in function `_start':
(.text+0x1b): undefined reference to `main'
collect2: error: ld returned 1 exit status

Los mensajes del enlazador no citan líneas del fuente, sino posiciones dentro de las secciones de los objetos, como .text+0xe, porque el enlazador no ve el C: ve símbolos y huecos. El primero falta porque olvidamos sumar.o. El segundo sobra porque sumar2.o, una copia, define otra vez la misma función, y el enlazador no puede elegir. El tercero es sumar.o enlazado solo: no tiene main, y _start lo reclama.

EtapaMensaje típicoCausa habitual
Preprocesadorfatal error: x.h: No such file or directoryNombre mal escrito, falta un paquete -dev o una opción -I.
Compiladorarchivo.c:5:40: error: expected ‘;’…Sintaxis, tipos, nombres no declarados.
EnsambladorError: no such instructionCasi nunca ocurre con código C; solo con ensamblador escrito a mano.
Enlazadorundefined reference to `f'Falta el .o o la biblioteca (-l) que define f, o el nombre está mal escrito.
Enlazadormultiple definition of `f'Dos archivos definen lo mismo; a menudo, una función definida en un .h incluido en dos .c.

11. Las advertencias

En el tema 2 establecimos la regla de compilar con -Wall -Wextra. Vale la pena saber qué hay detrás. gcc tiene casi quinientas advertencias, cada una con su nombre, y las agrupa:

terminal · cuántas advertencias enciende cada opción
$ gcc -Q --help=warning | grep -c '^  -W'
472
$ gcc -Q --help=warning | grep -c '\[enabled\]'
116
$ gcc -Q --help=warning -Wall | grep -c '\[enabled\]'
170
$ gcc -Q --help=warning -Wall -Wextra | grep -c '\[enabled\]'
190

A pesar del nombre, -Wall no enciende «todas»: enciende las que casi nunca dan falsas alarmas. -Wextra agrega otras veinte que señalan cosas sospechosas pero a veces intencionales, como un parámetro sin usar. Y hay más que ninguna de las dos enciende, porque en código existente producirían demasiados avisos, pero que en código nuevo valen la pena. Este programa tiene un problema para cada nivel:

avisos.c
#include <stdio.h>

int main(void)
{
    int total = 0;
    int resultado = 7;
    unsigned char byte = 300;               /* no cabe en un byte */

    for (int i = 0; i < 3; i++) {
        int total = i * 2;                  /* oculta al total de afuera */
        printf("%d\n", total);
    }
    if (resultado = 0)                      /* = en lugar de == */
        printf("resultado es cero\n");
    short corto = resultado * 100000;       /* puede no caber en short */
    int bin = 0b1010;                       /* binario: C23 o extensión */
    printf("%d %d %d %d\n", total, byte, corto, bin);
    return 0;
}
terminal · el mismo programa, con tres niveles de advertencias
$ gcc -std=c17 -o avisos avisos.c 2>&1 | grep warning
avisos.c:7:26: warning: unsigned conversion from ‘int’ to ‘unsigned char’ changes value from ‘300’ to ‘44’ [-Woverflow]
$ gcc -std=c17 -Wall -Wextra -o avisos avisos.c 2>&1 | grep warning
avisos.c:7:26: warning: unsigned conversion from ‘int’ to ‘unsigned char’ changes value from ‘300’ to ‘44’ [-Woverflow]
avisos.c:13:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
$ gcc -std=c17 -Wall -Wextra -Wshadow -Wconversion -pedantic -o avisos avisos.c 2>&1 | grep warning
avisos.c:7:26: warning: unsigned conversion from ‘int’ to ‘unsigned char’ changes value from ‘300’ to ‘44’ [-Woverflow]
avisos.c:10:13: warning: declaration of ‘total’ shadows a previous local [-Wshadow]
avisos.c:13:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
avisos.c:15:19: warning: conversion from ‘int’ to ‘short int’ may change value [-Wconversion]
avisos.c:16:15: warning: binary constants are a C23 feature or GCC extension [-Wpedantic]

Sin opciones aparece solo lo indiscutible: 300 no cabe en un byte, y se guarda como 44, la misma cuenta del código de salida del tema 3. -Wall agrega el error más famoso de C, = en lugar de ==: if (resultado = 0) no compara, sino que asigna 0 y luego evalúa ese 0 como falso, de modo que la condición nunca se cumple y además resultado queda destruido. Si de verdad se quiere asignar dentro de una condición, el compilador pide paréntesis dobles para saber que es intencional. Las tres opciones finales encuentran el resto:

OpciónAdvierte deComentario
-WshadowUna variable que oculta a otra del mismo nombre de un bloque exterior.El total de adentro hace invisible al de afuera: fuente frecuente de confusiones.
-WconversionConversiones implícitas que pueden perder información.Muy útil, pero ruidosa en código existente. El tema 7 trata las conversiones.
-pedanticTodo lo que no sea C estándar estricto, según -std.Las constantes binarias son de C23; en C17 son una extensión de GNU.
-Wformat=2Más comprobaciones de las cadenas de formato de printf.Incluye las cadenas de formato que no son literales, un riesgo de seguridad.
-Wsign-conversionMezclas de enteros con y sin signo.Complementa a -Wconversion.

Cada advertencia se puede controlar por su nombre, el que aparece entre corchetes: -Wno- delante del nombre la apaga, y -Werror= la convierte en error sin afectar a las demás. Así, -Werror=parentheses hace que el = en una condición impida compilar, pero deja el resto como advertencias. Apagar una advertencia debería ser la excepción, con un motivo, y nunca la forma de «arreglar» un programa.

12. El estándar y las extensiones

gcc no compila exactamente el C del estándar, sino el dialecto de GNU: C más un conjunto de extensiones propias, algunas de las cuales el estándar adoptó después, como las constantes binarias en C23. La opción -std elige ambas cosas, la versión y si se admiten extensiones:

OpciónSignifica
-std=c17C17 estricto: las extensiones que chocan con el estándar se desactivan, y la biblioteca oculta las funciones que no son de C, salvo que se pidan.
-std=gnu17C17 con las extensiones de GNU.
-std=c23, -std=gnu23Lo mismo para C23. gnu23 es lo que usa gcc 15 si no se indica nada.
-pedanticAdemás, advierte de cada extensión usada, aunque esté permitida.
-pedantic-errorsLo mismo, pero como errores.

Una versión más vieja rechaza lo que todavía no existía. El comentario con // llegó en C99, y en modo C89 estricto es un error:

terminal · un comentario de C99, compilado como C89
$ gcc -std=c89 -pedantic -c c89.c
c89.c: In function ‘main’:
c89.c:3:5: error: C++ style comments are not allowed in ISO C90
    3 |     // comentario
      |     ^

Hay una consecuencia de -std=c17 que sorprende al principio y que aparecerá en el curso: en modo estricto, los encabezados de glibc solo declaran las funciones del estándar de C, y ocultan las de POSIX, como fileno o strdup antes de C23. Un programa que las usa recibe implicit declaration of function, aunque haya incluido el encabezado correcto. La solución no es pasarse a gnu17, sino decir qué interfaz se quiere, definiendo una macro de prueba de características antes de cualquier #include, como #define _POSIX_C_SOURCE 200809L. La sección SYNOPSIS del manual indica cuál pide cada función, y el tema 14 lo explica en detalle.

13. Lo que el sistema agrega por su cuenta

La orden de cc1 que mostró gcc -v tenía opciones que nadie escribió: -fstack-protector-strong, -fstack-clash-protection, -fcf-protection, -Wformat-security. Las distribuciones compilan gcc con protecciones encendidas por omisión, para que cualquier programa, incluido el tuyo, sea más difícil de atacar:

terminal · algunas opciones que Ubuntu enciende por omisión
$ gcc -Q --help=common | grep -E 'fstack-protector-strong|fPIE '
  -fPIE                       		[enabled]
  -fstack-protector-strong    		[enabled]
$ gcc -dM -E -O2 - < /dev/null | grep FORTIFY
#define _FORTIFY_SOURCE 3
ProtecciónQué haceDónde se notó
-fPIE y -pieProduce ejecutables independientes de la posición, para que ASLR pueda cargarlos en cualquier dirección.Las direcciones distintas de memoria.c en el tema 1; el tipo DYN de readelf.
-fstack-protector-strongPone un valor testigo en la pila de las funciones con arreglos, y termina el programa si un desborde lo pisa.El mensaje stack smashing detected, que veremos al desbordar arreglos.
_FORTIFY_SOURCECon optimización, cambia funciones como printf o strcpy por versiones que comprueban los tamaños.El __printf_chk de etapas.c con -O2.
-fcf-protectionMarca los destinos válidos de los saltos.La instrucción endbr64 al principio de cada función.

Por eso el código que muestra este tema puede no coincidir exactamente con el que produce gcc en otra distribución, o en otra versión: las opciones por omisión cambian. gcc -v y gcc -Q --help=… permiten averiguar siempre qué se está usando realmente.

14. Las opciones, en una tabla

Para…Opción
Detenerse después del preprocesadorgcc -E archivo.c
Detenerse después del compiladorgcc -S archivo.c → archivo.s
Detenerse después del ensambladorgcc -c archivo.c → archivo.o
Enlazar objetosgcc -o programa a.o b.o
Conservar los archivos intermedios-save-temps
Ver las órdenes que ejecuta gcc-v
Definir una macro-DNOMBRE o -DNOMBRE=valor
Listar las macros predefinidasgcc -dM -E - < /dev/null
Optimizar-O0, -Og, -O2, -O3, -Os
Enlazar estáticamente-static
Advertencias básicas y extra-Wall -Wextra
Más advertencias útiles-Wshadow -Wconversion -Wformat=2 -pedantic
Apagar o endurecer una advertencia-Wno-nombre, -Werror=nombre
Inspeccionar objetos y ejecutablesnm, objdump -d -r, readelf -h -S, size

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué cuatro etapas ejecuta gcc, qué produce cada una, y con qué opción se detiene después de cada una?

El preprocesado convierte el .c en un .i, texto con los #include resueltos (-E); la compilación lo traduce a ensamblador, un .s (-S); el ensamblado produce un archivo objeto .o (-c); y el enlace une los objetos con el código de arranque y las bibliotecas en un ejecutable, que es lo que hace gcc sin ninguna de esas opciones.

2. En etapas.c, ¿por qué el compilador nunca ve la palabra LIMITE?

Porque LIMITE es una macro definida con #define, y el preprocesador, que trabaja antes que el compilador, reemplaza cada aparición por su valor, 10, y elimina la línea del #define. El texto que recibe el compilador ya dice 10 en todos esos lugares.

3. ¿Por qué el main de etapas.c compilado con -O2 no tiene ningún bucle?

Porque todos los datos del bucle, el valor inicial, el límite y la operación, se conocen al compilar. El optimizador calculó el resultado, 55, durante la compilación, y lo escribió directamente en el código como una constante. El efecto observable del programa, lo que imprime, es el mismo.

4. ¿Por qué un programa con comportamiento indefinido puede funcionar con -O0 y fallar con -O2?

Porque el optimizador supone que el programa respeta las reglas del lenguaje y transforma el código en base a esa suposición. Si el programa la viola, por ejemplo desbordando un entero con signo, esas transformaciones pueden cambiar su comportamiento: una comprobación que dependía del desbordamiento puede eliminarse. El error estaba en el programa; la optimización solo lo hizo visible.

5. En objdump -d -r etapas.o, la llamada a printf es e8 00 00 00 00. ¿Por qué ceros, y quién los reemplaza?

Porque al ensamblar no se sabe dónde estará printf: está en otra biblioteca, y la ubicación final del propio código tampoco se conoce. El ensamblador deja el hueco y una reubicación, R_X86_64_PLT32 printf, que indica qué va ahí. El enlazador la procesa y escribe la distancia correcta, que en el ejecutable lleva a printf@plt.

6. ¿Qué aportan Scrt1.o y -lc al enlace, si nunca los mencionamos?

Scrt1.o contiene _start, el punto de entrada del programa, que llama a __libc_start_main y, a través de ella, a main. -lc es la biblioteca de C, donde están printf, __libc_start_main y las demás funciones. gcc los agrega solo, porque todo programa en C los necesita.

7. ¿Por qué el ejecutable estático ocupa 816 KB y el dinámico 16 KB?

Porque el estático lleva copiado todo el código de la biblioteca de C que el programa usa, directa o indirectamente: printf arrastra el formateo de números, los buffers, la configuración regional y más. El dinámico solo lleva el código propio y una referencia a libc.so.6, que el cargador dinámico trae al ejecutar.

8. ¿Cómo puede compilarse principal.c sin sumar.c, y en qué momento se juntan?

A principal.c le basta la declaración de sumar_hasta para comprobar la llamada y generar el código, con un hueco para la dirección de la función. principal.o queda con sumar_hasta como símbolo indefinido. Se juntan en el enlace, cuando el enlazador encuentra la definición en sumar.o y llena el hueco.

9. ¿Cómo se reconoce un error del enlazador, y qué significan undefined reference y multiple definition?

Lo emite ld, termina con ld returned 1 exit status, y cita posiciones en secciones de objetos, como .text+0xe, en lugar de líneas del fuente. undefined reference significa que un símbolo se usa pero ningún objeto ni biblioteca lo define; multiple definition, que lo definen dos o más.

10. ¿Qué hace if (resultado = 0), y qué opción lo advierte?

Asigna 0 a resultado y evalúa ese valor como condición: como 0 es falso, el bloque nunca se ejecuta, y además resultado pierde su valor. Casi siempre se quería escribir ==. -Wall lo advierte con -Wparentheses, y pide paréntesis dobles si la asignación es intencional.

11. ¿Qué diferencia hay entre -std=c17 y -std=gnu17, y qué agrega -pedantic?

-std=c17 es C17 estricto: desactiva las extensiones de GNU que chocan con el estándar, y la biblioteca oculta las funciones que no son de C. -std=gnu17 es C17 con las extensiones. -pedantic advierte de cada extensión que el programa use, aunque esté permitida.

12. ¿Por qué etapas.c compilado con -O2 llama a __printf_chk y no a printf?

Porque Ubuntu define _FORTIFY_SOURCE por omisión cuando se optimiza, y con esa macro los encabezados de glibc reemplazan algunas funciones por versiones que comprueban tamaños y formatos al ejecutar. Es una de las protecciones que la distribución enciende sin que el programador lo pida.

Para practicar

  1. Compila etapas.c con -save-temps, abre etapas.i con less y busca la declaración de printf. ¿En qué línea de etapas.i empieza tu código?
  2. Agrega #ifndef LIMITE … #endif alrededor del #define de etapas.c y compila con -DLIMITE=100. Verifica el resultado y míralo con gcc -E.
  3. Escribe una función que devuelva el mayor de dos enteros, y compara su ensamblador con -O0 y con -O2. Busca en el resultado optimizado una instrucción que empiece con cmov: ¿qué crees que hace?
  4. Mide el tiempo de veloz.c con -O0, -O1, -O2 y -O3, pasándole un argumento más grande, como 3000000000. ¿Cuál gana, y por cuánto?
  5. Compila principal.c y sumar.c por separado, modifica solo sumar.c para que sume de 0 a n de dos en dos, recompila solo ese archivo y vuelve a enlazar. Comprueba con ls -l --time-style=full-iso qué archivos cambiaron.
  6. Con objdump -d -r principal.o, encuentra las reubicaciones de la llamada a sumar_hasta y de la llamada a printf. Luego mira las mismas llamadas en el ejecutable principal.
  7. Compila avisos.c con todas las advertencias de la sección 11 y corrige el programa hasta que compile sin ninguna, también con -Werror.