Skip to content
VLSI Mentor

C Part I · Module 11 · Building Multi-File C Programs

How Separate Compilation Works

Three chapters have described different things happening to your source — macros replaced, branches deleted, declarations checked — and treated the order as obvious. It is worth seeing laid out, because every error message in this module arrives from a specific stage and means something different depending on which. A preprocessing error, a compile error and a link error are not three severities of one problem; they are three separate programs objecting to three separate inputs, each knowing things the others do not and blind to things the others can see. This chapter runs each stage on its own, measures what comes out, and reads the symbol tables that pass between them — after which the question of what a given error could possibly have known answers itself.

Intermediate19 min readCPart IFundamentalsBuild

Part I · Module 11 · Chapter 11.8 · How Separate Compilation Works

1. The Four Stages

Four stages of building a C program. First, preprocess: includes are pasted, macros replaced and unselected branches deleted; this stage knows nothing about types. Second, compile: the translation unit is parsed and type-checked, and every warning in this curriculum comes from here; it sees exactly one translation unit. Third, assemble: the result becomes an object file with a symbol table; there is no C left to check. Fourth, link: symbols are matched across object files by name alone, because types no longer exist.One source file to one object file, four times over01PREPROCESSincludes pasted · macros replaced · dead branches deleted — knows nothingabout types02COMPILEone translation unit parsed and type-checked — every warning comes from here03ASSEMBLEmachine code plus a symbol table — no C left to check04LINKsymbols matched across objects by name alone — types are gone
Figure 1 — the four stages, and what each one is able to check. Reading down the right-hand column is the useful part: each stage is blind to something the previous one could see, because each stage is handed a smaller, more processed artefact. The preprocessor manipulates text and has no notion of a type. The compiler has every type in the translation unit and nothing from any other. The assembler has no C at all. The linker has names and addresses and has never heard of int. An error can only ever be reported by a stage that had the information to notice it — which is why the same mistake surfaces at different distances depending on where you put the declaration.

Stages 1 to 3 happen once per .c file, independently. Stage 4 happens once for the program. That asymmetry is the whole architecture.

2. Running Them Separately

A two-file project:

Azvya Education Pvt. Ltd.VLSI Mentor
util.h
#ifndef UTIL_H
#define UTIL_H
int  add(int a, int b);
extern int call_count;
#endif
Azvya Education Pvt. Ltd.VLSI Mentor
util.c
#include "util.h"
int call_count = 0;
int add(int a, int b) { call_count++; return a + b; }
Azvya Education Pvt. Ltd.VLSI Mentor
main.c
#include <stdio.h>
#include "util.h"
int main(void)
{
    printf("%d %d\n", add(2, 3), call_count);
    return 0;
}

Stage 1 — preprocess (-E):

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -E util.c -o util.i
  util.c     3 lines  ->  util.i     15 lines
$ cc -std=c17 -E main.c -o main.i
  main.c     7 lines  ->  main.i    578 lines

The two files differ by one line — #include <stdio.h> — and that line is worth forty times the rest of the file. This is Chapter 11.2 §2's measurement, now in the context that explains why it matters: everything downstream processes those 578 lines, once, for every .c file that includes <stdio.h>.

Stage 2 — compile to assembly (-S):

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -S util.i -o util.s
  util.i  ->  util.s    25 lines

Fifteen lines of C became twenty-five lines of assembly. Note the input: util.i, already preprocessed. The compiler proper never sees a #include.

Stage 3 — assemble (-c):

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -c util.s -o util.o
  util.s  ->  util.o   704 bytes
$ cc -std=c17 -c main.c -o main.o
  main.c  ->  main.o   856 bytes

The second command runs all three stages at once, which is what -c normally means. .o files are object files: machine code plus a symbol table, and not yet a program.

Stage 4 — link:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc util.o main.o -o prog
  two objects  ->  prog   33512 bytes
$ ./prog
5 1

Two files totalling 1,560 bytes produced a 33 KB executable — the difference is startup code and the machinery the C runtime needs, not your program.

3. What Passes Between Stage 3 and Stage 4

This is the interface that makes separate compilation possible, and it is readable.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ nm main.o                      $ nm util.o
                 U _add                          0000000000000000 T _add
                 U _call_count                   0000000000000050 S _call_count
0000000000000000 T _main
                 U _printf

U means undefined — this object file wants that symbol and does not have it. main.o wants three: _add, _call_count and _printf.

T and S mean defined — this object file provides them. util.o provides _add and _call_count.

Linking is the operation of turning U rows into addresses. The linker reads every object file, builds one table of what is provided, and for each U finds the single definition that matches by name. If there is none, that is an undefined reference. If there are two, that is a duplicate symbol.

After linking, almost nothing is left wanting:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ nm -u prog
_printf

_printf is still undefined because it lives in the C library, which is linked dynamically and resolved when the program starts. Everything of yours is resolved.

Two things that are not in those tables are worth noticing, because their absence is the subject of §4.

No types. _add is a name and an address. Nothing records that it takes two ints.

No file names, no line numbers, no scope. Chapter 11.4's static names are present but lowercase and unmatchable; everything else is a flat namespace of strings.

4. What Each Stage Cannot Check

This is c.ts's second clause and the chapter's deliverable. Each entry below is a measurement from an earlier chapter, now placed at the stage that explains it.

The preprocessor cannot see types, or anything else about C. It substitutes tokens. Chapter 11.6 measured the consequence: SQ_BAD(1+2) became 1+2 * 1+2 and evaluated to 5, because nothing at that stage knows what an expression is.

The compiler sees exactly one translation unit. Chapter 11.1 §6 measured the consequence: a function declared double add(double, double) in one file and defined int add(int, int) in another compiled cleanly twice. Neither compilation was wrong; neither could see the other file.

The compiler never sees a branch the preprocessor deleted. Chapter 11.7 §2 measured the consequence: deliberate garbage inside #if 0 produced no diagnostic at all.

The linker sees names, not types. The same Chapter 11.1 §6 program linked, because a symbol called _add was wanted and a symbol called _add was supplied. That is the entire check.

Collected:

StageCan checkCannot check
Preprocessdirective syntax, balanced #if/#endif, missing include filesanything about C — not types, not expressions, not declarations
Compiletypes, scope, const, exhaustiveness, every warning in this curriculumanything in another translation unit; anything in a deleted branch
Assemblenothing about your program— it is past the point of C
Linkthat every wanted name is supplied exactly oncewhether the types agreed; whether the name means what either side thought

An error can only be reported by a stage that had the information to notice it. That sentence is the frame for reading any build failure. If a mistake spans two translation units, no compiler invocation can catch it, and the linker — which is the only thing that sees both — knows only names. The gap is real and it is structural, which is why Chapter 11.3's discipline exists: putting a declaration in a header moves a cross-file mistake into one translation unit, where stage 2 can see it.

5. Why the Pipeline Is Shaped This Way

The obvious alternative is to compile the whole program at once, which would catch everything in §4's right-hand column. C does not, and the reasons are still good ones.

Rebuild cost. Change one function in util.c and only util.o must be rebuilt, then the program relinked. main.c — and its 578 preprocessed lines — is untouched. On a project with hundreds of files that is the difference between seconds and minutes.

Independence. Files can be compiled in any order, or at the same time on different cores, because none depends on another's compilation. This is why a parallel build works at all.

Libraries. An object file compiled by someone else, years ago, with a different compiler, links with yours — because the interface between stages 3 and 4 is a symbol table, not C source. Chapter 11.10 is about that.

And the cost is a specific, bounded one: nothing checks across the boundary. C's answer is to make the boundary narrow and to give you a tool — the header — for ensuring both sides of it saw the same declaration.

6. The Incremental Build

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -c util.c -o util.o          /* only this file changed */
$ cc util.o main.o -o prog                  /* relink */

Two commands instead of rebuilding everything. The rule for what must be rebuilt follows directly from §1: an object file is out of date if its .c file or any file it includes has changed.

That second clause is the one people get wrong. Editing util.h requires rebuilding every .o whose translation unit included it — because a header is text, and that text is part of each unit (Chapter 11.2 §4). Change a struct in a header, rebuild only one of the three files that use it, and you link object files that disagree about the type's layout. Nothing reports it: the symbols match by name, and Chapter 10.5 established that layout is not a thing the linker knows.

Working out that dependency set by hand does not scale past about three files, which is why build tools exist. Chapter 11.10 is about that, and about where the -D flags from Chapter 11.7 come from.

7. What You Should Be Able to Say Now

  1. Building a C program has four stages: preprocess, compile, assemble, link.
  2. Stages 1 to 3 run once per .c file, independently; stage 4 runs once for the program.
  3. Measured: util.c 3 lines → 15 preprocessed; main.c 7 → 578, from one #include <stdio.h>.
  4. Measured: util.o 704 bytes + main.o 856 bytes → prog 33,512 bytes.
  5. The interface between stage 3 and stage 4 is a symbol table: U = wanted, T/S/D = provided.
  6. Measured: main.o wanted _add, _call_count, _printf; util.o provided the first two.
  7. Linking turns U rows into addresses, matching by name only. After linking, nm -u prog listed only _printf.
  8. The preprocessor cannot see types — 11.6's SQ_BAD(1+2) = 5.
  9. The compiler sees one translation unit — 11.1 §6's mismatch compiled clean twice — and never sees a deleted branch — 11.7 §2's garbage.
  10. The linker sees names, not types — the same mismatch linked.
  11. An error can only be reported by a stage that had the information to notice it. Place the stage first: directives · a line and a C concept · a symbol with no line · or no error at all.
  12. An object is stale if its .c or any header it included changed — and a partial rebuild after a header change links objects that disagree, with no diagnostic.

8. Exercises

Exercise 1 — name the stage. For each message, say which stage produced it and what that stage could possibly have known.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
fatal error: 'config.h' file not found
error: conflicting types for 'add'
ld: duplicate symbol '_twice'
error: "TARGET must be 1 or 2"
warning: unused variable 'n'
Undefined symbols: "_helper", referenced from: _main

Exercise 2 — run the stages. For a one-file program, write the four commands that produce .i, .s, .o and the executable separately. Say which one you would use to debug a macro, and which to check a symbol.

Exercise 3 — read the symbol table. Given U _parse, T _main, U _printf in app.o, say what must be true for the link to succeed, what nm on the providing object would show, and which of the three is normally resolved by the C library.

Exercise 4 — explain the gap. In four sentences, explain why a function declared with two doubles and defined with two ints builds successfully, naming the stage that could have caught it and why it did not.

Exercise 5 — close the gap. Give the one change that turns Exercise 4's program into a compile error, say which stage then reports it, and explain why the file containing the definition has to make that change too.

Exercise 6 — the stale object. types.h defines a struct frame. You add a member and rebuild only two of the three .c files that include it. Describe what the linker does, what the program does, and which chapter explains why nothing warns.

Exercise 7 — cost the rebuild. A project has 200 .c files, each including a header that pulls in 600 lines. Estimate the preprocessed lines compiled in a full build. Then say what changes if one .c file is edited, and what changes if that shared header is edited.

Exercise 8 — argue the design. Give three benefits of separate compilation from §5 and the one cost, and say what C provides to mitigate the cost.

9. Summary

  • Four stages: preprocess · compile · assemble · link. The first three run per file; the last runs once.
  • Measured: 3 → 15 lines and 7 → 578 lines; 704 + 856 bytes → 33,512 bytes.
  • The stage 3–4 interface is a symbol table — U wanted, T/S/D provided.
  • Linking turns U rows into addresses, by name alone.
  • Preprocessor: no types. Compiler: one translation unit, and never a deleted branch. Linker: names, not types.
  • An error can only be reported by a stage that had the information to notice it.
  • Separate compilation buys cheap rebuilds, independence and libraries; it costs checking across the boundary, which headers mitigate.
  • An object is stale if its .c or any included header changed — a partial rebuild links disagreeing objects silently.

Stage four is now the only one still described from the outside. It has appeared four times in this module — a duplicate symbol when a definition landed in a header, an undefined symbol when a function was made private, a merge when two files both tentatively defined the same object, and the resolution that made the program in §2 work — and in every case the message named a symbol and nothing else. Those messages are the least informative in the whole toolchain and the most mechanical to diagnose, because the linker's job is small enough to hold in your head: it has a list of what each object wants, a list of what each provides, and exactly two ways to fail. The next chapter reads both failures properly, including the detail the error text always omits and that tells you which file to open.

Standards & specifications

Governing standard
ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)

Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C Programming curriculum.