C / MULTI-FILE PROGRAMS AND BUILDS
make and rebuilding only what changed
Write and reason about a Makefile that recompiles only the translation units an edit really affects, including edits reached through headers.
What you will learn
- Predict which targets make rebuilds from timestamps and declared prerequisites
- Write one %.o: %.c pattern rule with $@, $< and $^ instead of a rule per file
- Generate header dependencies with gcc -MMD -MP and -include the .d files
- Use .PHONY and make -n to keep all, clean and test out of the timestamp game
Understanding make and rebuilding only what changed
A Makefile is a list of rules: a target, the prerequisites it is made from, and a recipe of shell commands whose lines each begin with a tab. When you ask make for a target it calls stat on that file and on every declared prerequisite, and it runs the recipe only if the target is missing or some prerequisite has a newer modification time. Prerequisites are updated first, so a freshly recompiled util.o carries a new timestamp, which by the same rule makes the program that links it out of date; staleness propagates up the graph with no extra bookkeeping. That is also why C builds are split into object files: the .o is the cache entry, so one edited .c costs one compile plus the link rather than a full rebuild.
make has no idea what #include means. Its picture of the world is exactly the prerequisite lists you wrote, so if util.h is absent from main.o's prerequisites, changing a struct in that header leaves main.o untouched and the linker joins objects that disagree about field offsets, with no diagnostic anywhere. Hand-written header lists rot fast, so let the compiler produce them: -MMD makes gcc write main.d containing a line like main.o: main.c util.h, and -include $(OBJS:.o=.d) feeds the previous compile's discoveries back into the graph. Adding -MP emits an empty rule for each header, so deleting a header later does not leave make refusing to build with "No rule to make target".
The currency is timestamps, not contents, because a stat call is cheap and requires no memory of previous builds. The price is that make can be misled: touch main.c forces a recompile of code that did not change, unpacking an archive or copying files with older times can leave a genuinely stale object looking current, and equal timestamps count as up to date, so a filesystem storing whole seconds can miss an edit made in the same second as the compile. Two habits keep the graph honest: declare non-file targets like all and clean as .PHONY so a same-named file can never make them look finished, and reach for make -n to preview commands and make -d when you need make to explain why it considers something out of date.
<stdio.h>
<string.h>
/* A miniature make: a node with a recipe is rebuilt only when it is
missing or older than one of its prerequisites. */
struct node {
const char *name;
long mtime; /* 0 means the file does not exist */
int has_recipe;
const char *prereq[3];
};
static struct node graph[] = {
{ "main.c", 100, 0, { NULL } },
{ "util.c", 100, 0, { NULL } },
{ "util.h", 140, 0, { NULL } }, /* just edited */
{ "main.o", 120, 1, { "main.c", "util.h", NULL } },
{ "util.o", 120, 1, { "util.c", "util.h", NULL } },
{ "app", 130, 1, { "main.o", "util.o", NULL } }
};
static long now = 200;
static struct node *find(const char *name)
{
size_t i;
for (i = 0; i < sizeof graph / sizeof graph[0]; i++)
if (strcmp(graph[i].name, name) == 0)
return &graph[i];
return NULL;
}
static long update(const char *name)
{
struct node *t = find(name);
const char *culprit = "";
long newest = 0;
int i;
if (t == NULL) {
printf("*** No rule to make target '%s'\n", name);
return 0;
}
if (!t->has_recipe)
return t->mtime; /* a source file is never built */
for (i = 0; t->prereq[i] != NULL; i++) {
long m = update(t->prereq[i]);
if (m > newest) {
newest = m;
culprit = t->prereq[i];
}
}
if (t->mtime != 0 && newest <= t->mtime) {
printf("skip %s (up to date)\n", t->name);
return t->mtime;
}
if (t->mtime == 0)
printf("build %s (target missing)\n", t->name);
else
printf("build %s (%s is newer)\n", t->name, culprit);
t->mtime = ++now; /* the rebuilt file is stamped now */
return t->mtime;
}
int main(void)
{
puts("$ make (after editing util.h)");
update("app");
puts("$ make (nothing changed)");
update("app");
return 0;
}
make is a timestamp-driven dependency graph: it reruns a recipe only when the target is missing or older than a prerequisite you declared, and it knows nothing you did not declare.
Worked examples
What make actually compares
Uses stat and utime to reproduce the exact test make applies to a target and one prerequisite.
<stdio.h>
<sys/stat.h>
<time.h>
<utime.h>
static void touch_at(const char *path, time_t when)
{
struct utimbuf tb;
FILE *f = fopen(path, "w");
if (f != NULL)
fclose(f);
tb.actime = when;
tb.modtime = when;
utime(path, &tb);
}
static int out_of_date(const char *target, const char *prereq)
{
struct stat ts, ps;
if (stat(target, &ts) != 0)
return 1; /* target does not exist yet */
if (stat(prereq, &ps) != 0)
return 1;
return ps.st_mtime > ts.st_mtime; /* strictly newer prerequisite */
}
int main(void)
{
touch_at("util.o", 2000);
touch_at("util.h", 1000);
printf("header older than object -> rebuild %d\n",
out_of_date("util.o", "util.h"));
touch_at("util.h", 3000);
printf("header newer than object -> rebuild %d\n",
out_of_date("util.o", "util.h"));
touch_at("util.h", 2000);
printf("timestamps equal -> rebuild %d\n",
out_of_date("util.o", "util.h"));
remove("util.o");
printf("object deleted -> rebuild %d\n",
out_of_date("util.o", "util.h"));
remove("util.h");
return 0;
}
Example explained
Line 1The failing stat on the target is make's first branch: a missing .o means the recipe always runs, which is why a fresh checkout builds everything.
Line 2The whole decision is ps.st_mtime > ts.st_mtime, a comparison of numbers, so make never looks at a single byte of your source.
Line 3Because the test is strictly greater, equal timestamps mean up to date; that is the third line, and the reason a same-second edit can slip through on coarse filesystems.
Line 4utime sets the modification time explicitly, exactly what touch and touch -d do when you force or defeat a rebuild by hand.
One prerequisite, two dependents
Shows that a shared prerequisite such as a generated header has its recipe run once per invocation, always before its dependents.
<stdio.h>
<string.h>
/* version.h is generated, and both objects include it. */
struct rule {
const char *target;
const char *prereq[3];
int updated;
};
static struct rule rules[] = {
{ "version.h", { NULL }, 0 },
{ "a.o", { "version.h", NULL }, 0 },
{ "b.o", { "version.h", NULL }, 0 },
{ "app", { "a.o", "b.o", NULL }, 0 }
};
static struct rule *find(const char *name)
{
size_t i;
for (i = 0; i < sizeof rules / sizeof rules[0]; i++)
if (strcmp(rules[i].target, name) == 0)
return &rules[i];
return NULL;
}
static void update(const char *name)
{
struct rule *r = find(name);
int i;
if (r == NULL)
return;
if (r->updated) {
printf("%s: already considered in this run\n", name);
return;
}
r->updated = 1;
for (i = 0; r->prereq[i] != NULL; i++)
update(r->prereq[i]);
printf("%s: recipe runs\n", name);
}
int main(void)
{
update("app");
return 0;
}
Example explained
Line 1The updated flag is how make guarantees version.h is generated once even though a.o and b.o both name it as a prerequisite.
Line 2The printf after the loop means every prerequisite finishes before its dependent, so a.o can never be compiled before the header it includes exists.
Line 3That ordering is the only contract make -j needs: the a.o and b.o branches are independent and may run concurrently, but neither can start before version.h.
Line 4Without the flag, version.h would be regenerated a second time with a timestamp newer than a.o, which is one way a build starts recompiling on every single run.
Important notes
Every recipe line runs in its own shell, so cd build on one line has no effect on the next; chain them with && or use make -C.
GNU make carries built-in implicit rules, so a target may build with a command you never wrote, using the default CC and CFLAGS; make -p lists those rules and make -r turns them off.
Common mistakes
Indenting a recipe with spaces, or letting an editor expand the tab: make does not recognise the line as a recipe and stops with a "missing separator" error pointing at it.
Listing only .c files as prerequisites: after a struct changes in a header, make says there is nothing to do, the old objects are relinked, and the program reads fields at the wrong offsets until someone runs make clean.
Omitting .PHONY: the day a file or directory named clean or test exists, make declares the target up to date and silently runs none of its commands.
Try it yourself
Change, predict, then run
In the main example set util.h's timestamp to 110 and app's to 90, and predict the output before running: nothing should be recompiled but app must still be relinked. Then add cli.c and cli.o nodes, make cli.o a prerequisite of app (the prereq array needs one more slot), and confirm cli.o is skipped.
Open the C workspaceCheck your understanding
A Makefile builds main.o from main.c but never mentions config.h. You change a struct field in config.h and run make. What happens?
- make rebuilds every target, because a modified file in the directory invalidates the whole graph.
- make sees main.o is newer than main.c, compiles nothing, and relinks the old main.o, so the program keeps the old struct layout.
- make recompiles main.o, because gcc rescans the includes and reports that config.h changed.
- make stops with "No rule to make target config.h", since the header has no rule in the Makefile.
Show answer
make's only evidence is stat on the target and on the prerequisites you declared; config.h is not one of them, so nothing looks out of date and the stale object is linked, typically producing wrong field offsets at runtime. Option 3 is tempting because gcc really can compute include dependencies with -MMD, but that only happens when gcc is executed, and here make never invokes it.