Author: Leonardo Capossio — bard0 design — hello@bard0.com
Open source AXI4 / AXI4-Lite interconnect generator. Describe your bus topology in YAML, get Verilog. Or use a pre-built output directly.
MIT licensed. Built with SpinalHDL.
Hardware-validated on Xilinx Arty A7-100T and Altera DE25-Nano. 245 SpinalSim + 46 cocotb tests pass.
- What it does
- Comparison
- Quick start
- YAML configuration reference
- Simulation
- Protocol checking
- Example system — VexRiscv SoC
- Hardware validation
- Port naming
- Tool integration
- Project structure
- License
axiZero generates a non-blocking AXI interconnect that routes M masters to N slaves based on a static address map. Each port can be independently configured as AXI4 or AXI4-Lite; the required adapters are inserted automatically.
Implemented and working:
-
AXI4 full (with IDs, bursts, outstanding transactions)
-
AXI4-Lite (no IDs, single-beat)
-
Per-port mixed AXI4 / AXI4-Lite with automatic adapter insertion
-
AXI4-Lite data-width conversion (zero-extend / truncate at port boundaries)
-
Full AXI4 data-width conversion — burst-splitting upsizer and downsizer at port boundaries; all three burst types (FIXED, INCR, WRAP) supported
-
Register slices, per master and per slave port
-
Round-robin, fixed-priority, and weighted round-robin arbitration
-
QoS arbitration (highest AXQOS wins) with aging-based anti-starvation
-
Pipelined mode (
max_outstanding > 1) with per-slave W-route FIFOs and ID-based response routing -
IPIF compatibility — AW and W are presented simultaneously to slaves that require it
-
Decode-error responses — an address that falls outside every slave's range is completed with
DECERRon B or R instead of being left unacknowledged, so a stray address raises a bus fault rather than wedging the master forever -
A synthesizable AXI4 protocol checker — 19 rules, passive, one sticky bit each, running in simulation and in the bitstream from the same source; it is what caught the arbitration bug described in Protocol checking
-
A synthesizable saturating self-checking traffic generator — keeps several bursts in flight and reads back every word it wrote, so a crossbar can be loaded and judged on the board and not only in simulation; see loading the crossbar on hardware
-
YAML → Verilog generator with port-name post-processing for Vivado AXI naming conventions
-
AXI3-to-AXI4 bridge adapter with WID reorder buffer (write interleaving → strict AW-order), locked access conversion, LEN/LOCK field adaptation
-
Standalone AXI4-Stream utility cores: register slice, width adapter, FIFO, packet arb-mux, packet demux, broadcaster
-
VexRiscv example SoC: a RISC-V core booting through the crossbar into a mixed AXI4 / AXI4-Lite address map, in simulation and on an Arty A7-100T
-
Dhrystone 2.1 on that SoC, in simulation and on the board, with per-port AXI latency and occupancy measured at the crossbar
-
A system-level stress test: the same SoC with caches on and a third master saturating the fabric — 100.0% occupied and 80.8% of cycles contended, 1,064,161 burst beats checked against their expected values while the program on top still passes all 20 of its own self-checks
-
QoS measured where it matters: ranking the masters moves 9.2 points of bus share with 4-beat bursts and 0.3 with 16-beat ones, because the crossbar's anti-starvation age boost erases a priority gap that is smaller than the wait
-
A third-party video IP core (vtpgZero) as a fourth master, writing real 16-beat frames into RAM while the CPU runs from it — the only bursting write master in the design, and the one that found a response-routing bug in the Lite adapter
-
The same SoC on two FPGA families — Xilinx Artix-7 and Altera Agilex 5 — with a JTAG-to-AXI bridge acting as a third bus master on the board that has no UART
-
Arbitration validated on real silicon rather than only in simulation: four bitstreams — round robin, weighted, QoS, and one with the CPU's load/store path routed through the AXI3 adapter — each driven by two self-checking generators that hold the fabric contended 97% of the time in simulation of the same configuration, and each built for both vendors from one configuration object
-
AXI4's same-ID ordering rule validated on silicon too, which needs a master that actually varies its ID — nothing else here does, VexRiscv included. A fifth bitstream runs two multi-ID generators across two RAMs, each checking in hardware that every response came back under the right ID and in the right order, with a probe on the crossbar's own master port watching the single-slave-per-ID rule where it is enforced rather than inferring it from answers that arrived. Write responses carry only an ID and a status, so a swapped BID would be invisible between two RAMs that both answer OKAY: the generators write to an unmapped address as well, and hold every response to what its ID was owed
Not yet implemented:
- Clock domain crossing: all ports share a single clock (
aclk) and reset (aresetn). - AXI4-Lite crossbar pipelined mode: the Lite-only path is always single-outstanding per slave.
| axiZero | PULP axi | verilog-axi | taxi | dpretet/axi-crossbar | |
|---|---|---|---|---|---|
| License | MIT | SHL-0.51 | MIT | CERN-OHL-S¹ | MIT |
| AXI4 full | ✓ | ✓ | ✓ | ✓ | ✓ |
| AXI4-Lite | ✓ | ✓ | ✓ | ✓ | ✓ |
| Per-port mixed AXI4/Lite | ✓ | — | — | — | — |
| AXI4-Lite data-width conversion | ✓ | ✓ | ✓ | ✓ | — |
| Full AXI4 data-width conversion | ✓ | ✓ | ✓ | ✓ | — |
| Register slices | ✓ | ✓ | ✓ | ✓ | — |
| Round-robin / fixed-priority | ✓ | ✓ | ✓ | ✓ | ✓ |
| Weighted round-robin | ✓ | ✓ | — | — | ✓ |
| QoS arbitration | ✓ | ✓ | — | — | ✓ |
¹ CERN-OHL-S is copyleft (share-alike); requires releasing your full digital design on request.
Requirements: Python 3.8+ with PyYAML, Java 21+, sbt.
On Linux or WSL, Verilator 5.x is also required (SpinalHDL invokes it internally during sbt compilation and simulation).
An install script handles Java, sbt, Verilator, and Python packages. It detects whether you are on Linux, WSL, macOS, or Windows and runs the appropriate package manager commands.
# Check what is / isn't installed
python scripts/install_deps.py --check
# Install everything
python scripts/install_deps.pyOn Windows the script uses winget. On macOS it uses Homebrew (must be installed first). On Linux/WSL it uses apt. Note that Verilator and cocotb simulation require Linux or WSL — on Windows, install WSL Ubuntu 24.04 and run the script from inside it.
Java 21 (required by sbt/SpinalHDL):
# Ubuntu / Debian / WSL
sudo apt-get install -y temurin-21-jdk # via adoptium.net apt repo, or:
sudo apt-get install -y openjdk-21-jdk # standard OpenJDK
# macOS (Homebrew)
brew install --cask temurin@21
# Windows: download from https://adoptium.net/sbt (Scala build tool):
# Ubuntu / Debian / WSL — one-liner from sbt docs
echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" \
| sudo tee /etc/apt/sources.list.d/sbt.list
curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" \
| sudo apt-key add -
sudo apt-get update && sudo apt-get install -y sbt
# macOS
brew install sbt
# Windows: download the MSI from https://www.scala-sbt.org/download/Verilator 5.x (Linux / WSL only; used to validate generated output):
sudo apt-get install -y verilator # Ubuntu 24.04 ships Verilator 5.xpip install pyyaml
# Print a working example config
python scripts/axizero.py example > my_design.yaml
# Generate Verilog
python scripts/axizero.py generate my_design.yaml --output rtl/Every key the config file accepts — port modes, address maps, data and ID widths, arbitration policy, register slices, outstanding depth — is documented in the YAML configuration reference, along with what the generator validates before it emits anything.
Fifteen configurations are pre-generated in generated/. Copy the appropriate file into your project and instantiate it.
Resource usage is post-synthesis, out-of-context, Vivado 2025.2 targeting
xc7a100tcsg324-1 with no timing constraint applied; Fmax is derived from the
worst-case path Vivado reports under those conditions, so treat it as an upper
bound rather than a closed-timing figure. n/a means the design had no
internal path for Vivado to rank. Regenerate the whole table with
vivado -mode batch -source hw/vivado/synth_resource_usage.tcl. No BRAM or DSP
is used by any configuration. The memory-mapped configurations include the decode-error
responder, which is on by default; the AXI4-Stream cores have no address map and are unaffected.
| File | Description | LUTs | FFs | LUTRAM | Fmax (MHz) |
|---|---|---|---|---|---|
MyLite_1M4S.v |
1M×4S AXI4-Lite, round-robin | 278 | 13 | 0 | 344.8 |
AxiZeroLite_1M4S.v |
1M×4S AXI4-Lite, round-robin (wider addr) | 278 | 13 | 0 | n/a |
MyLite_2M2S_WRR.v |
2M×2S AXI4-Lite, weighted round-robin (3:1) | 387 | 301 | 0 | 185.2 |
MyLite_2M4S_FP.v |
2M×4S AXI4-Lite, fixed priority | 555 | 23 | 0 | 322.6 |
AxiZeroLite_2M4S_RS.v |
2M×4S AXI4-Lite, register slices on all ports | 668 | 793 | 0 | n/a |
AxiZeroLite_4M4S_FP.v |
4M×4S AXI4-Lite, fixed priority | 1467 | 33 | 0 | n/a |
MyFull_2M2S.v |
2M×2S AXI4 Full, 64-bit, round-robin | 644 | 37 | 0 | 227.3 |
MyFull_2M2S_QoS.v |
2M×2S AXI4 Full, 64-bit, QoS arbitration | 772 | 114 | 6 | 125.0 |
MyMixed_2M3S.v |
2M×3S mixed (Full + Lite), auto adapters | 580 | 59 | 0 | 263.2 |
MyAxisRegSlice.v |
AXI4-Stream register slice, 32-bit | 2 | 42 | 0 | 500.0 |
MyAxisWidth_8To32.v |
AXI4-Stream width adapter, 8→32-bit | 10 | 44 | 0 | 333.3 |
MyAxisFifo.v |
AXI4-Stream FIFO, 32-bit | 42 | 57 | 28 | 294.1 |
MyAxisMux_2To1.v |
AXI4-Stream arb-mux, 2→1, round-robin | 49 | 3 | 0 | 434.8 |
MyAxisDemux_1To2.v |
AXI4-Stream demux, 1→2 | 6 | 3 | 0 | 434.8 |
MyAxisBroadcaster_1To2.v |
AXI4-Stream broadcaster, 1→2 | 2 | 0 | 0 | n/a |
Every file in the table is reproducible from the generators, and CI regenerates and compares them on each run, so the table cannot drift from the RTL unnoticed.
If none of these match your topology, generate a custom one with Option A.
The YAML flow is a front end: it emits Scala that calls the same API described here, so anything the
generator can build can be built directly, and a few things it cannot — an Axi4Config with an
unusual combination of optional signals, for instance — can only be built this way. If your design
is already SpinalHDL, this is the shorter path, because the crossbar becomes an ordinary Component
you wire up rather than a Verilog file you instantiate.
There are three tops, all taking the same AxiZeroConfig: AxiZeroLiteTop when every port is
AXI4-Lite, AxiZeroFullTop when every port is full AXI4, and AxiZeroMixedTop for any mixture,
which inserts the Full↔Lite adapters and width converters for you. The mixed top is the general
case and the other two are narrower, cheaper specialisations.
import spinal.core._
import spinal.lib.bus.amba4.axi.Axi4Config
import axizero._
object MyFabric extends App {
val cpuCfg = Axi4Config(addressWidth = 32, dataWidth = 32, idWidth = 4)
val ramCfg = Axi4Config(addressWidth = 32, dataWidth = 32, idWidth = 5)
// An AXI4-Lite port is a full config with the optional signals turned off.
val regCfg = Axi4Config(
addressWidth = 32, dataWidth = 32,
useId = false, useRegion = false, useBurst = false, useLock = false,
useCache = false, useSize = false, useQos = false, useLen = false,
useLast = false, useResp = true, useProt = true, useStrb = true
)
val cfg = AxiZeroConfig(
masters = Seq(
MasterPort(cpuCfg, FullAxi4, regSlice = true),
MasterPort(cpuCfg, FullAxi4)
),
slaves = Seq(
SlavePort(ramCfg, FullAxi4, baseAddress = 0x80000000L, size = 64 KiB),
SlavePort(regCfg, LiteAxi4, baseAddress = 0xf0000000L, size = 4 KiB)
),
arbitration = QosBased,
maxOutstanding = 4
)
SpinalConfig(targetDirectory = "generated")
.generateVerilog(new AxiZeroMixedTop(cfg))
}Slave-side ID width must leave room for the originating master index — slaveSideIdWidth on the
config gives the rule, which is why ramCfg above is one bit wider than cpuCfg. The constructor
checks the address map for overlap, power-of-two sizes and alignment, so a bad map fails at
elaboration with a message rather than in simulation.
Master ports need not agree on their ID width: the fabric carries the widest of them and a narrower port has its IDs zero-extended on the way in and truncated on the way back, so a CPU with a single ID can sit next to a master that runs four. A design whose masters all declare the same width gets exactly the netlist it did before — the padding is only inserted where the widths differ.
To wire it into a design instead of emitting Verilog, the ports are plain Vec[Axi4], named from
the crossbar's point of view: io.masters are slave interfaces you drive from your masters, and
io.slaves are master interfaces you connect to your slaves.
val fabric = new AxiZeroMixedTop(cfg)
fabric.io.masters(0) << cpu.iBus // your master drives the fabric
fabric.io.masters(1) << cpu.dBus
ram.io.axi << fabric.io.slaves(0).toShared() // fabric drives your slave
regBlock.io.ctrl << fabric.io.slaves(1)VexZeroSoc is a worked example of exactly
this: a four-master SoC built around AxiZeroMixedTop in Scala, with a VexRiscv on two of the
ports. The AXI4-Stream cores in axizero.stream and the standalone adapters in axizero.adapters
— AXI3→AXI4, Full→Lite, register slices, width converters — are instantiated the same way.
Getting the dependency. axiZero is not published to a public repository yet, so there is no
coordinate to depend on. Until there is, use it as a source dependency, or run sbt publishLocal
and depend on "io.axizero" %% "axizero" % "0.4.0" from the local Ivy cache. Note that
AxiZeroUserGen.scala in hw/spinal/axizero/gen/ is written by scripts/axizero.py and
overwritten on every YAML run — it is the generator's output, not a file to edit or copy from.
The configuration file contains a designs list. Each entry generates one Verilog file.
designs:
- name: MySoC
arbitration: round_robin
max_outstanding: 4
fabric_data_width: 64
weights: [3, 1]
masters:
- type: full
addr_width: 32
data_width: 64
id_width: 4
reg_slice: false
slaves:
- base: 0x0000_0000
size: 0x8000_0000
type: full
data_width: 64
reg_slice: false| Key | Type | Default | Description |
|---|---|---|---|
name |
string | required | Output filename (without .v). Must be a valid Verilog module name. |
type |
string | auto | Force lite (all-Lite crossbar) or full (Full AXI4 crossbar). If omitted, inferred from port types: all-Lite ports use the lightweight Lite crossbar; any Full port uses the Full crossbar with automatic adapters. |
arbitration |
string | round_robin |
Arbitration policy when multiple masters contend for the same slave. See Arbitration modes. |
weights |
list[int] | — | One integer per master. Only used with weighted_round_robin. Master i receives weights[i] grants per round. |
max_outstanding |
int | 1 |
Maximum outstanding transactions per slave per direction. See Pipelined vs blocking mode. |
id_threads |
int | 2 |
How many distinct in-flight transaction IDs the pipelined crossbar tracks per master per direction, to keep same-ID transactions in issue order. Ignored when max_outstanding is 1. See Transaction ordering. |
fabric_data_width |
int | max of all ports | Override the internal fabric data width. Width converters are inserted automatically at any port whose data_width differs. See Data-width conversion. |
decode_error_response |
bool | true |
Answer an address that decodes to no slave with DECERR. Set false to restore the older behaviour, where such an address is never acknowledged. See Decode errors. |
Each entry in the masters list defines one slave-facing AXI interface on the crossbar (where you connect your CPU, DMA, etc.).
| Key | Type | Default | Description |
|---|---|---|---|
type |
string | full |
full (AXI4 with IDs and bursts), lite (AXI4-Lite, single-beat, no IDs), or axi3 (AXI3 master — see below). A Lite master gets an automatic Lite-to-Full adapter; an AXI3 master gets an automatic AXI3-to-AXI4 bridge. |
addr_width |
int | required | Address bus width in bits (typically 32). AXI3 limit: ≤ 32. |
data_width |
int | required | Data bus width in bits (32, 64, 128, …). If it differs from fabric_data_width, a width converter is inserted. |
id_width |
int | 4 |
Transaction ID width. Full AXI4 and AXI3 only; ignored for Lite. AXI3 limit: ≤ 4. The crossbar appends ceil(log2(nMasters)) master-index bits internally. |
reg_slice |
bool | false |
Insert a register slice (pipeline stage) on this master port for timing closure. Registers VALID and the payload; READY stays combinational through the slice. |
reg_slice_skid |
bool | false |
Make that slice a full pipe, so the READY the master sees is registered too. Needs reg_slice. Costs one more set of payload registers on AW/W/AR; forward latency and throughput are unchanged. |
When type: axi3, the generator inserts an Axi3ToAxi4 bridge adapter between the AXI3 master port and the AXI4 crossbar fabric. The bridge handles:
- LEN: 4-bit AXI3 burst length (max 16 beats) zero-extended to 8-bit AXI4.
- LOCK: 2-bit AXI3 lock truncated to 1-bit AXI4 exclusive; locked accesses (
2'b10) return SLVERR. - WID: AXI3 write-data ID used to reorder interleaved W beats into AXI4's strict AW-order sequence.
AXI3 constraints (validated by axizero.py):
| Field | AXI3 limit |
|---|---|
addr_width |
≤ 32 bits |
id_width |
≤ 4 bits |
burst length (len) |
≤ 15 (1–16 beats) |
No REGION or QOS fields |
— |
Example:
masters:
- type: axi3
addr_width: 32
data_width: 32
id_width: 4Each entry in the slaves list defines one master-facing AXI interface on the crossbar (where you connect your BRAM, peripheral, etc.).
| Key | Type | Default | Description |
|---|---|---|---|
base |
int | required | Base address. Hex (0xC000_0000) or decimal. Underscores are allowed for readability. |
size |
int | required | Address region size in bytes. Must be a power of 2. The slave occupies [base, base+size). |
type |
string | full |
full or lite. A Lite slave on a Full crossbar gets an automatic Full-to-Lite adapter. |
data_width |
int | required | Data bus width in bits. If it differs from fabric_data_width, a width converter is inserted. |
reg_slice |
bool | false |
Insert a register slice on this slave port. Registers VALID and the payload; READY stays combinational through the slice. |
reg_slice_skid |
bool | false |
Make that slice a full pipe, so the READY the fabric sees is registered too. Needs reg_slice. Same cost and same reasoning as on a master port. |
Address regions must not overlap. The crossbar uses a bitmask decoder: for each slave, bits above log2(size) must match base. Addresses that match no slave are answered by the decode-error responder with DECERR (see decode_error_response, on by default).
| Mode | Key value | Extra keys | Behavior |
|---|---|---|---|
| Round-robin | round_robin |
— | Equal turns among contending masters. No starvation. Default. |
| Fixed priority | fixed_priority |
— | Master 0 (first listed) has highest priority. Lower-priority masters may starve under sustained load. |
| Weighted round-robin | weighted_round_robin |
weights |
Like round-robin, but master i gets weights[i] consecutive grants before yielding. Example: weights: [3, 1] gives master 0 three turns for every one turn of master 1. |
| QoS-based | qos |
— | Arbitrates on AXI AXQOS[3:0]: higher QoS wins. Equal QoS falls back to round-robin. An aging counter increments for each cycle a request waits; once the age exceeds a threshold, it boosts effective QoS to prevent starvation. |
When a port's data_width differs from fabric_data_width, the generator inserts a converter automatically:
-
AXI4-Lite upsize (narrow port → wider bus): data and strobe go on the byte lanes the address selects, and read data comes back off them. One beat in, one beat out; the lane offset is taken from AW/AR and held for its data beat, since W carries no address.
-
AXI4-Lite downsize (wide port → narrower bus): one narrow transaction per chunk of the wide word. Writes go out only for chunks with a strobe set, so a slave with write side effects never sees bytes the master did not name; reads run from the chunk the address names to the end of the word; the worst of the chunk responses comes back. One transaction per direction at a time.
Both apply wherever a Lite port and the bus it meets differ in width — a master or a slave, on the all-Lite fabric or on the mixed one, including a fabric that
internalDataWidthmakes narrower than its ports. -
Full AXI4 upsize (narrow port → wider fabric): SpinalHDL
Axi4Upsizer. Assembles narrow beats into wide beats. -
Full AXI4 downsize (wide port → narrower fabric):
Axi4DownsizerExt(local fork). Splits wide beats into narrow sub-transactions. INCR bursts stay multi-beat for efficiency. FIXED and WRAP bursts are flattened to single-beat sub-transactions with addresses computed internally.
max_outstanding |
Mode | Behavior |
|---|---|---|
1 |
Blocking | One transaction in flight per slave per direction. No FIFOs. Minimal area. |
> 1 |
Pipelined | Per-slave W-route FIFOs, ID-based B/R response routing. Multiple transactions can be in flight simultaneously to different slaves. Required for high-throughput designs. |
Only affects the Full AXI4 crossbar. The Lite-only crossbar is always single-outstanding (blocking).
AXI4 makes two ordering promises that a crossbar has to keep on the master's behalf, and neither falls out of counting work per slave:
- Transactions that share an ID and a direction complete in the order they were issued, whichever slave each went to. Different IDs are unordered.
- Write data follows write address. W has carried no ID since AXI3, so a master's W beats go in the order of its own AWs, and the fabric must steer each burst to the slave its AW went to.
The pipelined crossbar keeps both with the rule the industry uses, single slave per ID: an ID may have work outstanding at only one slave at a time, and a request naming a second one waits. AMD states it in PG059, Arm's NIC-400 calls it a cyclic-dependency avoidance scheme, and Forencich's verilog-axi and PULP's axi_demux both implement it. W gets one rule more, NIC-400's extended write rule: a master may not address a new slave until every write it has already issued has sent its last W beat. Per-ID tracking alone does not cover that, because a master using two IDs could legally owe write data at two slaves at once.
id_threads is the size of the table of live IDs, per master per direction — a small fixed number, the way AMD's, Arm's and Forencich's are (verilog-axi defaults to 2 as well), rather than one entry per ID value. A request whose ID matches no live thread and finds none free waits for one, so the number costs throughput under many-ID traffic and never correctness. id_threads: 1 gives a master one destination in flight per direction, which is already what a constant-ID master gets.
None of this applies in blocking mode, which holds a master to one transaction per direction outright, or to the Lite crossbar, which has no IDs and no bursts.
An address that falls outside every slave's range belongs to no port, and a crossbar that only drives AWREADY/ARREADY for addresses it can place has no way to refuse one it cannot. Left like that, the master holds its request waiting for a handshake that never comes and the port is wedged for good — no error, no timeout, and nothing in a waveform to say which address did it.
By default the fabric answers instead. An internal responder owns every unmapped address, accepts the
handshake and completes the transaction with DECERR: on B for a write, and on every beat of a read
(all len + 1 of them, with RLAST on the last), carrying the requesting master's ID so the response
routes back the way any other does. The master gets its answer and a CPU sees a bus fault it can
report. It costs one small state machine per direction, shared across all masters and slaves.
This is not hypothetical. A single corrupted word in the VexRiscv example SoC's boot image made the
CPU compute a store address of 0x4E4F549B, which decodes to nothing; without a decode-error path
the load/store port then sat with AWVALID unaccepted for 19,948,154 of 20,000,000 simulated cycles.
One bad word became a dead system.
Set decode_error_response: false (decodeErrorResponse = false in Scala) to restore the older
behaviour. DecodeErrorSpec covers both, including a test that pins the unacknowledged failure mode
so it stays documented.
Standalone AXI4-Stream cores use kind: axis in the YAML generator. They do not use masters, slaves, address maps, or memory-mapped arbitration settings.
Common keys:
| Key | Type | Default | Description |
|---|---|---|---|
kind |
string | required | Set to axis. |
core |
string | required | reg_slice, width_adapter, fifo, arb_mux, demux, or broadcaster. |
data_width |
int | required except width adapter | AXIS TDATA width in bits. Must be byte-aligned. |
input_data_width |
int | width adapter only | Input TDATA width in bits. |
output_data_width |
int | width adapter only | Output TDATA width in bits. |
use_keep / use_strb / use_last |
bool | true |
Enable TKEEP, TSTRB, and TLAST. Packet mux/demux require use_last: true. |
id_width / dest_width / user_width |
int | 0 |
Sideband widths. use_id, use_dest, and use_user default to true when the matching width is non-zero. |
Core-specific keys:
| Core | Extra keys | Behavior |
|---|---|---|
reg_slice |
none | One-stage ready/valid register slice for timing closure. |
width_adapter |
input_data_width, output_data_width |
Packs or unpacks byte streams between different TDATA widths. |
fifo |
depth |
Elastic FIFO storing full AXIS beats, including enabled sidebands. depth must be at least 2. |
arb_mux |
inputs, arbitration |
N-to-1 packet arbiter/mux. arbitration is round_robin or fixed_priority; ownership is held until TLAST. |
demux |
outputs |
1-to-N packet demux. The explicit select input is sampled at packet start and held until TLAST. |
broadcaster |
outputs |
1-to-N synchronous broadcaster. A beat is accepted only when every output accepts it. |
Measured xc7a100t datapoint for the Arty AXIS smoke datapath, which instantiates all six utility cores (AxiStreamArbMux, FIFO, register slice, 32-to-8 and 8-to-32 width adapters, demux, broadcaster):
| Design | LUTs | FFs | LUTRAM | BRAM | DSP | Clock | Fmax note |
|---|---|---|---|---|---|---|---|
AxiStreamArtySmoke submodule |
175 | 208 | 20 | 0 | 0 | 100 MHz | Routed Arty AXIS build (Vivado 2025.2, SpinalHDL 1.14.2) WNS 0.465 ns, equivalent single-clock margin to about 105 MHz |
Example:
designs:
- name: MyAxisFifo
kind: axis
core: fifo
data_width: 32
depth: 16
use_keep: true
use_last: true
- name: MyAxisMux_2To1
kind: axis
core: arb_mux
data_width: 32
inputs: 2
arbitration: round_robin
use_keep: true
use_last: true
- name: MyAxisDemux_1To2
kind: axis
core: demux
data_width: 32
outputs: 2
use_keep: true
use_last: trueGenerated ports are renamed to AXI4-Stream style: s_axis_t* and m_axis_t* for single-input/single-output cores, s0_axis_t*/s1_axis_t* for vector inputs, and m0_axis_t*/m1_axis_t* for vector outputs. The demux selector is emitted as select.
Full example with all options: scripts/example.yaml.
Packet-pipeline example: scripts/examples/axis_packet_pipeline.yaml.
Verification details: docs/axis-stream-verification.md.
Requires Verilator 5.x on Linux or WSL.
sbt test245 tests pass across 32 suites:
For the focused AXI4-Stream loop, including lint, YAML generator smoke tests, and cocotbext-axi generated-RTL tests:
python3 scripts/run_sim.py axis| Suite | Tests | Description |
|---|---|---|
LiteCrossbarSpec |
6 | AXI4-Lite crossbar: arbitration, address decode, WRR |
LiteSameCycleResponseSpec |
2 | AXI4-Lite slave that raises RVALID in the same cycle as ARREADY: the Full→Lite adapter cannot capture the response ID a cycle late, and a master that reads such a slave — where the ordering table's claim and release land on one cycle — can still reach another slave afterwards |
PipelinedCrossbarSpec |
8 | Full AXI4: bursts, back-pressure, outstanding transactions |
ChannelSkewSpec |
3 | The channel skews AXI4 permits and no other slave model here produces: a slave that raises WREADY before AWREADY, so a W beat reaches it ahead of its address; a burst whose data all arrives early, where the forwarding path has to close again so the next burst's data is not swallowed by the slave still holding the first address; and a Lite slave whose answer lands on the cycle the next address is accepted |
BlockingWriteBoundarySpec |
3 | Where one write's data ends and the next begins in the blocking engines, which both crossbars carry their own copy of: a slave holding a write whose data is complete while its response is still outstanding, and a write whose data reached the slave before its address. In both the master may legally offer the next write's data, and the forwarding path has to be shut or that data is written under the previous address — at a slave it was never addressed to |
AxiMultiIdGenSpec |
8 | The synthesizable multi-ID generator that goes on the boards, checked against a crossbar with two RAMs: that a clean run really did have several IDs in flight and really did ask to move a live ID between slaves, that it catches a dropped byte lane, and — with a deliberately reordering RAM model — that it catches two same-ID reads answered out of order. A self-checking generator that cannot fail is worth nothing on hardware, so each of its checks is shown failing. Three more cover the write side, which the read-back cannot reach at all: a clean run whose extra writes go to an address no slave claims, answered DECERR by the fabric's own decode-error responder, with that answer required as evidence; a model that exchanges two BIDs between waiting IDs, caught; and the same model labelling every response correctly, clean. The control is also where the limit is written down — a swap between two OKAY responses stays invisible, because the two traces are the same bits |
Axi4OrderingProbeSpec |
6 | The ordering probe that goes on the boards, driven directly so it can be shown failing: the single-slave-per-ID rule broken with every response still in issue order, caught (a check that waited for a wrong answer would call that run clean); a request admitted on the cycle the last outstanding one retires, correctly allowed; a crossing request held against two live bursts recorded as the evidence it is; one-burst-at-a-time traffic claiming no evidence it did not earn; reads and writes judged apart; and the same violation on the write channels |
MultiIdOrderingSpec |
2 | Randomised multi-ID traffic — four IDs, two slaves, both directions, random response latency and master back-pressure — against a per-ID scoreboard. The only coverage of a master that varies its ID; every other master here, VexRiscv included, drives a constant one. B carries nothing but an ID and a response, so a swap between two busy IDs is invisible unless the two slaves answer differently: one slave returns SLVERR, which gives every write response an identity the scoreboard can hold it to, and the second test proves that scoreboard catches an exchanged BID and a same-ID response reordering while passing a correct trace |
MixedCrossbarSpec |
4 | Full↔Lite adapters, mixed address maps |
ArtySpec |
5 | Sequence matching the Arty A7 hardware tests (T4, T5, T6, T9, combined) |
IpifWriteSpec |
5 | IPIF-style slaves (Xilinx GPIO/UART-Lite require AW+W simultaneous), blocking and pipelined modes |
WidthConverterSpec |
6 | Full AXI4 width conversion: 32→64 upsize, 64→32 downsize, 32→64→32 passthrough; single-beat, burst, routing |
BurstTypeSpec |
6 | Downsizer burst types: INCR baseline, FIXED 1-beat and 2-beat overwrite, WRAP aligned, WRAP 4-beat, WRAP with actual wrap-around |
ArbitrationSpec |
7 | FixedPriority and WeightedRoundRobin: contention ordering, throughput proportionality, data integrity |
LiteWidthPathsSpec |
7 | Every other place an AXI4-Lite port changes width: a narrow slave on the Lite fabric (both halves written, a strobed half going out alone, the worst response of the halves returned), a fabric narrower than its master, a narrow Lite master and a narrow Lite slave on the mixed fabric, and a slave with a narrower address bus than the crossbar. Every address is chosen so a converter using the low lanes regardless would fail |
ConfigSweepSpec |
47 | Elaborates 30 configurations no simulation suite builds — every arbitration policy against one to three masters, 8- to 128-bit ports on one fabric, fabrics narrower than their ports, AXI3 + Full + Lite masters with register slices everywhere, mismatched address and ID widths, observed masters — and checks 17 invalid configurations are refused with a message naming the problem |
Axi4ToAxi3Spec |
5 | The AXI4→AXI3 shim VexZeroSoc uses: every field mapped (AXI4 exclusive is AXI3 01), what a missing field is driven to, a burst out to AXI3 and back through Axi3ToAxi4Adapter, and WID naming its own burst whether the data trails the address or leads it |
AxiStreamArtySmokeSpec |
1 | The self-checking AXI4-Stream design the Arty build runs, simulated to its status word: every check bit set, the pass bit latched and still set 200 cycles later |
RegSliceAndLiteWidthSpec |
10 | Register slices (Full + Lite, master/slave/both), AXI4-Lite width conversion (16→32 upsizing) including byte-lane placement and strobe preservation |
MixedIdWidthSpec |
2 | Masters declaring different numbers of IDs, so the narrow port's requests cross Axi4IdWidener. Every test checks the ID a response came back under, not just the data: the padding is constant zero outward, so the truncation back is exact only if the fabric returns the same zeros. No other root test builds a mixed-ID-width config — the widener's only exercise was through the VexRiscv example, a separate sbt project |
RegSliceSkidSpec |
6 | What regSliceSkid actually buys, rather than inferring it from timing closure: a stalled plain slice accepts one beat and a skid two; the plain slice's READY is combinational from the far side and the skid's is not, shown by stalling and releasing on one cycle; 400 beats survive random back-pressure through both; latency stays one cycle and throughput full for both; B and R keep the plain slice they are given; and the Lite slice behaves the same way. The skid buffer is on the AW/W/AR path of every registered master on both boards, and until now no test told the two apart |
PipelinedArbitrationSpec |
9 | Pipelined FixedPriority, WRR, and QoS: contention, concurrent bursts, data integrity |
NarrowPortSpec |
6 | Narrow ports: 32→16 downsizing, 16→32 upsizing, mixed Full+Lite concurrent traffic |
QosCrossbarSpec |
5 | QoS arbitration: higher AWQOS/ARQOS wins (blocking + pipelined), equal-QoS round-robin tie-break, aging anti-starvation |
DecodeErrorSpec |
17 | Decode errors: DECERR on B and on every read beat, blocking and pipelined, burst reads, W beats sunk, ID routing back to the right master, Lite, all four arbitration policies, two masters erroring at once, AXQOS deciding which of two contending masters the responder serves first (both orderings, reads and writes), and the unacknowledged failure mode with the responder disabled |
QosStressShortSpec |
1 | Short 4-master QoS stress: distinct patterns (sequential, reverse, sparse, random short bursts), concurrent traffic, end-state validation |
Axi3ToAxi4Spec |
5 | AXI3→AXI4 bridge: single-beat, INCR burst, write interleaving (WID reorder), locked→SLVERR, multiple outstanding |
Axi3MixedCrossbarSpec |
5 | Axi3Mode auto-adapter: single-beat to full slave, single-beat to Lite slave, routing to both, 4-beat INCR burst, register-sliced path |
Axi4ProtocolCheckerSpec |
17 | The protocol checker itself: clean traffic of every burst shape stays silent, each rule fires on its own injected violation, a burst ending exactly on a 4 KiB boundary does not, and losing track reports overflow rather than a violation |
ResponseStabilitySpec |
8 | Response channels and ordering under back-pressure: B and R payloads hold while the master stalls and a second slave answers, an R burst is not interleaved by another slave, a slave that bubbles mid-burst produces no phantom beats, both writes get their response in blocking mode, same-ID reads to two slaves come back in issue order, a second write to another slave does not take the first write's data, a same-ID write to another slave waits for the first response, and a read with a new ID waits for a free thread and then completes |
AxiSatGenSpec |
6 | The saturating self-checking traffic generator: a full run against a working RAM reports no errors, its own traffic passes the protocol checker, a RAM that drops a byte lane is caught, it stays off the bus while disabled, a fabric that stops answering is reported as a stall rather than as a pass, and a master straddling two slaves under B/R back-pressure neither hangs nor misreads |
AxiStreamCoreSpec |
17 | AXI4-Stream utility cores: register slice, width adapter, FIFO, packet arb-mux, packet demux, broadcaster, sparse TKEEP/TSTRB/TLAST edge cases, TID/TDEST/TUSER propagation |
python3 scripts/test_axizero.py32 tests covering the YAML front end's Scala emitters and its config validator. They
import scripts/axizero.py directly and never invoke sbt, so a mistake in a port
template or a validation rule is caught in milliseconds instead of at the far end of
an elaboration. Run in CI ahead of the end-to-end generate.
Tests the generated Verilog files directly using cocotbext-axi bus functional models.
# requires: pip install cocotb cocotbext-axi
python3 sim/cocotb_gen/run_all.py # all cocotb suites
python3 sim/cocotb_gen/run_all.py lite # MyLite_1M4S.v only
python3 sim/cocotb_gen/run_all.py full # MyFull_2M2S.v only
python3 sim/cocotb_gen/run_all.py wrr # MyLite_2M2S_WRR.v only
python3 sim/cocotb_gen/run_all.py qos # MyFull_2M2S_QoS.v only
python3 sim/cocotb_gen/run_all.py ipif # MyLite_1M4S.v IPIF slave only
python3 sim/cocotb_gen/run_all.py axis # generated AXI4-Stream cocotb suite46 tests pass across 6 suites:
| Suite | DUT | Tests | Description |
|---|---|---|---|
lite |
MyLite_1M4S.v |
7 | AxiLiteMaster → 4-slave crossbar: single R/W, address routing, sequential writes, multi-slave pattern, overwrite isolation, 60× random, byte and half-word strobes through every slave |
full |
MyFull_2M2S.v |
14 | AxiMaster → 2-slave crossbar: single R/W, address routing + isolation, 16-beat burst, 64-beat burst (AWLEN=63), alternating slaves, 40× random; WRAP and FIXED bursts (checked in the slave RAM, not only by read-back), sub-word strobes and narrow AxSIZE, 2/3/8/32-beat bursts, unaligned starts and ends, W ahead of AW and AW ahead of W, every handshake stalling mid-burst, and AxLOCK/AxCACHE/AxPROT arriving at the slave as sent |
wrr |
MyLite_2M2S_WRR.v |
6 | 2-master WRR crossbar: dual-master R/W, address routing, concurrent bandwidth, no starvation, concurrent different slaves, 80× random |
qos |
MyFull_2M2S_QoS.v |
7 | 2-master QoS crossbar: dual-master R/W, address routing, higher QoS wins contention, equal-QoS round-robin, aging anti-starvation, QoS read priority, eight reads with distinct IDs outstanding together (the test asserts they overlapped) |
ipif |
MyLite_1M4S.v |
4 | IPIF slave compatibility: strict IpifRam model requires AWVALID+WVALID simultaneously, routing unaffected |
axis |
generated AXI4-Stream cores | 8 | cocotbext-axi stream BFM tests for reg slice, width adapter, FIFO, arb-mux, demux, broadcaster |
Every other test here is value-based: drive traffic, check the numbers that come back. That catches an interconnect which loses or corrupts data. It is blind to one that returns every right answer while breaking AXI4 on the way — changing an address while AWVALID is waiting for AWREADY, putting WLAST on the wrong beat, answering a response nobody asked for. A tolerant slave hides it; the next slave, on the next board, does not.
Axi4ProtocolChecker watches instead of asking.
It is an Area, not a Component: it snoops a bus in place, reads only VALID, READY and payload,
and drives nothing, so adding one cannot change what the design does.
val fabric = new AxiZeroMixedTop(config)
val check = Axi4ProtocolChecker(fabric.io.slaves(0), label = "s0")
io.violation := check.any // one wire, one spare LEDEach rule sets one bit of sticky, held until reset, so a violation that happens once in a
ten-minute run is still there at the end. any is their OR. The bit numbering is part of the
interface — firmware and test benches decode it by index — so it is append-only.
| # | Rule | Fires when |
|---|---|---|
| 0–4 | {AW,W,B,AR,R}_UNSTABLE |
VALID dropped before READY, or the payload moved while stalled |
| 5 | W_BEATS_OVERRUN |
the write burst carried more beats than its AWLEN+1 |
| 6 | W_LAST_MISPLACED |
WLAST arrived before the burst reached AWLEN+1 beats |
| 7 | R_BEATS_OVERRUN |
the read burst carried more beats than its ARLEN+1 |
| 8 | R_LAST_MISPLACED |
RLAST arrived on a beat that is not the ARLEN'th |
| 9 | B_UNEXPECTED |
write response with no outstanding write of that ID |
| 10 | R_UNEXPECTED |
read data with no outstanding read of that ID |
| 11–12 | {AW,AR}_BURST_RESERVED |
AxBURST is the reserved encoding 2'b11 |
| 13–14 | {AW,AR}_WRAP_BAD_LEN |
a WRAP burst whose length is not 2, 4, 8 or 16 |
| 15–16 | {AW,AR}_4K_CROSS |
an INCR burst crosses a 4 KiB boundary |
| 17–18 | {AW,AR}_LEN_EXCEEDS_MAX |
a burst longer than the configured maxBurstLen |
Write address and write data are queued independently and judged when both halves arrive, because
AXI4 lets a master issue write data before its address. Read beats are counted per ID, because AXI4
lets read data for different IDs interleave — a single counter would fire on legal traffic. Losing
track of either sets overflow, which is not a rule: it says the checker stopped being able to see
everything, so silence from that port stopped being evidence.
It does not check X or unknown values (those need simulation semantics and mean nothing in hardware), exclusive access, the low-power interface, or timing. It is a protocol checker, not a liveness checker: a bus that stops entirely violates nothing here.
See ADR 003 for why this is written here rather than taken from a vendor or replaced with formal.
AMD ships a 160-rule axi_protocol_checker, and it is a Vivado block-design IP. It cannot be
instantiated in a plain RTL project, it cannot run in SpinalSim, and it cannot be built for Altera
at all — which rules out every design this interconnect is actually validated on. The VexZero SoC is
one generated Verilog file with no block design, built for both Vivado and Quartus. This checker is
ordinary SpinalHDL, so the same source runs in simulation, on Artix-7 and on Agilex 5.
Turning it on for the first time, on a loaded crossbar, reported s0: AR_UNSTABLE — the read
address to the RAM slave changing while ARVALID was high and ARREADY low:
[ 574] PAYLOAD MOVED: 0x80008380/id 4/len 15 -> 0x80001020/id 2/len 7
The arbiter re-evaluated its grant every cycle from the live request vector. A master that raised its request while an already-granted address was waiting for AxREADY could win the next cycle and swap the address out from under the slave. AxVALID never dropped, so no handshake was lost; both requests were real and both were issued eventually, which is exactly why 133 value-based tests and six hardware suites all passed over it. A slave that latches the address before asserting READY — a normal thing to do for timing — latches the wrong one.
The fix is lockGrant in both crossbars: the grant is held from the cycle AxVALID is asserted until
AxREADY. It costs nothing in throughput, because the held master was going to be served in that
cycle anyway. All six arbitration sites (blocking and pipelined, read and write, full and Lite) were
affected.
protocolCheck = true in VexZeroSocConfig puts a checker on every fabric port — master side and
slave side — and brings the verdict out on io.busViolation. It is on by default in both board
wrappers, where it is worth the most: a bitstream runs the same traffic for hours at 100 MHz, far
more of it than any simulation, and the checkers turn all of it into one reported letter. See the
B/b byte in the Arty UART line.
Test (sbt vexZero/test) |
Description |
|---|---|
the crossbar keeps AXI4 while the boot firmware runs on it |
every fabric port silent through a full boot |
the crossbar keeps AXI4 with a cached CPU and a host contending for the same RAM |
16-beat bursts, several outstanding, two masters on one slave |
an illegal burst on the host port lights the verdict the board reports |
the control: one 4 KiB-crossing read has to reach io.busViolation, or the two silences above prove nothing |
A complete RISC-V system built around the interconnect, under hw/examples/vexriscv/. Where the test suite drives the crossbar with bus functional models, this boots a real CPU through it — VexRiscv, which is also written in SpinalHDL, so the whole SoC elaborates from one Scala build.
VexRiscv IBus (Axi4ReadOnly) ─┐ ┌─ S0 on-chip RAM (AXI4 full)
├─ AxiZeroMixedTop ──┼─ S1 GPIO (AXI4-Lite)
VexRiscv DBus (Axi4Shared) ──┘ 2 masters x 3 └─ S2 system ctrl (AXI4-Lite)
| Slave | Base | Size | Port | Registers |
|---|---|---|---|---|
| S0 on-chip RAM | 0x8000_0000 |
8 KB | AXI4 full | preloaded with the boot image |
| S1 GPIO | 0xF000_0000 |
4 KB | AXI4-Lite | 0x00 led RW, 0x04 switch RO |
| S2 system control | 0xF001_0000 |
4 KB | AXI4-Lite | 0x00 cycles RO, 0x04 charOut WO, 0x08 status RW, 0x0C result RW |
The example is a separate sbt project (vexZero) that needs the pinned VexRiscv submodule.
The root project neither aggregates nor depends on it, so a checkout without the submodule
builds and tests exactly as before.
git submodule update --init third_party/VexRiscv
sbt vexZero/test # boot the SoC in SpinalSim
sbt "vexZero/Compile/runMain vexzero.gen.VexZeroSocGen" # -> generated/vexriscv/VexZeroSoc.v| Test | Description |
|---|---|
boot firmware completes on the pipelined crossbar |
maxOutstanding = 4 |
boot firmware completes on the blocking crossbar |
maxOutstanding = 1 |
the firmware image is a valid RV32I encoding |
pins the assembled program |
VexZeroProtocolSpec (3 tests) |
every fabric port judged against AXI4 itself; see Protocol checking |
The boot firmware is assembled by vexzero.Rv32,
a small RV32I encoder, so the example runs on a plain JDK and no binary is tracked in git. Its
output for the whole program is byte-identical to riscv64-unknown-elf-as.
The firmware fills and sums 16 words of RAM, writes the checksum to the GPIO LED register, reads
the switch register back, emits OK and a newline a byte at a time, parks the cycle counter in
RAM, then publishes the result and a done marker. Reaching the marker means fetch, load, store
and both full→Lite adapters all worked; the checked values pin down what was actually moved, so a
crossbar that merely keeps the bus alive cannot pass.
The example is not simulation-only — it runs on an Arty A7-100T. A board has no wires back to a
test runner, so the wrapper
(VexZeroArty) reruns the SoC's checks in
hardware and reports the verdict two ways: LD4–LD7 (done, pass, fail, heartbeat) for a human, and a
12-byte line on the USB-UART at 115200 8N1, repeated every ~0.67 s, for the runner.
| Line | Meaning |
|---|---|
VZPDRCLBGS5 |
every check passed; the switch nibble read back over AXI4-Lite was 0x5 (the value varies with the switches) |
VZFdrclBGS0 |
the CPU never finished — held in reset, or hung |
VZFDRCLbGS5 |
the program computed everything correctly and the bus broke AXI4 while it did |
VZFDRCLBgS5 |
a traffic generator read back data it had not written |
Upper case is a passing check: P overall, D done marker, R result value, C character
stream, L LED register, B bus protocol, G traffic
generators, S the AXI4-Stream island. A check the build
leaves out reads upper case, so the line has the same shape whatever the configuration and one
parser reads every variant. The last byte is the switch nibble the firmware read back through the
AXI4-Lite GPIO. It is reported because the result check is checksum + switches, so with every
switch down a Lite read that always returned zero would pass; the runner says as much when it sees a
zero nibble.
The same three verdict bits are also readable over the bus, at sysCtrl + 0x10, because a board
whose only link to a host is a debug cable has no serial line to print them on. That the host then
reads them across the crossbar under test is deliberate: a fabric broken badly enough to hide its
own verdict cannot report a pass either.
Bit 3 of that word is the protocol checkers' overflow flag, and it fails the run. Each checker
tracks a bounded number of outstanding transactions and says so when it runs out; a run that
overflowed has a checker whose silence means "stopped counting", not "saw nothing". It now fails the
run on both boards — it is folded into the B the Arty prints, and the DE25-Nano runner, which reads
the whole word, names which of the two it was — so a board that overflowed is never mistaken for a
board that was watched and found clean.
python hw/vivado/arty_a7/run_vexzero_test.py # generate, build, program, verify
python hw/vivado/arty_a7/run_vexzero_test.py --skip-build # reprogram and re-read only
python hw/vivado/arty_a7/run_vexzero_test.py --design all # every VexZero build in turnThe runner generates the netlist with sbt, builds the bitstream, programs the board over JTAG with
xsdb, then decodes the serial line. There is no MicroBlaze in this design, so unlike the crossbar HW
tests it needs no mb-gcc — the firmware is already inside the bitstream.
It does not stop at the first report line. Every verdict the board reports is sticky — a
generator error, a protocol violation and a checker that lost track all latch and never clear — so a
fault that first appears a second in is real, is still being reported, and would simply never have
been looked at; and the first report line is the least traffic the generators will ever have run
behind them. So the runner keeps reading for --observe seconds (default 5) and requires every
later report to match the first, printing both and failing the run if one changes. The DE25-Nano
runner does the same thing with the verdict word it reads over JTAG-AXI.
Result — the board reports VZPDRCLBGSF. The B is the protocol checker
verdict: five checkers, one on every fabric port, saw no AXI4 violation in the whole run. The
trailing nibble is the switch register read back over AXI4-Lite (all four slide switches up on this
run), so the result check ran against checksum + 15 rather than against zero. Timing closes:
| Resource | Used | Available | Utilisation |
|---|---|---|---|
| Slice LUTs | 2087 | 63400 | 3.29% |
| Slice registers | 2654 | 126800 | 2.09% |
| Block RAM tiles | 3 | 135 | 2.22% |
| DSPs | 0 | 240 | 0.00% |
Test conditions: Vivado 2025.2, xc7a100tcsg324-1 (speed grade -1), default synthesis and
implementation strategies, one 100 MHz clock domain, WNS +0.472 ns (105.0 MHz Fmax). The figures
cover the whole SoC — VexRiscv, the axiZero crossbar, the 8 KB RAM, both peripherals, the UART
reporter and the five protocol checkers — not the crossbar alone; see
crossbar-only resource usage for that.
The checkers are most of the difference from the 1140 LUTs / 1161 registers this design used before
they existed: they roughly double it, which is what watching five ports with per-ID tracking costs
and is the reason protocolCheck is a switch rather than always on. Their cost in Fmax is small but
it is not nothing, and it took work to get there: the first build with checkers came in at +0.027
ns, with the critical path running from a register slice's stored AWADDR through the burst
arithmetic and the 19-way OR into the sticky register's clock enable. Registering the rule outputs
before they reach sticky ends that path at the rules; the bit only records whether something ever
happened, so a cycle of latency on it changes nothing. pipelineInputs later took the checker off
the fabric's critical path entirely — see timing closure on the loaded
builds.
A firmware that reaches a done marker proves the crossbar moves the right bytes. It does not say
what the crossbar costs. For that the example carries a second board top level,
VexZeroBenchArty, which runs
Dhrystone 2.1 — a real program with a real working set — and prints the benchmark's own console
verbatim over the USB-UART.
The binary is the prebuilt RV32I dhrystoneO3.hex that ships inside the pinned VexRiscv submodule,
read into the RAM by HexImage. Nothing is
recompiled and no cross compiler is needed, and because it is the image VexRiscv's own regression
suite runs, the numbers are comparable to that suite's. Its console MMIO lives at the addresses that
testbench implements, so the SoC answers there through a fourth slave
(VexZeroBenchIo), added to the address
map for this build only:
| Slave | Base | Size | Port | Registers |
|---|---|---|---|---|
| S3 benchmark console | 0xF00F_F000 |
4 KB | AXI4-Lite | 0xF00 putchar WO, 0xF10 cycles RO, 0xF20 exit WO, 0xF24 error WO |
The character path backpressures the whole way: a full FIFO stalls the CPU's store rather than dropping a byte, so a 115200-baud line costs time but never corrupts a result line. Dhrystone prints nothing inside its timed loop, so that stall never reaches the measurement.
sbt "vexZero/testOnly *VexZeroBenchSpec" # simulate the benchmark
python hw/vivado/arty_a7/run_vexzero_bench.py # generate, build, program, stream
python hw/vivado/arty_a7/run_vexzero_bench.py --skip-build # reprogram and re-read onlyThe program runs once at configuration, so the runner opens the serial port before programming the board. It then re-runs Dhrystone's own self-checks on the captured text — every printed value against the "should be:" line the benchmark prints beside it — and recomputes the score from the cycle count rather than trusting the line the firmware printed.
Results. The board and the simulation agree on the cycle count exactly — 328,048 cycles for 200 runs — which is the strongest statement available here that the netlist behaves as simulated:
| Timed loop (200 runs) | Cycles/run | Dhrystones/s | DMIPS/MHz | |
|---|---|---|---|---|
| Arty A7-100T @ 100 MHz | 328,048 | 1640.2 | 60,967 | 0.347 |
SpinalSim, max_outstanding = 4 |
328,048 | 1640.2 | 60,967 | 0.347 |
SpinalSim, max_outstanding = 1 |
331,848 | 1659.2 | 60,269 | 0.343 |
All 20 of Dhrystone's self-checks pass in every case. Pipelined mode is 1.2% faster than blocking mode, which is what a single-issue RV32I with one load or store in flight should show: there is almost no concurrency for the crossbar to exploit, so this measures the fabric's overhead rather than its throughput, and the overhead is small. That 1.2% is not a throughput result, and what Dhrystone does not test says why.
What the crossbar actually saw during a run, counted at its master ports in simulation (AR→R and
AW→B, in aclk cycles, pipelined mode):
| Master port | Reads | Read latency | Writes | Write latency |
|---|---|---|---|---|
| M0 instruction fetch (AXI4 full) | 153,191 | 3.04 cycles | — | — |
| M1 load / store (AXI4 full) | 17,953 | 3.00 cycles | 18,796 | 3.09 cycles |
Three cycles is the floor for this path — register slice in, arbitration and decode, register slice
out — and the measured averages sit on it, so under this load the crossbar never queues. One
instruction is fetched every 3.30 cycles, and the ~1.4 cycles between the fetch latency and that
interval is the uncached IBusSimplePlugin waiting on the fabric rather than the fabric waiting on
anything. A cache would close most of that gap; this configuration deliberately has none.
Test conditions: Vivado 2025.2, xc7a100tcsg324-1 (speed grade -1), default strategies, one 100 MHz
clock domain, 32 KB on-chip RAM (the benchmark needs more than the 8 KB verdict build), Dhrystone
2.1 compiled -O3 without the register attribute, 200 runs, DMIPS/MHz = 10^6 x runs / (cycles x 1757).
| Resource | Used | Available | Utilisation |
|---|---|---|---|
| Slice LUTs | 1292 | 63400 | 2.04% |
| Slice registers | 1282 | 126800 | 1.01% |
| Block RAM tiles | 9 | 135 | 6.67% |
| DSPs | 0 | 240 | 0.00% |
WNS +0.593 ns (106.3 MHz Fmax). The figures cover the whole benchmark SoC — VexRiscv, the axiZero crossbar, 32 KB of RAM, three slaves and the UART — not the crossbar alone.
Dhrystone is the right test for does a real program run correctly across the fabric. It is the
wrong test for is the fabric any good, and the difference is large enough to be worth measuring
rather than asserting.
VexZeroProfileSpec counts what
the crossbar was actually asked for during a run, at its master ports:
| Uncached | Cached, 4 KiB D$ | Cached, 512 B D$ | |
|---|---|---|---|
| Transactions | 189,937 | 21,190 | 24,454 |
| Beats carried | 189,937 | 37,921 | 64,033 |
| Instruction fetch | 153,187 × 1-beat | 2,328 × 8-beat | 2,328 × 8-beat |
| Data reads | 17,953 × 1-beat | 63 × 8-beat | 3,327 × 8-beat |
| Data writes | 18,797 × 1-beat | 18,797 × 1-beat | 18,797 × 1-beat |
| Peak in flight, any port | 2 | 4 | 4 |
| Cycles with any request | 36.5% | 8.2% | 7.9% |
| Cycles with two masters requesting | 1.1% | 0.0% | 0.0% |
| DMIPS/MHz | 0.347 | 0.687 | 0.526 |
Coverage is maximal and pressure is minimal. With no caches every instruction and every load or store crosses the crossbar — bus traffic is the whole program — but all 189,937 transactions are single beats, never more than two are in flight, and the two masters want the bus in the same cycle in 1.1% of cycles. The measured 3.00–3.09 cycle latencies sit exactly on the registered-path floor, which is another way of saying the crossbar never queued. Bursts, outstanding depth and arbitration under contention are simply not on trial.
Turning the caches on (cachedCpu = true) buys the bursts — each miss becomes an 8-beat INCR line
refill — and nearly doubles DMIPS/MHz, but it makes the pressure problem worse, not better: the
hit rate is so high that the fabric goes idle 92% of the time and contention falls to zero.
Two things about the load/store port are worth stating plainly, because they bound what any workload on this CPU can prove:
- Cache size decides whether that port is a bus master at all. Dhrystone's data working set
fits inside 4 KiB with room to spare, so a 4 KiB data cache misses 63 times in a whole run.
Sized to miss (
dCacheSize = 512), the same port issues 3,327 line refills instead — a 50× difference in how hard it works the fabric, for a 24% drop in score. The stress test below uses the small one for exactly that reason. - No configuration makes this CPU write in bursts. VexRiscv's data cache has no dirty bit, so it is write-through by construction, and the write-aggregation buffer that could merge stores exists only on its BMB port and emits one wide beat rather than a burst. All 18,797 stores are single beats in every column above. Write bursts have to come from a different kind of master — in a real system a DMA engine, and here the traffic generator below.
So no configuration of this CPU loads the interconnect. The pressure has to come from somewhere else.
VexZeroStressSpec supplies it. The
CPU runs the same unmodified Dhrystone with caches on, and a third master —
HostTraffic, on the same port a debug
cable occupies on the DE25-Nano — drives the port as hard as it will go at the same RAM slave the
CPU is fetching from: 16-beat INCR bursts, several outstanding, and AW deliberately running ahead
of W so write data reaches the fabric after its address and has to be routed from a queue.
sbt "vexZero/testOnly *VexZeroProfileSpec" # what Dhrystone asks for, uncached vs cached
sbt "vexZero/testOnly *VexZeroStressSpec" # the same SoC with a third master saturating it
Every host burst carries the value it expects, so this is a checker and not only a load: a crossbar that mis-routed a beat, dropped one or returned another master's data fails here rather than merely running slowly.
Both columns below use the 512 B data cache, so the comparison isolates what the third master adds rather than mixing in a cache-size change:
| Dhrystone alone | Dhrystone + host traffic | |
|---|---|---|
| Cycles with any request | 7.9% | 100.0% |
| Cycles with two or more masters requesting | 0.0% | 80.8% |
| Longest burst | 8 beats | 16 beats |
| Transactions / beats | 24,454 / 64,033 | 90,967 / 1,128,200 |
| Instruction-fetch latency | 10.0 cycles | 40.2 cycles |
| Data-read latency | 10.0 cycles | 50.1 cycles |
| Host read-burst latency | — | 42.4 cycles |
| Host beats checked | — | 1,064,161, 0 mismatches |
| Dhrystone self-checks | 20/20 pass | 20/20 pass, exit 0 |
Latency rising from 10 to 40–50 cycles is the point: under Dhrystone alone the crossbar never queued, and here it queues constantly, while the program on top still computes every one of its results correctly.
Pipelined against blocking, under real load. With the fabric at 100.0% occupancy the two modes
move almost the same total traffic — 90,967 transactions in 1,126,518 cycles pipelined against
80,713 in 969,991 blocking, which is 3.0% fewer transactions per cycle for the pipelined path.
What changes is who gets the bandwidth: the pipelined path completes 18.2% more host bursts and
costs Dhrystone 20.5% in timed cycles. That is not a defect. This load is bandwidth-bound at a
single RAM slave, and allowing more transactions outstanding to one slave reorders who waits rather
than creating bandwidth that is not there — the host master, which always has another burst ready,
is simply better placed to use the extra slots than a CPU that stalls on each miss. The pipelined
path's advantage is concurrency across different slaves, which is what
PipelinedArbitrationSpec measures directly.
Test conditions: SpinalSim + Verilator, 3-master × 4-slave AxiZeroMixedTop, round-robin, 64 KB
on-chip RAM, VexRiscv RV32I with a 4 KiB one-way instruction cache and a 512 B data cache, both
32-byte lines, Dhrystone 2.1 -O3, 200 runs, master register slices as full pipes. The host reads
the program text back from where it is being fetched and round-trips a pattern through the unused
top 16 KB of RAM. Latency and beat counts are measured at the external master ports, because
latency is what the master waited for, register slices included; occupancy and contention are
measured one level in, at the crossbar's own master inputs, because that is where the arbiter makes
its choice. Measured outside the slices the same run reads as 60% contended rather than 81%: a
slice accepts an address beat the cycle it appears, so the master's VALID drops again immediately
and a request that has only just reached the arbiter looks finished.
QoS only means anything when there is not enough bus to go round, which is what the load above
supplies. Both masters are given a rank — the CPU's is declared by the SoC (cpuQos), because
VexRiscv has no QoS output of its own — and the same experiment is run with the ranking swapped, so
nothing but the QoS values can explain the difference.
The result depends on something no unit test surfaces. The crossbar boosts a waiting master's
effective priority by one per cycle, saturating at 15
(Axi4Crossbar.scala:89), so a master that
waits longer than its own distance from 15 arrives at the arbiter with the gap already erased:
| Burst | Ranking | CPU grants | Host grants | Host share | Host latency |
|---|---|---|---|---|---|
| 4 beats | CPU 12, host 2 | 22,290 | 58,949 | 72.6% | 11.7 cycles |
| 4 beats | CPU 2, host 12 | 14,564 | 65,378 | 81.8% | 11.9 cycles |
| 16 beats | CPU 12, host 2 | 6,784 | 17,853 | 72.5% | 42.2 cycles |
| 16 beats | CPU 2, host 12 | 6,702 | 17,867 | 72.7% | 42.2 cycles |
Swapping the ranking moves 9.2 points of bus share at 4-beat bursts and 0.3 points at 16. A four-beat read completes in 11.7 cycles end to end, so a master that loses a grant is asking again long before the boost has carried it to 15, and the declared ranking decides the split. A sixteen-beat read takes 42.2, every wait outlasts the boost, both masters reach an effective 15 before they are looked at, and the arbiter is round-robin again — the setting is still there and it no longer does anything.
That is the anti-starvation mechanism working as specified rather than a defect, and it is worth knowing before relying on a QoS number: a QoS gap survives only while the low-ranked master's wait is shorter than its distance from 15. Rank 2 against 12 and the gap holds for waits up to 13 cycles. If your masters issue long bursts, rank them further apart, or expect round-robin.
Neither ranking starves anyone in either regime. Demoting the CPU from 12 to 2 costs it a third of its grants (22,290 down to 14,564) rather than all of them, and the host's read latency moves by 0.2 cycles whichever way it is ranked. That is the same age boost, doing the job it is there for.
sbt "vexZero/testOnly *VexZeroStressSpec" # both the load and the QoS experiment
Test conditions: as above, plus QosBased arbitration and a fixed 300,000-cycle window per run
rather than a whole Dhrystone — the question is how the bus was shared, and the test above already
establishes that the program finishes correctly under the same load.
Everything above is simulation. A simulation of 300,000 cycles is about three milliseconds of a
100 MHz board, and the load that produced those numbers — HostTraffic
— is Scala and cannot be synthesised. To ask the same questions of real silicon, the load has to be
hardware.
AxiSatGen is that load: a saturating, self-checking
AXI4 master, in SpinalHDL, that plugs into a crossbar port. Two properties make it worth more than a
generic traffic generator.
It saturates. It keeps several bursts in flight, so AW runs ahead of W and of B. A master that waits for each response before issuing the next address never makes an arbiter choose, which is why a CPU alone proves so little: measured on this SoC running Dhrystone, the two CPU ports both want the bus in about one cycle in a hundred. With two generators attached, the fabric is contended for 97% of the run.
It checks itself. It reads back every word it wrote and compares. That is possible without storing anything because the data is a function of the address:
data(k, p) = dataPattern | (p << 16) | k
with k the word offset in the generator's window and p the pass index. A pass covers the window
exactly, so after its write phase every word holds data(k, p) and the read phase predicts each
value from the address alone. A dropped or mis-routed beat then fails on the board rather than only
in simulation, and the verdict is a wire — io.genOk — folded into the same report the firmware's
checks go into. It is false if any generator miscompares, sees a response other than OKAY, has
never completed a lap, or has gone hangCycles (65,536 by default) cycles without a single
handshake on any channel. The last is there because the other three only describe what happened
before a generator stopped: one that deadlocks after its first lap keeps zero errors and a non-zero
lap count for ever, and would otherwise read as a pass. Burst length varies per pass, 1 << (p % 4) beats, because long bursts are what
make an arbitration ranking decay while short ones make the arbiter choose often.
Five builds put this on both boards, four of them differing in one field — how the crossbar chooses — and the fifth in what the masters do:
| Build | Arbitration | Asks |
|---|---|---|
stress_rr |
round robin | does the fabric stay correct when saturated, and split evenly? |
stress_wrr |
weighted 3:1 | do the weights move the split, without starving the lower one? |
stress_qos |
QoS 12 / 6 / 2 | does ranking hold across three levels at once? |
stress_axi3 |
round robin | does the AXI3 adapter carry a CPU's whole load/store path under that load? |
stress_ids |
round robin | does the fabric keep AXI4's ordering promise — same-ID responses in issue order, one slave per live ID — with masters that actually vary their ID across two RAMs? |
stress_ids is the one that is not about arbitration. Every other master in this repository —
VexRiscv included, and AxiSatGen — drives a constant transaction ID, so until this build every
bitstream exercised the crossbar's ordering machinery in its degenerate shape: one thread per
master, nothing to order and nothing to hold back. It adds a second 4 KiB on-chip RAM at
0x9000_0000 and two AxiMultiIdGen masters — one
running four IDs, one running two — with up to four read bursts in flight per ID and sending each ID's bursts to the two
RAMs in pairs — two to one, two to the other. That is the single-slave-per-ID rule's own case, in
the shape that is hard rather than the shape that is easy: when the crossing request arrives, two of
that ID's bursts are still outstanding at the other RAM, so the fabric's per-ID count has to be
right and not merely its record of one live destination. It may not admit the crossing burst until
both have retired, and a fabric that admits it anyway answers out of order.
The generators check that themselves, in hardware, with no scoreboard in Scala: each ID owns a queue of the bursts issued under it and every R beat is compared against the head of the queue its RID names, so a same-ID response that overtook an earlier one, a response returned under the wrong RID and a burst answered with the wrong number of beats all land as errors. They also report two pieces of evidence that the run was the run it claims — that more than one ID really was outstanding at once, and that an ID really did ask to move to the other RAM while it was still live at one — and a run without both is a failure, because a clean result from a generator that quietly issued one ID at a time to one slave proves nothing.
The generators can only report what they saw from their own boundary, though, and that is not where
the rule is enforced. A register slice sits between each master and the crossbar: the skid accepts
requests the crossbar has not admitted, so a generator can truthfully report "several IDs in flight"
and "a live ID asked to cross" while the crossbar downstream was serializing everything and its ID
table never held two IDs at once. The evidence would be true and would prove nothing about the
machinery it is named after. So each generator also carries an
Axi4OrderingProbe watching the crossbar's own
master port, where ARREADY is the admission decision. It latches a violation if the fabric ever
admits a request for one RAM while that ID still has work outstanding at the other — seen directly,
rather than inferred from a response arriving late, which matters because two identical RAMs can
answer in issue order by luck — and it latches the proof that the interesting case was reached: a
crossing request presented and held while two of that ID's bursts were live elsewhere. Reads and
writes are tracked apart, because AXI4 orders each direction on its own. Both verdicts fold into
io.genOk, so a bitstream whose traffic never reached that case fails rather than passing quietly. AxiMultiIdGenSpec shows the data check failing against a
RAM that drops a byte lane and the ordering check failing against a RAM model that deliberately
answers two same-ID reads out of order, before any of it goes to a board.
The write side needed something the read-back cannot give it. A BID exchanged between two IDs that are both waiting moves no data — W beats follow the AW that preceded them — so memory ends up correct and reads back correct; the error is in the label on the response, and a label is an ID and two bits of status. Between two RAMs that both answer OKAY, the swapped trace and the correct one are the same bits. That is a property of AXI4, not of this fabric, and no amount of reading memory back reaches it.
So each generator issues one extra single-beat write per round to 0x9100_0000, an address no
slave claims. What answers it is the fabric's own
Axi4DecErrSlave, wired into every crossbar as
one more slave owning everything unmapped: it takes the address, sinks the beat and answers DECERR.
Each generator keeps, per ID, a queue of what its outstanding writes are owed — one bit each, error
or OKAY, which is enough because AXI4 orders same-ID writes — and every B is checked against the
head of the queue its BID names, so a swapped response lands where an OKAY was expected and an error
was owed. Nothing is ever read back from that region; its whole purpose is to make one master's
writes answerable in two distinguishable ways. errRespSeen reports that an error really did come
back, and io.genOk requires it — a bitstream whose extra writes never arrived would otherwise pass
a check nothing exercised.
Using the responder that is already there rather than adding a slave for the purpose is not just tidiness, and the first attempt here is worth recording. A dedicated SLVERR slave works and was measured working on both boards, but a sixth port widens the arbitration and decode it sits inside and spreads the logic further apart: it cost 942 LUTs and took the Arty from +0.325 ns of slack to +0.017 ns, on a path that is 80% routing. The unmapped address costs no port, no arbiter input and no decode term — 203 LUTs and +0.101 ns, against a build with no write-response check at all. It also puts the decode-error responder itself on a board, which nothing here did before; until now its only coverage was in simulation.
The two generators are also deliberately different widths. Each one's port is sized to its own
ID count, so the two-ID generator reaches the fabric one ID bit wide where the fabric carries two,
and everything it issues crosses
Axi4IdWidener with an ID that changes. That
widener sits in every mixed-width design here, but until this build the only narrow master on either
board was the CPU, whose ID is a constant — so its zero-extension was carried by a signal that never
moved, which is exactly where a padding or truncation bug survives a bitstream. Two IDs is still
enough for the ordering rule to have something to hold: one ID crossing while the other is live.
Each also runs the firmware self test, so the program on top still has to compute the right
checksum, LEDs and characters while the generators compete with it for the RAM it fetches from; each
carries a protocol checker on every fabric port; and each carries the
AXI4-Stream smoke island, which shares nothing with the bus and so costs a bitstream almost nothing
to bring along. The configurations live in one place,
VexZeroStress, and both the simulation
and the two board generators build from it — a hardware run confirms a simulation only if the two
were the same design.
sbt "vexZero/testOnly *VexZeroGenStressSpec" # all five, in simulation
python hw/vivado/arty_a7/run_vexzero_test.py --design all # all five, on the Arty
python hw/quartus/de25_nano/run_vexzero_de25.py --design all # all five, on the DE25-NanoWhat simulation measured, per policy, over a 60,000-cycle window after boot with two identical generators (256-word windows, up to 8-beat bursts, 4 outstanding) and the CPU running from the same RAM:
| Policy | gen0 : gen1 beats | Contended | Result |
|---|---|---|---|
| round robin | 25,940 : 25,874 (1.00:1) | 98.2% | even, as declared |
| weighted 3:1 | 34,235 : 19,187 (1.78:1) | 97.5% | skewed, neither starved |
| QoS 6 vs 2, CPU 12 | 31,092 : 20,581 (1.51:1) | 97.2% | ranked, neither starved |
| AXI3, round robin | 25,943 : 25,873 (1.00:1) | 98.2% | the adapter does not change the split |
stress_ids is not in that table because it is not measuring a split. What it measures is that
nothing came back in the wrong order: over the same 60,000-cycle window, 100.0% contended, its
two multi-ID generators each completed 10 full laps of both windows — 9,749 read bursts and 10,010
write bursts between them — with zero data, response and ordering errors, and both reported
having had several IDs outstanding at once and having asked to move a live ID to the other RAM. At
the crossbar's own master ports, where the rule is enforced rather than observed, both probes
recorded two IDs live at once, one ID two bursts deep, and a crossing request held against two
live bursts, with no violation — so the fabric was measured doing the holding, not merely
producing answers that happened to be in order. The
saturating generators and the CPU are running underneath all of it, so the ordering traffic is being
held up against real contention rather than against an idle fabric.
A 3:1 weight does not produce a 3:1 throughput and is not meant to: the generators are closed-loop, each waiting on its own responses, so a weight buys a share of the grants rather than a share of the bandwidth. What has to hold — and does — is that the weighted port is served clearly more and the other is still served at all.
Test conditions: SpinalSim/Verilator, VexZeroSoc with maxOutstanding = 4, 32 KB RAM,
protocolCheck on, register slices on every master and slave port, two AxiSatGen masters plus
VexRiscv's two ports; measurement window starts after the firmware writes its done marker, so the
boot transient is excluded.
The two columns are read at different points, deliberately. Beats are counted at the fabric's master ports, which is what each master was actually carried. Contention is counted one level further in, at the crossbar's own master inputs, because that is where the arbiter makes its choice: a register slice accepts an address beat into its own storage the cycle it appears, so at the external port the master's VALID drops again immediately and the request looks finished when the arbiter has only just received it. Measured outside the slices the same saturated run reads as 42-70% contended, which says nothing about the arbiter.
Every write in the sections above is a single beat, and that is a property of VexRiscv rather than
of the crossbar: its data cache has no dirty bit, so it is write-through by construction and no
setting makes it burst. The only bursting write master so far has been HostTraffic, which is
Scala and exists only in simulation.
vtpgZero is neither. It is a synthesizable video test pattern generator, carried as a pinned submodule, and it joins the SoC as two ports at once: a write-only AXI4 master that fills a framebuffer in RAM, and the AXI4-Lite slave that programs it. Putting the control window on the fabric rather than beside it means configuring the generator is itself arbitrated bus traffic.
vtpgz_axilite_top ──AXI4-Stream──> axis_to_ddr_writer ──AXI4 write bursts──> crossbar ──> RAM
▲
└── AXI4-Lite, a slave on the same crossbar
Both modules are plain Verilog-2001 with no vendor primitives, so unlike the JTAG bridge this blackbox simulates: Verilator compiles the submodule's own sources, and the traffic in the test is the traffic the board would produce. A 32×32 frame becomes:
| Write bursts | 64 |
| Write beats | 1,024 |
| Burst lengths | 16-beat × 64 |
| Read commands | 0 — the writer's read channel is tied off |
| Pixels verified | 1,024 of 1,024 |
Three things here are new to this interconnect: write bursts from real hardware, a write-only master (a port shape nothing else here has), and a burst length that varies, since the writer flushes a short burst at the end of a frame rather than only full ones.
git submodule update --init third_party/vtpgZero
sbt "vexZero/testOnly *VexZeroVideoSpec"
Test conditions: SpinalSim + Verilator, 4-master × 4-slave AxiZeroMixedTop, round-robin,
maxOutstanding = 4, 64 KB on-chip RAM, framebuffer at RAM + 0xC000, 32×32 solid-colour frame at
one pixel per clock. Solid colour is deliberate: every pixel of every frame is then the same
predictable {R,G,B,0xFF} word, so the read-back checks all 1,024 exactly and a burst landing at
the wrong address fails rather than merely looking wrong.
vtpgZero is Apache-2.0; it is referenced as a submodule, not vendored into this MIT-licensed tree.
Initialise it non-recursively — it carries its own fcapz submodule pointing at the same
fpgacapZero this repository already pins at v0.4.9, and a recursive init would clone a second copy
at a different commit.
The natural thing to want from a SoC like this is maximum priority for processor reads while the
video path keeps a bounded worst case, and QosBased arbitration with cpu_qos at 15 looks like
the lever for it. It is not, and
VexZeroVideoQosSpec exists to
say so with numbers rather than with an argument. It runs the real CPU against the real video core
at two burst lengths under both policies:
| Policy | Video burst | ifetch mean/worst | Video write mean/worst | Fetches | Video bursts |
|---|---|---|---|---|---|
| round-robin | 16 | 0.13 / 2 | 19.00 / 19 | 19,993 | 2,668 |
QoS, cpu_qos = 15 |
16 | 0.13 / 2 | 19.00 / 19 | 19,993 | 2,668 |
| round-robin | 4 | 0.27 / 2 | 7.00 / 7 | 26,187 | 7,145 |
QoS, cpu_qos = 15 |
4 | 0.27 / 2 | 7.00 / 7 | 26,187 | 7,145 |
The two policies are byte-identical, which is the shape of a lever that does nothing rather than one
that is merely weak. AXQOS ranks masters competing for one slave on one channel, and the crossbar
arbitrates reads and writes independently — separate grant logic and separate per-slave age state,
wrQosAge for AW and rdQosAge for AR. The CPU's active port issues only reads and the video core
issues only writes, so the two never appear in the same arbiter and no AXQOS value can order them.
Where they actually meet is the RAM's shared command port: Axi4SharedOnChipRam has one address
channel, so AR and AW are merged by toShared() on the crossbar's finished slave port — downstream
of the arbiter that AXQOS feeds.
The second number worth reading is that there is nothing to arbitrate here anyway. Single-cycle on-chip RAM answers a beat per clock; even after tripling the video load the two masters together ask for about 63% of it, every video burst is served in exactly its own transfer time, and the worst instruction fetch waits two cycles. A mean equal to a maximum is the signature of a queue that never forms.
So: QoS between a read stream and a write stream is not expressible in this fabric, and at this
memory it is also unnecessary. It becomes a real question only if the memory becomes the bottleneck
— a slower external memory, or a much larger frame — and answering it then would mean QoS-aware
merging at the shared slave port, which is a fabric feature that does not exist today. What QoS
does rank, today and on hardware, is two masters on the same channel; that is what
arbitration modes and DecodeErrorSpec's QoS tests cover.
Test conditions: SpinalSim + Verilator, 4-master × 4-slave AxiZeroMixedTop, max_outstanding = 4,
64 KB on-chip RAM, 32×32 solid-colour frame, frame_rate = 1200 clocks, 100,000-cycle measurement
window taken after the generator is programmed and running. Latencies are AR/AW accepted to the last
response beat, in cycles.
A third-party core is worth more than a testbench precisely because it does not do what your testbench does. This one answers reads in the same cycle it accepts the address — it raises ARREADY and RVALID from one register, which AXI permits and small register files commonly do.
Axi4FullToLiteAdapter could not carry that. It returned each response with an ID captured into a
register on the address handshake, and a register updates at the end of the cycle, so a same-cycle
response went out tagged with the previous transaction's ID. The pipelined crossbar routes
responses to masters by ID, so the answer never reached the master that asked and that master
waited forever. The adapter now takes the live ID whenever the address is firing in that cycle, on
both the read and the write path.
Two things kept it hidden. It needs the pipelined crossbar — the blocking path remembers which
master it granted and never consults the ID — and it needs a slave that answers this fast, where
every other Lite slave here holds READY combinationally and answers a cycle later.
LiteSameCycleResponseSpec now pins it with
a slave modelled on the one that found it. Revert the fix and it reports the failure exactly:
master 1's read is answered to master 0, whose ID is the zero the register resets to. A test using
only master 0 would have passed throughout.
The example is not tied to one FPGA family. The same SoC, the same crossbar and the same firmware also run on a Terasic DE25-Nano (Altera Agilex 5), built with Quartus Prime Pro instead of Vivado. Porting it needed no change to the interconnect and no vendor-specific memory or reset primitive — the design is ordinary inferred RTL — but it did need a different answer to one question: how does the board say what happened?
The DE25-Nano has no UART. Its only link to a host is the on-board USB-Blaster. So rather than the design pushing a report out, the host reaches in: fpgacapZero's JTAG-to-AXI bridge joins the crossbar as a third bus master, and the runner reads the registers the firmware wrote.
host ── USB-Blaster ──> M2 ─┐
VexRiscv IBus ── M0 ───┼─ AxiZeroMixedTop ──> RAM / GPIO / sysctrl / console
VexRiscv DBus ── M1 ───┘ 3 masters
That is a better arrangement than a side channel, because the report path is now part of what is being tested. Every value the runner checks crosses the interconnect, arbitrated against a CPU that is still fetching out of the same RAM. A crossbar that mixed up two masters' responses would fail the test rather than quietly reporting on itself.
The bridge comes from the fcapz submodule — the same one the
Arty debug flows already use, so fpgacapZero is pinned once for the whole
repository rather than once per board. It is wrapped in
JtagAxi, which presents it as a plain axiZero
master port and ties off the signals it does not drive. It has no IDs and issues one transaction at
a time, so a constant ID is enough for the pipelined crossbar to route its responses — the same
argument that already covers the two CPU ports.
git submodule update --init fcapz
python hw/quartus/de25_nano/run_vexzero_de25.py # self test
python hw/quartus/de25_nano/run_vexzero_de25.py --design bench # Dhrystone
python hw/quartus/de25_nano/run_vexzero_de25.py --skip-build # reprogram and re-readFor the benchmark there is no serial line to stream the console over either, so the console is
buffered inside the peripheral and the host drains it a read at a time
(VexZeroBenchIo). Backpressure is
unchanged: a full buffer stalls the store in the CPU rather than dropping a character, so a host
that reads slowly costs the run time and never a byte. Nothing prints inside Dhrystone's timed
loop, so the measurement is untouched.
Results — both designs pass on the board. The self test's every checked value is read back over JTAG-AXI: the done marker, the computed result, the GPIO register the firmware wrote, and a word of the program read straight out of RAM while the CPU was running. Dhrystone runs to completion with its whole console drained through the bridge, all 20 of its self-checks passing and an exit code of 0.
And it takes 328,048 cycles for 200 runs — the same count as the Arty, and the same count as simulation. Three platforms, two FPGA vendors, two toolchains, one number: the SoC is doing exactly the same work on the Agilex 5 as it does on the Artix-7, cycle for cycle.
| Timed loop | Cycles/run | Dhrystones/s | DMIPS/MHz | |
|---|---|---|---|---|
| DE25-Nano @ 50 MHz | 328,048 | 1640.2 | 30,483 | 0.347 |
| Arty A7-100T @ 100 MHz | 328,048 | 1640.2 | 60,967 | 0.347 |
| SpinalSim | 328,048 | 1640.2 | — | 0.347 |
Only the wall-clock rate differs, because the DE25-Nano's oscillator is half the Arty's.
| Resource | Self test | Benchmark | Available |
|---|---|---|---|
| ALMs | 6,163.3 | 4,885 | 46,800 |
| Registers | 9,351 | 6,960 | — |
| RAM blocks | 6 | 19 | 358 |
| DSP blocks | 0 | 0 | 376 |
Test conditions: Quartus Prime Pro 26.1, A5EB013BB23BE4SR1, default synthesis and fitter settings,
one 50 MHz clock domain. Worst-case setup slack +12.655 ns (self test) and +11.859 ns
(benchmark), zero failing endpoints in both. The self-test figures are from the 2026-09-10 rebuild on the fixed RTL that
also produced the loaded-build table; the benchmark column
predates the protocol checkers and the decode-error responder and has not been rebuilt since, so
treat it as the older design it is. The figures cover the whole system — VexRiscv, the axiZero crossbar, the RAM, the
peripherals and the JTAG-to-AXI bridge, which accounts for much of the register count and has no
counterpart in the Arty builds, so the two boards' numbers are not comparable to each other.
Simulation covers the three-master arrangement without needing the vendor primitive:
VexZeroHostSpec drives the SoC's
host port directly with the transactions the bridge would issue.
| Test | What it proves |
|---|---|
the host reads the firmware's verdict over the third master port |
the host reaches both the Lite peripherals and the full-AXI4 RAM |
the host and the CPU share the crossbar without disturbing each other |
dozens of host reads interleaved with instruction fetch, all correct, and the firmware's own result unchanged |
the host drains the benchmark console over the bus |
a whole Dhrystone run read out through the buffer, with Dhrystone's own self-checks re-run on the text |
- Register slices on the CPU ports are required, not decorative. VexRiscv couples its two bus
ports combinationally — a stalled store stalls the pipeline, which pulls
IBusSimplePlugin'scmd.validlow — andAxi4SharedOnChipRam's AR/AW arbiter derivesAWREADYfromARVALID. Together those close a ready → valid → ready ring through the fabric.regSlice = trueon both master ports registers every master → fabric valid and breaks it. regSliceregisters VALID, not READY. Each channel uses SpinalHDL'sStream.stage(), which ism2sPipe: VALID and the payload are registered andupstream.ready = !rValid || downstream.readystays combinational. That is correct and loses no beat — READY has to stay combinational for bubbles to collapse — but it means an arbiter's grant can reach a master's READY in the same cycle, which is what the critical path on the loaded builds turned out to be.regSliceSkid = truemakes the three forward channels full pipes (s2mPipe+m2sPipe) so READY is registered too; the skid register is what makes that safe, because once READY is a cycle stale the upstream can complete a handshake the downstream did not accept and the beat needs somewhere to go. Forward latency and throughput are unchanged. It is off by default and set only on the stress builds — see timing closure on the loaded builds.- Response ordering. Pipelined mode routes B/R by ID and both CPU ports drive a constant ID,
so ordering only has to hold per master. IBus fetches never leave the RAM region, and
DBusSimplePluginkeeps at most one read in flight and blocks reads while a write is outstanding.
See ADR 002 for why VexRiscv is carried as a pinned submodule in its own sbt project.
Validation runs on two boards from two vendors: a Xilinx Arty A7-100T (xc7a100t) at 100 MHz
through Vivado, and a Terasic DE25-Nano (Agilex 5) at 50 MHz through Quartus. Both run the same
SpinalHDL source.
Six builds of the VexRiscv example SoC, each built for both boards from the same configuration object:
| Build | What it adds | Replaces |
|---|---|---|
verdict |
the firmware self test on a quiet fabric, on the blocking crossbar (maxOutstanding = 1) |
base |
stress_rr |
two saturating self-checking generators, round robin | base under load |
stress_wrr |
the same, weighted 3:1 | wrr |
stress_qos |
the same, QoS across three ranks | qos, qos_stress |
stress_axi3 |
the same, CPU load/store routed through AXI3 | axi3 |
stress_ids |
a second RAM and two multi-ID generators driving IDs between the two | — |
verdict is the only one built on the blocking engine. Blocking and pipelined are separate RTL
paths, and until now every bitstream here was the pipelined one — the blocking engine had
simulation coverage and no silicon. verdict is where it belongs: on a quiet fabric a stall shows
up as a boot that never finishes rather than as a statistic, so carrying the other engine costs
nothing that build was measuring. The five stress builds stay pipelined, which is where
arbitration and ordering have something to do.
Every one of them carries a protocol checker on each fabric port and the AXI4-Stream smoke island, so the stream components are covered by all five stress builds rather than by a build of their own — that is what replaces the axis suite. See loading the crossbar on hardware for what the generators do and why.
python hw/vivado/arty_a7/run_vexzero_test.py --design all # Arty A7-100T, Vivado
python hw/quartus/de25_nano/run_vexzero_de25.py --design all # DE25-Nano, QuartusThe verdict build closes at 100 MHz with room to spare. The four stress builds did not, and the
reason is worth writing down: a register slice as Stream.stage() registers VALID and the payload
but leaves READY combinational, so on a fabric with four masters the arbiter's grant could reach a
master's READY through the decode-error slave in a single cycle. Three changes closed it, measured
one at a time on stress_qos, the worst of the four:
| Step | WNS | Failing endpoints | TNS |
|---|---|---|---|
| baseline | −1.902 ns | 821 | −416.8 |
+ pipelineInputs on the protocol checkers |
−1.021 ns | 255 | −83.0 |
+ slaveRegSlices |
−0.552 ns | 68 | −23.7 |
+ masterRegSliceSkid |
+0.316 ns | 0 | — |
That progression was measured on the RTL as it stood before the response-channel fix described in the changelog, so its last row (+0.316 ns) is not the number in the final table below (+0.540 ns) — the crossbar has since gained the response-channel hold, and every build in the final table was rebuilt from scratch on the fixed RTL. The rows are kept because what they show is the shape of the problem and which change moved it, and re-running three intermediate builds to restate an already-answered question would buy nothing.
None of the three changes is supposed to alter an arbitration decision, and the one that was most likely to — the master-side skid, which changes when a master's request reaches the arbiter — was checked: the generator beat counts before and after it are bit-identical (34,235 : 19,187 on the weighted build). The other two rows were not re-measured beat-for-beat, so read that as "structural by construction, confirmed for the step that mattered" rather than as four measured rows.
One attempt that did not work is worth recording too. Registering the QoS arbiter's effective-QoS computation, at the source of the path, made things worse — −0.917 ns and 448 failing endpoints. The path was 78–82% routing, not logic, so cutting it at its origin left the same long haul across the die with one fewer level of logic to hide it. The register had to go mid-haul, which is what the master-side skid does.
Final builds, all six programmed and verified on the board:
| Build | LUTs | FFs | BRAM | WNS | Fmax |
|---|---|---|---|---|---|
verdict |
2087 | 2654 | 3 | +0.472 ns | 105.0 MHz |
stress_rr |
3579 | 4556 | 9 | +1.036 ns | 111.6 MHz |
stress_wrr |
3707 | 4571 | 9 | +0.993 ns | 111.0 MHz |
stress_qos |
3767 | 4617 | 9 | +0.075 ns | 100.8 MHz |
stress_axi3 |
4203 | 5056 | 9 | +0.986 ns | 110.9 MHz |
stress_ids |
9441 | 8198 | 11 | +0.101 ns | 101.0 MHz |
Those are whole-SoC figures — VexRiscv, 8 KB RAM, peripherals, two traffic generators, a protocol checker on every fabric port and the AXI4-Stream island — not the crossbar alone.
stress_ids is more than twice the size of the others, and the reason is worth being explicit
about: it is a bigger system, not a more expensive crossbar. It carries six masters where the
others carry four and four slaves where they carry three, and the crossbar's cost is roughly the
product of the two — every extra master-slave pair is another arbiter input, another decode and
another ordering-table entry. On top of that its two multi-ID generators carry four and two per-ID
expectation queues and the comparison logic that checks every R beat against one, and three more
protocol checkers come with the three extra ports, since every fabric port gets one. It is the
price of the test, paid once in a build that exists to run it.
Test conditions: Vivado 2025.2, xc7a100tcsg324-1 (speed grade −1), 100 MHz target, Vivado
Implementation Defaults strategy with no directives, and implementation at --jobs 8 (the
runner's own default is 4). The design instantiates no Xilinx IP, so nothing is synthesised
out-of-context. WNS is STATS.WNS of impl_1 and Fmax is 1000 / (10 − WNS); the
implementation strategy is recorded beside the slack in vexzero_timing.txt, because an Fmax
without the strategy that produced it is not reproducible.
Both runners refuse to program a board when the worst setup slack is negative. Three limits are
worth stating plainly rather than leaving to be discovered: the gate reads setup only, so a hold
or pulse-width violation passes it (the tightest hold margin measured here is +0.011 ns on the Arty
stress_qos — positive, but not by much); --allow-timing-failure still ends in PASSED and exit
0, having said once, earlier, that the result is not evidence; and run_vexzero_bench.py has no
timing gate at all, so a Dhrystone score can come off a bitstream that missed timing.
stress_ids is worth a note of its own. Adding the write-response check cost it slack — +0.325 ns
before, +0.101 ns now — and the critical path is inside the crossbar's own ordering and
W-routing logic: 12 levels of logic but 80% routing delay, the same placement-bound shape the
QoS path has. The first version of that check, with a slave added for it, came out at +0.017 ns;
using the decode-error responder already in the fabric bought most of that back. What is left is the
generators' own per-ID expectation queues and the traffic they add, and +0.101 ns is a real margin
rather than a rounding error — but it is still a design to re-measure rather than assume when
anything near the crossbar changes. On the DE25-Nano at 50 MHz none of this matters: the same builds
sit between +9.0 ns and +9.8 ns, and the variation there is placement noise, not the change.
stress_qos is the one to watch: its margin is thin enough that placement variation moves it
noticeably. Six builds of this design have come out at +0.316 ns, +0.121 ns, +0.540 ns, +0.373 ns,
+0.026 ns and +0.044 ns — several consecutive pairs of which differed only by a fix adding a
register or a handful of gates and no combinational depth. So the design has something like 0.5 ns
of run-to-run spread at this frequency, and its Fmax should be read as ~100-106 MHz rather than as
any one of those numbers. Every one of the six closed with zero failing endpoints.
The ordering table added for the AXI4 same-ID rule costs roughly 80-160 LUTs per build here (four
masters, idThreads = 2, four slaves plus the decode-error responder), and the write-data skew
tracking that goes with it a further handful of registers per slave. No build came out of closure.
The same six builds on the DE25-Nano, where the 50 MHz clock leaves far more margin and timing was never the constraint:
| Build | ALMs | Registers | Block memory | Worst setup slack | Verdict word |
|---|---|---|---|---|---|
verdict |
6121.6 (13%) | 9269 | 67,584 bits | +11.070 ns | 0x00000106 |
stress_rr |
7822.4 (16%) | 12097 | 264,464 bits | +12.748 ns | 0x00000706 |
stress_wrr |
7874.8 (16%) | 12108 | 264,464 bits | +12.054 ns | 0x00000706 |
stress_qos |
8025.1 (17%) | 12191 | 264,464 bits | +9.942 ns | 0x00000706 |
stress_axi3 |
7891.5 (16%) | 12250 | 265,216 bits | +12.801 ns | 0x00000706 |
stress_ids |
13179.2 (28%) | 19274 | 297,232 bits | +9.093 ns | 0x00000706 |
The ordering matches the Arty: stress_qos is the tightest of the four arbitration builds on both
boards, for the same reason, and stress_ids is the largest on both for the reason given above —
six masters and four slaves, plus the checking hardware inside the generators themselves.
The verdict word is read back over JTAG-AXI across the crossbar under test, because the board has no serial link to a host — a fabric broken badly enough to hide its own verdict cannot report a pass either.
Test conditions: Quartus Prime Pro 26.1, Agilex 5 A5EB013BB23BE4SR1, 50 MHz target (20 ns
period), default synthesis and fitter settings, Hyper-Retimer enabled but reporting no retiming
opportunities. Slack is the worst setup slack from the Setup Summary of the post-fit timing
analysis, and the runner refuses to program the board when it is negative. Quartus reports this
device's timing and clock-uncertainty characteristics as preliminary, so treat the Agilex slack
figures as indicative rather than final — the margin is large enough that it does not change the
conclusion.
The six suites below are retired as of 2026-09-08. They are not run, not maintained, and not a
gate on a push. Nothing has been deleted and they still work; see
hw/vivado/arty_a7/RETIRED.md for the details and the replacement
map.
They were retired because each pulls in ten or eleven Xilinx IP cores plus a block design, so none
of them could ever be built for Altera — a hardware suite that runs on one vendor's silicon is
evidence about one vendor's silicon. The parts that were doing the testing were never the Xilinx
parts: the traffic generators in hw/vivado/arty_a7/ip/rtl/ are our own portable Verilog, and only
the CPU and the peripherals were the lock. ip/rtl/axi_sat_gen.v in particular lives on as
AxiSatGen, which does the same job in SpinalHDL and so
runs in simulation and on both vendors. See ADR 004
for the decision and what was rejected along the way.
The results below are the last ones recorded, kept for the record.
Topology: MicroBlaze LE → axiZero 1M×4S → 2× AXI4 BRAM ctrl (64 KB each) + AXI-Lite GPIO + AXI-Lite UART-Lite, max_outstanding=4.
All 10 tests pass (g_fail=0, g_pass=10).
| Test | Description |
|---|---|
| T1–T3 | Single-word write/read, address isolation (AXI4 Full) |
| T4–T6 | 64-word sequential, walking-1, alternating-stride across both BRAMs |
| T7 | GPIO 16-pattern LED sweep (AXI-Lite) |
| T8 | UART-Lite TX FIFO reset and drain (AXI-Lite) |
| T9 | Full 64 KB BRAM checkerboard — 16 384 word write + verify |
| T10 | Cross-slave boundary: last word of BRAM #0, first word of BRAM #1 |
Topology: MicroBlaze + hardware traffic generator → axiZero 2M×4S WRR (weights 3:1) → same slaves as base test.
All 3 tests pass (g_fail=0, g_pass=3).
| Test | Description |
|---|---|
| T1 | Sanity: single-word write/read to both BRAMs |
| T2 | Contention: MB and traffic gen write concurrently, both regions verified |
| T3 | Starvation: lower-weight master still makes progress under sustained load |
Topology: MicroBlaze QoS=15 plus 3 hardware traffic generators (QoS=8/4/0) → axiZero 4M×4S QoS → same slaves as base test. Each generator issues 512 words × 8 passes per iteration with intentionally different patterns:
- G0 (QoS=8): sequential writes to BRAM0, each word read back and compared in hardware
- G1 (QoS=4): reverse-order writes to BRAM1, each word read back and compared in hardware
- G2 (QoS=0): saturating generator on a 128-word BRAM1 window, 64 passes — bursts of 1, 2, 4 and 8 beats (one length per pass) with up to 4 outstanding, every word read back and compared. Fewer words and more passes than the other two, chosen so its beat count matches theirs even though bursts retire faster
The generators drive the read address channel as well as the write one, so AR arbitration sees generated traffic at each generator's own QoS rather than the CPU alone, and G2 keeps several bursts in flight at once, which is the case the crossbar's per-slave W-route FIFO exists for. Each generator ends its run by writing a status word of its own:
status = TAG | (resp_errors << 8) | data_errors
so a clean run leaves the bare tag, and the firmware can tell "never finished" apart from "finished and found errors". A read beat that is dropped or mis-routed therefore fails on hardware, not only in simulation.
run_qos_stress_test.py monitors the board continuously for 10 minutes and fails if:
g_failbecomes non-zero,- heartbeat (
g_heartbeat) stops advancing for 30 seconds, - no stress iteration (
g_iteration) completes, g_passdoes not match the number of completed iterations (one sanity check at startup plus a fixed 6 sub-tests per iteration, so a build where they silently stopped running can no longer pass on a live heartbeat alone).
Result over a 10-minute continuous run on an Arty A7-100T at 100 MHz (Vivado 2025.2, xc7a100tcsg324-1): 12,734 iterations, 76,400 sub-test checks, 0 failures, with all three generators reporting a clean read-back status on every iteration. The load is deterministic now that no generator randomises its addresses or data — two consecutive runs reach the same iteration count and agree at every 5-second sample — so a change in the numbers is a change in the design, not in the weather.
Topology: MicroBlaze (AXI4) → AXI4-to-AXI3 shim → Axi3ToAxi4Adapter → axiZero 1M×4S crossbar → same slaves as base test.
Every MicroBlaze transaction passes through the full AXI3→AXI4 round-trip, proving the adapter's FSM, WID reorder buffer, and field conversion work correctly in real hardware.
All 5 tests pass (g_fail=0, g_pass=5).
| Test | Description |
|---|---|
| T1 | Sanity: single-word write/read to BRAM0 and BRAM1 |
| T2 | Walking-1 pattern across 256 words in BRAM0 |
| T3 | Cross-slave: alternating writes to BRAM0+BRAM1, full verify |
| T4 | GPIO LED sweep (AXI-Lite slave path through adapter) |
| T5 | UART status read (second AXI-Lite slave path) |
Topology: MicroBlaze plus the dedicated fcapz EJTAG-AXI debug ingress -> axiZero 2M x 5S -> the normal base-test slaves plus a 32-bit AXI GPIO input at 0xC004_0000.
The GPIO samples a self-running AxiStreamArtySmoke datapath:
3 sources -> AxiStreamArbMux -> AxiStreamFifo -> AxiStreamRegSlice -> AxiStreamWidthAdapter 32-to-8 -> AxiStreamDemux -> direct byte sink or AxiStreamWidthAdapter 8-to-32 -> AxiStreamBroadcaster.
The smoke engine sends three two-beat 32-bit frames, arbitrates between all three sources, unpacks to bytes, routes frame 1 through the repack/broadcast path and frames 0/2 through the direct byte path, deliberately stalls one broadcast sink, then reports done/pass/fail, byte counts, frame counts, checksum matches, route checks, and backpressure observation. The MicroBlaze firmware polls that status through axiZero and passes only when the board-observed status has done=1, pass=1, fail=0, the expected counts/checksums/frame boundaries match, and backpressure was actually seen.
All Arty Vivado builds source hw/vivado/arty_a7/fcapz_debug.tcl, which adds the project-local axizero_fcapz_debug wrapper. The AXIS build inherits this through create_project_axis.tcl because it derives from the base Arty script. Builds with an appended debug ingress connect USER4 to the highest-numbered free s*_axi port, so existing MicroBlaze and traffic-generator ports keep their original wiring.
Debug chains:
| Chain | Function | Wiring |
|---|---|---|
| USER1 | fcapz ELA | Captures USER4 EJTAG-AXI requests plus the main-fabric BRESP/RRESP returned to that master. The default trigger fires on AXI BRESP[1] or RRESP[1]. |
| USER4 | fcapz EJTAG-AXI | Enters the main axiZero fabric through a dedicated appended AXI4-Lite ingress (s1_axi on base/AXIS, s2_axi on WRR/QoS, s4_axi on QoS stress), then reaches the normal axiZero slave map. |
The runners auto-detect Vivado, xsdb and Quartus by searching PATH and common install locations
(Windows and Linux). Override with environment variables if needed:
# Arty A7-100T, Vivado
python hw/vivado/arty_a7/run_vexzero_test.py # the self test
python hw/vivado/arty_a7/run_vexzero_test.py --design all # every build, in turn
python hw/vivado/arty_a7/run_vexzero_bench.py # Dhrystone on the same SoC
# DE25-Nano, Quartus
python hw/quartus/de25_nano/run_vexzero_de25.py --design all
# Override tool paths via env vars
VIVADO_BIN=/opt/Xilinx/2025.2/Vivado/bin/vivado \
XSDB_BIN=/opt/Xilinx/2025.2/Vitis/bin/xsdb \
python hw/vivado/arty_a7/run_vexzero_test.pyEach runner generates the netlist with sbt, builds the bitstream, programs the board, and reads the
verdict back — off the USB-UART with pyserial on the Arty, over the JTAG-to-AXI bridge on the
DE25-Nano. No mb-gcc is involved: the VexRiscv firmware is inside the bitstream. See
example system.
The retired MicroBlaze runners (run_base_test.py, run_wrr_test.py, run_qos_test.py,
run_qos_stress_test.py, run_axi3_test.py, run_axis_test.py) additionally need mb-gcc to
compile their firmware. They still work; they are not part of the flow above.
Crossbar-only resource usage — the interconnect alone, without the MicroBlaze, the BRAM controllers or the traffic generators around it:
| Configuration | LUTs | FFs |
|---|---|---|
| Base 2M×4S (round-robin, pipelined, max_outstanding=4) | 804 | 98 |
| WRR 3M×4S (weighted round-robin 3:1:1, pipelined) | 1545 | 154 |
| QoS 3M×4S (QoS arbitration, pipelined) | 2008 | 234 |
| QoS stress 5M×4S (QoS arbitration, pipelined) | 3814 | 330 |
Test conditions: Vivado 2025.2, xc7a100tcsg324-1, out-of-context synthesis with no timing
constraint applied, decode_error_response at its default of on. Every topology counts the fcapz
debug master alongside the functional ones — for example the base design is the MicroBlaze plus the
debug port, so it is 2M×4S rather than 1M×4S.
What the decode responder itself costs, measured by regenerating the same four designs with
decode_error_response = false and synthesising them the same way:
| Configuration | LUTs off → on | FFs off → on |
|---|---|---|
| Base 2M×4S | 706 → 804 (+98) | 68 → 98 (+30) |
| WRR 3M×4S | 1226 → 1545 (+319) | 120 → 154 (+34) |
| QoS 3M×4S | 1796 → 2008 (+212) | 176 → 234 (+58) |
| QoS stress 5M×4S | 3577 → 3814 (+237) | 252 → 330 (+78) |
The responder is one extra slave port on the fabric, so it costs a slave's worth of arbitration and routing rather than a fixed adder — which is why the widest topology does not pay the most.
Vivado TCL scripts and MicroBlaze firmware: hw/vivado/arty_a7/ and sw/arty_a7/.
axiZero crossbar
┌──────────────────────────┐
CPU / DMA ────►│ s0_axi_* m0_axi_* ├────► BRAM
│ │
Config port ──►│ s1_axi_* m1_axi_* ├────► GPIO (Lite)
│ m2_axi_* ├────► UART (Lite)
└──────────────────────────┘
sN = slave-facing mN = master-facing
(connect masters here) (connect slaves here)
sN_axi_* are the slave-facing interfaces — connect your AXI masters (CPUs, DMAs) here.
mN_axi_* are the master-facing interfaces — connect your AXI slaves (BRAMs, peripherals) here.
| Signal | Direction | Notes |
|---|---|---|
sN_axi_awvalid/awaddr/awready |
input | write address channel |
sN_axi_wvalid/wdata/wstrb/wready |
input | write data channel |
sN_axi_bvalid/bresp/bready |
output | write response channel |
sN_axi_arvalid/araddr/arready |
input | read address channel |
sN_axi_rvalid/rdata/rresp/rready |
output | read data channel |
sN_axi_awid/wid/bid/arid/rid |
— | Full AXI4 only |
sN_axi_awlen/awsize/awburst/… |
input | Full AXI4 only |
mN_axi_* |
reversed | crossbar drives the master-facing side |
aclk |
input | rising-edge clock |
aresetn |
input | active-low synchronous reset |
Add the generated Verilog to your project sources and instantiate it. All AXI signals are flat wires. This is how the Arty A7 reference design is wired.
Port names match Vivado's AXI naming conventions, so IP Packager infers all interfaces automatically. hw/vivado/package_ip.tcl produces a packaged IP core:
vivado -mode batch -source hw/vivado/package_ip.tcl
# Output: hw/vivado/axizero_ip/ (contains component.xml)To use: IP Settings → IP Repositories → + the hw/vivado/axizero_ip directory, then drag the IP into your block design. To package a different configuration, set RTL_FILE to the desired generated/*.v file and re-run.
hw/quartus/package_ip.tcl generates a _hw.tcl component description that maps all sN_axi_* / mN_axi_* ports to Platform Designer AXI4 or AXI4-Lite interfaces automatically. It parses the Verilog port list, detects Full vs Lite interfaces, and creates the correct clock/reset associations.
# Package the default 2M×2S Full AXI4 crossbar
quartus_sh -t hw/quartus/package_ip.tcl
# Package a different configuration
quartus_sh -t hw/quartus/package_ip.tcl generated/MyLite_1M4S.vOutput: hw/quartus/axizero_ip/ containing <ModuleName>_hw.tcl and the Verilog source.
To use: IP Components > Add Component Search Path → add hw/quartus/axizero_ip/, then drag the component into your Platform Designer system. Clock, reset, and AXI interfaces are pre-mapped.
build.sbt
hw/spinal/axizero/
AxiZeroConfig.scala # configuration model
AxiZeroTop.scala # top-level (Lite-only / Mixed)
crossbar/
Axi4LiteCrossbar.scala # Lite-only path (no ID logic)
Axi4Crossbar.scala # Full AXI4 path (ID expansion, pipelined)
adapters/
Axi4FullToLiteAdapter.scala
Axi4LiteToFullAdapter.scala
RegisterSlice.scala
WidthConverter.scala # Lite and Full AXI4 data-width conversion
Axi4DownsizerExt.scala # fork of SpinalHDL Axi4Downsizer; FIXED/WRAP flattened, INCR multi-beat
Axi3ToAxi4Adapter.scala # AXI3→AXI4 bridge: WID reorder buffer, locked access conversion
Axi4ToAxi3.scala # the other direction, as wires: an AXI4 master presented on AXI3
Axi4IdWidener.scala # pads a narrow master's ID to the fabric width, truncates it back
verif/
Axi4ProtocolChecker.scala # passive synthesizable AXI4 checker, 19 rules, one sticky bit each
AxiSatGen.scala # saturating self-checking AXI4 master — the synthesizable load
AxiMultiIdGen.scala # multi-ID self-checking AXI4 master — the synthesizable order check
stream/
AxiStreamCores.scala # AXI4-Stream reg slice, width adapter, FIFO, arb-mux, demux, broadcaster
gen/
AxiZeroGen.scala # built-in generation entry point
ArtyDutGen.scala # (retired) Arty A7 MicroBlaze DUT (1M×4S)
ArtyQosDutGen.scala # (retired) Arty A7 QoS DUT (2M×4S, QoS arbitration)
ArtyAxi3DutGen.scala # (retired) Arty A7 AXI3 adapter DUT
hw/sim/axizero/sim/ # SpinalSim testbenches (sbt test)
hw/examples/vexriscv/ # VexRiscv example SoC — separate sbt project `vexZero`
spinal/vexzero/
VexZeroSoc.scala # CPU + AxiZeroMixedTop + peripherals
Peripherals.scala # AXI4-Lite register bus, GPIO, system control
Rv32.scala # RV32I encoder (no cross compiler needed)
Firmware.scala # boot image assembled from Rv32
VexZeroArty.scala # Arty A7-100T board wrapper: checks + LED/UART report
VexZeroBenchArty.scala # Arty A7-100T board wrapper: Dhrystone console over UART
VexZeroBoard.scala # clock/reset generation shared by the board wrappers
VexZeroChecks.scala # the self test's verdict, shared by the board wrappers
VexZeroDe25.scala # DE25-Nano board wrapper: verdict over JTAG-AXI
VexZeroBenchDe25.scala # DE25-Nano board wrapper: Dhrystone over JTAG-AXI
VexZeroStress.scala # the five loaded configurations, shared by sim and both boards
JtagAxi.scala # fpgacapZero JTAG-to-AXI bridge as an axiZero master
HexImage.scala # Intel HEX reader for prebuilt firmware images
gen/VexZeroSocGen.scala # -> generated/vexriscv/VexZeroSoc.v
gen/VexZeroArtyGen.scala # -> generated/vexriscv/VexZeroArty.v (ROM inlined)
gen/VexZeroBenchArtyGen.scala # -> generated/vexriscv/VexZeroBenchArty.v
gen/VexZeroDe25Gen.scala # -> generated/vexriscv/VexZero{,Bench}De25.v
gen/VexZeroStressGen.scala # -> generated/vexriscv/VexZeroStress{Arty,De25}_<policy>.v
sim/vexzero/sim/ # SpinalSim tests for the example (sbt vexZero/test)
VexZeroBenchSpec.scala # Dhrystone over the crossbar, pipelined vs blocking
VexZeroProfileSpec.scala # what that run asks of the fabric, uncached vs cached
VexZeroStressSpec.scala # the same SoC with a third master saturating the fabric
VexZeroGenStressSpec.scala # the five board builds: arbitration under 97% contention, and ordering
VexZeroProtocolSpec.scala # the protocol checkers on every fabric port, in the SoC
VexZeroVideoQosSpec.scala # why AXQOS cannot rank CPU reads against video writes
HostTraffic.scala # the saturating master: 16-beat bursts, AW ahead of W
AxiProfile.scala # per-port traffic shape and latency, measured at the master ports
third_party/VexRiscv/ # pinned submodule, compiled by `vexZero` only
sim/cocotb_gen/
run_all.py # Python runner (lite + full + wrr + qos + ipif + axis suites)
lite/test_lite.py # AxiLiteMaster tests against MyLite_1M4S.v
full/test_full.py # AxiMaster tests against MyFull_2M2S.v
scripts/
axizero.py # YAML → Verilog generator
example.yaml # all configuration options
generated/ # pre-built Verilog
sw/arty_a7/ # MicroBlaze firmware (source + linker script)
hw/quartus/
package_ip.tcl # Platform Designer _hw.tcl generator
hw/vivado/arty_a7/ # Vivado TCL build and test scripts
find_xilinx_tools.py # cross-platform Vivado/xsdb/mb-gcc auto-detection
create_project_vexzero.tcl # plain RTL project for any VexZero board wrapper
run_vexzero_test.py # VexZero HW test runner: verdict + the four stress builds
run_vexzero_bench.py # VexZero Dhrystone runner (UART console)
RETIRED.md # what the MicroBlaze suites were, and what replaced them
ip/rtl/ # (retired) Arty handoff RTL for the MicroBlaze block designs
run_{base,wrr,qos,qos_stress,axi3,axis}_test.py # (retired) MicroBlaze HW test runners
hw/quartus/de25_nano/
build_vexzero_de25.tcl # Quartus batch build for any VexZero DE25 wrapper
run_vexzero_de25.py # DE25-Nano runner: verdict read back over JTAG-AXI
MIT — see LICENSE.
Copyright © Leonardo Capossio, bard0 design — hello@bard0.com