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
// ── 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| Method | Returns | Description |
|---|---|---|
first() | enum value | First declared member regardless of current value |
last() | enum value | Last declared member |
next() | enum value | Member after current; wraps to first() after last() |
prev() | enum value | Member before current; wraps to last() before first() |
num() | int | Total count of declared members |
name() | string | String 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
| Name | Value (logic [1:0]) | int' cast | Waveform shows | name() returns |
|---|---|---|---|---|
ST_IDLE | 2'b00 | 0 | ST_IDLE | "ST_IDLE" |
ST_RUN | 2'b01 | 1 | ST_RUN | "ST_RUN" |
ST_DRAIN | 2'b10 | 2 | ST_DRAIN | "ST_DRAIN" |
ST_ERROR | 2'b11 | 3 | ST_ERROR | "ST_ERROR" |
enum next() and prev() Traversal
| Current state | state.next() | state.prev() | Wrap behavior |
|---|---|---|---|
ST_IDLE | ST_RUN | ST_ERROR (wraps!) | prev() on first wraps to last |
ST_RUN | ST_DRAIN | ST_IDLE | Normal |
ST_DRAIN | ST_ERROR | ST_RUN | Normal |
ST_ERROR | ST_IDLE (wraps!) | ST_DRAIN | next() on last wraps to first |
Synthesis Encoding Options
| Encoding | 4-state FSM bits | Power | Area | Specify how |
|---|---|---|---|---|
| Binary (default) | 2 bits | Low | Minimal | Default for enum logic [1:0] |
| One-hot | 4 bits (1 per state) | Higher | More FFs | Synthesis directive or explicit values |
| Gray code | 2 bits | Low switching | Same as binary | Synthesis directive |
| Auto (tool choice) | Tool decides | Optimized | Optimized | enum { A, B, C } default |
Code Examples — FSMs to Protocol Opcodes
Example 1 — Beginner: Basic enum and Methods
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
endmoduleExpected output:
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 stateExample 2 — Intermediate: RTL FSM With unique case
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
endmoduleExample 3 — Verification: Enum in Constraints and Scoreboards
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
endmoduleExample 4 — Corner Case: Invalid Values, $cast, and Coverage
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
endmoduleSimulation 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.
| Construct | Missing arm behavior | Illegal value behavior | Synthesis |
|---|---|---|---|
case | Executes default, no warning | No detection | Standard mux |
case + default | Default executes | Default catches it | Standard mux with default |
unique case | Runtime warning: no match | Runtime warning: illegal value | Synthesis may remove redundant logic |
priority case | No warning, implicit default=no-op | No detection | Priority encoder |
Where enum Appears in Real Verification
// ── 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
// 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 — correctBug 2 — Direct Integer Assignment to enum Without $cast
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
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 occurBug 4 — next() Wraps Unexpectedly at Last Member
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
endA 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.
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
endmoduleSection 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.
An FSM reached a state that did not exist, and every diagnostic stayed quiet
ENUM-STATIC-CAST-ILLEGAL-STATEA 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.
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
endcaseDumping the raw encoding rather than the name resolved it in one line:
$display("state=%s raw=%b", state.name(), state);
-> state= raw=11 <-- 2'b11: a legal bit pattern with no label2'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.
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.
// 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
endThe 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:
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()andname(), including thatname()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;
$castperforms a checked conversion and returns 0 on failure, while a static casttype'(expr)performs no validation. - IEEE 1800-2023 §12.5.3 —
unique-case,unique0-caseandpriority-case. The violation reports for no matching arm and for multiple matching arms, and the interaction with adefaultarm. - IEEE 1800-2023 §11.4.13 —
insideoperator. 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 case | Correct approach | Avoid |
|---|---|---|
| RTL FSM state | typedef enum logic [N:0] { ... } | typedef enum { ... } (int base) |
| FSM combinational logic | unique case (state) | Plain case (latches on partial coverage) |
| int → enum | $cast(e, i) + check return | e = i (compile error/warning) |
| Iterate all values | repeat(e.num()) with e.next() | While loop — next() wraps unexpectedly |
| Detect illegal state | e.name() == "" | No check — illegal states propagate silently |
| Print state in log | state.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 defaultintbase synthesizes to 32 flip-flops. - Use
unique casefor 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 withrepeat(e.num()), not a while loop with a stop-at-last assumption.
Part of SystemVerilog Fundamentals·Data Types·Lesson 10 of 53
View program