Your First Makefile
Summary: in this tutorial, you will learn write your first makefile from scratch. learn targets, prerequisites, recipes, phony targets, and how make decides what to rebuild.
Your First Makefile
Now that Make is installed, let's write real Makefiles. This tutorial covers the fundamental building blocks that every Makefile is built from.
What you'll learn in this tutorial:
- How to write Makefile rules (target, prerequisites, recipe)
- How Make decides what to rebuild based on file timestamps
- How to create multiple rules that depend on each other
- What "phony targets" are and why you need them
- How to suppress command echo and ignore errors
- How each recipe line runs in its own shell (and why that matters)
- How to create a self-documenting help target
The Basic Rule
Every Makefile is made of rules. A rule tells Make how to create a file (the target) from other files (the prerequisites) using commands (the recipe):
target: prerequisites
recipeLet's build a concrete example. First, create a file called hello.c:
#include <stdio.h>
int main() {
printf("Hello, Make!\n");
return 0;
}Don't know C? That's okay! This is a simple C program that prints "Hello, Make!" to the screen. You just need a C compiler (gcc) installed — if you ran sudo apt install build-essential (Ubuntu) or xcode-select --install (macOS) during setup, you already have one. The important thing here is learning Make, not C.
Now create a Makefile in the same directory:
hello: hello.c
gcc hello.c -o helloThis rule says:
- Target:
hello(the file we want to create) - Prerequisite:
hello.c(the file it depends on) - Recipe:
gcc hello.c -o hello(the command to compile it)
Run it:
makeOutput:
gcc hello.c -o hello
Make compiled the program. Now run it again without changing anything:
makeOutput:
make: 'hello' is up to date.
Make checked the timestamps and found that hello already exists and is newer than hello.c, so there's nothing to rebuild. This is the core value of Make — it only runs commands when something actually changed.
How Make Decides What to Rebuild
Make uses a simple algorithm based on file timestamps (the "last modified" time):
Step 1: Look at the target file's last-modified time
Step 2: Look at each prerequisite file's last-modified time
Step 3: If any prerequisite is newer than the target, run the recipe
Step 4: If the target doesn't exist at all, always run the recipe
Try this to see it in action:
touch hello.c # Update hello.c's timestamp to "right now"
make # Make sees hello.c is newer than hello, so it rebuildsgcc hello.c -o hello
What does touch do? The touch command updates a file's "last modified" timestamp to the current time without changing its contents. It's useful for testing because it tricks Make into thinking the file was just edited.
Multiple Rules
A Makefile can have many rules. By default, when you type make with no arguments, Make builds the first target in the file (called the default goal).
Let's say you have two programs to build. Create a goodbye.c file too:
#include <stdio.h>
int main() {
printf("Goodbye, Make!\n");
return 0;
}Now update your Makefile:
all: hello goodbye
hello: hello.c
gcc hello.c -o hello
goodbye: goodbye.c
gcc goodbye.c -o goodbyeHow this works:
allis the first target, so it's the default goalalldepends onhelloandgoodbye- To build
all, Make needs to build bothhelloandgoodbyefirst - Make looks at each prerequisite, finds the matching rules, and runs them
makegcc hello.c -o hello
gcc goodbye.c -o goodbye
You can also build a specific target by name:
make goodbye # Only build the "goodbye" targetPhony Targets
Some targets don't create files — they just run commands. The most common example is clean, which deletes build artifacts:
all: hello goodbye
hello: hello.c
gcc hello.c -o hello
goodbye: goodbye.c
gcc goodbye.c -o goodbye
clean:
rm -f hello goodbyeRun make clean to delete the compiled programs:
make cleanrm -f hello goodbye
But there's a subtle problem: what if someone creates a file literally named clean in your directory? Make would check the timestamp of the clean file, see it already exists with no prerequisites, and say make: 'clean' is up to date. — it wouldn't actually run the cleanup!
The fix is .PHONY:
.PHONY: all clean
all: hello goodbye
hello: hello.c
gcc hello.c -o hello
goodbye: goodbye.c
gcc goodbye.c -o goodbye
clean:
rm -f hello goodbyeWhat does .PHONY do? It tells Make: "These targets are not file names — always run their recipes, regardless of whether a file with that name exists." You should mark any target that doesn't produce a file (like clean, all, test, install) as .PHONY.
Suppressing Command Echo
By default, Make prints each command before running it. That's why you see both the command and its output:
gcc hello.c -o hello
To suppress the command echo (only show the output), prefix the command with @:
hello: hello.c
@gcc hello.c -o helloThis is especially useful for echo commands where showing the command is redundant:
hello: hello.c
@echo "Compiling hello..."
@gcc hello.c -o hello
@echo "Done!"Output:
Compiling hello...
Done!
Without the @ signs, you'd see:
echo "Compiling hello..."
Compiling hello...
gcc hello.c -o hello
echo "Done!"
Done!
Ignoring Errors
Normally, if a command fails (returns a non-zero exit code), Make stops immediately. Sometimes you want to continue anyway — for example, when running rm on files that might not exist.
Prefix the command with - to tell Make to ignore errors:
clean:
-rm hello goodbye
@echo "Cleanup done!"If hello doesn't exist, rm would normally fail and Make would stop. The - prefix tells Make: "If this command fails, keep going."
A better approach for rm: Instead of -rm hello goodbye, use rm -f hello goodbye. The -f flag tells rm to silently ignore files that don't exist, so it never fails. This is more common in practice.
Each Line Runs in Its Own Shell
This is a gotcha that catches many beginners:
Each line of a recipe runs in a separate shell. This means that changing a directory with cd on one line has NO effect on the next line — each line starts fresh in the original directory.
This will NOT work as expected:
build:
cd src
gcc main.c -o mainThe cd src runs in one shell, then that shell exits. The gcc command runs in a new shell, back in the original directory. So gcc won't find main.c in src/.
Fix 1: Chain commands on one line with &&:
build:
cd src && gcc main.c -o mainThe && means "run the next command only if the previous one succeeded." Since they're on the same line, they run in the same shell.
Fix 2: Use a backslash \ to continue a line:
build:
cd src && \
gcc main.c -o mainThe backslash tells Make to treat the next line as a continuation of the current line, so both commands run in the same shell.
Setting the Default Goal
The first target in the Makefile is the default goal (what gets built when you just type make). You can also set it explicitly:
.DEFAULT_GOAL := allThis is useful when you want to reorganize your Makefile but keep the same default behavior.
A Self-Documenting Help Target
A nice pattern is adding a help target that shows what each target does:
.PHONY: all clean help
help: ## Show this help message
@echo "Available targets:"
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk -F ':.*?## ' '{printf " %-15s %s\n", $$1, $$2}'
all: hello goodbye ## Build everything
hello: hello.c ## Build the hello program
gcc hello.c -o hello
goodbye: goodbye.c ## Build the goodbye program
gcc goodbye.c -o goodbye
clean: ## Remove build artifacts
rm -f hello goodbyeHow does this work? Each target has a ## comment after it. The help target uses grep to find all lines matching the pattern target: ... ## description, then uses awk to format them into a nice table. Don't worry about understanding the grep and awk commands in detail — just copy this pattern into your Makefiles.
What is $(MAKEFILE_LIST)? It's a special Make variable that contains the name of the current Makefile. And $$1 / $$2 use double dollar signs because $ has special meaning in Make — $$ escapes to a literal $ for the shell.
Running make help outputs:
Available targets:
help Show this help message
all Build everything
hello Build the hello program
goodbye Build the goodbye program
clean Remove build artifacts
A Complete Example
Here's a complete Makefile that puts everything together. Create these files:
main.c:
#include <stdio.h>
#include "greeting.h"
int main() {
greet("World");
return 0;
}greeting.h:
#ifndef GREETING_H
#define GREETING_H
void greet(const char *name);
#endifgreeting.c:
#include <stdio.h>
#include "greeting.h"
void greet(const char *name) {
printf("Hello, %s!\n", name);
}Makefile:
.PHONY: all clean run
all: myapp
myapp: main.o greeting.o
gcc main.o greeting.o -o myapp
main.o: main.c greeting.h
gcc -c main.c -o main.o
greeting.o: greeting.c greeting.h
gcc -c greeting.c -o greeting.o
run: myapp
./myapp
clean:
rm -f myapp *.oWhat's happening here?
gcc -c file.c -o file.o— compiles a single.cfile into an object file (.o). Object files contain machine code but aren't complete programs yet.gcc main.o greeting.o -o myapp— links multiple object files together into a final executable program calledmyapp.- Both
main.oandgreeting.odepend ongreeting.h— if the header changes, both need recompiling. - The
runtarget depends onmyapp, so it builds the program first (if needed), then runs it.
Try this workflow:
make # Compiles everything
make run # Runs the program (rebuilds first if needed)
# Edit greeting.c...
make # Only recompiles greeting.c and re-links (main.c untouched!)
make clean # Removes all compiled filesSummary
Here's what you've learned:
- A Makefile rule has three parts: target, prerequisites, and recipe
- Make only rebuilds when prerequisites are newer than the target
- The first target is the default goal (what
makeruns with no arguments) .PHONYmarks targets that don't produce files (likeclean,all,test)@before a command suppresses the command echo-before a command tells Make to ignore errors- Each recipe line runs in a separate shell — use
&&or\to chain commands $(MAKEFILE_LIST)and the##pattern create a self-documentinghelptarget
In the next tutorial, you'll learn about variables and automatic variables — which eliminate repetition and make your Makefiles flexible and maintainable.
Build a Multi-File Project
Create a small project with at least two source files and a Makefile that:
- Has an
alltarget as the default goal - Compiles each source file separately into an object file
- Links all object files into a final program
- Has a
cleantarget that removes all generated files - Has a
runtarget that builds and then runs the program - Uses
.PHONYfor all non-file targets - Includes the self-documenting
helptarget
Show Solution
Here's a complete solution using the files from the example above:
.PHONY: all clean run help
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk -F ':.*?## ' '{printf " %-15s %s\n", $$1, $$2}'
all: myapp ## Build the application
myapp: main.o greeting.o
@echo "Linking..."
@gcc main.o greeting.o -o myapp
@echo "Build complete!"
main.o: main.c greeting.h ## Compile main.c
@echo "Compiling main.c..."
@gcc -c main.c -o main.o
greeting.o: greeting.c greeting.h ## Compile greeting.c
@echo "Compiling greeting.c..."
@gcc -c greeting.c -o greeting.o
run: myapp ## Build and run the program
@./myapp
clean: ## Remove all build files
rm -f myapp *.oTest it:
make help # Shows all targets with descriptions
make # Builds everything (default is "all" via help → all ordering)
make run # Runs the program
make clean # Cleans upKey points in this solution:
.PHONYlists all targets that don't create files@echoprovides friendly build progress without showing raw gcc commandsgreeting.his a prerequisite for both.ofiles — changing it triggers recompilation of bothrundepends onmyapp, so it automatically builds first if needed
Written by the ShellRAG Team
The ShellRAG editorial team writes practical, beginner-friendly Makefile tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.
Learn more about us →