- Makefile 64.3%
- Tcl 14.9%
- Shell 12.1%
- VHDL 3.8%
- Batchfile 2.5%
- Other 2.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
A plain cp -r carried ignored leftovers of earlier runs into the fixture, so fresh_clone.sh failed in a used checkout. Copy tracked and non-ignored files only, with a plain copy outside git. |
||
| example | ||
| make | ||
| scripts | ||
| templates | ||
| test | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| Makefile | ||
| NOTICE | ||
| project.mk | ||
| README.md | ||
| TODO.md | ||
Makefile Project Template
A portable, multi-toolchain firmware/HDL project template built on GNU Make.
A helper script scans the source tree and places a Makefile.mk fragment in
every directory that contains source files. Each fragment uses GNU make's
$(wildcard …) function so the source list updates automatically on every
make invocation — no manual editing required when files are added or
removed within an existing directory.
Supported toolchains
TOOLCHAIN= |
Languages | Tools |
|---|---|---|
gcc |
C | GCC gcc |
gxx |
C, C++ | GCC gcc / g++ |
ghdl |
VHDL | GHDL |
modelsim |
VHDL, Verilog, SV | ModelSim / QuestaSim |
vivado |
VHDL, Verilog, SV + XDC | Xilinx Vivado |
quartus |
VHDL, Verilog, SV | Intel/Altera Quartus Prime |
Non-volatile programming is covered for two cases. On the Xilinx SoC,
boot-image and flash-boot build a BOOT.BIN with bootgen and write it to
QSPI with program_flash — the Zynq-7000 path, where the BootROM parses a boot
image rather than loading a raw bitstream. On the Intel side, cfgmem, flash
and flash-erase build and write a configuration-device image, the Active
Serial path, where the device holds a bitstream and no boot image exists. The
Xilinx non-SoC case (write_cfgmem) stays scoped in TODO.md, along
with what the covered cases taught: the SoC case is not the non-SoC case with
extra steps, and treating it as one is the trap.
Quick start
# 1. Clone (or copy) the template
git clone https://github.com/pcsf/makefile_project_template my_project
cd my_project
# 2. Edit project configuration
$EDITOR project.mk # set PROJECT_NAME, TOOLCHAIN, compiler flags …
# 3. Add your source files anywhere under the project root, e.g.:
mkdir -p src/app src/drivers
cp ~/my_code/*.c src/app/
# 4. Build — the scan runs automatically on the first invocation of any
# target that needs sources (make, make test, make analyze, …)
make
# Subsequent builds pick up new/removed files within existing directories
# automatically. For a brand-new source directory, run:
make scan
Using as a git submodule (recommended)
Instead of copying the template into each project, embed it as a submodule so
a plain git pull inside the submodule brings in template updates without
touching your project. The checkout path is free — a short name like mk/
keeps invocations tidy:
cd my_project
git submodule add https://github.com/Pcsf/makefile_project_template mk
# One-line root Makefile that delegates to the template:
echo 'include mk/Makefile' > Makefile
# Project configuration lives in YOUR repo, next to the root Makefile:
cp mk/project.mk .
$EDITOR project.mk
make
git add .gitmodules mk Makefile project.mk
git commit -m "Add make-based build via makefile_project_template submodule"
The template detects that it is included from a parent directory and resolves
its own make/ and scripts/ paths accordingly; its internal tree
(example/, make/, …) is automatically excluded from source scanning.
project.mk, the generated Makefile.mk fragments, and the .compile_order
files all live in the consuming project — the submodule stays pristine.
Anyone cloning the consuming project needs the submodule populated before the
first build. Nothing else: the Makefile.mk fragments are gitignored, and the
first make <target> that needs sources scans for them on its own.
git clone --recurse-submodules <project-url>
# or, in an existing clone:
git submodule update --init
Updating the template later:
git -C mk pull
git add mk
git commit -m "Update makefile template"
Worked example: a VHDL simulation project
cdc-lib uses this exact setup to run a GHDL testbench. Its layout:
cdc-lib/
├── Makefile ← 'include mk/Makefile' (one line)
├── project.mk ← project configuration (see below)
├── mk/ ← this template, as a submodule
├── src/ ← VHDL sources
│ └── .compile_order ← within-directory compile order (committed)
└── tb/ ← testbench
└── .compile_order
The relevant project.mk settings:
PROJECT_NAME := cdc_lib
TOOLCHAIN := ghdl
# src (package + entities) before tb (instantiates them):
VHDL_SRCS_DIR := \
src \
tb
GHDL_STD := 93
GHDL_TOP := tb_cdc_lib
With that in place, make scans on the first run, analyses everything in the
declared order, elaborates tb_cdc_lib, and runs the simulation with a VCD
written to build/. The consuming repo ignores the generated fragments
(**/Makefile.mk in .gitignore) and commits the .compile_order files.
Directory layout
makefile_project_template/
├── Makefile ← Root makefile (do not edit)
├── project.mk ← YOUR project configuration (edit this)
├── make/
│ ├── common.mk ← Utility targets: scan, clean, help, info
│ ├── gcc.mk ← GNU C toolchain rules
│ ├── gxx.mk ← GNU C++ toolchain rules
│ ├── ghdl.mk ← GHDL VHDL simulation rules
│ ├── modelsim.mk ← ModelSim / QuestaSim rules
│ ├── vivado.mk ← Xilinx Vivado synthesis/implementation rules
│ └── quartus.mk ← Intel Quartus synthesis/fit/asm/STA rules,
│ Platform Designer and Nios II
├── scripts/
│ ├── scan_project.sh ← Source-tree scanner (Linux / macOS / WSL)
│ ├── scan_project.bat ← Source-tree scanner (native Windows cmd)
│ ├── preset_to_config.sh ← Board preset XML → set_property CONFIG pairs
│ ├── vivado_lib.tcl ← Shared Vivado procedures (sources, IP, BD, ELF)
│ ├── vivado_nonproject.tcl ← In-memory build engine (the build)
│ ├── vivado_ipsim.tcl ← IP simulation models for XSIM_REAL_IP
│ └── vivado_project.tcl ← .xpr flows: inspection + block-design round trip
├── templates/
│ └── Makefile.mk.tmpl ← Reference copy of the generated fragment
└── example/
├── src/
│ ├── main.c
│ └── utils/
│ ├── utils.c
│ └── utils.h
└── hdl/
├── top.vhd
└── modules/
└── counter.vhd
After make scan, each source directory gains a Makefile.mk:
src/
├── Makefile.mk ← auto-generated, $(wildcard *.c) inside
├── main.c
└── utils/
├── Makefile.mk ← auto-generated
├── utils.c
└── utils.h
How it works
The framework answers two questions — what are the sources? and which tool consumes them? — and deliberately nothing else. Everything else is split three ways, and that split is the whole architecture:
| Layer | Lives in | Contains |
|---|---|---|
| Data | project.mk |
the project's facts: name, toolchain, top, part, pins, IP, flags |
| Dispatch | Makefile + scripts/scan_project.sh |
source discovery, ordering, toolchain selection |
| Flow | make/<toolchain>.mk + scripts/*.tcl |
the recipes and Tcl that actually run the tools |
The invariant that keeps this usable across projects: nothing
project-specific or device-family-specific goes inside the framework. A
project declares facts, the framework provides mechanism. The test before
editing anything here is whether the line would be wrong in somebody else's
project — a hardcoded zynq_fsbl, a hand-listed set of source files, a
testbench's private log format. If it would, it belongs in project.mk, or it
has to be discovered rather than named. VITIS_BOOT_DIR_GLOB and
XSIM_PASS_PATTERN are both there because of this rule.
The include chain
A consuming project's root Makefile is one line — include mk/Makefile —
and control then runs through five steps in a fixed order:
mk/Makefile
1. TEMPLATE_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
└─ where the framework is, so it works as project root OR submodule.
TEMPLATE_EXCLUDE keeps the framework's own example/ out of discovery.
2. -include project.mk ← the project's data
3. C_SRCS := CXX_SRCS := VHDL_SRCS := … ← simply-expanded, see below
4. include $(shell find … -name Makefile.mk) ← the discovered source lists
└─ none found and the goal needs sources? scan now, at parse time,
then continue below; scan / clean / distclean / help need none
└─ VHDL_SRCS_DIR set? rebuild VHDL_SRCS in the declared directory order
5. include make/$(TOOLCHAIN).mk ← defines 'all' and the real flow
include make/common.mk ← scan / clean / info / help
Step 5 is why make means something different per toolchain without any
conditional logic in the build: all is defined by whichever toolchain module
was included. Under ghdl it is simulate; under vivado it is bitstream.
The order within step 5 is also what lets a toolchain module contribute to
make help. Set TOOLCHAIN_HELP_TARGET to a target the module defines, and
common.mk runs it between the core target list and the workflow notes:
.PHONY: _help_mytool
TOOLCHAIN_HELP_TARGET := _help_mytool
_help_mytool:
@echo ""
@echo " MyTool targets:"
@echo " program Load the bitstream over JTAG"
Setting nothing is fine — gcc and gxx add no targets beyond all, so they
set no hook and make help shows the core list alone. The same is true before
the first make scan: the bootstrap path includes common.mk without any
toolchain module, so a brand-new project's make help lists the core targets
only until it has been scanned once.
Why step 3 matters more than it looks
Each generated fragment appends to those lists:
C_SRCS += $(wildcard $(_THIS_DIR)*.c)
Because step 3 initialised the variables with :=, this += expands its
$(wildcard …) immediately, at parse time, on every single make
invocation. That one detail is the source of the framework's central
ergonomic claim, and its one real limitation:
- Adding or deleting a file in an existing directory needs no rescan — the
next
makealready sees it. - Adding a new source directory needs
make scan, because no fragment exists there yet to be included.
make scan is safe to re-run: fragments are rewritten only when their content
would change, and a .compile_order is never overwritten once created, so
hand-tuned VHDL ordering survives. See
How source discovery and VHDL ordering work
for the mechanics and VHDL compilation order for the
three ordering layers.
Data is generated, flow is committed
The Vivado module holds to the same split one level down. Make generates
exactly one Tcl file — build/vivado_params.tcl, a flat ::p(...) array of
values — and the engine that reads it, scripts/vivado_nonproject.tcl, is
committed and reviewable on its own. No recipe writes a build script it later
sources, which is what makes a failed stage inspectable instead of
archaeological. The same holds for the Vitis and boot targets: the .tcl fed
to xsct is generated per invocation from declared variables, and the
declaration is authoritative — VITIS_DEFINES clears the workspace's existing
symbols before applying the project's, so deleting a line really does delete
the symbol rather than leaving a working build of the wrong firmware.
Available make targets
| Target | Description |
|---|---|
make / make all |
Scan (first run only) then build. Every other target that needs sources scans on its first run too |
make scan |
Re-scan tree; create/update Makefile.mk fragments |
make clean |
Remove the build/ directory |
make distclean |
Remove build/ and all generated Makefile.mk files |
make info |
Show discovered sources and current settings |
make help |
Print the core targets, then those of the selected toolchain |
make test |
Run the project's checks, or every TESTS case; the exit status is the verdict |
make test-report |
Run them and write JUnit XML per test case |
Toolchain-specific targets (available when the relevant toolchain is selected):
| Target | Toolchain | Description |
|---|---|---|
make analyze |
ghdl |
Parse and type-check all VHDL |
make elaborate |
ghdl |
Build simulation binary |
make simulate |
ghdl |
Run simulation, emit a waveform (GHDL_WAVE: vcd, ghw, fst) |
make compile |
modelsim |
Compile HDL into work library |
make simulate |
modelsim |
Run simulation with vsim in batch mode |
make sim-gui |
modelsim |
Compile and open the testbench in the simulator GUI |
make synth |
vivado, quartus |
Synthesis only |
make impl |
vivado |
Implementation (place & route) |
make bitstream |
vivado |
Full flow to bitstream |
make xsa |
vivado |
Full flow, then export the hardware platform (.xsa) |
make sim / sim-gui |
vivado |
XSim behavioural simulation |
make gui |
vivado |
Open the newest checkpoint in the IDE (read) |
make project |
vivado |
Build a browsable .xpr from the same sources (read) |
make project-gui |
vivado |
Open that project in the IDE |
make bd-draft |
vivado |
Create the editable block-design project |
make bd-export |
vivado |
Write the block design back out as versioned Tcl |
make fit |
quartus |
Fitter (place & route) |
make asm |
quartus |
Assembler — generate .sof |
make sta |
quartus |
Static timing analysis (full flow) |
make qsys |
quartus |
Generate HDL from every Platform Designer system |
make nios-bsp |
quartus |
Generate the board support package for each Nios II app |
make nios-apps |
quartus |
Build every Nios II application into an .elf |
make cfgmem |
quartus |
Build the configuration-memory image from the .sof |
make flash |
quartus |
Write that image to the configuration device |
make flash-erase |
quartus |
Erase the configuration device and blank-check it |
make program |
vivado, quartus |
Program connected device |
Bare-metal software on the exported platform, and the non-volatile path (Xilinx SoC). Kept as separate targets because the first is slow and rarely changes while the last runs constantly:
| Target | Toolchain | Description |
|---|---|---|
make vitis-platform |
vivado |
Create the hardware platform + BSP from the .xsa; overlay VITIS_BOOT_SRC onto the generated bootloader and rebuild it |
make vitis-apps |
vivado |
Create/import/build every app in VITIS_APPS |
make vitis-run |
vivado |
Reset, ps7_init, load the bitstream, download and run VITIS_RUN_APP |
make boot-image |
vivado |
Build each BOOT_IMAGES entry from its .bif with bootgen |
make flash-boot |
vivado |
Write each BOOT_FLASH_IMAGES entry into QSPI at its offset, with verification |
vitis-platform is deliberately not idempotent: an existing platform is an
error naming its own fix, because the reasons to re-run it — a changed .xsa,
a changed VITIS_LIBS — are exactly the cases where silently reusing a stale
one would be wrong.
BOOT_ARCH has no default, and boot-image refuses without it. bootgen
does not check -arch against the .bif: given the wrong family it reports
Bootimage generated successfully, exits 0, and writes an image carrying the
other family's boot header, which the BootROM will not load. The failure
surfaces as a board that does not come up, long after a build that passed. Set
BOOT_ARCH := zynq | zynqmp | versal in project.mk.
flash-boot stops a local hw_server before running program_flash, because
program_flash attaches to one that is already listening instead of launching
its own — and a server started by anything else (an xsct session, a chain
probe, an open Hardware Manager) leaves the cable enumerated but the chain
unopened, failing with Given target do not exist. Set
BOOT_HW_SERVER_RESET := 0 when BOOT_HW_URL points at a server you run
deliberately.
FORCE=1 is required by project and bd-draft to overwrite an existing
project — recreating one discards every IDE edit that has not been exported.
The test contract
make test means one thing in every project on this framework, whatever the
language: it ran the project's checks, and its exit status is the truth about
them. Nothing else here makes that promise. all builds and sim simulates,
and both can succeed while every check fails.
Two kinds of runner, one target:
-
The runner reports honestly. Declare
TEST_CMDinproject.mk; the generic target incommon.mkbuilds, runs it, and takes its exit status as the verdict. This coversgccandgxxwith no toolchain-specific code. -
The runner does not. A batch script that ends without setting a code, or a simulator that prints a failure and exits zero, has to have its transcript read. The toolchain defines its own
test:recipe, setsTOOLCHAIN_HAS_TEST := 1, and calls the shared verdict inmake/verdict.mk.
| Variable | Meaning |
|---|---|
TEST_CMD |
Command that runs the checks (generic target only) |
TEST_LOG |
Transcript the verdict reads |
TEST_FAIL_PATTERN |
Extended regex; any match fails the run |
TEST_PASS_PATTERN |
Extended regex that must appear, or the run fails |
TEST_CHECK |
0 skips the verdict, for a run expected to fail |
Each simulator toolchain supplies a default TEST_FAIL_PATTERN matching its own
rendering of VHDL severities, so a testbench using assert/report is covered
without adopting any convention. A verification library that counts its own
alerts is not covered by it. It reports in a summary line and raises no
severity at all, so the simulator exits zero with every check failed. Declare
that project's own patterns:
# project.mk
TEST_FAIL_PATTERN := Simulation FAILED
TEST_PASS_PATTERN := Simulation SUCCESS
TEST_PASS_PATTERN is empty by default because a completion marker is a project
convention, not something the framework can know. Declaring one is what catches
a run that died quietly partway through, leaving no failing check to match.
Two refusals are deliberate. make test with neither a TEST_CMD nor a
toolchain recipe fails rather than passing, because a silent zero is
indistinguishable from a suite that ran and passed. And a pattern grep cannot
read fails the run rather than counting as "no match": a pattern that matches
nothing would otherwise pass everything.
The verdict is self-tested. tests/verdict.sh exercises it against a passing
transcript, a failing one, a missing one, an empty one, an unusable pattern, and
each toolchain default, because a check proven only against a passing run has
not been proven at all.
Several testbenches — TESTS
A project with more than one testbench, or one testbench run several ways,
declares its cases and make test runs all of them:
# project.mk
TEST_PASS_PATTERN := PASS
TEST_TIME := 10ms # default simulated-time limit per case
TESTS := tb_fifo tb_core_full tb_core_lite
TEST_tb_core_full_TOP := tb_core
TEST_tb_core_full_GENERICS := G_MODE=FULL
TEST_tb_core_full_TIME := 50ms
TEST_tb_core_lite_TOP := tb_core
TEST_tb_core_lite_GENERICS := G_MODE=LITE G_DEPTH=16
| Variable | Meaning |
|---|---|
TESTS |
Case names, run in the order given |
TEST_<case>_TOP |
Top-level unit; defaults to the case name |
TEST_<case>_GENERICS |
NAME=VALUE pairs for the top's generics |
TEST_<case>_TIME |
Simulated-time limit; defaults to TEST_TIME |
TEST_<case>_PASS_PATTERN |
Overrides TEST_PASS_PATTERN for this case |
SIM_DATA_DIRS |
Directories testbenches open files from, relative to the project root |
The settings are the same words on every simulator. GHDL takes generics and the
limit at run time, Questa at load time, and xsim elaborates a snapshot per case.
The sources compile once, then each case runs as its own make test-run, with
its transcript at $(BUILD_DIR)/test/<case>.log and its own verdict. A failing
case does not stop the list, and the summary names every case that failed.
The time limit is what turns a hang into a failure. A testbench that never
reaches its completion marker stops at the limit, and the missing
TEST_PASS_PATTERN fails it. Without a limit a simulator runs until the design
has no events left, which a free-running clock never allows. Set the limit to
the longest a passing run needs, not to "large".
Run one case by naming it, make test TESTS=tb_core_lite. make test TESTS=
runs the toolchain's single default top as if no cases were declared.
The same testbench is not automatically the same run on every simulator.
Questa's default time resolution is 1 ns and it rounds without a warning: a
3.333 ns clock period becomes 3 ns, drifts against wait for stimulus, and a
testbench that passes on GHDL fails one beat out of step. Give Questa the
resolution the testbenches assume, VSIM_FLAGS := -t ps or finer.
SIM_DATA_DIRS exists because xsim runs in a directory of its own: a testbench
that opens tb/stim/input.hex finds nothing there. Listed directories are
linked into the run directory before elaboration, because xelab already reads
the files that initialise constants. GHDL and Questa run in the project root
and need nothing.
tests/test_cases.sh <toolchain> proves every claim above on the toolchain
named, including that a hanging case fails and a failing case leaves the
others running. tests/fresh_clone.sh <toolchain> runs the same fixture as a
fresh clone sees it, with no fragments: make test, make info and plain
make work first time, and clean, distclean and help do not scan.
Per-test-case results — make test-report
make test answers one question for the whole suite. make test-report answers
it per test case, by writing JUnit XML to $(TEST_REPORT), default
$(BUILD_DIR)/test-results.xml. That is what lets something outside the build
bind one requirement to one named test rather than to the whole run.
The framework does not know how the report is produced, and must not: a VHDL
test framework, a Python one, and a language's own runner all emit JUnit XML.
The project declares the command, which receives TEST_REPORT in its
environment; the framework fixes the path and checks that a JUnit document was
actually written there.
# project.mk
TEST_REPORT_CMD := ./run_tests.sh
It is independent of test on purpose. A toolchain may implement one, both, or
neither, a report generator usually drives its own compilation, and test never
depends on a report existing.
How source discovery and VHDL ordering work
Initial scan
make scan
└─ bash scripts/scan_project.sh .
├─ finds hdl/ → writes hdl/Makefile.mk + hdl/.compile_order
├─ finds hdl/modules/ → writes hdl/modules/Makefile.mk + hdl/modules/.compile_order
├─ finds src/ → writes src/Makefile.mk
└─ finds src/utils/ → writes src/utils/Makefile.mk
Each generated Makefile.mk contains:
_THIS_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
C_SRCS += $(wildcard $(_THIS_DIR)*.c)
CXX_SRCS += $(wildcard $(_THIS_DIR)*.cpp $(_THIS_DIR)*.cxx $(_THIS_DIR)*.cc)
# VHDL: uses .compile_order when present, wildcard otherwise
ifneq ($(wildcard $(_THIS_DIR).compile_order),)
VHDL_SRCS += $(addprefix $(_THIS_DIR),$(shell grep -v '^[[:space:]]*#' …))
else
VHDL_SRCS += $(wildcard $(_THIS_DIR)*.vhd $(_THIS_DIR)*.vhdl)
endif
V_SRCS += $(wildcard $(_THIS_DIR)*.v $(_THIS_DIR)*.sv)
ASM_SRCS += $(wildcard $(_THIS_DIR)*.s $(_THIS_DIR)*.S $(_THIS_DIR)*.asm)
Excluding a vendored sub-project
A project that vendors a self-contained sub-project — one with its own
project.mk and its own sources — must keep it out of the scan:
SCAN_EXCLUDE := vendor/demo # paths relative to SRC_ROOT, space-separated
Without it the scan sweeps that sub-project's sources into this build. The
failure is not subtle for C (two mains, a link error) and is very subtle for
HDL (an entity compiled into the wrong library). SCAN_EXCLUDE filters the
scan, the sub-makefile discovery, and distclean alike, so a stale
Makefile.mk left inside an excluded directory is ignored rather than included.
Subsequent builds
The root Makefile initialises C_SRCS := (simple-expanded) before
including the fragments. Because C_SRCS is a simple-expanded variable, every
+= with $(wildcard …) expands immediately at parse time — source lists
are always current with no rescanning.
make
├─ root Makefile: C_SRCS :=, VHDL_SRCS :=, …
├─ include src/Makefile.mk → C_SRCS = src/main.c
├─ include src/utils/Makefile.mk → C_SRCS += src/utils/utils.c
├─ include hdl/modules/Makefile.mk → VHDL_SRCS = hdl/modules/counter.vhd (from .compile_order)
├─ include hdl/Makefile.mk → VHDL_SRCS += hdl/top.vhd (from .compile_order)
└─ compile & link
Add new_file.c to src/ → next make picks it up automatically.
Add a new directory src/hal/ with .c files → run make scan once.
VHDL compilation order
VHDL requires packages and entities to be compiled before any design unit that references them. Three layers handle this, from most to least automatic:
Layer 3 — silent pre-pass (automatic)
ghdl.mk and modelsim.mk compile all VHDL files once with errors silenced
before the real pass. Any file that succeeds pre-populates the work library so
the real pass finds all dependencies already there. Most designs need no
further action.
Layer 2 — .compile_order file (per directory)
make scan creates a .compile_order in each VHDL directory listing files
alphabetically as a starting point. Edit it once to set the correct
within-directory order. It is never overwritten by subsequent make scan
runs, so your edits survive. Commit it alongside your source files.
hdl/modules/.compile_order
# list counter before any entity that instantiates it
counter.vhd
Layer 1 — VHDL_SRCS_DIR (cross-directory ordering)
When a package in one directory is used by entities in another, set
VHDL_SRCS_DIR in project.mk to list directories in compilation order.
The root Makefile expands each directory through its .compile_order (or
wildcard) — you declare directory order, file discovery stays automatic.
# project.mk
VHDL_SRCS_DIR := \
hdl/lib \ # packages compiled first
hdl/rtl \ # entities that use those packages
hdl/top # top-level that instantiates rtl entities
This replaces what the per-directory Makefile.mk fragments accumulated, so
all VHDL directories must appear in the list when this variable is set.
Named VHDL libraries
Use named libraries for external verification or vendor sources that cannot be
compiled into work. The list declares the libraries; each library supplies an
ordered source list and optional dependencies and simulator-specific flags.
VHDL_LIBS := uvvm_util bitvis_vip_spi
VHDL_LIB_uvvm_util_SRCS := \
vendor/uvvm/uvvm_util/src/types_pkg.vhd \
vendor/uvvm/uvvm_util/src/adaptations_pkg.vhd
VHDL_LIB_uvvm_util_GHDL_FLAGS := -frelaxed-rules
VHDL_LIB_uvvm_util_VCOM_FLAGS := -suppress 1236,1346
VHDL_LIB_uvvm_util_XVHDL_FLAGS := --relax
VHDL_LIB_bitvis_vip_spi_SRCS := \
vendor/uvvm/bitvis_vip_spi/src/spi_bfm_pkg.vhd
VHDL_LIB_bitvis_vip_spi_DEPS := uvvm_util
GHDL analyses each named library into BUILD_DIR/ghdl_work and adds that
location to its library search path. ModelSim/Questa creates and maps every
library in the build-local modelsim.ini. Vivado XSim compiles each library
into its own directory under BUILD_DIR/xsim/xsim.dir, where elaboration finds
it by name. Project sources remain in work.
VHDL_LIB_<name>_SRCS is an ordered list. A library target depends on every
library named by VHDL_LIB_<name>_DEPS, so parallel make cannot compile a
consumer before its provider.
Configuring project.mk
Put comments on a line of their own. Make keeps the spaces between a value and
a trailing # as part of the value, so VIVADO_PART := xc7a35tcpg236-1 # part
reached Vivado as a part name ending in spaces ("Specified part could not be
found"). The framework strips the values it passes to Vivado, but a project
rule that quotes a variable would still see the spaces.
# output binary name
PROJECT_NAME := my_project
# build artefact directory
BUILD_DIR := build
# see table above
TOOLCHAIN := gcc
# dirs the scan must skip, relative to SRC_ROOT
SCAN_EXCLUDE :=
# C / C++
CC := gcc
CFLAGS := -Wall -Wextra -O2 -g
INC_DIRS := include third_party/include
# GHDL (ghw keeps record signals; a VCD drops them)
GHDL_STD := 08
GHDL_TOP := top
GHDL_WAVE := ghw
# Vivado
VIVADO_PART := xc7a35tcpg236-1
VIVADO_TOP := top
VIVADO_XDC := constraints/pins.xdc
# Implementation strategy — Default everywhere; escalate where timing bites.
# Explore-class directives cost real time on every build, so they are opt-in.
VIVADO_OPT_DIRECTIVE := Default
VIVADO_PLACE_DIRECTIVE := Default
VIVADO_ROUTE_DIRECTIVE := Default
# empty = skip phys_opt entirely
VIVADO_PHYS_OPT_DIRECTIVE :=
# 1 = run it only when placement missed
VIVADO_PHYS_OPT_ON_WNS := 0
# utilization, timing, power, DRC, methodology
VIVADO_REPORTS := 1
# Quartus
QUARTUS_PART := EP4CE6E22C8
QUARTUS_FAMILY := "Cyclone IV E"
QUARTUS_TOP := top
# Quartus — Platform Designer and Nios II (omit when the design has neither)
QSYS_SYSTEMS := system/my_system.qsys
NIOS_APPS := hello
NIOS_hello_SRC_DIR := sw/hello
Quartus — Platform Designer and Nios II
Proven on Quartus Prime Lite 22.1std.0.915, Cyclone 10 LP, in the container
described by altera-dev-env. A worked end-to-end example lives in
example/nios2/: a Nios II/e, on-chip memory and a JTAG UART, all free IP, so
it builds without an IP licence.
The contract
A project declares its systems and its applications; the framework discovers everything else.
QSYS_SYSTEMS := system/my_system.qsys # one entry per .qsys
QSYS_LANG := VERILOG # VERILOG | VHDL
NIOS_APPS := hello
NIOS_hello_SRC_DIR := sw/hello
make qsys generates HDL, make nios-bsp the board support package, and
make nios-apps the .elf. make synth depends on qsys, so a plain make
does the right thing.
Generated output never touches the source tree
Every artefact lands under BUILD_DIR:
build/qsys/<system>/<system>.qsys staged copy
build/qsys/<system>/<system>.sopcinfo generated
build/qsys/<system>/generated/… generated HDL, and the .qip
build/nios/<app>/bsp/ board support package
build/nios/<app>/app/<app>.elf application
The staged copy is not tidiness. qsys-generate honours --output-directory
for HDL but writes the .sopcinfo next to the .qsys it was handed, so
generating in place drops a ~170 kB generated file into the source tree on
every build. Generating from a copy under BUILD_DIR is what keeps git status
clean.
Generated HDL enters the Quartus project as a single QIP_FILE, not as
enumerated sources. Platform Designer already emits a .qip listing every
generated file in the right order and rewrites it whenever the system changes;
enumerating that tree would be a hand-maintained list of files the tool owns.
It is deliberately absent from V_SRCS/VHDL_SRCS — make scan does not look
inside BUILD_DIR.
Never generate a system into the source tree
make qsys generates under BUILD_DIR for a reason beyond tidiness: make scan sweeps the source tree, and a hand-generated Platform Designer tree left
beside the .qsys gets swallowed whole. Every generated file then appears
twice — once enumerated by the scan, once through the .qip — and the two
disagree about file types.
The symptom is not "duplicate source". It is a SystemVerilog syntax error in vendor IP:
Error (10839): Verilog HDL error at altera_tse_pipeline_stage.sv(127):
'0 is a SystemVerilog feature
because the scan enumerates .sv files as VERILOG_FILE while the .qip
correctly declares them SYSTEMVERILOG_FILE. Delete the stray generated
directory and re-run make scan; or if a generated tree genuinely has to live
in the source tree, name it in SCAN_EXCLUDE.
Configuration memory — cfgmem and flash
Proven on Quartus Prime Lite 22.1std.0.915, a Cyclone 10 LP 10CL055 and an EPCQ16: written over JTAG indirect, then confirmed by power-cycling the board and watching it come up on the flashed design.
FLASH_DEVICE := EPCQ16 # required — see below
FLASH_FORMAT := jic # jic | pof | rbf
FLASH_COMPRESSION := on
FLASH_VERIFY := 1 # on by default
make cfgmem builds the image, make flash writes it. Two targets rather than
one because building is cheap and repeatable and writing flash is neither — the
same split the Xilinx boot-image / flash-boot pair uses.
A multi-image or remote-update layout is described by a Quartus conversion file:
FLASH_IMAGE := images/recovery.jic
FLASH_COF := images/two_image_jic.cof
FLASH_COF_DEPS := images/recovery.sof images/application.sof
With FLASH_COF set, cfgmem runs that file instead of silently rebuilding
FLASH_IMAGE from the current single SOF. The conversion file must name the
same output as FLASH_IMAGE; cfgmem rejects a missing output.
make flash-erase erases the device and blank-checks it in the same pass. It
still takes an image, because the loader that reaches the configuration device
is built from it — nothing of the image is written. In quartus_pgm option
strings R is erase and E is examine, so the erase operation is IR, and
IE is rejected as illegal rather than quietly examining something.
Blank-checking in the same pass is the point of the target: an erase that reports success and an erase that happened are different claims, and the blank-check is what separates them. It is a pass-on-emptiness check, so validate it against a programmed device — where it must fail — before trusting a pass.
The format follows how the device is reached, not preference. A
configuration device the FPGA boots from in Active Serial mode is written
through the FPGA over JTAG, which is what jic means; pof addresses the
configuration device directly; rbf is a raw bitstream for a loader that is not
Quartus.
FLASH_DEVICE has no default, and cfgmem refuses without it. quartus_cpf
accepts a wrong configuration device, reports success, and writes an image the
FPGA will not boot — the same failure mode that made BOOT_ARCH mandatory.
Compression is not an optimisation. On the device this was proven against, one uncompressed image is 88% of the configuration memory and one compressed image is 25%. Any design wanting two images in one device needs it. The ratio depends on how full the device is and worsens as a design grows, so measure it rather than assume it.
Platform Designer copies component HDL — QSYS_DEPS
A system built from a project's own components generates by copying their HDL
into the generated tree. Editing that HDL therefore does not make the .qip
out of date: the .qip is still newer than the .qsys, make has nothing to do,
and the design synthesises the copy taken at the last generation.
The failure mode is the expensive one — the build succeeds, the board runs code that is not in the repository, and every symptom points at the source you are reading rather than at the source that was compiled.
QSYS_SEARCH_PATH := ip/my_component
QSYS_DEPS := rtl/my_component.vhd
_hw.tcl files under QSYS_SEARCH_PATH are picked up automatically. The HDL
they reference cannot be — a _hw.tcl may point anywhere — so name those
sources in QSYS_DEPS.
A system built from a script — QSYS_<system>_SCRIPT
A .qsys can be authored by hand or built by a Tcl script. When a script is
the source, name it and the .qsys becomes a build product:
QSYS_SYSTEMS := system/my_sys.qsys
QSYS_my_sys_SCRIPT := system/build_system.tcl
Without this the .qsys is just a file that happens to exist. Editing the
script changes nothing, the build keeps using whatever the file last contained,
and the two drift apart silently — the same failure as a stale component copy,
one level up.
qsys-script continues past errors and saves anyway. A run that cannot
resolve a component writes a system missing exactly the connections it could
not make, and signals the problem only through its exit code. Building from
that file afterwards produces warnings that point at the design rather than at
the generation that produced it. The wrapper therefore builds in a temporary
directory and only replaces the real file on success, so a failed run leaves
the previous system intact.
Building one program in variants — NIOSV_<app>_DEFINES
When one program is built more than once from the same sources, differing only in a compile-time setting:
NIOSV_myapp_DEFINES := VARIANT=0 VERBOSE=1
The tool that generates the application has no option for preprocessor defines, so they are handed to the generated build instead.
project.mk is a prerequisite of every application, because a setting
baked into an artefact is a build input like a source file. Without that,
editing the setting leaves the artefact up to date and the change simply does
not happen — the program keeps the previous definition and nothing reports that
anything was ignored. PROJECT_MK is available to any rule with the same
property.
The tool-path trap
Sourcing the Quartus environment puts quartus/linux64 and the simulator on
PATH and nothing else. qsys-generate lives under
quartus/sopc_builder/bin and the Nios II SDK under nios2eds/; neither is
added. A bare qsys-generate is "command not found" in a shell where
quartus_map works fine.
nios2_command_shell.sh is the vendor's answer, and it has a sharp edge:
nios2_command_shell.sh <command> [args…] # runs the command with PATH set — correct
source nios2_command_shell.sh # execs an interactive bash — never returns
Every Platform Designer and Nios II invocation in quartus.mk goes through the
wrapper in its command form. NIOS2_SHELL is derived from QUARTUS_ROOTDIR,
which the Quartus environment exports; override it only when the tools live
somewhere the derivation cannot reach.
Getting the program into the FPGA image
NIOS_MEM_INIT := <app> bakes an application into the on-chip memory of the
FPGA image, so the board runs the moment it is configured:
NIOS_MEM_INIT := hello
This inverts the usual order — synthesis waits for the .elf — and one
make sta from a clean tree runs qsys → BSP → application → memory init →
synthesis → fit → assembler → STA.
Needed whenever the program cannot arrive over a JTAG debug download after configuration: no cable driver for the board's programmer, or a board expected to run standalone.
The memory instance must also be declared with its "initialize memory
content" parameter set. With it clear, Platform Designer emits
init_file = "UNUSED", the RAM synthesises empty, and the processor fetches
zeros. Nothing in the build complains — the .hex is generated, the QIP is
added to the project, and the memory simply ignores both. Check the fitter
report for the .hex filename against the RAM instance if a Nios II design
builds cleanly and does nothing.
Two things the framework will not guess
Which system an application runs on. NIOS_<app>_SOPCINFO is derived only
when exactly one QSYS_SYSTEMS entry is declared. With several it is required,
because an application built against the wrong memory map links cleanly and
then does not run — the same class of failure as a wrong BOOT_ARCH.
Whether the application fits. A minimal system's on-chip memory is small and
the full C library is not. NIOS_<app>_BSP_SETTINGS passes settings straight to
nios2-bsp; hal.enable_small_c_library=true took the example's application
from 22 kB of text to 2.5 kB. Without it a printf does not fit in 32 kB.
Building the example
cd example/nios2
make scan # first invocation only — see below
make # qsys → synthesis → fit → assembler → STA → .sof
make nios-apps # the application
make scan first because the bootstrap path that runs on a tree with no
Makefile.mk files includes only the common targets, not the toolchain module —
so make qsys on a fresh clone reports "No rule to make target". A bare make
bootstraps and then builds, which is why the quick start does not mention this.
example/nios2/system/build_system.tcl is what produced the committed .qsys.
Keeping it beside the system means the design can be rebuilt or reviewed without
opening the GUI.
Quartus — Nios V
Nios V sits beside Nios II rather than replacing it. Everything core-agnostic is shared — Platform Designer generation, constraints, the VHDL standard handling, the flash targets, the scan guards. The software half is a separate implementation, because almost nothing about it is the same:
| Step | Nios II | Nios V |
|---|---|---|
| BSP | nios2-bsp |
niosv-bsp --create |
| Application | generated makefile, make -C |
niosv-app emits a CMakeLists.txt, built with CMake |
| Memory init | the BSP's mem_init_generate target |
elf2hex, run by the framework |
| Extra flags | --set APP_CFLAGS_USER_FLAGS |
a BSP setting — the toolchain file governs the app too |
The contract
NIOSV_APPS := hello
NIOSV_hello_SRC_DIR := sw/hello
NIOSV_hello_BSP_SETTINGS := hal.make.cflags_user_flags=-march=rv32ia_zicsr
NIOSV_MEM_INIT := hello
NIOSV_MEM_INIT_HEX := my_ram.hex
NIOSV_MEM_INIT_BASE := 0x0
NIOSV_MEM_INIT_END := 0x7FFF
make niosv-bsp generates the BSP, make niosv-apps builds each application,
and a declared NIOSV_MEM_INIT puts the program into the FPGA image so make
alone produces a .sof that runs. Every variable is listed with a commented
example in project.mk.
Three things that are not in any installer
A RISC-V toolchain. No Intel installer ships one. The BSP writes
riscv32-unknown-elf-gcc into its own generated toolchain.cmake with a plain
set(), which shadows any CMake cache override, so a toolchain reachable under
a different name will not be used — provide that exact prefix, or set NIOSV_CC.
niosv-shell looks for Intel's RiscFree under $ACDS_ROOT/riscfree, and when
it is absent prints an INFO line and carries on, so the failure surfaces much
later as a missing compiler inside a generated CMake build. Proven here against
xPack riscv-none-elf-gcc 12.5.0-1, symlinked to that prefix; rv32ia/ilp32 is
an exact multilib in it and newlib is present.
CMake. The application build is CMake, not a generated makefile.
A licence, on some editions. The Nios V/m IP is licensed (FlexLM feature
6AF7_D036) and, unlike Triple-Speed Ethernet, supports no evaluation mode.
Without a licence the design synthesises and fits, then quartus_asm reports
"successful, 0 errors" and writes no programming file at all. Check
output_files/ for the .sof rather than trusting the exit status.
The zicsr trap
binutils 2.38 moved the CSR instructions out of the base RISC-V I extension.
The processor IP hardcodes -march=rv32ia as a module software property, and
the BSP's own crt0.S writes csrw mtvec,t0 against it, so under any binutils
at or past that release the BSP fails to assemble:
crt0.S:134: Error: unrecognized opcode `csrw mtvec,t0', extension `zicsr' required
The fix is a supported BSP setting, not a patched file:
hal.make.cflags_user_flags=-march=rv32ia_zicsr. It is emitted after the
hardcoded -march in the generated add_compile_options, and the compiler
takes the last one. rv32ia is a 32-register core; a 16-register one is
rv32ea.
LD_LIBRARY_PATH has to be cleared
Quartus's bundled libstdc++ shadows the system one once its environment has
been sourced, so CMake dies on a missing GLIBCXX_3.4.30 before it ever reaches
the compiler. The framework clears it for the CMake and make invocations. The
rule is not "CMake needs this" — it is anything outside quartus/linux64.
Memory initialisation
A Nios V BSP has no mem_init_generate, so the framework runs elf2hex itself.
The contract is the on-chip memory's initializationFileName in the Platform
Designer system: the memory is synthesised from a file of that name, and
NIOSV_MEM_INIT_HEX has to match it. Declare the memory with
useNonDefaultInitFile set, or the tool picks its own name and the two disagree
in silence.
None of the name, base or end gets a default. Each builds a plausible artefact
when wrong: a mismatched name synthesises an empty RAM and the processor fetches
zeros, a wrong range truncates the image, and nothing in the build complains.
Confirm it landed by looking in the fitter report's RAM Summary for the .hex
against the memory instance — the same check the Nios II flow needs.
Building the example
cd example/niosv
make scan
make # qsys → BSP → app → elf2hex → synthesis → … → .sof
Proven on Quartus Prime Lite 22.1std.0.915 with xPack riscv-none-elf-gcc
12.5.0-1 and CMake 3.22.1, against a 10CL025YU256C8G.
Vivado build flow — Non-Project Mode
The build is Non-Project Mode: sources are read, implemented and exported
inside one in-memory Vivado session and no .xpr is written. Everything the
build needs comes from project.mk, so a fresh clone reproduces the design
exactly.
The flow lives in four committed, reviewable Tcl files under scripts/:
| File | Role |
|---|---|
vivado_lib.tcl |
shared procedures — sources, IP, block design, ELF |
vivado_nonproject.tcl |
the in-memory build engine (synth→impl→bitstream→xsa) |
vivado_project.tcl |
the .xpr flows: inspection and the block-design round trip |
vivado_ipsim.tcl |
the IP's simulation models and their manifest, for XSIM_REAL_IP |
None of them contains project-specific data. Make generates exactly one file —
build/vivado_params.tcl, a flat parameter array — and the engine sources it.
Data is generated; flow is committed.
Each stage is cumulative and runs in a single Vivado invocation, because an
in-memory design does not outlive the process. Every stage leaves a checkpoint
in build/nonproject/, so a failed run can be opened and inspected without
rebuilding:
post_synth.dcp post_place.dcp post_route.dcp
Two things that are easy to get wrong here, both learned by hitting them:
-
IP must be synthesized explicitly. Project Mode gets this free — the run manager spawns a child run per IP. Non-Project Mode has none, and
synth_designwill not compile an IP from its.xci. Skipsynth_ipand the build dies atopt_designwithDRC INBB-3 … considered a black box, long after synthesis reported success.vivado_lib.tcldoes this for every IP. -
Compilation order is the contract. There is no
update_compile_orderto fall back on, so the order from.compile_order/VHDL_SRCS_DIRis what Vivado gets.
Out-of-context implementation — VIVADO_SYNTH_MODE
A core with no board around it yet still needs to be placed and routed on its target part, to learn whether it fits and meets timing. In-context synthesis cannot do that: it puts an I/O buffer on every top-level port, and a core with a few thousand port bits needs more pins than any package has.
VIVADO_TOP := my_core_ooc # a thin wrapper, usually with a clock buffer
VIVADO_SYNTH_MODE := out_of_context
Synthesis then runs with -mode out_of_context: no I/O buffers, and the ports
become the boundary of a block meant to sit inside a larger design. make impl
places, routes and reports it. Timing across that boundary is only as good as
the constraints that describe it, so constrain the clock and, where it matters,
the boundary delays.
A design with no pins has nothing for a bitstream to bond, so bitstream and
xsa refuse an out-of-context design before spending a synthesis run on it.
impl is the end of this flow.
Post-route checks — VIVADO_POST_ROUTE_TCL
The flow reports timing and DRC loudly but does not fail on them; whether a design that misses timing is still useful is the project's call. A project that wants it to be a failure says so with a check:
VIVADO_POST_ROUTE_TCL := scripts/check_timing.tcl scripts/check_clock.tcl
Each file is sourced in the build session after routing and the reports, with
the routed design still open, so a check queries it directly with
get_timing_paths, get_nets and the rest, with no checkpoint to reopen. A check fails the
build by raising an error:
set wns [get_property SLACK [get_timing_paths -delay_type max]]
if {$wns < 0} { error "setup not met: WNS $wns ns" }
tests/vivado_flow.sh proves both features against a small design, including
that a failing check fails the build and that an out-of-context design inserts
no I/O buffers while an in-context one does.
The simulation verdict — why make sim reads the transcript
This toolchain is one instance of The test contract; the
mechanism lives in make/verdict.mk and the XSIM_* names below are this
toolchain's spelling of the shared variables. XSim's exit status is not a
verdict, so neither make sim nor make test uses it. Measured on 2021.2:
| Run | Transcript | xsim -runall exit |
|---|---|---|
| testbench passes | Note: TEST COMPLETE |
0 |
testbench fails (severity failure) |
Failure: TEST FAILED |
0 |
testbench errors (severity error) |
Error: …, run continues |
0 |
| tool failure (missing snapshot) | ERROR: Please check the snapshot name … |
1 |
A passing run and a run where every check failed are indistinguishable by exit
status. Left unchecked, make sim reports success on a testbench that failed —
a green build proving nothing, which is worse than a red one. GHDL has no such
problem: it propagates severity failure into its exit status, so ghdl.mk
needs none of this.
The transcript is therefore the source of truth. make sim tees it to
$(TEST_LOG), which defaults to $(XSIM_LOG), and matches two patterns against it:
XSIM_LOG ?= $(BUILD_DIR)/xsim_$(TEST_TOP).log # TEST_TOP defaults to VIVADO_SIM_TOP
XSIM_FAIL_PATTERN ?= ^(Error|Failure|Fatal):|^ERROR: # any match fails the run
XSIM_PASS_PATTERN ?= # must appear, or the run fails
XSIM_CHECK ?= 1 # 0 skips the verdict
The default XSIM_FAIL_PATTERN is xsim's own rendering of VHDL severities, so
any testbench using assert/report is covered without adopting a convention;
the ERROR: alternative catches a tool-level failure, whose non-zero status the
pipe to tee would otherwise swallow.
XSIM_PASS_PATTERN is empty by default because a completion marker is a
testbench convention and the framework cannot know it. Declare one —
it is what catches the run that died quietly partway through, leaving no failing
assert to match:
# project.mk
XSIM_PASS_PATTERN := TEST COMPLETE
XSIM_CHECK=0 is for a run that is expected to fail — a TDD red phase, which
asserts the inverse against the same transcript:
red:
@$(MAKE) sim XSIM_CHECK=0 XELAB_FLAGS="$(XELAB_FLAGS) -generic_top G_RED=true"
@grep -q "TEST FAILED" $(XSIM_LOG) || { echo "stub did not fail"; exit 1; }
Pass it on the command line, not in project.mk: a project that sets it
permanently has switched the check off.
Simulating the real IP — XSIM_REAL_IP
A design built on VIVADO_IP usually simulates against behavioural stand-ins:
they are fast, they need no vendor libraries, and any simulator runs them. The
stand-ins are still a model of the IP, though, and only the vendor's own
simulation model says what the hardware will do. XSIM_REAL_IP=1 runs that
model instead:
# project.mk
VIVADO_SIM_SRCS = $(filter tb/% src/ip_stubs/%,$(VHDL_SRCS)) # never synthesised
VIVADO_IP_STUBS = $(filter src/ip_stubs/%,$(VHDL_SRCS)) # replaced by the real IP
make test XSIM_REAL_IP=1 TESTS="tb_fft tb_core_short"
scripts/vivado_ipsim.tcl creates each IP from the same VIVADO_IP_* settings
the build uses, generates its simulation products only (no synth_ip), and
writes a manifest of the files they need, in compile order and each with its
library. Every file is compiled into that library, the sources in
VIVADO_IP_STUBS are left out, and elaboration searches the IP libraries. The
models are regenerated when project.mk changes.
Real-IP runs keep their own work directory, $(BUILD_DIR)/xsim_ip, so a
stand-in and a vendor model never share a library under one entity name, and
switching back and forth needs no clean. tests/vivado_ip_sim.sh proves the
switch with an IP whose stand-in has a different latency from the real core.
The IP libraries are compiled into that directory too, never into the Vivado
install. Vivado's own xsim.ini maps every IP support library
(xbip_utils_v3_0_10, mult_gen_v12_0_17, ...) to a precompiled copy in the
install, and a bare --work <lib> would follow that mapping. Where the install
is read-only, or ships without the precompiled IP, the compile then fails with
VRFC 10-449 Cannot open file ....vdb. The flow writes xsim_ip.ini, which maps
each library in the manifest to $(BUILD_DIR)/xsim_ip/xsim.dir, and passes it
to xvhdl, xvlog and xelab with --initfile, which adds to the default
xsim.ini rather than replacing it. A failed model compile prints the end of
its transcript, which stays in $(BUILD_DIR)/xsim_ip/ip_compile.log.
The project is for reading
A .xpr is still available, but nothing is ever built from one:
make gui # open the newest checkpoint — the usual way to inspect a build
make project # build a browsable .xpr from the same sources
make project-gui # open it
Nothing authored in the IDE survives, with exactly one sanctioned exception: the block design round trip below.
Vivado block designs
A block design is authored in the IDE and stored as versioned Tcl. The round trip is:
make bd-draft # create the editable project, seeded from what exists
# ... open it, wire it up, SAVE ...
make bd-export # write_bd_tcl -> $(VIVADO_BD_TCL)
git add $(VIVADO_BD_TCL)
From then on VIVADO_BD_TCL is the source of truth: the build replays it and
every VIVADO_BD_* key below is ignored. Those keys are a bootstrap — they
seed the first draft so a processor-only handoff design works before anyone has
opened the IDE, and they stop being read the moment an export exists.
This matters because the export can express everything IP Integrator can —
interface-to-interface connections, address assignment, apply_bd_automation,
hierarchies — none of which the bootstrap keys can reach. The cost is that
write_bd_tcl output is verbose (tens of KB), re-emits every cell's full config
dict, and embeds a Vivado version check that errors on a mismatch.
Declaring one
# project.mk
VIVADO_BD := ps_bd # block design name
VIVADO_BD_TCL := vivado/bd/ps_bd.tcl # versioned export — canonical
# once it exists
# Bootstrap only — ignored as soon as VIVADO_BD_TCL exists on disk:
VIVADO_BD_CELLS := ps7_0 # cells inside it
VIVADO_BD_EXT_INTF := ps7_0/DDR ps7_0/FIXED_IO ps7_0/M_AXI_GP0
VIVADO_BD_EXT_PINS := ps7_0/FCLK_CLK0 ps7_0/FCLK_RESET0_N
VIVADO_BD_NETS := ps7_0/M_AXI_GP0_ACLK=ps7_0/FCLK_CLK0
VIVADO_BD_INTF_FREQ := M_AXI_GP0_0=125000000
# Cells reuse the same variables as create_ip IP:
VIVADO_IP_ps7_0_VLNV := xilinx.com:ip:processing_system7:5.5
VIVADO_IP_ps7_0_PRESET := vivado/board_files/<board>/<rev>/preset.xml
VIVADO_IP_ps7_0_CONFIG := CONFIG.PCW_FPGA0_PERIPHERAL_FREQMHZ=125
The generated wrapper is added to sources_1. Instantiate it from your own
top rather than making it the top, so the block design stays as small as
whatever forced you to have one, and the rest of the fabric stays in RTL where
it is reviewable and diffable.
VIVADO_BD_INTF_FREQ is not decoration: external interface ports do not inherit
the clock rate of the pin they were made from, so overriding an FCLK makes
validate_bd_design fail with a FREQ_HZ mismatch between the port and the
interface.
What the bootstrap keys can and cannot express
| Can | Cannot |
|---|---|
| Create cells of any VLNV | Connect interfaces between cells (connect_bd_intf_net) |
Configure them (_PRESET, _CONFIG) |
Assign addresses (assign_bd_address) |
| Expose interface pins externally | apply_bd_automation |
| Expose scalar pins externally | Hierarchies / nested blocks |
| Connect scalar pins | Ordering beyond this fixed sequence |
Set external interface FREQ_HZ |
This covers "a processor with its pins brought out", which is all a
hardware-platform export usually needs. Anything richer is not a reason to
extend make/vivado.mk — it is a reason to draft, edit and export. The
exported Tcl has none of these limits, which is the whole point of the round
trip.
To see exactly what make hands the flow, read build/vivado_params.tcl.
Working in the IDE
make bd-draft # refuses to clobber; FORCE=1 to recreate
make project-gui # open it
The draft is seeded from VIVADO_BD_TCL when that file exists, and from the
bootstrap keys when it does not — so the loop is genuinely round-trippable:
export, edit, re-export, and the diff is just your change.
Edit, save in the IDE, then export. bd-export reads what is on disk; an
unsaved edit is not in the project file and will not appear in the output.
You can also export from the IDE's own Tcl console without leaving it:
write_bd_tcl -force <VIVADO_BD_TCL>
make bd-draft prints that exact line, with the path filled in.
Hardware platform export (XSA)
make xsa # -> $(VIVADO_XSA), default build/$(VIVADO_TOP).xsa
Runs synthesis, implementation, the bitstream and write_hw_platform -fixed -include_bit in a single Vivado invocation — an in-memory design does not
outlive the process, so there is nothing for a second invocation to find.
The export has one non-obvious step in it. write_hw_platform refuses to run
against the in-memory routed design: with no implementation run to inspect,
Vivado computes the platform state as pre_synth and fails with
ERROR: [Common 17-69] Platform state 'pre_synth' is only supported for
synthesized, implemented, checkpoint or non-DFX elaborated designs
There is nothing to override — PLATFORM.STATE is not a property that exists.
The fix is to reopen the routed checkpoint, which puts the design into the
checkpoint state that message names. The block design survives that, because
only the design is replaced and the in-memory project still holds the .bd.
Verified on 2021.2: the resulting archive carries the .hwh, ps7_init.* and
the bitstream.
An XSA is only useful to Vitis if the design contains a block design.
write_hw_platform derives its .hwh hardware handoff from one. Export from a
pure-RTL design and you get an archive holding just a bitstream — no address
map, no ps7_init, nothing to build a bare-metal platform or an FSBL from. That
is the usual reason to introduce a block design at all, even a single-cell one.
Judge an export by its sysdef.xml, not xsa.xml. The latter is the
acceleration-flow manifest and can still report PlatformState="pre_synth" for
a perfectly good embedded platform. A complete embedded export lists:
HW_HANDOFF <bd>.hwh
PS_FSBL_INIT ps7_init.c / ps7_init.h
PS_XMD_INIT ps7_init.tcl
BIT <top>.bit
Windows (native cmd.exe)
The .bat scanner requires PowerShell (available on Windows 7+) to write
Makefile.mk content without escaping issues. Make itself should be
installed via WinLibs,
MSYS2, or
GNU Make for Windows.
Alternatively, use Git Bash or WSL and run
scripts/scan_project.sh directly.
Adding a new toolchain
- Create
make/<name>.mkfollowing the pattern of the existing toolchain files. - Define:
- An
all:phony target. - A recipe for
$(BUILD_DIR)/$(PROJECT_NAME)(may be an alias/phony).
- An
- Decide how it takes part in
make test. A toolchain whose runner exits non-zero on failure needs nothing: the generic target runs the project'sTEST_CMD. One whose runner does not defines its owntest:recipe and setsTOOLCHAIN_HAS_TEST := 1, and that recipe keeps two promises —testexits non-zero when the checks failed, and it reads its verdict frommake/verdict.mkrather than inventing one. See Thetestcontract. - Set
TOOLCHAIN := <name>inproject.mk.
License
Apache License 2.0 — see LICENSE. The patent grant is deliberate: this framework drives builds that end up in shipped hardware.