BitHub

· C · C++ · GCC · Make · CMake

Compilar

Compilar un programa de dos ficheros cabe en un solo comando. Este artículo parte de ahí y deja crecer el proyecto: cada herramienta aparece cuando la anterior deja de bastar, desde gcc hasta CMake y Ninja.

Programa de ejemplo

Un programa pequeño que imprime diez números aleatorios con distribución normal. Está repartido en varios ficheros y usa la biblioteca matemática, así que sirve para ver todo el proceso. Puedes descargar el código de ejemplo para seguir la página.

Fases de la compilación

Compilar es traducir código fuente a un programa que la máquina puede ejecutar. Aunque se le llame así a todo, son cuatro fases seguidas, y el resultado de cada una se puede ver parando gcc con -E, -S o -c.

main.c
calc.cpp
solver.f90
start.s
gcc -E main.i
g++ -E calc.ii
cc1 main.s
cc1plus calc.s
f951 solver.s
as main.o
as calc.o
as solver.o
as start.o
ld

libclibmlibstdc++libgfortran

programa
Cada lenguaje tiene su compilador, pero todos llegan al mismo formato: el .o. Al enlazador no le importa de dónde viene cada uno.

Errores frecuentes por fase

MensajeFaseCausa
fatal error: random.h: No such file or directoryPreprocesadoNo encuentra la cabecera
expected ';' before...CompilaciónError de sintaxis
undefined reference to 'sqrt'EnlazadoFalta una definición (aquí, -lm)
multiple definition of...EnlazadoDos .o definen lo mismo

Compilación manual con GCC

Antes de recurrir a ninguna herramienta conviene construir el programa invocando directamente al compilador. Todo lo que hacen Make, CMake o Ninja se reduce, al final, a estos mismos comandos.

Compilación en un solo comando

gcc main.c random.c -lm -o main

Con dos ficheros no hace falta nada más. El coste aparece al editar: cada cambio, por pequeño que sea, recompila todos los ficheros. En un proyecto de cientos de ficheros, eso significa esperar minutos por una línea.

Compilación y enlazado por separado

Las tres primeras fases trabajan con un solo fichero, así que se pueden guardar sus resultados y reutilizarlos:

gcc -c main.c
gcc -c random.c
gcc main.o random.o -lm -o main

El ejecutable es el mismo, pero ahora, si solo cambias random.c, basta con recompilar ese fichero y volver a enlazar.

-lm va después de los .o porque el enlazador procesa los ficheros en orden y usa de cada biblioteca lo que esté pendiente hasta ese momento. Con gcc -lm main.o random.o falla con undefined reference.

Código fuente Código objeto Enlazado Binario main.c random.c gcc -c gcc -c main.o random.o enlazador libm (-lm) main
Cada .c se compila por separado. El enlazador une los objetos y resuelve las llamadas entre ellos y a las bibliotecas.

Declaración y definición

  • Un .h declara: dice qué existe y con qué firma.
  • Un .c define: dice cómo funciona.
  • Solo un .o puede definir cada función. Si dos .o la definen, el enlazador no sabe cuál usar.
  • El #ifndef RANDOM_H evita que la cabecera se procese dos veces si un mismo fichero la incluye más de una vez.

Automatización con Make

Compilar por separado ahorra tiempo, pero traslada el trabajo a tu memoria: hay que saber qué ficheros han cambiado, qué depende de ellos y en qué orden repetir los comandos. Si olvidas uno, ejecutas código antiguo sin darte cuenta.

Make se encarga de esa contabilidad. Le describes qué depende de qué y, comparando las fechas de modificación, rehace solo lo que ha quedado desactualizado.

Reglas explícitas

Makefile

all: main

main: main.o random.o
	gcc main.o random.o -o main -lm

main.o: main.c random.h
	gcc -c main.c

random.o: random.c
	gcc -c random.c

clean:
	rm -f main *.o

Cada regla tiene tres componentes:

objetivo: dependencias
	receta
  • Objetivo: lo que se quiere construir, normalmente un fichero.
  • Dependencias: lo que necesita. Si alguna es más nueva que el objetivo, este se reconstruye.
  • Receta: los comandos que lo construyen.

all y clean no crean ningún fichero con ese nombre, son nombres para una acción.

Otros elementos del ejemplo:

  • Objetos (main.o, random.o): el resultado de compilar cada .c, código máquina todavía sin enlazar. Son los que permiten recompilar solo lo que cambia.
  • -c: compila sin enlazar y genera el .o.
  • -o main: nombre del fichero de salida.
  • -lm: enlaza con la biblioteca matemática (-l + m = libm).
all main main.o random.o main.c random.h random.c
La flecha significa «depende de». Make recorre el grafo y ejecuta las recetas desde las hojas hasta el objetivo.

Al ejecutar make se construye el primer objetivo, all. El flujo es:

  1. Make ve que all necesita main, y este necesita los dos .o.
  2. Compila cada .o que falte o sea más antiguo que sus dependencias (fases 1 a 3).
  3. Enlaza los objetos con -lm para crear main (fase 4).
  4. Si lo lanzas de nuevo sin cambios, no hace nada. Si editas random.c, solo recompila random.o y vuelve a enlazar. Si editas random.h, recompila main.o.

Tabuladores obligatorios. Las recetas deben empezar por un tabulador. Si el editor lo convierte en espacios, verás el error missing separator.

Variables y reglas de patrón

El Makefile anterior funciona, pero crece con el proyecto: cada .c nuevo exige otra regla casi idéntica, y las opciones del compilador se repiten en todas. Las variables y las reglas de patrón eliminan esa repetición:

Makefile

CC = gcc

# -Wall: Enable ALL warnings
# -Wextra: Enable extra warnings
# -g: Include debugging info
CFLAGS = -Wall -Wextra -g

TARGET = main

SRCS = main.c random.c

OBJS = $(SRCS:.c=.o)

LIBS = -lm

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) $(OBJS) -o $(TARGET) $(LIBS)

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -f $(TARGET) *.o
ElementoQué hace
CC, CFLAGS, LIBSCompilador, opciones y bibliotecas, definidos en un único sitio.
$(SRCS:.c=.o)Sustituye la extensión: se convierte en main.o random.o.
%.o: %.cRegla de patrón: cualquier .o se crea a partir del .c del mismo nombre.
$<La primera dependencia, por ejemplo main.c.
$@El objetivo de la regla, por ejemplo main.o.

Para añadir un fichero nuevo basta con ampliar SRCS. Pero hemos pagado un precio: esta regla de patrón no sabe nada de las cabeceras. Si editas random.h, make responde que no hay nada que hacer y sigues con objetos desactualizados.

Generación de la build con CMake

El problema de random.h tiene arreglo dentro de Make, pero es una muestra de lo que viene cuando el proyecto sigue creciendo:

  • Dependencias. Las de las cabeceras hay que mantenerlas a mano o añadir más reglas para generarlas.
  • Otros sistemas. Las recetas son comandos de shell. gcc, rm o -lm no se traducen por sí solos a Windows con MSVC.
  • Variantes. Tener builds de depuración y de producción exige duplicar opciones o añadir lógica condicional.

Todo apunta a lo mismo: el Makefile se ha vuelto demasiado complejo para escribirlo a mano, así que conviene generarlo. Eso hace CMake. No compila por sí mismo: a partir de una descripción del proyecto, genera los ficheros de Make, Ninja, Visual Studio o Xcode.

Flujo de trabajo

  1. Defines el proyecto en un fichero CMakeLists.txt.
  2. Ejecutas cmake para crear automáticamente el Makefile.
  3. Ejecutas make para construir el proyecto.
  4. Si solo editas el código existente, basta con volver a ejecutar make.
  5. Si añades ficheros .c o .cpp o cambias dependencias, editas también CMakeLists.txt.

CMakeLists.txt

cmake_minimum_required(VERSION 3.10)

project(main)

add_executable(main main.c random.c random.h)

target_link_libraries(main PRIVATE m)
  • project: nombre del proyecto.
  • add_executable: el ejecutable y sus fuentes.
  • target_link_libraries: equivale a -lm.

No se menciona ninguna regla ni ningún comando, y el problema de las cabeceras desaparece: en el Makefile generado, editar random.h recompila main.c.

Builds fuera del código fuente

Con Make todo quedaba junto al código. Con CMake, los ficheros generados se ubican en otro directorio, normalmente build. El código queda limpio y puedes borrar esa carpeta sin perder nada.

mkdir build && cd ./build
cmake ..
make

Variantes de build

La lógica condicional también sobra. Cada variante vive en su propia carpeta, con el mismo CMakeLists.txt:

cmake -DCMAKE_BUILD_TYPE=Debug ..
cmake -DCMAKE_BUILD_TYPE=Release ..

Generadores

Un único CMakeLists.txt sirve para distintas alternativas: Make, Ninja, Visual Studio y Xcode. Se eligen con la opción -G.

Construcción rápida con Ninja

Queda un último límite, y solo se nota a gran escala: el propio Make tarda en decidir qué hacer. En Chromium necesitaba más de 10 segundos para empezar a construir, incluso con poco que recompilar. Ninja lo hacía en menos de uno.

Ninja hace el mismo trabajo que Make: lee las dependencias de un fichero (build.ninja) y orquesta la compilación. Pero está pensado para que lo genere una herramienta como CMake, no para escribirlo a mano. Como ya no escribimos el Makefile, cambiar de uno a otro no toca el proyecto:

mkdir ninja && cd ./ninja
cmake -G Ninja ..
ninja
CMakeLists.txt cmake Generado Construcción Makefile build.ninja make ninja gcc main
CMake describe el proyecto, Make o Ninja deciden qué reconstruir, y GCC compila y enlaza.

Resumen

  • Compilar son cuatro fases: preprocesado, compilación, ensamblado y enlazado. Cada lenguaje llega por su camino al mismo .o, y el enlazador une las piezas.
  • Compilar por separado evita recompilarlo todo, pero obliga a recordar qué ha cambiado.
  • Make lleva esa cuenta: a partir de las dependencias, solo rehace lo desactualizado.
  • CMake evita escribir el Makefile a mano: resuelve las cabeceras, los distintos sistemas y las variantes de build.
  • Ninja sustituye a Make en proyectos grandes, porque arranca mucho más rápido.

Como las dependencias quedan descritas, además se puede compilar en paralelo con make -j o con ninja, que ya lo hace por defecto.

HerramientaPapelCuándo usarla
GCC / ClangCompilan y enlazanSiempre: el resto acaba llamando a un compilador
MakeDecide qué reconstruirProyectos pequeños, o para aprender cómo funciona
CMakeGenera los ficheros de construcciónProyectos que crecen o que deben funcionar en varios sistemas
NinjaDecide qué reconstruir, más rápidoProyectos grandes, generado desde CMake