Userland toolchains and build systems

Compare compiler drivers and build graphs using GCC, Clang, Meson and Ninja.

13 min readChapter 101 of 168Intermediate → advanced
In this chapter

Before you start#

Review C and object lifetime, ELF and linking, Kbuild, and userspace baselines. The examples below inspect Meson 1.5.0 and Ninja 1.12.1 separately. They are historical teaching snapshots, not a promised compatibility matrix or a statement about the host's installed tools.

Learning goals#

Follow a source file from parsing to a linked executable, distinguish compiler targets from build machines, and explain how Meson describes a Ninja dependency graph. Read a normal and failure path in both tools. Explain why a clean build, an incremental build and a reproducible build answer different questions.

Source walkthrough#

Configuration and incremental build
  1. Configure

    Meson evaluates project and dependency checks.

  2. Generate

    The backend detects Ninja and writes build rules.

  3. Scan

    Ninja recomputes graph dirtiness and validation dependencies.

  4. Execute

    Ready edges compile objects and link outputs.

Source map. A simplified Meson and Ninja source model, not a captured build; declared inputs and versions affect the result.

A compiler driver coordinates several steps, often through separate programs or internal components. A frontend parses source, checks types and language rules, and constructs an abstract syntax tree. It may lower that tree into an intermediate representation suitable for optimization and target-specific code generation. The backend chooses instructions and emits assembly or an object file. An assembler converts assembly into relocatable machine code; a linker combines objects, archives and shared-library references into an executable or shared object. Inlining and optimization can remove the apparent relationship between a source line and a machine instruction. A debug build can retain DWARF information for a debugger, but debug information does not guarantee that every optimized variable still has a recoverable value.

GCC and Clang both accept many familiar command-line options, but their frontends, intermediate representations, tool versions, default linkers, diagnostics and target support are not interchangeable. Clang commonly uses LLVM infrastructure; GCC has its own implementation and intermediate forms. A driver invocation can invoke an assembler and linker from a different package. Record the actual commands and versions rather than recording only the word “GCC” or “LLVM”; GCC documents -v as printing commands used for compilation stages (GCC 14.2 Overall Options). An object file can compile successfully yet fail at link time because a definition, symbol version, library architecture or ABI does not match. A successful link does not prove that the resulting program behaves correctly.

The following exact excerpt is from Meson 1.5.0 mesonbuild/backend/ninjabackend.py, function NinjaBackend.generate; the file carries SPDX Apache-2.0. Surrounding detection and file-writing code is omitted:

python
        if ninja is None:
            raise MesonException('Could not detect Ninja v1.8.2 or newer')
        (self.ninja_command, self.ninja_version) = ninja

The conditional checks the result of Ninja detection for this backend. If detection failed, generation raises an exception instead of pretending a usable backend exists. On the successful path the detected command and version are assigned to the backend object for subsequent work. The version threshold shown belongs to this Meson source revision; it is not a general statement that every later feature works with every Ninja release. Meson then produces build descriptions using information obtained from its interpreter, project definition, compiler checks and dependency discovery. A failed configuration may leave a build directory containing partial logs or state; inspect the error and remove only a disposable directory rather than treating an incomplete manifest as a successful build.

Ninja 1.12.1 src/build.cc, Builder::AddTarget, is Apache-2.0 licensed. The exact excerpt below omits the rest of the function:

cpp
  std::vector<Node*> validation_nodes;
  if (!scan_.RecomputeDirty(target, &validation_nodes, err))
    return false;

A target is represented by a Node; the scanner recomputes its dirty state and records validation nodes. The caller passes an error string for failure reporting. If scanning fails, this function returns false rather than adding an apparently valid target to the plan. In the following source, the builder examines the input edge and adds work to a plan if outputs are not ready. The Node and Edge objects belong to Ninja's in-memory graph; an application project should not keep a pointer into that process after Ninja exits. This source path is a graph-construction decision, not a measured compilation on the host.

Compiler drivers are separate projects#

In the pinned GCC 14.2 driver source, driver::main coordinates driver methods and the source separately defines command processing and execution. The pinned Clang 18.1.8 driver has Driver::BuildCompilation, BuildActions and ExecuteCompilation. These entry points show a driver assembling and executing work; they are not interchangeable internal APIs and do not prove which assembler or linker a particular invocation selected. Use a verbose driver command for a private test to inspect the actual subprocess arguments. A wrapper, distribution configuration or cross-toolchain prefix may change them.

The source-level names also help isolate a failure: a parser diagnostic occurs before a usable object is produced, whereas an unresolved reference can occur when the linker combines already compiled objects. If a program links but refuses to start because its interpreter is absent, investigate the resulting ELF and target runtime rather than changing source types at random. Keep the generated command, input revision and full error so another person can reproduce the distinction.

Build, host, target and ABI#

The build machine runs Meson, Ninja and build-time tools. In Meson's terminology, the host machine is where the produced application runs; the target machine matters when building a compiler that itself emits code for another machine (Meson cross-compilation). Other compiler documentation sometimes uses “target” for the output architecture, so state which meaning is intended. A cross compiler can run on x86-64 while producing arm64 objects, but the linker, startup files, libc headers and shared objects must match the chosen target and ABI. A sysroot supplies a filesystem-like root for target headers and libraries; accidentally mixing build-host headers with target libraries can succeed surprisingly far before an ABI or link failure. Meson cross files and compiler flags are inputs to the build and should be retained.

Architecture names alone are insufficient. Calling convention, word size, endianness, floating-point ABI, libc, minimum symbol versions and linker settings can differ. The ELF interpreter named by a dynamic executable also has to exist in the target environment. pkg-config or pkgconf reads metadata and prints requested compile and link flags; a successful flag lookup does not verify that a discovered library actually has the expected ABI, source provenance or runtime behavior. Inspect the compiler's reported target and search paths and the produced ELF headers before claiming a cross build works. Never execute an unknown cross-built binary on the host as a verification shortcut.

Where each build step runs
Build machine
Meson, Ninja, the compiler driver and pkg-config run here; their versions belong in the record.
Compiler stages
The frontend parses, the backend emits assembly, and the assembler produces object files.
Link step
The linker combines objects and libraries, resolving symbols and versions for the target ABI.
Target runtime
The interpreter, libc and shared objects must exist there and match the target ABI.
Conceptual model. Teaching model of where build, compile, link and run steps occur, using this chapter's terms; not a specific toolchain's layout.

A compiler driver commonly runs preprocessing and compilation for each translation unit separately. A header change affects a source file only if the build graph knows about that dependency. Compiler-generated depfiles can record the headers actually included under a particular set of macros; a missing or stale depfile can leave a build apparently up to date when it is not. The graph must also include generated headers and their producers. Parallel jobs expose missing dependencies because independent commands can run in an order the author did not anticipate. Repeating a lucky serial build is not evidence that the graph is correct.

Meson configuration and Ninja execution#

Meson reads meson.build, evaluates the project's declarative description and performs configured compiler and dependency checks. It constructs internal objects representing targets, sources, include directories, dependencies and options. The Ninja backend serializes that model into rules and edges in build.ninja; Ninja then schedules the graph and invokes commands. The configuration phase and execution phase have different failure conditions. A missing required dependency should fail configuration rather than generating a misleading executable. A compile command can fail later because a source file, header or compiler changed after configuration.

A Ninja edge associates inputs, a command rule and outputs. A node represents a file or a phony target. An edge is eligible to run when its prerequisites have completed successfully; its outputs may satisfy later edges. Ninja tracks command lines and timestamps and can read dependency files for discovered header dependencies. restat can avoid propagating needless rebuilds when a command runs but the resulting output has not changed in the manner Ninja checks. Dyndep files allow supported dependencies to be added during the build, useful for dependencies not completely known during initial generation. None of these features automatically detects an omitted dependency that was never declared or discovered.

In Ninja 1.12.1 src/graph.cc, Apache-2.0 per its header, DependencyScan::RecomputeDirty walks the queued nodes and returns false at the first scan error (surrounding code omitted):

cpp
bool DependencyScan::RecomputeDirty(Node* initial_node,
                                    std::vector<Node*>* validation_nodes,
                                    string* err) {
  std::vector<Node*> stack;
  std::vector<Node*> new_validation_nodes;

  std::deque<Node*> nodes(1, initial_node);

  // RecomputeNodeDirty might return new validation nodes that need to be
  // checked for dirty state, keep a queue of nodes to visit.
  while (!nodes.empty()) {
    Node* node = nodes.front();
    nodes.pop_front();

    stack.clear();
    new_validation_nodes.clear();

    if (!RecomputeNodeDirty(node, &stack, &new_validation_nodes, err))
      return false;

A failure in RecomputeNodeDirty stops the walk and returns false to the caller, which is the behavior the knowledge check describes. The excerpt does not show the dirty-marking rules, the cycle check or how validation nodes are created, and the Builder::AddTarget excerpt above shows only the caller side.

Consider a small project with main.c, helper.c, helper.h, and a linked executable. At T0 there are no objects. Two compilation edges may run in parallel, producing main.o and helper.o; the link edge must wait for both. At T1, only helper.c changes. A correct graph rebuilds helper.o and then relinks, leaving main.o intact. At T2, helper.h changes and both sources include it. If compiler depfiles recorded the header, both objects become dirty and the executable is relinked. At T3, a nonexistent required dependency is requested during a fresh configuration: the configuration fails, and the prior successful executable, if any, is not evidence that the new configuration succeeded.

Incremental timeline T0 to T3
  1. T0 clean build

    main.c and helper.c compile in parallel; the link edge waits for both objects.

  2. T1 helper.c edited

    helper.o is rebuilt and demo is relinked; main.o should not change.

  3. T2 helper.h edited

    If depfiles recorded the header, both objects are dirty and demo is relinked.

  4. T3 fresh configure fails

    A missing required dependency stops configuration; the earlier executable is not evidence for the new setup.

Conceptual model. Teaching model of the T0 to T3 graph in this chapter; a dependency model, not a captured build log.

This timeline assumes the filesystem timestamps and build tools observe the changes; timestamp granularity, clock changes, generated files, caches and restat behavior can alter actual observations. An incremental build answers which recorded edges Ninja decided to run under its present state. It does not prove the project declared every dependency. Compare with a separate clean build and tests when investigating a suspected dependency omission. Avoid editing the main kernel checkout or the host's installed toolchain for this exercise.

Linking, archives and reproducibility#

A static archive is a collection of object members, commonly indexed so that a linker can extract members satisfying unresolved symbols. Merely putting an object in an archive does not guarantee every member is included. Link order can matter, particularly with static archives and linker options. A shared library is a separately loadable ELF object with its own exported symbols and dependencies. A program built against a shared library must find compatible runtime objects and the intended interpreter. Debug symbols can be in the object, the executable, or a separate file identified by a build ID or distribution convention.

A reproducible build asks whether independently controlled builds from specified inputs produce matching artifacts under a defined comparison. The source revision alone is not enough: include compiler and linker versions, sysroot, configuration, dependencies, build paths, locale, environment, generated files and timestamps. The SOURCE_DATE_EPOCH specification standardizes a source-derived timestamp for build tools to use in place of current time; setting it cannot by itself make arbitrary inputs deterministic (specification). Two different binaries can also be functionally equivalent in a narrow test, while bit-for-bit identical binaries do not prove safety or correctness.

Kbuild is Linux's own build infrastructure and configuration model. Meson and Ninja are common userspace tools, but a kernel source tree is not built by feeding its Kconfig to Meson. Some external projects combine several build tools or generate code before compiling. Treat each build layer as an explicit producer with inputs and outputs, and preserve failures with the exact command and tool versions. For a privileged installation or package transaction, the consequences would differ from this private build; neither is required here.

Practice#

This lab uses the installed Meson, Ninja and C compiler, a private temporary directory and no network or package installation. It builds, runs, changes one source and verifies the incremental result. If any tool is absent, use the source-only timeline after the commands.

sh
work=$(mktemp -d)
mkdir "$work/src"
cat > "$work/src/meson.build" <<'M'
project('small-demo', 'c')
executable('demo', ['main.c', 'helper.c'])
M
cat > "$work/src/main.c" <<'C'
#include <stdio.h>
int value(void);
int main(void) { printf("%d\n", value()); return 0; }
C
cat > "$work/src/helper.c" <<'C'
int value(void) { return 7; }
C
meson setup "$work/build" "$work/src"
meson compile -C "$work/build"
"$work/build/demo"
printf '%s\n' 'int value(void) { return 9; }' > "$work/src/helper.c"
meson compile -C "$work/build"
"$work/build/demo"
mkdir "$work/fail"
printf "%s\n" "project('failure', 'c')" "dependency('missing_demo_dependency_24791', required: true)" > "$work/fail/meson.build"
meson setup "$work/fail-build" "$work/fail" || printf '%s\n' 'missing dependency rejected'
rm -rf -- "$work"

Expected program values are 7 then 9, and the deliberately missing dependency should cause the separate setup to fail. Inspect Ninja's displayed commands: typically the helper object and executable are rebuilt, while main is not, but command formatting and timestamp behavior vary. If the filesystem's timestamp resolution prevents the change from being detected, record the failure and compare a clean build; do not alter host clocks. The missing dependency name is intentionally private to this test and is not a claim about a real package. The final command removes only the directory allocated by mktemp.

Source-only alternative: use the T0–T3 graph above. Write the input and output nodes for both compile edges and the link edge, then identify which edges must rerun after each change. Explain why the failed dependency check does not erase evidence of the earlier success but prevents claiming the new configuration worked. Submit this graph with the versions and an explicit statement that it is a model, not a captured build.

Knowledge check#

  1. Why can a C file compile successfully but fail while linking?
  2. What does RecomputeDirty decide before Ninja adds a target to its plan, and what happens if it returns failure?
  3. Why should changing helper.h rebuild more objects than changing only helper.c in the worked graph?
  4. What does a successful pkg-config lookup establish, and what ABI evidence is still missing?
  5. Why do a pinned source tag and SOURCE_DATE_EPOCH alone not prove a reproducible build?

Answers#

  1. Compilation can produce a relocatable object with unresolved external references. The linker still needs compatible definitions, libraries, symbol versions and target ABI settings; a missing symbol or wrong-architecture library can fail later.
  2. It scans the target and relevant graph state for dirty inputs and validation nodes. On failure, the shown code returns false and supplies an error path instead of adding the target as if its state were known.
  3. Both translation units in the example include the header, so correctly recorded dependencies make both object edges dirty; only the helper source edge is directly affected by editing helper.c. Missing depfiles can invalidate that conclusion.
  4. The tool found metadata and returned flags under its configured search environment. It did not prove the selected headers and libraries share a compatible ABI or that a runtime loader will select the intended object.
  5. Compiler, linker, sysroot, dependencies, options, paths, environment and generated inputs also affect outputs. A timestamp convention helps only where the build actually honors it; independently controlled matching builds are needed for a reproducibility claim.

Checkpoint#

Submit the T0–T3 graph and either private build logs or the supplied source-only analysis, including one failed dependency case. Ready means naming the build and runtime machines, identifying the changed edges, and distinguishing compiler, linker and scheduler failures. If the graph is unclear, return to ELF linking and redraw the two object edges before retrying.

Sources#

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.