All Courses
C Advanced

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)
  • extern variable 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 { }) — unless static 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:

KeywordLinkageMeaning
extern (default for functions and non-static globals)ExternalVisible across all translation units. Every declaration refers to the same entity.
staticInternalVisible 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; .c files define. A header is a contract that tells other translation units what exists. The .c file fulfills that contract.
  • Every header must be guarded with #ifndef/#define/#endif or #pragma once to prevent multiple-inclusion errors within a single translation unit.
  • static at file scope gives internal linkage — the symbol is invisible to other .c files. 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 .c file. 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.