The Header Problem
Part 7 of 16 — GNU Make Mastery Series. Up to now our Makefiles only track .c → .o dependencies. But a .c file can include dozens of headers. Change a header and nothing gets rebuilt — your binary silently becomes stale. This part fixes that completely.
1
Build Systems Foundations
Why Make, compilation pipeline, basics
2
Targets, Prerequisites & Execution
Rule anatomy, PHONY, build workflows
3
Variables, Expansion & Scope
= vs :=, ?=, +=, CLI overrides
4
Automatic Variables & Pattern Rules
$@, $<, $^, $?, %.o: %.c patterns
5
Built-in Functions & Make Language
subst, wildcard, foreach, $(shell)
6
Conditionals & Configurable Builds
ifeq, ifdef, platform detection, flags
7
Automatic Dependency Generation
-M, -MM, -MD, .d files, header deps
You Are Here
8
Compilation Workflow & Libraries
Static/shared libs, ar, -fPIC, SONAME
9
Project Architecture & Multi-Directory
Recursive make, include, out-of-source
10
Cross-Compilation & Toolchains
Toolchain prefixes, sysroots, embedded
11
Parallel Builds & Performance
make -j, jobserver, race conditions
12
Testing, Coverage & Debug Tooling
Test targets, gcov/lcov, sanitizers
13
Make as Automation & DevOps Tool
Task runner, Docker, install, packaging
14
CI/CD Integration
Deterministic builds, GitHub Actions, cache
15
Advanced Make & Debugging
--debug, dynamic rules, evaluation traps
16
Ecosystem, Alternatives & Mastery
CMake, Ninja, Meson, Bazel, production
Stale Builds Explained
Consider a project where main.c includes config.h. Our Makefile rule is:
# BUG: only lists main.c — missing config.h as a prerequisite!
main.o: main.c
$(CC) $(CFLAGS) -c main.c -o main.o
If we edit config.h and run make, Make sees that main.o is newer than main.c — so it does nothing. The resulting binary contains stale code that reflects the old header. This is a real-world source of extremely hard-to-diagnose bugs.
Naive Solutions & Their Flaws
Anti-pattern
Manually Listing Headers
# Don't do this — doesn't scale
main.o: main.c main.h config.h utils.h network.h crypto.h
$(CC) $(CFLAGS) -c $< -o $@
This works but becomes a maintenance nightmare in any non-trivial project. Add a new header to main.c and forget to update the Makefile — stale builds again.
Sample Source Files
Self-contained examples: Dependency-generation examples scan header includes in main.c and its headers. These stubs give the -MMD compiler flag realistic include chains to trace.
main.h
/* main.h — top-level config */
#ifndef MAIN_H
#define MAIN_H
#include "utils.h"
#include "network.h"
#define APP_NAME "demo"
#endif
utils.h
/* utils.h */
#ifndef UTILS_H
#define UTILS_H
void utils_greet(const char *name);
int utils_add(int a, int b);
#endif
network.h
/* network.h */
#ifndef NETWORK_H
#define NETWORK_H
void net_connect(const char *host, int port);
void net_disconnect(void);
#endif
crypto.h
/* crypto.h */
#ifndef CRYPTO_H
#define CRYPTO_H
void crypto_hash(const char *input, char *output, int outlen);
#endif
main.c
/* main.c — includes several headers to create a realistic dep chain */
#include <stdio.h>
#include "main.h"
#include "crypto.h"
int main(void) {
char hash[64];
crypto_hash("hello", hash, 64);
utils_greet(hash);
return 0;
}
GCC Dependency Flags
-M and -MM
-M instructs GCC to output a Make dependency rule listing all headers (including system headers). -MM is usually preferred — it omits system headers (/usr/include/) since those rarely change.
# Run manually to see what's generated:
gcc -MM main.c
# Output:
# main.o: main.c main.h config.h utils.h
-MD and -MMD — During Compilation
These flags make GCC write the dependency output to a .d file alongside normal compilation — no separate preprocessing pass needed. -MMD = -MM -MD (omit system headers, write to .d).
# GCC writes main.d during the regular compile:
gcc -Wall -c -MMD -MP main.c -o main.o
# Creates: main.o AND main.d
# Example contents of main.d:
main.o: main.c main.h config.h utils.h
main.h: # phony target added by -MP
config.h: # (prevents error if header is deleted)
utils.h:
-MP — Phony Targets for Headers
When a header is deleted and it still appears in a .d file, Make errors with "No rule to make target deleted_header.h". The -MP flag adds a phony (empty) target for each header, so Make silently ignores missing ones and just rebuilds the affected .o.
-MF — Custom Dependency File Path
# Write dependency file to a specific path (e.g., in a separate dep dir):
gcc -MMD -MP -MF build/deps/main.d -c main.c -o build/main.o
Including .d Files in Make
Include the generated dependency files at the end of your Makefile using -include (the - prefix suppresses errors on first build when .d files don't exist yet).
CC := gcc
CFLAGS := -Wall -Wextra -std=c11 -O2
SRCDIR := src
BUILDDIR := build
DEPDIR := $(BUILDDIR)/deps
SRCS := $(wildcard $(SRCDIR)/*.c)
OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS))
DEPS := $(patsubst $(SRCDIR)/%.c,$(DEPDIR)/%.d,$(SRCS))
TARGET := $(BUILDDIR)/myapp
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) -o $@ $^
# The key: -MMD -MP to generate .d file, -MF to specify output path
$(BUILDDIR)/%.o: $(SRCDIR)/%.c
@mkdir -p $(BUILDDIR) $(DEPDIR)
$(CC) $(CFLAGS) -MMD -MP -MF $(DEPDIR)/$*.d -c $< -o $@
# Include all .d files — silent on first build (no .d files yet)
-include $(DEPS)
clean:
rm -rf $(BUILDDIR)
How it works: On the first make, no .d files exist — the -include is silently ignored. GCC creates them during compilation. On the second make, the .d files are included, adding each header as a prerequisite of the corresponding .o. Now any header change triggers the right recompilation.
Hands-On Milestone
Milestone: A production-grade Makefile with full automatic dependency tracking — change any .h file and only the affected translation units rebuild.
# Production Makefile with automatic dependency generation
# ── Variables ────────────────────────────────────────
CC := gcc
CFLAGS := -Wall -Wextra -std=c11 -O2
SRCDIR := src
BUILDDIR := build
TARGET := $(BUILDDIR)/myapp
SRCS := $(wildcard $(SRCDIR)/*.c) # find all .c files
OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS)) # src/foo.c → build/foo.o
DEPS := $(OBJS:.o=.d) # .o → .d (dep files generated by -MMD)
.PHONY: all clean info
all: $(TARGET)
# ── Link ──
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) -o $@ $^ # $^ = all .o files
# ── Compile (with auto-dep generation) ──
$(BUILDDIR)/%.o: $(SRCDIR)/%.c
@mkdir -p $(BUILDDIR)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@ # -MMD writes .d file, -MP adds phony header targets
# Pull in generated .d files (silent on first build when none exist)
-include $(DEPS)
clean:
rm -rf $(BUILDDIR)
info:
@echo "CC = $(CC)"
@echo "CFLAGS = $(CFLAGS)"
@echo "TARGET = $(TARGET)"
@echo "SRCDIR = $(SRCDIR)"
@echo "SRCS = $(SRCS)"
@echo "OBJS = $(OBJS)"
@echo "DEPS = $(DEPS)"
# --- Setup: create src/ directory with all source files ---
mkdir -p src
cat > src/main.h << 'EOF'
#ifndef MAIN_H
#define MAIN_H
#include "utils.h"
#include "network.h"
#define APP_NAME "demo"
#endif
EOF
cat > src/utils.h << 'EOF'
#ifndef UTILS_H
#define UTILS_H
void utils_greet(const char *name);
int utils_add(int a, int b);
#endif
EOF
cat > src/network.h << 'EOF'
#ifndef NETWORK_H
#define NETWORK_H
void net_connect(const char *host, int port);
void net_disconnect(void);
#endif
EOF
cat > src/crypto.h << 'EOF'
#ifndef CRYPTO_H
#define CRYPTO_H
void crypto_hash(const char *input, char *output, int outlen);
#endif
EOF
cat > src/main.c << 'EOF'
#include <stdio.h>
#include <string.h>
#include "main.h"
#include "crypto.h"
int main(void) {
char hash[64];
crypto_hash("hello", hash, 64);
utils_greet(hash);
return 0;
}
EOF
cat > src/utils.c << 'EOF'
#include <stdio.h>
#include "utils.h"
void utils_greet(const char *name) { printf("Hello, %s!\n", name); }
int utils_add(int a, int b) { return a + b; }
EOF
cat > src/network.c << 'EOF'
#include <stdio.h>
#include "network.h"
void net_connect(const char *host, int port) { printf("Connecting to %s:%d\n", host, port); }
void net_disconnect(void) { printf("Disconnected\n"); }
EOF
cat > src/crypto.c << 'EOF'
#include <stdio.h>
#include <string.h>
#include "crypto.h"
void crypto_hash(const char *input, char *output, int outlen) {
snprintf(output, outlen, "hash(%s)", input);
}
EOF
# --- Try it: verify dependency tracking ---
make info # display all variable values
make # full build
ls build/*.d # see generated .d dependency files
cat build/main.d # inspect dependency chain for main.c
touch src/utils.h # simulate header change
make # only files including utils.h rebuild
touch src/crypto.h # simulate another header change
make # only crypto-dependent files rebuild
make clean # remove build artifacts
Next in the Series
In Part 8: Compilation Workflow & Libraries, we extend our Makefile to produce static (.a) and shared (.so) libraries using ar and -fPIC, manage SONAME versioning, and control link order for complex multi-library projects.
Continue the Series
Part 8: Compilation Workflow & Libraries
Build static and shared libraries with ar, -fPIC, and -shared; manage SONAME versioning and link order.
Read Article
Part 9: Project Architecture & Multi-Directory
Scale to large codebases with recursive $(MAKE) -C, include, and out-of-source build layouts.
Read Article
Part 6: Conditionals & Configurable Builds
Review ifeq, ifdef, OS detection, and feature flags for portably configuring Makefiles from the command line.
Read Article