Kludgeworks

← All tutorials

🖥️ PixelCore — Build a RISC-V CPU in Simulation · Part 3 · Beginner

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.

countermoduleclkrstcount[3:0]
The 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] makes count four bits wide.
  • always_ff @(posedge clk) — “on every rising edge of the clock, do this.” always_ff tells 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, schedule count to 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:

+1addermux2:1count4-bit registerDQ0rstclkcount[3:0]feedback: current count
What 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:

tb_counter — testbenchclock + resetstimuluscounter(DUT)$display+ wave.vcdclkrstcount[3:0]
The testbench wraps the design: it generates the clock and reset, feeds them into the counter (the DUT), and records the output to the console and a waveform file.
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. #5 is 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 to wave.vcd so we can open it in GTKWave afterwards.
  • initial begin ... end — runs once at the start of simulation. Here it drives reset, then loops, printing count on 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:

  • ~clk flips the clock bit: 0 becomes 1, 1 becomes 0.
  • #5 is a delay — “wait 5 time units, then do the assignment.”
  • always repeats 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:

clk051015202530time unitsone period = 10 units
The clock generator in action. Each flip is 5 time units apart (the #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 $dumpvars actually writes wave.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:

  1. In the top-left panel, click tb_counter to expand the hierarchy. You’ll see clk, rst, count, and the dut instance.
  2. Select clk, rst, and count, then drag them into the Signals area (or click Append).
  3. 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:

GTKWave showing clk, rst and count[3:0] for the counter testbench. rst is high briefly at the start, clk is a steady square wave, and count steps through 0, 1, 2 … up to F (15) in hex, then wraps back to 0 and continues 1, 2, 3.
The counter in GTKWave: rst pulses high at the start, clk ticks steadily, and count[3:0] climbs 0 → F (hex) before wrapping back to 0 — exactly matching the console output.

What you just did

Step back and appreciate the loop you just completed:

  1. Described hardware in SystemVerilog (counter.sv).
  2. Built a testbench to drive and observe it (tb_counter.sv).
  3. Simulated it into a running program with Verilator.
  4. 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.