Practical #1: Makefile
Objectives
By the end of this session, you will be able to:
- Compile a multi-file C project manually and understand each step
- Write a complete Makefile with explicit rules and variables
- Refactor rules using automatic variables and pattern rules
- Set up automatic dependency tracking for header files
Starter Code
Create a directory tp_makefile/ with the following project structure — a small data-processing program that reads numbers and computes statistics:
tp_makefile/
├── main.c
├── stats.c
├── stats.h
├── io.c
└── io.h
io.h
#ifndef IO_H
#define IO_H
#define MAX_VALUES 100
// Read integers from stdin until EOF or MAX_VALUES reached.
// Returns the number of values read.
int read_values(int values[], int max);
// Print an array of integers, one per line.
void print_values(const int values[], int count);
#endif
io.c
#include <stdio.h>
#include "io.h"
int read_values(int values[], int max) {
int count = 0;
while (count < max && scanf("%d", &values[count]) == 1) {
count++;
}
return count;
}
void print_values(const int values[], int count) {
for (int i = 0; i < count; i++) {
printf(" [%d] %d\n", i, values[i]);
}
}
stats.h
#ifndef STATS_H
#define STATS_H
// Return the sum of count integers.
int stats_sum(const int values[], int count);
// Return the minimum value.
int stats_min(const int values[], int count);
// Return the maximum value.
int stats_max(const int values[], int count);
// Return the arithmetic mean (integer division).
int stats_mean(const int values[], int count);
#endif
stats.c
#include "stats.h"
#include <limits.h>
int stats_sum(const int values[], int count) {
int s = 0;
for (int i = 0; i < count; i++) {
s += values[i];
}
return s;
}
int stats_min(const int values[], int count) {
int m = INT_MAX;
for (int i = 0; i < count; i++) {
if (values[i] < m) m = values[i];
}
return m;
}
int stats_max(const int values[], int count) {
int m = INT_MIN;
for (int i = 0; i < count; i++) {
if (values[i] > m) m = values[i];
}
return m;
}
int stats_mean(const int values[], int count) {
if (count == 0) return 0;
return stats_sum(values, count) / count;
}
main.c
#include <stdio.h>
#include "io.h"
#include "stats.h"
int main(void) {
int values[MAX_VALUES];
printf("Enter integers (Ctrl+D to finish):\n");
int count = read_values(values, MAX_VALUES);
if (count == 0) {
printf("No values entered.\n");
return 1;
}
printf("\n%d values read:\n", count);
print_values(values, count);
printf("\nStatistics:\n");
printf(" Sum = %d\n", stats_sum(values, count));
printf(" Min = %d\n", stats_min(values, count));
printf(" Max = %d\n", stats_max(values, count));
printf(" Mean = %d\n", stats_mean(values, count));
return 0;
}
Create all these files, then move on to Exercise 1.
Exercise 1 — Manual Compilation
Difficulty: EasyThe goal is to compile the project by hand to understand every step before automating with make.
Step 1: Compile each source file to an object file
gcc -Wall -Wextra -c main.c -o main.o
gcc -Wall -Wextra -c stats.c -o stats.o
gcc -Wall -Wextra -c io.c -o io.o
What does -c do?
The -c flag tells gcc to compile and assemble, but not link. It produces an object file (.o) from each source file.
Step 2: Link the object files into an executable
gcc main.o stats.o io.o -o datastats
Step 3: Test
echo "10 20 30 40 50" | ./datastats
Questions
- How many
.ofiles were created? What does each one contain? - Modify only
stats.c(e.g. add a comment). Which commands do you need to re-run to get an updated executable? Which ones can you skip? - What happens if you modify
stats.h? Which files need recompilation?
Key takeaway
When a single file changes, you only need to recompile that file and re-link. This is exactly the problem make solves: it tracks dependencies and only rebuilds what is necessary.
Exercise 2 — Basic Makefile
Difficulty: RxCreate a Makefile that automates the manual steps from Exercise 1.
Step 1: Variables
Define the following variables at the top of your Makefile:
CC = gcc
CFLAGS = -Wall -Wextra -g
TARGET = datastats
Step 2: Rules with explicit dependencies
Write rules for each target. Remember: recipes must be indented with a tab character (not spaces).
# Final executable depends on all object files
$(TARGET): main.o stats.o io.o
$(CC) $^ -o $@
Now write similar rules for each .o file with its dependencies:
main.odepends onmain.c,io.h,stats.hstats.odepends onstats.c,stats.hio.odepends onio.c,io.h
Step 3: Add a clean target
clean:
rm -f *.o $(TARGET)
.PHONY: clean
.PHONY
Marking clean as .PHONY tells make that clean is not a real file — it should always run the recipe when requested, even if a file named clean happens to exist.
Verification
make clean
make
echo "5 15 25" | ./datastats
Modify stats.c, run make again, and verify that only stats.o and the final executable are rebuilt (not io.o or main.o).
Exercise 3 — Automatic Variables & Pattern Rules
Difficulty: RxYour Makefile from Exercise 2 has a lot of repetition. Let’s refactor it.
Step 1: List object files with a variable
SRCS = main.c stats.c io.c
OBJS = $(SRCS:.c=.o)
The substitution reference $(SRCS:.c=.o) replaces .c with .o for every word in SRCS.
Step 2: Replace individual .o rules with a pattern rule
Instead of writing one rule per .o file, use a pattern rule:
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
| Variable | Meaning |
|---|---|
$@ | The target (e.g. stats.o) |
$< | The first prerequisite (e.g. stats.c) |
$^ | All prerequisites |
Step 3: Update the linking rule
$(TARGET): $(OBJS)
$(CC) $(OBJS) -o $@
Verification
Your complete Makefile should now be around 15 lines (excluding comments). Run:
make clean && make
Missing header dependencies
With the pattern rule %.o: %.c, make no longer knows that main.o depends on io.h and stats.h. If you modify a header, the affected .o files will not be rebuilt. Exercise 4 solves this problem.
Exercise 4 — Automatic Dependency Tracking
Difficulty: HardGCC can generate dependency information automatically using -MMD. This creates .d files alongside .o files that list header dependencies.
Step 1: Add -MMD to CFLAGS
CFLAGS = -Wall -Wextra -g -MMD
Step 2: Include the generated dependency files
Add these lines at the end of your Makefile:
DEPS = $(OBJS:.o=.d)
-include $(DEPS)
Why -include and not include?
The -include directive (with a dash) silently ignores missing files. On the first build, no .d files exist yet — using include (without dash) would cause an error.
Step 3: Update clean to remove .d files
clean:
rm -f $(OBJS) $(DEPS) $(TARGET)
Verification
- Run
make clean && make - Inspect one of the
.dfiles:cat main.d— you should seemain.o: main.c io.h stats.h - Add a blank line to
stats.h, then runmake. Bothmain.oandstats.oshould be recompiled (because both includestats.h), but notio.o.
Your Makefile is now fully generic: adding a new .c file only requires updating the SRCS variable.
Bonus — Build Modes and Utility Targets
Difficulty: HardEnhance your Makefile with the following features:
Debug and Release modes
Add two targets that set different compiler flags:
debug: CFLAGS += -O0 -DDEBUG -fsanitize=address
debug: $(TARGET)
release: CFLAGS += -O2 -DNDEBUG
release: $(TARGET)
Target-specific variables
The syntax target: VARIABLE += value sets a variable only when building that specific target and its prerequisites. This is a Make feature called target-specific variable values.
run target
run: $(TARGET)
@echo "3 7 1 9 4" | ./$(TARGET)
valgrind target
valgrind: debug
@echo "3 7 1 9 4" | valgrind --leak-check=full ./$(TARGET)
Don’t forget to add all new targets to .PHONY.
Summary
| Concept | What you learned |
|---|---|
| Manual compilation | gcc -c to compile, gcc *.o to link |
| Basic Makefile | Targets, prerequisites, recipes |
| Variables | CC, CFLAGS, TARGET, OBJS |
| Automatic variables | $@, $<, $^ |
| Pattern rules | %.o: %.c — one rule for all .o files |
| Dependency tracking | -MMD + -include $(DEPS) |
.PHONY | Declare non-file targets |
Useful make flags
make -n— dry run: print commands without executing themmake -j4— parallel build with 4 jobsmake -B— unconditionally rebuild all targets