Makefile · Part I · Module 2 · Rules and Dependencies
Dependencies and Automatic Rebuilding
This is the chapter that decides whether you can trust a Makefile. Make rebuilds on one comparison repeated across a graph, and once you can run that comparison in your head you can predict any build. You will also see the two ways it goes wrong in real projects: an input nobody declared, so the output is quietly stale, and a recipe that dies halfway, leaving a half-written file that Make then reports as current.
Foundation24 min readMakefileGNU MakePart IDependenciesRebuilding
Part I · Module 2 · Chapter 2.3 · Dependencies and Automatic Rebuilding
1. What a Dependency Claims
A dependency is a claim you make about two files: this output is produced from that input. You write it after the colon:
output.txt: input.txtRead aloud, that line says three separate things:
input.txtmust be up to date beforeoutput.txtis considered. If something else producesinput.txt, that happens first.- If
input.txtis newer thanoutput.txt, thenoutput.txtis out of date. This is the comparison the whole chapter is about. - Nothing else affects
output.txt. Make will consider no other file, because you named no other file.
Point 3 is the one that costs people real time. A dependency list is not a hint or a note — it is the complete set of things Make will look at. Anything you leave out does not exist as far as rebuilding is concerned.
The word prerequisite is Make's own term for an item in that list, and the two words are used interchangeably here. A prerequisite may be an ordinary file, or another target with a rule of its own; Chapter 2.1 showed that Make resolves both the same way.
2. The Rebuild Decision, Precisely
For a file target, Make asks exactly two questions, in this order, after its prerequisites have been handled:
- Does the target file exist? If not → build.
- Is any prerequisite newer than the target? If yes → build. If no → skip, and report the target up to date.
"Newer" means a later last-modified time, and it is strictly newer: a prerequisite with the same timestamp as its target does not trigger a rebuild.
Everything else in this chapter is that sequence applied to a harder graph, or a case where one of the two questions gives an answer you did not intend.
3. Level 1: One Input, One Output
The smallest complete example. Put two lines in input.txt:
clk
rst_noutput.txt: input.txt
@echo " [build] output.txt"
@cat input.txt > output.txtFour invocations cover all four possible answers:
make output.txt # 1. target missing
make output.txt # 2. nothing newer
# append a line to input.txt, then:
make output.txt # 3. prerequisite newer
rm output.txt
make output.txt # 4. target missing again [build] output.txt
make: 'output.txt' is up to date.
[build] output.txt
[build] output.txtThree builds and one skip. Nothing here is surprising yet, which is the point: with one input and one output, the two questions are easy to answer in your head. The rest of the chapter adds exactly one complication at a time.
4. Direct and Transitive Prerequisites
Real builds are chains. Add a middle stage so there are three levels: two hand-written inputs, a merged intermediate, and a final report.
clk 100
rst_n 1alu 32
regfile 16merged.txt: ports.txt blocks.txt
@echo " [1] merged.txt"
@cat ports.txt blocks.txt > merged.txt
report.txt: merged.txt
@echo " [2] report.txt"
@wc -l < merged.txt | tr -d ' ' > report.txt
@cat merged.txt >> report.txtports.txt and blocks.txt are direct prerequisites of merged.txt. They are transitive prerequisites of report.txt — not named in its rule, but reachable through the chain. Make follows the chain; you do not have to list them twice.
Ask for the end of the chain and then exercise it:
make report.txt # A. nothing exists
make report.txt # B. nothing changed
touch blocks.txt
make report.txt # C. a transitive prerequisite changed
touch merged.txt
make report.txt # D. only the intermediate changed [1] merged.txt
[2] report.txt
make: 'report.txt' is up to date.
[1] merged.txt
[2] report.txt
[2] report.txtHere is the same run as a trace, which is the habit worth building:
| Run | Target checked | Prereq state | Recipe runs | Why |
|---|---|---|---|---|
| A | report.txt | merged.txt missing → recurse | both | nothing exists yet |
| A | merged.txt | both inputs exist, nothing produces them | yes | target missing |
| B | report.txt | merged.txt not newer | neither | both questions say no |
| C | merged.txt | blocks.txt newer | yes | a direct prereq changed |
| C | report.txt | merged.txt rebuilt, now newer | yes | the change propagated one link |
| D | report.txt | merged.txt newer | only [2] | merged.txt's own inputs did not change |
Run C is the important one. Touching blocks.txt rebuilt merged.txt, and rebuilding merged.txt made it newer than report.txt, which rebuilt in turn. The change travelled one link at a time, and each link was an independent two-question comparison — not a single global decision about the whole graph.
Run D shows the reverse: touching the middle file rebuilt only what is downstream of it. Make had no reason to revisit ports.txt or blocks.txt, because nothing claimed they had changed.
5. A Timestamp Is Not a Content Hash
Make compares modification times, not contents. Those two usually agree, because editing a file updates its time. They are not the same question, and both directions of disagreement are worth seeing.
Direction one: a new timestamp with identical content rebuilds anyway. Continuing from §4, touch an input without editing it:
touch ports.txt
make report.txt [1] merged.txt
[2] report.txtBoth recipes ran. The bytes in ports.txt are exactly what they were; only its timestamp moved. Make has no way to know the content is unchanged, and it does not look — so the whole chain rebuilt for nothing. This is why a touch is a legitimate way to force a rebuild, and also why checking out a branch, restoring a backup, or copying a tree can trigger a large pointless rebuild.
Direction two: changed content with an older timestamp does not rebuild. Edit a prerequisite and then deliberately give it an old timestamp:
# edit input.txt so its content really changes, then:
touch -t 202001010000 input.txt
make output.txtmake: 'output.txt' is up to date.The content changed and Make declined to rebuild. The output now disagrees with its input, with a successful exit status. This is not a bug in Make — it answered the question it asks — but it is the reason "I changed the file and nothing happened" is a real support question.
6. Under-declaration: The Stale Result
Now the failure that costs real engineering time, in the shape it actually takes.
Verilog and SystemVerilog sources use textual includes. Put a defines file and a module that includes it under rtl/:
`define DATA_W 32`include "defines.vh"
module alu;
endmoduleNow a rule that builds an artifact from them. The recipe reads both files; the rule declares only one:
build/alu.list: rtl/alu.v
@echo " [build] reading alu.v AND the file it includes"
@mkdir -p build
@cat rtl/defines.vh rtl/alu.v > build/alu.listBuild it once, then edit only rtl/defines.vh — add a second define — and build again:
make build/alu.list
# append `define ADDR_W 16 to rtl/defines.vh, then:
make build/alu.list [build] reading alu.v AND the file it includes
make: 'build/alu.list' is up to date.grep -c ADDR_W build/alu.list0The artifact is stale and Make reported success. The reasoning is the same as always: build/alu.list exists, its only declared prerequisite rtl/alu.v was not touched, so nothing is newer and there is nothing to do. The file the recipe actually read is not in the comparison, because it is not in the rule.
The fix is to declare what the recipe really consumes:
build/alu.list: rtl/alu.v rtl/defines.vh
@echo " [build] reading alu.v AND the file it includes"
@mkdir -p build
@cat rtl/defines.vh rtl/alu.v > build/alu.listmake build/alu.list
grep -c ADDR_W build/alu.list [build] reading alu.v AND the file it includes
1And a further edit to the defines file now propagates, as verified. One word after the colon turned a silently wrong build into a correct one.
7. Make's Knowledge Is Not the Compiler's Knowledge
§6 is worth one more paragraph, because the reason it happens is a boundary people keep crossing by accident.
When a simulator compiles alu.v, it follows the `include "defines.vh" directive. It opens that file, reads it, and would fail if it were missing. The compiler therefore knows the dependency exists.
Make does not, and cannot. It never opened alu.v — Chapter 2.1 established that Make does not read source files or even recipes. Its entire model of your project is the graph you typed after the colons.
So there are two different sets of knowledge about one project:
knows about `include | knows about rebuild order | |
|---|---|---|
| the simulator / compiler | yes — it parses the source | no — it compiles what it is given |
| Make | no — it never reads the source | yes — but only from what you declared |
Neither tool has the whole picture, and the gap between them is where stale artifacts live. Real build systems close it by having the compiler emit its discovered dependencies in a form Make can read back, which is a genuine solution and a Chapter 5.2 subject. Until then, the honest rule is: if the recipe reads it, the rule must name it.
8. Over-declaration: Rebuilding Too Much
The opposite mistake is cheaper but not free. Declaring a prerequisite the recipe does not actually use makes Make rebuild for no reason.
output.txt: input.txt README.md
@echo " [build] output.txt"
@cat input.txt > output.txtmake output.txt
# edit README.md, then:
make output.txt [build] output.txt
[build] output.txtEditing documentation rebuilt the artifact. In this example that costs nothing. Attach it to a twenty-minute compile and a team of engineers, and an over-declared dependency is how a build system acquires the reputation of being slow and arbitrary — which is how people start working around it.
A prerequisite list should be exactly the files whose contents affect the output. Not the directory everything lives in, not the Makefile itself by habit, not a convenient wildcard that happens to match extra files. Under-declaring gives wrong answers; over-declaring gives slow ones; the list is a technical claim and deserves the same care as the recipe.
9. When a Recipe Fails Halfway
This is the most dangerous behaviour in the chapter, and it follows inevitably from everything above.
A recipe is a sequence of commands. If one fails, Make stops — but the commands before it already ran, and anything they wrote is still on disk. Consider a recipe that writes its target in stages and fails in the middle:
output.txt: input.txt
@echo " [build] starting"
@head -1 input.txt > output.txt
@echo " [build] now failing"
@false
@cat input.txt > output.txtWith a three-line input.txt:
make output.txt
echo "exit status: $?"
cat output.txt [build] starting
[build] now failing
make: *** [output.txt] Error 1
exit status: 2
aSo far so good: the build failed loudly and make exited 2. But look at what is on disk — output.txt exists and contains one line instead of three. It is a partial, corrupt artifact, and it was written after input.txt, so it is newer than its prerequisite.
Now run make again:
make output.txt
echo "exit status: $?"
cat output.txtmake: 'output.txt' is up to date.
exit status: 0
aMake reports the corrupt file as up to date and exits 0. The failure has been erased from Make's view: the target exists, nothing is newer, both questions say no. A second engineer — or a CI job that re-runs make — now sees a clean, successful build sitting on top of a truncated artifact.
This was verified twice, including with a deliberate one-second gap between writing the input and the failed build, so it is not the granularity effect from §5. It is the ordinary rule doing exactly what it says.
GNU Make has a declaration for this. .DELETE_ON_ERROR tells Make to remove a target whose recipe failed:
.DELETE_ON_ERROR:
output.txt: input.txt
@head -1 input.txt > output.txt
@false
@cat input.txt > output.txtmake: *** [output.txt] Error 1
make: *** Deleting file 'output.txt'lsMakefile input.txtThe next make now correctly tries again and fails again, honestly, instead of declaring victory over a broken file. For a flow whose artifacts matter — a compiled simulation image, a netlist, a coverage database — this one line is close to mandatory. When and how to use it as part of a deliberate error-handling strategy, along with the harder case of a tool that fails while reporting success, is Chapter 4.3.
10. Level 3: An RTL Chain End to End
Putting the chapter together. Three sources, a defines file they all depend on, a compiled artifact, and a simulation log — with every dependency declared.
This version is portable and was executed, standing in for a simulator:
.DELETE_ON_ERROR:
build/sim.img: rtl/defines.vh rtl/alu.v rtl/tb_top.v
@echo " [compile] build/sim.img"
@mkdir -p build
@cat rtl/defines.vh rtl/alu.v rtl/tb_top.v > build/sim.img
build/run.log: build/sim.img
@echo " [run] build/run.log"
@wc -l < build/sim.img | tr -d ' ' > build/run.logThree behaviours to predict before running, then check:
make build/run.logfrom clean → both recipes run.- Again with nothing touched → neither runs.
- Edit
rtl/defines.vh→ both run, because the defines file is a declared prerequisite of the compile and the compile feeds the log.
That third case is the one §6 got wrong before the fix. The declaration is what makes an edit to a shared defines file reach the simulation output.
The simulator version is illustrative — vcs is not installed here and was not run:
.DELETE_ON_ERROR:
build/simv: rtl/defines.vh rtl/alu.v rtl/tb_top.v
mkdir -p build
vcs -full64 -sverilog rtl/alu.v rtl/tb_top.v -o build/simv
build/run.log: build/simv
./build/simv -l build/run.logTwo honest limitations of this illustration, so it is not mistaken for a finished flow:
The source list is written out by hand. A real block has hundreds of files and uses a filelist. Naming the filelist as the prerequisite does not make Make notice an edit to a source listed inside it — Chapter 1.2 demonstrated that trap, and generating real dependency information is Chapter 5.2.
defines.vh is declared but not passed on the command line. The simulator finds it through the `include in alu.v. So the prerequisite exists purely to make rebuilding correct, which is exactly its job — a dependency edge is a statement about when to rebuild, not a list of command-line arguments. Those two lists overlap but are not the same, and confusing them is how defines.vh gets dropped from the rule in the first place.
11. Common Mistakes
Leaving an input out of the rule. The output goes stale silently with a zero exit status (§6). If the recipe reads it, name it.
Assuming Make compares contents. It compares times (§5). A touch rebuilds; a restored older file does not.
Trusting a make that said "nothing to be done". It means nothing was newer, not that the artifact is correct. After any failure, after a branch switch, or whenever the answer matters, rebuild from clean.
Declaring the whole directory or the Makefile as a prerequisite. Over-declaration makes every unrelated edit trigger a full rebuild (§8).
Believing a failed build leaves nothing behind. It can leave a newer, partial target that the next run accepts (§9). Use .DELETE_ON_ERROR.
Expecting Make to follow an `include. It never reads your sources (§7). Only the graph you typed exists.
Adding a prerequisite to control ordering. A dependency means "rebuild when this changes", and using it for sequencing has side effects — Chapter 2.2 showed a phony prerequisite destroying skipping, and Chapter 2.4 covers ordering properly.
12. Check Your Understanding
Question 1. report.txt depends on merged.txt, which depends on ports.txt and blocks.txt. You edit ports.txt. Which recipes run, in what order, and why does report.txt rebuild even though its rule never mentions ports.txt?
Question 2. A colleague restores an old copy of input.txt from a backup, preserving its original timestamp. make output.txt reports up to date. Is Make wrong? What is the shortest way to get the correct output?
Question 3. A compile rule names a filelist as its only prerequisite. An engineer edits an RTL file listed inside that filelist and re-runs make. What happens, and what single sentence explains it?
Question 4. A compile fails after writing half of build/sim.img. The engineer re-runs make run and the simulation starts normally. Explain the two separate facts that combine to produce this, and the one-line declaration that prevents it.
13. Exercise
Set up this project exactly:
dep-exercise/
├── rtl/
│ ├── defines.vh
│ ├── alu.v
│ └── regfile.v
└── Makefilertl/defines.vh contains one define. rtl/alu.v and rtl/regfile.v each contain an `include "defines.vh" line and a small module.
build/design.list: rtl/alu.v rtl/regfile.v
@echo " [1] design.list"
@mkdir -p build
@cat rtl/defines.vh rtl/alu.v rtl/regfile.v > build/design.list
build/count.txt: build/design.list
@echo " [2] count.txt"
@wc -l < build/design.list | tr -d ' ' > build/count.txtAnswer these before touching a terminal, then verify each one:
- From a clean tree, which recipes run for
make build/count.txt? - You then edit
rtl/regfile.v. Which recipes run? - You then edit
rtl/defines.vhonly. Which recipes run, and isbuild/count.txtcorrect afterwards? - Write the corrected Makefile.
14. Remember
- A dependency list is complete, not advisory. Make considers exactly the files you named and nothing else.
- Two questions decide every rebuild: does the target exist, and is any prerequisite strictly newer. Equal timestamps do not rebuild.
- Prerequisites are handled before the comparison, recursively, so asking for the end of a chain builds the whole chain bottom-up.
- A change travels one link at a time. Each link is its own independent comparison, not one global decision.
- Timestamps are not contents. A
touchrebuilds identical files; a restored older file with new content does not rebuild at all. - Under-declaring gives wrong answers. An input the recipe reads but the rule omits leaves the output stale with exit status 0 — the
`includecase is the common real one. - Over-declaring gives slow ones. An unused prerequisite rebuilds the artifact for unrelated edits.
- Make's model of your project is only what you typed. It never reads a source file or a recipe, so it cannot discover an include the compiler follows.
- A failed recipe can leave a newer, partial target that the next
makecalls up to date and exits 0 on..DELETE_ON_ERRORremoves it. - "Nothing to be done" is not a correctness guarantee. When the answer matters, rebuild from clean.
You can now predict what a Makefile will rebuild and explain why. What is still unanswered is which target Make picks when you type make with no arguments, and exactly what order it is allowed to do the work in — including what stops being true the moment a build runs in parallel. Chapter 2.4 closes the module with both.
Where this fits
Part of the Makefile curriculum.
