El modelo de Make
Objetivo, prerrequisitos y receta: la tríada que define una regla. Cómo Make declara un grafo dirigido acíclico, lo recorre en profundidad y decide con marcas de tiempo qué reconstruir y qué dejar en paz.
Make no compila nada: decide. Lo que escribes no es un guion de comandos sino la declaración de un grafo dirigido acíclico de artefactos, y lo que Make hace con él es un recorrido en profundidad que compara marcas de tiempo y ejecuta solo las recetas cuya salida quedó atrás respecto a sus entradas. Confundir ese modelo declarativo con un script imperativo es el origen de la inmensa mayoría de los Makefiles rotos que verás en tu vida profesional.
- Descomponer una regla en objetivo, prerrequisitos y receta, y saber qué papel juega cada parte.
- Leer un Makefile como el grafo que declara, no como la secuencia en que está escrito.
- Explicar el criterio de obsolescencia por marca de tiempo y sus tres modos de fallo.
- Distinguir objetivos reales de objetivos falsos y entender por qué
.PHONYno es cosmética.
La tríada elemental
Una regla de Make tiene exactamente tres partes. El objetivo es el nombre de lo que se quiere obtener, y por defecto se interpreta como una ruta de fichero. Los prerrequisitos son los nombres de los que ese objetivo depende. La receta es la lista de líneas de shell que saben producir el objetivo a partir de los prerrequisitos.
prog: main.o parser.o
gcc main.o parser.o -o prog
main.o: main.c parser.h
gcc -std=c23 -Wall -Wextra -c main.c -o main.o
parser.o: parser.c parser.h
gcc -std=c23 -Wall -Wextra -c parser.c -o parser.o
La receta se ejecuta en una shell nueva por cada línea. Eso tiene una consecuencia que sorprende siempre: un cd en la primera línea no sobrevive a la segunda, porque son procesos distintos. Si necesitas continuidad, encadena con punto y coma y barra invertida, o declara .ONESHELL para que Make pase la receta entera a una sola shell.
Make no valida que la receta produzca realmente el objetivo. Si tu comando escribe en otro sitio, Make quedará convencido de que ese objetivo sigue sin existir y lo reconstruirá en cada invocación, para siempre, sin decirte por qué.
Las líneas de receta deben comenzar con un carácter de tabulación real. Un editor que expande tabuladores a espacios produce el mensaje missing separator, que no dice nada útil. La variable .RECIPEPREFIX permite cambiar ese carácter, pero casi nadie lo hace: el ecosistema entero asume tabulador.
El grafo, no el guion
El orden en que escribes las reglas es irrelevante salvo por un detalle: el primer objetivo del fichero es el objetivo por defecto. Todo lo demás es un grafo, y Make lo recorre en profundidad desde el objetivo pedido, resolviendo cada prerrequisito antes de considerar la receta del padre.
flowchart BT MC[main.c] --> MO[main.o] PH[parser.h] --> MO PC[parser.c] --> PO[parser.o] PH --> PO MO --> PROG[prog] PO --> PROG style PROG fill:#a6e3a1,color:#11111b style PH fill:#f9e2af,color:#11111b
Ese grafo debe ser acíclico. Si a depende de b y b depende de a, Make detecta la circularidad, la denuncia y descarta una de las dos aristas para poder continuar, con resultados impredecibles.
Un nodo sin regla propia y sin fichero correspondiente es un error fatal: no rule to make target. Un nodo sin regla propia pero con fichero existente es una hoja del grafo, y Make se limita a leer su marca de tiempo. Las hojas típicas son tus ficheros fuente y tus cabeceras.
Puedes pedirle a Make que te enseñe el grafo sin ejecutar nada, lo cual es la primera herramienta de diagnóstico que deberías aprender:
make -n prog # imprime las recetas sin ejecutarlas
make -d prog | less # traza completa de por que decide reconstruir
make -p | less # volca la base de datos entera de reglas y variables
Marcas de tiempo: el criterio y sus grietas
El algoritmo de decisión es de una simplicidad brutal. Para cada objetivo, Make resuelve recursivamente sus prerrequisitos y después compara: si el objetivo no existe, se reconstruye; si algún prerrequisito tiene una marca de tiempo de modificación posterior a la del objetivo, se reconstruye; en caso contrario, se declara actualizado y su receta no se ejecuta.
No hay hashes de contenido, no hay registro de qué comando produjo qué, no hay memoria entre invocaciones. Solo el campo mtime que el sistema de ficheros ya guardaba. De ahí salen tres modos de fallo que conviene conocer por su nombre.
Primero, el reloj miente. En sistemas de ficheros con granularidad de un segundo, dos operaciones dentro del mismo segundo son indistinguibles y Make puede no ver un cambio real. Peor: un fichero copiado desde otra máquina con el reloj adelantado queda permanentemente en el futuro y nunca se considera obsoleto.
Segundo, el contenido no cuenta. Un touch sobre una fuente que no ha cambiado provoca una recompilación completa e inútil; guardar un fichero sin modificarlo hace lo mismo. Sistemas modernos como Bazel o Nix sustituyen la marca de tiempo por un hash del contenido precisamente para eliminar esta clase de trabajo desperdiciado.
Tercero, el grafo declarado puede ser incompleto. Este es el grave. Make solo conoce las aristas que le has escrito, y una cabecera olvidada en la lista de prerrequisitos produce objetos obsoletos que enlazan sin protestar y fallan de formas absurdas en ejecución. La lección siguiente está dedicada entera a este problema.
touch parser.h && make # reconstruye todo lo que declare depender de ella
make -t # marca los objetivos como actualizados sin construir
make -B # fuerza la reconstruccion ignorando marcas de tiempo
make -j"$(nproc)" # recorre ramas independientes del grafo en paralelo
Con -j, Make ejecuta a la vez cualquier conjunto de nodos sin dependencias mutuas. Si tu build se rompe con -j8 y funciona con -j1, no es un fallo del paralelismo: es que el grafo que declaraste miente sobre alguna dependencia y el orden secuencial la estaba ocultando por casualidad.
Objetivos que no son ficheros
Objetivos como clean, all, test o install no producen ningún fichero con ese nombre. Como Make interpreta todo objetivo como una ruta, basta con que alguien cree un fichero llamado clean en el directorio para que la regla deje de ejecutarse: Make verá un fichero sin prerrequisitos, lo considerará permanentemente actualizado y no hará nada.
.PHONY: all clean test install
all: prog
clean:
rm -f *.o *.d prog
test: prog
./prog --autotest
Objetivo real
Corresponde a un fichero. Su marca de tiempo decide si hay que reconstruirlo. Es el caso normal.
Objetivo falso
No corresponde a ningún fichero. Marcado con .PHONY, se ejecuta siempre que alguien lo pida.
Objetivo centinela
Un fichero vacío que representa un paso costoso sin salida única. Permite mentirle al grafo con honestidad.
El objetivo centinela merece una nota. Cuando un comando genera muchos ficheros a la vez y no puedes nombrarlos todos, se declara un fichero testigo como objetivo, se crea con touch al final de la receta y las demás reglas dependen de él. Es la manera canónica de representar en un grafo de nodos y aristas un proceso que no encaja en ese molde.
Existe además una cuarta clase de arista. Una barra vertical dentro de la lista de prerrequisitos separa los normales de los prerrequisitos de orden: cosas que deben existir antes de ejecutar la receta, pero cuya marca de tiempo no debe provocar reconstrucción.
build/%.o: src/%.c | build
$(CC) $(CFLAGS) -c $< -o $@
build:
mkdir -p $@
Sin esa barra, el directorio build sería un prerrequisito ordinario, y como el directorio actualiza su marca de tiempo cada vez que se escribe un fichero dentro, el proyecto entero se recompilaría en cada invocación. Es el ejemplo canónico de una dependencia real en el orden y falsa en el contenido.
Queda un último patrón que conviene reconocer para saber evitarlo: el Make recursivo, donde una receta invoca a $(MAKE) sobre un subdirectorio. Parece modularidad y no lo es. Cada invocación construye su propio grafo parcial y ninguna ve el conjunto, con lo que las dependencias entre subdirectorios no se pueden expresar, el paralelismo se degrada a los límites de cada subárbol y aparecen reconstrucciones de más y de menos a la vez. La alternativa correcta es un único grafo global alimentado por fragmentos incluidos desde cada subdirectorio, que es lo que hacen el kernel de Linux y todos los generadores modernos.
La tentación es estudiar Make como si fuera una utilidad de compilación con una sintaxis rara. No lo es. Make es la implementación mínima de una idea general: dado un conjunto de artefactos, un conjunto de reglas de producción y un criterio de invalidación, calcula el subconjunto mínimo de trabajo necesario para llevar el sistema a un estado consistente. Que ese trabajo sea invocar a gcc es incidental; Stuart Feldman lo escribió en 1976 para un problema concreto y acabó formalizando el patrón que hoy sostiene la industria entera. La misma tríada —artefacto, dependencias, regla— reaparece literalmente en el grafo de tareas de un orquestador de datos, en las capas cacheadas de una imagen de contenedor, en el árbol de módulos de un empaquetador de JavaScript, en la reactividad de una interfaz que recalcula solo los nodos afectados. Lo único que cambia entre todos ellos es el criterio de invalidación: marca de tiempo en Make, hash de contenido en Bazel y Nix, versión de contenido en un empaquetador, marcado sucio en la interfaz. Cuando esa abstracción te resulta obvia, dejas de aprender herramientas de construcción y empiezas a reconocerlas.
- Escribe el Makefile explícito de tres fuentes y una cabecera. Dibuja a mano el grafo que declara y verifícalo con
make -p. - Ejecuta
make -d progsobre un árbol ya construido y localiza en la traza las líneas donde Make compara marcas de tiempo. - Aplica
toucha una cabecera sin modificar su contenido y comprueba cuántos objetos se recompilan. Justifica el resultado. - Crea un fichero vacío llamado
cleanen el directorio y observa qué ocurre al pedirmake clean. Después arréglalo con.PHONY. - Omite deliberadamente una arista del grafo y encuentra un caso en que
make -j8falle mientrasmake -j1funciona.