Makefile Tutorial for C Beginners — Stop Typing gcc Three Times

The moment your C project grows past one file, compiling gets tedious: three gcc commands, typed in the right order, every single time — and if you forget one, you’re debugging a stale binary. Make fixes this with a small file of build rules, and it’s been the standard way to build C for four decades. This tutorial builds a real two-module project and grows a Makefile from three lines to production shape, with every command’s actual output shown (GNU Make 4.3, gcc 13.3, Ubuntu 24.04).

The Project We’re Building

A tiny calculator split across three files — the smallest project where Make starts paying rent:

/* utils.h */
#ifndef UTILS_H
#define UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
/* utils.c */
#include "utils.h"

int add(int a, int b)      { return a + b; }
int multiply(int a, int b) { return a * b; }
/* main.c */
#include <stdio.h>
#include "utils.h"

int main(void)
{
    printf("3 + 4 = %d\n", add(3, 4));
    printf("3 * 4 = %d\n", multiply(3, 4));
    return 0;
}

Building it by hand means compiling each .c to an object file, then linking:

$ gcc -Wall -Wextra -g -c main.c
$ gcc -Wall -Wextra -g -c utils.c
$ gcc -Wall -Wextra -g -o calc main.o utils.o

Three commands, every time, forever. Let’s not.

Anatomy of a Rule

A Makefile is a list of rules, each with the same three-part shape:

target: prerequisites
<TAB> recipe
  • target — the file this rule produces (calc, main.o)
  • prerequisites — the files it’s built from; if any is newer than the target, the rule runs
  • recipe — the shell command(s) that build it, indented with a real TAB character, not spaces

That TAB is the most famous gotcha in all of Unix: indent a recipe with spaces and Make dies with *** missing separator. Stop. — see the issues table below.

A First Working Makefile

Save this as Makefile (capital M, no extension) next to the sources:

CC      = gcc
CFLAGS  = -Wall -Wextra -g
OBJS    = main.o utils.o
TARGET  = calc

$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) -o $@ $(OBJS)

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

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

Piece by piece:

  • CC, CFLAGS, OBJS, TARGET are variables — change the compiler or flags in one place, referenced as $(NAME).
  • $@ and $< are automatic variables: inside a recipe, $@ is the target’s name and $< is the first prerequisite. They keep rules generic.
  • %.o: %.c utils.h is a pattern rule: one rule that builds any .o from its matching .c. Listing utils.h as a prerequisite means “if the header changes, recompile” — the detail most beginner Makefiles get wrong.
  • .PHONY: clean tells Make that clean is a command, not a file — so it runs even if a file named clean ever exists.

Watch It Work

First build — Make runs all three steps and echoes each command:

$ make
gcc -Wall -Wextra -g -c main.c
gcc -Wall -Wextra -g -c utils.c
gcc -Wall -Wextra -g -o calc main.o utils.o
$ ./calc
3 + 4 = 7
3 * 4 = 12

Run make again without changing anything:

$ make
make: 'calc' is up to date.

This is Make’s whole superpower: it compares file timestamps and does nothing unless something changed. Now edit only utils.c and rebuild:

$ touch utils.c
$ make
gcc -Wall -Wextra -g -c utils.c
gcc -Wall -Wextra -g -o calc main.o utils.o

Only utils.o was recompiled, then the link — main.o was untouched because main.c didn’t change. On a 3-file project that saves a second; on a 300-file project it turns a ten-minute rebuild into two seconds. And because our pattern rule lists utils.h, a header edit correctly rebuilds both objects:

$ touch utils.h
$ make
gcc -Wall -Wextra -g -c main.c
gcc -Wall -Wextra -g -c utils.c
gcc -Wall -Wextra -g -o calc main.o utils.o

Finally, housekeeping:

$ make clean
rm -f calc main.o utils.o

Reading the Dependency Graph

It helps to see the Makefile as the graph Make sees:

calc  ←  main.o  ←  main.c, utils.h
      ←  utils.o ←  utils.c, utils.h

When you type make, it starts at the first target (calc), walks down the arrows checking timestamps, and re-runs exactly the recipes on any path where a source is newer than its product. Everything else — variables, patterns, phony targets — is convenience layered on this one idea.

Common Issues

Problem Fix
*** missing separator. Stop. Recipe indented with spaces — replace with a real TAB (check your editor’s “tabs to spaces” setting)
make: 'calc' is up to date but your change isn’t in You edited a header that isn’t listed as a prerequisite — add it to the pattern rule (or use gcc’s -MMD auto-dependencies as the next step up)
make: *** No targets specified and no makefile found File must be named Makefile or makefile, in the directory where you run make
undefined reference to 'add' at link An object file is missing from OBJS — every .c module needs its .o listed
make clean says “up to date” A file named clean exists and shadows the target — this is exactly what .PHONY prevents

What’s Next

Your Makefile already compiles with -g, so every build is debugger-ready — learn to use that in How to Use GDB to Debug C Programs, and add a leak check to your workflow with Valgrind.

New to the command-line toolchain entirely? Start from installing GCC on Ubuntu or the complete C environment setup guide.

For bigger projects with multiple directories and third-party libraries, the industry step after Make is CMake — a tutorial is coming in this series.


As an Amazon Associate we earn from qualifying purchases.

Recommended Book

Multi-file program structure — headers, modules, linkage — is exactly what Chapter 4 of The C Programming Language by Kernighan & Ritchie (Amazon.com) teaches. Make is how you build what K&R shows you how to design.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>