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 (
staticat file scope)
The two forms of #include
// 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
#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.
#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
#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);
}
#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.
// Define the global variables (memory is allocated here)
int max_connections = 100;
const char* app_name = "MyApp";
#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.
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.cfile containing it to the compiler. -
multiple definition of 'varName': a global variable is defined (not justextern) in more than one file. The fix: declare itexternin the header and define it in exactly one.cfile.
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
#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
// 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
#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 (##)
#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
#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)
#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
#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
#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.
# 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
# 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:
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/)
# 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.
#define SQUARE(x) ((x) * (x)), what happens when you call
SQUARE(a++)?
Comments