Part 1 · Language Foundations · Intermediate
Enums, Structs & Unions
Enum base types and methods, packed structs as bus payloads, packed vs unpacked layout, and unions/tagged unions.
Enums: named values with a real type underneath
An enum is a variable of some base type (default int — 2-state, 32-bit) restricted to named values. For RTL state machines, always pin the base type explicitly: enum logic [1:0] {...} — that makes the state register 4-state (so an unreset FSM shows X, not a fake IDLE) and exactly as wide as synthesis needs. Enums are strongly typed : you can assign an enum to an int freely, but assigning an int to an enum requires a cast — $cast if you want a runtime legality check. That asymmetry is a deliberate guard against absorbing illegal state encodings.
typedef enum logic [1:0] {IDLE = 2'b00, REQ = 2'b01,
GRANT = 2'b10, DONE = 2'b11} state_e;
state_e st = st.first();
// Built-in methods — iteration and debug printing
$display("state=%s (%0d of %0d)", st.name(), st, st.num());
st = st.next(); // IDLE → REQ; wraps DONE → IDLE
st = st.prev(); // back again; wraps IDLE → DONE
st = st.last(); // DONE
// Iterate every value — covergroup/checker idiom
state_e s = s.first();
do begin
$display("legal state: %s = %b", s.name(), s);
s = s.next();
end while (s != s.first());
// int → enum needs a checked cast
int raw = 2'b11;
if (!$cast(st, raw)) $error("illegal encoding %0d", raw);The methods — first(), last(), next(), prev(), num(), name() — make enums self-describing: monitors print st.name() instead of raw bits, and checkers iterate all legal values without hardcoding a list. Note next()/prev() iterate declaration order , wrapping at the ends — they do not add 1 to the encoding.
Packed structs: named fields over a bit vector
A packed struct lays its members out contiguously as one vector — the first-declared field takes the most-significant bits . Because it is just a shaped vector, you can assign it to/from logic [N-1:0], slice it, concatenate it, and put it through ports. This makes packed structs the standard way to give names to bus payloads and packet headers: the monitor does one assignment from the raw bus and every field gets its name. An unpacked struct gives no layout guarantee — the simulator may pad and align fields like a C compiler — so it cannot be treated as a vector, but its members can be any type (arrays, strings, reals), which suits testbench-side bookkeeping.
Legend: [TYPE]
PACKED STRUCT LAYOUT [TYPE]
typedef struct packed {
logic [7:0] cmd; ← declared first = MSBs
logic [23:0] addr;
logic [31:0] data;
} pkt_t; total: 64 bits
bit 63 56 55 32 31 0
┌──────────────┬─────────────────────┬─────────────────────┐
│ cmd [7:0] │ addr [23:0] │ data [31:0] │
└──────────────┴─────────────────────┴─────────────────────┘
▲ one flat vector: pkt_t ⇄ logic [63:0] freely ▲
UNPACKED struct: members stored separately, layout
tool-defined, NOT castable to a vector, any member type OKtypedef struct packed {
logic [7:0] cmd;
logic [23:0] addr;
logic [31:0] data;
} pkt_t;
pkt_t pkt;
logic [63:0] bus;
pkt = '{cmd: 8'hA5, addr: 24'h1000, data: 32'hDEAD_BEEF};
bus = pkt; // struct → vector: free
pkt = bus; // vector → struct: names the bits
$display("cmd=%h addr=%h", pkt.cmd, pkt.addr);
// Unpacked struct: TB-side record, flexible member types
typedef struct {
string name;
int count;
realtime first_seen;
} stats_t;Unions and tagged unions
A packed union overlays members on the same storage — all members must be the same packed width. The classic use is one register viewed two ways: as a flat vector for the bus interface and as a field struct for the protocol layer; write through one member, read through the other. The danger is the same as C: nothing records which view is currently valid . A tagged union adds a hidden tag and makes the simulator enforce it — you must write with tagged Member value syntax and read via pattern matching, and reading the wrong member is a runtime error rather than silent garbage. Tagged unions see limited tool support, so most production code uses a packed union plus an explicit discriminant field — but interviewers ask about tagged unions to see if you know why raw unions are unsafe.
// Packed union: two views of one 32-bit register
typedef struct packed { logic [15:0] hi; logic [15:0] lo; } halves_t;
typedef union packed {
logic [31:0] word;
halves_t h;
} reg_view_t;
reg_view_t r;
r.word = 32'h1234_5678;
$display("hi=%h lo=%h", r.h.hi, r.h.lo); // 1234 / 5678 — same bits
// Tagged union: simulator-checked discriminant
typedef union tagged {
logic [31:0] Addr;
logic [7:0] Irq;
void Idle;
} cmd_u;
cmd_u c = tagged Addr 32'h4000;
case (c) matches
tagged Addr .a : $display("addr access %h", a);
tagged Irq .i : $display("irq line %0d", i);
tagged Idle : ;
endcaseKey takeaways
Pin enum base types in RTL (enum logic [N:0]) — 4-state width-exact state registers that show X when unreset.
Enum methods (name/first/next/num) give self-describing prints and exhaustive iteration for free.
Packed struct = named view of a vector, first field at MSB — the right type for bus payloads and headers.
Unions share storage; without a tag (or explicit discriminant) reading the wrong view is silent garbage.
Common pitfalls
Default enum base type is int — 2-state and 32 bits wide; an unreset FSM then fakes a legal state instead of X.
Assuming next() increments the encoding — it follows declaration order and wraps, regardless of values.
Reading unpacked struct bytes as if layout were defined — padding and ordering are tool-specific.
Mixing up packed-struct field order — first-declared is the MSB slice, the opposite of many C bitfield habits.