Header Files & Multi-File Projects
The jump from single-file programs to multi-file projects is the moment a C programmer becomes a C engineer. It's not just about organization — it's about understanding compilation units, translation phases, linkage, and the separation of interface from implementation. The Linux kernel (over 30 million lines), the CPython interpreter, and the SQLite amalgamation all started with the same questions you're about to answer: what goes in a header, what stays in a .c file, and how do you make it all link together?
1. The Translation Unit: The Fundamental Unit of Compilation
The C compiler never sees your "project." It sees one translation unit at a time — a single .c file after preprocessing has expanded all #include directives. Each translation unit is compiled independently into an object file. Only later, during linking, do these island universes discover each other. This has profound consequences:
main.c ──[preprocess]──> main.i ──[compile]──> main.o ──┐
utils.c ──[preprocess]──> utils.i ──[compile]──> utils.o ──┤── [link] ──> app
parser.c ──[preprocess]──> parser.i ──[compile]──> parser.o ──┘
Each .c file is a self-contained universe during compilation.
The linker is the only stage that sees the whole picture.
2. The Header File Contract: Interface vs. Implementation
Headers (.h) declare what exists. Source files (.c) define how it works. This separation is not just style — it enables separate compilation, information hiding, and API stability.
What belongs in a header:
- Function declarations (prototypes)
externvariable declarations- Type definitions (
typedef,struct,enum) - Macro definitions (
#define) - Inline function definitions (
static inline)
What does NOT belong in a header:
- Function definitions (the body with
{ }) — unlessstatic inline - Global variable definitions (without
extern) - Any code that allocates storage — headers should only describe storage
3. A Complete Multi-File Project: Step by Step
Let's build a small but realistic project: a dynamic array (vector) library. We'll implement the full public/private separation pattern used in production code.
Step 1: The Public Header (vector.h)
#ifndef VECTOR_H
#define VECTOR_H
#include <stddef.h> /* for size_t */
/* Opaque type: users see the name but not the internals.
This is C's version of information hiding — the struct
definition lives in the .c file only. */
typedef struct Vector Vector;
/* Public API — all the operations users are allowed to perform */
Vector* vector_create(size_t initial_capacity);
void vector_destroy(Vector *vec);
int vector_push(Vector *vec, int value);
int vector_get(const Vector *vec, size_t index, int *out);
size_t vector_length(const Vector *vec);
size_t vector_capacity(const Vector *vec);
void vector_print(const Vector *vec);
#endif /* VECTOR_H */
Step 2: The Implementation (vector.c)
#include "vector.h"
#include <stdio.h>
#include <stdlib.h>
/* The full struct definition is PRIVATE to this file.
External code can only use Vector through pointers and API calls. */
struct Vector {
int *data;
size_t length;
size_t capacity;
};
Vector* vector_create(size_t initial_capacity) {
if (initial_capacity == 0) initial_capacity = 8;
Vector *vec = (Vector*)malloc(sizeof(Vector));
if (!vec) return NULL;
vec->data = (int*)malloc(initial_capacity * sizeof(int));
if (!vec->data) {
free(vec);
return NULL;
}
vec->length = 0;
vec->capacity = initial_capacity;
return vec;
}
void vector_destroy(Vector *vec) {
if (!vec) return;
free(vec->data);
free(vec);
}
int vector_push(Vector *vec, int value) {
if (vec->length == vec->capacity) {
size_t new_cap = vec->capacity * 2;
int *temp = (int*)realloc(vec->data, new_cap * sizeof(int));
if (!temp) return 0;
vec->data = temp;
vec->capacity = new_cap;
}
vec->data[vec->length++] = value;
return 1;
}
int vector_get(const Vector *vec, size_t index, int *out) {
if (!vec || index >= vec->length) return 0;
*out = vec->data[index];
return 1;
}
size_t vector_length(const Vector *vec) {
return vec ? vec->length : 0;
}
size_t vector_capacity(const Vector *vec) {
return vec ? vec->capacity : 0;
}
void vector_print(const Vector *vec) {
if (!vec) { printf("(null vector)\n"); return; }
printf("[");
for (size_t i = 0; i < vec->length; i++) {
printf("%d", vec->data[i]);
if (i + 1 < vec->length) printf(", ");
}
printf("] (len=%zu, cap=%zu)\n", vec->length, vec->capacity);
}
Step 3: The Main Program (main.c)
#include <stdio.h>
#include <stdlib.h>
#include "vector.h"
int main() {
Vector *nums = vector_create(4);
if (!nums) {
fprintf(stderr, "Failed to create vector\n");
return EXIT_FAILURE;
}
for (int i = 1; i <= 15; i++) {
if (!vector_push(nums, i * i)) {
fprintf(stderr, "Push failed at %d\n", i);
vector_destroy(nums);
return EXIT_FAILURE;
}
}
vector_print(nums);
int val;
if (vector_get(nums, 3, &val)) {
printf("nums[3] = %d\n", val);
}
vector_destroy(nums);
return EXIT_SUCCESS;
}
Step 4: Building and Running
# Individual compilation (explicit):
gcc -Wall -Wextra -std=c11 -c vector.c -o vector.o
gcc -Wall -Wextra -std=c11 -c main.c -o main.o
gcc vector.o main.o -o app
# Or all at once:
gcc -Wall -Wextra -std=c11 main.c vector.c -o app
./app
# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225] (len=15, cap=16)
# nums[3] = 16
4. Linkage: extern, static, and the One Definition Rule
C has two types of linkage for identifiers at file scope:
| Keyword | Linkage | Meaning |
|---|---|---|
extern (default for functions and non-static globals) | External | Visible across all translation units. Every declaration refers to the same entity. |
static | Internal | Visible only within the translation unit where it's defined. Each .c file gets its own copy. |
/* --- globals.h --- */
extern int global_counter; /* declaration: "this exists somewhere" */
void increment_counter(void); /* declaration */
/* --- globals.c --- */
#include "globals.h"
int global_counter = 0; /* DEFINITION: allocates storage */
static int internal_counter = 0; /* static: invisible outside globals.c */
void increment_counter(void) {
global_counter++;
internal_counter++;
}
/* --- main.c --- */
#include
#include "globals.h"
/* int global_counter = 5; ERROR: multiple definition — only one .c
file can define global_counter */
int main() {
printf("Before: %d\n", global_counter);
increment_counter();
printf("After: %d\n", global_counter);
/* internal_counter is NOT accessible here — static linkage */
return 0;
}
The One Definition Rule: any function or global variable with external linkage must have exactly one definition across all linked translation units. Declarations (prototypes, extern) can appear many times. Violating this produces the dreaded "multiple definition" linker error.
5. Opaque Types: C's Information Hiding
Notice in our vector example that vector.h declares typedef struct Vector Vector; but never defines the struct members. The full struct Vector { ... } definition lives only in vector.c. This is an opaque type — users can only interact with it through pointer and API functions. This technique:
- Prevents users from depending on internal structure layout (they'd have to recompile if you change it).
- Enforces the API contract — users cannot bypass your functions and access fields directly.
- Is how the C standard library hides
FILE's internals from you.
6. Common Multi-File Pitfalls
6.1 Defining Variables in Headers
/* config.h — WRONG */
int max_connections = 100; /* DEFINITION: if two .c files include this,
you get a "multiple definition" linker error */
/* config.h — RIGHT */
extern int max_connections; /* DECLARATION only */
/* config.c — the single definition */
int max_connections = 100;
6.2 Forgetting to Include the Module's Own Header
/* vector.c */
/* WRONG: if you forget #include "vector.h", the compiler can't check
that your definitions match your declarations! */
/* RIGHT: always include your own header first */
#include "vector.h" /* this must be the first include in vector.c */
#include
/* This ensures vector.h is self-contained (doesn't depend on
other includes being in a specific order) and catches signature
mismatches between declaration and definition. */
6.3 Circular Includes
/* a.h */
#include "b.h"
typedef struct { B *b_ptr; } A; /* needs B */
/* b.h */
#include "a.h"
typedef struct { A *a_ptr; } B; /* needs A */
/* This creates an infinite include loop. Solution: forward declarations: */
/* a.h */
#ifndef A_H
#define A_H
struct B; /* forward declaration — enough for pointers */
typedef struct { struct B *b_ptr; } A;
#endif
/* b.h */
#ifndef B_H
#define B_H
#include "a.h" /* B needs the full definition of A */
struct B { A *a_ptr; };
#endif
7. Key Takeaways
- Headers declare;
.cfiles define. A header is a contract that tells other translation units what exists. The.cfile fulfills that contract. - Every header must be guarded with
#ifndef/#define/#endifor#pragma onceto prevent multiple-inclusion errors within a single translation unit. staticat file scope gives internal linkage — the symbol is invisible to other.cfiles. Use it for helper functions and private globals that are implementation details.- Opaque types (
typedef struct Foo Foo;in the header, full definition only in.c) enforce information hiding, prevent ABI breakage, and are the standard pattern for C libraries. - Always include a module's own header first in its
.cfile. This guarantees the header is self-contained and catches signature mismatches at compile time.
8. Practice Exercises
Exercise 1: Build a Stack Library
Create a generic integer stack as a multi-file module with stack.h and stack.c. The public API should be: stack_create, stack_destroy, stack_push, stack_pop, stack_peek, stack_is_empty, stack_size. Use the opaque type pattern. Write a main.c that tests all operations including edge cases (pop from empty stack, push until reallocation needed).
/* stack.h — starter */
#ifndef STACK_H
#define STACK_H
#include
typedef struct Stack Stack;
Stack* stack_create(size_t initial_cap);
void stack_destroy(Stack *s);
int stack_push(Stack *s, int value);
int stack_pop(Stack *s, int *out); /* returns 0 if empty */
int stack_peek(const Stack *s, int *out);
int stack_is_empty(const Stack *s);
size_t stack_size(const Stack *s);
#endif
Exercise 2: Resolve Circular Dependencies
Design two modules: student.h/.c and course.h/.c. A Student struct contains an array of Course* pointers (courses enrolled). A Course struct contains an array of Student* pointers (students enrolled). Resolve the circular dependency using forward declarations. Implement enroll_student that adds the student to the course and the course to the student atomically.
Exercise 3: Static vs. External Linkage Detective
Create two .c files, each with a function named helper() — one declared static, the other without. Also create identically-named global variables across the files with different storage class specifiers. Compile and link them. Which combinations produce linker errors? Explain why for each. Then add extern declarations in a shared header and observe the behavior.
Exercise 4: Build a Library with a Makefile
Take the vector library from this lesson (or your stack from Exercise 1) and create a Makefile that: (a) compiles the library into a static library (libvector.a) using ar, (b) compiles main.c separately, (c) links main.o against libvector.a to produce the final executable, and (d) provides all, clean, and lib targets. Research the ar and ranlib commands.