C in 100 Seconds: Multi-File Projects
Video: C in 100 Seconds: Multi-File Projects — Headers, Linking, Make | Episode 41 by Taught by Celeste AI - AI Coding Coach
C Multi-File Projects: Headers, Linking, Make
Split code across
.cfiles; declare APIs in.hheaders. Compile each.cto a.o; link the.os into one executable. Use aMakefileto automate.
Real C projects span multiple files. Today: how the pieces fit together — and a minimal Makefile that builds them.
The setup
math_utils.h (header — declarations):
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
math_utils.c (implementation):
#include "math_utils.h"
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
main.c (uses both):
#include <stdio.h>
#include "math_utils.h"
int main() {
printf("add(10, 20) = %d\n", add(10, 20));
printf("multiply(5, 6) = %d\n", multiply(5, 6));
return 0;
}
Compiling by hand
gcc -c math_utils.c -o math_utils.o
gcc -c main.c -o main.o
gcc main.o math_utils.o -o app
./app
Three steps:
- Compile each
.cto.o.-cstops after compilation; produces an object file (machine code, but unlinked). - Compile main.c to main.o. Same step for the file that uses our library.
- Link the objects into an executable. GCC sees the
.oextension and links instead of compiling.
For larger projects, doing this manually is painful. Enter make.
The Makefile
CC = gcc
CFLAGS = -Wall
app: main.o math_utils.o
$(CC) $(CFLAGS) -o app main.o math_utils.o
main.o: main.c math_utils.h
$(CC) $(CFLAGS) -c main.c
math_utils.o: math_utils.c math_utils.h
$(CC) $(CFLAGS) -c math_utils.c
clean:
rm -f app *.o
make is a build tool. It reads the Makefile and runs only the commands needed.
Run it:
make # builds the default target (first rule: 'app')
make clean # runs the 'clean' target
make app # explicitly build app
Anatomy of a Makefile rule
target: dependencies
<TAB>command
- target — what you're building.
- dependencies — files that, if newer than the target, trigger a rebuild.
- command — the shell command to run. Must start with a TAB, not spaces.
For app: main.o math_utils.o, make:
- Checks if
appexists. - If
main.oormath_utils.ois newer thanapp(orappdoesn't exist), runs the command.
Otherwise, says "app is up to date" and skips.
Variables
CC = gcc
CFLAGS = -Wall
Reusable values. $(CC) and $(CFLAGS) substitute throughout. To override at command line:
make CC=clang CFLAGS=-O2
Common variables:
CC— C compiler.CFLAGS— compiler flags.LDFLAGS— linker flags.LDLIBS— libraries to link (-lm,-lpthread).
Why incremental builds matter
# Edit math_utils.c
make
# Output: only math_utils.c is recompiled; main.o is reused
Make figures out the minimal recompile set. For huge projects (thousands of files), this is the difference between "make takes 1 second" and "make takes 5 minutes."
Pattern rules
For projects with many .c files, write one rule that handles all:
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
% is a wildcard. $< is the first dependency; $@ is the target. This rule says "to build X.o, compile X.c."
Then list dependencies:
app: main.o math_utils.o
$(CC) -o $@ $^
main.o: main.c math_utils.h
math_utils.o: math_utils.c math_utils.h
$^ is all dependencies. The pattern rule fills in the recipe; the explicit rules add header dependencies.
For very modern Makefiles, gcc -MMD generates dependency files automatically:
%.o: %.c
$(CC) $(CFLAGS) -MMD -c $< -o $@
-include $(wildcard *.d)
-MMD outputs X.d files listing each .o's header deps. -include *.d reads them in. Now editing a header triggers correct recompiles.
phony targets
.PHONY: clean
clean:
rm -f app *.o
.PHONY: clean tells make "clean isn't a file; always run the recipe." Without it, if a file named clean existed, make would do nothing.
Common phony targets: all, clean, install, test.
Larger projects
project/
├── include/
│ ├── math_utils.h
│ └── string_utils.h
├── src/
│ ├── main.c
│ ├── math_utils.c
│ └── string_utils.c
├── obj/ (build artifacts; .gitignore'd)
└── Makefile
Compile with -Iinclude so #include "math_utils.h" works. Output objects into obj/ to keep source clean.
SRC_DIR = src
OBJ_DIR = obj
INC_DIR = include
SOURCES = $(wildcard $(SRC_DIR)/*.c)
OBJECTS = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SOURCES))
CC = gcc
CFLAGS = -Wall -I$(INC_DIR)
app: $(OBJECTS)
$(CC) -o $@ $^
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
$(CC) $(CFLAGS) -c $< -o $@
$(OBJ_DIR):
mkdir -p $(OBJ_DIR)
.PHONY: clean
clean:
rm -rf $(OBJ_DIR) app
Wildcards find all .c files; patsubst translates source names to object names. Adding a new .c file requires no Makefile changes.
Beyond Make
For non-trivial projects, use:
- CMake — generates platform-specific build files (Make, Ninja, MSBuild).
- Meson — modern, fast, write-once configure tool.
- Bazel/Buck — large monorepos.
- scons/waf — Python-based.
Make is fine for small/medium C projects; the others scale better.
Common mistakes
Tabs vs spaces. Make recipes must use TAB. Spaces give "missing separator" error.
Forgetting header dependencies. Editing a .h doesn't trigger rebuild. Either list explicitly or use -MMD.
Including .c files. Never #include "other.c" — that compiles its contents twice. Headers (.h) only.
Defining things in headers. Linker errors from multiple definitions. Headers should declare; .c files should define.
Phony targets without .PHONY. A file named clean in your tree blocks the rule.
Recipe lines that depend on shell state. Make runs each line in a fresh subshell. To chain, use &&: cd build && make.
What's next
Episode 42: linked lists. Dynamic data structures — nodes connected by pointers, growing as needed.
Recap
Multi-file C: header (.h) declares; source (.c) defines. Compile each .c to .o (-c); link .os into executable. Makefile automates with rules: target: deps + TAB-indented command. $(CC) and $(CFLAGS) for variable reuse. Pattern rule %.o: %.c for one rule covering all sources. .PHONY for non-file targets. For dependency tracking, -MMD auto-generates header-deps. For larger projects, organise in src/, include/, obj/.
Next episode: linked lists.