You have typed the same four commands ten times this week: lint the design, compile it, run the simulation, synthesize it. Each one is a long line with flags you look up every time. Last Tuesday you mistyped one flag and spent an hour chasing a failure that was never in the design.
You type the same four commands every time. How do you make them one command that you can trust?
The answer is a build file that names every step, its inputs, and its exact command line. You type make all. The file remembers the flags so you do not have to.
Make is a build tool: it reads a file called Makefile, works out which steps are needed, and runs them in an order that respects their inputs. A target is a named step, such as sim. A prerequisite is something the step needs first, such as a directory. A recipe is the list of shell commands that performs the step.
Each recipe line runs in the shell exactly as written. That is the whole trick: the commands you already type by hand move into the file unchanged, and from then on there is one source of truth for how each step runs. When the flags change, the file changes, and every run after that uses the new flags.
Read the project's file below. It is short, and every line earns its place. The top names the tools and files. The middle names the three checks. The bottom names two helpers.
SHELL := /bin/bash
IVERILOG ?= iverilog
VVP ?= vvp
YOSYS ?= yosys
VERILATOR ?= verilator
RTL := rtl/mini_controller.sv
TB := tb/tb_mini_controller.sv
BUILD := build
.PHONY: help sim lint synth all clean check-tools
.DEFAULT_GOAL := all
help:
@echo "Targets: sim lint synth all check-tools clean"
all: lint sim synth
$(BUILD):
mkdir -p $(BUILD)
sim: | $(BUILD)
$(IVERILOG) -g2012 -Wall -s tb_mini_controller -o $(BUILD)/sim.vvp $(RTL) $(TB)
set -o pipefail; cd $(BUILD) && $(VVP) sim.vvp | tee sim.log
@grep -c "^PASS:" $(BUILD)/sim.log | grep -qx 1
lint:
$(VERILATOR) --lint-only -Wall --top-module mini_controller $(RTL)
synth: | $(BUILD)
$(YOSYS) -Q -T -p 'read_verilog -sv $(RTL); hierarchy -check -top mini_controller; proc; opt; check -assert; stat' > $(BUILD)/synth.log
check-tools:
@bash scripts/check_tools.sh
clean:
rm -rf $(BUILD)This is the complete build file for the running example: three checks, one default target that runs them all (set explicitly with .DEFAULT_GOAL, since the first target in the file is help), and the variables at the top.
The first four lines assign names: the simulator compiler, the simulator runner, the synthesis tool, and the linter. The ?= operator assigns only when the name has no value yet, so a caller can override from the command line without editing the file. The := operator assigns immediately and is used for paths that are fixed, such as the design file, the testbench, and the build directory.
Because every recipe below refers to these names, switching a tool version or renaming a file means editing one line. Compare that with four hand-typed commands, where the same change must be remembered separately each time you type them.
The line all: lint sim synth says the all target needs three prerequisites, and make all runs those three steps. The sim target carries an order-only prerequisite, written | $(BUILD): make creates the build/ directory first, but the existence of that directory never counts as a reason to rerun the simulation. The directory is preparation, not an input.
The .PHONY line lists targets that are actions rather than files: help, sim, lint, synth, all, clean, and check-tools. A phony target always runs when named, even if a file with the same name happens to exist. Without that line, a stray file called clean would convince make that there is nothing to do, and make clean would silently pass while deleting nothing.
The help target answers the question every newcomer asks first: what can I type here? It shows the target names in one line. Write that target in every Makefile you own, and keep it accurate as targets are added.
The picture below draws the whole file as dependencies. Read it from top to bottom: all needs the three checks, and only sim and synth need the build/ directory created first. Lint reads the design file directly and writes nothing, so it points at nothing below it.
The first command asks the file what it offers. The second runs the full loop: lint, then simulation, then synthesis, each showing its own tool line and the verdict line of the simulation.
# captured terminal output
$ make help
Targets: sim lint synth all check-tools cleanThis output lists the available targets, so a newcomer learns the vocabulary of the project in one command.
# captured terminal output
$ make all 2>&1 | grep -E '^(PASS|iverilog|vvp|verilator|yosys)' | cut -c1-110
verilator --lint-only -Wall --top-module mini_controller rtl/mini_controller.sv
iverilog -g2012 -Wall -s tb_mini_controller -o build/sim.vvp rtl/mini_controller.sv tb/tb_mini_controller.sv
PASS: mini_controller
yosys -Q -T -p 'read_verilog -sv rtl/mini_controller.sv; hierarchy -check -top mini_controller; proc; opt; cheThis output runs the full loop on the good design: the linter line, the compile line, the verdict line starting with PASS:, and the synthesis line, measured with GNU Make 3.81 on macOS. The same make all passes on Ubuntu 24.04 with GNU Make 4.3, alongside that distribution's own Icarus 12.0, Verilator 5.020, and Yosys 0.33 tool packages.
make all twice in a row without changing any file. What runs the second time?Everything runs again: lint, simulation, and synthesis. These targets are phony, which tells make they are actions rather than files, so make never considers them finished. That repetition is deliberate: the checks are fast enough to rerun, and rerunning removes any doubt that an old result is being mistaken for a fresh one.
Recipe lines must start with a tab character, not spaces. Copy the file into a scratch directory, replace the leading tab of one recipe line with four spaces, and run make. The tool refuses before running anything:
# captured terminal output
$ make
Makefile:2: *** missing separator. Stop.
[exit status 2]This output shows the classic missing separator error, measured with GNU Make 3.81 on macOS: the recipe line the tool points at starts with spaces. Restore the tab and the file runs again. Set your editor to keep tabs in Makefiles so this failure stays a story rather than a habit.
A target name that matches a real file, without phony cover. A log file called sim or a directory called lint convinces make the step is already done. Keep every action target in the .PHONY line, and be suspicious when a target finishes suspiciously fast.
Typing the tool command by hand with different flags. The moment you compile with flags the Makefile does not use, you have two builds and only one of them is the project's. Always go through make, even when the direct command feels shorter.
Running `make clean` on a file you did not read. The recipe removes a whole directory tree named by a variable. Read the clean recipe first, confirm the path names only the project's own build/ directory, and never broaden that removal to paths outside the project.
Rule: if a step matters, it lives in the Makefile with its exact flags.
wave target that shows the path of the recorded waveform without opening anything.Add two lines at the end of the file. The first names the target and the second starts with a tab character: wave: @echo build/sim.vcd The recipe shows the path of the value change dump (VCD), the file of signal changes the testbench records, so the viewer always opens the file this build wrote. If the second line starts with spaces instead of a tab, make answers with the missing separator error from this lesson.
make sim rerun the simulation even when nothing changed?Because sim is listed as phony, which tells make it is an action rather than a file. Make never treats a phony target as up to date, so the simulation compiles and runs on every request instead of trusting a result from an earlier run.
make clean on a Makefile you did not write, what do you check?You read the clean recipe and confirm the removal path names only the project's own build directory. A variable holding an empty or wrong value would aim the removal elsewhere, so you verify the path before the recipe ever runs.
A Makefile names every step, its inputs, and its exact command line, so make all replaces four remembered commands.
Phony targets always run, variables keep tool names in one place, and order-only prerequisites prepare directories without triggering rebuilds.
Recipe lines start with a tab, and clean deserves a read before it ever runs.