C Header Files and Multi-file Programs
A C program rarely stays in a single file for long. As soon as you want to reuse code across projects, split a large program into logical modules, or let several source files agree on the same functions and types, you need header files. A header file (a .h file) holds declarations — function prototypes, type definitions, macros, and extern variable declarations — that multiple source files can #include, while the actual code lives in exactly one place. Understanding how headers and multi-file compilation work is essential once a program grows beyond a single main.c.
Overview: How Header Files and Multi-file Programs Work
A C project is usually made of several .c files, called translation units. Each translation unit is compiled independently and completely on its own — the compiler working on math_utils.c knows nothing about what is written in main.c unless it is told. That "telling" happens through declarations: statements that describe the type and signature of a function or variable without providing its actual body or storage. A definition, by contrast, is the real thing — the function body, or the memory-allocating variable statement.
The compiler only needs a declaration to check that a call is used correctly (right number and type of arguments, correct return type). It does not need the definition until later, when a separate program called the linker stitches all the compiled object files together into one executable. The linker’s job is to match every function or variable that was only declared (used) in one object file with the object file that actually defines it. If no file defines it, you get an undefined reference error. If more than one file defines it, you get a multiple definition error.
This is exactly the problem header files solve. Instead of retyping the same function prototypes in every .c file that needs them (and risking them getting out of sync), you write the declarations once in a .h file and #include that header wherever it is needed. Remember that #include is handled entirely by the preprocessor, before real compilation starts: it literally opens the named file and pastes its full text at that exact spot, then keeps preprocessing as if you had typed it there yourself. There is no special "header file" behavior built into the compiler — a header is just text that gets copied into whichever file includes it.
Syntax
The general form of an include directive is one of these two:
#include <filename.h>
#include "filename.h"
| Form | Where it searches | Typical use |
|---|---|---|
#include <file.h> |
The compiler’s standard system include directories | Standard library headers, e.g. <stdio.h>, <string.h> |
#include "file.h" |
The current file’s own directory first, then the system directories | Your own project headers |
Because a header can be included by more than one file, and sometimes even more than once by the same file (through nested includes), headers are protected with an include guard:
#ifndef MODULE_NAME_H
#define MODULE_NAME_H
/* declarations go here */
#endif
#ifndef MODULE_NAME_H— only proceed if this macro is not yet defined.#define MODULE_NAME_H— define it immediately, so a second inclusion in the same translation unit is skipped.#endif— closes the guarded block.
Many compilers also support the non-standard but very widely supported #pragma once as a one-line shortcut for the same effect. Finally, if a header needs to expose a global variable, it must use extern to declare it (no storage created), while exactly one .c file provides the real definition.
Examples
Example 1: How Standard Library Headers Work
Every time you write #include <stdio.h>, you are using exactly the mechanism described above: the header brings in prototypes such as printf, while the actual compiled code for printf lives in the C standard library and is linked in automatically.
#include <stdio.h>
#include <string.h>
#include <ctype.h>
int main(void) {
char word[] = "Header";
int len = strlen(word);
for (int i = 0; i < len; i++) {
word[i] = toupper(word[i]);
}
printf("Uppercase word: %s\n", word);
printf("Length: %d characters\n", len);
return 0;
}
Output:
Uppercase word: HEADER
Length: 6 characters
Three different headers each contribute declarations for functions defined elsewhere in the standard library: strlen from <string.h>, toupper from <ctype.h>, and printf from <stdio.h>. Your own project headers work the same way, just with your own code instead of library code.
Example 2: Creating Your Own Header for a Module
Suppose you want a small reusable math module with three functions. You create three files: a header with declarations, a source file with the definitions, and a main program that uses the module.
/* mathutils.h */
#ifndef MATHUTILS_H
#define MATHUTILS_H
int add(int a, int b);
int subtract(int a, int b);
double average(int a, int b);
#endif
/* mathutils.c */
#include "mathutils.h"
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
double average(int a, int b) {
return (a + b) / 2.0;
}
/* main.c */
#include <stdio.h>
#include "mathutils.h"
int main(void) {
int x = 15, y = 7;
printf("Sum: %d\n", add(x, y));
printf("Difference: %d\n", subtract(x, y));
printf("Average: %.1f\n", average(x, y));
return 0;
}
Output:
Sum: 22
Difference: 8
Average: 11.0
Notice that mathutils.c includes its own header — this lets the compiler double-check that the definitions actually match the declared prototypes. To build this program you compile and link both source files together, for example: gcc main.c mathutils.c -o program. Neither file alone contains a complete program; the header is what lets them cooperate.
Example 3: Sharing a Struct Type and a Global Constant
Header files are also where you put shared type definitions, such as struct and typedef, since a type definition doesn’t allocate storage and is safe to duplicate into every file that includes it.
/* point.h */
#ifndef POINT_H
#define POINT_H
typedef struct {
double x;
double y;
} Point;
extern const double ORIGIN_TOLERANCE;
Point make_point(double x, double y);
double distance(Point a, Point b);
void print_point(Point p);
#endif
/* point.c */
#include <stdio.h>
#include <math.h>
#include "point.h"
const double ORIGIN_TOLERANCE = 0.0001;
Point make_point(double x, double y) {
Point p;
p.x = x;
p.y = y;
return p;
}
double distance(Point a, Point b) {
double dx = a.x - b.x;
double dy = a.y - b.y;
return sqrt(dx * dx + dy * dy);
}
void print_point(Point p) {
printf("(%.2f, %.2f)\n", p.x, p.y);
}
/* main.c */
#include <stdio.h>
#include "point.h"
int main(void) {
Point a = make_point(0.0, 0.0);
Point b = make_point(3.0, 4.0);
printf("Point A: ");
print_point(a);
printf("Point B: ");
print_point(b);
printf("Distance: %.2f\n", distance(a, b));
return 0;
}
Output:
Point A: (0.00, 0.00)
Point B: (3.00, 4.00)
Distance: 5.00
The Point type and the function prototypes live in point.h, so both point.c and main.c agree on exactly what a Point looks like. The global constant ORIGIN_TOLERANCE is declared with extern in the header but defined only once, in point.c — this is the correct pattern for sharing a global variable across files. You would build this with gcc main.c point.c -o program (adding -lm on some systems for sqrt).
Under the Hood: What the Toolchain Actually Does
Building a multi-file program happens in distinct stages:
- 1. Preprocessing (per file): Each
.cfile is preprocessed independently. Every#includeis textually expanded, every macro is substituted, and include guards make sure a header’s content is only pasted in once per file. The result is one long, self-contained block of source text called a translation unit. - 2. Compiling (per file): Each translation unit is compiled into an object file (
.oon Linux/macOS,.objon Windows). The compiler checks every function call and variable use against the declarations it has seen (often via headers) and emits machine code, leaving a placeholder for any symbol whose definition isn’t in this file. - 3. Linking (all files together): The linker combines all the object files (and any libraries) into a single executable. It resolves each placeholder by finding the matching definition in another object file. This is also where undefined reference and multiple definition errors appear — they are link-time errors, not compile-time errors, which is why they can be confusing: the code looks syntactically fine.
Because each .c file is preprocessed and compiled on its own, the same header can safely be included by many different .c files — each gets its own independent copy of the declarations pasted in during preprocessing. That is expected and required. The danger is only when a header accidentally contains a definition instead of a declaration, because then the same definition gets duplicated into multiple object files, and the linker refuses to pick one arbitrarily.
Common Mistakes
Mistake 1: Forgetting Include Guards
Without a guard, including the same header twice in one translation unit (often indirectly, through two other headers that both include it) redefines every type and prototype in it, which the compiler rejects.
/* config.h -- WRONG: no include guard */
struct Settings {
int volume;
int brightness;
};
If two different headers both #include "config.h" and main.c includes both of those headers, struct Settings gets pasted in twice, producing a "redefinition of struct Settings" error. The fix is always to guard the header:
/* config.h -- CORRECT */
#ifndef CONFIG_H
#define CONFIG_H
struct Settings {
int volume;
int brightness;
};
#endif
Mistake 2: Defining a Variable in a Header Instead of Declaring It
A header that is included by several .c files must never contain an actual variable definition, or the linker will see the same symbol defined in multiple object files.
/* counter.h -- WRONG: defines storage */
int global_counter = 0;
Including this header from two different .c files causes a "multiple definition of `global_counter`" error at link time. The fix is to declare it with extern in the header and define it exactly once in a source file:
/* counter.h -- CORRECT */
extern int global_counter;
/* counter.c -- the one and only definition */
int global_counter = 0;
Best Practices
- Always protect every header with an include guard (or
#pragma once), with no exceptions. - Put only declarations, type definitions (
struct/typedef/enum), macros, andexternvariable declarations in headers — never function bodies with side effects or variable definitions that allocate storage. - Make each header self-contained: have it
#includeanything it itself depends on, rather than relying on the includer to get the order right. - Use
"quotes"for your own project headers and<angle brackets>for standard library or third-party headers. - Give one
.h/.cpair to each logical module, and name the header after the module it declares. - Avoid circular includes (A.h including B.h which includes A.h back); guards stop infinite loops, but a design that needs this usually needs restructuring instead.
- For anything beyond a couple of files, use a build system (a
Makefileor similar) instead of typing the fullgcccommand by hand every time.
Practice Exercises
- Create a
string_utils.h/string_utils.cpair with two functions,reverse_stringandcount_vowels, and call both from a separatemain.c. Compile all files together into one program. - Take a single-file program that defines a
struct Rectangleand a function that computes its area, and split it into a header (type and prototype), a source file (the function definition), and amain.cthat uses it. Confirm it still compiles and links. - Deliberately remove the include guard from one of your headers, then include it from two other headers that are both included in
main.c. Observe the compiler error, then add the guard back and confirm the error disappears.
Summary
- A header file holds declarations — prototypes, types, macros, and
externvariables — shared by multiple source files. #includeis a preprocessor directive that textually pastes a file’s contents in place; it has no special compiled behavior of its own.- Each
.cfile is a separate translation unit, compiled independently; the linker later matches declarations to their one true definition across object files. - Use
<angle brackets>for system headers and"quotes"for your own headers. - Always guard headers with
#ifndef/#define/#endif(or#pragma once) to prevent redefinition errors from double inclusion. - Keep definitions out of headers; declare global variables with
externand define them exactly once in a.cfile to avoid multiple-definition linker errors.
