Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

<p>A Makefile earns its place when it rebuilds exactly what a change requires and nothing more. The seven levels below start from a single C file that needs no Makefile at all and end with a build that recompiles the right object files when a header changes. Each level adds one convention and solves one specific problem, so you can stop at the level your project needs.</p><p>The progression follows Richard van der Oost’s article "The 7 levels of highly effective Makefiles" (vanderoost.com, published 5 August 2026). The code here is an illustrative rewrite of that progression, written for GNU make. It is not a copy of the article’s files, and it has not been benchmarked.</p>

<h2>How make decides what to rebuild</h2><p>A rule has three parts: a target (the file or task name), its prerequisites (the things the target depends on), and a recipe (the shell commands that produce the target). GNU make treats a target as out of date when the file is missing or when any prerequisite has a newer modification time than the target. It then runs the recipe. The <a href="https://www.gnu.org/software/make/manual/make.html">GNU make Manual</a> describes this timestamp comparison as the basis for every rebuild decision.</p><p>The whole build is a chain of these comparisons. If you edit <code>util.c</code>, then <code>util.o</code> becomes older than its source, so it is recompiled. That newer object file makes the executable older than its prerequisites, so it is relinked. Nothing else is touched. Every level below either makes this chain more complete or makes it easier to write.</p><h2>The seven levels at a glance</h2><table><thead><tr><th>Level</th><th>What it adds</th><th>Problem it solves</th></tr></thead><tbody><tr><td>1. No Makefile</td><td>make’s built-in C rule</td><td>Quick builds of one file</td></tr><tr><td>2. Bare minimum</td><td>Explicit rule, flags, run and clean</td><td>Repeatable commands</td></tr><tr><td>3. Phony tasks</td><td>.PHONY and a deliberate default goal</td><td>Task names clashing with files; plain make running the program</td></tr><tr><td>4. Variables</td><td>Named values and VPATH</td><td>Hard-coded names and paths</td></tr><tr><td>5. Separate compilation</td><td>Object files, then a link step</td><td>Recompiling every file on each change</td></tr><tr><td>6. Source discovery</td><td>wildcard, patsubst, static pattern rules</td><td>Editing lists of files by hand</td></tr><tr><td>7. Header dependencies</td><td>Compiler-generated .d files</td><td>Header edits that do not trigger rebuilds</td></tr></tbody></table><h2>Levels 1 to 7 in detail</h2><h3>Level 1: No Makefile, using make’s built-in C rule</h3><p>If a directory holds only <code>main.c</code>, you can run <code>make main</code> without writing anything. GNU make ships with a built-in rule that compiles a C source of the same name as the target and links it. The command it prints typically looks like <code>cc main.c -o main</code>, although the exact form depends on the make version and your environment.</p><p>The built-in rule reads the <code>CFLAGS</code> variable, so you can still add warnings from the command line with <code>make CFLAGS="-Wall -Wextra -g" main</code>. This is enough for a one-file experiment. It gives you no clean target and no place to record flags, which is what level 2 adds.</p><h3>Level 2: A bare-minimum Makefile</h3><p>Write the rule yourself so the build command is recorded in the project. Recipe lines must begin with a tab character, not spaces; spaces produce a "missing separator" error.</p><pre><code>CC = gcc
CFLAGS = -Wall -Wextra -g

main: main.c
t$(CC) $(CFLAGS) main.c -o main

run: main
t./main

clean:
trm -f main
</code></pre><p>Now <code>make main</code> builds the program, <code>make run</code> executes it after building, and <code>make clean</code> removes the binary. Because the explicit rule exists, it takes precedence over the implicit one from level 1.</p><h3>Level 3: Phony tasks and the default goal</h3><p>Two problems appear at level 2. First, <code>run</code> and <code>clean</code> are actions, not files. If someone creates a file named <code>clean</code>, <code>make clean</code> will consider it up to date and do nothing. Second, GNU make uses the first target in the file as its default goal, so running plain <code>make</code> in the level 2 file builds the program, but reordering the targets could make it run the program instead.</p><p>The GNU make manual defines a phony target as "one that is not really the name of a file; rather it is just a name for a recipe to be executed when you make an explicit request." Declaring it with <code>.PHONY</code> prevents the name collision and also tells make not to search for implicit rules for it. The level 3 version puts an aggregate target named <code>all</code> first:</p><pre><code>CC = gcc
CFLAGS = -Wall -Wextra -g

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

.PHONY: all run clean

all: main

main: main.c
t$(CC) $(CFLAGS) main.c -o main

run: main
t./main

clean:
trm -f main
</code></pre><p>Plain <code>make</code> now builds the program and stops. Avoid making a real file depend on a phony target: the file’s rule will then be remade on every invocation, because the phony target is never considered up to date.</p><h3>Level 4: Variables and the source directory</h3><p>Level 4 moves the names into variables and points make at a source directory. <code>VPATH</code> lists directories where make searches for prerequisites that are not in the current directory. It affects where make finds inputs; outputs are still written to the current directory unless a rule says otherwise.</p><pre><code>CC = gcc
CFLAGS = -Wall -Wextra -g
BIN = main
SRC_DIR = src
VPATH = $(SRC_DIR)

.PHONY: all run clean

all: $(BIN)

$(BIN): main.c
t$(CC) $(CFLAGS) $< -o $@

run: $(BIN)
t./$(BIN)

clean:
trm -f $(BIN)
</code></pre><p>Here <code>$<</code> expands to the first prerequisite as make found it, which is <code>src/main.c</code>, and <code>$@</code> is the target, <code>main</code>. Renaming the program or moving the sources now means editing one line.</p><h3>Level 5: Separate compilation</h3><p>With more than one source file, each <code>.c</code> file becomes an object file (<code>.o</code>) through a compile-only step, and a separate link step combines the objects. The pattern rule below applies to every object file, using the matching source as its prerequisite.</p><pre><code>CC = gcc
CFLAGS = -Wall -Wextra -g
BIN = main
SRC_DIR = src
VPATH = $(SRC_DIR)
OBJS = main.o util.o

.PHONY: all run clean

all: $(BIN)

$(BIN): $(OBJS)
t$(CC) $(OBJS) -o $@

%.o: %.c
t$(CC) $(CFLAGS) -c $< -o $@

run: $(BIN)
t./$(BIN)

clean:
trm -f $(BIN) $(OBJS)
</code></pre><p>Editing <code>util.c</code> now recompiles only <code>util.o</code>, followed by the link. The benefit grows with project size, because unchanged objects are reused.</p><h3>Level 6: Discovering sources and arranging outputs</h3><p>Level 6 stops listing files by hand. <code>$(wildcard)</code> expands a file pattern into a list, and <code>$(patsubst)</code> rewrites each name in a list, such as turning a source path into an object path. A static pattern rule ties a set of named targets to a pattern for their prerequisites. The convention below is the one the article uses: C files directly inside <code>src</code> are executable entry points, and C files in immediate subdirectories are library code.</p><pre><code>CC = gcc
CFLAGS = -Wall -Wextra -g
SRC_DIR = src
OBJ_DIR = obj
BIN_DIR = bin

MAIN_SRCS = $(wildcard $(SRC_DIR)/*.c)
LIB_SRCS = $(wildcard $(SRC_DIR)/*/*.c)
MAIN_OBJS = $(patsubst $(SRC_DIR)/%.c,$(OBJ_DIR)/%.o,$(MAIN_SRCS))
LIB_OBJS = $(patsubst $(SRC_DIR)/%.c,$(OBJ_DIR)/%.o,$(LIB_SRCS))
BINS = $(patsubst $(SRC_DIR)/%.c,$(BIN_DIR)/%,$(MAIN_SRCS))

.PHONY: all run clean

all: $(BINS)

$(BINS): $(BIN_DIR)/%: $(OBJ_DIR)/%.o $(LIB_OBJS)
tmkdir -p $(dir $@)
t$(CC) $^ -o $@

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

$(MAIN_OBJS) $(LIB_OBJS): $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
tmkdir -p $(dir $@)
t$(CC) $(CFLAGS) -c $< -o $@

run: $(BIN_DIR)/main
t./$(BIN_DIR)/main

clean:
trm -rf $(OBJ_DIR) $(BIN_DIR)
</code></pre><p>The convention rests on several assumptions. Check each one against your own tree before copying the pattern:</p><ul><li>Entry points must sit directly in <code>src</code>. A <code>.c</code> file two levels down is ignored by both wildcards.</li><li>Library code is only one directory deep (<code>src/<a>/*.c</code> pattern). Nested libraries need a deeper wildcard or a recursive search.</li><li>Every entry point links every library object. That is simple, but it makes binaries larger than they need to be when libraries are unrelated.</li><li>Generated sources, multiple build variants, or sources in unrelated trees need different lists and rules.</li></ul><h3>Level 7: Header dependencies</h3><p>The rules so far list only <code>.c</code> files as prerequisites. Suppose <code>util.c</code> includes <code>util.h</code>. If you change a declaration in <code>util.h</code>, make sees no newer prerequisite for <code>obj/util.o</code> and skips the recompile, which leaves a stale object file. Writing every header into every rule by hand is tedious and easy to get wrong.</p><p>The fix is to let the compiler report its own header list. With GCC and Clang, <code>-MMD</code> writes a <code>.d</code> file next to each object file, listing the project headers that source includes (system headers are left out). Including those files with <code>-include</code> makes make read them as ordinary rules. The <code>-include</code> form also ignores a missing file, which matters on the first build when no <code>.d</code> files exist yet. Add these lines to the level 6 Makefile:</p><pre><code>CFLAGS += -MMD -MP
DEPS = $(MAIN_OBJS:.o=.d) $(LIB_OBJS:.o=.d)
-include $(DEPS)
</code></pre><p>The <code>-MP</code> flag is not part of the article’s version. It is a common addition that makes make generate an empty rule for each header, so deleting a header does not stop the build with a missing-prerequisite error. Flag names are specific to GCC and Clang; other compilers provide equivalent options under different names, so check their documentation.</p><h3>Using the finished setup</h3><p>The seven-level file gives you four commands:</p><ul><li><code>make</code> builds every entry point in <code>bin</code>, recompiling only what changed.</li><li><code>make run</code> builds and runs <code>bin/main</code>. Change that line to run another entry point.</li><li><code>make watch</code> reruns a command when source files change, using the external <code>entr</code> utility (installed with a package manager such as <code>sudo apt install entr</code> on Debian and Ubuntu).</li><li><code>make clean</code> removes <code>obj</code> and <code>bin</code>, including the dependency files.</li></ul><pre><code>.PHONY: watch
watch:
tfind src -name ‘*.[ch]’ | entr -c make run
</code></pre><p><code>entr</code> watches only the files it was given when it started. A new source file will not be watched until you restart <code>make watch</code>.</p><h2>When a level misbehaves</h2><ul><li><strong>Header edits still do not rebuild.</strong> Confirm that <code>.d</code> files exist in <code>obj</code>. If the directory was built before <code>-MMD</code> was added, run <code>make clean</code> once so every object is regenerated with its dependency file.</li><li><strong>Plain <code>make</code> runs the program.</strong> The first target in the file is not an aggregate target. Move <code>all</code> above the others.</li><li><strong><code>make clean</code> does nothing.</strong> A file with the same name exists and the target is not declared phony. Add it to <code>.PHONY</code>.</li><li><strong>"missing separator" error.</strong> A recipe line starts with spaces. Replace the leading spaces with a tab.</li><li><strong><code>make watch</code> is silent.</strong> Either <code>entr</code> is not installed, or the <code>find</code> command returns no files. Run the <code>find</code> part alone to check.</li></ul><h2>Further reading</h2><p>The <a href="https://www.gnu.org/software/make/manual/make.html">GNU make Manual</a> (version 4.4.1, last updated 2023-02-26) is the authoritative reference for every feature used here. The section on <a href="https://www.gnu.org/software/make/manual/html_node/Phony-Targets.html">Phony Targets</a> covers level 3 in full. For a book-length treatment, a PDF of <em>Managing Projects with GNU Make</em> is hosted at <a href="https://wanderinghorse.net/computing/make/book/ManagingProjectsWithGNUMake-3.1.3.pdf">wanderinghorse.net</a>. That file is labelled 3.1.3; current print or digital editions were not checked for this article.</p>

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Frequently Asked Questions

Will these Makefiles work with BSD make or nmake?

Not without changes. The examples rely on GNU make features such as wildcard, patsubst, static pattern rules, $(dir), and -include. BSD make uses different syntax, so on BSD systems install GNU make and run it as gmake. Microsoft’s nmake uses its own syntax and cannot read these files.

The Bottom Line

<p>Stop at the level your project needs: level 3 for a single-file tool, level 5 for a handful of source files that rarely change their headers, and level 7 as soon as headers change and you rely on incremental builds.</p>

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Best Value

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.