Unit tests, co-simulation, and running it all on every push
A hardware test in QuartzHDL is a Julia test. That is the whole point of this chapter, but it is worth spelling out what falls out of it.
Unit tests with step
@quartzstruct Gray@in x::Bits{4} =0@out g::Bits{4}end@on Gray posedge(clk) g ← x ⊻ (x >>1)@testset"gray code"begin m =Gray()for i in0:15 m =step(m; x=Bits{4}(i))@testInt(m.g) == i ⊻ (i >>1)endend;
Test Summary: | Pass Total Time
gray code | 16 16 0.4s
Because step takes a value and returns one, a test can fork a state, run two stimuli from it, and compare — nothing has to be reset between cases.
Checking against a reference model
Porting an algorithm is where this shines. Keep the floating-point reference beside the hardware and compare on the same data:
reference(xs) = [sum(xs[max(1, i -3):i]) for i ineachindex(xs)] # a 4-tap moving sum@quartzstruct MovSum@in x::Bits{8} =0@out y::Bits{10} h::Bits{32} =0# the last four 8-bit samplesend@on MovSum posedge(clk) begin h ← h <<8| x y ←Bits{10}(x) + h[0:7] + h[8:15] + h[16:23] # this sample and the three before itendxs =rand(0:255, 50)ys =Int[]let m =MovSum()for x in xs m =step(m; x=Bits{8}(x))push!(ys, Int(m.y))endendys ==reference(xs)
true
When the two differ, findfirst(ys .!= reference(xs)) says at which sample, and you have both models in one process to step through.
A matched filter against its reference
A fuller example, from the package’s own tests: a passband matched filter for a 13-chip Barker code, one chip per sample. The last thirteen 8-bit samples sit in a window register; each is added or subtracted as the code says, and the sum goes through a two-stage pipeline. The arithmetic is a plain Julia function, unrolled into the Verilog:
# A passband matched filter for a 13-chip Barker code: the last thirteen samples of# an 8-bit signal, each added or subtracted as the code says, through a two-stage# pipeline. One chip per sample, so the window is the code's length.usingQuartzHDLconst TAPS =13const BARKER =Bits{13}(0b1111100110101)@quartzstruct Correlator@in x::SBits{8} =0 window::Bits{8 * TAPS} =0 y::Pipeline{2,SBits{16}}@out mf::SBits{16} =0@out valid::Bool =falseendtap(window, k) =SBits{16}(SBits{8}(window[8k:8k+7]))correlate(window) =sum(BARKER[k] ? tap(window, k) :-tap(window, k) for k in0:TAPS-1)@on Correlator posedge(clk) begin window ← window <<8|Bits{8}(x) y ←correlate(window) mf ←coalesce(y, 0) valid ←isnew(y)end
The reference is the same dot product in ordinary integers, over a window that starts empty. Bury the code in noise and run both on the same samples:
usingRandomRandom.seed!(4)code = [BARKER[k] ? 1:-1 for k in0:TAPS-1]xs = [rand(-20:20) for _ in1:60]xs[31:43] .+=100.*reverse(code) # the code, oldest chip firstout =Int[]let m =Correlator()for x in xs m =step(m; x=SBits{8}(x)) m.valid &&push!(out, Int(m.mf))endendpad = [zeros(Int, TAPS); xs]ref = [sum(pad[n - k] * code[k +1] for k in0:TAPS-1) for n in TAPS+1:length(pad)]out[2:end] == ref[1:length(out)-1], argmax(out), maximum(out)
(true, 44, 1311)
The two agree sample for sample — the hardware one sample behind, since its block reads the window before this cycle’s sample joins it — and the peak stands where the code ends, thirteen times the chip amplitude above the noise. When a change to the filter moves any of that, the comparison says at which sample.
Co-simulation
cosim emits the Verilog for a module, writes a testbench, runs it under Icarus Verilog with the same stimulus as the Julia model, and compares every output and pad after every clock:
stim = [(x=Bits{8}(rand(0:255)),) for _ in1:500]r =cosim(MovSum, stim)r.ok
true
The stimulus is a vector with one entry per clock edge, each a named tuple of the module’s inputs. What comes back has ok, mismatches (the first disagreements, with cycle, signal and both values), and the paths of the files it made, for looking at by hand.
Options:
reference = "movsum.v" compares against a hand-written Verilog module instead of the generated one. This is how a ported design is checked against its original, and the original is left untouched — an independent oracle rather than a mirror of our own output.
ref_init = ["state" => "3'd0"] initializes registers of the reference that the Julia model powers up differently.
clocks = (clk=48MHz, clk_slow=1MHz) for a module with several clocks; the testbench then advances them in the right ratio.
scale = (clk_slow=10,) runs a slow clock faster than the board would, so a test with a 400 kHz bus does not take a minute of wall time. A property of the run, not of the design.
debug = true emits @info messages and @check failures into the Verilog as $display and $error.
Where a design uses a black box, simmodels writes behavioural Verilog for each part from its clockout recipes, and cosim includes it; a part with a stand-in needs a Verilog model of its own, passed with extra_sources.
Note
cosim needs iverilog and vvp on the path. It is the one part of QuartzHDL that runs anything but Julia, and it is what makes the claim “the Verilog does what the Julia does” a tested fact rather than an intention.
System tests
A system test builds a Simulation with the library components around the design and talks to it as a host would. It reads like a description of the board:
@testset"the device answers a version request"begin sim =Simulation(Top(); clocks=CLOCKS, wiring=WIRING, flash=W25Q()) host =UART(sim; rx="tx", tx="rx", baud=115200)@run sim begin sim.rst =trueadvance_by(1ms) sim.rst =falseendwrite(host, "V\n")@testreadline(host) =="1.2.0"end;
CI
A QuartzHDL package tests like any Julia package, so the standard GitHub Actions workflow works. Add Icarus Verilog for the co-simulation tests:
A pull request that changes a module runs the unit tests, the reference comparisons, the co-simulations and the system tests before anyone reads the diff.
Next
Verilog output: getting the Verilog out, and the command line.