Back to Technology

GNU Make Mastery Part 7: Automatic Dependency Generation

February 23, 2026 Wasil Zafar 20 min read

Eliminate stale builds caused by header changes: use GCC's -MM, -MD, and -MP flags to auto-generate .d dependency files and include them so Make knows exactly which translation units to rebuild after any .h modification.

Table of Contents

  1. The Header Problem
  2. GCC Dependency Flags
  3. Including .d Files in Make
  4. Generated Headers & Out-of-Source
  5. Hands-On Milestone
  6. Next Steps

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.

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.

Generated Headers & Out-of-Source Builds

Some projects generate headers (e.g., from .proto files or configure scripts). These headers must be listed as prerequisites of the rules that generate them and as order-only prerequisites of the compile rules that use them.

BUILDDIR := build

# Rule to generate a header from a .proto file
$(BUILDDIR)/protocol.pb.h: protocol.proto
	protoc --c_out=$(BUILDDIR) $<              # $< = protocol.proto

# Compile rule: order-only prereq (|) ensures header is generated first
# but does NOT trigger recompile when header's timestamp changes
$(BUILDDIR)/handler.o: src/handler.c | $(BUILDDIR)/protocol.pb.h
	$(CC) $(CFLAGS) -I$(BUILDDIR) -MMD -MP -c $< -o $@

For out-of-source builds where .d files contain absolute or different-prefix paths, use -MF explicitly to control where dependency files are written.

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.

Technology