Skip to content

Your First SystemVerilog Program

Build your first SV program from scratch — module anatomy, ports, initial blocks, testbench structure.

Module 1 · Page 1.4 · Fundamentals

Building From Nothing — The Right Mindset

Most tutorials show you a complete working program and say "run this." This one builds the program one piece at a time, explaining what happens inside the simulator at each step. By the end you won't just have code that works — you'll understand why it works, and more importantly, you'll know exactly what to do when something doesn't work.

The program we're building is a simple but complete verification scenario: a 4-bit up-counter as the design under test, and a testbench that resets it, counts for several cycles, pauses, and checks the output. This is the fundamental pattern of every SystemVerilog simulation ever written — just with more complexity layered on top in professional projects.

Module Anatomy — Every Line Explained

A SystemVerilog module is the fundamental building block. Everything — RTL logic, testbenches, interfaces — is defined inside a module. Let's dissect one completely before writing any actual logic.

Module Anatomy — Annotated
// ① Compiler directive: sets the time unit and precision for this file
`timescale 1ns/1ps
 
// ② Module declaration with port list
//    Keyword + name + ( port list ) + semicolon
module counter (
// ③ Port declarations: direction + type + width + name
input  logic       clk,     // clock — 1-bit 4-state
input  logic       rst_n,   // active-low reset
input  logic       en,      // count enable
output logic [3:0] count    // 4-bit count output
);
 
// ④ Internal signals (optional — only if needed inside the module)
//    These are NOT visible outside the module boundary
logic [3:0] next_count;
 
// ⑤ Combinational logic: compute next state
always_comb
next_count = count + 4'h1;
 
// ⑥ Sequential logic: register the state on clock edge
always_ff @(posedge clk) begin
if (!rst_n)
count <= 4'h0;          // synchronous reset: clear on clock edge
else if (en)
count <= next_count;    // non-blocking: update takes effect next delta
end
 
// ⑦ endmodule: required — marks end of the module definition
endmodule
ElementWhat it isWhat simulator does with it
``timescale 1ns/1ps`Compiler directive — not SV codeSets #1 = 1 nanosecond; tracks time with 1ps resolution
module counter (...)Module declaration with port interfaceCreates a module template; instantiation creates a named instance
input logic clk1-bit 4-state input portCreates a read-only signal driven from outside this module
output logic [3:0] count4-bit 4-state output portCreates a signal driven from inside this module, readable outside
logic [3:0] next_countInternal 4-bit signalModule-scoped — not visible to parent or children
always_combCombinational logic blockEvaluates whenever any input signal changes; infers no storage
always_ff @(posedge clk)Flip-flop register blockActivates only on rising clock edges; infers D flip-flops
count <= next_countNon-blocking assignmentRHS evaluated now; LHS updated at end of current timestep (NBA phase)

Port Directions — input, output, inout

  • input — Module reads this signal; it's driven from outside. Cannot assign to an input inside the module. Always use logic type in SV.
  • output — Module drives this signal; outside can read it. Add logic type — it replaces Verilog's output reg and output wire.
  • inout — Bidirectional — both sides can drive it. Used for tri-state buses. Requires wire type, not logic. Rarely used in modern RTL.
  • Port widths — [N:0] = N+1 bits, MSB first. [0:N] = N+1 bits, LSB first (avoid). Use [N-1:0] for N-bit vectors.

The Testbench — Structure and Strategy

A testbench module has no ports. It is the top of the simulation hierarchy — nothing drives signals into it from outside because there is no outside. The testbench generates its own stimulus, instantiates the DUT, and checks the DUT's responses. It's the test engineer's workbench in software form.

Testbench Structure — Fully Annotated
`timescale 1ns/1ps
 
// ── Testbench module: NO ports — it's the simulation root ─────────
module tb_counter;
 
// ── 1. Signal declarations ─────────────────────────────────────
//    These connect the testbench to the DUT ports
logic       clk   = 1'b0;   // initialized to 0 at start
logic       rst_n = 1'b0;   // start in reset (active-low)
logic       en    = 1'b0;
logic [3:0] count;             // driven by DUT output
 
// ── 2. DUT instantiation ──────────────────────────────────────
//    .port_name(signal_name) — connects each port by name
//    .* shorthand: connects ports to same-named signals
counter dut (
.clk   (clk),
.rst_n (rst_n),
.en    (en),
.count (count)
);
 
// ── 3. Clock generator: toggles every 5ns = 10ns period ───────
always #5 clk = ~clk;
 
// ── 4. Waveform dump (for GTKWave / waveform viewer) ──────────
initial begin
$dumpfile("waves.vcd");
$dumpvars(0, tb_counter);
end
 
// ── 5. Main test sequence ─────────────────────────────────────
initial begin
$display("╔══════════════════════════════╗");
$display("║   Counter Testbench Start    ║");
$display("╚══════════════════════════════╝");
 
// Phase 1: Reset — hold rst_n low for 2 clock cycles
rst_n = 1'b0; en = 1'b0;
@(posedge clk); @(posedge clk); // wait for 2 rising edges
$display("[%4t ns] After reset: count = %0d (expect 0)", $time, count);
assert(count == 4'h0) else $error("FAIL: count should be 0 after reset");
 
// Phase 2: Count — release reset, enable counting for 5 cycles
rst_n = 1'b1; en = 1'b1;
repeat(5) @(posedge clk);
$display("[%4t ns] After 5 clks: count = %0d (expect 5)", $time, count);
assert(count == 4'h5) else $error("FAIL: count should be 5");
 
// Phase 3: Pause — disable enable for 2 cycles
en = 1'b0;
@(posedge clk); @(posedge clk);
$display("[%4t ns] Enable off:   count = %0d (expect 5)", $time, count);
assert(count == 4'h5) else $error("FAIL: count should hold at 5");
 
// Phase 4: Reset again — verify counter clears
rst_n = 1'b0;
@(posedge clk);
$display("[%4t ns] Reset again:  count = %0d (expect 0)", $time, count);
assert(count == 4'h0) else $error("FAIL: count should clear on reset");
 
$display("╔══════════════════════════════╗");
$display("║      All Tests PASSED        ║");
$display("╚══════════════════════════════╝");
$finish;
end
 
endmodule

Expected output:

Simulation Output
╔══════════════════════════════╗
║   Counter Testbench Start    ║
╚══════════════════════════════╝
[  10 ns] After reset: count = 0 (expect 0)
[  60 ns] After 5 clks: count = 5 (expect 5)
[  80 ns] Enable off:   count = 5 (expect 5)
[  90 ns] Reset again:  count = 0 (expect 0)
╔══════════════════════════════╗
║      All Tests PASSED        ║
╚══════════════════════════════╝

System Tasks and Functions — Your Simulation Interface

System tasks and functions are built-in simulator commands starting with $. They're how you communicate with the simulation engine — printing messages, controlling time, querying state, dumping waveforms. Every SystemVerilog engineer uses these constantly.

System Task/FunctionTypeWhat it doesCommon use
$display(...)TaskPrints message with newline, executes immediately at current timeTestbench progress, scoreboard results
$monitor(...)TaskPrints whenever any listed signal changes value (one active at a time)Signal trace during simulation
$strobe(...)TaskPrints at end of current timestep — after all NBA updatesGetting stable post-clock values
$timeFunctionReturns current simulation time as integer (timescale units)Timestamps in messages
$realtimeFunctionReturns current simulation time as real (fractional ns)Sub-nanosecond timestamps
$finishTaskTerminates simulation cleanlyEnd of every testbench
$stopTaskPauses simulation (interactive mode)Breakpoint during interactive debugging
$fatal(severity, msg)TaskTerminates immediately with fatal messageUnrecoverable error conditions
$error(msg)TaskReports error, increments error count, continuesAssertion failures, scoreboard mismatches
$warning(msg)TaskReports warning, continuesAdvisory conditions
$dumpfile(name)TaskSets VCD output filenameFirst line of waveform setup
$dumpvars(d, scope)TaskEnables VCD dumping for given scope and depthSecond line of waveform setup
$sformatf(fmt, ...)FunctionReturns a formatted string — like sprintf in CBuilding messages with embedded values
$randomFunctionReturns a 32-bit random number (4-state integer)Simple random stimulus in Verilog-style code

$display vs $monitor vs $strobe

$display vs $monitor vs $strobe
module tb_print_comparison;
 
logic [3:0] data = 4'h0;
 
// $monitor: fires whenever 'data' changes. Only one active at a time.
initial
$monitor("MONITOR [%0t]: data=%0h", $time, data);
 
initial begin
#10; data = 4'hA;
#10; data = 4'hB;
 
// $display: prints IMMEDIATELY when this line executes
// Even if data changed earlier in this same timestep, you see NOW's value
$display("DISPLAY [%0t]: data=%0h", $time, data);
 
// $strobe: schedules print at END of this timestep
// All NBA (non-blocking) assignments have settled before it prints
// Gives you the FINAL value at this time — most reliable for checking FFs
$strobe("STROBE  [%0t]: data=%0h", $time, data);
 
#10; $finish;
end
 
endmodule
// Output shows monitor fires on each value change;
// $display and $strobe both see the final settled value.

Format Specifiers — Displaying Values Correctly

Format specifiers in $display control how values are printed. Choosing the wrong one gives you garbage output — displaying a 32-bit address as decimal when you wanted hex is a common beginner mistake that makes debug output unreadable.

Format Specifiers — Complete Reference
logic [31:0] addr = 32'hA000_1234;
logic [7:0]  data = 8'b1010_0101;
int           count = 42;
 
// ── Numeric formats ───────────────────────────────────────────────
$display("%d  = %d",  addr, addr);  // decimal: 2684358196
$display("%0d = %0d", count, count); // decimal no padding: 42
$display("%h  = %h",  addr, addr);  // hex: a0001234
$display("%08h= %08h",addr, addr);  // hex, 8 chars wide: a0001234
$display("%b  = %b",  data, data);  // binary: 10100101
$display("%08b= %08b",data, data);  // binary, 8 wide: 10100101
$display("%o  = %o",  data, data);  // octal: 245
 
// ── Simulation time ───────────────────────────────────────────────
$display("%t",  $time);           // time (formatted per $timeformat)
$display("%0t", $time);           // time, no padding
 
// ── String and character ──────────────────────────────────────────
$display("%s",  "Hello SV");      // string literal
$display("%c",  8'h41);           // ASCII char: A
 
// ── Struct/array shorthand ────────────────────────────────────────
typedef struct { int a; int b; } pair_t;
pair_t p = '{10, 20};
$display("%p", p);                  // prints struct: '{a:10, b:20}
 
// ── RULE: always match format to data type ────────────────────────
// Addresses → %08h   (8-digit hex, 0-padded)
// Counters  → %0d    (decimal, no extra spaces)
// Opcodes   → %02h   (2-digit hex)
// Bit flags → %b     (binary — shows each bit)

Simulation Flow — What the Simulator Does Step by Step

This is the piece most tutorials skip entirely. Understanding exactly what the simulator does at each moment is what separates engineers who can debug by reading waveforms from those who just "try things until it works." Elaboration (before T=0)Design hierarchy builtSimulator reads all .sv files, builds module instances, connects ports, resolves parameter values. Any port width mismatch or undefined module is caught here.T = 0, Δ0All processes activate simultaneouslyEvery initial and always block starts executing. Signals declared with initializers (e.g., logic clk = 0) take those values. always_comb blocks evaluate once.T = 0, Δ1 (NBA phase)Non-blocking assignments completeAll <= assignments from Δ0 take effect. If any of these trigger sensitivity lists, new delta cycles are created. This is how flip-flop behavior is modeled.T = 5 nsFirst scheduled event firesalways #5 clk = ~clk fires. Clock toggles from 0→1. Any @(posedge clk) sensitivity lists are triggered. All always_ff blocks with posedge clk evaluate.T = 5 ns, NBA phaseFlip-flop outputs updateNon-blocking assignments from the posedge evaluation complete. The counter output count takes its new value. Any combinational logic that depends on count re-evaluates.T = 10 nsInitial block wakes from @(posedge clk)The testbench initial block was waiting at @(posedge clk). The second rising edge triggers it. The block continues executing until the next wait point.T = N ns$finish encounteredThe initial block reaches $finish. Simulator finishes the current timestep, prints simulation statistics (time, memory, event counts), and exits.

The Complete Program — Both Files

Here are both files as they would exist in your project directory, clean and ready to compile. No shortcuts, no placeholders.

counter.sv — Complete RTL Design
// ─────────────────────────────────────────────────────────────────
// File   : counter.sv
// Design : 4-bit synchronous up-counter
// Ports  : clk (1-bit), rst_n (active-low), en, count [3:0]
// ─────────────────────────────────────────────────────────────────
`timescale 1ns/1ps
 
module counter (
input  logic       clk,
input  logic       rst_n,
input  logic       en,
output logic [3:0] count
);
 
always_ff @(posedge clk) begin
if (!rst_n)
count <= 4'h0;
else if (en)
count <= count + 4'h1;
// If en=0 and rst_n=1: count holds its value (implicit)
end
 
endmodule
tb_counter.sv — Complete Testbench
// ─────────────────────────────────────────────────────────────────
// File : tb_counter.sv
// Tests: reset, count-up, enable hold, re-reset
// Run  : iverilog -g2012 -o sim counter.sv tb_counter.sv && ./sim
// ─────────────────────────────────────────────────────────────────
`timescale 1ns/1ps
 
module tb_counter;
 
// ── Signals ───────────────────────────────────────────────────
logic       clk   = 1'b0;
logic       rst_n = 1'b0;
logic       en    = 1'b0;
logic [3:0] count;
int         fail_count = 0;
 
// ── DUT instantiation ─────────────────────────────────────────
counter dut (.clk(clk), .rst_n(rst_n), .en(en), .count(count));
 
// ── Clock: 10 ns period (100 MHz) ─────────────────────────────
always #5 clk = ~clk;
 
// ── Waveform dump ─────────────────────────────────────────────
initial begin
$dumpfile("counter_waves.vcd");
$dumpvars(0, tb_counter);
end
 
// ── Checker task: compares got vs expected ─────────────────────
task automatic check(
input logic [3:0] expected,
input string        msg
);
#1;  // small delay to let NBA assignments settle
if (count === expected) begin
$display("PASS [%0t ns] %s: count=%0d", $time, msg, count);
end else begin
$error("FAIL [%0t ns] %s: expected=%0d got=%0d",
$time, msg, expected, count);
fail_count++;
end
endtask
 
// ── Global timeout: safety net if simulation hangs ─────────────
initial #10_000 $fatal(1, "TIMEOUT: simulation exceeded 10us");
 
// ── Main test sequence ─────────────────────────────────────────
initial begin
$timeformat(-9, 0, " ns", 6); // format: -9=ns, 0 decimal places
$display("=== Counter Test ===");
 
// Test 1: Reset holds count at 0
rst_n = 0; en = 0;
repeat(3) @(posedge clk);
check(4'h0, "Reset holds 0");
 
// Test 2: Counting
rst_n = 1; en = 1;
repeat(5) @(posedge clk);
check(4'h5, "5 cycles counted");
 
// Test 3: Enable = 0 holds count
en = 0;
repeat(3) @(posedge clk);
check(4'h5, "Count held when en=0");
 
// Test 4: Continue counting from 5
en = 1;
repeat(3) @(posedge clk);
check(4'h8, "Resumed from 5, now at 8");
 
// Test 5: Synchronous reset mid-count
rst_n = 0;
@(posedge clk);
check(4'h0, "Sync reset clears count");
 
// Results
$display("=== %s (failures: %0d) ===",
fail_count ? "FAILED" : "PASSED", fail_count);
$finish;
end
 
endmodule

First-Program Mistakes — Real and Common

Mistake 1: Assigning to an input port inside the module

// WRONG: cannot drive an input port from inside the module always_ff @(posedge clk) clk \<= ~clk; // ERROR: clk is 'input' — can't write it here Root cause: Input ports are read-only from the module's perspective. They're driven by whatever instantiates the module. To generate a clock, declare a local signal in the testbench and toggle it there, then pass it to the DUT as an input.

Mistake 2: Forgetting the # delay in the clock generator

`// WRONG: zero-delay feedback loop — simulator hangs! always clk = ~clk;

// CORRECT: 5ns half-period always #5 clk = ~clk;` Root cause: Without a delay, the always block re-triggers itself instantly creating an infinite loop that never advances simulation time. The simulator spins forever. Always add a time delay to any oscillating signal.

Mistake 3: Checking output immediately after clock edge

`// FRAGILE: count might not have updated yet (NBA phase pending) @(posedge clk); if (count == 4'h5) ... // might see pre-clock value!

// SAFE: small delay lets NBA phase complete @(posedge clk); #1; if (count == 4'h5) ... // sees post-clock value reliably**Root cause:** Non-blocking assignments update at the NBA phase, which runs after the posedge evaluation. Reading the signal immediately at the clock edge may catch it before the flip-flop output has updated. Adding#1(or using$strobe`) ensures you see the settled post-clock value.

Mistake 4: Using blocking (=) in always_ff

`// WRONG: blocking assignment in clocked block always_ff @(posedge clk) count = count + 1; // = is blocking — wrong flip-flop behavior

// CORRECT: non-blocking always_ff @(posedge clk) count <= count + 1; // <= is non-blocking — correct` Root cause: Blocking assignment in a clocked block evaluates AND assigns immediately — effectively creating combinational behavior for the rest of that block's execution. Non-blocking schedules the update for the NBA phase, correctly modeling hardware flip-flop behavior.

Mistake 5: Module name doesn't match file name

// File: Counter.sv (capital C) // Module: module counter (lowercase c) // On Linux: these are DIFFERENT — case-sensitive filesystem Root cause: Linux filesystems are case-sensitive. If you name the file Counter.sv but the module inside is counter, and your compile command references counter.sv, the file won't be found. Convention: always use lowercase with underscores for both file names and module names, and keep them matching.

Interview Questions

Q1: What is the difference between an initial block and an always block?

An initial block executes once starting at time 0 and terminates when it reaches the end of the block (or $finish). An always block (and its SV variants always_ff/comb/latch) executes continuously — it re-triggers based on its sensitivity list and runs for the entire simulation. initial blocks are for testbench stimulus sequences. always blocks model hardware that is always operating.

Q2: What is the purpose of #1 after a posedge clock event in a testbench?

In SystemVerilog simulation, non-blocking assignments (<=) used in always_ff blocks take effect at the NBA (Non-Blocking Assignment) phase — which runs after the posedge evaluation. If you read a flip-flop output immediately at @(posedge clk), you might see the pre-clock value before the NBA phase completes. Adding #1 (a tiny 1ns delay) ensures the NBA phase has run and you're reading the correctly updated post-clock value. Professional testbenches handle this more formally using clocking blocks.

Q3: What does . mean in a module instantiation?*

.* is implicit port connection by name. When you instantiate a module with dut (.*), the simulator automatically connects every port to a signal in the enclosing scope that has exactly the same name. It's equivalent to listing all .port(signal) pairs manually. This eliminates repetitive port connection lists. Explicit connections take priority: dut (.clk(sys_clk), .*) explicitly connects clk to sys_clk, while all other ports connect by name.

Q4: What is the difference between $display and $strobe?

$display prints immediately when the statement executes — in the active region of the current timestep. If called right after a posedge, flip-flop outputs may not have updated yet (NBA phase is pending). $strobe schedules the print for the end of the current timestep — after all NBA assignments have settled. For checking flip-flop outputs correctly after a clock edge, $strobe gives you the final settled value; $display may give you the pre-update value.

Best Practices for Your First Programs

  • Add a global timeout — Always add initial #10_000 $fatal(1, "TIMEOUT") in every testbench. If simulation hangs (zero-time loop, missed $finish), it terminates automatically instead of running forever.
  • Check with ===, not == — Use === (case equality) in testbench assertions. == returns X if either operand has X — causing silent pass-through of uninitialized values. === always returns 0 or 1.
  • Always dump waveforms — Put $dumpfile/$dumpvars as the first statements in every testbench. Waveforms are your primary debugging tool — having them from the start costs nothing.
  • Separate DUT from TB — Keep RTL in counter.sv, testbench in tb_counter.sv. Never put testbench code in the design file. This separation is how real projects are structured.
  • Use a checker task — Don't scatter if/else $error throughout your testbench. Create a task check(expected, msg) that centralizes comparison logic and tracks failure counts.
  • Count failures, not just report — Keep a fail_count integer. Print a final summary: PASSED (0 failures) or FAILED (3 failures). Makes automated regression parsing trivial.

Summary — Module 1 Complete

You've built a complete SystemVerilog simulation from the ground up — an RTL design, a testbench with real checking logic, and a clear understanding of what the simulator does at each moment. The counter example is deliberately simple, but the structure — DUT + testbench, clock generator, reset sequence, stimulus, checker task, timeout, waveform dump — is the same structure used in every professional verification project, just with more complexity.