Makefile · Part II · Module 5 · RTL Simulation Automation
Compile, Elaborate and Run Targets
Chapter 5.2 made the compile honest. Now the other half: a flow has to let an engineer run a different test, or the same test with a different seed, without rebuilding the design. The obvious way to write that has a failure mode worth seeing in full — it does not error, it prints nothing, and it leaves a log on disk that looks like the answer you asked for.
Intermediate26 min readMakefileGNU MakePart IISimulationTargets
A simulation is not one action. Even with a tiny design, three different things happen, they cost wildly different amounts of time, and they fail for different reasons. Treating them as one step is how a flow ends up recompiling a design for twenty minutes because somebody wanted to run the same test with a different seed.
This chapter builds the split properly. It starts from the version most people write first, shows exactly how it fails, and then fixes it with a design that is small, standard and used in real verification flows.
1. Three Stages, and What Each One Costs
The three stages are worth naming carefully, because the vocabulary differs between tools and the confusion is common.
Analysis (sometimes called compilation) reads your source files and checks them. Syntax, port connections, types. It produces no runnable thing — it either succeeds or it tells you where the code is wrong.
Elaboration takes the analysed design, picks the top module, works out the full hierarchy of instances, resolves parameters, and builds something that can actually execute.
Running executes that thing. This is the only stage that knows which test you want, which seed, and whether to record a waveform.
The costs are not comparable. On a real block, analysis and elaboration together might be fifteen minutes. A single test run might be ninety seconds. If your flow redoes the first two every time you change the test name, you have made a ninety-second job into a sixteen-minute one, and you will do that twenty times a day.
What the tools actually do
Being honest about this matters, because the chapter is written against a tool you can install for free.
Icarus Verilog merges analysis and elaboration. One iverilog command reads the sources and writes a runnable image; there is no separate elaborate step to call. Running is separate, with vvp.
iverilog -g2012 -f filelists/all.f -o build/simv # analyse + elaborate
vvp build/simv +TEST=smoke +SEED=1 # runCommercial simulators separate all three. This is reference syntax — these tools are not installed here and nothing below was executed:
# Synopsys VCS - three commands, three stages
vlogan -sverilog -f filelists/all.f # analyse into a library
vcs -sverilog tb_counter -o simv # elaborate
./simv +TEST=smoke +SEED=1 # run
# Cadence Xcelium - the same three stages
xmvlog -sv -f filelists/all.f # analyse
xmelab tb_counter # elaborate
xmsim tb_counter +TEST=smoke +SEED=1 # runThe Makefile shape is identical in all three cases. What changes is how many commands sit inside each recipe — and that is exactly the kind of difference a Makefile is for.
2. The Compile Target
Start with what 5.2 left us, trimmed to the essentials. The image depends on the filelist (and, with 5.2's depfile, on every source it reads):
BUILD := build
FILELIST := filelists/all.f
SIMV := $(BUILD)/simv
.PHONY: compile
compile: $(SIMV)
$(SIMV): $(FILELIST)
@mkdir -p $(@D)
@echo " [compile] -> $@"
@iverilog -g2012 -f $(FILELIST) -o $@Two targets for one job, and both are needed. $(SIMV) is the file target — it names the thing on disk, so Make can compare timestamps and skip the work. compile is the phony target — a name a human can type. That pairing is Chapter 5.1 §7's point, and Chapter 2.2 explains why the phony declaration is not optional.
make compile [compile] -> build/simvmake compilemake: Nothing to be done for `compile'.That second run is the entire value of Make, and it is working correctly.
3. The Run Target
Now add the run. The output of a run is a log file, so the natural thing — and the right instinct — is to make the log a file target that depends on the image:
RUNLOG := $(BUILD)/run.log
TEST ?= smoke
SEED ?= 1
.PHONY: compile run
compile: $(SIMV)
run: $(RUNLOG)
$(RUNLOG): $(SIMV)
@echo " [run] test=$(TEST) seed=$(SEED) -> $@"
@vvp $(SIMV) +TEST=$(TEST) +SEED=$(SEED) > $@TEST and SEED use ?= so a command-line value wins, which is Chapter 3.2's override mechanism. They are passed to the simulation as plusargs, not defines — Chapter 5.2 §2 is where that distinction was made, and this is the payoff: a plusarg is read at run time, so changing one should not need a recompile.
On a clean tree it does exactly what you want. Make walks run → build/run.log → build/simv, finds no image, builds it, then runs:
make run [compile] -> build/simv
[run] test=smoke seed=1 -> build/run.logOne command, both stages, in the right order, and nobody had to remember the order. This looks finished.
4. The Knob That Does Nothing
Now use the thing we just built for its actual purpose — running a different test:
make run TEST=axi_burst SEED=42make: Nothing to be done for `run'.Nothing ran. No error, no warning, no clue. And it gets worse, because the log file is still sitting there looking authoritative:
cat build/run.logTB: test=smoke seed=1
TB: count=10
TB: PASSED
tb/tb_counter.v:23: $finish called at 112000 (1ps)Read that in sequence. An engineer asked for axi_burst with seed 42. Make said there was nothing to do. A log exists, it says PASSED, and it is the result of a completely different test. If anything downstream reads that log — a script grepping for PASSED, a person glancing at it, a regression report — it will report that axi_burst passed, and axi_burst never ran.
5. Why Make Is Right to Do Nothing
Make is not malfunctioning. It is doing precisely what it promises, and understanding why is the key to the fix.
Make's rule is the same one it has followed since Chapter 1.2: rebuild a target when a prerequisite file is newer than the target file. So it checked:
- Is
build/run.logpresent? Yes. - Is its one prerequisite,
build/simv, newer than it? No — the log was written after the image.
Therefore nothing to do. Correct, by its own rules.
The problem is that TEST=axi_burst was never part of that reasoning. A variable is not a file. Make compares timestamps on files; it has no notion of "the value of TEST differs from the value used last time," because it does not record what the value was last time. Chapter 5.2 §8 hit the identical wall with DEFINES and solved it by writing the options into a file, putting them inside the dependency graph where Make could see them.
The same idea works here, but with a better shape available. With compile options, one options file was the right answer because there is one image. With a test name, something more useful is possible: different tests are different results, so they should be different files.
6. A Directory Per Run
Put the test name and the seed into the path of the log:
BUILD := build
FILELIST := filelists/all.f
SIMV := $(BUILD)/simv
TEST ?= smoke
SEED ?= 1
RUNDIR := $(BUILD)/$(TEST)_$(SEED)
RUNLOG := $(RUNDIR)/run.log
.PHONY: all compile run clean
all: run
compile: $(SIMV)
run: $(RUNLOG)
$(SIMV): $(FILELIST)
@mkdir -p $(@D)
@echo " [compile] -> $@"
@iverilog -g2012 -f $(FILELIST) -o $@
$(RUNLOG): $(SIMV)
@mkdir -p $(@D)
@echo " [run] test=$(TEST) seed=$(SEED) -> $@"
@vvp $(SIMV) +TEST=$(TEST) +SEED=$(SEED) > $@
clean:
@rm -rf $(BUILD)The change is two lines, and it changes the behaviour completely. Because RUNDIR is expanded with the current TEST and SEED, the target that Make is asked about is a different path for every combination. A path that does not exist cannot be up to date.
Four invocations, in order, on a clean tree:
$ make run
[compile] -> build/simv
[run] test=smoke seed=1 -> build/smoke_1/run.log
$ make run TEST=axi_burst SEED=42
[run] test=axi_burst seed=42 -> build/axi_burst_42/run.log
$ make run TEST=axi_burst SEED=7
[run] test=axi_burst seed=7 -> build/axi_burst_7/run.log
$ make run TEST=axi_burst SEED=42
make: Nothing to be done for `run'.Three things in that transcript are worth stopping on.
The second invocation has no [compile] line. A different test ran, and the image was reused. This is the behaviour the whole chapter exists to produce: the expensive stage was skipped because its inputs did not change, and the cheap stage ran because its output did not exist.
The third invocation shows the seed is a real part of the identity. Same test, different seed, different directory, and it ran. In constrained-random verification that is essential — the same test at two seeds is two different stimulus streams and genuinely two different results.
The fourth invocation does nothing, and that is now correct. Test axi_burst at seed 42 has already been run against this image. The simulation is deterministic, so running it again would produce a byte-identical log. Skipping it is not a bug; it is the same caching that made make compile free. §8 covers how to force a repeat when you want one.
And the logs coexist:
find build -name run.log | sort
for f in build/*/run.log; do printf '%-28s %s\n' "$f" "$(head -1 $f)"; donebuild/axi_burst_42/run.log
build/axi_burst_7/run.log
build/smoke_1/run.log
build/axi_burst_42/run.log TB: test=axi_burst seed=42
build/axi_burst_7/run.log TB: test=axi_burst seed=7
build/smoke_1/run.log TB: test=smoke seed=1Each log holds the result of the run whose name is in its path. That is the property §4's version violated.
The property that makes this correct rather than merely tidy
There is a question the transcript above does not answer: what happens to an old run directory after the design is recompiled? Its log was produced by the previous image, so it is stale.
Make handles it, because every run log names $(SIMV) as a prerequisite:
$ make run TEST=axi_burst SEED=9
[run] test=axi_burst seed=9 -> build3/axi_burst_9/run.logThat run had already completed before the recompile. Asking for it again re-ran it, because the image is now newer than the log. So a per-run directory is not a cache that can go quietly wrong — it is a correct one. Each log is valid against the image it was produced from, and Make knows when that stops being true.
7. Splitting Analyse from Elaborate
Icarus gives you one command for analysis and elaboration, but the two-stage shape is still worth building, because it is what commercial tools need and because it has a real benefit on its own: a fast syntax check that produces no image.
iverilog -t null runs the front end and selects the null code generator. Measured, it exits 0 and writes no output file at all:
iverilog -g2012 -t null -f filelists/all.f
echo "rc=$? files created: $(ls a.out out.x 2>/dev/null | wc -l | tr -d ' ')"rc=0 files created: 0A stage that produces no file is awkward for Make, which compares files. The standard answer is a stamp file: an empty file whose only job is to carry a timestamp recording that the stage succeeded.
BUILD := build3
FILELIST := filelists/all.f
ANALYZED := $(BUILD)/analyze.stamp
SIMV := $(BUILD)/simv
TEST ?= smoke
SEED ?= 1
RUNDIR := $(BUILD)/$(TEST)_$(SEED)
.PHONY: all analyze elaborate run
all: run
analyze: $(ANALYZED)
elaborate: $(SIMV)
run: $(RUNDIR)/run.log
# Stage 1 - check the sources, record success in a stamp file
$(ANALYZED): $(FILELIST)
@mkdir -p $(@D)
@echo " [analyze] checking sources"
@iverilog -g2012 -t null -f $(FILELIST)
@touch $@
# Stage 2 - build the image, only reached if analysis succeeded
$(SIMV): $(ANALYZED)
@echo " [elaborate] -> $@"
@iverilog -g2012 -f $(FILELIST) -o $@
# Stage 3 - run, once per test and seed
$(RUNDIR)/run.log: $(SIMV)
@mkdir -p $(@D)
@echo " [run] test=$(TEST) seed=$(SEED) -> $@"
@vvp $(SIMV) +TEST=$(TEST) +SEED=$(SEED) > $@The touch $@ is the whole trick. It runs only if the iverilog line before it succeeded, because Chapter 4.3 established that Make abandons a recipe at the first command that fails. So the stamp exists if and only if analysis passed.
$ make run
[analyze] checking sources
[elaborate] -> build3/simv
[run] test=smoke seed=1 -> build3/smoke_1/run.log
$ make run
make: Nothing to be done for `run'.
$ make run TEST=axi_burst SEED=9
[run] test=axi_burst seed=9 -> build3/axi_burst_9/run.log
$ touch filelists/all.f
$ make run
[analyze] checking sources
[elaborate] -> build3/simv
[run] test=smoke seed=1 -> build3/smoke_1/run.logEach stage redoes exactly when its own inputs changed. Note the third invocation again: a new test, and neither of the first two stages ran.
When analysis fails
Break a source file deliberately — an unterminated module declaration in rtl/counter.v — and ask for a run:
[analyze] checking sources
tb/tb_counter.v:1: error: timescale directive cannot be inside a module definition.
tb/tb_counter.v:2: syntax error
rtl/counter.v:1: Errors in port declarations.
tb/tb_counter.v:8: syntax error
I give up.
make: *** [build3/analyze.stamp] Error 5Two things to take from that.
The flow stopped at stage one. No elaboration was attempted, no run was attempted, and make exited non-zero. The previously built build3/simv is still on disk, untouched — the failing stage did not damage the last known-good image, because touch $@ never ran and so the stamp was never created. The next make will retry analysis rather than wrongly believing it passed.
The error locations are misleading, and this is not Make's fault. The bug is in rtl/counter.v, but three of the four messages point at tb/tb_counter.v. A filelist is compiled as one compilation unit: the files are effectively concatenated in the order listed, so an unterminated module in one file swallows the start of the next one, and the parser reports where it finally gave up rather than where the mistake is. When a filelist-driven compile produces errors in a file you did not touch, suspect the file listed before it.
8. Forcing a Repeat
§6's fourth invocation refused to re-run a completed test. Usually that is what you want. Sometimes it is not — the testbench prints a timestamp, you are chasing something intermittent, or you simply want to watch it go again.
The clean way is a target that removes the run directory and then asks for it back:
.PHONY: rerun
rerun:
@rm -rf $(RUNDIR)
@$(MAKE) --no-print-directory run TEST=$(TEST) SEED=$(SEED)$ make rerun TEST=axi_burst SEED=42
[run] test=axi_burst seed=42 -> build/axi_burst_42/run.logExactly one stage redone. $(MAKE) rather than a literal make is the correct way to invoke Make from inside a recipe — it carries the right executable and flags down into the sub-invocation.
The blunt alternative is make -B, which treats every target as out of date:
$ make -B run TEST=axi_burst SEED=42
[compile] -> build/simv
[run] test=axi_burst seed=42 -> build/axi_burst_42/run.logNote the [compile] line. -B forced the recompile too, which on this design costs a moment and on a real block costs a coffee break. -B is a debugging tool for when you suspect the dependency graph is lying to you; it is not how you re-run a test.
9. Common Mistakes
A single build/run.log for every test. The §4 bug. Either nothing runs, or each run destroys the last one's result. Put the test and seed in the path (§6).
Expecting a changed variable to trigger work. TEST=axi_burst is invisible to Make unless it reaches a filename or a file's contents (§5).
Making the run phony to force it. It re-runs, but it re-runs always, and it still overwrites one shared log (§8).
Reaching for make -B. It forces the compile back, which is the expensive stage you were trying to protect (§8).
Putting the test name in a +define+. A define is compile-time, so every test change becomes a recompile — Chapter 5.2 §2. Run-time knobs belong in plusargs.
A stage with no file to show for it and no stamp. A recipe whose work leaves no artifact runs every single time. Record success in a stamp file (§7).
touching the stamp before the tool runs. Then a failed analysis still marks itself successful. The touch goes last, so the failure stops the recipe first (§7).
Trusting the file named in a filelist compile error. The files are one compilation unit; look at the file listed before the one in the message (§7).
Using a bare make inside a recipe. Use $(MAKE), so flags and the right executable propagate (§8).
10. Check Your Understanding
Question 1. A Makefile has $(BUILD)/run.log: $(SIMV) and run: $(BUILD)/run.log. An engineer runs make run TEST=smoke, then make run TEST=axi_burst. State what the second command prints and what build/run.log contains afterwards.
Question 2. With the per-run-directory version, make run TEST=smoke SEED=1 is run twice in a row. The second one prints Nothing to be done. Explain in one sentence why that is correct rather than a bug, and name one situation where you would want to override it.
Question 3. Why does the analyse stage need a stamp file when the elaborate stage does not?
Question 4. A filelist lists rtl/counter.v then tb/tb_counter.v. The compile reports a syntax error at tb/tb_counter.v:2, but tb_counter.v has not been edited in weeks. Where would you look first, and why?
11. Exercise
Start from the single-log version in §3 and convert it to the per-run-directory design, then confirm each of the following by running it. Every transcript below is real output from the finished Makefile.
Step 1. Build only the image:
make compile [compile] -> build/simvStep 2. Run the default test. Confirm there is no [compile] line — the image from step 1 is reused:
make run [run] test=smoke seed=1 -> build/smoke_1/run.logStep 3. Run a different test. Again, no recompile:
make run TEST=axi_burst [run] test=axi_burst seed=1 -> build/axi_burst_1/run.logStep 4. Ask for the first test again. It is already done:
make run TEST=smokemake: Nothing to be done for `run'.Step 5. Check that both results survived, which the §3 version could not manage:
find build -name run.log | sortbuild/axi_burst_1/run.log
build/smoke_1/run.logStep 6. Run make with no target at all. With all: run as the first rule, the default goal is all, and everything it needs is already present:
makemake: Nothing to be done for `all'.Chapter 2.4 is where the default goal was established; make -p | grep DEFAULT_GOAL confirms it as .DEFAULT_GOAL := all.
Step 7. Clean up, and check that only hand-maintained input remains:
make clean
ls -1Makefile
filelists
rtl
tbThen answer two questions about what you built. First: after a source edit forces a recompile, what happens the next time you ask for a run directory that already exists — and why? Second: RUNDIR uses :=. What would break if a reader changed it to =? (For the second, Chapter 3.1 has the mechanism, and the answer is subtler than it looks — with TEST and SEED both set before RUNDIR is used, either operator produces the same path here, which is exactly why := is the safer habit rather than a required one.)
12. Remember
- A simulation is three stages with wildly different costs — analysis, elaboration and running — and a flow that fuses them turns a ninety-second job into a sixteen-minute one.
- Icarus merges analysis and elaboration into one
iverilogcommand; VCS and Xcelium separate them into three. The Makefile shape is the same either way. - Pair a file target with a phony name.
$(SIMV)lets Make skip work;compileis what a person types. - A variable is not a file.
make run TEST=axi_bursttells Make nothing, because Make records no history of variable values. - A single shared run log is the dangerous bug of this chapter — it either does nothing or silently leaves another test's
PASSEDon disk under the name you asked about. - Put the test and seed in the path.
RUNDIR := $(BUILD)/$(TEST)_$(SEED)makes every combination its own target, so a new test runs and the image is reused. - Measured: changing the test produced no
[compile]line and three run logs coexisted, each holding the result its own path names. - It is a correct cache, not a lucky one. Every run log names the image as a prerequisite, so a recompile makes old run directories genuinely out of date and they re-run when asked for.
- Record a file-less stage in a stamp file, and
touchit last so a failure cannot mark itself successful. - A filelist is one compilation unit, so a syntax error is reported where the parser gave up — suspect the file listed before the one named.
- Force a repeat by deleting the run directory, not with
-B, which forces the expensive compile back as well. - Use
$(MAKE), never a baremake, inside a recipe.
The flow now compiles once and runs many times, and each run has somewhere of its own to put things. That last part is an invitation: a run produces more than a log. Waveforms are expensive enough that they need to be opt-in, logs need to be found and read rather than just written, and clean has to remove everything generated without touching anything a person wrote. Chapter 5.4 finishes the module with the artifact side of the flow.
Where this fits
Part of the Makefile curriculum.
