Make / CMake / Meson Build Systems

On this page

Build systems address the question: "To produce this artifact, which inputs must be prepared first, and what changes require redoing which steps?" Writing a command that compiles is just the starting point; whether incremental builds, parallelism, and dependency propagation are correct depends on whether the dependency graph is complete.

Make executes tasks based on rules and file states. CMake and Meson typically configure first, generate backend build descriptions, and then hand them over to executors like Ninja or Make. You cannot directly compare the configuration time of CMake with the compilation time of Ninja to conclude that one language is inherently slower to build.

The Same Minimal Project

The following three approaches build the same set of files, with source code located in the project root:

/* lib.h */
#ifndef DEMO_LIB_H
#define DEMO_LIB_H
int twice(int x);
#endif
/* lib.c */
#include "lib.h"
int twice(int x) { return x * 2; }
/* main.c */
#include "lib.h"
int main(void) { return twice(21) == 42 ? 0 : 1; }
Incremental build dependency graph: changes to the common header affect two compilations lib.h main.c lib.c main.o lib.o libdemo.a prog main.o and lib.o can be built in parallel; linking requires both main.o and the updated static library to be complete.

Modifying lib.c should rebuild the library and relink; modifying the common header lib.h should cause both consumers to recompile. Building with no changes should result in no unnecessary compilation actions. Missing this header dependency can lead to mixing old declarations with the new library, a problem that "full build success" alone cannot detect.

GNU Make: Rules, Variables, and Automatic Dependencies

Save as Makefile. The first character of the recipe lines below is an actual Tab, not a regular space:

.DEFAULT_GOAL := all
CC ?= cc
AR ?= ar
CFLAGS ?= -O2 -g -Wall -Wextra
CPPFLAGS ?=
LDFLAGS ?=
LDLIBS ?=
OBJS := main.o lib.o

.PHONY: all clean
all: prog

prog: main.o libdemo.a
	$(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS)

libdemo.a: lib.o
	$(AR) rcs $@ $^

%.o: %.c Makefile
	$(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -MF $(@:.o=.d) -MT $@ -c $< -o $@

-include $(OBJS:.o=.d)

clean:
	$(RM) $(OBJS) $(OBJS:.o=.d) libdemo.a prog

Here, $@ is the target, $< is the first prerequisite, and $^ is the list of unique prerequisites. Placing library arguments after the objects that reference them is the correct order under traditional static library extraction rules.

CPPFLAGS holds preprocessor arguments, CFLAGS holds C compiler arguments, LDFLAGS holds linker options, and LDLIBS holds library dependencies like -lm. Categorizing them allows callers to override one class without affecting others.

Compile time also generates .d files for each object. -MMD usually omits system header dependencies, -MP generates auxiliary empty rules for headers, and -include allows the first build when .d files do not yet exist. Make can only know about indirect include relationships after reading the generated files. GNU Make Automatic Prerequisites

make -j4
./prog
make

Make primarily relies on timestamps and dependencies to determine if updates are needed, and by default does not record "command line arguments changed." In this example, treating the Makefile itself as an object dependency means editing the file triggers recompilation, but changing CFLAGS via command line will not automatically invalidate old objects; real projects can use configuration signatures, separate build directories, or clean up when switching configurations.

Generating header files must also explicitly declare the generation action; without .d files before the first build, relying solely on automatic scanning cannot guarantee the generator runs first. Output directories are generally suitable as order-only prerequisites to avoid triggering unnecessary recompilations due to directory timestamp changes.

CMake: Targets Carry Usage Requirements

Save as CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)
project(demo VERSION 1.0 LANGUAGES C)

add_library(demo STATIC lib.c)
target_include_directories(demo PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
target_compile_features(demo PUBLIC c_std_11)

add_executable(prog main.c)
target_link_libraries(prog PRIVATE demo)
cmake -S . -B build-cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build build-cmake --parallel 4
./build-cmake/prog

PUBLIC means that both building demo itself and targets using demo need this property; PRIVATE serves only the target itself, and INTERFACE is passed only to users. If a header has a public dependency on a type, compile definition, or language feature, you must consider whether the corresponding usage requirement should propagate. CMake Build System

"Implementation dependencies not public" for static libraries does not mean the final link never needs them; the build system handles link dependency propagation according to target types. Use target relationships; do not copy include paths, library names, and global flags everywhere.

CMAKE_BUILD_TYPE applies to single-config generators in examples. Multi-config generators usually select via --config Release at build time; do not mix the two models. The compile database records actual compilation commands, suitable for tools like clangd; see LSP and Language Servers.

Meson: Explicitly Declare Targets and Dependencies

Save as meson.build:

project('demo', 'c', version: '1.0',
        default_options: ['c_std=c11', 'warning_level=2'])

inc = include_directories('.')
lib = static_library('demo', 'lib.c', include_directories: inc)
demo_dep = declare_dependency(link_with: lib, include_directories: inc)
executable('prog', 'main.c', dependencies: demo_dep)
meson setup build-meson --buildtype=release
meson compile -C build-meson -j 4
./build-meson/prog

Use a dependency object to pass both link libraries and include requirements together, avoiding consumers missing one. Optimization and debugging strategies are usually expressed via buildtype or options; there is no need to hardcode -O2 into the c_args of every target, especially when considering different compilers. Meson Targets, Meson Dependencies

A directory that has already been setup can be reconfigured; independent build directories help avoid cache mixing when changing critical environments like the compiler or cross-configuration. Whether configuration automatically downloads certain fallback dependencies is determined by the project and options; do not universally understand dependency() as a package manager install command.

Adding Generated Header Files to Verify the Complete Build Graph

Write factor.txt as the integer 2, and add the following generate.py. It only accepts 1–100 to avoid injecting arbitrary input directly into C source code; output is placed at the location specified by the command.

from pathlib import Path
import sys
source, output = map(Path, sys.argv[1:])
factor = int(source.read_text().strip())
if not 1 <= factor <= 100:
    raise SystemExit("factor must be in 1..100")
output.write_text(f"#define DEMO_FACTOR {factor}\n")

Replace lib.c and main.c with the following content respectively, keeping lib.h with the previous declaration. twice retains the original function name, but the actual multiplier is now controlled by configuration; the example only calls with 21, so the range will not cause int overflow.

/* lib.c */
#include "lib.h"
#include "generated.h"
int twice(int x) { return x * DEMO_FACTOR; }
/* main.c */
#include "lib.h"
#include "generated.h"
int main(void) { return twice(21) == 21 * DEMO_FACTOR ? 0 : 1; }
Generated header files are compilation inputs; dependency edges must exist even for the first build factor.txt + generate.py Data changes or generator changes generated.h First produced by a unique generation rule Compile lib.c Reads DEMO_FACTOR Compile main.c Also reads DEMO_FACTOR Compiler-generated .d files occur at compile time; missing headers cannot be generated in advance relying on non-existent .d files.

Make Explicitly Declares First-Build Dependencies

Append the following rules to the previous Makefile, and add generated.h to the clean recipe's deletion list:

PYTHON ?= python3
main.o lib.o: generated.h

generated.h: factor.txt generate.py
	$(PYTHON) generate.py factor.txt $@

Both objects must depend on generated.h; you cannot just let the final prog depend on it, otherwise parallel builds might start compiling source code missing the header. Here, the generated header is a normal prerequisite: it should trigger recompilation of consumers when it changes. If mistakenly written as order-only, it only guarantees sequence, not the requirement to recompile due to content changes.

After the first successful compilation, .d files will further record actual include relationships, but the generation rules for the first build still require manual edge creation. When generating multiple outputs, ensure they belong to the same correct rule to avoid multiple tasks writing to the same file simultaneously.

CMake Uses a Unique Generation Target to Connect Multiple Consumers

Add the following snippet to the existing CMakeLists.txt, placed after demo and prog are defined:

find_package(Python3 REQUIRED COMPONENTS Interpreter)
set(generated_header "${CMAKE_CURRENT_BINARY_DIR}/generated.h")
add_custom_command(
  OUTPUT "${generated_header}"
  COMMAND "${Python3_EXECUTABLE}"
          "${CMAKE_CURRENT_SOURCE_DIR}/generate.py"
          "${CMAKE_CURRENT_SOURCE_DIR}/factor.txt"
          "${generated_header}"
  DEPENDS generate.py factor.txt
  VERBATIM)
add_custom_target(generate_header DEPENDS "${generated_header}")
add_dependencies(demo generate_header)
add_dependencies(prog generate_header)
target_include_directories(demo PUBLIC "${CMAKE_CURRENT_BINARY_DIR}")

Custom commands have file outputs; the common generation target ensures both consumers compile after generation is complete; fine-grained recompilation after header content changes is handled by compile dependency scanning. VERBATIM helps correctly pass path arguments, avoiding manual shell quote concatenation. If the generator also produces necessary side effects, declare them using BYPRODUCTS in applicable versions; do not hide inputs needed by Ninja inside the command. CMake Custom Output

Meson Places the Generation Target in the Consumer's Source List

This step replaces the original meson.build with:

project('demo', 'c', version: '1.0',
        default_options: ['c_std=c11', 'warning_level=2'])
python = import('python').find_installation('python3')
header = custom_target('generate_header',
  input: 'factor.txt', output: 'generated.h',
  command: [python, files('generate.py'), '@INPUT@', '@OUTPUT@'])
inc = include_directories('.')
lib = static_library('demo', ['lib.c', header], include_directories: inc)
demo_dep = declare_dependency(link_with: lib, include_directories: inc)
executable('prog', ['main.c', header], dependencies: demo_dep)

Here, include_directories('.') includes the corresponding source and build directories; header in the consumer's source list expresses generation order. generate.py participates in the command as a file object, factor.txt is an explicit input, and changes to both enter the build judgment. You cannot just write an include path pointing to a future generation directory and expect the build system to infer who the producer is. Meson Generating Sources

Checking Three Build Approaches with Four Changes

It is recommended to use separate source copies for the three experiments: Make generates generated.h in the source directory, while CMake/Meson generate in their respective build directories. If using the same source directory, C's quoted includes might find Make's leftover headers first, causing the other build to use the wrong generated result.

ChangeExpected ActionProblem Discovered
Fresh directory, parallel buildGenerate first, then compile two objects, then linkMissing first-time dependencies
Build again with no modificationsNo generation or compilation actionsUnconditional execution leads to wasted work
Change factor.txt from 2 to 3Regenerate, recompile consumers, relinkExpressing sequence only without expressing change
Delete generated.hAutomatically rebuild output and restore successful buildOutput not registered or dependency cache hides issues

The generator in this example updates timestamps every time it actually executes, facilitating understanding of propagation. If adopting "do not rewrite if content is unchanged" to reduce subsequent compilations, Make must also handle the case where inputs are newer than outputs to avoid always re-running the generator; you cannot assume incremental behavior is complete just by modifying the Python write logic; you must design the relationship between stamp and real output according to project needs.

With One Edge Missing, Which Artifacts Quietly Become Stale?

Changes to the generated header content need to invalidate both main.o and lib.o. Removing the edge to main.o, even if linking is still triggered by another path, main.o might continue using old macro values, forming a mixed-version artifact. Fresh builds also need these edges to ensure generation precedes compilation; incremental correctness and first-time parallel correctness should be verified separately.

Preparing the visual
With One Edge Missing, Which Artifacts Quietly Become Stale?

Build graphs need to propagate from changed inputs to all consumers. Letting only the final program depend on the generated header cannot replace the compilation dependencies of object files.

pkg-config: Querying Build Metadata for Installed Libraries

pkg-config --modversion zlib
pkg-config --cflags --libs zlib
pkg-config --static --libs zlib

.pc files tell users about header locations, libraries, and related dependencies; they do not install libraries, nor do they guarantee the runtime loader can find them. Paths vary by distribution, architecture, and install prefix; do not hardcode them to /usr/lib64/pkgconfig.

A sample demo.pc content is as follows, should be saved as a text file, not executed as a shell script:

prefix=/usr/local
libdir=${prefix}/lib
includedir=${prefix}/include

Name: demo
Description: Small example library
Version: 1.0
Libs: -L${libdir} -ldemo
Cflags: -I${includedir}

Distinguish between public dependencies and private dependencies needed for static linking; fill in Requires, Requires.private, and Libs.private as appropriate. --static queries more complete dependency information and does not alone force the linker to select all static libraries. pkg-config Author Guide

In CMake, prefer consuming imported targets to avoid copying ${ZLIB_LIBRARIES} while missing directories or link options:

find_package(PkgConfig REQUIRED)
pkg_check_modules(ZLIB REQUIRED IMPORTED_TARGET zlib)
target_link_libraries(prog PRIVATE PkgConfig::ZLIB)

Adding this to the previous CMakeLists.txt attaches dependencies to prog. If an applicable find_package(ZLIB) imported target already exists, you can use it directly without forcing it through pkg-config. FindPkgConfig

Cross-Compilation and Reproducible Builds

Code generators run during the build belong to the build machine; the final program belongs to the target machine. Cross-compilation requires specifying the compiler, sysroot, tools, and target dependency search paths separately; do not mistakenly use host machine .pc results and expect the linker to automatically correct the architecture.

CMake toolchain files and Meson cross files are used to describe these environments. More complete reproducibility also involves dependency versions, generators, environment variables, path embedding, and time information; using a specific build system does not automatically guarantee this.

How to Compare and Verify

Focus AreaBehavior to Verify
CorrectnessClean build, header file changes, generated file dependencies are all correct
Incremental EfficiencyNo recompilation with no changes, local changes only update necessary targets
ParallelismMulti-task builds do not exhibit sporadic missing files or race conditions
Configuration SwitchingDebug, Release, different compilers do not mix old artifacts
IntegrationCompile database, tests, installation, and package metadata meet project needs

Build speed mainly depends on real compilation work, dependency graph granularity, caching, and executors. Make also has a dependency graph, and CMake can generate Ninja; you cannot replace measurement with "imperative is slow, declarative is fast."

Keywords: Make, CMake, Meson, Ninja, dependency graph, depfile, target, usage requirements, pkg-config, cross compilation, incremental build