SystemVerilog · Module 2
Structures — Packed & Unpacked
Register field modeling, protocol frame packing, struct literals.
Module 2 · Page 2.7
The Type That Models Both Hardware Registers and Transaction Objects
Every register in your DUT has named fields. The AXI AW channel has AWADDR, AWLEN, AWSIZE, AWBURST. Without struct, your RTL has to manually track which bits hold which field, and every access looks like ctrl_reg[15:8] with a comment saying "this is the TIMEOUT field." With a packed struct, you write ctrl_reg.timeout and the compiler handles the bit positioning.
Named field types are declared with typedef, and where several layouts must share storage a union is the related construct. On the verification side, every UVM transaction class is conceptually an unpacked struct — a named collection of fields like address, data, response code, and burst type. The difference from a class is that structs have no methods, no inheritance, and are value types (assignment copies data, not a handle). That simplicity makes them perfect for lightweight data carriers passed between functions and tasks.
The packed vs unpacked distinction is the critical split in struct usage. Packed means the fields are laid out as a contiguous bit vector — you can treat the entire struct as a single wide signal, connect it to ports, do bitwise operations on it, and send it through an interface. Unpacked means each field has independent storage — you can use arrays, strings, class handles, and dynamic types as fields, but you lose the single-vector treatment.
Packed vs Unpacked — The Fundamental Split
A packed struct lays its fields out as a contiguous bit vector, MSB-first in declaration order. The first declared field occupies the most significant bits; the last occupies the least significant. The entire struct can be assigned to/from a logic vector of the same total width, passed through ports, and used in bitwise operations. Every field must have a fixed bit width — no strings, no dynamic arrays.
An unpacked struct stores each field independently, like a C struct. Fields are accessed by name but have no mandatory bit packing or ordering. You can use any type as a field: arrays, queues, strings, class handles, other structs. The struct cannot be treated as a single bit vector — you cannot assign it to logic [N:0] or connect it to a plain port.
struct packed — contiguous vector
Fields form a contiguous bit vector. First field = MSB. Can be treated as logic [N:0]. Port-connectable. Synthesizable. Use for registers, protocol frames, hardware signals.
struct (unpacked) — independent fields
Each field has independent storage. Any field type allowed including arrays, strings, class handles. Cannot be treated as a bit vector. Use for TB transactions, data models.
Whole-struct copy
Both packed and unpacked support single-statement copy: s2 = s1. Creates a deep copy of all fields. Value type — modifying s2 does not affect s1.
Struct literal '{}
Initialize with named or positional values: '{field: val} or '{val1, val2}. Named form is clearer and order-independent.
Syntax — Every Form You'll Use
// ── PACKED STRUCT ─────────────────────────────────────────────────
// Fields laid out as contiguous bit vector, MSB-first (first declared = MSB)
typedef struct packed {
logic [3:0] opcode; // bits [15:12] — MSB group
logic [3:0] dst; // bits [11:8]
logic [3:0] src; // bits [7:4]
logic [3:0] imm; // bits [3:0] — LSB group
} instr_t; // total = 16 bits
instr_t ins;
logic [15:0] raw_bits;
// Field access
ins.opcode = 4'hA;
ins.dst = 4'h3;
ins.src = 4'h1;
ins.imm = 4'h7;
// Packed struct ↔ logic vector (same total width)
raw_bits = ins; // 16'hA317 — direct assignment
ins = instr_t'(raw_bits); // logic → packed struct via cast
// ── UNPACKED STRUCT ───────────────────────────────────────────────
// Fields have independent storage; any type is allowed
typedef struct {
logic [31:0] addr;
logic [31:0] data;
bit is_write;
string label; // string: only in unpacked struct
int latency;
} txn_t;
txn_t t1, t2;
t1.addr = 32'hA000;
t1.data = 32'hCAFE;
t1.is_write = 1'b1;
t1.label = "WRITE_0";
t1.latency = 5;
t2 = t1; // deep copy — t2 is independent of t1
// ── STRUCT LITERAL ────────────────────────────────────────────────
instr_t ins2 = '{opcode:4'h5, dst:4'h2, src:4'h1, imm:4'h0};
txn_t t3 = '{addr:32'h1000, data:32'hFF, is_write:1, label:"RD", latency:3};
// Or positional (order must match declaration):
instr_t ins3 = '{4'h5, 4'h2, 4'h1, 4'h0}; // positional
// ── SIGNED PACKED STRUCT ──────────────────────────────────────────
typedef struct packed signed {
logic [15:0] real_part;
logic [15:0] imag_part;
} complex_t; // whole struct treated as signed 32-bit when used as vector
// ── NESTED STRUCT ─────────────────────────────────────────────────
typedef struct packed {
instr_t cmd; // nested packed struct
logic valid;
} pkt_t; // total = 17 bits
pkt_t pkt;
pkt.cmd.opcode = 4'hF; // nested field access
pkt.valid = 1'b1;| Feature | struct packed | struct (unpacked) |
|---|---|---|
| Memory layout | Contiguous bit vector | Independent field storage |
| First field position | MSB (highest bits) | No bit ordering |
| Assign to logic [N:0] | Yes — direct or via cast | No — type error |
| Port-connectable | Yes — as a packed type | No — port must use packed/plain types |
| Allowed field types | Only packed types (logic, bit, packed struct/enum) | Any type including string, array, class handle |
| Synthesizable | Yes | Partially (non-synthesizable field types excluded) |
| Bitwise operations on whole struct | Yes | No |
| Whole-struct copy | Yes | Yes |
Visual — Packed Bit Layout and Field Mapping
Packed Struct Bit Positions
Declaration order determines bit position — first declared = most significant bits. typedef struct packed { logic [3:0] op; logic [3:0] dst; logic [3:0] src; logic [3:0] imm; } instr_t
| Field | Width | Bit positions in 16-bit word | Access | ins = 16'hA317 → field value |
|---|---|---|---|---|
op | 4 | [15:12] — MSB | ins.op | 4'hA |
dst | 4 | [11:8] | ins.dst | 4'h3 |
src | 4 | [7:4] | ins.src | 4'h1 |
imm | 4 | [3:0] — LSB | ins.imm | 4'h7 |
| Whole struct | 16 | [15:0] | ins or cast to logic | 16'hA317 |
Struct Literal Forms
| Form | Example | Notes |
|---|---|---|
| Named (preferred) | '{op:4'hA, dst:4'h3, src:4'h1, imm:4'h7} | Order-independent, self-documenting |
| Positional | '{4'hA, 4'h3, 4'h1, 4'h7} | Must match declaration order exactly |
| Default fill | '{default: '0} | All fields to 0 |
| Partial named | '{op:4'hA, default:'0} | Set op, fill rest with 0 |
Code Examples — Register Models to Protocol Transactions
Example 1 — Beginner: Packed Struct as Register
module tb_packed_struct;
// 32-bit control register layout:
// [31:24] = timeout (8 bits) [23:16] = max_retry (8 bits)
// [15:8] = mode (8 bits) [7:0] = flags (8 bits)
typedef struct packed {
logic [7:0] timeout;
logic [7:0] max_retry;
logic [7:0] mode;
logic [7:0] flags;
} ctrl_reg_t;
ctrl_reg_t ctrl;
logic [31:0] raw;
initial begin
// ── Field-by-field assignment ─────────────────────────────────
ctrl.timeout = 8'd100;
ctrl.max_retry = 8'd3;
ctrl.mode = 8'h05;
ctrl.flags = 8'b0000_0001;
// ── Packed struct → raw bits ──────────────────────────────────
raw = ctrl;
$display("ctrl raw = 0x%08h", raw); // 0x64030501
$display("timeout = %0d", ctrl.timeout); // 100
$display("flags = %08b", ctrl.flags); // 00000001
// ── Assign raw bits to struct ─────────────────────────────────
raw = 32'hAABBCCDD;
ctrl = ctrl_reg_t'(raw);
$display("From raw 0xAABBCCDD:");
$display(" timeout = 0x%02h", ctrl.timeout); // AA
$display(" max_retry = 0x%02h", ctrl.max_retry); // BB
$display(" mode = 0x%02h", ctrl.mode); // CC
$display(" flags = 0x%02h", ctrl.flags); // DD
// ── Struct literal ────────────────────────────────────────────
ctrl = '{timeout:8'd50, max_retry:8'd5, mode:8'h02, flags:8'h00};
$display("Literal: 0x%08h", logic'(ctrl)); // 0x32050200
$finish;
end
endmoduleExample 2 — Intermediate: AXI Write Address Channel as Packed Struct
// AXI4 Write Address Channel beat — pack into single vector for monitoring
typedef struct packed {
logic [7:0] awid;
logic [31:0] awaddr;
logic [7:0] awlen;
logic [2:0] awsize;
logic [1:0] awburst;
} axi_aw_t; // total = 56 bits
module tb_axi_struct;
axi_aw_t aw_beat;
// Build a write beat for a 4-beat INCR burst at 0xA000_0000
initial begin
aw_beat = '{
awid: 8'h05,
awaddr: 32'hA000_0000,
awlen: 8'd3, // 4 beats (len+1)
awsize: 3'b010, // 4 bytes per beat
awburst: 2'b01 // INCR
};
$display("AW Beat:");
$display(" AWID = 0x%02h", aw_beat.awid);
$display(" AWADDR = 0x%08h", aw_beat.awaddr);
$display(" AWLEN = %0d (burst of %0d)", aw_beat.awlen, aw_beat.awlen+1);
$display(" AWBURST = %02b (INCR)", aw_beat.awburst);
// Pass the packed struct as a single 56-bit vector
logic [55:0] raw_beat = aw_beat;
$display("Packed: 0x%014h", raw_beat);
// Reconstruct from raw bits
axi_aw_t recovered = axi_aw_t'(raw_beat);
$display("Recovered addr: 0x%08h", recovered.awaddr);
$finish;
end
endmoduleExample 3 — Verification: Unpacked Struct Transaction Object
typedef enum logic [1:0] { OKAY, EXOKAY, SLVERR, DECERR } resp_t;
typedef struct {
logic [7:0] tid;
logic [31:0] addr;
logic [31:0] data []; // dynamic array — ONLY in unpacked struct
resp_t resp;
string label; // string — ONLY in unpacked struct
int latency;
} axi_resp_txn_t;
module tb_unpacked_struct;
task automatic print_txn(input axi_resp_txn_t t);
$display("[%s] tid=0x%h addr=0x%08h resp=%s lat=%0d",
t.label, t.tid, t.addr, t.resp.name(), t.latency);
foreach (t.data[i])
$display(" data[%0d] = 0x%08h", i, t.data[i]);
endtask
task automatic compare_txn(input axi_resp_txn_t exp, got);
if (exp.tid !== got.tid) $error("TID mismatch");
if (exp.addr !== got.addr) $error("ADDR mismatch");
if (exp.resp !== got.resp)
$error("RESP: exp=%s got=%s", exp.resp.name(), got.resp.name());
foreach (exp.data[i])
if (exp.data[i] !== got.data[i])
$error("data[%0d] mismatch: exp=0x%h got=0x%h", i, exp.data[i], got.data[i]);
$display("PASS: %s", exp.label);
endtask
initial begin
axi_resp_txn_t t1, t2;
t1.tid = 8'h07;
t1.addr = 32'hA000_0100;
t1.data = new[2]('{32'hAAAA, 32'hBBBB});
t1.resp = OKAY;
t1.label = "write_beat_0";
t1.latency = 8;
print_txn(t1);
t2 = t1; // whole-struct copy — t2 is independent
t2.data[0] = 32'hCCCC; // does NOT affect t1.data[0]
compare_txn(t1, t2); // will report data[0] mismatch
$finish;
end
endmoduleExample 4 — RTL: Packed Struct on Module Port
package axi_pkg;
typedef struct packed {
logic awvalid;
logic [31:0] awaddr;
logic [7:0] awlen;
logic [1:0] awburst;
} axi_aw_t; // 42 bits total
endpackage
import axi_pkg::*;
// DUT: receives packed struct on a single port
module axi_slave (
input logic clk,
input axi_aw_t aw, // packed struct port — one 42-bit input
output logic awready
);
assign awready = aw.awvalid; // access fields directly
endmodule
// Testbench: drive the packed struct port
module tb_port_struct;
import axi_pkg::*;
logic clk = 0;
axi_aw_t aw_drive;
logic awready;
axi_slave dut (.clk(clk), .aw(aw_drive), .awready(awready));
always #5 clk = ~clk;
initial begin
aw_drive = '{awvalid:1'b0, awaddr:32'h0, awlen:8'h0, awburst:2'b00};
@(posedge clk);
aw_drive.awvalid = 1'b1;
aw_drive.awaddr = 32'hA000_0000;
aw_drive.awlen = 8'd15;
aw_drive.awburst = 2'b01;
@(posedge clk);
$display("awready = %0b", awready); // 1
$finish;
end
endmoduleSimulation and Synthesis Behavior
Packed Struct in Synthesis
A packed struct synthesizes identically to a plain logic [N:0] of the same total width. The field names are purely a compile-time convenience — the synthesizer generates the same gates whether you write ctrl.timeout or ctrl_reg[31:24]. This means packed structs have zero overhead — no extra logic, no alignment padding. The field boundaries are exact, and accessing one field at synthesis is equivalent to a bit-slice of the underlying vector.
Unpacked Struct — Value Type Semantics
Unpacked struct assignment (t2 = t1) creates a deep copy of all fields. For fields that are themselves arrays or dynamic types, the copy is element-by-element — each field in t2 gets its own independent storage. After t2 = t1, modifying t2.data does not affect t1.data. This is value-type semantics — the same as copying all the fields manually one by one, but in one statement.
| Operation | Packed struct | Unpacked struct |
|---|---|---|
| Synthesizable | Yes | Depends on field types |
| Port connection | Yes — treated as packed vector | No — incompatible with plain ports |
| Bitwise operations (& | ^) | Yes — on whole struct as vector | No |
| Dynamic array field | Not allowed | Allowed |
| string field | Not allowed | Allowed |
| Assignment | Deep copy of bit vector | Deep copy of all fields |
| Comparison (== !=) | Whole-struct comparison | Whole-struct comparison (all fields) |
Where Structs Shape Real Verification Architecture
// ── 1. PACKED STRUCT FOR REGISTER MAP FIELD ACCESS ────────────────
typedef struct packed {
logic err_en; // [31]
logic [6:0] rsvd; // [30:24]
logic [7:0] timeout; // [23:16]
logic [15:0] base_addr; // [15:0]
} dma_ctrl_t;
dma_ctrl_t ctrl_shadow; // mirror of DUT register
logic [31:0] reg_readback;
// After reading DUT register via RAL:
ctrl_shadow = dma_ctrl_t'(reg_readback);
$display("err_en=%b timeout=%0d base=0x%04h",
ctrl_shadow.err_en, ctrl_shadow.timeout, ctrl_shadow.base_addr);
// ── 2. UNPACKED STRUCT FOR TRANSACTION QUEUES ─────────────────────
typedef struct {
logic [7:0] tid;
logic [31:0] addr;
logic [31:0] data;
bit is_write;
} beat_t;
beat_t exp_q [$]; // queue of struct objects
beat_t got_q [$];
// ── 3. PACKED STRUCT COMPARISON (uses === for X detection) ────────
function automatic bit beats_match(input beat_t e, g);
return (e.tid === g.tid && e.addr === g.addr && e.data === g.data);
endfunction
// ── 4. STRUCT IN COVERAGE ─────────────────────────────────────────
// covergroup cg_beat with function sample(beat_t b);
// cp_rw: coverpoint b.is_write;
// cp_addr: coverpoint b.addr[31:28]; // top nibble
// endgroup
// ── 5. NESTED PACKED STRUCT FOR AXI W BEAT ────────────────────────
typedef struct packed {
logic [7:0] wstrb;
logic [31:0] wdata;
logic wlast;
} axi_w_beat_t; // 41 bits total — directly connectable to AXI W channelBugs Engineers Hit With struct
Bug 1 — Wrong Field Order in Packed Struct: Silent Bit Misalignment
// Register spec: [31:24]=opcode, [23:16]=addr_hi, [15:0]=addr_lo
// BUGGY: fields declared in wrong order (LSB first)
typedef struct packed {
logic [15:0] addr_lo; // declared first → goes into bits [31:16] — WRONG!
logic [7:0] addr_hi; // bits [15:8] — WRONG!
logic [7:0] opcode; // bits [7:0] — WRONG!
} bad_reg_t;
bad_reg_t r = bad_reg_t'(32'hABCDEF12);
$display("opcode = 0x%02h", r.opcode); // 12 — WRONG, should be AB
// CORRECT: MSB field declared first
typedef struct packed {
logic [7:0] opcode; // bits [31:24] — MSB first
logic [7:0] addr_hi; // bits [23:16]
logic [15:0] addr_lo; // bits [15:0]
} good_reg_t;
good_reg_t g = good_reg_t'(32'hABCDEF12);
$display("opcode = 0x%02h", g.opcode); // AB — correctBug 2 — Dynamic Array in Packed Struct: Compile Error
// BUGGY: dynamic array and string inside packed struct
typedef struct packed {
logic [7:0] tid;
logic [31:0] data []; // COMPILE ERROR: dynamic array not allowed in packed struct
string label; // COMPILE ERROR: string not allowed in packed struct
} bad_t;
// FIXED option 1: use unpacked struct for variable-content data
typedef struct {
logic [7:0] tid;
logic [31:0] data []; // OK in unpacked
string label; // OK in unpacked
} good_t;
// FIXED option 2: if you need packed + variable data, use separate fields
typedef struct packed { logic [7:0] tid; logic [31:0] ctrl; } packed_part_t;
typedef struct {
packed_part_t hdr; // packed sub-struct
logic [31:0] payload []; // dynamic part in unpacked wrapper
} hybrid_t;Bug 3 — Unpacked Struct Port Connection Fails
typedef struct { // UNPACKED — note: no 'packed' keyword
logic [31:0] addr;
logic [31:0] data;
} txn_t;
// BUGGY: unpacked struct on a module port
module bad_module (input txn_t txn); // ERROR: port type must be packed
endmodule
// FIXED: use packed struct for port connections
typedef struct packed {
logic [31:0] addr;
logic [31:0] data;
} packed_txn_t;
module good_module (input packed_txn_t txn); // OK
$display("addr = 0x%08h", txn.addr);
endmoduleA Runnable Proof — Where Each Field Actually Lands
The page states that a packed struct lays its fields MSB-first in declaration order. That is the rule the whole type depends on, and it is worth proving rather than trusting, because an off-by-one in a field's position produces data that is wrong in a way no compiler can see.
module packed_struct_layout_proof;
// First declared field occupies the MOST significant bits.
typedef struct packed {
logic [7:0] a; // bits [23:16]
logic [7:0] b; // bits [15:8]
logic [7:0] c; // bits [7:0]
} three_byte_t;
// A realistic case: an AXI W-channel beat, 37 bits wide.
typedef struct packed {
logic wlast; // bit [36]
logic [3:0] wstrb; // bits [35:32]
logic [31:0] wdata; // bits [31:0]
} axi_w_t;
three_byte_t s;
axi_w_t beat;
logic [23: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. Total width is the sum of the fields");
expect("three_byte_t is 24 bits", $bits(three_byte_t) == 24);
expect("axi_w_t is 37 bits", $bits(axi_w_t) == 37);
$display("\n2. A packed struct IS a vector - assignment both ways");
raw = 24'hAA_BB_CC;
s = raw; // no cast needed: same packed width
$display(" raw = %h -> a=%h b=%h c=%h", raw, s.a, s.b, s.c);
expect("first declared field takes the MSBs (a == AA)", s.a == 8'hAA);
expect("middle field (b == BB)", s.b == 8'hBB);
expect("last declared field takes the LSBs (c == CC)", s.c == 8'hCC);
$display("\n3. ...and the reverse direction agrees");
s.a = 8'h11; s.b = 8'h22; s.c = 8'h33;
expect("struct reads back as one vector", 24'(s) == 24'h11_22_33);
$display("\n4. Field positions are exactly where the rule says");
// Slicing the struct as a vector must match the named field.
expect("a occupies [23:16]", 24'(s)[23:16] == s.a);
expect("b occupies [15:8]", 24'(s)[15:8] == s.b);
expect("c occupies [7:0]", 24'(s)[7:0] == s.c);
$display("\n5. The AXI beat - the case that motivates all of this");
beat = 37'( {1'b1, 4'hF, 32'hDEAD_BEEF} );
$display(" wlast=%b wstrb=%h wdata=%h", beat.wlast, beat.wstrb, beat.wdata);
expect("wlast is the MSB", beat.wlast == 1'b1);
expect("wstrb next", beat.wstrb == 4'hF);
expect("wdata the LSBs", beat.wdata == 32'hDEAD_BEEF);
$display("\n%0s (%0d failures)\n",
fails == 0 ? "ALL CHECKS PASSED" : "CHECKS FAILED", fails);
if (fails) $fatal(1, "packed_struct_layout_proof failed");
$finish;
end
endmoduleSection 4 is the one worth running once yourself. Slicing the struct as a vector and comparing against the named field is the direct statement of the layout rule, and it is also the check that catches a typedef whose field order does not match the wire order it was written to describe.
A monitor decoded every AXI beat with the fields in the wrong places
PACKED-STRUCT-FIELD-ORDERAn AXI write monitor reconstructed beats whose WDATA was plausible but wrong,
WSTRB was almost always 4'h0 or 4'hF, and WLAST was asserted on
approximately every beat rather than at the end of a burst.
The scoreboard reported data mismatches on every write burst. Because WLAST
looked asserted constantly, the first hypothesis was that the DUT was terminating
bursts early, and two days went into examining burst-length handling in the
design.
Reads were completely clean, which was treated as evidence that the monitor was fine and the write path was broken.
// The wire layout, from the interface, is:
// [36] WLAST
// [35:32] WSTRB
// [31:0] WDATA
// The typedef was written in the order the fields appear in the SPEC TABLE,
// which lists WDATA first because it is the most important field.
typedef struct packed {
logic [31:0] wdata; // <- declared FIRST, so it takes bits [36:5]
logic [3:0] wstrb; // <- bits [4:1]
logic wlast; // <- bit [0]
} axi_w_t;
axi_w_t beat = axi_w_t'(captured_37_bits);Printing the raw capture beside the decoded fields made it obvious in one beat:
captured = 37'h1_F_DEADBEEF (wlast=1, wstrb=F, wdata=DEADBEEF)
decoded with the buggy typedef:
beat.wdata = 32'hBF56DF7D <- bits [36:5] of the capture
beat.wstrb = 4'h7 <- bits [4:1]
beat.wlast = 1'b1 <- bit [0]Every field is shifted by five bit positions. wdata picked up wlast and
wstrb in its top bits and lost the bottom five bits of the real data, which is
why the value looked like plausible data rather than obvious garbage — it was
mostly the right bits in the wrong place.
The WLAST-always-asserted symptom followed directly: bit 0 of the capture is
the least significant bit of WDATA, which is high on roughly half of random
data — so wlast read as asserted about half the time and, on the ASCII-ish
payloads this test used, nearly always.
The reads being clean was a coincidence that cost a day: the read-channel typedef had been written by someone else, in wire order, and was correct.
A packed struct lays its fields MSB-first in declaration order. The typedef was written in the order the fields appear in the specification's field table, which lists the payload first for readability, and that order is the reverse of the wire order.
Nothing catches this. The struct is 37 bits either way, so the cast from a 37-bit vector is width-correct and legal; the compiler has no knowledge of what the bits are supposed to mean. Both typedefs are valid SystemVerilog describing different layouts, and only one of them matches the interface.
The bug is also self-consistent, which is what makes it survive review. If both the monitor and the driver used the same wrong typedef, the testbench would talk to itself perfectly and only disagree with the DUT — and if a scoreboard compared driver-side and monitor-side transactions rather than against the interface, even that disagreement could vanish.
The lesson is that a packed struct is a layout specification, not a documentation convenience. Writing it in reading order rather than bit order produces a type that is correct in isolation and wrong at the boundary.
// Declare in WIRE order: most significant field first.
typedef struct packed {
logic wlast; // bit [36]
logic [3:0] wstrb; // bits [35:32]
logic [31:0] wdata; // bits [31:0]
} axi_w_t;Verifying it does not need the DUT. Assert the field positions directly against the vector, which is a compile-and-run check that takes four lines:
initial begin
axi_w_t t;
t = 37'( {1'b1, 4'hF, 32'hDEAD_BEEF} );
assert (t.wlast == 1'b1 && t.wstrb == 4'hF && t.wdata == 32'hDEAD_BEEF)
else $fatal(1, "axi_w_t field order does not match the wire layout");
endTwo habits prevent recurrence.
Add a width assertion beside every packed typedef used at a boundary.
$bits(axi_w_t) == 37 catches the related failure where a field is added or
widened and the struct silently stops matching the interface — a change that
otherwise shifts every field below it.
Write packed structs in bit order and comment the ranges, as in the corrected version above. The comment is not decoration: it is the only place the intended layout is stated in a form a reviewer can check against the interface without mentally recomputing the packing.
For the type rules this rests on see 4-state versus 2-state types; for the fixed-width array dimensions that interact with packed layout, fixed-size arrays.
Interview Questions
A packed struct lays all its fields out as one contiguous bit vector, first declared field in the most significant bits. It can be assigned to and from a logic [N:0] of the same width, connected to module ports, sliced, and used in bitwise operations. Every field must itself be a packed type — logic, bit, packed structs, enums with a packed base — so no strings, no dynamic arrays, no class handles.
An unpacked struct gives each field independent storage, like a C struct. Any type is allowed, including queues, strings and class handles, and the price is that it has no single bit-level representation: it cannot be cast to a vector or connected to a plain port.
The practical split is what the struct is for. Modelling something that exists on wires — a register, a protocol beat, a port bundle — means packed, because the bit layout is the specification. Carrying a group of values around a testbench means unpacked, because the convenience of arbitrary field types matters more than a layout nobody will look at.
Both are value types: s2 = s1 copies the contents, and modifying s2 does not affect s1. That is the main thing distinguishing a struct from a class, where assignment copies a handle.
The first declared field. In struct packed { logic [7:0] a; logic [7:0] b; } s, a occupies bits [15:8] and b occupies [7:0], so assigning 16'hABCD gives a = 8'hAB and b = 8'hCD.
The convention matches how hardware documentation is written: a register table lists bit 31 first, and a packed struct written in the same order produces the same layout. That is the whole reason the rule is MSB-first rather than the other way round.
The failure it invites is writing the typedef in the order a specification table lists the fields, which is often payload-first for readability rather than bit-order. The struct is the correct total width either way, so the cast from a captured vector is legal, and every field lands shifted. That is the Debug Lab above.
The check is one line and worth having beside any boundary typedef: cast a known value in and assert the fields come out where you expect.
Only a packed struct. Module ports require a type with a defined bit-level representation, and an unpacked struct has none — its fields have independent storage with no mandated packing.
At the boundary a packed struct port behaves exactly like a logic [N:0] of the same total width. The connection is by position and width, not by field name, so a module declaring input axi_w_t beat can be driven by any 37-bit expression. The field names are a convenience inside each module; they are not checked across the connection.
That has a consequence worth stating plainly: connecting two modules that use different typedefs of the same width compiles and runs, and silently reorders the fields. The type system does not protect the boundary, only the arithmetic.
The defence is to share one typedef from a package rather than declaring it in each module, so there is exactly one definition of the layout and both sides import it. A width assertion — $bits(axi_w_t) == 37 — catches the case where the shared typedef changes and one side was not recompiled.
Packed structs synthesise directly: they are a naming convention over a bit vector, so ctrl_reg.timeout becomes exactly the same hardware as ctrl_reg[15:8]. There is no cost and no inferred logic — the compiler resolves field access at elaboration.
Unpacked structs are synthesisable only in the restricted sense that a synthesis tool may accept one whose fields are all synthesisable types, treating it as a bundle of separate signals. Support varies, and an unpacked struct containing a queue, a string or a class handle is not synthesisable under any tool.
The reliable guidance is to treat packed as the RTL type and unpacked as the testbench type. That maps onto the reason each exists: RTL needs a layout, testbenches need convenience.
One detail worth knowing for RTL: a packed struct made of logic fields is 4-state, so it initialises to X and shows unreset state honestly. Declaring the fields bit makes it 2-state, faster to simulate, and silently zero at time zero — the same trade discussed for scalar types, and the same reason to prefer logic for anything that touches the DUT.
Almost certainly the typedef declares its fields in the wrong order — written in the order a specification table lists them rather than in wire order, most significant field first.
The reason it is hard to spot is that nothing is illegal. The struct is the correct total width, so the cast from the captured vector is width-correct and compiles; the compiler has no idea what the bits are supposed to mean. Two typedefs describing different layouts are both valid SystemVerilog.
The symptom is distinctive once you know it: fields are shifted, not garbage. A payload field picks up neighbouring fields in its top bits and loses its own bottom bits, so the value looks plausible rather than obviously wrong. A one-bit flag declared at the wrong end reads as roughly random data, which is why a LAST-type signal appearing asserted on most beats is a strong hint at this specific bug.
Diagnose by printing the raw capture beside the decoded fields on a single transaction. The bit offset between what you expect and what you get is the sum of the widths of the misplaced fields, which points straight at the typedef.
Note that a self-consistent testbench hides it entirely: if the driver and monitor share the wrong typedef they agree with each other and disagree only with the DUT.
When it is a value rather than an object — no methods, no inheritance, no randomisation, no factory involvement.
Structs are value types, so assignment copies the contents. That removes an entire class of aliasing bug: two handles to the same transaction, where modifying one silently changes the other, is impossible with a struct. For a small data carrier passed between functions, that safety is worth more than the features it lacks.
Classes earn their place the moment you need what they provide: rand fields and constraints, factory overrides so a test can substitute a derived type, do_copy/do_compare/convert2string for the UVM automation, or inheritance so a base sequence item can be extended per protocol. A UVM sequence item has to be a class, because the whole sequencer/driver mechanism is built on uvm_object.
A useful middle pattern is a class that contains a packed struct: the class supplies randomisation and factory support, and the packed field carries the exact wire layout for driving and sampling. That keeps the layout specification in one checkable place while leaving the methodology machinery to the class.
Where This Is Specified
- IEEE 1800-2023 §7.2 — Structures. Declaration syntax, the
packedqualifier, and the rule that a packed structure is represented as a contiguous set of bits with the first declared member in the most significant position. - IEEE 1800-2023 §7.2.1 — Packed structures. The restriction that every member of a packed structure must itself be a packed type, and the resulting ability to treat the structure as an integral value.
- IEEE 1800-2023 §6.22 — Type compatibility. Assignment compatibility between a packed structure and an integral value of the same width, and the equivalence rules that govern struct-to-struct assignment.
- IEEE 1800-2023 §23.3.3 — Port declarations. The requirement that a port have an integral or otherwise connectable type, which admits packed structures and excludes unpacked ones.
- IEEE 1800-2023 §20.6 — Expression size system function.
$bits, used above to assert that a boundary typedef still matches its interface width.
Best Practices and Coding Guidelines
Packed for hardware, unpacked for TB
Use struct packed for anything that maps to hardware: registers, protocol beats, port bundles. Use unpacked structs for TB transaction objects where fields may include arrays, strings, or latency counters.
First declared = MSB in packed
Always verify field ordering against the register spec. Draw the bit-field diagram next to your struct declaration during code review. Wrong order = silent bit misassignment.
Use typedef and package together
Define struct types with typedef in a shared package. Never duplicate struct definitions — one change in the package spec propagates everywhere immediately.
Named struct literals over positional
'{addr:32'h0, data:32'h0} over '{32'h0, 32'h0}. Named is order-independent, self-documenting, and survives field additions without silent value shifts.
| Use case | Type | Reason |
|---|---|---|
| Hardware register field model | struct packed | Direct assignment to/from raw register value |
| Protocol frame (AXI/AHB beat) | struct packed | Port-connectable, synthesizable, single-vector treatment |
| UVM transaction / scoreboard entry | struct (unpacked) | Can hold dynamic arrays, strings, class handles |
| Function argument grouping | Either, depending on fields | Pass multiple related values as one parameter |
| Array of transactions | Unpacked struct | txn_t q [$] — queue of struct objects |
Summary
Structs bridge the gap between raw bit vectors and named data structures. Packed structs are hardware — they map directly to register fields and protocol frames with zero overhead and full synthesizability. Unpacked structs are software — they model transactions and data objects with maximum field type flexibility. The three things that cause bugs: wrong field order in a packed struct (silently misaligns every field), trying to put dynamic arrays or strings in a packed struct (compile error), and connecting an unpacked struct to a module port (type error).
- First declared field = MSB in packed struct. Declare fields in the same order as the register spec (MSB first).
- Packed structs can be assigned to/from logic [N:0]. Cast with
struct_t'(raw)or assign directly when types match. - Unpacked structs allow any field type including dynamic arrays, strings, and class handles — packed structs do not.
- Only packed structs can be module ports. Design interfaces with packed structs for signal bundles.
- Both types support whole-struct copy and struct literals. Use named literals for readability and resilience to field reordering.
Part of SystemVerilog Fundamentals·Data Types·Lesson 11 of 53
View program