Unions & Tagged Unions
Packed unions, overlapping bit fields, tagged union type safety, protocol packet decoding.
Module 2 · Page 2.8
When the Same Bits Mean Different Things
Protocol headers are the canonical union use case. An Ethernet frame header can be parsed as a packed struct with named fields (destination MAC, source MAC, EtherType), or the same bytes can be viewed as a raw byte array for CRC calculation. Both interpretations occupy exactly the same memory — the union gives you both views simultaneously.
In hardware, this maps directly to multi-granularity bus access. A 32-bit register can be written as a word (one 32-bit write) or read back as four individual bytes. A union with a word_t member and a byte_t [4] member expresses exactly that semantics. Both members share the same 32 bits — writing through one member immediately affects what you read through the other.
The tagged union is the safer variant. It adds an implicit type tag that tracks which member was last written. Reading a member that wasn't written last is a runtime error — the simulator enforces type safety at the cost of one extra tag field. In practice, tagged unions are used more in verification testbenches (where type safety is more important than raw performance) than in RTL (where the extra tag bits add overhead).
Three Union Variants — Packed, Unpacked, Tagged
SystemVerilog has three union forms. The most common in real projects is union packed. The tagged variant is useful in verification but rarely seen in RTL. Plain unpacked unions exist but are almost never used since they sacrifice the key advantage (overlapping storage) without gaining anything.
- union packed — All members share the same contiguous bit storage. All members must have the same total width. Read through any member at any time — you see the same bits interpreted differently. Most useful for multi-granularity data access.
- union (unpacked) — Members may have different sizes (the union size = largest member). No bit-level overlapping guarantee in simulation. Rarely used — if you need overlapping bits, use packed; if you need different types, use tagged.
- union tagged — Type-safe variant. Simulator tracks which member was last written via a hidden tag. Reading wrong member is a runtime error. Members can have different types and sizes. Used for variant/discriminated union patterns in TB.
- union packed vs struct packed — Struct: fields occupy adjacent bits — total width = sum of all. Union: fields overlap the same bits — total width = width of one member (all must be equal).
Syntax — All Three Forms
// ── PACKED UNION: all members same total width ────────────────────
typedef union packed {
logic [31:0] word; // 32-bit view
logic [1:0][15:0] half; // two 16-bit halves (packed array)
logic [3:0][7:0] bytes;// four 8-bit bytes (packed array)
} word32_t;
word32_t data;
data.word = 32'hAABBCCDD;
$display("word = 0x%08h", data.word); // AABBCCDD
$display("half[1] = 0x%04h", data.half[1]); // AABB (upper half)
$display("half[0] = 0x%04h", data.half[0]); // CCDD (lower half)
$display("bytes[3]= 0x%02h", data.bytes[3]); // AA (MSB byte)
$display("bytes[0]= 0x%02h", data.bytes[0]); // DD (LSB byte)
// ── PACKED UNION WITH STRUCT ──────────────────────────────────────
typedef struct packed {
logic [7:0] op;
logic [7:0] dst;
logic [7:0] src;
logic [7:0] imm;
} instr_fields_t;
typedef union packed {
logic [31:0] raw; // raw bits view
instr_fields_t fields; // named field view (same total width: 32)
} instr_t;
instr_t ins;
ins.raw = 32'hA5_03_01_FF; // write raw
ins.fields.op = 8'hA5; // or write via fields
// ── TAGGED UNION: type-safe, members can differ in type/size ──────
typedef union tagged {
int IntVal;
real RealVal;
logic [31:0] BitsVal;
string StrVal;
} variant_t;
variant_t v;
// Write: use tagged assignment syntax
v = tagged IntVal 42; // set tag=IntVal, value=42
v = tagged RealVal 3.14; // set tag=RealVal, value=3.14
v = tagged StrVal "hello"; // set tag=StrVal
// Read: use .member (valid ONLY for the tag that was last written)
$display("%s", v.StrVal); // OK: tag is StrVal
// $display("%0d", v.IntVal); FATAL: tag is StrVal, not IntVal
// Pattern match with tagged union (type-safe switch)
case (v) matches
tagged IntVal .i : $display("int: %0d", i);
tagged RealVal .r : $display("real: %f", r);
tagged StrVal .s : $display("string: %s", s);
default: $display("other");
endcase| Property | union packed | union (unpacked) | union tagged |
|---|---|---|---|
| Members share storage? | Yes — exactly | Largest member's size allocated | No — each member separate (tag tracked) |
| All members same width? | Required | Not required | Not required |
| Read wrong member? | Allowed — sees bits from last write | Allowed — implementation-defined | Fatal runtime error |
| Non-packed field types | Not allowed | Allowed | Allowed (any type) |
| Synthesizable | Yes | Partially | No |
| Port-connectable | Yes (packed) | No | No |
Visual — Overlapping Bits and Tagged Type Tracking
Packed Union Memory Overlap
Value stored: 32'hAABBCCDD. Every member view reads from the same 32 bits:
| Bits | [31:24] | [23:16] | [15:8] | [7:0] |
|---|---|---|---|---|
| Raw | AA BB CC DD | |||
.word | 32'hAABBCCDD | |||
.half[1] | 16'hAABB | — | ||
.half[0] | — | 16'hCCDD | ||
.bytes[3] | 8'hAA | — | ||
.bytes[0] | — | 8'hDD |
Tagged Union — Tag Tracks Active Member
| Assignment | Active tag | Valid read | Invalid read (fatal) |
|---|---|---|---|
v = tagged IntVal 42 | IntVal | v.IntVal = 42 | v.RealVal, v.StrVal |
v = tagged RealVal 3.14 | RealVal | v.RealVal = 3.14 | v.IntVal, v.StrVal |
v = tagged StrVal "hi" | StrVal | v.StrVal = "hi" | v.IntVal, v.RealVal |
| Uninitialized | None / undefined | None | Any member read is fatal |
Union vs Struct — Same Bits, Different Semantics
| Type | 32-bit example | Total bits | Members relationship |
|---|---|---|---|
struct packed | { logic [15:0] A; logic [15:0] B; } | 32 (16+16) | A occupies [31:16], B occupies [15:0] — adjacent |
union packed | { logic [31:0] word; logic [1:0][15:0] halves; } | 32 (both) | word and halves both occupy the same 32 bits |
Code Examples — Multi-Granularity Access to Protocol Decoding
Example 1 — Beginner: Multi-Granularity Register Access
module tb_packed_union;
typedef union packed {
logic [31:0] word; // full 32-bit access
logic [1:0][15:0] half; // two 16-bit halves
logic [3:0][7:0] bytes; // four bytes
} bus32_t;
bus32_t bus;
initial begin
// ── Write as word, read back as bytes ─────────────────────────
bus.word = 32'hAABBCCDD;
$display("Word = 0x%08h", bus.word);
foreach (bus.bytes[i])
$display(" byte[%0d] = 0x%02h", i, bus.bytes[i]);
// byte[3]=AA byte[2]=BB byte[1]=CC byte[0]=DD
// ── Write a byte, read back as word ──────────────────────────
bus.bytes[3] = 8'hFF; // only modify MSB byte
$display("After byte[3]=FF: word = 0x%08h", bus.word);
// FFBBCCDD — only byte[3] changed
// ── Swap upper and lower halves ───────────────────────────────
bus.word = 32'hAABBCCDD;
logic [15:0] tmp = bus.half[1]; // save upper: AABB
bus.half[1] = bus.half[0]; // upper = lower: CCDD
bus.half[0] = tmp; // lower = saved: AABB
$display("Swapped halves: 0x%08h", bus.word); // CCDDAABB
$finish;
end
endmoduleExpected output:
Word = 0xAABBCCDD
byte[3] = 0xAA
byte[2] = 0xBB
byte[1] = 0xCC
byte[0] = 0xDD
After byte[3]=FF: word = 0xFFBBCCDD
Swapped halves: 0xCCDDAABBExample 2 — Intermediate: Protocol Packet Decoder Using Union
// A command packet: 32 bits, two possible layouts based on opcode[31:28]
// If opcode[31:28] == 4'hA: ALU format {op[31:28], dst[27:24], src1[23:20], src2[19:16], imm[15:0]}
// If opcode[31:28] == 4'hB: MEM format {op[31:28], rsvd[27:25], base[24:8], offset[7:0]}
typedef struct packed {
logic [3:0] op;
logic [3:0] dst;
logic [3:0] src1;
logic [3:0] src2;
logic [15:0] imm;
} alu_fmt_t;
typedef struct packed {
logic [3:0] op;
logic [2:0] rsvd;
logic [16:0] base;
logic [7:0] offset;
} mem_fmt_t;
typedef union packed {
alu_fmt_t alu;
mem_fmt_t mem;
logic [31:0] raw;
} cmd_t; // all members = 32 bits
module tb_packet_decode;
function automatic void decode(logic [31:0] pkt_bits);
cmd_t cmd;
cmd.raw = pkt_bits; // load raw bits
case (cmd.alu.op) // op field is same position in both formats
4'hA: $display("ALU: dst=%0h src1=%0h src2=%0h imm=%0h",
cmd.alu.dst, cmd.alu.src1, cmd.alu.src2, cmd.alu.imm);
4'hB: $display("MEM: base=0x%05h offset=0x%02h",
cmd.mem.base, cmd.mem.offset);
default: $display("UNKNOWN op=0x%h", cmd.alu.op);
endcase
endfunction
initial begin
decode(32'hA3_1_2_0005); // ALU: op=A dst=3 src1=1 src2=2 imm=5
decode(32'hB0_10000_42); // MEM: op=B base=... offset=42h
$finish;
end
endmoduleExample 3 — Verification: Tagged Union as Type-Safe Variant
module tb_tagged_union;
// Tagged union: one event log that holds different event types
typedef union tagged {
logic [31:0] WriteEvent; // holds write data
logic [31:0] ReadEvent; // holds read data
logic [7:0] ErrorCode; // holds error code
} sim_event_t;
sim_event_t log [$];
function automatic string format_event(input sim_event_t e);
case (e) matches
tagged WriteEvent .d : return $sformatf("WR data=0x%08h", d);
tagged ReadEvent .d : return $sformatf("RD data=0x%08h", d);
tagged ErrorCode .c : return $sformatf("ERR code=0x%02h", c);
endcase
return "UNKNOWN";
endfunction
initial begin
sim_event_t e;
// Log a write, a read, and an error
e = tagged WriteEvent 32'hCAFE_BABE; log.push_back(e);
e = tagged ReadEvent 32'h1234_5678; log.push_back(e);
e = tagged ErrorCode 8'hFF; log.push_back(e);
$display("Event log (%0d entries):", log.size());
foreach (log[i])
$display(" [%0d] %s", i, format_event(log[i]));
$finish;
end
endmoduleExpected output:
Event log (3 entries):
[0] WR data=0xCAFEBABE
[1] RD data=0x12345678
[2] ERR code=0xFFExample 4 — Corner Case: RTL Register with Packed Union View
// STATUS register — can be read as whole word or as individual fields
typedef struct packed {
logic err_flag; // [31]
logic [6:0] rsvd; // [30:24]
logic [7:0] err_code; // [23:16]
logic [15:0] count; // [15:0]
} status_fields_t;
typedef union packed {
status_fields_t fields;
logic [31:0] raw;
} status_reg_t;
module tb_union_reg;
status_reg_t sr;
initial begin
// Scoreboard: read raw register, decode fields immediately
sr.raw = 32'h8005_0064; // simulated register readback
$display("err_flag = %0b", sr.fields.err_flag); // 1 (bit 31 = 1)
$display("err_code = 0x%02h", sr.fields.err_code); // 05
$display("count = %0d", sr.fields.count); // 100 (0x0064)
// Clear error: write raw, preserving other bits
sr.fields.err_flag = 1'b0;
sr.fields.err_code = 8'h00;
$display("After clear: raw = 0x%08h", sr.raw); // 0x0000_0064
$finish;
end
endmoduleSimulation Behavior — Storage, Safety, and Synthesis
Packed Union Storage Model
In simulation, a packed union maintains a single block of N bits (where N = the common width of all members). Every member read and write accesses the same underlying bit storage. There is no "last written member" tracking — you can write through .word and immediately read through .bytes[0] without any error. This is the expected behavior: the union expresses "the same bits, seen differently."
Tagged Union — The Tag Overhead
Tagged unions add a hidden tag field managed by the simulator. The tag records which member was last written. Any attempt to read a member whose tag does not match causes a fatal simulation error — "union tag mismatch." This makes tagged unions safe but adds memory overhead (the tag field) and prevents them from being used in synthesizable RTL. They belong exclusively in testbench code where type safety matters more than area.
| Scenario | Packed union | Tagged union |
|---|---|---|
| Write member A, read member B | OK — B sees A's bits interpreted differently | Fatal: tag mismatch |
| Write member A, read member A | OK | OK |
| Uninitialized read | Returns X (logic type) | Fatal: no active tag |
| Synthesis support | Yes — same as packed vector | No — tag has no hardware equivalent |
| Port connection | Yes (packed) | No |
| Dynamic member sizes | No — all must be same width | Yes |
Where Unions Appear in Real Verification
// ── 1. REGISTER SHADOW: raw readback + field decode ────────────────
typedef union packed { ctrl_fields_t fields; logic [31:0] raw; } ctrl_reg_t;
ctrl_reg_t shadow;
shadow.raw = bus_readback; // assign raw readback
$display("timeout=%0d", shadow.fields.timeout); // immediate field access
// ── 2. OPCODE DISAMBIGUATION: packet format decoder ────────────────
cmd_t pkt;
pkt.raw = received_bits;
case (pkt.raw[31:28])
4'hA: $display("ALU: dst=%0h", pkt.alu.dst);
4'hB: $display("MEM: offset=%0h", pkt.mem.offset);
endcase
// ── 3. TAGGED UNION: type-safe event dispatch ─────────────────────
sim_event_t evt;
evt = tagged WriteEvent data;
// later, safely extract:
if (evt matches tagged WriteEvent .d)
scoreboard.record_write(d);
// ── 4. BYTE LANE STROBE MODELING ──────────────────────────────────
typedef union packed {
logic [31:0] word;
logic [3:0][7:0] lanes;
} wdata_t;
wdata_t wd;
logic [3:0] strb;
wd.word = 32'hAABBCCDD;
// Apply byte strobes — only write enabled bytes
foreach (wd.lanes[i])
if (!strb[i]) wd.lanes[i] = mem_contents[i]; // preserve masked bytesBugs Engineers Hit With Unions
Bug 1 — Packed Union Members with Mismatched Widths
// BUGGY: members have different widths
typedef union packed {
logic [31:0] word; // 32 bits
logic [15:0] half; // 16 bits — COMPILE ERROR: must match
} bad_union_t;
// FIXED option 1: all members same width
typedef union packed {
logic [31:0] word; // 32
logic [1:0][15:0] halves; // 32 (2×16)
logic [3:0][7:0] bytes; // 32 (4×8)
} good_union_t;
// FIXED option 2: use tagged union if members genuinely differ in size
typedef union tagged {
logic [31:0] word;
logic [15:0] half; // different size is OK in tagged union
} tagged_ok_t;Bug 2 — Reading Wrong Member in Packed Union (Intentional vs Accidental)
typedef union packed {
logic [31:0] word;
logic [3:0][7:0] bytes;
} u_t;
u_t u;
u.word = 32'h11223344;
// INTENTIONAL: read via different member — this is THE POINT of packed union
$display("bytes[0] = 0x%02h", u.bytes[0]); // 0x44 — correct and intended
// ACCIDENTAL: using wrong interpretation
// Example: struct member field order bug in union — accidental struct selection
typedef struct packed { logic [15:0] A; logic [15:0] B; } ab_t;
typedef struct packed { logic [15:0] X; logic [15:0] Y; } xy_t;
typedef union packed { ab_t ab; xy_t xy; } combo_t;
combo_t c;
c.ab.A = 16'hAAAA; c.ab.B = 16'hBBBB;
$display("c.xy.X = 0x%04h", c.xy.X); // AAAA — X and A are same bits
$display("c.xy.Y = 0x%04h", c.xy.Y); // BBBB — Y and B are same bits
// No bug here — this is correct union behavior. Just be aware it's intentional.Bug 3 — Tagged Union: Read Before Write (Fatal)
typedef union tagged { int I; real R; } num_t;
num_t v;
// BUGGY: reading before writing — no active tag
$display("%0d", v.I); // FATAL: "read from uninitialized tagged union"
// BUGGY: reading wrong member
v = tagged I 42;
$display("%f", v.R); // FATAL: tag is I, not R — "union tag mismatch"
// CORRECT: always write before read, match member to tag
v = tagged R 3.14;
$display("%f", v.R); // OK: 3.140000
// CORRECT: use case-matches to safely dispatch
case (v) matches
tagged I .i : $display("int: %0d", i);
tagged R .r : $display("real: %f", r); // executes this branch
endcaseInterview Questions
Beginner Level
Q1: What is the key difference between a union packed and a struct packed? In a struct packed, fields occupy adjacent, non-overlapping bit positions — total size equals the sum of all fields. In a union packed, all members share the same bit storage — total size equals the (mandatory equal) width of any one member. Writing through one member changes what you read through any other. Q2: What constraint applies to the members of a packed union? All members of a packed union must have exactly the same total bit width. If any member has a different width, the compiler reports an error. This constraint exists because a packed union represents a single block of N bits viewed in multiple ways — every view must cover exactly those N bits. If sizes differed, the bit mapping would be ambiguous.
Intermediate Level
Q3: What is a tagged union and what problem does it solve compared to a regular union? A tagged union adds a hidden type tag that the simulator tracks. It records which member was last written. Reading a member whose tag doesn't match causes a fatal runtime error. This solves the type safety problem of regular unions — in a regular packed union, you can accidentally read via the wrong member and get nonsense bits with no error. Tagged unions enforce that you only read the data you actually wrote. Members can have different types and sizes (unlike packed unions). The cost: no synthesis support and a small memory overhead for the tag.
Experienced Engineer Level
Q4: A scoreboard captures a 32-bit bus value and needs to decode it as either an instruction format or a data format based on bit [31]. How would you model this cleanly using a union? Define two packed structs for the two formats (both totaling 32 bits), then wrap them in a packed union with a logic [31:0] raw member: typedef union packed { instr_fmt_t instr; data_fmt_t data; logic [31:0] raw; } bus_word_t; Capture: bw.raw = captured_bits. Then dispatch: if (bw.raw[31]) decode_as_instruction(bw.instr); else decode_as_data(bw.data);. No separate bit-slice extraction or manual repacking — the union gives you all views simultaneously at zero cost.
Best Practices & Coding Guidelines
- Always include a raw member — Include logic [N:0] raw in every packed union. This gives you a clean way to load raw bits from a bus or compare the entire value without field-by-field operations.
- Verify all member widths match — Before declaring a packed union, manually count the total bits for each member. A struct member's total = sum of its field widths. Mismatch is a compile error, but catching it conceptually first saves time.
- Tagged union for variant TB types — When a testbench needs to store and dispatch different event or transaction types safely, use a tagged union. The runtime tag-mismatch errors catch programming mistakes early.
- case-matches for tagged union dispatch — Always use case (v) matches for dispatching on tagged unions. Avoid direct member access (v.member) without first verifying the tag — use matches which is both the check and the extract.
| Use case | Union type | Notes |
|---|---|---|
| Multi-granularity register (word/byte) | Packed union | Include raw member; all views same width |
| Protocol packet format disambiguation | Packed union of structs + raw | Capture raw, dispatch via field[31:28] etc. |
| Variant event/transaction log in TB | Tagged union | Type-safe dispatch with case-matches |
| Different-width views of same data | Tagged union | Packed union requires equal widths; tagged allows different |
| Synthesizable multi-view register | Packed union | Tagged union is not synthesizable |
Summary
Unions solve a specific problem: the same bits, interpreted differently. Packed unions are the hardware-friendly form — equal-width members, all sharing the same storage, synthesizable, port-connectable. The union-of-struct pattern (raw + named views) is the most practical application: load raw bits from a bus, decode fields through struct members. Tagged unions are the type-safe TB variant — different member sizes allowed, runtime tag enforcement, ideal for polymorphic event logs and variant types where type safety matters more than area.
- Packed union: all members must be the same total width. The union stores one N-bit block; all members are different views of it.
- Include a
rawmember in every packed union. It gives you a clean single-assignment entry point and a full-value comparison path. - Regular packed union has no type safety. Cross-member reads are legal and intentional — the point is multiple views of the same bits.
- Tagged union tracks the active member. Cross-member reads are fatal. Use
case matchesfor safe dispatch. - Tagged union is TB-only. Not synthesizable. Use packed union for RTL; tagged union for verification variant types.