Automate the multifolder makefile based fw project creation
  • Makefile 64.3%
  • Tcl 14.9%
  • Shell 12.1%
  • VHDL 3.8%
  • Batchfile 2.5%
  • Other 2.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Pcsf 7f399b0088 Copy self-test fixtures as a clone has them
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.
2026-10-29 06:22:24 +01:00
example Add a Nios V path beside the Nios II one 2026-08-18 14:07:21 +02:00
make Link SIM_DATA_DIRS before xsim elaborates 2026-10-29 06:06:02 +01:00
scripts Simulate the real Xilinx IP with XSIM_REAL_IP 2026-10-06 22:19:58 +02:00
templates Fix generated Makefile.mk parse error on GNU make < 4.3 2026-07-14 01:49:21 +02:00
test Link SIM_DATA_DIRS before xsim elaborates 2026-10-29 06:06:02 +01:00
tests Copy self-test fixtures as a clone has them 2026-10-29 06:22:24 +01:00
.gitignore Add three-layer VHDL compilation-order strategy 2026-04-19 10:16:09 +00:00
LICENSE Add the Apache-2.0 licence, and scope TODO.md to what is still open 2026-08-17 20:22:49 +02:00
Makefile Scan on the first run of any target, not only plain make 2026-10-09 08:03:54 +02:00
NOTICE Add the Apache-2.0 licence, and scope TODO.md to what is still open 2026-08-17 20:22:49 +02:00
project.mk Compile named VHDL libraries in the XSim flow 2026-10-18 16:10:05 +02:00
README.md Link SIM_DATA_DIRS before xsim elaborates 2026-10-29 06:06:02 +01:00
TODO.md Record Intel non-SoC flash as covered, proven on hardware 2026-08-18 12:44:45 +02:00

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

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 make already 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_CMD in project.mk; the generic target in common.mk builds, runs it, and takes its exit status as the verdict. This covers gcc and gxx with 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, sets TOOLCHAIN_HAS_TEST := 1, and calls the shared verdict in make/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_design will not compile an IP from its .xci. Skip synth_ip and the build dies at opt_design with DRC INBB-3 … considered a black box, long after synthesis reported success. vivado_lib.tcl does this for every IP.

  • Compilation order is the contract. There is no update_compile_order to fall back on, so the order from .compile_order / VHDL_SRCS_DIR is 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

  1. Create make/<name>.mk following the pattern of the existing toolchain files.
  2. Define:
    • An all: phony target.
    • A recipe for $(BUILD_DIR)/$(PROJECT_NAME) (may be an alias/phony).
  3. 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's TEST_CMD. One whose runner does not defines its own test: recipe and sets TOOLCHAIN_HAS_TEST := 1, and that recipe keeps two promises — test exits non-zero when the checks failed, and it reads its verdict from make/verdict.mk rather than inventing one. See The test contract.
  4. Set TOOLCHAIN := <name> in project.mk.

License

Apache License 2.0 — see LICENSE. The patent grant is deliberate: this framework drives builds that end up in shipped hardware.