Help improve SovranCode?

Google Analytics can measure anonymous usage after you choose Allow. Essential account and progress features work either way.

SovranCode
C: Foundations for Systems Programming Build Tools and Makefiles
This device
Course contentsBuild Tools and Makefiles · 15 titles

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
C: Foundations for Systems Programming15 complete lessons

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
PREVIOUS LESSONErrors, Debugging, and Undefined Behavior
NEXT UP · PLANNEDProject: Command-Line Inventory Manager
Memory, tooling, and practice 330 min

Build Tools and Makefiles

A build tool is an executable model of your project. It records which source creates which object, which headers invalidate that object, which flags created an executable, and how tests prove the result. Correct incremental builds are faster than rebuilding everything—and prevent stale-object bugs.

What you will leave with

You will write a Makefile for a multi-file C program, use explicit targets and prerequisites, generate header dependencies, separate compile and link steps, create debug and release outputs, and diagnose an incremental build instead of relying on clean as a ritual.

Make is a dependency graph

A rule has a target, prerequisites, and a recipe. If the target is missing or older than any prerequisite, Make runs its recipe. An executable depends on objects; each object depends on its source and every included header. This graph is the durable alternative to a list of commands copied into a terminal.

build/inventory: build/main.o build/inventory.o
⇥$(CC) $^ $(LDLIBS) -o $@

build/main.o: src/main.c include/inventory.h
⇥$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

$@ is this rule's target, $< is its first prerequisite, and $^ is all prerequisites without duplicates. The visible ⇥ means a literal tab in a real Makefile recipe. A missing-separator error nearly always means the recipe indentation is spaces instead of a tab.

COMPILE

One .c → one .o

Use -c. Compilation checks one translation unit but does not produce the executable.

LINK

Objects → executable

Link the complete object set and libraries only after objects are fresh.

ACTION

.PHONY target

Targets like test and clean are actions, not files, so mark them phony.

A complete, maintainable starting Makefile

CC       := cc
CPPFLAGS := -Iinclude
CFLAGS   := -std=c17 -Wall -Wextra -Wpedantic -Wconversion -g -O0
BUILD    := build
APP      := $(BUILD)/inventory
SOURCES  := src/main.c src/inventory.c src/parse.c
OBJECTS  := $(SOURCES:src/%.c=$(BUILD)/%.o)
DEPS     := $(OBJECTS:.o=.d)

.PHONY: all debug test clean print-vars
all: debug
debug: $(APP)

$(APP): $(OBJECTS) | $(BUILD)
⇥$(CC) $(OBJECTS) -o $@

$(BUILD)/%.o: src/%.c | $(BUILD)
⇥$(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -c $< -o $@

$(BUILD):
⇥mkdir -p $@

-include $(DEPS)

test: $(APP)
⇥./tests/run-tests.sh

clean:
⇥rm -rf build dist

print-vars:
⇥@printf 'APP=%s\nOBJECTS=%s\n' '$(APP)' '$(OBJECTS)'

The pattern substitution maps src/parse.c to build/parse.o. | $(BUILD) is order-only: Make creates the directory, but its timestamp does not make every object stale. Keep generated outputs in build/ and dist/, never beside source files.

Header dependency files keep incremental builds correct

An object embeds code compiled from the headers it saw. If a shared header changes, every source that includes it must recompile. Hand-maintained header prerequisite lists become wrong as modules grow. -MMD asks the compiler to generate a .d file beside each object; -MP makes removed headers less brittle; -include tolerates missing dependency files on the first build.

# Generated build/parse.d
build/parse.o: src/parse.c include/parse.h include/inventory.h
include/parse.h:
include/inventory.h:

# Edit inventory.h. Make must rebuild parse.o, main.o, and any test object
# that includes it, then relink exactly the affected executables.
Clean is evidence, not a permanent repair

If make clean && make fixes your project, find the missing prerequisite or configuration collision. The graph should make a normal incremental build correct after any edit.

Debug, sanitizer, and release builds need distinct artifacts

Flags are inputs. A release executable should not quietly link debug objects compiled with another flag set. Use different output directories or an explicit clean boundary. Run tests against every configuration you intend to ship.

# Example policy (extend shared rules rather than copy recipes):
debug: CFLAGS += -g -O0 -fsanitize=address,undefined
debug: BUILD := build/debug

release: CFLAGS := -std=c17 -Wall -Wextra -Wpedantic -O2 -DNDEBUG
release: BUILD := dist/release

# The full project should derive APP and OBJECTS from BUILD for each target,
# then run the same test suite against both configurations.

Assertions may disappear under NDEBUG; required error checks cannot depend on them. Sanitizers are a powerful testing configuration, but their instrumented output is not a release artifact.

Diagnose a build graph deliberately

  • 01
    Dry run

    make -n target shows what Make would run without changing files.

  • 02
    Explain stale targets

    make --debug=b target explains why a target is out of date.

  • 03
    Inspect expansion

    Use make print-vars or make -p to verify paths, inputs, and flags.

  • 04
    Verify change classes

    Compare no edit, a .c edit, and a header edit; each should rebuild only its legitimate dependents.

  • make -n debug              # inspect recipes safely
    make --debug=b debug      # why targets rebuild
    make -j4 test              # parallel independent prerequisites
    make clean && make test    # diagnostic clean rebuild, not daily workflow

    Parallel builds are safe only if every recipe writes its declared target and every generated input is named as a prerequisite. An undeclared shared generated file can race under -j even when it appears to work locally.

    Build the application and tests as separate deliverables

    A serious project needs more than an application target that launches manually. Give the test binary its own target and link it only with the module objects it needs—not the application's main.o. That makes module tests independent and lets Make rebuild test results when public headers or implementation objects change.

    TEST_BUILD := build/tests
    TEST_APP   := $(TEST_BUILD)/inventory-test
    TEST_SOURCES := tests/inventory_test.c tests/parse_test.c
    TEST_OBJECTS := $(TEST_SOURCES:tests/%.c=$(TEST_BUILD)/%.o)
    MODULE_OBJECTS := build/inventory.o build/parse.o
    
    $(TEST_APP): $(TEST_OBJECTS) $(MODULE_OBJECTS) | $(TEST_BUILD)
    ⇥$(CC) $^ -o $@
    
    $(TEST_BUILD)/%.o: tests/%.c | $(TEST_BUILD)
    ⇥$(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -c $< -o $@
    
    $(TEST_BUILD):
    ⇥mkdir -p $@
    
    test: $(TEST_APP)
    ⇥$(TEST_APP)

    This graph exposes a useful design test: if a module cannot be linked into a test binary without the CLI entry point, separate the module API from command-line presentation. A test target should return non-zero when any assertion fails so Make and continuous integration reliably stop.

    Keep each class of compiler flag in the right variable

    CPPFLAGS

    Preprocessor inputs

    Include paths and feature definitions: -Iinclude, -D_POSIX_C_SOURCE=200809L.

    CFLAGS

    C compilation

    Language standard, warnings, optimization, debug symbols, and sanitizers.

    LDFLAGS / LDLIBS

    Linking

    Linker search paths/options in LDFLAGS; libraries such as -lm in LDLIBS.

    CPPFLAGS := -Iinclude -D_POSIX_C_SOURCE=200809L
    CFLAGS   := -std=c17 -Wall -Wextra -Wpedantic -Wconversion
    LDFLAGS  :=
    LDLIBS   := -lm
    
    # Link libraries after the objects that reference them.
    $(APP): $(OBJECTS)
    ⇥$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@

    This convention lets a developer or CI override a class of flags without editing your Makefile: make CFLAGS="-O2 -g" release. It also avoids the common error of putting -lm before the object that needs math symbols.

    Understand expansion before you build a configuration matrix

    := is a simply expanded variable: its value is computed when Make reads the assignment. = is recursively expanded later, every time it is used. Use := for stable derived paths and lists; use recursive expansion only when you intentionally need late binding.

    MODE := debug
    BUILD := build/$(MODE)      # build/debug immediately
    FLAGS = $(BASE_FLAGS) -O2   # BASE_FLAGS is looked up later
    BASE_FLAGS := -Wall
    
    # Use target-specific values carefully: prerequisites can inherit them.
    release: MODE := release
    release: CFLAGS += -O2 -DNDEBUG
    release: $(APP)

    For larger projects, avoid mutating the same object path for several modes. A configuration is an input to compilation, therefore its object directory must include the configuration name. If you cannot explain which flags produced build/release/parse.o, the build is not reproducible.

    Independent lab: automate the inventory manager

    01Draw the graphExecutable → objects → source/header inputs. Add a separate test executable or test runner.
    02Generate dependenciesUse -MMD -MP, edit one shared header, and prove affected objects rebuild.
    03Separate outputsCreate debug and release directories, record their flags, and test both.
    04Prove incrementalityUse make -n after no edit, source edit, and header edit; explain every command.

    Lesson review

    • Rules model targets, prerequisites, and recipes; Make uses timestamps to refresh stale targets.
    • Compile each source to an object, then link fresh objects into executables.
    • Use automatic variables and pattern rules to avoid duplicated paths and commands.
    • Generate .d dependencies for headers and mark actions as .PHONY.
    • Keep debug, sanitizer, and release artifacts distinct and test each configuration.
    • Use dry runs and Make debug output to repair the graph instead of depending on clean rebuilds.
    KNOWLEDGE CHECK

    Make the correct build the easy build

    Check targets, prerequisites, dependency files, configurations, and build diagnostics before the final C project.

    01In a Make rule, what does a prerequisite express?
    02What does $@ expand to in a normal Make recipe?
    03Why should a .o rule depend on headers it includes?
    04Which command should normally compile a C source without linking?
    05Why is clean usually marked .PHONY?
    06What is a sound release-build change?
    07What does make -n help you inspect?
    08What is the best first step after a suspicious incremental build?
    PREVIOUS LESSONErrors, Debugging, and Undefined Behavior
    NEXT UP · PLANNEDProject: Command-Line Inventory Manager
    ON THIS PAGEBuild Tools and MakefilesDependency graphComplete MakefileHeader trackingConfigurationsBuild diagnosticsIndependent labLesson reviewKnowledge check
    Course contents