wandres.dev
HERRERO · Make y el tooling clásico

Dependencias de cabeceras

El agujero clásico de todo Makefile ingenuo: cambiar una cabecera y que nadie recompile. Cómo el propio compilador declara lo que leyó con -MMD y -MP, y cómo cerrar el ciclo para que el grafo deje de mentir.

⏱ 16 min

Tu Makefile declara que un objeto depende de su fuente. La verdad es que depende de su fuente y de la clausura transitiva de todo lo que esa fuente incluye, directa o indirectamente. Esa diferencia entre el grafo declarado y el grafo real es el fallo más caro de los sistemas de construcción escritos a mano, porque no produce un error: produce un binario que enlaza limpio, arranca bien y corrompe memoria por razones que ninguna herramienta de depuración va a explicarte.

🎯 Al terminar esta lección sabrás
  • Reconstruir por qué un objeto obsoleto por una cabecera enlaza sin protestar y falla en ejecución.
  • Generar el grafo real de inclusiones con las banderas de dependencias del compilador.
  • Integrar los ficheros de dependencias en el Makefile de forma que se autoactualicen.
  • Explicar qué resuelve -MP y qué límites conserva el método incluso bien aplicado.

El fallo que no avisa

El escenario es siempre el mismo. Una cabecera declara una estructura; dos unidades de traducción la incluyen. Se modifica la estructura y solo una de las dos unidades se recompila, porque el Makefile no sabía que la otra dependía de esa cabecera.

struct Config {
    int  puerto;
    char nombre[32];   /* ayer eran 16 */
};

El objeto que no se recompiló conserva el sizeof antiguo, los desplazamientos antiguos de cada campo y el código de acceso antiguo. El enlazador acepta la unión sin una sola queja, porque los tipos de C no viajan en la tabla de símbolos: solo viajan nombres. El programa arranca, escribe treinta y dos bytes donde el otro módulo reservó dieciséis y corrompe lo que hubiera al lado.

Este es el modo de fallo canónico de la compilación separada, y tiene tres agravantes. Es silencioso, porque no hay diagnóstico en ninguna fase. Es no determinista, porque el efecto depende de qué haya en memoria contigua. Y es no reproducible, porque un make clean lo hace desaparecer, con lo que el desarrollador concluye que fue cosa del compilador y sigue adelante.

⚠️
Por qué el reflejo de make clean es un mal hábito

Reconstruir desde cero funciona, y por eso engancha. Pero cada make clean es la confesión de que no confías en tu grafo de dependencias, y el coste se paga en cada iteración durante toda la vida del proyecto. Un grafo correcto convierte una espera de minutos en una de segundos; la reconstrucción total debería ser un evento raro, no una costumbre.

El compilador ya lo sabe

El preprocesador conoce con exactitud la lista de ficheros que abrió al procesar una unidad de traducción, porque los abrió él. Basta con pedirle que la escriba. La bandera -MMD hace exactamente eso durante la compilación real, sin coste apreciable y sin un segundo paso.

gcc -std=c23 -MMD -MP -c src/main.c -o build/main.o
cat build/main.d
build/main.o: src/main.c src/config.h src/parser.h src/util.h

src/config.h:
src/parser.h:
src/util.h:

Lo generado es un fragmento de Makefile perfectamente válido: una regla sin receta cuya única función es añadir aristas al grafo. Make combina las reglas que comparten objetivo, así que estos prerrequisitos se suman a los que ya declaraste.

Las banderas de la familia forman una pequeña taxonomía que conviene tener clara.

Bandera Efecto
-M escribe las dependencias en la salida estándar y no compila
-MM igual, pero omite las cabeceras del sistema
-MD compila y además escribe el fichero de dependencias
-MMD compila y escribe las dependencias sin las del sistema
-MP añade un objetivo vacío por cada cabecera
-MF fija el nombre del fichero de salida
-MT fija el nombre del objetivo dentro del fichero generado

La elección estándar es -MMD -MP. Se omiten las cabeceras del sistema porque cambian solo al actualizar el sistema operativo, momento en el que reconstruir todo es lo correcto de todos modos, y porque incluirlas multiplica el tamaño de los ficheros generados.

Hay dos banderas vecinas que no participan en el grafo pero conviene conocer. La primera es -H, que imprime el árbol de inclusiones con la profundidad marcada y sirve para diagnosticar por qué una unidad tarda diez segundos en preprocesarse. La segunda es -MJ, que emite un fragmento de base de datos de compilación, el fichero que los servidores de lenguaje leen para saber con qué banderas se compila cada unidad.

gcc -std=c23 -H -c src/main.c -o /dev/null 2>&1 | head -30
gcc -std=c23 -MJ build/main.json -c src/main.c -o build/main.o

El coste real de una cabecera no es el fichero que ves sino su clausura. Incluir una cabecera de conveniencia que arrastra otras treinta multiplica el trabajo del preprocesador en cada unidad y, sobre todo, amplía el conjunto de objetos que una sola edición invalida. Un grafo de dependencias correcto hace visible ese coste, que antes estaba oculto precisamente porque nadie recompilaba lo que debía.

-MT importa cuando el objeto no vive junto a la fuente: sin él, el objetivo generado sería el nombre simple del objeto y no la ruta dentro del directorio de construcción, con lo que la regla no casaría con nada y el fichero sería inerte.

Cerrar el ciclo

Falta el último paso: que Make lea esos ficheros. Se hace con -include, la variante que no falla si el fichero todavía no existe, algo imprescindible en la primera compilación.

CC     := gcc
CFLAGS := -std=c23 -Wall -Wextra -Og -g3
DEPFLAGS = -MMD -MP -MF build/$*.d -MT $@

SRC := $(shell find src -name '*.c')
OBJ := $(patsubst src/%.c,build/%.o,$(SRC))
DEP := $(OBJ:.o=.d)

build/prog: $(OBJ)
	@mkdir -p $(@D)
	$(CC) $(CFLAGS) $^ -o $@

build/%.o: src/%.c
	@mkdir -p $(@D)
	$(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@

-include $(DEP)

El mecanismo tiene una elegancia recursiva que merece la pena entender. Make lee los ficheros incluidos al arrancar y comprueba si él mismo sabe reconstruirlos. Si un fichero de dependencias falta, se genera al compilar el objeto correspondiente; si existe pero está desfasado, se regenera en la misma compilación que actualiza el objeto. El grafo se mantiene a sí mismo.

flowchart TD
A[make arranca] --> B[lee los ficheros .d existentes]
B --> C[anade aristas al grafo]
C --> D[compara marcas de tiempo]
D --> E[compila lo obsoleto]
E --> F[cada compilacion reescribe su .d]
F -.siguiente invocacion.-> B
style C fill:#89b4fa,color:#11111b
style F fill:#a6e3a1,color:#11111b

-MP resuelve un problema concreto y muy real. Si borras o renombras una cabecera, el fichero de dependencias viejo sigue nombrándola como prerrequisito y Make aborta con no rule to make target, dejando el proyecto en un estado en el que solo make clean avanza. Los objetivos vacíos que -MP genera hacen que una cabecera desaparecida se considere trivialmente construible, con lo que Make recompila el objeto, se regenera el fichero de dependencias correcto y el problema se disuelve solo.

Lo que el método no cubre

🏗️

Cambios en las banderas

Modificar CFLAGS no altera ninguna marca de tiempo. Los objetos quedan compilados con banderas mezcladas y Make no lo detecta.

⚙️

Ficheros generados

Una cabecera producida por un generador debe existir antes de la primera compilación; los .d llegan tarde para eso.

🔗

Rutas de búsqueda

Cambiar el orden de los directorios de inclusión puede hacer que una unidad lea otra cabecera distinta con el mismo nombre.

El primero se resuelve con la misma idea llevada un paso más allá: escribir las banderas en un fichero, hacer que ese fichero solo se reescriba cuando su contenido cambie y declararlo prerrequisito de todos los objetos. El segundo exige prerrequisitos de orden, la construcción que obliga a producir algo antes que otra cosa sin forzar reconstrucción cuando cambia. El tercero no tiene solución dentro de Make y es una de las razones de peso para usar un generador de nivel superior.

El grafo correcto no se escribe: se deriva de quien lo conoce

Hay una lección de diseño escondida bajo la sintaxis, y es la más transferible de todo el nivel. El problema de las cabeceras no se resuelve escribiendo mejor el Makefile, se resuelve negándose a escribirlo: el único componente que sabe con certeza qué ficheros se leyeron es el preprocesador, así que la respuesta correcta es hacer que el compilador emita el grafo como efecto secundario de su trabajo normal y que el sistema de construcción lo consuma. Mantener esa información a mano no es difícil, es imposible, porque la clausura transitiva de inclusiones cambia con cada edición y ningún humano la revisa. El patrón —derivar los metadatos de la herramienta que ya los posee en lugar de duplicarlos en un fichero de configuración— es el mismo que separa un fichero de bloqueo de dependencias generado de una lista de versiones escrita a mano, o un esquema derivado del código de un esquema mantenido en paralelo. Toda duplicación de información entre dos artefactos que deben coincidir es una desincronización esperando su turno, y aquí el turno llega en forma de un binario que corrompe memoria sin decir nada. La versión fuerte del principio es incómoda: si tu build necesita que alguien recuerde actualizar algo, tu build ya está roto y solo falta que se note.

⚔️ Haz que el grafo deje de mentir
  1. Provoca el fallo a propósito: cambia el tamaño de un campo en una cabecera compartida, recompila solo una unidad y observa el comportamiento del binario resultante.
  2. Compila con -MMD -MP y examina el .d generado. Cuenta cuántas cabeceras aparecen que no habías declarado nunca.
  3. Integra -include en tu Makefile y verifica que tocar una cabecera profunda recompila exactamente las unidades correctas.
  4. Borra una cabecera sin tocar nada más y comprueba la diferencia entre haber usado -MP y no haberlo usado.
  5. Diseña un mecanismo que invalide todos los objetos cuando cambien las CFLAGS, sin invalidarlos cuando no cambien.