Back to Technology

GNU Make Mastery Part 15: Advanced Make & Debugging

February 26, 2026 Wasil Zafar 25 min read

Unlock GNU Make's full debugging toolkit: --debug verbosity levels, -p database dumps, $(warning) and $(error) tracing macros, dynamic rule generation with $(eval), $(call), and define, plus the subtle evaluation-order traps that trip up experienced users.

Table of Contents

  1. Why Makefiles Are Hard to Debug
  2. The --debug Flag
  3. The -p Database Dump
  4. Tracing with $(warning) & $(error)
  5. Dynamic Rule Generation
  6. Evaluation Traps
  7. Makefile Linting & Tooling
  8. Next Steps

Why Makefiles Are Hard to Debug

Part 15 of 16 — GNU Make Mastery Series. GNU Make is a declarative, two-phase language with lazy string expansion — a combination that makes bugs uniquely difficult to spot. This article gives you the full debugging arsenal: built-in flags, print tracing, dynamic rule generation, and a catalogue of the evaluation traps every Make veteran has fallen into at least once.

Two-Phase Execution

Every GNU Make invocation runs in two distinct phases:

Phase 1 — Read & Parse

Make reads all Makefiles, expands immediate (:=) variables, evaluates include directives, and builds the dependency graph. No recipes run during this phase.

Phase 2 — Execute

Make walks the dependency graph, checks timestamps, and runs shell recipe lines for out-of-date targets. Deferred (=) variables are expanded here, immediately before each recipe line executes.

Most confusing Makefile bugs are caused by misunderstanding which phase a particular expansion happens in. The debugging tools below reveal exactly what Make sees at each phase.

Diagram showing Make's two-phase execution model with Phase 1 parsing variables and building the dependency graph, and Phase 2 walking the graph and executing recipes
Make's two-phase execution: Phase 1 reads Makefiles and builds the dependency graph; Phase 2 walks the graph and fires recipes for out-of-date targets

Sample Source Files

Self-contained examples: Debugging examples run Make against a real project to show how --debug, $(warning), and --dry-run output maps to source. These two files produce the exact build graph described in the tracing walkthroughs.

main.c

/* main.c — entry point */
#include <stdio.h>
#include "utils.h"

int main(void) {
    utils_greet("Debugging Make");
    printf("Result: %d\n", utils_add(3, 4));
    return 0;
}

utils.h

/* utils.h */
#ifndef UTILS_H
#define UTILS_H
void utils_greet(const char *name);
int  utils_add(int a, int b);
#endif

utils.c

/* utils.c */
#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; }

The --debug Flag

Pass --debug (or -d) to make Make print internal decisions to stderr. Without an argument it defaults to the basic level.

Diagram showing the hierarchy of --debug verbosity levels from basic through verbose, implicit, jobs, makefile, to all
The --debug flag hierarchy: basic shows rebuild decisions, verbose adds implicit rule search, jobs traces parallel scheduling, and all combines everything

Verbosity Levels

Debug Levels Reference
FlagWhat It Shows
--debug=b / --debugbasic — which targets are considered, why each is rebuilt
--debug=vverbose — implicit rule search details
--debug=iimplicit — every implicit rule examined per target
--debug=jjobs — parallel job fork/reap events
--debug=mmakefile — re-reading of Makefiles themselves
--debug=nnone — turns debug off (useful to override MAKEFLAGS)
--debug=aall — everything; equivalent to combining all letters above
# Why is 'all' being rebuilt?
make --debug=b all 2>&1 | head -40

# Trace parallel job scheduling problems
make --debug=j -j4 2>&1 | grep -E 'Putting|Reaping|Live'

# Full diagnostic dump (pipe through less for large outputs)
make --debug=a 2>&1 | less

Reading Debug Output

A typical --debug=b line looks like:

Considering target file 'src/main.o'.
  File 'src/main.o' does not exist.
  File 'src/main.c' exists.
Must remake target 'src/main.o'.
  Invoking recipe from Makefile:12 ...

Key phrases to scan for:

  • does not exist — target has never been built
  • older than — normal incremental rebuild trigger
  • is up to date — no rebuild needed
  • Circular ... dependency dropped — dependency cycle detected (see below)
Tip: Pipe make --debug=b 2>&1 | grep -E 'Considering|Must|up to date' to get a concise rebuild decision log without the noise of every prerequisite check.

The "Circular Dependency Dropped" Warning

One of the most confusing messages Make can produce is:

make: Circular main.o <- main.o dependency dropped.

This warning means Make detected a dependency cycle — target A depends on B, and B depends on A (directly or through a chain). Make cannot build a cycle, so it silently drops the circular edge and continues. The build may succeed, but the dependency graph is now incomplete, meaning incremental rebuilds can miss necessary recompilations.

What Causes Circular Dependencies?

The most common causes are:

  1. A target listed as its own prerequisite — often from a variable expansion that accidentally includes the target in its own prerequisite list.
  2. Mutual dependencies between targets — target A depends on B, and B depends on A (e.g., generated headers that depend on each other).
  3. Wildcard or $(shell find …) picking up build outputs — if your source-discovery glob also matches files in the build directory, object files can appear as both targets and prerequisites.
  4. Incorrect -include of auto-generated .d files — if a .d file lists the .o file as both target and prerequisite.

Concrete Example — Triggering the Warning

# BUG: SRCS wildcard picks up everything, including generated files
SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)

# This rule says main.o depends on $(OBJS), which includes main.o itself!
app: $(OBJS)
	$(CC) -o $@ $^

# main.o: main.c  ← fine
# But if OBJS somehow contains main.o as a prereq of itself → circular!
# Run with --debug=b to see the cycle:
$ make --debug=b app 2>&1 | grep -i circular
make: Circular main.o <- main.o dependency dropped.

How to Diagnose and Fix

Debugging Steps

Step-by-Step Fix

  1. Find the cycle: Run make --debug=b 2>&1 | grep -i circular to identify exactly which targets are involved.
  2. Trace the variable: Add $(warning OBJS = $(OBJS)) near the top of the Makefile to see the expanded prerequisite list — check if the target name appears in its own prerequisite list.
  3. Check wildcard scope: If using $(wildcard …) or $(shell find …), ensure it only matches source directories, not build output directories.
  4. Inspect .d files: Run cat build/*.d and check for lines where a .o file is listed as both target and prerequisite.
  5. Fix the list: Use $(filter-out $@,$(OBJS)) in the recipe, or restructure the build so sources and outputs live in separate directories.
# FIX 1: Separate source and build directories (recommended)
SRCS := $(wildcard src/*.c)                           # only scan src/
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))          # outputs go to build/

app: $(OBJS)
	$(CC) -o $@ $^

build/%.o: src/%.c | build
	$(CC) $(CFLAGS) -c $< -o $@

build:
	mkdir -p $@
# FIX 2: Filter out the target from prerequisites
app: $(filter-out app,$(OBJS))
	$(CC) -o $@ $^
Why This Matters: Make silently drops the circular edge and continues building. The build often succeeds — but your dependency graph is now broken. Changes to the circular file won't trigger rebuilds, causing stale binaries and mysterious bugs that only appear after a make clean. Always treat "Circular dependency dropped" as a bug to fix, never as a warning to ignore.

The -p Database Dump

make -p (or --print-data-base) dumps the entirety of Make's internal state after reading all Makefiles — every variable, every rule (explicit and implicit), and their sources. It's the definitive answer to "what does Make actually see?"

Diagram showing make -p database dump output structure with variable origins (default, environment, file, command line) and rule listings
The -p database dump reveals every variable's origin (default, environment, file, command line) and every rule Make knows about — the definitive internal state view
# Dump database without running any targets
make -p --dry-run 2>/dev/null | less

# Dump database for a specific target's context
make -p --dry-run all 2>/dev/null | less

# Search for a variable's value and origin
make -p --dry-run 2>/dev/null | grep -A3 '^CC'

Finding Variable Origins

Each variable entry in the database shows its origin (default, environment, file, command line, automatic):

# Typical output for CC:
# CC
# origin = default
# flavor = recursive
# value = cc

# After override via CLI:
# CC
# origin = command line
# flavor = recursive
# value = clang

This immediately reveals if an environment variable is silently overriding your Makefile declaration — one of the most frustrating bugs in CI/CD environments.

Inspecting Implicit Rules

# Find the built-in rule that compiles .c → .o
make -p --dry-run 2>/dev/null | grep -A6 '%.o : %.c'

# Typical output:
# %.o : %.c
#   $(COMPILE.c) $(OUTPUT_OPTION) $<

Knowing the built-in rule chain helps you decide whether to override it with a pattern rule or extend it via CFLAGS / COMPILE.c.

Using -p safely: Always combine with --dry-run (-n) so no recipes actually execute during the dump. The output can be several thousand lines; always pipe through less or grep.

Tracing with $(warning) & $(error)

Make provides three message-printing functions that expand during Phase 1 (parse time), making them invaluable for tracing variable values before any recipe runs:

Diagram comparing $(info), $(warning), and $(error) trace functions showing their output format and whether they halt execution
The three trace functions: $(info) prints cleanly to stdout, $(warning) adds file:line context to stderr, and $(error) aborts Make immediately
Message Functions
FunctionBehaviour
$(info text)Prints text to stdout with no file/line annotation. Continues processing.
$(warning text)Prints file:line: text to stderr. Continues processing.
$(error text)Prints file:line: *** text. Stop. and immediately aborts Make.

Common Tracing Patterns

# 1. Inspect a variable at parse time
$(warning SRCS = $(SRCS))

# 2. Validate a required variable
ifndef CC
$(error CC is not set — aborting)
endif

# 3. Trace which branch of a conditional is taken
ifeq ($(DEBUG),1)
$(warning DEBUG mode ON)
CFLAGS += -g -O0
else
$(warning RELEASE mode)
CFLAGS += -O2
endif

# 4. Confirm an include file was found
$(warning Loading config.mk)
include config.mk
$(warning Loaded config.mk: VERSION=$(VERSION))

$(info) vs $(warning)

# Use $(info) for clean build-time messages that go to stdout
$(info Building version $(VERSION) for $(TARGET_ARCH))

# Use $(warning) when you want file:line context (easier to grep in CI logs)
$(warning CFLAGS after config.mk: $(CFLAGS))

# Remove all tracing before committing:
# grep -rn '$(warning' Makefile *.mk
Trace all variables at once: Add $(foreach v,$(.VARIABLES),$(info $v = $(value $v))) near the top of your Makefile to dump every variable and its un-expanded value — useful when the origin of a bad value is completely unclear.

Dynamic Rule Generation

One of Make's most powerful — and least understood — features is the ability to generate rules programmatically at parse time using $(eval), $(call), and define.

Diagram showing how $(eval $(call TEMPLATE,arg)) expands a template, substitutes arguments, then feeds the result back to Make's parser as new rules
Dynamic rule generation: $(call) substitutes arguments into a template, $(eval) feeds the expanded text back to Make's parser, creating rules programmatically

$(eval) and $(call)

$(eval text) causes Make to parse text as if it appeared literally in the Makefile. $(call var,arg1,arg2,...) expands a variable as a function, substituting $(1), $(2), etc.

# Define a rule-generating template as a multi-line variable
define COMPILE_template
# Rule: build/$(1).o from src/$(1).c
build/$(1).o: src/$(1).c | build
	$$(CC) $$(CFLAGS) -c $$< -o $$@
endef

# Source file stems (no extension)
MODULES := main utils parser lexer

# Generate one rule per module
$(foreach m,$(MODULES),$(eval $(call COMPILE_template,$(m))))

Notice the double-dollar signs ($$) inside the template: they become single $ when the template is expanded by $(call), which is what Make then parses as a rule.

define Templates

# Template that creates object rules, dep inclusion, AND a test runner
# for each module passed via $(1).
# Double-dollar $$ delays expansion until $(eval) processes the block.
define MODULE_rules
.PHONY: test-$(1)

# $$(wildcard ...) — deferred so it runs when rules are evaluated, not defined
$(1)_SRCS := $$(wildcard src/$(1)/*.c)
$(1)_OBJS := $$(patsubst src/$(1)/%.c,build/$(1)/%.o,$$($(1)_SRCS))

# Pattern rule: compile each .c in this module's directory
build/$(1)/%.o: src/$(1)/%.c | build/$(1)
	$$(CC) $$(CFLAGS) -MMD -MP -c $$< -o $$@   # $$< = source, $$@ = object

# Pull in auto-generated .d dependency files
-include $$($(1)_OBJS:.o=.d)

# Link and run this module's test binary
test-$(1): $$($(1)_OBJS)
	$$(LD) $$^ -o bin/test-$(1) && ./bin/test-$(1)   # $$^ = all object files
endef

MODULES := core net ui

# Instantiate one set of rules per module:
$(foreach m,$(MODULES),$(eval $(call MODULE_rules,$(m))))

$(foreach) + $(eval) Patterns

# Pattern: generate a clean-* target per module
define CLEAN_template
.PHONY: clean-$(1)
clean-$(1):
	rm -rf build/$(1)
endef

$(foreach m,$(MODULES),$(eval $(call CLEAN_template,$(m))))

# Pattern: register all module libs into a master variable
ALL_LIBS :=
define REGISTER_lib
ALL_LIBS += build/$(1)/lib$(1).a
endef
$(foreach m,$(MODULES),$(eval $(call REGISTER_lib,$(m))))

# Now ALL_LIBS holds every module's static library path
program: $(ALL_LIBS)
	$(CC) $(LDFLAGS) $^ -o $@
$(eval) debugging tip: If generated rules look wrong, replace $(eval ...) with $(info ...) temporarily — $(info) prints what would be evaluated without actually adding it to Make's rule database. This lets you inspect the expanded text before committing to $(eval).

Evaluation Traps

These are the most common bugs caused by Make's two-phase evaluation model.

Expansion Order Issues

Trap 1 — Deferred vs Immediate
# WRONG: SRC is defined after OBJS, but = is deferred — this works!
OBJS = $(patsubst %.c,%.o,$(SRC))   # deferred: SRC expanded when OBJS is used
SRC  = main.c utils.c               # defined after OBJS — still fine with =

# WRONG with :=
OBJS := $(patsubst %.c,%.o,$(SRC))  # immediate: SRC is empty at this line!
SRC  := main.c utils.c              # too late — OBJS is already empty

# FIX: swap order when using :=
SRC  := main.c utils.c
OBJS := $(patsubst %.c,%.o,$(SRC))
Trap 2 — += Mixes Flavors
# If VAR was defined with =, then += appends and stays deferred
# If VAR was defined with :=, then += appends immediately (good!)
# If VAR was defined with ?=, the first += makes it deferred

# SAFE pattern: always use := before +=
CFLAGS  := -Wall
CFLAGS  += -Wextra    # still immediate, fine
CFLAGS  += $(EXTRA)   # EXTRA expanded now — if EXTRA is set later, it's empty!

$(shell) Side Effects

Trap 3 — $(shell) Runs at Parse Time
# $(shell) runs DURING PHASE 1 — before any recipe has executed!
# This means it cannot depend on build outputs:

# BAD: tries to read a file that doesn't exist yet (build hasn't run)
VERSION := $(shell cat build/version.h | grep VERSION | cut -d'"' -f2)

# GOOD: read from a committed source file, not a generated one
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)

# GOOD: use target-specific variables for generated data
version.o: EXTRA_DEFS := -DVERSION="$(shell cat build/version.txt)"

Recursive Variable Loops

Trap 4 — Self-Referencing Deferred Variable
# INFINITE LOOP — Make detects this and aborts:
X = $(X) extra   # deferred expansion of X expands X again → loop

# FIX: use += which handles self-append correctly,
# or use := to break the circular reference:
X := initial
X += extra       # X is now "initial extra" — safe
Trap 5 — Recipe Multiline Pitfall
# Each line in a recipe is a SEPARATE shell invocation!
# WRONG: cd does not persist to the next line:
deploy:
	cd dist
	tar czf archive.tar.gz .  # runs in the ORIGINAL directory, not dist!

# FIX 1: combine with &&
deploy:
	cd dist && tar czf ../archive.tar.gz .

# FIX 2: use .ONESHELL for permanent same-shell recipes
.ONESHELL:
deploy:
	cd dist
	tar czf ../archive.tar.gz .

Makefile Linting & Tooling

Several external tools complement Make's built-in debugging capabilities:

checkmake

A Go-based static linter for Makefiles. Catches missing .PHONY declarations, tabs vs spaces in recipes, and other common mistakes.

go install github.com/mrtazz/checkmake@latest
checkmake Makefile
remake

A drop-in Make replacement with a step-through debugger (remake --debugger). Set breakpoints on targets, step through the dependency graph interactively, and inspect variables at any point.

sudo apt install remake
remake --debugger Makefile
# Inside debugger:
# (gdb-style) break src/main.o
# run
# print CFLAGS
make2graph / makefile2dot

Generate a visual dependency graph from make --debug=a output, rendered with Graphviz.

make --debug=a --dry-run 2>&1 | make2graph | dot -Tsvg -o deps.svg
open deps.svg   # see the full build graph
Quick debugging checklist:
  1. Run make --dry-run --debug=b first — it shows rebuild decisions without side effects.
  2. Use $(warning) to pinpoint the exact line where a variable changes value.
  3. Run make -p --dry-run 2>/dev/null | grep '^CC' to check variable origins.
  4. Check for tab vs space errors: cat -A Makefile | grep '^I' shows actual tabs.
  5. Simplify: comment out sections until the bug disappears, then narrow down.

Try It — Debugging Commands

Create a small project to practice the key debugging techniques from this article:

mkdir -p src

cat > src/main.c << 'EOF'
#include <stdio.h>
extern void helper(void);
int main(void) { helper(); return 0; }
EOF

cat > src/helper.c << 'EOF'
#include <stdio.h>
void helper(void) { printf("helper called\n"); }
EOF

cat > Makefile << 'EOF'
CC       := gcc
CFLAGS   := -Wall -Wextra -O2
SRCS     := $(wildcard src/*.c)
OBJS     := $(patsubst src/%.c,build/%.o,$(SRCS))
TARGET   := myapp

$(warning [DEBUG] SRCS = $(SRCS))
$(warning [DEBUG] OBJS = $(OBJS))

.PHONY: all clean info

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) -o $@ $^

build/%.o: src/%.c
	@mkdir -p build
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -rf build $(TARGET)

info:
	@echo "CC     = $(CC)"
	@echo "CFLAGS = $(CFLAGS)"
	@echo "SRCS   = $(SRCS)"
	@echo "OBJS   = $(OBJS)"
EOF

# 1. See $(warning) traces during parse phase
make --dry-run

# 2. Show rebuild decisions (basic debug)
make --dry-run --debug=b

# 3. Variable database — check where CC is defined
make -p --dry-run 2>/dev/null | grep '^CC'

# 4. Build and verify
make info
make
./myapp

# 5. Touch a source — see what rebuilds
touch src/helper.c
make --debug=b

make clean

Next Steps

Technology