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
	recipe

Let'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 hello

This 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:

make

Output:

gcc hello.c -o hello

Make compiled the program. Now run it again without changing anything:

make

Output:

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 rebuilds
gcc 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 goodbye

How this works:

  • all is the first target, so it's the default goal
  • all depends on hello and goodbye
  • To build all, Make needs to build both hello and goodbye first
  • Make looks at each prerequisite, finds the matching rules, and runs them
make
gcc hello.c -o hello
gcc goodbye.c -o goodbye

You can also build a specific target by name:

make goodbye    # Only build the "goodbye" target

Phony 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 goodbye

Run make clean to delete the compiled programs:

make clean
rm -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 goodbye

What 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 hello

This 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 main

The 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 main

The && 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 main

The 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 := all

This 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 goodbye

How 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);
#endif

greeting.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 *.o

What's happening here?

  • gcc -c file.c -o file.o — compiles a single .c file into an object file (.o). Object files contain machine code but aren't complete programs yet.
  • gcc main.o greeting.o -o myapplinks multiple object files together into a final executable program called myapp.
  • Both main.o and greeting.o depend on greeting.h — if the header changes, both need recompiling.
  • The run target depends on myapp, 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 files

Summary

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 make runs with no arguments)
  • .PHONY marks targets that don't produce files (like clean, 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-documenting help target

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:

  1. Has an all target as the default goal
  2. Compiles each source file separately into an object file
  3. Links all object files into a final program
  4. Has a clean target that removes all generated files
  5. Has a run target that builds and then runs the program
  6. Uses .PHONY for all non-file targets
  7. Includes the self-documenting help target
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 *.o

Test 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 up

Key points in this solution:

  • .PHONY lists all targets that don't create files
  • @echo provides friendly build progress without showing raw gcc commands
  • greeting.h is a prerequisite for both .o files — changing it triggers recompilation of both
  • run depends on myapp, so it automatically builds first if needed
Was this page helpful?
SR

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 →