Sitemap

Understanding Makefiles

25 min readApr 17, 2025

Chapter 1: Introduction and Motivation

1.1 What is Make? (A High-Level Overview)

At its core, make is a build automation tool. Its primary job is to automatically determine which pieces of a larger program need to be recompiled or rebuilt and then issue the necessary commands to do so.

Think of it like a very smart assistant for developers, especially when working on projects with multiple source files, libraries, or other components. Instead of manually typing compilation commands every time you change a file, you define the relationships between your files and the commands needed to build them in a special file (usually called Makefile). Then, you simply run the make command, and it figures out the minimum amount of work needed to bring your project up to date.

1.2 A Brief History: The Origins of Make (Bell Labs, Stuart Feldman)

make has a rich history rooted in the early days of the Unix operating system.

Origin: It was created by Stuart Feldman in 1976 while he was working at Bell Labs (the birthplace of Unix, C, and many other foundational technologies).

Motivation: Feldman was working on a project where recompiling everything took too long, and he frequently made errors forgetting which files needed recompilation after a change. He needed a way to automate this process reliably.

Key Insight: The core idea was to formalize the dependencies between files. If File A depends on File B, and File B is newer than File A, then File A needs to be rebuilt.

Impact: make quickly became an indispensable tool in the Unix ecosystem and remains a standard tool on virtually all Unix-like systems (Linux, macOS, BSD) today. Many variations and implementations exist (GNU Make is the most common today), but the core concepts remain remarkably consistent.

1.3 The Problem Before Make: Manual Build Processes and Their Drawbacks

Imagine you’re developing a C program split into three files: main.c, utils.c, and utils.h.

  • main.c uses functions defined in utils.c.
  • Both main.c and utils.c include the header file utils.h.

To build the final executable program (let’s call it my_program), you would typically need to:

  1. Compile main.c into an object file (main.o): cc -c main.c -o main.o
  2. Compile utils.c into an object file (utils.o): cc -c utils.c -o utils.o
  3. Link the object files together to create the executable: cc main.o utils.o -o my_program

Now, consider the manual process:

Initial Build: You run all three commands. Fine.

Change utils.c: You modify something in utils.c. Which commands do you need to run again?

  • You must re-run step 2 (cc -c utils.c -o utils.o).
  • You must re-run step 3 (cc main.o utils.o -o my_program) because utils.o changed.
  • You don’t need to re-run step 1 (cc -c main.c -o main.o) because main.c didn’t change.

Change utils.h: You modify the header file. This is trickier! Since both main.c and utils.c include utils.h, both depend on it.

  • You must re-run step 1 (cc -c main.c -o main.o).
  • You must re-run step 2 (cc -c utils.c -o utils.o).
  • You must re-run step 3 (cc main.o utils.o -o my_program) because both main.o and utils.o changed.

Change only main.c:

  • You must re-run step 1 (cc -c main.c -o main.o).
  • You must re-run step 3 (cc main.o utils.o -o my_program).
  • You don’t need to re-run step 2.

Drawbacks of this manual approach:

  1. Error-Prone: It’s incredibly easy to forget a step or run unnecessary steps. Forgetting a step leads to bugs (using outdated code). Running unnecessary steps wastes time.
  2. Time-Consuming: As projects grow (dozens or hundreds of files), figuring out the correct sequence and typing the commands becomes tedious and slow. Full rebuilds take significant time.
  3. Not Scalable: This manual process simply doesn’t work for large, complex projects.
  4. Difficult Collaboration: How do you ensure everyone on the team builds the project the same way? You’d need complex build scripts or detailed instructions that are hard to maintain.

1.4 The Core Problem Make Solves: Dependency Management and Incremental Builds

Make directly addresses the drawbacks mentioned above by focusing on two key concepts:

Dependency Management: You explicitly tell make how files relate to each other. For our example, you’d tell make:

  • my_program depends on main.o and utils.o.
  • main.o depends on main.c and utils.h.
  • utils.o depends on utils.c and utils.h.
    You also tell make the commands (recipes) needed to create each file from its dependencies.

Incremental Builds: make uses file modification timestamps. When you ask make to build a target (like my_program), it performs the following logic (simplified):

  • Look at the target (my_program).
  • Look at its prerequisites (main.o, utils.o).
  • For each prerequisite:
  • Recursively check if it needs to be rebuilt based on its prerequisites and their timestamps.
  • After ensuring all prerequisites are up-to-date, compare the timestamp of the target (my_program) with the timestamps of its prerequisites (main.o, utils.o).
  • If the target does not exist, or if any prerequisite is newer than the target, then execute the command associated with building the target.

This timestamp comparison allows make to rebuild only what is necessary, saving significant time, especially on large projects where only a few files might change between builds.

1.5 Why is Make (or Make-like tools) Still Relevant Today?

Despite its age and the rise of newer, more complex build systems, make remains relevant for several reasons:

Ubiquity: It’s available almost everywhere, especially in Unix-like environments. It’s often a lowest common denominator build tool.

Simplicity (for basic tasks): For small to medium-sized projects, especially in C/C++, a Makefile can be relatively simple and straightforward to write and understand.

Flexibility: make is not tied to any specific language. You can use it to automate any task that involves dependencies and commands — compiling code, processing data, generating documentation, deploying applications, managing infrastructure configuration, etc.

Foundation: Many higher-level build systems (like CMake, Autotools) actually generate Makefiles under the hood. Understanding Make helps in understanding how these tools work and debugging them.

Speed (for incremental builds): The core timestamp-based incremental build mechanism is efficient and fast for its specific task.

While modern build systems often offer features like better dependency detection (especially for complex scenarios), cross-platform abstraction, and package management integration, the fundamental problem of managing dependencies and performing incremental builds, which make pioneered, is still a central concern in software development. Understanding make provides a solid foundation for understanding build automation in general.

Chapter 2: The Core Mechanism: Rules and Dependencies

This chapter delves into the fundamental structure of a Makefile and how make uses that structure to perform its magic.

2.1 The Fundamental Unit: Rules (Target, Prerequisites, Recipe)

The heart of any Makefile is a set of rules. A rule tells make how to create or update a specific file (or perform an action). A typical rule has the following structure:

target: prerequisite1 prerequisite2 ...
<TAB>recipe_command_1
<TAB>recipe_command_2
<TAB>...

Let’s break down the components:

Target: This is usually the name of a file that the rule knows how to create or update (e.g., my_program, main.o, documentation.pdf). It can also be an “abstract” name for an action (like clean or install), which we’ll cover later as “phony targets”. The target is placed before the colon (:).

Prerequisites (or Dependencies): These are the files or other targets that the target depends on. The target needs these prerequisites to exist and be up-to-date before the recipe can be run. They are listed after the colon (:), separated by spaces. A target might have zero or many prerequisites.

Recipe (or Commands): These are the shell commands that make executes to create or update the target from its prerequisites.

Crucially, each command line in the recipe MUST begin with a TAB character. This is a common source of errors for beginners. Spaces will not work.

  • make executes each command line in a separate sub-shell instance by default.
  • The recipe is executed only if make determines the target needs to be built (based on dependencies and timestamps).

Example Rule Breakdown:

main.o: main.c utils.h
cc -c main.c -o main.o
  • Target: main.o (the object file)
  • Prerequisites: main.c (the source file) and utils.h (the header file)
  • Recipe: cc -c main.c -o main.o (the command to compile main.c into main.o)

This rule tells make: “To create or update main.o, you first need main.c and utils.h. If main.o doesn’t exist, or if either main.c or utils.h is newer than main.o, then execute the command cc -c main.c -o main.o.”

2.2 How Make Decides What to Do: Dependency Checking and Timestamps

When you run make (potentially specifying a target), it uses the rules in the Makefile and the file system’s last modification timestamps to decide what needs to be done. Here’s the basic algorithm for a given target:

  1. Find the Rule: Locate the rule that defines how to build the target.
  2. Check Prerequisites: For each prerequisite listed in the rule:
  • Does a rule exist to build this prerequisite?
  • If yes, recursively apply this entire decision process to that prerequisite first. Ensure all prerequisites are considered and potentially rebuilt before proceeding.
  • If no rule exists for a prerequisite, make assumes it’s a source file or something that should already exist. It just needs its timestamp.

3. Check Target Existence: Does the target file currently exist?

4. Compare Timestamps:

  • If the target does not exist, it must be built.
  • If the target exists, compare its last modification timestamp with the timestamp of each prerequisite (after ensuring they are up-to-date).
  • If any prerequisite is newer than the target, the target is considered out-of-date and must be rebuilt.

5. Execute Recipe (if needed): If steps 3 or 4 determined that the target must be built, execute the recipe commands associated with the target’s rule.

This dependency graph traversal and timestamp comparison ensures that make performs the minimum necessary work.

2.3 The Simplest Makefile: Compiling a Single Source File (Code Example)

Let’s start with the absolute simplest case: compiling a single hello.c file into an executable hello.

  • hello.c:
#include <stdio.h>

int main() {
printf("Hello, Make!\n");
return 0;
}
  • Makefile:
# This is a comment
# Rule to create the executable 'hello' from the object file 'hello.o'
hello: hello.o
cc hello.o -o hello # Linker command

# Rule to create the object file 'hello.o' from the source file 'hello.c'
hello.o: hello.c
cc -c hello.c -o hello.o # Compiler command

How it works:

  1. You save these two files in the same directory.
  2. You open a terminal in that directory and run make.
  3. make looks for a Makefile (or makefile).
  4. It reads the Makefile and decides the goal is the first target: hello.
  5. To build hello, make sees it needs hello.o.
  6. It looks for a rule to build hello.o. It finds hello.o: hello.c.
  7. It checks the prerequisite hello.c. It exists, and there’s no rule to build it (it’s a source file).
  8. It checks if hello.o needs building:
  • Does hello.o exist? (Probably not the first time). Let’s assume no.
  • Since hello.o doesn’t exist, make runs the recipe for hello.o: cc -c hello.c -o hello.o.

9. Now make returns to the hello target. It needed hello.o. hello.o is now up-to-date.

10. It checks if hello needs building:

  • Does hello exist? (Probably not). Let’s assume no.
  • Since hello doesn’t exist, make runs the recipe for hello: cc hello.o -o hello.

11. The process finishes. You now have hello.o and hello.

If you run make again immediately, make will see that hello exists and is newer than hello.o, and hello.o exists and is newer than hello.c. It will report that everything is up-to-date and do nothing. If you then touch hello.c (updating its timestamp) and run make again, it will re-run both commands because hello.c is now newer than hello.o, which in turn makes hello.o newer than hello (when it gets rebuilt).

2.4 Running make: Basic Invocation and Output Interpretation

  • make: When run without arguments, make attempts to build the first target defined in the Makefile. This is called the default goal.
  • make <target_name>: You can explicitly tell make which target you want to build (e.g., make hello.o). make will then only perform the steps necessary to build that specific target and its prerequisites.
  • Output: By default, make prints each recipe command to the standard output before executing it. This lets you see exactly what commands are being run.
$ make  # Assuming the Makefile from 2.3
cc -c hello.c -o hello.o
cc hello.o -o hello
$ ./hello
Hello, Make!
$ make # Run again
make: 'hello' is up to date.
$ touch hello.c # Update timestamp of source file
$ make
cc -c hello.c -o hello.o
cc hello.o -o hello

2.5 The Concept of the “Default Goal”

As mentioned, if you just type make, it builds the first target it encounters in the Makefile. This is a simple but important convention.

Because of this, it’s common practice to make the very first target in a Makefile an “all” target (often literally named all) that lists the main executable(s) or final products of the build process as its prerequisites. This way, simply running make builds the entire project.

Example:

# Default goal: Build the main program
all: my_program
my_program: main.o utils.o
cc main.o utils.o -o my_program
main.o: main.c utils.h
cc -c main.c -o main.o
utils.o: utils.c utils.h
cc -c utils.c -o utils.o
# Later, we'll add a 'clean' target, but 'all' comes first!

Now, running make is equivalent to running make all, which triggers the build of my_program, which in turn triggers the builds of main.o and utils.o as needed.

Chapter 3: Making Makefiles Reusable: Variables

Hardcoding filenames and compiler options directly into rules, as we did in the previous examples, quickly becomes tedious and error-prone, especially in larger projects. If you want to change a compiler option or rename a set of source files, you’d have to find and replace it everywhere. Variables solve this problem.

3.1 What are Variables (Macros) in Make?

In Make, variables (often called macros in older documentation, but “variables” is more common now) are essentially placeholders for text strings. You define a variable name and assign it a value (a string). Later, when make encounters a reference to that variable, it substitutes the variable’s value into the text.

Benefits of using variables:

  • Readability: Gives meaningful names to strings (like COMPILER_FLAGS instead of -g -Wall).
  • Maintainability: Change a value in one place (the variable definition), and it takes effect everywhere it’s used.
  • Flexibility: Allows easy configuration of the build process (e.g., switching between debug and release flags).
  • Conciseness: Avoids repeating the same long strings multiple times.

3.2 Defining Variables: Simple (=), Recursive (:=), Conditional (?=), Appending (+=)

There are several ways to define variables in a Makefile, each with slightly different behavior regarding when and how the variable’s value is evaluated.

= (Recursive Expansion)

  • Syntax: VARIABLE_NAME = value
  • Behavior: The value is stored literally, including any references to other variables. When VARIABLE_NAME is used, its value is expanded at that point. If the value contains references to other variables, those are expanded recursively at the time of use.
  • Potential Issue: Can lead to infinite loops if variables refer to each other recursively. Also, the value can change if variables it references are redefined later in the Makefile.
  • Example:
GREETING = Hello
MESSAGE = $(GREETING) World!
GREETING = Goodbye # Redefined later

all:
@echo $(MESSAGE) # Output: Goodbye World!

:= (Simple Expansion)

  • Syntax: VARIABLE_NAME := value
  • Behavior: The value is expanded once, right when the variable is defined. Any variable references within the value are expanded at definition time using the values currently defined for those variables. The resulting text is then stored as the value of VARIABLE_NAME.
  • Benefit: Avoids infinite loops and makes the variable’s value predictable, regardless of later definitions. Generally preferred unless you specifically need the recursive behavior.
  • Example:
GREETING := Hello
MESSAGE := $(GREETING) World!
GREETING := Goodbye # Redefined later

all:
@echo $(MESSAGE) # Output: Hello World!

?= (Conditional Assignment)

  • Syntax: VARIABLE_NAME ?= value
  • Behavior: Assigns the value to VARIABLE_NAME only if VARIABLE_NAME is not already defined (either earlier in the Makefile, via the environment, or on the command line). If it’s already defined, this assignment is ignored.
  • Use Case: Setting default values that can be easily overridden.
  • Example:
# Set default compiler if not overridden
CC ?= gcc

# If run as 'make', CC will be 'gcc'
# If run as 'make CC=clang', CC will be 'clang'

hello: hello.c
$(CC) hello.c -o hello

+= (Appending)

  • Syntax: VARIABLE_NAME += extra_value
  • Behavior: Appends the extra_value (with a preceding space) to the existing value of VARIABLE_NAME. If the variable was previously undefined, it behaves like =. If it was defined with :=, the appended variable also uses simple expansion at the point of appending.
  • Use Case: Building up lists of options or files.
  • Example:
CFLAGS := -Wall # Basic flags
# Add debugging flags later
CFLAGS += -g

# CFLAGS now contains "-Wall -g"

hello.o: hello.c
cc $(CFLAGS) -c hello.c -o hello.o

Variable Naming Conventions: Variable names are case-sensitive. By convention, they are often written in uppercase, especially for variables controlling build tools or options (like CC, CFLAGS), but this is not strictly required.

3.3 Using Variables in Targets, Prerequisites, and Recipes

To use the value of a variable, you reference it using one of these forms:

  • $(VARIABLE_NAME)
  • ${VARIABLE_NAME}

Both forms are equivalent in most cases. Parentheses are generally more common, especially for single-character variable names (which we’ll see later with Automatic Variables). Braces are sometimes preferred for clarity when the variable name might be ambiguous if placed next to other characters.

Example using Variables:

Let’s rewrite the Makefile from Chapter 2 (for main.c, utils.c, utils.h) using variables:

# Compiler and flags
CC := gcc
CFLAGS := -Wall -g # Warning flags, debug info
LDFLAGS := # Linker flags (none for now)
# Source and object files
SRCS := main.c utils.c
OBJS := $(SRCS:.c=.o) # A simple substitution: replace .c with .o
# OBJS will be "main.o utils.o"
# Header files (optional, but good practice for dependencies)
HEADERS := utils.h
# Executable name
TARGET := my_program
# Default goal (first target)
all: $(TARGET)
# Rule to link the final executable
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) $(OBJS) -o $(TARGET)
# Rule to compile .c files into .o files
# Note: This uses specific rules for now. We'll improve this later with pattern rules.
main.o: main.c $(HEADERS)
$(CC) $(CFLAGS) -c main.c -o main.o
utils.o: utils.c $(HEADERS)
$(CC) $(CFLAGS) -c utils.c -o utils.o
# A common "clean" target to remove generated files
clean:
rm -f $(TARGET) $(OBJS) # Use -f to ignore errors if files don't exist
# Declare 'all' and 'clean' as phony targets (more on this later)
.PHONY: all clean

Notice how changing CC to clang or adding a new flag to CFLAGS now only requires editing one line. If we added another source file network.c, we’d just add it to the SRCS list. The OBJS variable automatically updates.

3.4 Built-in Variables (e.g., CC, CFLAGS, RM)

make comes with a set of predefined variables, many of which are used by its built-in rules (which we’ll cover in Chapter 4). Using these conventional names makes your Makefiles more standard and allows users to leverage implicit behavior. Some common ones include:

  • CC: The C compiler command (default: cc, often gcc on Linux).
  • CFLAGS: Flags for the C compiler (e.g., -Wall, -g, -O2).
  • CXX: The C++ compiler command (default: g++).
  • CXXFLAGS: Flags for the C++ compiler.
  • CPPFLAGS: Flags for the C Preprocessor (often used for -I<include_dir> or -D<macro>).
  • LDFLAGS: Flags for the linker (e.g., -L<lib_dir>).
  • LDLIBS: Library flags for the linker (e.g., -lm for the math library).
  • RM: The command to remove files (default: rm -f).
  • AR: Archive maintaining program (for static libraries).
  • ARFLAGS: Flags for ar (default: rv).
  • MAKE: The name of the make program itself. Useful for recursive make calls.
  • SHELL: The shell make uses to execute recipes (default: /bin/sh).

Even if you define all your rules explicitly (not relying on implicit rules), using these standard variable names is good practice.

3.5 Variables from the Environment

When make starts, it reads variables from your shell’s environment and defines them as make variables. These are treated as if they were defined with = (recursive expansion) unless explicitly overridden in the Makefile or on the command line.

Example:

export CFLAGS="-O3" # Set an environment variable in the shell
make # Make will use CFLAGS="-O3" unless the Makefile overrides it

This allows for external configuration of builds, but be aware that it can sometimes lead to unexpected behavior if environment variables conflict with Makefile definitions. Generally, variables defined inside the Makefile override environment variables, and command-line variables override both.

3.6 Overriding Variables on the Command Line

You can override the value of any variable defined in the Makefile or the environment by specifying it on the make command line:

# Use clang instead of the default/Makefile-defined CC
make CC=clang
# Build with specific debugging flags, overriding any CFLAGS in the Makefile
make CFLAGS="-g -O0"
# Append to flags defined in the Makefile (requires careful Makefile design, often using +=)
make CFLAGS+="-DDEBUG"
# Define a variable not present in the Makefile
make BUILD_MODE=debug

Precedence Order (Highest to Lowest):

  1. Command-line arguments (make VAR=value)
  2. Variables defined within the Makefile (using :=, =, +=)
  3. Variables inherited from the environment
  4. Built-in variables defined by make

Conditional assignment (?=) specifically checks if a variable has a value from sources 1, 2, or 3 before applying its own default.

Variables are fundamental to writing effective Makefiles. They abstract away details, promote reuse, and make your build process configurable. This chapter covered the basics of defining and using them.

Chapter 4: More Powerful Rules

So far, we’ve mostly defined explicit rules: one rule for each specific target file (e.g., one rule for main.o, one for utils.o). This works, but it doesn’t scale well. If you have hundreds of source files, writing a separate rule for each object file is impractical. make provides more powerful ways to define rules: Implicit Rules and Pattern Rules. We’ll also cover Phony Targets, which are essential for defining actions.

4.1 Implicit Rules: Make’s Built-in Knowledge

make isn’t completely naive; it comes pre-programmed with a database of implicit rules that tell it how to perform common file transformations, especially related to compilation and linking.

The most common implicit rule is for creating an object file (.o) from a C source file (.c). make inherently knows: “To create a file named foo.o, look for a file named foo.c. If found, run the C compiler using a command like $(CC) $(CPPFLAGS) $(CFLAGS) -c foo.c -o foo.o.”

  • $(CC): C compiler (default cc)
  • $(CPPFLAGS): C preprocessor flags
  • $(CFLAGS): C compiler flags
  • COMPILE.c: A variable holding the complete compilation command template.

Similarly, make knows how to link a single object file foo.o into an executable foo: $(CC) $(LDFLAGS) foo.o $(LDLIBS) -o foo.

How this simplifies Makefiles:

Remember our simple hello.c example from Chapter 2?

# Original explicit rules:
hello: hello.o
cc hello.o -o hello
hello.o: hello.c
cc -c hello.c -o hello.o

Using implicit rules, make already knows how to do both steps. You only need to tell it the dependencies if they aren’t the standard ones (hello.c for hello.o, and hello.o for hello). You might only need:

# Simplified Makefile using implicit rules
# Define variables used by implicit rules
CC := gcc
CFLAGS := -Wall -g
# The target executable depends on the object file
hello: hello.o
# The object file depends on the source file (Make often infers this,
# but explicitly stating dependencies, especially headers, is good practice)
# hello.o: hello.c # This line is often technically optional for the .c -> .o rule
# No recipes needed! Make uses its built-in rules.

When you run make hello:

  1. It sees hello needs hello.o.
  2. It sees no explicit rule for hello.o, but finds hello.c.
  3. It uses the implicit rule for .c -> .o, running something like: gcc -Wall -g -c hello.c -o hello.o.
  4. Now it returns to hello. It has hello.o.
  5. It sees no explicit rule for hello, but it has the prerequisite hello.o.
  6. It uses the implicit rule for linking an object file to an executable of the same name, running something like: gcc hello.o -o hello.

Limitations: Implicit rules work best for simple, standard cases (like one .c file to one .o file, or one .o file to an executable of the same name). They become less useful when you have more complex dependencies (like header files) or non-standard build commands. You often need to at least specify the correct prerequisites.

4.2 Pattern Rules: Using Wildcards (%) for Generalization

Implicit rules are useful but limited in flexibility (e.g., tied to specific suffix pairs like .c -> .o). Pattern rules provide a much more powerful way to define general transformations. They look like regular rules, but they use the wildcard character % in the target and prerequisites.

The % acts as a placeholder that matches any non-empty sequence of characters. It must appear at least once in the target. When the % matches a particular string in the target (called the “stem”), the same string is substituted for the % in the prerequisites.

The Classic Example: Compiling any .c to .o

Instead of relying on the built-in implicit rule or writing separate rules for main.o and utils.o, we can define a single pattern rule:

# Pattern rule: How to create any file ending in .o from a
# corresponding file ending in .c
%.o: %.c
$(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@

Let’s break this down:

  • %.o: This target matches any filename ending in .o (e.g., main.o, utils.o). The part matched by % is the “stem” (e.g., main, utils).
  • %.c: This prerequisite uses the same stem matched in the target and appends .c. So, if the target is main.o, this prerequisite becomes main.c.
  • $(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@: This is the recipe.
  • $<: This is an “automatic variable” (covered fully in Chapter 5) that means “the first prerequisite” (e.g., main.c).
  • $@: This is an automatic variable meaning “the target name” (e.g., main.o).

Using the Pattern Rule in our Project:

CC := gcc
CFLAGS := -Wall -g
LDFLAGS :=
LDLIBS :=
SRCS := main.c utils.c
OBJS := $(SRCS:.c=.o) # Generates "main.o utils.o"
HEADERS := utils.h
TARGET := my_program
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) $^ -o $@ $(LDLIBS) # $^ means "all prerequisites"
# Single pattern rule to build all object files
%.o: %.c $(HEADERS) # We add HEADERS as prerequisites for ALL .o files*
$(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
clean:
$(RM) $(TARGET) $(OBJS)
.PHONY: all clean

*Note: Adding $(HEADERS) to the pattern rule prerequisites means every .o file will be rebuilt if any header in that list changes. For more fine-grained dependency tracking, more advanced techniques are needed (often involving compiler-generated dependency files), but this is a common starting point.

Now, if you add network.c to SRCS, the OBJS variable updates to include network.o, and the same pattern rule will be used automatically by make when it needs to build network.o. You didn’t need to write a new rule!

4.3 Phony Targets: Targets That Aren’t Files (.PHONY)

Often, you want to define targets in your Makefile that don’t actually correspond to files you create. Instead, they represent actions you want make to perform, like cleaning up generated files (clean) or building the main executables (all). These are called phony targets.

Why declare them as phony?

  1. Avoiding Conflicts: Imagine you have a target named clean, and accidentally, a file named clean gets created in your directory. If clean is not declared phony, make will check the file clean. Since this file exists and likely has no prerequisites (or its prerequisites are older), make will think the clean target is up-to-date and will not run the recipe (the rm command). Declaring clean as phony tells make to ignore any file named clean and always run the recipe when make clean is invoked.
  2. Performance (Minor): make skips the implicit rule search and timestamp checks for phony targets.
  3. Clarity: It explicitly documents that the target represents an action, not a file.

Syntax: You declare phony targets using the special built-in target .PHONY:

.PHONY: clean all install test deploy

It’s conventional to place the .PHONY declaration near the beginning or end of the Makefile. You list all your phony target names as prerequisites of .PHONY.

Example Usage:

# ... (variables defined earlier) ...
OBJS := main.o utils.o
TARGET := my_program
# Phony target 'all' is the default goal
.PHONY: all
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $^ -o $@
%.o: %.c utils.h
$(CC) $(CFLAGS) -c $< -o $@
# Phony target 'clean' performs cleanup
.PHONY: clean
clean:
echo "Cleaning up generated files..."
$(RM) $(TARGET) $(OBJS)
# Phony target 'install' (example, does nothing useful here)
.PHONY: install
install: $(TARGET)
echo "Installing $(TARGET) (not really)..."
# Add actual installation commands here, e.g., cp $(TARGET) /usr/local/bin

Now, make clean will always run the rm command, regardless of whether a file named clean exists. make all (or just make) will always try to build $(TARGET).

4.4 Multiple Targets and Prerequisites per Rule

Multiple Prerequisites: As we’ve already seen extensively, a rule can have many prerequisites listed after the colon, separated by spaces. make ensures all of them are up-to-date before considering the target.

my_program: main.o utils.o network.o config.dat
<TAB># Recipe using all prerequisites...

Multiple Targets: Less commonly, a single rule can be responsible for creating multiple target files simultaneously. This is useful if a single command generates several outputs.

# Example: A tool that generates both header and source from an input file
output.h output.c: input.idl
<TAB>idl_compiler --header output.h --source output.c input.idl

# Using this requires care, as 'make' usually focuses on one target at a time.
# Special variables like $@ don't work well here. Often requires specific naming
# or using pattern rules if the relationship is systematic.
  • While possible, rules generating multiple targets are often less clear than separate rules unless the command intrinsically creates all outputs at once.

Pattern rules and phony targets significantly increase the power and usability of your Makefiles, allowing you to generalize build steps and define standard actions reliably.

Chapter 5: Essential Syntax and Features

This chapter covers specific syntax elements and features within Makefiles that you’ll encounter frequently and that help you write cleaner, more robust, and more flexible rules.

5.1 Automatic Variables: Making Recipes Concise

When make runs the recipe for a rule, it automatically sets several special variables, called automatic variables. These variables provide convenient access to the target name(s) and prerequisite name(s) of the rule currently being executed. Using them makes your recipes much more generic and avoids hardcoding filenames within the commands.

Here are the most commonly used automatic variables:

$@: The filename of the target of the rule. If the rule has multiple targets, it’s the name of the specific target that caused the rule to be run.

  • Example: In main.o: main.c, $@ would be main.o.

$<: The filename of the first prerequisite.

  • Example: In main.o: main.c utils.h, $< would be main.c. This is extremely common in compilation rules where the first prerequisite is usually the primary source file.

$^: The filenames of all prerequisites, separated by spaces. Duplicate prerequisites listed in the rule are automatically removed. The order is generally preserved (except for duplicates).

  • Example: In my_program: main.o utils.o main.o, $^ would be main.o utils.o.

$+: Similar to $^, but it lists all prerequisites, including duplicates, in the order they were specified in the rule. Useful if the order or repetition matters for a command.

  • Example: In my_program: main.o utils.o main.o, $+ would be main.o utils.o main.o.

$?: The filenames of all prerequisites that are newer than the target, separated by spaces. This is useful in recipes for archive files (libraries) where you only want to add the updated object files.

  • Example: If main.o was just recompiled but utils.o wasn’t, in the rule my_program: main.o utils.o, $? would likely expand to just main.o when running the recipe for my_program.

$*: The stem with which an implicit rule or pattern rule matched. If a target is dir/file.o and it matched the pattern rule %.o: %.c, then $* would be dir/file.

  • Example: In our pattern rule %.o: %.c, if it’s being used to build main.o, then $* is main.

Code Example using Automatic Variables:

Let’s revisit the pattern rule from Chapter 4 and the linking rule, now using automatic variables:

# ... (Variables CC, CFLAGS, TARGET, OBJS defined as before) ...
# Default goal
all: $(TARGET)
# Linking rule using $@ (target) and $^ (all prerequisites)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) $^ -o $@ $(LDLIBS)
# Pattern rule for compilation using $@ (target) and $< (first prerequisite)
%.o: %.c $(HEADERS)
$(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
.PHONY: all clean
clean:
$(RM) $(TARGET) $(OBJS)

Notice how the recipes are now completely generic. They don’t mention my_program, main.o, or main.c explicitly. The same recipe command works correctly whether it’s building main.o from main.c or utils.o from utils.c, thanks to $@ and $<. Similarly, the linking rule works regardless of the specific object files listed in $(OBJS) because it uses $@ and $^.

5.2 Recipe Syntax: Execution, Line Continuations (\), Suppressing Output (@), Ignoring Errors (-)

Understanding how make handles recipes is crucial:

Execution: As mentioned before, each line in a recipe is executed in its own separate sub-shell. This means environment variables set on one line are not available on the next line by default. If you need commands to share shell context (like changing directory and then running a command), you must put them on the same logical line, often joined by semicolons (;) or using shell logical operators (&&).

# BAD: cd happens in one subshell, ls in another (in the original directory)
bad_example:
<TAB>cd /tmp
<TAB>ls

# GOOD: Both commands run in the same subshell
good_example:
<TAB>cd /tmp; ls

# ALSO GOOD (using && ensures ls runs only if cd succeeds)
another_good_example:
<TAB>cd /tmp && ls

Line Continuation (\): If a recipe command is very long, you can split it across multiple physical lines by ending each line (except the last) with a backslash (\). make treats this as a single logical line executed in one sub-shell.

long_command_target: prerequisite
<TAB>echo "This is a very long command..." && \
<TAB>echo "that continues onto the next line."
  • Important: There should be no spaces or other characters after the backslash.

Suppressing Output (@): Normally, make prints (echoes) each recipe line to standard output before executing it. To prevent a specific line from being echoed, prefix it with an @ symbol. This is commonly used for echo commands within the Makefile itself to provide user-friendly status messages.

all: my_program
<TAB>@echo "Linking the program..." # Only the message is shown, not the 'echo' command
<TAB>$(CC) $(LDFLAGS) $^ -o $@ $(LDLIBS) # This command *will* be echoed
<TAB>@echo "Build complete!"

Ignoring Errors (-): By default, if any command in a recipe exits with a non-zero status (indicating an error), make immediately stops processing the current rule and potentially aborts the entire build process. To tell make to ignore an error from a specific command and continue regardless, prefix the command line with a hyphen (-). This is often used for commands like rm where the file might not exist, which would normally cause rm to fail.

5.3 Comments (#)

Any text on a line starting with a hash symbol (#) is treated as a comment by make and is ignored, unless the # appears within a recipe line (where the shell might interpret it differently, although typically as a comment too). Comments are essential for explaining complex rules or variable definitions.

# This is a full-line comment
VAR := value # This is a comment after a definition
target: prerequisite # Comment describing the rule
# Another comment explaining the recipe
<TAB>@echo "Running recipe..." # Comment within the recipe line (shell comment)
<TAB>command # This is a shell comment within the recipe

5.4 Including Other Makefiles (include directive)

As Makefiles grow, it can be useful to split them into multiple files for better organization or to include dynamically generated content. The include directive tells make to suspend reading the current Makefile, read one or more other Makefiles, and then resume.

Syntax: include filename1 filename2 …

Use Cases:

  • Sharing variable definitions or standard rules across multiple projects/Makefiles.
  • Including machine-generated dependency files (a common advanced technique for precise header dependency tracking).
  • Splitting a very large Makefile into logical parts (e.g., variables.mk, rules.mk).
# Main Makefile
include config.mk # Include configuration variables
SRCS = main.c utils.c
OBJS = $(SRCS:.c=.o)
TARGET = my_program
include rules.mk # Include standard build rules
# Include generated dependencies (if they exist - '-' ignores error)
-include $(OBJS:.o=.d)

If the included file doesn’t exist and the directive is prefixed with — (-include), make will not report an error and will simply continue.

5.5 Conditional Processing (ifeq, ifneq, ifdef, ifndef)

make provides directives for conditional processing, allowing parts of the Makefile to be evaluated or ignored based on variable values or definitions. This is useful for creating builds that behave differently based on settings (e.g., debug vs. release).

  • Syntax:
# Based on variable value equality
ifeq (arg1, arg2)
# Makefile text if strings arg1 and arg2 are identical
else
# Optional else part
endif

ifneq (arg1, arg2)
# Makefile text if strings arg1 and arg2 differ
else
# Optional else part
endif

# Based on variable definition
ifdef VARIABLE_NAME
# Makefile text if VARIABLE_NAME is defined (has any value)
else
# Optional else part
endif

ifndef VARIABLE_NAME
# Makefile text if VARIABLE_NAME is not defined
else
# Optional else part
endif
  • Important: The arguments (arg1, arg2) are expanded before comparison. Be mindful of leading/trailing whitespace unless using functions like $(strip …). The conditional directives themselves (ifeq, else, endif) should not be indented with tabs.

Example: Debug vs. Release Build

# Build type can be set via command line: make BUILD_TYPE=debug
BUILD_TYPE ?= release # Default to release if not set
CFLAGS := -Wall
ifeq ($(BUILD_TYPE), debug)
CFLAGS += -g -O0 -DDEBUG # Add debug flags
else
CFLAGS += -O2 -DNDEBUG # Add release flags
endif
# ... rest of the Makefile using $(CFLAGS) ...
hello: hello.c
<TAB>$(CC) $(CFLAGS) $< -o $@

Now, running make will use release flags (-O2 -DNDEBUG), while running make BUILD_TYPE=debug will use debug flags (-g -O0 -DDEBUG).

These syntax elements — automatic variables, recipe modifiers, comments, includes, and conditionals — provide the necessary tools to write sophisticated and maintainable Makefiles beyond the most basic rules.

This post is based on interaction with https://aistudio.corp.google.com/.

Enjoy learning !!!

--

--

Dilip Kumar
Dilip Kumar

Written by Dilip Kumar

With 18+ years of experience as a software engineer. Enjoy teaching, writing, leading team. Last 4+ years, working at Google as a backend Software Engineer.

No responses yet