Stage 3: the SystemVerilog
python prng_build.py --through svsim
You write two things here: the state update in prng_next.sv, and the loop in
tb_prng.sv that turns single-bit steps into whole samples. The stage then runs
the simulation and compares its output with your own Python model, sample for
sample — the two languages have to agree.
The two files, and why there are two
Hardware is almost always written as a pair: the thing you are building, and a separate program that exercises it.
prng_next.sv is the design under test — the DUT. It is the hardware. Every
line in it has to be something that could become real logic on a chip: gates,
wires, registers. Nothing in it reads files, prints, or waits for a while.
tb_prng.sv is the testbench. It is not hardware and is never synthesised.
Its whole job is to drive the DUT — make up inputs, apply them, look at what
comes out, and write the results somewhere you can check them. Because it never
becomes a circuit, it can do things hardware cannot: open files, print, and take
an arbitrary number of steps in an initial block.
The relationship is the same one you already have in Python. prng_next() is the
algorithm; lfsr_stream() is the harness that calls it repeatedly and collects
the answers. Here prng_next is the module and the testbench is the harness.
In this lab the DUT is one step of the LFSR and the testbench does the
iterating — it calls the DUT BITS times per sample, feeding each output back in
as the next input. That split is deliberate. Iterating inside the hardware needs
a clock and registers, which is sequential logic, and that is Unit 2. Once
you have it, the same generator becomes a module that advances itself once per
clock edge and the testbench shrinks to almost nothing.
So for now: everything that repeats lives in the testbench, and the DUT is pure combinational logic that computes one answer from one input.
The state update, in prng_next.sv
You get the module’s shell. The ports and the body are yours:
module prng_next #(
parameter int WIDTH = 16,
parameter logic [15:0] TAPS = 16'hD008
)(
// your port declarations here
);
always_comb begin
// your one line here
end
endmodule
Purely combinational — no always_ff, no clock, no reset, no state of its own.
It is a function written in hardware: give it a state, it gives you the next one.
The sequencing lives in the testbench.
Declaring the ports
A module’s port list is its interface: the wires that cross the boundary between this block and whatever instantiates it. Each one needs three things — a direction, a type, and a width:
direction type [range] name
You need two ports. The testbench drives one and reads the other, so one is an
input and one is an output. Both carry a whole LFSR state, so both are
WIDTH bits wide — write the range in terms of WIDTH rather than as [15:0],
so that changing the parameter changes the ports with it.
Ports are separated by commas, with no comma after the last one. The names have to be the ones the testbench already uses; look at how it instantiates the module and you will find them.
Getting a width wrong here does not usually produce an error. SystemVerilog will happily connect a 16-bit signal to an 8-bit port and quietly discard the top half. It is the same silent truncation as the discarded product in the datatypes demo, and it is why widths are worth checking by hand rather than trusting.
Two operators you need
Reduction XOR. Applied to a vector, unary ^ XORs together every bit of
its operand and gives back a single bit:
logic [7:0] w;
logic parity;
assign parity = ^w; // w[7] ^ w[6] ^ w[5] ^ ... ^ w[0]
This is not the same as the binary ^ you use between two values. It is the
reason the feedback needs no explicit chain of XORs: ^(state_in & TAPS) masks
off everything but the taps and then XORs what is left.
Concatenation. {a, b} builds a wider vector by gluing pieces together, most
significant first. That is how you write a shift:
{state_in[WIDTH-2:0], new_bit} // drop the top bit, append new_bit at the bottom
Count the width: WIDTH-1 bits plus 1 bit is WIDTH bits, so this fits
state_out exactly. Getting that count wrong is the usual way to lose a bit
silently — the same trap as the truncated product in the
datatypes demo.
The sample loop, in tb_prng.sv
The second thing you write. prng_next advances the state by one bit; something
has to run it BITS times to build a whole sample, and that something is the
testbench. It is the same loop you wrote as prng_sample in Python.
for (k = 0; k < BITS; k++) begin
state_in = state;
#1; // let the combinational logic settle
state = state_out;
end
sample = ...
The #1 is not optional. prng_next is combinational, so state_out only
reflects a new state_in after the simulator has advanced time. Leave it out and
you read the previous value on every pass, and the generator never moves. This is
the difference between a function call and a piece of hardware: the hardware does
not compute the moment you assign to its input, it computes shortly after, and
in a testbench you have to say how long to wait.
sample is BITS wide and state is WIDTH wide, so taking the low bits is a
part-select: state[BITS-1:0].
What is given
Everything else in tb_prng.sv: it reads the seed, sample count and output
directory as plusargs, instantiates your prng_next, runs the outer loop over
samples, and writes vectors/prng_sv.csv.
There is also a watchdog, which is worth stealing for your own testbenches:
initial begin
#10_000_000;
$display("FATAL: still running after 10 ms of simulated time.");
$fatal(1);
end
A loop with the wrong bound runs forever, and a simulator that never returns looks exactly like a simulator that is working hard. This turns a hang into an error message.
What is checked
Your SystemVerilog against your Python model, sample for sample, with partial credit for a partial match.
When they disagree, the feedback names the first sample that differs and what each side said. That is usually enough — if sample 0 already differs, your update is wrong; if the streams run together for a while and then split, suspect a width or a wrap.
Two things score zero regardless of matching:
- The simulation did not run. A syntax error or an elaboration failure is a zero with the tool output attached, not a crashed build. You can still submit.
- Your Python model produced a constant stream. Every file starts with a
placeholder that returns 0, and two placeholders agree with each other
perfectly while implementing nothing. Matching a constant proves nothing, so it
earns nothing. Fix
prng_model.pyfirst.
Note what this stage does not check: whether the thing you built is any good. Two implementations can agree perfectly and both be wrong — which is exactly why the evaluation stage comes before this one.
Running the simulator yourself
prng_build.py calls the simulator for you through a Waveflow helper,
run_sv_sim. That helper does nothing magic — it shells out to three Vivado
command-line tools in a scratch directory and raises an error if any of them
exits non-zero.
It is worth doing by hand at least once. Waveflow is this course’s convenience, not an industry standard; nobody at your next job will have heard of it. The tools underneath are the ones everybody uses, and being able to drive them directly is what lets you debug when a script does something you did not expect.
The three commands
From the lab directory:
# Put the Vivado tools on PATH for this shell (adjust for your install)
$env:PATH = "C:\Xilinx\2025.1\Vivado\bin;$env:PATH"
mkdir sim\manual\logs -Force
cd sim\manual
# 1. Compile: parse the SystemVerilog into a library called `work`
xvlog -sv ..\..\prng_next.sv ..\..\tb_prng.sv
# 2. Elaborate: link the modules into a runnable snapshot named tb_prng_sim
xelab tb_prng -s tb_prng_sim -debug typical -log logs/xelab.log
# 3. Run it, passing the values the testbench reads with $value$plusargs
xsim --% tb_prng_sim --runall -log logs/xsim.log -testplusarg "vecdir=." -testplusarg "nsamp=1000" -testplusarg "seed=45242"
You should see the first eight samples printed and prng_sv.csv written into the
directory you ran from.
The three steps map onto compiling a C program: xvlog is the compiler,
xelab is the linker, and xsim runs the resulting binary. tb_prng is named
in step 2 because the testbench is the top of the hierarchy — it instantiates
the DUT, not the other way round.
The
--%in thexsimline is PowerShell’s stop-parsing token, and without it the-testplusarg "name=value"pairs are mangled before the tool ever sees them. On Linux, macOS, or incmd, drop it.45242is0xB0BA, the seed —$value$plusargs("seed=%d", ...)reads decimal.
What Waveflow adds
Nothing you could not type. It resolves where the Vivado binaries live so you do
not have to source settings64, makes and cleans the scratch directory, converts
path plusargs to forward slashes (a Windows backslash arrives at
$value$plusargs with \r read as a carriage return), checks that the testbench
actually wrote the files it promised, and raises with the log path when a step
fails.
That last one matters more than it sounds: xsim exits 0 even after $fatal.
A testbench that could not open its input file will report success to the shell.
Anything driving these tools from a script has to check the outputs rather than
the exit code — which is a thing worth knowing whatever tooling you end up using.
Once a design gets past a few files, everyone automates this. The wrapper is a Makefile, a TCL script, or a Python harness depending on the shop, but the three commands underneath are the same ones above.
Go to grading and submission