Multi-File Programs and the Build Process

Header files, include guards, compiling multiple .c files together, object files, and a minimal Makefile.

Why split a program across multiple files

A real C program is never one giant .c file — it's split so that related functionality lives together, so that different parts of a team can work on different files without constant merge conflicts, and, critically, so the compiler doesn't have to re-process the entire program every time a single function changes. This page covers the mechanics that make that split actually work: header files, the rule that keeps declarations and definitions from colliding, compiling several .c files into one program, and a minimal Makefile to automate the whole process.

Declarations vs. definitions, and the "one definition" rule

A declaration tells the compiler a function or variable exists somewhere, along with its type — enough to check calls against it, but no actual code or storage. A definition is the real thing: the function body, or the variable's actual storage. Every function and global variable must have exactly one definition across the whole program, but can be declared — the same declaration, repeated — in as many files as need to call it.

C
// Declaration — "a function named add exists, taking two ints, returning an int"
int add(int a, int b);

// Definition — the actual implementation; this must exist exactly once in the whole program
int add(int a, int b) {
    return a + b;
}

Header files exist purely to hold declarations, so that every .c file that needs to call a function (or use a struct/typedef) can #include one shared statement of "here's what this looks like," without duplicating (and risking a mismatch in) the same declaration by hand in every file.

Building a small multi-file project

A tiny calculator project, split into three files: a header declaring the public interface, a source file implementing it, and a main.c that uses it.

calculator.h:

C
#ifndef CALCULATOR_H
#define CALCULATOR_H

int add(int a, int b);
int subtract(int a, int b);
int multiply(int a, int b);

#endif

calculator.c:

C
#include "calculator.h" // the .c file including its OWN header keeps the two in sync automatically

int add(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

int multiply(int a, int b) {
    return a * b;
}

main.c:

C
#include <stdio.h>
#include "calculator.h" // quotes for a project-local header, angle brackets for a system one

int main(void) {
    printf("%d\n", add(4, 3));       // 7
    printf("%d\n", subtract(4, 3));  // 1
    printf("%d\n", multiply(4, 3));  // 12
    return 0;
}

Compiling and linking all three together in one command:

Bash
gcc -std=c17 -Wall -Wextra -o calculator main.c calculator.c
./calculator

Notice calculator.c #includes its own header. This is a deliberate, idiomatic habit, not an accident: if calculator.c's function signatures ever drift out of sync with what calculator.h declares (a changed parameter type, for instance), the compiler catches the mismatch immediately while compiling calculator.c itself, rather than only failing later when some unrelated file tries to call it.

Separate compilation: object files and the linker

Compiling main.c calculator.c together as shown above is fine for a small project, but it means recompiling every source file every time you build — wasteful once a project has dozens of files and you've only touched one of them. The two-stage alternative compiles each .c file to an object file (.o) independently, then a separate link step combines the object files into the final executable:

Bash
gcc -std=c17 -Wall -Wextra -c main.c        # produces main.o (compile only, no linking: -c)
gcc -std=c17 -Wall -Wextra -c calculator.c  # produces calculator.o
gcc -o calculator main.o calculator.o       # link the two object files into one executable

The real payoff: if you only change calculator.c, only calculator.c needs recompiling to a fresh calculator.omain.o is untouched and can be reused as-is in the final link. This is exactly the problem a Makefile (below) automates: figuring out which object files are now stale and need rebuilding, and re-linking only when necessary.

A minimal Makefile

make reads a Makefile describing targets, the dependencies each target needs, and the recipe (shell commands) to build it — then rebuilds only the targets whose dependencies are newer than the target itself.

Makefile
CC = gcc
CFLAGS = -std=c17 -Wall -Wextra

calculator: main.o calculator.o
	$(CC) -o calculator main.o calculator.o

main.o: main.c calculator.h
	$(CC) $(CFLAGS) -c main.c

calculator.o: calculator.c calculator.h
	$(CC) $(CFLAGS) -c calculator.c

clean:
	rm -f *.o calculator
Bash
make          # builds only what's stale or missing
make clean    # removes generated object files and the executable

The recipe lines ($(CC) ...) must be indented with an actual tab character, not spaces — this is a famous, longstanding Makefile gotcha, and most editors default to spaces unless configured otherwise for Makefiles specifically. Running make a second time with nothing changed does no work at all (make: 'calculator' is up to date); touching only calculator.c and running make again recompiles just calculator.o and re-links, leaving main.o alone — exactly the incremental behavior that makes larger C projects practical to build repeatedly during development.

Sharing a variable across files with extern

Occasionally more than one .c file genuinely needs to read (or write) the same global variable, not just call shared functions. The variable is defined once, in one .c file, and declared as extern (meaning "this exists somewhere else, just use it") in the header everyone else includes:

config.h:

C
#ifndef CONFIG_H
#define CONFIG_H

extern int debugMode; // declaration only — "this variable exists somewhere"

#endif

config.c:

C
#include "config.h"

int debugMode = 0; // the ONE actual definition, with storage

main.c:

C
#include <stdio.h>
#include "config.h"

int main(void) {
    debugMode = 1;                    // legal — same variable, shared across files
    printf("%d\n", debugMode); // 1
    return 0;
}

Global mutable state shared this way should be used sparingly — it makes it harder to reason about which part of a large program changed a value and when — but extern is the correct, standard mechanism when it's genuinely needed (a global logging flag, a shared configuration struct).

Common mistakes

  • Forgetting the include guard (#ifndef/#define/#endif) in a header included from more than one .c file (directly or indirectly), causing "redefinition" compiler errors.
  • Defining a function's actual body in a header file instead of only declaring it there — if that header is then included by more than one .c file, the linker reports a "multiple definition" error.
  • Writing a Makefile recipe line indented with spaces instead of a literal tab — make fails with a cryptic "missing separator" error.
  • Forgetting extern on a shared global variable's declaration in a header, which either fails to compile or (worse) accidentally creates a separate variable in each file instead of truly sharing one.

Interview questions

Q: What's the practical difference between a declaration and a definition in C, and why does that distinction matter across multiple files? A declaration tells the compiler a function or variable's type/signature so it can check and compile calls to it, without providing its actual code or storage. A definition is the real implementation or storage, and must exist exactly once across the entire linked program. Header files exist specifically to share declarations across many .c files safely, while each definition stays in exactly one .c file — this is what lets separate files call each other's functions without either duplicating code or causing "multiple definition" link errors.

Q: Why does splitting a build into separate compilation and linking steps (.o files, then a final link) matter for a larger project? Because it lets a build tool like make recompile only the source files that actually changed (or whose header dependencies changed) since the last build, reusing the already-compiled object files for everything else, then relinking. For a project with dozens or hundreds of files, this turns a full rebuild that might take minutes into an incremental rebuild that takes a fraction of a second for a small, localized change.