Proven C Book←↑→

102 Build and test — how a project is put together by many hands

What to know first

chapter 18, Setting up a development environment · Makefiles and sanitizers
chapter 101, C in practice · make and git
chapter 53, Errors and contracts · the idea of a contract

Looking back

chapter 18 wrote a single Makefile and every example in this book has run from it. So why do real projects put so much into the build — if it compiles, is that not enough?

A. Because “it compiles” and “there is a build” are different statements. Compiling is source becoming machine code this once; a build is a promise about when what gets remade. With three files you need no such promise — remake the lot. With several hundred, remaking the lot costs minutes, and then people stop remaking. From that moment nobody knows whether the executable in hand came from the source in hand.

★ The first demonstration in this chapter is the accident that happens right there. And it shows up not as “slow and annoying” but as a buffer overflow.

The need for this chapter, and its context

chapter 101 showed the names make and git. This chapter puts scale on top of them. So far the book has assumed one person’s desk, but things built in C are mostly built by several people over a long time. How to split the files, what to test, what to let the machine refuse — these are not matters of taste but structure that scale forces on you.

By the end of this chapter

Four things. What changes when there is a build (and what is lost without one), how files and folders are divided, how testing is organised, and what to hand the machine and what people must look at when several work together. The syntax of the tools is not covered — make and CMake syntax shifts from release to release, and what this chapter means to leave behind is not syntax but why things are laid out as they are. The grounds are four real projects.

The questions this chapter answers

  1. Are more tests always better?

102.1 What changes when there is a build#

A build tool takes three jobs off your hands.

WhatWhat happens without it
Incrementalremake only what changed. Without it a full rebuild takes minutes and people skip remaking
Dependencyknow what must be remade when something changes. Without it stale artefacts quietly survive
Reproduciblethe same source yields the same thing. Without it “it works on my machine” becomes an argument

Table 102.1 — The jobs a build takes on

The middle one is this section’s subject. What happens without it is worth causing on purpose.

examples-en/ch102/stale/run.sh

#!/bin/sh
# Reproduce the stale-artifact accident.
#
#   Widen GREET_MAX from 16 to 32 in the header and touch only greet.c.
#   The rule with no dependencies rebuilds greet.o alone: greet now writes
#   trusting 32 while main.o still reserves the old 16. The buffer overflows.
set -eu
cd "$(dirname "$0")"

for kind in broken fixed; do
    printf '== Makefile.%s\n' "$kind"
    make -s -f "Makefile.$kind" clean >/dev/null
    make -s -f "Makefile.$kind" CFLAGS="-std=c23 -Wall -Wextra -g -fsanitize=address$([ $kind = fixed ] && printf ' -MMD -MP')" >/dev/null 2>&1
    printf '   first          : %s\n' "$(./demo 2>&1 | head -1)"

    sed -i 's/#define GREET_MAX 16/#define GREET_MAX 32/' greet.h
    touch greet.c
    make -s -f "Makefile.$kind" CFLAGS="-std=c23 -Wall -Wextra -g -fsanitize=address$([ $kind = fixed ] && printf ' -MMD -MP')" >/dev/null 2>&1
    out=$(./demo 2>&1 | head -3 || true)
    printf '   after widening : %s\n' "$(printf '%s' "$out" | head -1)"
    printf '%s' "$out" | grep -q 'stack-buffer-overflow' && printf '   → ASan: stack-buffer-overflow\n' || true

    sed -i 's/#define GREET_MAX 32/#define GREET_MAX 16/' greet.h
    make -s -f "Makefile.$kind" clean >/dev/null
done

Output

== Makefile.broken
   first          :   GREET_MAX=16  len=15
   after widening : =================================================================
   → ASan: stack-buffer-overflow
== Makefile.fixed
   first          :   GREET_MAX=16  len=15
   after widening :   GREET_MAX=32  len=31

Take apart what happened. The size of the container lives in the header.

#define GREET_MAX 16
void greet(char *out);

greet.c fills that many, and main.c reserves that many. Both files trust the same number. And yet the rule in Makefile.broken says only this.

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

Nowhere in it does it say “this object file also depends on greet.h”. So widen the header from 16 to 32 and touch only greet.c, and make rebuilds greet.o alone. The new greet writes 31 characters and the closing NUL — 32 bytes — while the old main.o still reserves a 16-byte container.

★ The result is not slowness but a stack buffer overflow. The demonstration catches it on the spot under the sanitizer (chapter 18). Uncaught, this program would have become the kind that “sometimes gives odd values”.

A common misconception. “Then why not always make clean?”

It does work. And it is precisely throwing the incremental part away. With three files the cost is zero; with several hundred it is minutes every time, and those minutes add up until people skip clean as well. Leave discipline to human will and scale defeats it.

The answer is not to write the dependencies by hand. The compiler already knows — which headers were opened is settled while compiling, so have it write that down.

CFLAGS = ... -MMD -MP
-include main.d greet.d

-MMD leaves, for each object file, a rule saying “this file depends on these headers” in a .d file, and -include feeds it into the next build. The The Makefile.fixed of the demonstration merely adds those two words to CFLAGS and one -include line, and the same manoeuvre causes no accident.

102.2 How files are divided — the header is the contract, the source the circumstances#

Files are not divided because one grew long. The criterion is keep together what changes together, and separate what changes separately. Split what changes together and every fix walks across several files; join what changes separately and one side keeps forcing the other to be rebuilt.

On top of that sits one circumstance peculiar to C — the header is the contract and the source the circumstances (chapter 53). What stands in the header is the promise the caller leans on; what stands in the source is how that promise is kept. The how may change; change the promise and other people break.

WhatWhereWhy
prototypes, public types, public macrosheaderpromises the caller must know
internal helpersstatic in the sourcenot promises. The name does not leak either (chapter 56)
the innards of a structthe source, where possibleexpose the members and the layout becomes contract — unchangeable thereafter
global variablesavoid themnobody can tell where the thing is changed (chapter 58)
definitions of static functionsnot in a headerevery including file gets its own copy — but see below

Table 102.2 — What belongs in a header and what does not

★ That last row has one widely used exception: writing a very short function in a header as static inline (from C99 on). Copies are still made, but the compiler usually folds them in and an unused one raises no warning. Even so, the body of that function is now visible in the header, so anything still likely to change belongs in the source.

★ The third row is the most expensive at scale. Write a struct’s members into the header and that layout becomes a public promise, so adding one member becomes rebuilding every caller. Large projects therefore often publish the name and hide the innards.

/* header: a name, and no innards */
typedef struct config config;
config *config_open(const char *path);
int     config_get_int(const config *c, const char *key, int fallback);
void    config_close(config *);

The caller does not know how many bytes config is. So it need not know, and does not break when it changes. The price is that it cannot be used as a value, only through a pointer.

102.3 The boundary between public and internal#

As a project grows its headers fall into two kinds — those put outside and those used only within. The best-known case of pinning that boundary down through file layout is the Linux kernel. It split the declarations that go out to user space into include/uapi/, with the inner headers including those. Two reasons are given for the split — reducing the tangle between headers, and making what is a public promise plain to the eye.

In practice. What happens with no boundary

The program runs perfectly well with no boundary written down. The trouble comes later.

Someone includes an internal header and starts calling an internal function. That function was circumstance, not contract, so its name and arguments were things that could change — and now it has a user. The next person who wants to fix it has no ground to say “but this is internal”, because it is written nowhere.

So it is better to draw the boundary with directories. Splitting into include/ and src/ is far stronger than a comment saying “internal use”. People do not read comments, but a path is visible, and a build rule can enforce it.

102.4 Folder layout — what the conventions are for#

The names differ a little from project to project but the kinds are much the same. The point is not a list to memorise but what each one is for.

PlaceWhat is in itWhy it is kept apart
include/public headerswhat is here is the promise — the boundary is visible as a path
src/sources and internal headerscircumstances with circumstances
tests/tests and their datakept out of the body so they are easy to leave out when shipping
build/artefacts★ never mixed with source. It must be deletable whole and easy to keep out of version control
docs/documentsthey mean something only when versioned with the code
scripts/what the machine doesprocedures that used to rest on human memory, frozen into files

Table 102.3 — Common folder kinds and their reasons

★ Keeping build/ apart repays more than it looks. With artefacts scattered beside the source, nobody can say “which configuration made this .o”, and you cannot build under two configurations at once. Separate the place and build/debug and build/release can live side by side.

102.5 Kinds of test — what each one catches#

“Writing a test” is not one activity. Each kind catches a different accident.

KindWhat it catchesValue and price
unitone function breaking its contract — boundary values, failure pathsfast and run often. But it cannot see where the parts mesh
integrationthe mismatch that appears once parts are joinedclose to the real thing. Slow, and hard to locate when it breaks
regression, goldenthat “it used to come out like this” has changed — a defect that came back is a regressioncatches something else changing besides what you meant to fix
fuzzinputs nobody thought of — overflow, null, broken formatssweeps outside human imagination. It only means something alongside a sanitizer

Table 102.4 — Kinds of test and what each catches

This book has already shown two of them. The sanitizer of chapter 18 is the partner of fuzzing, and the “failing shell” of chapter 92 is the technique of making a unit test deliberately walk the path that rarely runs.

102.6 Golden tests — freezing the answer in a file#

Of the kinds, golden repays most as things grow, because any program with output can have one almost for free. Keep the input in a file, freeze the output you have checked to be right as an answer file, and from then on the machine compares with diff.

PostgreSQL uses this on a large scale. Under src/test/regress/ it keeps sql/ for the inputs, expected/ for the answers and results/ where this run’s output lands, and leaves the mismatches in regression.diffs. The tool doing the comparison is nothing special: it is diff.

The same thing can be stood up in a dozen or so lines.

examples-en/ch102/golden/run.sh

#!/bin/sh
# A golden test - freeze the right answer in a file and let the machine compare.
#   Feed in/X.txt, then diff what comes out against expected/X.txt.
#   --accept freezes the current result as the answer (the first time, and after
set -eu
cd "$(dirname "$0")"
cc -std=c23 -Wall -Wextra -o wordcount wordcount.c
mkdir -p results
fail=0 n=0
for input in in/*.txt; do
    name=$(basename "$input" .txt)
    ./wordcount < "$input" > "results/$name.txt"
    n=$((n + 1))
    if [ "${1:-}" = "--accept" ]; then
        cp "results/$name.txt" "expected/$name.txt"
        continue
    fi
    if ! diff -u "expected/$name.txt" "results/$name.txt" > "results/$name.diff"; then
        printf '  ✗ %s\n' "$name"
        sed -n '3,6p' "results/$name.diff" | sed 's/^/      /'
        fail=$((fail + 1))
    else
        printf '  ✓ %s\n' "$name"
        rm -f "results/$name.diff"
    fi
done
[ "${1:-}" = "--accept" ] && { printf 'froze %d answers\n' "$n"; exit 0; }
[ "$fail" -eq 0 ] && printf '%d golden tests passed\n' "$n" || { printf '%d mismatched\n' "$fail"; exit 1; }

Output

  ✓ empty
  ✓ spaces
  ✓ two-lines
3 golden tests passed

Inputs and answers frozen like this are called fixtures — the provisions a test stands on. Their properties and traps are taken up separately in chapter 103.

Three things are worth noticing.

  1. The answer is not written by hand. --accept freezes the current result. Written by hand it comes out wrong, and a wrong answer turns the test backwards.
  2. A failure shows only the lines that differ. diff already does that work.
  3. So this test earns its keep even when you do not know “what is right” — because what it asks is has it changed since yesterday.

Counter-example. Freezing an answer without looking at it

--accept is dangerous because it is convenient. Freeze again without looking at why the test broke and you have made the bug the answer. From then on that test guards nothing.

The discipline is one line — press --accept only when you have read the mismatch and can say “this change was intended”. This book’s own example verification runs on the same discipline.

102.7 How much, and what not to test#

Is there an answer to “how much should I test”? There is one case that shows the upper end.

SQLite states the numbers in its own testing document1 — as of 3.42.0 (May 2023), the library itself is about 155.8 KSLOC while the test code and scripts come to 92,053.1 KSLOC, that is 590 times the library. The frames that run the tests — the harnesses — are not one but four (the TCL tests, TH3, the SQL Logic Test and dbsqlfuzz), and it states that the core, in its default configuration, holds 100% branch coverage under TH3 — the qualification is in the original, and it is not “the whole library, always”.

★ What is to be learned here is not “do 590 times”. It is two things.

  1. Why keep several frames — where one approach shuts its eyes, another looks. Ten tests written from different thinking beat a hundred written from the same.
  2. The quantity of tests is a result, not a goal — SQLite tests that much because it claims to be “a database that runs anywhere”. A different goal gives a different answer.

And the question on the other side matters more.

Q. Are more tests always better?

A. No. A test is code too, and code is debt. Add one test and the work of maintaining it is added as well. A test that does not earn its keep is a burden.

The ones that do not pay tend to look alike. The test that copies the implementation (it writes down again exactly what the function does, so fixing the implementation always breaks it), the test that tests the language (checking that 1 + 1 is 2), and the test that does not say what went wrong when it breaks (one five-hundred-line integration test).

The criterion for picking the useful ones reduces to a single question — does it tell me what to fix when it breaks?

102.8 Working together — what the machine refuses and what people look at#

Once several people are working, one more question appears. Who stops what has gone wrong?

The answer divides in two. What the machine refuses (a gate) and what people look at (a review). Mix them and both get worse — give a person what a machine can count and the person tires; give a machine what a person must judge and the rule refuses the wrong things.

The machine refusesPeople look at
does it build; do the tests runis this design pointed the right way
formatting and naming rulesdoes the name match the meaning
are the sanitizer and static analysis quietis this contract natural for the caller
do the numbers written in the docs match realitydoes this explanation reach a reader

Table 102.5 — What to hand the machine and what people look at

In practice. This book runs that way

This book’s repository has twenty-three such gates (scripts/check-*.py). A few of them: whether the two editions (Korean and English) point at the same chapters; whether a cited clause of the standard exists at all, down to the paragraph number; whether “this is the last chapter of the part” really is the last; whether an acronym is spelled out where each chapter first uses it.

★ Every one of those is something human eyes cannot hold. In practice, each time a chapter was inserted several statements that pointed at numbers went quietly stale, and each time it was a checker, not a person, that found them. What a gate is worth is not “strictness” but sparing human attention.

And gates have a price. A check that cries wolf teaches people to ignore it, and then the real thing is missed. This repository built one such check and did not ship it — run against the manuscript, all twenty-four of the things it caught were false positives. A gate that cries wolf is worse than none.

102.9 What has to be written down#

Last, the work of making a place for others to come into. Documents earn their keep not by being many but when each answers a different question.

DocumentThe question it answers
READMEwhat this is, how to build it, how to use it
contributing guidewhat must be kept for my change to be accepted — the list of gates goes here
changelogwhat changed since the last release — especially breaking changes
design recordwhy it was settled that way. Code says “what” but cannot say “why”

Table 102.6 — Which document answers which question

★ The last row is the one most often missing and the most expensive. Months later, the person who wants to undo that decision does not know what was weighed to reach it, and so walks again a road already examined and discarded.

Recap

What to rememberIn brief
what a build doesincremental, dependency, reproducible. Without them people skip remaking
stale artefactswrite no dependencies and one side alone is rebuilt — the buffer overflows
the curedo not write them by hand — let the compiler with -MMD -MP
splitting fileswhat changes together. Header is contract, source is circumstance
a struct’s innardsexpose them in the header and the layout becomes contract — hide them where you can
foldersdraw the boundary as a path. Keep artefacts apart in build/
kinds of testunit, integration, golden and fuzz each catch a different accident
goldenfreeze the answer and compare with diff. Read the mismatch before freezing again
how muchnot quantity but independence. And a test that does not earn its keep is debt
working togetherwhat a machine can count becomes a gate; what must be judged becomes a review

Table 102.7 — Build and test — what to remember

That was what goes where. The next chapter looks at how to work on top of it — the vocabulary and procedure used when several people keep a thing turning, and how to tell whether a check really bites.

Notes

  1. SQLite. How SQLite Is Tested. sqlite.org/testing.html — the figures are from that document’s section as of 3.42.0. ↩