C / MULTI-FILE PROGRAMS AND BUILDS
Splitting a program across translation units
Split a working single-file C program into separate translation units, knowing exactly what the compiler can and cannot see at each file boundary.
What you will learn
- Define a translation unit as one .c file after all its #includes are expanded
- Split a program into a module that owns its state and callers that only see functions
- Explain why macros and struct layouts do not cross a translation unit boundary
- Tell apart errors the compiler finds per file from ones only the linker can find
Understanding Splitting a program across translation units
The C compiler never sees your project. It sees one translation unit at a time: a single .c file after the preprocessor has pasted in every #include and expanded every macro, flattened into one stream of tokens. Anything that stream refers to must be declared inside it, because the compiler has no mechanism for looking into another .c file. When main.c calls tally_add, the declaration is what lets the compiler emit the call at all; it writes the call with the target address left as a hole and records tally_add as a name still needed.
Compiling a translation unit yields an object file carrying two lists: names it defines and names it still needs. The linker's whole job is to match the second list against the first across every object file you hand it. That is why two files may call each other in a cycle, and why the order of .c files on the gcc line does not change the resulting program: nothing is resolved until link time, and matching is by name. It also explains the sharp division of errors, since a misspelled identifier inside a function is a compile error in that one file, while a function you declared but never defined anywhere stays invisible until the linker goes looking for it.
Because the compiler's knowledge stops at the translation unit boundary, that boundary is where you get to hide things. A good split puts a piece of state together with the operations on it in one .c file, keeps helper functions and struct layouts inside that file, and exposes only the few declarations callers genuinely need. The payoff is mechanical: internals change without callers being touched, names inside one file cannot collide with names inside another, and an edit recompiles one file instead of everything. A split that forces callers to know field offsets or a required call order gives all of that up while still costing you two files.
/* build: gcc -Wall -Wextra main.c tally.c -o app then: ./app */
/* ===== tally.h ===== */
TALLY_H
TALLY_H
void tally_add(int n);
int tally_total(void);
/* ===== tally.c ===== */
"tally.h"
static int total = 0; /* owned by this translation unit */
static int clamp_low(int n) { /* not visible outside this file */
return n < 0 ? 0 : n;
}
void tally_add(int n) {
total += clamp_low(n);
}
int tally_total(void) {
return total;
}
/* ===== main.c ===== */
<stdio.h>
"tally.h" /* declarations only: no body, no total */
int main(void) {
tally_add(7);
tally_add(-3); /* clamped inside tally.c */
tally_add(5);
printf("total = %d\n", tally_total());
return 0;
}A translation unit is the largest thing the compiler ever sees at once, so every cross-file connection is made by declaration at compile time and resolved by name at link time.
Worked examples
A macro defined in main.c does not reach scale.c
Shows that the preprocessor runs separately for each translation unit, so a #define cannot travel between files.
/* build: gcc -Wall -Wextra main.c scale.c -o app then: ./app */
/* ===== scale.h ===== */
SCALE_H
SCALE_H
int scaled(int n);
/* ===== scale.c ===== */
"scale.h"
FACTOR
FACTOR
int scaled(int n) {
return n * FACTOR;
}
/* ===== main.c ===== */
<stdio.h>
FACTOR/* affects this file's token stream only */
"scale.h"
int main(void) {
printf("FACTOR here = %d\n", FACTOR);
printf("scaled(3) = %d\n", scaled(3));
return 0;
}Example explained
Line 1#define FACTOR 10 in main.c rewrites tokens while main.c is being preprocessed, which is a separate run from scale.c's.
Line 2scale.c's #ifndef FACTOR finds nothing defined, so scaled was compiled with 2 baked into the multiply.
Line 3By link time no macro exists anywhere: object files hold machine code and names, never preprocessor definitions.
Line 4To make both files agree, pass -DFACTOR=10 on every compilation or put the value in a header both files include.
Two files that call each other
Demonstrates that cross-file dependencies may form a cycle because each file only needs a declaration at compile time.
/* build: gcc -Wall -Wextra ping.c pong.c -o app then: ./app */
/* ===== ping.h ===== */
PING_H
PING_H
void ping(int n);
/* ===== pong.h ===== */
PONG_H
PONG_H
void pong(int n);
/* ===== ping.c ===== */
<stdio.h>
"ping.h"
"pong.h"
void ping(int n) {
printf("ping %d\n", n);
if (n > 0) pong(n - 1);
}
int main(void) {
ping(2);
return 0;
}
/* ===== pong.c ===== */
<stdio.h>
"ping.h"
"pong.h"
void pong(int n) {
printf("pong %d\n", n);
if (n > 0) ping(n - 1);
}Example explained
Line 1ping.c compiles with no access to pong's body: the declaration in pong.h is enough to emit a call whose target is left unresolved.
Line 2pong.c refers back to ping, and the cycle is harmless because neither compilation reads the other's source.
Line 3Swapping the two .c files on the gcc line produces the same program, since the linker matches names rather than input order.
Line 4Drop pong.c from the command line and the compile of ping.c still succeeds; the failure appears only when the linker cannot find pong.
A struct whose layout exists in one file only
Uses an incomplete type in the header so that field offsets are known inside a single translation unit.
/* build: gcc -Wall -Wextra main.c stack.c -o app then: ./app */
/* ===== stack.h ===== */
STACK_H
STACK_H
typedef struct Stack Stack; /* incomplete: size and fields unknown here */
Stack *stack_new(void);
void stack_push(Stack *s, int v);
int stack_pop(Stack *s);
int stack_size(const Stack *s);
void stack_free(Stack *s);
/* ===== stack.c ===== */
<stdlib.h>
"stack.h"
CAP
struct Stack { int data[CAP]; int n; }; /* complete only in this file */
Stack *stack_new(void) {
Stack *s = malloc(sizeof *s);
if (s) s->n = 0;
return s;
}
void stack_push(Stack *s, int v) {
if (s->n < CAP) s->data[s->n++] = v;
}
int stack_pop(Stack *s) {
return s->n > 0 ? s->data[--s->n] : 0;
}
int stack_size(const Stack *s) {
return s->n;
}
void stack_free(Stack *s) {
free(s);
}
/* ===== main.c ===== */
<stdio.h>
"stack.h"
int main(void) {
Stack *s = stack_new();
if (!s) return 1;
stack_push(s, 10);
stack_push(s, 20);
stack_push(s, 30);
printf("size = %d\n", stack_size(s));
printf("pop = %d\n", stack_pop(s));
printf("size = %d\n", stack_size(s));
stack_free(s);
return 0;
}Example explained
Line 1typedef struct Stack Stack; gives main.c a type it can form pointers to, while sizeof(Stack) or s->n in main.c would be a compile error there.
Line 2The full struct definition sits in stack.c, so that is the only translation unit where field offsets exist and the only place they can change safely.
Line 3malloc(sizeof *s) must live in stack.c because only that file can compute the size of the object.
Line 4Replacing the fixed array with a growable buffer edits stack.c alone; main.c needs no change and no recompile beyond relinking.
Important notes
The unit of compilation is preprocessed text, not the file on disk: a header included by five .c files is compiled five times, and each copy belongs to a separate, independent translation unit.
gcc -E main.c prints the exact translation unit the compiler receives, so read that output instead of guessing when you cannot explain what is visible in a file.
Common mistakes
Putting a function body in a header: every .c that includes it defines the same symbol, so each file compiles fine alone and the link then fails with a multiple definition error.
Writing #include "other.c" to silence a missing-declaration warning: other.c's text becomes part of this translation unit and is compiled a second time when other.c is also passed to gcc, producing duplicate definitions.
Retyping a declaration by hand instead of including the shared header: the two translation units disagree about the type, no stage reports it, and the mismatch surfaces as a wrong value or a crash at runtime.
Try it yourself
Change, predict, then run
Start from a single file containing main plus double mean(const int *a, int n) and split it into mean.c, mean.h and main.c so that main.c does no arithmetic and mean.c does no printing, then build with gcc -Wall -Wextra main.c mean.c and confirm the output is identical. Now delete the #include "mean.h" line from main.c and note which stage of the build reports the problem.
Open the C workspaceCheck your understanding
You add #define DEBUG 1 at the top of main.c, but the logging code in log.c is still compiled as though DEBUG were undefined. What explains this?
- The macro must be written as #define DEBUG with no replacement value before other files can use it
- Macros are resolved by the linker, which only ever looks at function and variable names
- The preprocessor runs once per translation unit, so log.c is preprocessed without ever seeing main.c's text
- DEBUG needs an extern declaration in log.c so its value can be imported from main.c
Show answer
Preprocessing happens separately for each translation unit: main.c's #define exists only while main.c is being turned into tokens, and log.c is a different run over different text. Option 3 is tempting because extern really does make objects and functions usable across files, but a macro is gone before the compiler proper starts, so there is no symbol for the linker to import; share the value by putting it in a header both files include, or by passing -DDEBUG=1 to every compilation.