Volume 08 Intermediate 5 sub-modules ~55 min read

SystemVerilog for Design (IEEE 1800)

Volume 01 called reg the worst-named keyword in the language and promised that SystemVerilog fixes it. It does - but the real theme of this volume is bigger than naming. Every feature here exists to convert a class of bug that Verilog would let you ship into an error the compiler catches before you run a single test.

SystemVerilog is a superset, not a replacement Every legal Verilog-2001 file is legal SystemVerilog. You can adopt these features one at a time in an existing codebase, and mix old and new modules freely. Nothing in this volume requires rewriting anything.

8.1 2-state vs 4-state data types

Verilog had one value system: four states (0, 1, X, Z). SystemVerilog adds a second, faster family that holds only 0 and 1. Choosing between them is a real decision with real consequences.

Type States Width Signed Typical use
logic41 (packable)noEverything in RTL
wire41 (packable)noOnly where multiple drivers are needed
integer432yesLegacy loop counters
bit21 (packable)noTestbench flags, counters
byte28yesTestbench data
int232yesLoop variables, scoreboard maths
longint264yesLarge counters, addresses

The temptation is to reach for 2-state types everywhere - they simulate roughly twice as fast and use half the memory. In RTL that is a mistake, and here is why.

Comparison showing four-state logic exposing an uninitialised register as X while a two-state bit silently reads zero A REGISTER THAT WAS NEVER RESET rst_n reset released logic 4-state XXXX ← the bug is VISIBLE 0000 0001 bit 2-state 0000 ← looks perfectly fine 0000 0001 4-state: the test FAILS. You find the missing reset today. 2-state: the test PASSES. Silicon powers up to a random value.
Figure 8.1 - X is not a nuisance; it is a free bug detector. A 2-state variable quietly initialises to 0, which happens to match what the test expected - so a genuine missing-reset bug sails through simulation and shows up in the lab, where real flops power up unpredictably.
The rule 4-state (logic) for all RTL and everything on the DUT boundary. You want X propagation there - it is the mechanism from Volume 1.3 that makes missing resets and bus conflicts visible. Use 2-state types for testbench internals: loop counters, scoreboard arithmetic, array indices - places where an X would be meaningless anyway.

Why logic is safer than reg and wire

logic can be driven procedurally or continuously, which removes the wire/reg decision entirely. But the real win is what it forbids: a logic variable may have only one driver, and the compiler enforces it.


module dual_drive (
  input  wire a, b, sel,
  output wire y
);
  // TWO continuous drivers on one wire.
  // Verilog allows this. The result is
  // resolved by strength - in practice
  // X whenever they disagree.
  assign y = a;
  assign y = b;

  // No error. No warning in many tools.
  // You find it in the waveform, days later.
endmodule

module dual_drive (
  input  logic a, b, sel,
  output logic y
);
  assign y = a;
  assign y = b;
  //     ^^^
  // ERROR: variable 'y' driven by more
  // than one continuous assignment.
  //
  // Caught at COMPILE time, with a file
  // and line number, before any test runs.
endmodule
When you still need wire Exactly one case: a genuine multi-driver net - a tri-state bus where several devices take turns driving, as in Volume 1.3. That is the whole point of a net type, and logic deliberately cannot express it. Inside a modern chip those are rare; at I/O pads they are still the norm.

8.2 Enums, structs & user-defined types

Volume 04 built state machines out of localparam constants. That works, but the state variable is just a number - nothing stops you assigning 7 to a 5-state machine, and your waveform viewer shows 3'b011 instead of S101.


// The base type is NOT optional in practice. Without "logic [2:0]" an
// enum defaults to a 32-bit 2-state int - wasteful, and it loses X
// propagation on your state register.
typedef enum logic [2:0] {
  S0    = 3'd0,
  S1    = 3'd1,
  S10   = 3'd2,
  S101  = 3'd3,
  S1011 = 3'd4
} state_e;

state_e state, next_state;

always_ff @(posedge clk or negedge rst_n)
  if (!rst_n) state <= S0;
  else        state <= next_state;

always_comb begin
  next_state = state;
  unique case (state)          // "unique" asks the tool to check overlap
    S0   : next_state = din ? S1    : S0;
    S1   : next_state = din ? S1    : S10;
    S10  : next_state = din ? S101  : S0;
    S101 : next_state = din ? S1011 : S10;
    S1011: next_state = din ? S1    : S10;
  endcase
end

// Enums carry methods. This prints "S101", not "3".
always_ff @(posedge clk)
  if (trace_en) $display("state = %s", state.name());
What you gainWhy it matters
Waveforms show namesDebugging an FSM by reading 3'b011 is how afternoons disappear
Type checkingAssigning a raw integer to an enum is an error, not a silent illegal state
.name()Log messages that a human can read
.next() .first() .num()Iterate every state in a testbench without a hand-written list
One place to add a stateThe typedef. Not five localparam lines across three files

Packed structs: a bundle that is still a bus

Bit layout of a packed struct compared with the separate variables of an unpacked struct PACKED - ONE CONTIGUOUS 37-BIT VECTOR valid [36] id [35:32] data [31:0] pkt.data == pkt[31:0] - the fields ARE bit ranges Assignable whole · passes through a port · sliceable · synthesizable UNPACKED - THREE UNRELATED VARIABLES valid id data No bit layout. Not a bus. Not synthesizable as one.
Figure 8.2 - packed is the keyword that keeps a struct usable as hardware. The first field declared becomes the most significant bits, so the layout is predictable and every field is simultaneously a named member and a bit range.

typedef struct packed {
  logic        valid;      // bit 36  - first declared = most significant
  logic [3:0]  id;         // bits 35:32
  logic [31:0] data;       // bits 31:0
} pkt_t;                   // 37 bits total

// A whole bundle crosses a module boundary as ONE port.
module sink (
  input  logic clk,
  input  pkt_t pkt_in
);
  always_ff @(posedge clk)
    if (pkt_in.valid)
      $display("id=%0d data=%h", pkt_in.id, pkt_in.data);
endmodule

// Because it is packed, all of these are legal and mean the same thing:
//   pkt.data        pkt[31:0]        pkt_t'(37'h1_0000_00FF)
//
// Adding a field to pkt_t updates every module that uses it. No port
// lists to edit, nothing to get out of step.

8.3 Procedural blocks with synthesis intent

Volume 1.5 spent a whole section on accidental latches, and ended with a promise: there is a way to make the tool catch them for you. This is it.

always @(*) tells the tool what to be sensitive to. always_comb tells it what you meant - and a tool that knows your intent can check it.

The same incomplete conditional producing a buried warning under always at star, and a hard compile error under always_comb SAME CODE - if (en) y = a; WITH NO ELSE always @(*) "be sensitive to these" Warning: latch inferred line 4131 of a 9000-line log LATCH SHIPS found at DFT, or in the lab always_comb "this IS combinational" ERROR: y not assigned on every path BUILD STOPS fixed in 30 seconds Declaring intent is what lets a tool tell the difference between a bug and a decision.
Figure 8.3 - Nobody reads warnings. Everybody reads errors. That is the entire practical argument for the always_* family.
always @(*) always_comb
Latch inference Warning, if you are lucky Error
Runs at time 0 Not guaranteed - waits for an input to change Always
Sensitive to function contents No - a classic silent bug Yes
Another block may assign the same var Allowed - race conditions follow Compile error

// COMBINATIONAL - the tool errors if this could infer a latch.
always_comb begin
  next_state = state;            // default first (Volume 1.5 still applies)
  case (state)
    IDLE: if (start) next_state = RUN;
    RUN : if (done)  next_state = IDLE;
    default: next_state = IDLE;
  endcase
end

// SEQUENTIAL - the tool errors if this is not a flip-flop.
always_ff @(posedge clk or negedge rst_n) begin
  if (!rst_n) count <= '0;       // '0 fills the whole width with zeros
  else if (en) count <= count + 1'b1;
end

// LATCH - deliberately. Declaring it makes review trivial: an
// always_latch is a decision, an inferred latch is an accident.
always_latch begin
  if (gate) q_held <= d;
end
Small things worth adopting at the same time '0 and '1 fill any width with zeros or ones, so {W{1'b0}} becomes '0 and stays correct when the width changes. unique case asks the tool to check that branches do not overlap and that one always matches - the safe, checked version of the parallel_case pragma Volume 4.5 told you never to use.

8.4 Interfaces, modports & clocking blocks

A bus is a dozen signals that always travel together. In Verilog you write those twelve names in every port list, every instantiation, and every wire declaration between them - and when the bus gains a thirteenth signal, you edit all of it.

Individual bus wires between two modules compared with a single interface connection using modports WITHOUT AN INTERFACE - 8 SIGNALS, 3 PLACES TO EDIT EACH MASTER SLAVE paddr psel penable pwrite pwdata prdata pready pslverr WITH AN INTERFACE - ONE CONNECTION, ONE FILE TO EDIT MASTER apb_if.master SLAVE apb_if.slave interface apb_if modports set the direction per side - wiring a master to a master is now a compile error.
Figure 8.4 - The interface owns the signal list. Modules declare which role they play, and the modport supplies the directions. Adding pprot to the bus becomes a one-line change in one file.

interface apb_if #(
  parameter int AW = 32,
  parameter int DW = 32
) (
  input logic pclk,
  input logic presetn
);

  logic [AW-1:0] paddr;
  logic          psel, penable, pwrite;
  logic [DW-1:0] pwdata, prdata;
  logic          pready, pslverr;

  // A modport is a VIEW of the same signals with directions applied.
  modport master (
    input  pclk, presetn, prdata, pready, pslverr,
    output paddr, psel, penable, pwrite, pwdata
  );

  modport slave (
    input  pclk, presetn, paddr, psel, penable, pwrite, pwdata,
    output prdata, pready, pslverr
  );

endinterface


// The module declares a ROLE, not eight ports.
module apb_slave (apb_if.slave bus);

  always_ff @(posedge bus.pclk or negedge bus.presetn) begin
    if (!bus.presetn) begin
      bus.prdata <= '0;
      bus.pready <= 1'b0;
    end else begin
      bus.pready <= bus.psel && bus.penable;
      if (bus.psel && bus.penable && !bus.pwrite)
        bus.prdata <= regfile[bus.paddr[7:2]];
    end
  end

endmodule
An honest caveat about interfaces in RTL Interfaces are synthesizable in modern tools, but support has historically been uneven - and some flows, lint rules and legacy IP integration steps handle them badly. A number of teams therefore use interfaces only in the testbench and keep flat port lists in synthesizable RTL. Neither position is wrong; find out which your project takes before you refactor a bus.

Clocking blocks - solving a race you already know about

A testbench driving and sampling at the same clock edge as the DUT hits exactly the race from Volume 1.2: two processes triggered by one edge, with no defined order. Clocking blocks fix it by moving testbench sampling into a later region of the stratified event queue.

Testbench sampling at the clock edge is ambiguous, while a clocking block with input skew samples the value just before the edge deterministically clock edge clk dut_out changes t_cq after the edge naive TB might see the NEW value… …or the OLD one. Undefined. clocking cb always the value just BEFORE the edge input #1step WHY A CLOCKING BLOCK REMOVES THE TESTBENCH RACE
Figure 8.5 - input #1step samples in the Postponed region of the previous time step - the settled value the hardware itself would have seen. Deterministic, every run, on every simulator.
Clocking blocks are verification-only They are not synthesizable and have no place in RTL - they exist so a testbench can talk to a DUT without racing it. They come into their own in Volume 09, where a driver and a monitor both need to touch the same signals on the same edge without interfering.

8.5 Packages, compilation units & $cast

Types are only useful if every module agrees on them. Verilog's answer was `include and a forest of macros. SystemVerilog's answer is the package: a proper namespace holding types, parameters and functions.


package chip_pkg;

  localparam int ADDR_W = 32;
  localparam int DATA_W = 64;

  typedef enum logic [1:0] { IDLE, BUSY, DONE, ERROR } state_e;

  typedef struct packed {
    logic              valid;
    logic [3:0]        id;
    logic [DATA_W-1:0] data;
  } pkt_t;

  // Functions in packages must be `automatic` - a static function would
  // share one set of locals across every concurrent call.
  function automatic logic [3:0] parity_nibble(input logic [15:0] v);
    return {^v[15:12], ^v[11:8], ^v[7:4], ^v[3:0]};
  endfunction

endpackage


// Three ways to reach into it - pick one and be consistent.
import chip_pkg::*;              // everything (convenient, can collide)
import chip_pkg::state_e;        // one name (explicit, verbose)
// chip_pkg::state_e s;          // fully qualified, no import at all

module core (
  input  logic       clk,
  input  chip_pkg::pkt_t pkt_in
);
  chip_pkg::state_e state;
  ...
endmodule

$cast: converting into an enum safely

A static cast state_e'(raw) converts without checking, so an out-of-range value lands your FSM in an encoding that does not exist - precisely the illegal-state problem from Volume 4.5. $cast checks first and tells you whether it worked.


import chip_pkg::*;

state_e      s;
logic [1:0]  raw;

// STATIC cast - no check. If raw is not a legal encoding you now hold an
// enum variable containing a value the type says cannot exist.
s = state_e'(raw);

// DYNAMIC cast - returns 1 on success, 0 on failure. Used as a function
// it lets you handle the bad case instead of corrupting state.
if (!$cast(s, raw))
  $error("illegal state encoding: %0d", raw);

// Reading a register written by software is exactly this situation: the
// value came from outside the design and cannot be trusted to be legal.
Interview grilling - "You have a legacy Verilog codebase. Which SystemVerilog features would you introduce first, and why?"

They are testing judgement, not vocabulary. The good answer is ordered by bugs prevented per line changed, and acknowledges risk.

  1. always_comb / always_ff. Highest value, near zero risk - a pure find-and-replace that immediately converts latent latch bugs into build failures. Expect it to break the build on day one. That is the feature working.
  2. logic instead of wire/reg. Mechanical, and it buys single-driver checking. Leave genuine tri-state nets as wire.
  3. Typed enums for state machines. Small change, enormous debug payoff the first time a waveform shows WAIT_ACK instead of 3'b101.
  4. Packages for shared types and parameters. Kills the `include/macro layer, at the cost of touching the build file order.
  5. Packed structs for buses that keep gaining signals.
  6. Interfaces - last, and cautiously. Biggest structural change, and the one most likely to trip lint rules, legacy IP integration or an older synthesis flow.

The line that shows seniority: "I would take them in that order because the first three are locally verifiable - I can convert one module, diff the synthesized netlist, and prove nothing changed. Interfaces alter module boundaries, so they cannot be validated that cheaply."

Volume 08 recap

Concept The one thing to remember
4-state vs 2-state logic in RTL - X propagation is a free bug detector, not a nuisance.
logic Drivable procedurally or continuously, and limited to one driver.
wire Still required for genuine multi-driver tri-state nets. Nothing else.
Enums Always give a base type: enum logic [2:0]. Names in waveforms.
Structs packed or it is not a bus. First field declared = most significant.
always_comb Turns the Volume 1.5 latch warning into a build-stopping error.
'0 / unique case Width-independent fills, and the checked replacement for parallel_case.
Interfaces One file owns the bus. Modports enforce direction. Adopt last.
Clocking blocks Verification-only. #1step samples the pre-edge value, race-free.
$cast Checked conversion into an enum. A static cast checks nothing.