Your First Simulation: A Counter in SystemVerilog
Part three of PixelCore. We write a small piece of real hardware, simulate it with Verilator, and watch its signals tick in GTKWave — exercising the exact write → simulate → inspect loop we'll use to build the entire CPU.
In this part
- Write your first real hardware module and a testbench to exercise it.
- Simulate a design end-to-end with Verilator and read its console output.
- Open the result in GTKWave and understand what the waveform is telling you.
Your environment is ready. Now we use it — not to build the CPU yet, but to prove the whole chain works and to learn the loop you’ll repeat hundreds of times: write a design → write a testbench → simulate → inspect the waveform. Master this loop on something tiny and the rest of the project becomes “the same thing, bigger.”
Our subject is a 4-bit counter: a piece of hardware that counts up by one every clock tick and wraps around from 15 back to 0. Small, but genuinely sequential — it has a clock, a reset, and state that changes over time, which makes it perfect for a first waveform.
Set up the project folder
We made ~/pixelcore back in Part 1. Move into it and open it in VS Code:
🐧 Run in Ubuntu:
cd ~/pixelcore
code .
Create two files (in VS Code, or with touch counter.sv tb_counter.sv): one for the
design, one for the testbench.
The design: counter.sv
This is the hardware — the thing that could, in principle, become real logic gates on a chip.
Before the code, picture the module as a black box: a component with a few wires going in
and one bus coming out. Two inputs (clk, rst) and one 4-bit output (count) — that’s the
entire interface the rest of a system would see.
counter module as a black box — two 1-bit inputs, one 4-bit output. Everything else is hidden inside.module counter (
input logic clk, // clock: the heartbeat
input logic rst, // synchronous reset
output logic [3:0] count // 4-bit output, counts 0..15
);
// On every rising edge of the clock:
always_ff @(posedge clk) begin
if (rst)
count <= 4'd0; // reset forces the count to zero
else
count <= count + 4'd1; // otherwise, count up by one
end
endmodule
A few things worth naming, because they’re the vocabulary of everything to come:
module/endmodule— the unit of hardware design. A module has ports (its inputs and outputs) and a body describing its behaviour.logic— the basic signal type in SystemVerilog.[3:0]makescountfour bits wide.always_ff @(posedge clk)— “on every rising edge of the clock, do this.”always_fftells the tools this block describes flip-flops (sequential logic that remembers state).<=— the non-blocking assignment, used for sequential logic. Read it as “on the clock edge, schedulecountto become this new value.”
Here’s what those four lines of code actually build in hardware. The register holds the
current count; its output loops back through a +1 adder; a multiplexer (a signal-picker)
chooses between that “count + 1” value and a constant 0, steered by rst; and the chosen
value is what the register latches on the next clock edge:
count <= rst ? 0 : count + 1 becomes: a 4-bit register whose output feeds a +1 adder, with a mux selecting 0 on reset. The orange wire is the feedback that makes it count.The testbench: tb_counter.sv
The counter can’t run on its own — it needs a clock, a reset sequence, and something to observe it. That’s the job of a testbench: a piece of SystemVerilog that exists only to drive and watch your design in simulation. It wraps around your design — generating inputs on one side and recording outputs on the other:
module tb_counter;
// Signals to connect to the design
logic clk = 0;
logic rst;
logic [3:0] count;
// Instantiate the design under test (DUT), wiring its ports to our signals
counter dut (
.clk (clk),
.rst (rst),
.count (count)
);
// Clock generator: flip clk every 5 time units -> a 10-unit period
always #5 clk = ~clk;
// The test sequence
initial begin
// Record everything into a waveform file for GTKWave
$dumpfile("wave.vcd");
$dumpvars(0, tb_counter);
rst = 1; // hold the counter in reset
#12 rst = 0; // release reset after a couple of clock edges
// Watch it count for 20 clock edges
repeat (20) begin
@(posedge clk);
$display("t=%0t count=%0d", $time, count);
end
$finish; // end the simulation
end
endmodule
What each part is doing:
counter dut (...)— creates an instance of your design and connects its ports.dut(“design under test”) is just a conventional name.always #5 clk = ~clk;— a free-running clock.#5is a time delay — pause 5 units, then toggle. This is a simulation-only construct (real hardware doesn’t “wait 5 units”), which is exactly why it lives in the testbench, not the design.$dumpfile/$dumpvars— tell the simulator to log every signal towave.vcdso we can open it in GTKWave afterwards.initial begin ... end— runs once at the start of simulation. Here it drives reset, then loops, printingcounton each rising edge.$display/$finish— print to the console, and stop the simulation.
Zooming in on the clock generator
That one line — always #5 clk = ~clk; — does a lot, and you’ll write some version of it in
every testbench you make, so it’s worth unpacking. Read it in three pieces:
~clkflips the clock bit:0becomes1,1becomes0.#5is a delay — “wait 5 time units, then do the assignment.”alwaysrepeats its statement forever.
Put together, it loops endlessly: wait 5 → flip → wait 5 → flip → …. Since clk starts at
0 (from logic clk = 0;), that produces a steady square wave:
#5), so one full low-then-high cycle — the period — is 10 units.Want a slower clock? Just increase the number — always #10 clk = ~clk; gives a 20-unit
period. And note this is a simulation-only trick: real hardware gets its clock from an
oscillator, not a “wait 5 units” instruction, which is exactly why it lives here in the
testbench.
Simulate it with Verilator
From the ~/pixelcore folder, run:
🐧 Run in Ubuntu:
verilator --binary --timing --trace -j 0 counter.sv tb_counter.sv --top-module tb_counter
What the flags mean:
--binary— build a standalone runnable executable (no hand-written C++ needed).--timing— enable the#delays and@(posedge clk)waits our testbench uses.--trace— enable waveform (VCD) output, so$dumpvarsactually writeswave.vcd.-j 0— compile using all your CPU cores (faster).--top-module tb_counter— tell Verilator the testbench is the top of the hierarchy.
Verilator produces the executable under obj_dir/. Run it:
🐧 Run in Ubuntu:
./obj_dir/Vtb_counter
You should see the counter come to life in your terminal:
t=15 count=0
t=25 count=1
t=35 count=2
t=45 count=3
...
t=145 count=13
t=155 count=14
t=165 count=15
t=175 count=0
...
Reset holds it at 0, then it climbs 1, 2, 3 … up to 15, and wraps back to 0 — exactly what a 4-bit counter should do. That wraparound is your first real proof that hardware you described is behaving correctly.
See it in GTKWave
The console output is nice, but the real superpower is seeing the signals. Open the waveform:
🐧 Run in Ubuntu:
gtkwave wave.vcd
A GTKWave window opens on your Windows desktop (thanks to WSLg). To view your signals:
- In the top-left panel, click
tb_counterto expand the hierarchy. You’ll seeclk,rst,count, and thedutinstance. - Select
clk,rst, andcount, then drag them into the Signals area (or click Append). - Click the Zoom Fit button (the magnifying glass with a box) to fit the whole run on screen.
Now you can read the story of the simulation directly:
clk— a steady square wave, the heartbeat driving everything.rst— high at the start, then dropping low once, right where the counting begins.count— a staircase climbing 0 → 15, then snapping back to 0 and climbing again.
Here’s the finished waveform — the whole run in one picture:
What you just did
Step back and appreciate the loop you just completed:
- Described hardware in SystemVerilog (
counter.sv). - Built a testbench to drive and observe it (
tb_counter.sv). - Simulated it into a running program with Verilator.
- Inspected its exact behaviour — in the console and in a waveform.
This four-step cycle is the heartbeat of the entire project. An ALU, a register file, a pipeline stage, the whole CPU — every one of them gets built and verified with this same loop. You now know how to make hardware, run it, and prove it works, all without touching a single physical component.
The setup is over. Next, we build a processor.
That’s the entire onboarding done: a Linux environment, a full simulation toolchain, and a proven workflow. Everything from here is the real project.
In Part 4, we design the first genuine building block of the CPU — the ALU (Arithmetic Logic Unit), the part that actually does the math and logic every instruction relies on. We’ll build it, then verify it far more rigorously than we did this counter, because from now on correctness is everything. The counter was practice. Now we start building the machine.