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.
etapas.c, con el tamaño real de cada archivo. Cada opción de gcc detiene la cadena después de una etapa.| Etapa | Programa | Entrada → salida | Qué hace | Parar aquí |
|---|---|---|---|---|
| Preprocesado | cc1 -E | .c → .i | Resuelve #include y #define, quita los comentarios. Texto a texto. | -E |
| Compilación | cc1 | .i → .s | Analiza el C, lo comprueba, lo optimiza y lo traduce a ensamblador. | -S |
| Ensamblado | as | .s → .o | Convierte el ensamblador en código de máquina, con huecos para lo que no conoce. | -c |
| Enlace | ld | .o → ejecutable | Une 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:
#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:
$ 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:
$ 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:
$ 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:
$ 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:
int cuadrado(int x)
{
return x * x;
}
$ 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ón | Qué hace |
|---|---|
cuadrado: | Una etiqueta: da nombre a la dirección donde empieza la función. |
endbr64 | Marca un destino válido de salto, una protección del procesador contra ciertos ataques. |
pushq %rbpmovq %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), %eax | Lo vuelve a leer, ahora en el registro %eax. |
imull %eax, %eax | Multiplica %eax por sí mismo: x * x. |
popq %rbpret | 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í:
$ 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:
$ 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:
#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;
}
$ 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:
| Nivel | Qué hace | Cuándo usarlo |
|---|---|---|
-O0 | Ninguna 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. |
-Og | Las optimizaciones que no estorban a la depuración. | Desarrollo diario con gdb. |
-O1 | Optimizaciones baratas y seguras. | Poco usado por sí mismo. |
-O2 | Casi todas las optimizaciones que no agrandan el código. | El nivel habitual de los programas que se distribuyen, y de las distribuciones de Linux. |
-O3 | Además, optimizaciones agresivas que pueden agrandar el código. | Cálculo intensivo, midiendo si realmente ayuda. |
-Os | Como -O2, pero prioriza el tamaño. | Sistemas pequeños, con poca memoria. |
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:
$ 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:
$ 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:
$ 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:
| Archivo | Viene de | Aporta |
|---|---|---|
Scrt1.o | glibc | _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.o | glibc | El principio y el final de las funciones de inicialización y finalización, _init y _fini. |
crtbeginS.o, crtendS.o | gcc | El soporte del compilador para constructores, destructores y excepciones. |
/tmp/cc….o | nuestro fuente | main. |
-lc | glibc | La biblioteca de C: printf, __libc_start_main y todo lo demás. |
-lgcc, -lgcc_s | gcc | Funciones 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:
$ 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:
$ 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ón | Contiene | En etapas.c |
|---|---|---|
.text | El código de máquina. | main, y _start y compañía. |
.rodata | Datos constantes, de solo lectura. | La cadena de formato, con su 0a (el \n) y su 00 final. |
.data | Variables globales con valor inicial. | Ninguna nuestra; algunas del código de arranque. |
.bss | Variables 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:
$ 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ño | Pequeño: solo el código propio. | Grande: incluye la parte usada de cada biblioteca. |
| Memoria | Todos los procesos comparten una copia de libc.so.6. | Cada programa carga su propia copia. |
| Actualizaciones | Corregir un error en glibc corrige todos los programas. | Hay que volver a enlazar cada programa. |
| Dependencias | Necesita 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:
int sumar_hasta(int n)
{
int total = 0;
for (int i = 1; i <= n; i++)
total += i;
return total;
}
#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;
}
$ 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:
$ gcc -c nf.c
nf.c:1:10: fatal error: noexiste.h: No such file or directory
1 | #include "noexiste.h"
| ^~~~~~~~~~~~
compilation terminated.
$ 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.
| Etapa | Mensaje típico | Causa habitual |
|---|---|---|
| Preprocesador | fatal error: x.h: No such file or directory | Nombre mal escrito, falta un paquete -dev o una opción -I. |
| Compilador | archivo.c:5:40: error: expected ‘;’… | Sintaxis, tipos, nombres no declarados. |
| Ensamblador | Error: no such instruction | Casi nunca ocurre con código C; solo con ensamblador escrito a mano. |
| Enlazador | undefined reference to `f' | Falta el .o o la biblioteca (-l) que define f, o el nombre está mal escrito. |
| Enlazador | multiple 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:
$ 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:
#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;
}
$ 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ón | Advierte de | Comentario |
|---|---|---|
-Wshadow | Una 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. |
-Wconversion | Conversiones implícitas que pueden perder información. | Muy útil, pero ruidosa en código existente. El tema 7 trata las conversiones. |
-pedantic | Todo 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=2 | Más comprobaciones de las cadenas de formato de printf. | Incluye las cadenas de formato que no son literales, un riesgo de seguridad. |
-Wsign-conversion | Mezclas 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ón | Significa |
|---|---|
-std=c17 | C17 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=gnu17 | C17 con las extensiones de GNU. |
-std=c23, -std=gnu23 | Lo mismo para C23. gnu23 es lo que usa gcc 15 si no se indica nada. |
-pedantic | Además, advierte de cada extensión usada, aunque esté permitida. |
-pedantic-errors | Lo 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:
$ 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:
$ 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ón | Qué hace | Dónde se notó |
|---|---|---|
-fPIE y -pie | Produce 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-strong | Pone 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_SOURCE | Con optimización, cambia funciones como printf o strcpy por versiones que comprueban los tamaños. | El __printf_chk de etapas.c con -O2. |
-fcf-protection | Marca 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 preprocesador | gcc -E archivo.c |
| Detenerse después del compilador | gcc -S archivo.c → archivo.s |
| Detenerse después del ensamblador | gcc -c archivo.c → archivo.o |
| Enlazar objetos | gcc -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 predefinidas | gcc -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 ejecutables | nm, objdump -d -r, readelf -h -S, size |
15. Resumen
gcces un conductor que ejecuta cuatro etapas: preprocesado y compilación (cc1), ensamblado (as) y enlace (ld, a través decollect2).-E,-Sy-cdetienen la cadena después de cada una de las tres primeras etapas;-save-tempsconserva todos los intermedios, y-vmuestra las órdenes.- El preprocesador trabaja con texto: incluye archivos, reemplaza macros y quita comentarios. El compilador nunca ve un
#define. - El compilador traduce C a ensamblador; los argumentos y el resultado viajan en registros según la convención de llamada de la plataforma.
- La optimización puede eliminar código, como un bucle cuyo resultado se conoce al compilar, y hace los programas más rápidos; también revela advertencias y expone el comportamiento indefinido.
- Un archivo objeto es código de máquina con huecos: las reubicaciones indican qué símbolo va en cada uno, y
nmdistingue los definidos (T) de los pendientes (U). - El enlazador agrega el código de arranque (
Scrt1.o, con_start) y la biblioteca de C, asigna las direcciones y llena los huecos. - Un ejecutable organiza su contenido en secciones:
.text,.rodata,.datay.bss. - Por omisión, el enlace es dinámico y las bibliotecas se cargan al ejecutar;
-staticlas copia dentro del ejecutable, que crece de 16 KB a 800 KB. - La compilación separada permite compilar cada
.cpor su cuenta y unirlos al final; una declaración basta para usar una función definida en otro archivo. - Los errores del enlazador no citan líneas:
undefined referencees un símbolo que falta,multiple definitionuno que sobra. -Wallno enciende todas las advertencias;-Wextra,-Wshadow,-Wconversiony-pedanticagregan otras útiles, y cada una se controla por su nombre.-std=c17es C estricto,-std=gnu17admite extensiones; en modo estricto, las funciones de POSIX necesitan una macro como_POSIX_C_SOURCE.- Las distribuciones encienden protecciones por omisión: PIE, protector de pila,
_FORTIFY_SOURCEy-fcf-protection.
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
- Compila
etapas.ccon-save-temps, abreetapas.iconlessy busca la declaración deprintf. ¿En qué línea deetapas.iempieza tu código? - Agrega
#ifndef LIMITE…#endifalrededor del#definedeetapas.cy compila con-DLIMITE=100. Verifica el resultado y míralo congcc -E. - Escribe una función que devuelva el mayor de dos enteros, y compara su ensamblador con
-O0y con-O2. Busca en el resultado optimizado una instrucción que empiece concmov: ¿qué crees que hace? - Mide el tiempo de
veloz.ccon-O0,-O1,-O2y-O3, pasándole un argumento más grande, como3000000000. ¿Cuál gana, y por cuánto? - Compila
principal.cysumar.cpor separado, modifica solosumar.cpara que sume de 0 ande dos en dos, recompila solo ese archivo y vuelve a enlazar. Comprueba conls -l --time-style=full-isoqué archivos cambiaron. - Con
objdump -d -r principal.o, encuentra las reubicaciones de la llamada asumar_hastay de la llamada aprintf. Luego mira las mismas llamadas en el ejecutableprincipal. - Compila
avisos.ccon todas las advertencias de la sección 11 y corrige el programa hasta que compile sin ninguna, también con-Werror.