Make and Build Systems

As your projects grow, compiling gets annoying. One file? Easy: gcc main.c -o main. But what about ten files? Twenty? With libraries and flags?

gcc -Wall -Wextra -g main.c utils.c network.c database.c parser.c -lm -lpthread -o myprogram

You don’t want to type that every time. And if you change just one file, you don’t want to recompile everything.

That’s what Make solves. You write the rules once, then just type make. It figures out what needs to be rebuilt and does it automatically.

What Make Does

Make is a build automation tool. You tell it:

  1. What you want to build (the target)
  2. What files it depends on (the dependencies)
  3. How to build it (the commands)

Make then:

  • Checks if the target is older than its dependencies
  • If so, runs the commands to rebuild it
  • If not, skips it - nothing to do

This means if you change one file out of twenty, Make only recompiles what’s affected. Big projects go from minutes to seconds.

Your First Makefile

Make reads instructions from a file called Makefile (or makefile). Here’s the simplest one:

hello: hello.c
	gcc hello.c -o hello

This says:

  • The target is hello
  • It depends on hello.c
  • To build it, run gcc hello.c -o hello

Now just type:

make

Make builds your program. Run it again without changing anything:

make

You’ll see: make: 'hello' is up to date.

Make checked the timestamps. hello is newer than hello.c, so there’s nothing to do.

The Critical Rule: Tabs, Not Spaces

Here’s where everyone gets burned. The command lines in a Makefile must start with a tab character, not spaces.

hello: hello.c
	gcc hello.c -o hello

That indentation before gcc must be a real tab (the Tab key), not spaces.

If you use spaces, you get a cryptic error:

Makefile:2: *** missing separator.  Stop.

This trips up everyone at least once. Your editor might convert tabs to spaces automatically. Turn that off for Makefiles, or tell your editor that Makefiles need real tabs.

Makefile Structure

A Makefile is a list of rules. Each rule looks like this:

target: dependencies
	command
	another command
  • Target: What you’re building (usually a file name)
  • Dependencies: Files the target needs (if any change, rebuild)
  • Commands: Shell commands to run (must start with tab)

You can have multiple rules. Make builds the first target by default:

program: main.o utils.o
	gcc main.o utils.o -o program

main.o: main.c
	gcc -c main.c -o main.o

utils.o: utils.c utils.h
	gcc -c utils.c -o utils.o

Type make and it builds program. But first it checks if main.o and utils.o exist and are up to date. If not, it builds those first.

This is the power of Make - it figures out the right order automatically.

The -c Flag: Compiling Without Linking

You’ll see gcc -c a lot in Makefiles. The -c flag tells GCC to compile but not link. It produces an object file (.o) instead of an executable.

gcc -c main.c        # Creates main.o
gcc -c utils.c       # Creates utils.o
gcc main.o utils.o -o program  # Links them into program

Why bother? Because if you change main.c, you only need to recompile main.c. The other object files stay the same. On big projects, this saves a lot of time.

Variables

Typing the same flags over and over gets old. Use variables:

CC = gcc
CFLAGS = -Wall -Wextra -g

program: main.o utils.o
	$(CC) $(CFLAGS) main.o utils.o -o program

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

utils.o: utils.c utils.h
	$(CC) $(CFLAGS) -c utils.c -o utils.o

$(CC) expands to gcc. $(CFLAGS) expands to -Wall -Wextra -g.

Common Variable Names

These names are conventions - Make doesn’t require them, but everyone uses them:

  • CC - The C compiler (usually gcc or clang)
  • CFLAGS - Flags for compiling (warnings, optimization, etc.)
  • LDFLAGS - Flags for linking (library paths, etc.)
  • LDLIBS - Libraries to link against (-lm, -lpthread, etc.)
CC = gcc
CFLAGS = -Wall -Wextra -O2
LDFLAGS = -L/usr/local/lib
LDLIBS = -lm -lpthread

program: main.o utils.o
	$(CC) $(LDFLAGS) main.o utils.o $(LDLIBS) -o program

Change compilers? Edit one line. Add a warning flag? Edit one line. Much easier than hunting through every command.

Automatic Variables

Make has special variables that save typing:

  • $@ - The target name
  • $< - The first dependency
  • $^ - All dependencies
program: main.o utils.o
	$(CC) $^ -o $@

This expands to: gcc main.o utils.o -o program

  • $^ becomes main.o utils.o (all dependencies)
  • $@ becomes program (the target)

For compiling source files:

main.o: main.c
	$(CC) $(CFLAGS) -c $< -o $@
  • $< becomes main.c (first dependency)
  • $@ becomes main.o (target)

Pattern Rules

Writing a rule for every .c file is tedious. Pattern rules let you write one rule for all of them:

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

The % is a wildcard. This rule says: “For any .o file, if there’s a matching .c file, here’s how to build it.”

Now your whole Makefile can be much shorter:

CC = gcc
CFLAGS = -Wall -Wextra -g

program: main.o utils.o network.o
	$(CC) $^ -o $@

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

Three source files, one pattern rule. Add more source files? Just add them to the dependency list.

Phony Targets

Sometimes you want targets that aren’t files. The classic example is clean:

clean:
	rm -f program *.o

This removes the compiled files. But what if someone creates a file named clean? Make would see that clean exists and say “nothing to do.”

The fix is .PHONY:

.PHONY: clean

clean:
	rm -f program *.o

This tells Make that clean isn’t a file - always run the commands.

Common phony targets:

  • clean - Remove built files
  • all - Build everything
  • install - Copy program to system directories
  • test - Run tests
.PHONY: all clean install test

all: program

clean:
	rm -f program *.o

install: program
	cp program /usr/local/bin/

test: program
	./program --test

A Complete Example

Here’s a realistic Makefile for a project with multiple source files:

# Compiler and flags
CC = gcc
CFLAGS = -Wall -Wextra -g
LDLIBS = -lm

# Source files and objects
SRCS = main.c utils.c parser.c network.c
OBJS = $(SRCS:.c=.o)

# The final executable
TARGET = myprogram

# Default target
all: $(TARGET)

# Link the program
$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) $^ $(LDLIBS) -o $@

# Pattern rule for object files
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# Header dependencies (if utils.c includes utils.h)
utils.o: utils.h
parser.o: parser.h utils.h
network.o: network.h

# Clean up
.PHONY: all clean

clean:
	rm -f $(TARGET) $(OBJS)

Let’s break down the new parts:

SRCS = main.c utils.c parser.c network.c - List all source files in one variable.

OBJS = $(SRCS:.c=.o) - This is a substitution. It takes SRCS and replaces .c with .o. So OBJS becomes main.o utils.o parser.o network.o.

Header dependencies - If utils.c includes utils.h, changing utils.h should trigger a rebuild of utils.o. We add these manually.

Using Make

Build your project:

make

This builds the first target (usually all or the program name).

Build a specific target:

make clean

Rebuild everything from scratch:

make clean && make

See what Make would do without doing it:

make -n

This prints the commands but doesn’t run them. Useful for checking your Makefile.

Build with multiple jobs (faster on multi-core machines):

make -j4

This runs up to 4 commands in parallel.

Header File Dependencies

There’s one tricky part we glossed over. If you change a header file, which source files need to be recompiled?

You can list them manually:

main.o: main.c config.h utils.h
utils.o: utils.c utils.h

But this is error-prone. GCC can generate these dependencies automatically:

gcc -MM main.c

This outputs:

main.o: main.c config.h utils.h

Some projects use this to auto-generate dependency information. But for smaller projects, manual listing works fine.

Other Build Tools

Make is old (1976!) but still widely used. You’ll encounter other tools too:

CMake - Generates Makefiles (and other build files) from a simpler description. Popular for cross-platform projects. You write CMakeLists.txt, CMake writes the Makefile.

Meson - Modern build system, similar idea to CMake but cleaner syntax. Growing in popularity.

Ninja - Like Make but faster. Usually used with CMake or Meson, not directly.

Autotools - The ./configure && make && make install dance. Older, complex, but you’ll see it in many open source projects.

For learning and small projects, Make is perfect. It’s simple, universal, and teaches you what build tools actually do. Once you understand Make, the others make more sense.

Try It Yourself

  1. Create a simple project with main.c and utils.c. Write a Makefile that compiles them into a program. Make sure changing one file only recompiles that file.

  2. Add a clean target that removes the executable and all .o files. Don’t forget .PHONY.

  3. Add variables for CC and CFLAGS. Set CFLAGS to -Wall -Wextra -g. Verify that changing CFLAGS in the Makefile causes a rebuild.

  4. Create a header file utils.h that main.c includes. Add the dependency so changing utils.h triggers a rebuild of main.o.

  5. Try make -n to see what commands would run without running them.

Common Mistakes

  • Using spaces instead of tabs. Command lines must start with a tab character. If you see missing separator, this is probably why.

  • Forgetting dependencies. If main.c includes utils.h, add it as a dependency. Otherwise changing utils.h won’t trigger a rebuild.

  • Forgetting .PHONY. If you have a target like clean or test, mark it as phony. Otherwise a file with that name breaks your build.

  • Wrong order of dependencies. The first target in the Makefile is the default. Usually you want all first.

  • Not using -c for object files. If you’re building .o files, use gcc -c. Without it, GCC tries to make an executable.

  • Spaces in file names. Make doesn’t handle spaces in file names well. Avoid them.

  • Recursive variable expansion. Using = instead of := can cause infinite loops in some cases. For simple Makefiles it doesn’t matter, but be aware.

What You’ve Learned

  • Make automates building by tracking what’s changed
  • Makefiles contain rules: target, dependencies, commands
  • Commands must start with a tab character
  • Variables avoid repetition (CC, CFLAGS, etc.)
  • Pattern rules handle multiple files with one rule
  • .PHONY marks targets that aren’t files
  • make clean is the convention for removing built files

Build systems seem like overkill for small projects. But even for a few files, a Makefile saves time and prevents mistakes. And when your project grows, you’ll be glad the foundation is already there.

Next Up

In Part 27, we’ll learn about debugging - finding and fixing bugs in your C programs, from reading error messages to using a real debugger.


Enjoyed This?

If this helped something click, subscribe to my YouTube channel. More content like this, same approach - making things stick without insulting your intelligence. It’s free, it helps more people find this stuff, and it tells me what’s worth making more of.