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.

systemverilog
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.

diagram
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 OK
systemverilog
typedef 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.

systemverilog
// 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    : ;
endcase

Key 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.