When we build a new hardware block, we usually start by modeling what the block should do. This helps us understand the behavior before writing RTL, and that understanding leads to better design decisions. Jumping straight into RTL can be shortsighted and often causes headaches later.
For behavioral modeling, Python is usually a good place to start because it gives us flexibility, fast iteration, and access to a large standard library. When the behavior is standard enough, we try not to reinvent the wheel and use an existing implementation as a reference. The point is to get a simple executable description of what the block should do before committing to a hardware implementation.
Once the model behaves the way we expect it to, writing the RTL is much easier and less surprising. The model helps expose corner cases early, reduces implementation errors, and gives us a clearer target before we even start coding.
Standard interfaces: why we choose AXI Stream
Designing around standard interfaces is a practice we like to enforce. It keeps module boundaries clear and makes designs easier to reuse, test, and integrate with our own blocks or with third-party designs, as long as everyone follows the same interface contract. A block with a custom interface may work fine by itself, but it usually becomes harder to connect to the rest of the system.
For streaming datapaths, our default choice is AXI4-Stream.
It gives us a simple contract:
tdatacarries data.tvalidandtreadyhandle flow control.tlastmarks packet boundaries.
For most of our blocks, this subset is enough. AXI4-Stream also leaves room for sideband signals such as tkeep, tstrb, tid, tdest, and tuser when a design needs byte qualifiers, routing metadata, or user-defined information.
Alex Forencich's verilog-axi is a great source of AXI4-Stream compliant modules.
Putting it all together
Once we have a golden model and an RTL implementation, we need a way to compare them. For that we use cocotb, which lets us write HDL tests in Python, and cocotb-bus, which gives us reusable drivers, monitors, and scoreboards for common interfaces.
In this example, we use an AXI4-Stream driver to send packets into the RTL and an AXI4-Stream monitor to observe what comes out. The Python model produces the expected output, and the scoreboard compares the RTL transactions against it.
To demonstrate the flow, we use an Ethernet FCS inserter. The model computes the expected FCS with Python's zlib.crc32, while the RTL computes the CRC and appends the FCS bytes on the stream. If the RTL sends the wrong bytes, uses the wrong order, or asserts tlast at the wrong time, the scoreboard fails.
You can find the source code in our hardmatrix-examples repository.
The structure of the test is simple:
- Drive input transactions into the RTL.
- Observe those same input transactions and feed them into the golden model.
- Store the model output as the expected result.
- Observe the RTL output.
- Let the scoreboard compare RTL output against expected output.
For an AXI4-Stream block, that looks roughly like this:
driver = AxisDrv(dut, "s_axis", dut.clk)
monitor_in = AxisMon(dut, "s_axis", dut.clk, config={"capture_tlast": True})
monitor_out = AxisMon(dut, "m_axis", dut.clk, config={"capture_tlast": True})
expected = []
scoreboard = Scoreboard(dut)
scoreboard.add_interface(monitor_out, expected)
The input monitor builds the expected output:
def build_expected(transaction):
model_outputs = model.push(transaction["tdata"], transaction["tlast"])
expected.extend(model_outputs)
monitor_in.add_callback(build_expected)
Then the test sends data into the RTL:
driver.append(packet_bytes, pkt_size=len(packet_bytes))
From that point on, the scoreboard checks every output transaction. If the RTL produces the wrong value, drops a beat, adds an extra beat, or places tlast incorrectly, the test fails.
This setup is already powerful, and it does not take much code. We are not only checking a few hand-written expected values; we are checking the complete stream produced by the RTL against an independent model backed by a standard library implementation. That gives us good coverage of normal behavior, packet boundaries, and interface timing without building a heavyweight verification environment.
Scaling the same idea
The same pattern scales to larger designs.
If we compose AXI4-Stream RTL blocks into a pipeline, we can compose their Python models the same way. The combined Python model mirrors the RTL pipeline and becomes the golden model for a larger integration test.
That composability is the main reason we like this workflow: small models first, standard streaming interfaces, focused RTL, and tests that compare hardware behavior against an independent executable reference.
Closing thoughts
This workflow is simple, but it scales well. Start with a golden model, use standard interfaces where possible, and compare RTL behavior against the model through the same interface the design will use in the real system.
For small blocks, this keeps tests lightweight and easy to understand. For larger systems, the same golden models can be composed to mirror the RTL pipeline. That gives us a practical path from unit tests to integration tests without changing the basic verification approach.