Skip to content
VLSI Mentor

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.

Five rows in sequence. Row one, sources and filelist: maintained by a person, and the origin of every change. Row two, analyse: redone when a source or the filelist changes. Row three, elaborate: redone when analysis produced something newer than the image. Row four, run: redone per test and per seed, reusing the image. Row five, run log: one log per test and seed combination, stored in its own directory.Stages, and what forces each one to redo01SOURCES + FILELISTmaintained by a person02ANALYSEredone when a source changes03ELABORATEredone when analysis is newer04RUNredone per test and seed05RUN LOGone per test and seed
Figure 1 — the three stages and the two artifacts between them. The caption under each stage names what forces it to be redone, which is the whole subject of this chapter: a source edit reaches every stage, while a change of test or seed reaches only the last one.

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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
iverilog -g2012 -f filelists/all.f -o build/simv   # analyse + elaborate
vvp build/simv +TEST=smoke +SEED=1                 # run

Commercial simulators separate all three. This is reference syntax — these tools are not installed here and nothing below was executed:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# 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         # run

The 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):

Azvya Education Pvt. Ltd.VLSI Mentor
Makefile — the compile stage
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make compile
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
  [compile] -> build/simv
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make compile
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
make: 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Makefile — adding a run stage
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make run
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
  [compile] -> build/simv
  [run] test=smoke seed=1 -> build/run.log

One 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make run TEST=axi_burst SEED=42
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
make: 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cat build/run.log
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
TB: 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.log present? 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Makefile — a directory per run
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
find build -name run.log | sort
for f in build/*/run.log; do printf '%-28s %s\n' "$f" "$(head -1 $f)"; done
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
build/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=1

Each 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Actual output — after a source edit forced a recompile
$ make run TEST=axi_burst SEED=9
  [run] test=axi_burst seed=9 -> build3/axi_burst_9/run.log

That 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
iverilog -g2012 -t null -f filelists/all.f
echo "rc=$?  files created: $(ls a.out out.x 2>/dev/null | wc -l | tr -d ' ')"
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
rc=0  files created: 0

A 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Makefile — three stages
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
$ 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.log

Each 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
  [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 5

Two 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Makefile — an explicit rerun
.PHONY: rerun
rerun:
	@rm -rf $(RUNDIR)
	@$(MAKE) --no-print-directory run TEST=$(TEST) SEED=$(SEED)
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
$ make rerun TEST=axi_burst SEED=42
  [run] test=axi_burst seed=42 -> build/axi_burst_42/run.log

Exactly 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
$ make -B run TEST=axi_burst SEED=42
  [compile] -> build/simv
  [run] test=axi_burst seed=42 -> build/axi_burst_42/run.log

Note 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make compile
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
  [compile] -> build/simv

Step 2. Run the default test. Confirm there is no [compile] line — the image from step 1 is reused:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make run
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
  [run] test=smoke seed=1 -> build/smoke_1/run.log

Step 3. Run a different test. Again, no recompile:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make run TEST=axi_burst
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
  [run] test=axi_burst seed=1 -> build/axi_burst_1/run.log

Step 4. Ask for the first test again. It is already done:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make run TEST=smoke
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
make: Nothing to be done for `run'.

Step 5. Check that both results survived, which the §3 version could not manage:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
find build -name run.log | sort
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
build/axi_burst_1/run.log
build/smoke_1/run.log

Step 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
make: 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make clean
ls -1
Azvya Education Pvt. Ltd.VLSI Mentor
Actual output
Makefile
filelists
rtl
tb

Then 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 iverilog command; 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; compile is what a person types.
  • A variable is not a file. make run TEST=axi_burst tells 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 PASSED on 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 touch it 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 bare make, 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.