Skip to content

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

SystemVerilog — union Syntax 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
Propertyunion packedunion (unpacked)union tagged
Members share storage?Yes — exactlyLargest member's size allocatedNo — each member separate (tag tracked)
All members same width?RequiredNot requiredNot required
Read wrong member?Allowed — sees bits from last writeAllowed — implementation-definedFatal runtime error
Non-packed field typesNot allowedAllowedAllowed (any type)
SynthesizableYesPartiallyNo
Port-connectableYes (packed)NoNo

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]
RawAA BB CC DD
.word32'hAABBCCDD
.half[1]16'hAABB
.half[0]16'hCCDD
.bytes[3]8'hAA
.bytes[0]8'hDD

Tagged Union — Tag Tracks Active Member

AssignmentActive tagValid readInvalid read (fatal)
v = tagged IntVal 42IntValv.IntVal = 42v.RealVal, v.StrVal
v = tagged RealVal 3.14RealValv.RealVal = 3.14v.IntVal, v.StrVal
v = tagged StrVal "hi"StrValv.StrVal = "hi"v.IntVal, v.RealVal
UninitializedNone / undefinedNoneAny member read is fatal

Union vs Struct — Same Bits, Different Semantics

Type32-bit exampleTotal bitsMembers 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

Example 1 — Packed Union: Word/Byte 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
 
endmodule

Expected output:

Simulation Output
Word = 0xAABBCCDD
byte[3] = 0xAA
byte[2] = 0xBB
byte[1] = 0xCC
byte[0] = 0xDD
After byte[3]=FF: word = 0xFFBBCCDD
Swapped halves: 0xCCDDAABB

Example 2 — Intermediate: Protocol Packet Decoder Using Union

Example 2 — Union for Packet Format Decoding
// 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
 
endmodule

Example 3 — Verification: Tagged Union as Type-Safe Variant

Example 3 — Tagged Union for Polymorphic Event Log
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
 
endmodule

Expected output:

Simulation Output
Event log (3 entries):
[0] WR  data=0xCAFEBABE
[1] RD  data=0x12345678
[2] ERR code=0xFF

Example 4 — Corner Case: RTL Register with Packed Union View

Example 4 — Union in RTL Register Model
// 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
endmodule

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

ScenarioPacked unionTagged union
Write member A, read member BOK — B sees A's bits interpreted differentlyFatal: tag mismatch
Write member A, read member AOKOK
Uninitialized readReturns X (logic type)Fatal: no active tag
Synthesis supportYes — same as packed vectorNo — tag has no hardware equivalent
Port connectionYes (packed)No
Dynamic member sizesNo — all must be same widthYes

Where Unions Appear in Real Verification

Verification Patterns Using union
// ── 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 bytes

Bugs Engineers Hit With Unions

Bug 1 — Packed Union Members with Mismatched Widths

Bug 1 — Mismatched Member 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)

Bug 2 — Understanding Cross-Member Read in Packed Union
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)

Bug 3 — Tagged Union Access Without Setting Tag
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
endcase

Interview 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 caseUnion typeNotes
Multi-granularity register (word/byte)Packed unionInclude raw member; all views same width
Protocol packet format disambiguationPacked union of structs + rawCapture raw, dispatch via field[31:28] etc.
Variant event/transaction log in TBTagged unionType-safe dispatch with case-matches
Different-width views of same dataTagged unionPacked union requires equal widths; tagged allows different
Synthesizable multi-view registerPacked unionTagged 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 raw member 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 matches for safe dispatch.
  • Tagged union is TB-only. Not synthesizable. Use packed union for RTL; tagged union for verification variant types.