Once a C project grows past a few hundred lines, cramming everything into a single file stops being workable. In this lesson we will learn how to organise source code professionally across several files, master C's powerful preprocessor, and use automated build tools.

1. Why split the source across several files?

Real software projects run from thousands to millions of lines. Writing all of that into one file runs into serious problems:

  • Hard to maintain: an enormous file makes reading, searching and fixing bugs extremely painful.
  • Impossible to work on as a team: several developers cannot edit the same file without constant merge conflicts.
  • Slow to compile: every small change forces the whole file to be recompiled from scratch.

The answer is to split the code into modules, following the principle of separation of concerns:

  • Header file (.h) β€” the interface: declares the public face of the module: structs, typedefs, function prototypes, macros, constants.
  • Source file (.c) β€” the implementation: defines the functions in detail, and holds the module's internal (static) helpers.

The biggest win is incremental compilation β€” only the file you actually changed has to be recompiled, which saves an enormous amount of time on a large project.

2. Header files & source files

A module in C is usually a pair of files, .h and .c, with the same name.

What goes in the header (.h)

  • Struct and typedef definitions
  • Function prototypes
  • Macros and constants (#define)
  • Type declarations (enum, union)

What goes in the source file (.c)

  • The implementations of the functions declared in the .h
  • Internal functions marked static (not exposed outside)
  • Internal global variables (static at file scope)

The two forms of #include

include_demo.c
// System libraries - searched on the system include path
#include <stdio.h>
#include <stdlib.h>

// Project headers - the current directory is searched first
#include "student.h"
#include "utils/math_helper.h"

The difference is where the compiler looks first: <stdio.h> is only searched for in the system directories, while "student.h" is searched for in the directory of the current .c file first, and only then further out. The convention is: angle brackets for the standard library, quotes for your own project's files.

3. Header guards & #pragma once

When several .c files include the same header, or headers include one another (circular includes), the compiler hits a redefinition error. Header guards solve this.

The traditional way: #ifndef / #define / #endif

student.h
#ifndef STUDENT_H    // If STUDENT_H has not been defined yet...
#define STUDENT_H    // ...then define it right now

#include <stdio.h>

typedef struct {
    char name[50];
    int age;
    float gpa;
} Student;

// Function prototypes
void printStudent(Student s);
Student createStudent(const char* name, int age, float gpa);

#endif // STUDENT_H  // End of the guarded block

Read the first two lines as a conditional: on the first #include, STUDENT_H does not exist yet, so the whole body is pulled in and that macro gets defined. On the second #include (from a different file), STUDENT_H already exists, #ifndef is false, and the entire block is skipped β€” no more duplicate definitions.

The modern way: #pragma once

#pragma once is a non-standard directive, but it is supported by essentially every modern compiler (GCC, Clang, MSVC). The advantages: it is short, and you never have to worry about two headers picking the same guard macro name.

student.h (pragma once)
#pragma once

#include <stdio.h>

typedef struct {
    char name[50];
    int age;
    float gpa;
} Student;

void printStudent(Student s);
Student createStudent(const char* name, int age, float gpa);

The complete example: student.h + student.c + main.c

student.c
#include "student.h"
#include <string.h>

// Internal helper - only used inside this file
static void capitalize(char* str) {
    if (str[0] >= 'a' && str[0] <= 'z') {
        str[0] -= 32;
    }
}

Student createStudent(const char* name, int age, float gpa) {
    Student s;
    strncpy(s.name, name, 49);
    s.name[49] = '\0';
    capitalize(s.name);
    s.age = age;
    s.gpa = gpa;
    return s;
}

void printStudent(Student s) {
    printf("Name: %s | Age: %d | GPA: %.2f\n", s.name, s.age, s.gpa);
}
main.c
#include <stdio.h>
#include "student.h"

int main() {
    Student s = createStudent("nguyen van a", 20, 8.5);
    printStudent(s);
    return 0;
}

Notice that main.c only ever does #include "student.h" β€” never student.c. The header tells the compiler that createStudent exists and what its type is; the function body lives in student.c and is only joined on at the linking step. That is why the build command has to list both .c files: gcc main.c student.c -o demo (a .h file never goes on the command line).

4. Linkage: extern, static & compilation units

extern: sharing variables and functions between files

extern declares that a variable or function is defined in another file. It does not allocate any new memory β€” it merely promises the compiler that the thing exists somewhere.

config.c
// Define the global variables (memory is allocated here)
int max_connections = 100;
const char* app_name = "MyApp";
server.c
#include <stdio.h>

// extern declarations - use the variables defined in config.c
extern int max_connections;
extern const char* app_name;

void startServer() {
    printf("Starting %s with max %d connections\n", app_name, max_connections);
}

The key point: config.c defines the variables (that is where the memory is actually allocated), while server.c only declares that they exist somewhere. If you drop the extern in server.c, both files end up defining max_connections and the linker reports multiple definition.

static at file scope: internal linkage

Putting static in front of a global variable or a function limits its scope to the current source file only. Other files cannot reach it at all, which gives you safe encapsulation.

logger.c
static int log_count = 0;  // Visible only inside this file

static void writeToFile(const char* msg) {  // Internal function
    // ... write the message to the log file
}

// Public function (external linkage) - other files may call it
void logMessage(const char* msg) {
    log_count++;
    writeToFile(msg);
}

Neither log_count nor writeToFile can be seen from another file, even with a correctly spelled extern declaration. Only logMessage is this module's public doorway β€” this is how C encapsulates data when it has no private keyword like the object-oriented languages.

Translation units & the linker errors you will meet

A translation unit is one .c file after every #include and macro has been processed. Each translation unit is compiled independently into an object file (.o), and the linker then joins them together.

Common linker errors:

  • undefined reference to 'funcName': the function is declared (there is a prototype) but no file defines it β€” or you forgot to pass the .c file containing it to the compiler.
  • multiple definition of 'varName': a global variable is defined (not just extern) in more than one file. The fix: declare it extern in the header and define it in exactly one .c file.

5. The C preprocessor in depth

The C preprocessor runs before the compiler, handling every directive that starts with #. It performs pure text substitution β€” it does not understand C semantics at all.

Object-like macros

macros.c
#define PI          3.14159265358979
#define MAX_SIZE    1024
#define APP_VERSION "2.1.0"

// Using them
double area = PI * r * r;
char buffer[MAX_SIZE];

The preprocessor only substitutes text: every occurrence of MAX_SIZE is turned into 1024 before the compiler ever sees the line. It has no notion of types, so there is no type checking either β€” that is both the power and the trap of macros.

Function-like macros

function_macros.c
// ALWAYS wrap both the parameters and the whole expression in brackets!
#define MAX(a, b)       ((a) > (b) ? (a) : (b))
#define SQUARE(x)       ((x) * (x))
#define ABS(x)          ((x) < 0 ? -(x) : (x))

// What goes wrong without the brackets:
// #define BAD_SQUARE(x)  x * x
// BAD_SQUARE(2 + 3) => 2 + 3 * 2 + 3 = 11 (wrong! 25 was expected)
// SQUARE(2 + 3)     => ((2 + 3) * (2 + 3)) = 25 (correct!)

// Warning: macros and side effects!
// int a = 5;
// SQUARE(a++) => ((a++) * (a++)) => a is incremented twice! (undefined behaviour)

The last two comment lines are the most expensive lesson about macros. Because this is text substitution rather than a function call, the argument is written out several times in the resulting expression. With SQUARE(a++), a is incremented twice inside one expression β€” that is undefined behaviour, not merely a predictably wrong answer.

#undef β€” removing a macro definition

undef_demo.c
#define BUFFER_SIZE 256
// ... BUFFER_SIZE is used here ...

#undef BUFFER_SIZE          // Undefine it
#define BUFFER_SIZE 1024    // Define it again with a new value

Stringification (#) and token pasting (##)

advanced_macros.c
#include <stdio.h>

// Stringification (#) - turn the parameter into a string literal
#define PRINT_VAR(var)  printf(#var " = %d\n", var)

// Token pasting (##) - glue tokens together
#define DECLARE_PAIR(type, name) \
    type name##_first;           \
    type name##_second;

int main() {
    int score = 95;
    PRINT_VAR(score);    // => printf("score" " = %d\n", score);
                         // => prints: score = 95

    DECLARE_PAIR(int, point)  // Expands to: int point_first; int point_second;
    point_first = 10;
    point_second = 20;

    return 0;
}

These two operators exist only in the preprocessor: #var turns the parameter's name into the string "score", while name##_first glues two tokens into the new variable name point_first. They are typically used to generate repetitive code (logging macros, bulk declarations) without typing every line by hand.

Predefined macros

predefined_macros.c
#include <stdio.h>

int main() {
    printf("File: %s\n", __FILE__);        // Current file name
    printf("Line: %d\n", __LINE__);        // Current line number
    printf("Function: %s\n", __func__);    // Current function name (C99)
    printf("Date: %s\n", __DATE__);        // Compilation date
    printf("Time: %s\n", __TIME__);        // Compilation time
    printf("C Standard: %ld\n", __STDC_VERSION__); // C version (e.g. 201112L = C11)
    return 0;
}

The compiler fills these in itself, so the value of __LINE__ changes according to exactly which line you put it on. That is why the logging macro below can print the precise location of the log statement without you passing the file name in by hand.

Variadic macros (a variable number of arguments)

variadic_macros.c
#include <stdio.h>

// LOG macro - prints the file name and line number too
#define LOG(fmt, ...) \
    printf("[%s:%d] " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)

// ##__VA_ARGS__: the ## handles the case where no extra argument is passed
// (it removes the dangling comma when the argument list is empty)

int main() {
    LOG("Server started");              // [variadic_macros.c:11] Server started
    LOG("Port: %d", 8080);              // [variadic_macros.c:12] Port: 8080
    LOG("Host: %s, Port: %d", "localhost", 3000);
    return 0;
}

__VA_ARGS__ receives the whole remaining argument list. What is worth noticing is the ## in front of it: when you call LOG("Server started") with nothing extra, the trailing comma is removed β€” without the ##, that call would not compile. (This is a GCC/Clang extension; C++20 and C23 standardised a different spelling, __VA_OPT__.)

6. Conditional compilation

Conditional compilation lets you keep or completely discard sections of source code based on a condition evaluated at compile time. It is an extremely powerful tool for writing cross-platform code and for turning features on and off by configuration.

The basic directives

conditional.c
#include <stdio.h>

// Define DEBUG at compile time: gcc -DDEBUG main.c
// Or define it directly in the source:
// #define DEBUG

#ifdef DEBUG
    #define DBG_PRINT(fmt, ...) \
        fprintf(stderr, "[DEBUG %s:%d] " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#else
    #define DBG_PRINT(fmt, ...) // Expands to nothing (stripped out of a release build)
#endif

// Check the C standard version
#if __STDC_VERSION__ >= 201112L
    #define C_VERSION "C11 or later"
#elif __STDC_VERSION__ >= 199901L
    #define C_VERSION "C99"
#else
    #define C_VERSION "C89/C90"
#endif

int main() {
    DBG_PRINT("Program started");
    printf("Compiled with: %s\n", C_VERSION);
    DBG_PRINT("Value of x = %d", 42);
    return 0;
}

The point to take away: on an ordinary build (gcc conditional.c), DBG_PRINT expands to nothing β€” the debug lines disappear from the executable entirely, costing not a single machine instruction. They only appear when you add the -DDEBUG flag. Try building it both ways to see the difference.

Cross-platform code

platform.c
#include <stdio.h>

// Detect the operating system at compile time
#if defined(_WIN32) || defined(_WIN64)
    #include <windows.h>
    #define CLEAR_SCREEN() system("cls")
    #define PATH_SEP '\\'
#elif defined(__linux__) || defined(__APPLE__)
    #include <unistd.h>
    #define CLEAR_SCREEN() system("clear")
    #define PATH_SEP '/'
#else
    #error "Unsupported platform!"
#endif

// Feature toggles
#ifndef MAX_USERS
    #define MAX_USERS 100  // Default value when none is passed on the command line
#endif

int main() {
    printf("Path separator: '%c'\n", PATH_SEP);
    printf("Max users: %d\n", MAX_USERS);
    // Compile with: gcc -DMAX_USERS=500 platform.c
    return 0;
}

The #error in the #else branch is a good habit: if somebody compiles on a platform you have not supported, they get a clear message at compile time instead of a baffling error about PATH_SEP not existing.

7. The multi-file build process & more advanced Makefiles

Manual compilation

Building a multi-file project takes 2 steps: compile each .c file into an object file (.o), then link them all into an executable.

Terminal
# Step 1: compile each .c file into a .o object file
gcc -c main.c -o main.o
gcc -c student.c -o student.o
gcc -c utils.c -o utils.o

# Step 2: link every .o file into one executable
gcc main.o student.o utils.o -o my_program

# Or do it all in one command (convenient, but not incremental)
gcc main.c student.c utils.c -o my_program

Makefiles and Make's built-in variables

Makefile
# Variables
CC      = gcc
CFLAGS  = -Wall -Wextra -std=c11
TARGET  = my_program
SRCS    = main.c student.c utils.c
OBJS    = $(SRCS:.c=.o)   # Rewrites .c to .o: main.o student.o utils.o

# Default rule
all: $(TARGET)

# Link the object files
$(TARGET): $(OBJS)
	$(CC) $(OBJS) -o $(TARGET)

# Pattern rule: compile any .c file into a .o
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# Clean up generated files
clean:
	rm -f $(OBJS) $(TARGET)

# Phony targets (not real files)
.PHONY: all clean

The real value of a Makefile is in the pattern rule %.o: %.c: make compares the modification time of student.c against student.o and recompiles only the file that is newer. This is exactly the incremental compilation mentioned in section 1 β€” change one file in a 500-file project and only that one file is rebuilt.

CMake β€” a cross-platform build tool

For larger projects that must support several operating systems and several compilers, CMake is the most widely used build tool. CMake does not compile anything itself: it generates a Makefile (or a project file for Visual Studio, Xcode, and so on) from a single CMakeLists.txt configuration file:

CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(MyProject C)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

# Collect every .c file in the src/ directory
file(GLOB SOURCES "src/*.c")

add_executable(my_program ${SOURCES})

# Add the include directory
target_include_directories(my_program PRIVATE include/)
Terminal
# Create a separate build directory (out-of-source build)
mkdir build && cd build
cmake ..
make

Download the sample source:

The real three-file project from section 3 β€” student.h, student.c and student_main.c (this is the main.c of the article, renamed so it does not clash with the sample files of other lessons). Put all three in the same directory and build them: gcc student_main.c student.c -o demo.

There is also multifile_demo.c, a self-contained program you can run straight away with gcc multifile_demo.c -o demo, revisiting the macro and preprocessor material.

πŸ“ Check your understanding β€” Lesson 11
Given the macro #define SQUARE(x) ((x) * (x)), what happens when you call SQUARE(a++)?

Related lessons in this series

Lesson 12: An in-browser data structure visualiser Lesson 10: Building data structures in C: linked list, stack & queue Back to the C series roadmap (Vietnamese)