Skip to content

SystemVerilog · Module 2

Enumeration Types

FSM state encoding, enum methods, $cast, waveform naming, unique case.

Module 2 · Page 2.6

The Feature That Makes Waveforms Readable

Open any professional RTL design and you will find enums wherever finite state machines exist. The reason has nothing to do with syntax elegance — it's about debuggability. When a simulator displays FSM state values as symbolic names (AXI_BURST, WAIT_BRESP) instead of raw binary (3'b101), you can trace control flow in a waveform viewer in seconds. Without enums, every debug session starts with opening the RTL and mentally mapping encoding table entries to bit patterns.

Beyond waveforms, enum provides compile-time safety for case statements. Using unique case with an enum type causes the simulator to flag any state transitions into illegal (undefined) enum values, and synthesis tools issue warnings for missing case arms. These catches happen at compile or elaboration time — before your simulation even starts.

The two things engineers get wrong most often: assigning an integer directly to an enum variable (illegal without $cast), and forgetting that the default base type of an enum is int (2-state, 32-bit) — not a minimal-width logic. Both bite people the first time they actually look at the synthesized gate count.

How enum Works — The Mental Model

An enum defines a set of named constants and a variable type that can only hold one of those constants at a time. Under the hood, each name maps to an integer value (auto-assigned starting from 0, or explicitly specified). The enum variable stores that integer, but the simulator and synthesis tool know the name associated with each value.

The critical distinction: an enum variable is strongly typed within SV's type system. You cannot assign a raw integer to it without a cast. You can compare it to other enum values directly, pass it to functions expecting that enum type, and iterate over all values using the built-in methods. The type system enforces that only defined values are used — which catches off-by-one and copy-paste errors in state machine code.

Named constants

Each enum member is a named symbolic constant. The simulator displays names in waveforms. Code reads as state == IDLE instead of state == 2'b00.

Typed variable

An enum variable can only hold values that were declared in the enum. Assigning values outside the declared set requires $cast and will fail if the value is invalid.

Built-in iteration methods

first(), last(), next(), prev(), name(), num() — iterate over all values or get the string name of the current value.

Synthesis-aware

Synthesis tools understand enum base types and can apply one-hot, binary, or Gray encoding. Explicit base type (logic [N:0]) gives you full control over encoding width and style.

Syntax — Every Form and All Methods

SystemVerilog — enum Syntax & Methods
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── BASIC enum ────────────────────────────────────────────────────
// enum [base_type] { member_list } [variable]
 
// Default base type = int (32-bit 2-state, signed) — BAD for synthesis
enum { IDLE, ACTIVE, DONE } state_bad;  // 32-bit int underneath — don't do this in RTL
 
// ── PREFERRED: explicit base type for RTL ─────────────────────────
typedef enum logic [1:0] {
  ST_IDLE  = 2'b00,
  ST_RUN   = 2'b01,
  ST_DRAIN = 2'b10,
  ST_ERROR = 2'b11
} fsm_state_t;
 
fsm_state_t state, next_state;   // variables of that type
 
// ── AUTO-NUMBERED (no explicit values) ───────────────────────────
typedef enum logic [2:0] {
  IDLE, FETCH, DECODE, EXECUTE, WRITEBACK
} pipeline_t;   // IDLE=0, FETCH=1, DECODE=2, EXECUTE=3, WRITEBACK=4
 
// ── MIXED: some explicit, some auto ──────────────────────────────
typedef enum logic [3:0] {
  OP_NOP   = 4'h0,
  OP_ADD   = 4'h1,
  OP_SUB   = 4'h2,
  OP_LOAD  = 4'h8,   // explicit jump in value
  OP_STORE = 4'h9    // auto: 4'h9
} opcode_t;
 
// ── OPERATIONS ────────────────────────────────────────────────────
state = ST_IDLE;
if (state == ST_RUN) $display("running");
if (state != ST_ERROR) $display("no error");
 
// ── BUILT-IN METHODS ──────────────────────────────────────────────
state.first()     // ST_IDLE (first declared member)
state.last()      // ST_ERROR (last declared member)
state.next()      // next member after current value
state.prev()      // previous member before current value
state.num()       // 4 (number of members in the enum)
state.name()      // "ST_IDLE" (string name of current value)
 
// ── int ↔ enum CONVERSION ─────────────────────────────────────────
int         raw = 2;
fsm_state_t s;
if (!$cast(s, raw))
  $error("Invalid enum value %0d", raw);   // $cast returns 0 if value invalid
int         back = int'(state);             // enum → int: always safe
MethodReturnsDescription
first()enum valueFirst declared member regardless of current value
last()enum valueLast declared member
next()enum valueMember after current; wraps to first() after last()
prev()enum valueMember before current; wraps to last() before first()
num()intTotal count of declared members
name()stringString name of current value; empty string for non-member values

Visual — Encoding, Waveforms, and Value Tables

Enum Values and Their Encodings

Declaration: typedef enum logic [1:0] { ST_IDLE=2'b00, ST_RUN=2'b01, ST_DRAIN=2'b10, ST_ERROR=2'b11 } fsm_state_t

NameValue (logic [1:0])int' castWaveform showsname() returns
ST_IDLE2'b000ST_IDLE"ST_IDLE"
ST_RUN2'b011ST_RUN"ST_RUN"
ST_DRAIN2'b102ST_DRAIN"ST_DRAIN"
ST_ERROR2'b113ST_ERROR"ST_ERROR"

enum next() and prev() Traversal

Current statestate.next()state.prev()Wrap behavior
ST_IDLEST_RUNST_ERROR (wraps!)prev() on first wraps to last
ST_RUNST_DRAINST_IDLENormal
ST_DRAINST_ERRORST_RUNNormal
ST_ERRORST_IDLE (wraps!)ST_DRAINnext() on last wraps to first

Synthesis Encoding Options

Encoding4-state FSM bitsPowerAreaSpecify how
Binary (default)2 bitsLowMinimalDefault for enum logic [1:0]
One-hot4 bits (1 per state)HigherMore FFsSynthesis directive or explicit values
Gray code2 bitsLow switchingSame as binarySynthesis directive
Auto (tool choice)Tool decidesOptimizedOptimizedenum { A, B, C } default

Code Examples — FSMs to Protocol Opcodes

Example 1 — Beginner: Basic enum and Methods

Example 1 — enum Basics and Methods
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module tb_enum_basics;
 
  typedef enum logic [1:0] {
    IDLE = 2'b00, RUN = 2'b01, DRAIN = 2'b10, ERR = 2'b11
  } state_t;
 
  state_t s = IDLE;
 
  initial begin
 
    // ── Basic operations ──────────────────────────────────────────
    $display("Initial state: %s (%0d)", s.name(), int'(s));  // IDLE (0)
 
    s = RUN;
    $display("After  assign: %s (%0d)", s.name(), int'(s));  // RUN (1)
 
    // ── Iteration methods ─────────────────────────────────────────
    $display("first = %s",  s.first().name());  // IDLE
    $display("last  = %s",  s.last().name());   // ERR
    $display("next  = %s",  s.next().name());   // DRAIN (next after RUN)
    $display("prev  = %s",  s.prev().name());   // IDLE (prev of RUN)
    $display("num   = %0d", s.num());           // 4
 
    // ── Iterate all members ───────────────────────────────────────
    state_t e = s.first();
    repeat (s.num()) begin
      $display("  %s = %02b", e.name(), e);
      e = e.next();
    end
 
    // ── $cast: int → enum (with validity check) ───────────────────
    int raw = 2;
    if ($cast(s, raw))
      $display("Cast OK: %s", s.name());    // DRAIN
    raw = 10;
    if (!$cast(s, raw))
      $display("Cast FAIL: %0d is not a valid state", raw);
 
    $finish;
  end
 
endmodule

Expected output:

Simulation Output
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Initial state: IDLE (0)
After  assign: RUN (1)
first = IDLE
last  = ERR
next  = DRAIN
prev  = IDLE
num   = 4
  IDLE = 00
  RUN = 01
  DRAIN = 10
  ERR = 11
Cast OK: DRAIN
Cast FAIL: 10 is not a valid state

Example 2 — Intermediate: RTL FSM With unique case

Example 2 — FSM Using enum + unique case
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
typedef enum logic [1:0] {
  S_IDLE  = 2'b00,
  S_REQ   = 2'b01,
  S_WAIT  = 2'b10,
  S_DONE  = 2'b11
} req_state_t;
 
module axi_ctrl (
  input  logic   clk, rst_n, start, done_i,
  output logic   req_o, busy_o
);
  req_state_t state, next_state;
 
  // State register
  always_ff @(posedge clk or negedge rst_n)
    if (!rst_n) state <= S_IDLE;
    else        state <= next_state;
 
  // Next-state logic — unique case ensures: all states covered, no overlaps
  always_comb begin
    next_state = state;    // default: hold current
    req_o  = 1'b0;
    busy_o = 1'b0;
 
    unique case (state)
      S_IDLE: if (start)  next_state = S_REQ;
      S_REQ:  begin req_o = 1; busy_o = 1; next_state = S_WAIT; end
      S_WAIT: begin         busy_o = 1;
              if (done_i)  next_state = S_DONE; end
      S_DONE:              next_state = S_IDLE;
    endcase
  end
 
endmodule
 
// Testbench: read enum state name directly — $display %s works with enum
module tb_axi_ctrl;
  logic clk=0, rst_n, start, done_i, req_o, busy_o;
  axi_ctrl dut(.*);
  always #5 clk = ~clk;
 
  // Monitor state using hierarchical reference
  always @(posedge clk)
    $display("t=%0t state=%s req=%b busy=%b", $time, dut.state.name(), req_o, busy_o);
 
  initial begin
    rst_n=0; start=0; done_i=0; #12; rst_n=1;
    @(posedge clk); start=1; @(posedge clk); start=0;
    @(posedge clk); done_i=1; @(posedge clk); done_i=0;
    repeat(2) @(posedge clk);
    $finish;
  end
endmodule

Example 3 — Verification: Enum in Constraints and Scoreboards

Example 3 — enum in Constraints and Coverage
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
typedef enum logic [1:0] {
  OKAY=2'b00, EXOKAY=2'b01, SLVERR=2'b10, DECERR=2'b11
} axi_resp_t;
 
typedef enum logic [1:0] {
  FIXED=2'b00, INCR=2'b01, WRAP=2'b10
} axi_burst_t;
 
class axi_txn;
  rand axi_burst_t burst;
  rand axi_resp_t  resp;
  rand logic [7:0]  len;
 
  // Constrain using enum members — readable and type-safe
  constraint no_wrap  { burst != WRAP; }
  constraint ok_resp  { resp inside {OKAY, EXOKAY}; }
  constraint len_c    { len inside {[1:16]}; }
 
  function void print();
    $display("burst=%s resp=%s len=%0d", burst.name(), resp.name(), len);
  endfunction
endclass
 
// Scoreboard: compare enum responses by name for readable error messages
task automatic check_resp(input axi_resp_t exp, got);
  if (exp !== got)
    $error("RESP MISMATCH: expected=%s got=%s", exp.name(), got.name());
  else
    $display("PASS resp=%s", got.name());
endtask
 
module tb_enum_constraints;
  initial begin
    axi_txn t = new();
    repeat(3) begin
      void'(t.randomize()); t.print();
    end
    check_resp(OKAY, SLVERR);   // deliberate mismatch for demo
    check_resp(OKAY, OKAY);
    $finish;
  end
endmodule

Example 4 — Corner Case: Invalid Values, $cast, and Coverage

Example 4 — $cast and Coverage with Enums
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module tb_enum_corners;
 
  typedef enum logic [1:0] {
    A = 2'b00, B = 2'b01, C = 2'b10
    // 2'b11 is NOT defined — it's an illegal value!
  } abc_t;
 
  abc_t val;
  int   raw;
 
  initial begin
 
    // ── $cast with validity check ─────────────────────────────────
    for (raw = 0; raw < 4; raw++) begin
      if ($cast(val, raw))
        $display("raw=%0d → val=%s", raw, val.name());
      else
        $display("raw=%0d → INVALID (not in enum)", raw);
    end
    // raw=0 → A, raw=1 → B, raw=2 → C, raw=3 → INVALID
 
    // ── name() on undefined value returns "" ──────────────────────
    // If an enum variable somehow holds an invalid value (via force/DPI/etc.)
    // name() returns "" (empty string) — use this to detect invalid states
    logic [1:0] bits = 2'b11;
    val = abc_t'(bits);           // force-cast — bypasses validation!
    $display("Illegal value name(): '%s'", val.name()); // ""
    if (val.name() == "")
      $error("FSM in illegal state: %02b", val);
 
    // ── Coverage: iterate all enum values ────────────────────────
    val = val.first();
    repeat (val.num()) begin
      $display("Covering: %s", val.name());
      val = val.next();
    end
 
    $finish;
  end
 
endmodule

Simulation Behavior — What the Tools See

Waveform Display: The Core Value of enum

When a signal is declared as an enum type, the simulator stores a mapping from integer values to symbolic names. In the waveform viewer, the signal displays as the symbolic name rather than the raw binary value. This is the single most impactful benefit of enum in debug workflows: reading IDLE → REQ → WAIT → DONE in a waveform is immediate. Reading 00 → 01 → 10 → 11 requires a lookup table in your head or the datasheet.

unique case and FSM Coverage

unique case on an enum type gives you two simulation-time checks: (1) the simulator warns if the expression ever holds a value not listed in any case arm (illegal state detection), and (2) the simulator warns if more than one case arm can match simultaneously (though this cannot happen with an enum if the values are unique, which they always are). With case (not unique), you lose both checks.

ConstructMissing arm behaviorIllegal value behaviorSynthesis
caseExecutes default, no warningNo detectionStandard mux
case + defaultDefault executesDefault catches itStandard mux with default
unique caseRuntime warning: no matchRuntime warning: illegal valueSynthesis may remove redundant logic
priority caseNo warning, implicit default=no-opNo detectionPriority encoder

Where enum Appears in Real Verification

Verification Patterns Using enum
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 1. AXI PROTOCOL FIELDS AS ENUMS ───────────────────────────────
typedef enum logic [1:0] { FIXED, INCR, WRAP }       axi_burst_t;
typedef enum logic [2:0] { SZ_1, SZ_2, SZ_4, SZ_8 }  axi_size_t;
typedef enum logic [1:0] { OKAY, EXOKAY, SLVERR, DECERR } axi_resp_t;
 
// ── 2. UVM PHASE TRACKER ──────────────────────────────────────────
typedef enum { BUILD, CONNECT, RUN, REPORT, FINAL } uvm_phase_t;
uvm_phase_t curr_phase = BUILD;
$display("Phase: %s", curr_phase.name());
 
// ── 3. CONSTRAINT WITH ENUM MEMBERS ──────────────────────────────
rand axi_burst_t burst;
constraint no_wrap_c { burst inside {FIXED, INCR}; }  // readable!
 
// ── 4. SCOREBOARD: compare and report with names ──────────────────
if (got_resp !== exp_resp)
  $error("RESP: exp=%s got=%s", exp_resp.name(), got_resp.name());
 
// ── 5. COVERAGE: cover all enum values ────────────────────────────
// covergroup cg_burst;
//   cp_burst: coverpoint burst {
//     bins fixed = {FIXED};
//     bins incr  = {INCR};
//     bins wrap  = {WRAP};
//   }
// endgroup
 
// ── 6. MONITOR: decode incoming response ─────────────────────────
function automatic string decode_resp(logic [1:0] raw);
  axi_resp_t r;
  if ($cast(r, raw)) return r.name();
  return $sformatf("UNKNOWN(%02b)", raw);
endfunction
 
// ── 7. ASSERTION: valid response check ────────────────────────────
// assert property (@(posedge clk) bvalid |-> bresp inside {OKAY, EXOKAY});

Bugs Engineers Hit With enum

Bug 1 — Default Base Type is int: 32-bit FSM State Register

Bug 1 — int Base Type in RTL enum
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// BUGGY: default base type is int — synthesizes 32 flip-flops for state!
typedef enum { IDLE, RUN, DONE } state_t;   // int underneath = 32-bit
state_t state;
// Synthesis report shows: state uses 32 flip-flops
// Gate-sim shows 32-bit state register — overkill for a 3-state FSM
 
// FIXED: always specify the base type for RTL
typedef enum logic [1:0] {
  IDLE = 2'b00, RUN = 2'b01, DONE = 2'b10
} state_ok_t;
// Now state register is 2 flip-flops — correct

Bug 2 — Direct Integer Assignment to enum Without $cast

Bug 2 — Integer Assignment Without $cast
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
typedef enum logic [1:0] { A, B, C } abc_t;
abc_t val;
int   raw = 1;
 
// BUGGY: direct int → enum assignment
val = raw;    // COMPILE ERROR or WARNING (tool-dependent)
              // Most tools: "implicit type conversion may cause unexpected results"
 
// ALSO BUGGY: force-cast bypasses validation
val = abc_t'(raw);    // compiles but skips the validity check
// If raw=3 (not defined), val holds illegal value — name() returns ""
 
// CORRECT: use $cast with validity check
if (!$cast(val, raw))
  $error("%0d is not a valid abc_t value", raw);

Bug 3 — Missing default in case on Partially-Defined Enum

Bug 3 — Latch Inference From Missing Case Arm
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
typedef enum logic [1:0] { S0, S1, S2 } s_t;
// Note: 2'b11 is unused — but synthesis tool doesn't know that
 
logic   out;
s_t     s;
 
// BUGGY: 3 arms for a 2-bit type = 4 possible values
// The 4th (2'b11) is uncovered → synthesis infers a latch for 'out'
always_comb
  case (s)
    S0: out = 0;
    S1: out = 1;
    S2: out = 0;
    // missing 2'b11 → latch inferred!
  endcase
 
// CORRECT option 1: add default
always_comb
  case (s)
    S0: out = 0; S1: out = 1; S2: out = 0;
    default: out = 0;   // covers 2'b11 explicitly
  endcase
 
// CORRECT option 2: use unique case (synthesis knows remaining states are illegal)
always_comb
  unique case (s)
    S0: out = 0; S1: out = 1; S2: out = 0;
  endcase   // unique: synthesis knows other values won't occur

Bug 4 — next() Wraps Unexpectedly at Last Member

Bug 4 — next() Wraps at Boundary
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
typedef enum { INIT, ACTIVE, DONE } phase_t;
phase_t p = DONE;
 
// BUGGY: assuming next() stops at DONE
while (p != DONE) begin
  $display("%s", p.name());
  p = p.next();    // if started at DONE, next() wraps to INIT!
end
// The loop body never executes — p starts at DONE, condition false immediately
// But if you use next() carelessly thinking it "stops" at last member: infinite loop
 
// CORRECT: use num() to count explicitly
p = p.first();
repeat (p.num()) begin
  $display("%s", p.name());
  p = p.next();    // safe: exactly num() iterations
end

A Runnable Proof — What an Out-of-Range Cast Actually Produces

The page states that $cast validates and enum_t'(...) does not. This file shows what "does not" means in practice, and two of the results surprise most engineers.

enum_illegal_proof.sv — the static cast and its consequences
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module enum_illegal_proof;
 
  typedef enum logic [1:0] { IDLE = 2'b00, BUSY = 2'b01, DONE = 2'b10 } state_t;
  // 2'b11 is a legal BIT PATTERN with no label. That gap is the whole subject.
 
  state_t s;
  logic [1:0] raw;
  int fails = 0;
 
  task automatic expect (input string what, input bit cond);
    if (cond) $display("  PASS  %s", what);
    else begin $display("  FAIL  %s", what); fails++; end
  endtask
 
  initial begin
    $display("\n1. $cast validates and reports");
    raw = 2'b10;
    expect("$cast succeeds for a declared value", $cast(s, raw) == 1);
    raw = 2'b11;
    expect("$cast REFUSES an undeclared value",   $cast(s, raw) == 0);
    // $cast leaves the target unchanged on failure, so s still holds DONE.
    expect("target unchanged after a failed cast", s === DONE);
 
    $display("\n2. The static cast performs no validation at all");
    s = state_t'(2'b11);          // legal SystemVerilog; no error, no warning
    $display("  s now holds 2'b%b", s);
    expect("s holds an encoding no label names", s === 2'b11);
 
    $display("\n3. What the usual diagnostics report about it");
    // name() returns an EMPTY STRING rather than erroring. A $display of the
    // state prints nothing where a name should be, which reads as a format bug.
    $display("  s.name() = \"%s\"  (length %0d)", s.name(), s.name().len());
    expect("name() is empty, not an error", s.name().len() == 0);
 
    $display("\n4. unique case only catches it without a default arm");
    unique case (s)
      IDLE: $display("  IDLE");
      BUSY: $display("  BUSY");
      DONE: $display("  DONE");
      // No default: the tool reports a unique-case no-match violation here.
      // Add `default: ;` to silence a latch warning and this check is gone
      // too - you get one or the other, never both.
    endcase
 
    $display("\n5. The boundary check that prevents all of the above");
    raw = 2'b11;
    if (!$cast(s, raw))
      $display("  rejected 2'b%b at the boundary, as intended", raw);
    else begin
      $display("  accepted an illegal value"); fails++;
    end
 
    $display("\n%0s (%0d failures)\n",
             fails == 0 ? "ALL CHECKS PASSED" : "CHECKS FAILED", fails);
    if (fails) $fatal(1, "enum_illegal_proof failed");
    $finish;
  end
endmodule

Section 3 is the one to remember. name() returning an empty string rather than raising an error means the most natural debug print — $display("state=%s", s.name()) — produces state= and looks like a formatting mistake rather than a state that should not exist.

1

An FSM reached a state that did not exist, and every diagnostic stayed quiet

ENUM-STATIC-CAST-ILLEGAL-STATE
Symptom

A DMA controller hung intermittently. Once hung it stopped responding to new descriptors, ignored its own soft reset bit, and only a full block reset recovered it. Frequency was roughly one hang per several hours of traffic — often enough to fail overnight regressions, rarely enough that reproducing it took a day of seeding.

The state machine's debug print showed state= on a hung run: the label was blank. That was read for two days as a message-formatting problem, because a blank field in a %s is what a formatting problem looks like.

Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
typedef enum logic [1:0] { IDLE=2'b00, FETCH=2'b01, XFER=2'b10 } dma_state_t;
 
dma_state_t state;
 
// A soft-reset register write restores the state from a saved value.
// The saved value comes from a 32-bit CSR written by software.
always_ff @(posedge clk or negedge rst_n)
  if (!rst_n)              state <= IDLE;
  else if (restore_state)  state <= dma_state_t'(csr_state[1:0]);   // ✗ no check
  else unique case (state)
    IDLE : if (start) state <= FETCH;
    FETCH: if (got)   state <= XFER;
    XFER : if (done)  state <= IDLE;
    default: ;                       // added to silence a latch warning
  endcase
Diagnostic Evidence

Dumping the raw encoding rather than the name resolved it in one line:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  $display("state=%s raw=%b", state.name(), state);
  -> state= raw=11        <-- 2'b11: a legal bit pattern with no label

2'b11 is not one of the three declared states. The FSM's unique case had a default: ; arm, so the no-match violation never fired, and the empty arm held the state at 2'b11 forever — a hang by construction rather than by deadlock.

The trigger came from the CSR trace: software wrote 0x3 to the state-restore register during an error-recovery path. That path was rare, which is why the hang was rare, and it was in the driver rather than the RTL, which is why RTL review never found it.

Two details made this expensive. name() returning an empty string disguised the problem as a formatting bug. And the default: ; — added months earlier to silence a latch warning, a change every reviewer would approve — is what converted a detectable illegal state into a permanent one.

Root Cause

dma_state_t'(csr_state[1:0]) is a static cast, and a static cast performs no validation. Any 2-bit pattern is accepted, including the one no label names, so the type system's guarantee that the variable holds a declared value is bypassed at exactly the boundary where an untrusted external value enters the design.

The hang then follows from the default arm. With three declared states and four encodings, the FSM needs some behaviour for the fourth; default: ; supplies "hold", which for a state variable means "stay here forever". The state machine had no transition out of 2'b11 because no such state was ever intended to exist.

The two defences the language offers both happened to be disabled. $cast was not used, so the illegal value was never rejected at the boundary. And unique case's no-match check — which would have reported the illegal state on the very first cycle it occurred — is suppressed by the presence of a default arm, so the mechanism designed to catch this was silently traded away for a clean lint report.

Fix
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 1. Validate at the boundary. $cast rejects an undeclared value and leaves
//    the target unchanged, so an illegal CSR write cannot enter the FSM.
always_ff @(posedge clk or negedge rst_n)
  if (!rst_n) begin
    state <= IDLE;
  end else if (restore_state) begin
    dma_state_t tmp;
    if ($cast(tmp, csr_state[1:0])) state <= tmp;
    else begin
      state <= IDLE;                       // defined recovery
      illegal_state_err <= 1'b1;           // and tell somebody
    end
  end else begin
    unique case (state)
      IDLE : if (start) state <= FETCH;
      FETCH: if (got)   state <= XFER;
      XFER : if (done)  state <= IDLE;
      // 2. Make the default do something rather than nothing. Recovering to
      //    IDLE turns a permanent hang into a reported, self-clearing event.
      default: begin state <= IDLE; illegal_state_err <= 1'b1; end
    endcase
  end

The test that fails on the old RTL and passes on the new one is a directed CSR write of 0x3 to the state-restore register followed by a check that the FSM still accepts a descriptor. It takes four lines and nobody wrote it, because the value was assumed to come from a trusted source.

The durable guard is an assertion that says what the type already claims:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
a_state_is_declared: assert property (@(posedge clk) disable iff (!rst_n)
  state inside {IDLE, FETCH, XFER})
  else $error("FSM state %b matches no declared label", state);

Two habits generalise beyond this FSM. Validate at every boundary where an external value becomes a typed one — CSR fields, packed struct members, interface reads — using $cast and handling the failure. And never let a default arm be empty in a state register; if it exists to satisfy a latch check, give it a defined recovery and an error flag, because an empty default turns every unreachable state into an unrecoverable one.

For the case-statement semantics this rests on see case, casex and casez and if-else, unique and priority; for why the 2-state default base type hides X, see 4-state versus 2-state types.

Where This Is Specified

  • IEEE 1800-2023 §6.19 — Enumerations. Declaration syntax, the default base type int, explicit base types, and the rule that enum labels take successive values unless assigned.
  • IEEE 1800-2023 §6.19.3 — Enumerated type ranges and methods. first(), last(), next(), prev(), num() and name(), including that name() returns an empty string when the value matches no label.
  • IEEE 1800-2023 §6.19.2 — Type checking. Assignment to an enum variable requires a cast; $cast performs a checked conversion and returns 0 on failure, while a static cast type'(expr) performs no validation.
  • IEEE 1800-2023 §12.5.3 — unique-case, unique0-case and priority-case. The violation reports for no matching arm and for multiple matching arms, and the interaction with a default arm.
  • IEEE 1800-2023 §11.4.13 — inside operator. The set-membership form used by the declared-state assertion above.

Interview Questions

int — 32-bit, 2-state, signed. That is fine for a testbench and wrong for synthesisable RTL.

The consequence is concrete: a three-state FSM declared with the default base type infers a 32-bit state register instead of a 2-bit one. Most synthesis tools will optimise away the unreachable bits, but not all will, and not in every context — a state variable that crosses a module boundary as a port, or that feeds a wide comparison, can keep its full declared width. Reviewing gate counts and finding thirty spare flops per FSM is a routine discovery on code that never specified a base type.

The 2-state part matters too, and it is the half people forget. A default-typed enum cannot hold X, so an un-reset state register reads as the enum value encoded 0 rather than as unknown — the same masking discussed for bit versus logic, applied to a state machine where the consequence is that a missing reset looks like a legitimate state.

For anything synthesisable, write the base type: enum logic [1:0] { IDLE, BUSY, DONE }.

Because an enum is a distinct type, and a raw integer carries no guarantee that its value corresponds to any declared label. Allowing the assignment would let a variable hold a value the type says is impossible.

SystemVerilog therefore requires the conversion to be explicit, and gives two forms with very different characters. $cast(state, raw) checks — it returns 0 and leaves the target unchanged if the value matches no label, so an invalid value becomes a testable condition. state_t'(raw) is a static cast that performs no validation: the bits are reinterpreted and the variable now holds a value no label names.

The second form is legal, occasionally necessary, and the source of the illegal-state bug in the Debug Lab below. Reach for $cast by default and check its return value; reserve the static cast for cases where the value has already been validated elsewhere, and comment why.

Note that this asymmetry is why the type system's protection is real but not total: it prevents the accidental assignment and cannot prevent the deliberate one.

Two simulation checks and one synthesis assertion.

In simulation it reports a violation if the expression matches no arm — which for an enum FSM means the state variable holds a value outside the declared set, exactly the illegal-state condition you want to know about — and it reports one if more than one arm matches, which for distinct enum labels should be impossible and therefore indicates a genuine encoding mistake.

For synthesis it asserts that the arms are mutually exclusive and exhaustive, so the tool may build a parallel decode rather than a priority chain and may treat unlisted encodings as don't-cares.

Two cautions. The no-match check is what catches an illegal state, and it only fires if there is no default arm — adding one to silence latch warnings also disables the check, so you get one or the other. And unique is a promise you are making rather than something the tool verifies at elaboration: if the state can in fact leave the declared set, you have told the synthesis tool something false, and the hardware may not match the simulation.

Three options, with different trade-offs.

Add a default arm that drives every output to a known value. All four encodings are covered, no latch is inferred, and the design has a defined recovery behaviour for the unreachable encoding. This is the most portable choice and the safest for anything where a corrupted state must not produce undefined outputs.

Use unique case without a default. This tells synthesis the fourth encoding is unreachable so it can optimise freely, and it preserves the simulation no-match check that a default would disable. The cost is that the guarantee rests on your promise rather than on the structure.

Choose an encoding with no spare values — one-hot, or a width that the states exactly fill — so the partial-coverage question does not arise.

The choice people get wrong is combining the first two: adding a default and relying on unique to catch illegal states. The default matches, so the no-match violation never fires, and the illegal state is silently absorbed into whatever the default arm does. If you want both latch-freedom and detection, put the check in the default arm itself: assign safe outputs and raise an error there.

The variable holds a bit pattern that no label names, and every mechanism you would expect to catch that either does not fire or actively hides it.

name() returns an empty string for an unmatched value rather than erroring, so a $display of the state prints nothing where a name should be and reads as a formatting problem. Waveform viewers show the raw encoding rather than a label, which is easy to miss in a wide trace. unique case reports the no-match violation only if there is no default arm, and most FSMs have one to avoid latch warnings.

The traversal methods are the sharpest surprise: next() and prev() are defined over the declared value set, so calling them on a variable holding an undeclared value has no well-defined starting point in that set, and an FSM that advances with state.next() can behave in ways that are consistent in one tool and different in another.

The defence is to use $cast and check its return value at every boundary where an external value becomes a state — a register write, a packed struct field, a value read from an interface. That converts an unrepresentable state into a reported error at the moment it is created rather than a silent one discovered downstream.

first(), last(), next(), prev() and num() are defined by IEEE 1800 as enum methods and are not uniformly supported by synthesis tools — support for next() and prev() in particular varies, and where supported the result depends on the declared order.

name() is the clearer case: it returns a string, so it belongs to simulation only and has no synthesisable meaning at all. It is extremely useful in uvm_info messages and waveform annotation, and it should not appear in RTL.

Where the methods genuinely earn their place is the testbench: iterating every state with first()/next() to build a coverage model, or printing state.name() in a scoreboard message, is far more maintainable than a hand-written mapping that drifts when someone adds a state.

For RTL, prefer explicit encodings and explicit transitions. A state machine whose next-state logic reads state.next() is compact and obscures the transition graph, which is the thing a reviewer most needs to see.

Best Practices and Coding Guidelines

Always specify base type for RTL

enum logic [N:0] — always. The default int base type gives you 32 flip-flops for a 3-state machine. Never use the default in synthesizable code.

Use unique case for FSMs

unique case (state) is the standard for RTL FSMs. It catches illegal states at simulation, prevents latch inference at synthesis, and documents mutual exclusivity.

$cast for int→enum conversion

Never directly assign integers to enum variables. Use $cast() and check the return value. A failing cast means an illegal state was about to be loaded.

Use name() in error messages

In scoreboard mismatches and FSM debug messages: $error("exp=%s got=%s", exp.name(), got.name()). Human-readable state names in log files save hours of debugging.

Use caseCorrect approachAvoid
RTL FSM statetypedef enum logic [N:0] { ... }typedef enum { ... } (int base)
FSM combinational logicunique case (state)Plain case (latches on partial coverage)
int → enum$cast(e, i) + check returne = i (compile error/warning)
Iterate all valuesrepeat(e.num()) with e.next()While loop — next() wraps unexpectedly
Detect illegal statee.name() == ""No check — illegal states propagate silently
Print state in logstate.name()int'(state) — raw numbers in logs

Summary

Enum is the type that makes FSMs debuggable and FSM case statements safe. The waveform benefit alone justifies using it everywhere you have a state variable. The compile-time and runtime checks from unique case catch entire classes of FSM bugs before they reach simulation. The three things that burn engineers: forgetting the base type (32 flip-flops), assigning integers without $cast, and partial case coverage causing latch inference.

  • Always specify enum logic [N:0] in RTL. The default int base synthesizes to 32 flip-flops.
  • Use unique case for FSM next-state logic. Catches illegal states in simulation, prevents latch inference in synthesis.
  • Convert integers to enum with $cast. Always check the return value — a failing cast means an invalid state was incoming.
  • name() returns "" for illegal values. Use this to detect FSM corruption: if (state.name() == "") $error(...);
  • next() wraps. Iterate with repeat(e.num()), not a while loop with a stop-at-last assumption.

Part of SystemVerilog Fundamentals·Data Types·Lesson 10 of 53

View program

Continue learning