Part of C in 100s

C in 100 Seconds: Multi-File Projects

Celest KimCelest Kim

Video: C in 100 Seconds: Multi-File Projects — Headers, Linking, Make | Episode 41 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

C Multi-File Projects: Headers, Linking, Make

Split code across .c files; declare APIs in .h headers. Compile each .c to a .o; link the .os into one executable. Use a Makefile to 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:

  1. Compile each .c to .o. -c stops after compilation; produces an object file (machine code, but unlinked).
  2. Compile main.c to main.o. Same step for the file that uses our library.
  3. Link the objects into an executable. GCC sees the .o extension 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:

  1. Checks if app exists.
  2. If main.o or math_utils.o is newer than app (or app doesn'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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.