Why bother with make?

Real-World Analogy

Think of a Makefile as a recipe book for your project. Each recipe (rule) says: "to make this dish (target), you need these ingredients (dependencies), and here are the steps (commands)." The chef (make) is smart: if an ingredient hasn't changed since you last cooked, it skips that step. Cooking only what is necessary saves enormous time on large projects.

In Python and Java you typically run one command to compile or interpret a file. When you write a Python script you type python myfile.py; in Java you type javac MyClass.java. For tiny programs this is fine. But C projects often consist of dozens of .c files. Recompiling every file every time you change one line wastes minutes on large codebases.

make solves this by tracking which source files have changed (by comparing timestamps) and only recompiling those. It also standardises exactly how your project is built, so anyone who clones your repo can reproduce your build with one command: make.

The COMP2017 assignments all include a Makefile. The marker runs make to build your submission — if your Makefile is broken, nothing compiles and you lose marks.

Python / Java (what you know)
# Python: just run the file
python main.py

# Java: compile then run
javac Main.java
java Main

# No incremental rebuild —
# everything reprocessed every time
C with make
# Build everything (first time)
make

# Only recompile changed .c files
make        # incremental

# Remove generated files
make clean

# Run automated tests
make test
TAB indent — not spaces!

The recipe line in a Makefile rule must start with a real TAB character, not spaces. This is the single most common Makefile mistake. If you copy-paste from a website or your editor converts tabs to spaces, make will print Makefile:5: *** missing separator. Stop. Check with cat -A Makefile — you should see ^I (tab) before each recipe line.

Mental model

A Makefile is a directed graph. Each target depends on zero or more files. make walks the graph from the requested target downward, rebuilding any node whose file is older than its dependencies. If nothing changed, nothing is rebuilt. If you ask for a target that doesn't exist as a file (a phony target), it always runs.

Rules, variables, automatic variables, and pattern rules

Basic rule structure

target: dependency1 dependency2
	recipe_command   # <-- MUST be a TAB, not spaces

# Example:
program: main.o utils.o
	gcc -o program main.o utils.o
target — the file to build (or a phony name like clean).   dependencies — files that must exist and be up-to-date first.   recipe — shell command(s) to produce the target; tab-indented.

Variables

CC     := gcc                     # compiler
CFLAGS := -Wall -Wextra -g        # flags
TARGET := program

# Use with $(VAR_NAME)
program: main.o utils.o
	$(CC) $(CFLAGS) -o $(TARGET) main.o utils.o
:= assigns immediately (preferred). = assigns lazily (evaluated at use). Variables avoid repeating the compiler name and flags everywhere — change CC once to switch compilers.

Automatic variables

VariableMeaningExample use
$@The target namegcc -o $@ ...
$<The first dependencygcc -c $<
$^All dependencies (space-separated)gcc -o $@ $^
$*The stem (matched by %)echo $*

Pattern rules

# Generic rule: compile any .c file into a .o file
%.o: %.c
	$(CC) $(CFLAGS) -c $<
#                     ^^ first dep = the .c file

# make finds main.o is needed → looks for main.c → applies rule
The % wildcard matches any stem. %.o: %.c means "to build anything.o, compile anything.c". This eliminates writing one rule per source file.

Phony targets

.PHONY: all clean test

clean:
	rm -f program *.o

test: program
	./run_tests.sh
.PHONY tells make these targets are not files. Without it, if a file named clean exists, make clean would do nothing (the "file" already exists and has no deps). Declaring phony ensures the recipe always runs.

Important compiler flags

FlagPurpose
-WallEnable common warnings (uninitialized vars, unused vars, etc.)
-WextraExtra warnings beyond -Wall
-WerrorTreat all warnings as errors — forces clean code
-gInclude debug symbols (required for gdb, valgrind)
-O2Optimise for speed (release builds)
-std=c11Use the C11 standard
-fsanitize=addressAddressSanitizer: detect buffer overflows and memory errors at runtime

Complete Makefiles you can use

Example 1 — Minimal Makefile for a single-file project Course Lessons
CC     := gcc
CFLAGS := -Wall -Wextra -g -std=c11
TARGET := hello

# Default target: build the executable
all: $(TARGET)

$(TARGET): hello.c
	$(CC) $(CFLAGS) -o $@ $^

.PHONY: clean
clean:
	rm -f $(TARGET)
Usage
make # builds hello
make clean # removes hello
Example 2 — Multi-file project with pattern rules (assignment-style) P1/P2 Assignment Pattern
CC     := gcc
CFLAGS := -Wall -Wextra -Werror -g -std=c11
TARGET := program
SRCS   := main.c utils.c parser.c
OBJS   := $(SRCS:.c=.o)   # replace .c with .o in SRCS list

all: $(TARGET)

# Link all object files into the final executable
$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) -o $@ $^

# Generic pattern rule: compile any .c to .o
%.o: %.c
	$(CC) $(CFLAGS) -c $<

.PHONY: clean test

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

test: $(TARGET)
	./test_script.sh
How make decides what to rebuild
If only utils.c changed: make recompiles utils.o, then re-links program.
main.o and parser.o are NOT recompiled — timestamps unchanged.
Example 3 — Automated test script using diff Assignment Testing Pattern
#!/bin/bash
# test_script.sh — run program and compare output to expected

PASS=0
FAIL=0

run_test() {
    local test_name="$1"
    local input_file="$2"
    local expected_file="$3"

    # Run program with input, capture output
    actual=$(./program < "$input_file" 2>/dev/null)
    expected=$(cat "$expected_file")

    if [ "$actual" = "$expected" ]; then
        echo "PASS: $test_name"
        PASS=$((PASS + 1))
    else
        echo "FAIL: $test_name"
        echo "  Expected: $expected"
        echo "  Got:      $actual"
        FAIL=$((FAIL + 1))
    fi
}

run_test "basic_input"  tests/input1.txt  tests/expected1.txt
run_test "empty_input"  tests/input2.txt  tests/expected2.txt
run_test "large_input"  tests/input3.txt  tests/expected3.txt

echo ""
echo "Results: $PASS passed, $FAIL failed"
[ $FAIL -eq 0 ]   # exit 0 on success, nonzero on failure
Sample output
PASS: basic_input
PASS: empty_input
FAIL: large_input
Expected: 42
Got: 41

Results: 2 passed, 1 failed

Practice problems with solutions

P1 — Write a Makefile for a two-file project Assignment-style

You have two source files: main.c (which includes math_utils.h) and math_utils.c. Write a Makefile that: compiles both with -Wall -g, links them into an executable called calc, and has a clean target. Use variables for CC and CFLAGS.

CC     := gcc
CFLAGS := -Wall -g

all: calc

calc: main.o math_utils.o
	$(CC) $(CFLAGS) -o $@ $^

main.o: main.c math_utils.h
	$(CC) $(CFLAGS) -c $<

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

.PHONY: clean
clean:
	rm -f calc main.o math_utils.o
Key points: calc depends on both .o files. Each .o lists both the .c file and the header — if math_utils.h changes, both .o files are rebuilt. $@ expands to the target name. $^ expands to all dependencies.
P2 — Spot the error: why does make refuse to run? Common mistake

This Makefile produces the error Makefile:4: *** missing separator. Stop.. Why? Fix it.

CC := gcc

program: main.c
    gcc -o program main.c
CC := gcc

program: main.c
	gcc -o program main.c
The bug: The recipe line uses four spaces for indentation instead of a TAB character. The make tool requires recipes to start with a literal tab (\t). Many text editors silently convert tabs to spaces. Fix: replace the leading spaces with a single tab character. In vim: :set noexpandtab then use Tab key.
P3 — Add AddressSanitizer and a test target Course assignment pattern

Extend this Makefile to add: (a) a debug target that compiles with -fsanitize=address, and (b) a test target that builds the program and runs ./run_tests.sh.

CC := gcc
CFLAGS := -Wall -g
program: main.c
	$(CC) $(CFLAGS) -o $@ $^
CC     := gcc
CFLAGS := -Wall -g

all: program

program: main.c
	$(CC) $(CFLAGS) -o $@ $^

debug: main.c
	$(CC) $(CFLAGS) -fsanitize=address -o program_asan $^

.PHONY: all debug clean test

test: program
	./run_tests.sh

clean:
	rm -f program program_asan
Note: -fsanitize=address instruments the binary to detect out-of-bounds accesses, use-after-free, and stack buffer overflows. It adds runtime overhead (about 2x slower), so keep it as a separate debug target rather than in your default build. The test target depends on program — make will build the program first if it doesn't exist.
P4 — Predict make's behaviour: what gets rebuilt? Conceptual

You have a Makefile with targets: program (depends on main.o utils.o), main.o (depends on main.c utils.h), utils.o (depends on utils.c utils.h). All files exist and were last built 1 hour ago. You then edit only utils.h. When you run make, which targets are rebuilt and in what order?

Rebuilt in order: 1. main.o — because utils.h (a dependency) is newer than main.o. 2. utils.o — because utils.h is newer than utils.o. 3. program — because both .o files are now newer than program.

Why this matters: Header files often declare structs and function signatures used across many .c files. If a header changes, every .o that includes it must be recompiled to pick up the new declarations. This is why dependencies list both .c files and the headers they include.

cmocka — C unit testing framework

cmocka is a lightweight C unit testing framework — the C equivalent of JUnit (Java) or pytest (Python). You write test functions, each verifying one small behaviour, and cmocka runs them all and reports pass/fail.

LanguageFrameworkAssert example
Pythonpytestassert result == expected
JavaJUnitassertEquals(expected, result)
Ccmockaassert_int_equal(result, expected)
Include order matters

#include order matters: <stdarg.h>, <stddef.h>, <setjmp.h> MUST come before <cmocka.h>. Getting this wrong causes cryptic compile errors that have nothing to do with your actual test code.

Setup and structure — complete cmocka test file cmocka pattern
/* IMPORTANT: these three includes must come before cmocka.h */
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>

/* Test function — must have this exact signature */
static void test_add(void **state) {
    (void)state;  /* unused */
    assert_int_equal(add(2, 3), 5);
    assert_int_equal(add(-1, 1), 0);
    assert_int_equal(add(0, 0), 0);
}

int main(void) {
    const struct CMUnitTest tests[] = {
        cmocka_unit_test(test_add),
    };
    return cmocka_run_group_tests(tests, NULL, NULL);
}
Compile & run
gcc test_add.c add.c -o test_add -lcmocka && ./test_add

Key assert macros

MacroPurpose
assert_int_equal(a, b)integers equal
assert_string_equal(a, b)strings equal (like strcmp==0)
assert_non_null(ptr)pointer is not NULL
assert_null(ptr)pointer is NULL
assert_true(cond)condition is true
assert_false(cond)condition is false
assert_memory_equal(a, b, n)n bytes equal
P5 — Write a cmocka test suite for my_strlen cmocka practice

Write a cmocka test suite for int my_strlen(const char *s). Test: empty string returns 0, single char returns 1, normal string returns correct length.

#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>

static void test_my_strlen(void **state) {
    (void)state;
    assert_int_equal(my_strlen(""), 0);
    assert_int_equal(my_strlen("a"), 1);
    assert_int_equal(my_strlen("hello"), 5);
}

int main(void) {
    const struct CMUnitTest tests[] = {
        cmocka_unit_test(test_my_strlen),
    };
    return cmocka_run_group_tests(tests, NULL, NULL);
}
Key points: The test function signature is always static void name(void **state). Cast state to void to suppress unused-parameter warnings. Each assert_int_equal call checks one specific case — empty string, single character, and a multi-character string. Register with cmocka_unit_test() and run with cmocka_run_group_tests().

Key concepts to memorize

Card 1 of 10
Question — click to flip
Answer
Click card to flip • Use buttons to navigate

Test your understanding

Topic 23 Quiz — Makefiles & Testing Score: 0 / 6
1
What character must begin a recipe line in a Makefile?LO7
multiple choice
2
What does the automatic variable $@ expand to?LO7
multiple choice
3
True or False: Declaring a target as .PHONY ensures its recipe always runs, even if a file with that name exists.LO7
true / false
4
What gcc flag enables AddressSanitizer for memory error detection?LO7
fill in the blank
5
What does the pattern rule %.o: %.c mean?LO7
multiple choice
6
You edit only utils.c and run make. Which files does make rebuild (assuming a standard multi-file Makefile)?LO7
multiple choice
0/6
Quiz complete!