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.
// ① 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| Element | What it is | What simulator does with it |
|---|---|---|
| ``timescale 1ns/1ps` | Compiler directive — not SV code | Sets #1 = 1 nanosecond; tracks time with 1ps resolution |
module counter (...) | Module declaration with port interface | Creates a module template; instantiation creates a named instance |
input logic clk | 1-bit 4-state input port | Creates a read-only signal driven from outside this module |
output logic [3:0] count | 4-bit 4-state output port | Creates a signal driven from inside this module, readable outside |
logic [3:0] next_count | Internal 4-bit signal | Module-scoped — not visible to parent or children |
always_comb | Combinational logic block | Evaluates whenever any input signal changes; infers no storage |
always_ff @(posedge clk) | Flip-flop register block | Activates only on rising clock edges; infers D flip-flops |
count <= next_count | Non-blocking assignment | RHS 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.
`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
endmoduleExpected 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/Function | Type | What it does | Common use |
|---|---|---|---|
$display(...) | Task | Prints message with newline, executes immediately at current time | Testbench progress, scoreboard results |
$monitor(...) | Task | Prints whenever any listed signal changes value (one active at a time) | Signal trace during simulation |
$strobe(...) | Task | Prints at end of current timestep — after all NBA updates | Getting stable post-clock values |
$time | Function | Returns current simulation time as integer (timescale units) | Timestamps in messages |
$realtime | Function | Returns current simulation time as real (fractional ns) | Sub-nanosecond timestamps |
$finish | Task | Terminates simulation cleanly | End of every testbench |
$stop | Task | Pauses simulation (interactive mode) | Breakpoint during interactive debugging |
$fatal(severity, msg) | Task | Terminates immediately with fatal message | Unrecoverable error conditions |
$error(msg) | Task | Reports error, increments error count, continues | Assertion failures, scoreboard mismatches |
$warning(msg) | Task | Reports warning, continues | Advisory conditions |
$dumpfile(name) | Task | Sets VCD output filename | First line of waveform setup |
$dumpvars(d, scope) | Task | Enables VCD dumping for given scope and depth | Second line of waveform setup |
$sformatf(fmt, ...) | Function | Returns a formatted string — like sprintf in C | Building messages with embedded values |
$random | Function | Returns a 32-bit random number (4-state integer) | Simple random stimulus in Verilog-style code |
$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.
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.
// ─────────────────────────────────────────────────────────────────
// 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// ─────────────────────────────────────────────────────────────────
// 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
endmoduleFirst-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.