SystemVerilog Struct and Union: A Practical Guide
A SystemVerilog struct groups several related signals under one name, so you can move a whole bundle of fields around as a single object instead of wiring a dozen loose signals by hand. If you have ever passed an address, a data word and a valid bit through three separate ports, a struct collapses them into one named package. Structs make intent obvious, cut down on copy-paste wiring mistakes, and let an interface grow by editing one definition. This tutorial builds the idea up with runnable examples: declaring a struct with typedef, the difference between packed and unpacked, how a union reinterprets the same bits, and how to send a struct through a module port.
Declaring a struct with typedef
A struct declaration lists its members between struct { ... }. On its own that describes just one variable, so the usual pattern is to wrap it in a typedef to create a reusable type. Below, header_t becomes a type you can declare as many times as you like, pass to functions, or use as a port. You read or write each field with dot notation, such as hdr.src.
module tb;
typedef struct packed {
logic [7:0] src;
logic [7:0] dst;
logic [7:0] len;
} header_t;
header_t hdr;
initial begin
hdr.src = 8'h0a;
hdr.dst = 8'h0b;
hdr.len = 8'd64;
$display("src=%h dst=%h len=%0d", hdr.src, hdr.dst, hdr.len);
$display("whole header=%h (%0d bits)", hdr, $bits(hdr));
$finish;
end
endmodule
src=0a dst=0b len=64 whole header=0a0b40 (24 bits) /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpoezyl063/header.sv:16: $finish called at 0 (1s)
Because this struct is declared packed, the three 8-bit members also behave as one 24-bit vector. Printing hdr directly shows 0a0b40 — the members laid out in order, with the first-listed field in the most-significant position — and $bits(hdr) confirms the total width. That dual nature, named fields on one side and a flat vector on the other, is the whole reason packed structs are so useful in hardware.
You can assign one field at a time, as shown above, or set the entire struct at once. For a packed struct an ordinary concatenation works, for example hdr = {8'h0a, 8'h0b, 8'd64}, and a positional assignment pattern hdr = '{8'h0a, 8'h0b, 8'd64} does the same. Reading a field never disturbs the others, which keeps field-level updates cheap and explicit.
Packed vs unpacked struct
The packed keyword decides how the members are stored, and that single choice drives where you are allowed to use the type.
| Aspect | Packed struct | Unpacked struct |
|---|---|---|
| Storage | Contiguous bits, one vector | Members stored separately |
| Member types | Only integral (logic, bit, vectors) | Any type, including real, string, arrays |
| Whole-struct ops | Assign, slice, and cast as a vector | Treated as an aggregate only |
| Synthesis | Synthesizable; maps to wires | Mainly for testbench and modelling |
| Typical use | RTL buses, instruction words, registers | Scoreboards, configuration records |
An unpacked struct drops the keyword and can hold any member type. It shines in testbenches, where the '{member: value} named assignment pattern fills every field in one readable statement:
module tb;
// unpacked struct: members stored separately, not as one vector
typedef struct {
int id;
string name;
real score;
} student_t;
student_t s;
initial begin
// named assignment pattern fills every member at once
s = '{id: 7, name: "Asha", score: 91.5};
$display("id=%0d name=%s score=%0.1f", s.id, s.name, s.score);
end
endmodule
Reach for a packed struct whenever the data represents real hardware bits — an instruction, a packet header, a control register. Reach for an unpacked struct when you are modelling abstract records in verification code and want the freedom of mixed member types like real and string.
Nesting and arrays of structs
Structs compose naturally. A member of a struct can itself be another struct, letting you describe a layered format — a packet with a nested header, say — while still reaching any leaf with chained dot notation such as pkt.hdr.src. You can also build an array of structs, for example a small register file declared as reg_t bank [0:15], and index it like any array: bank[3].data. When every member of a packed struct is itself integral, the nested result stays packed and remains a single synthesizable vector, so these compositions cost nothing in hardware terms beyond the bits they name.
Unions: one storage, many views
A union stores all of its members in the same bits, so writing one member changes how the others read back. A packed union requires every member to be the same width, which makes it ideal for viewing a register as either one wide word or as individual fields. Here a 16-bit register is viewed both as a single word and as a high/low byte pair.
module tb;
typedef struct packed {
logic [7:0] high;
logic [7:0] low;
} bytes_t;
typedef union packed {
logic [15:0] word;
bytes_t b;
} reg16_t;
reg16_t r;
initial begin
r.word = 16'hDEAD;
$display("word=%h high=%h low=%h", r.word, r.b.high, r.b.low);
r.b.low = 8'hBE;
$display("after low=BE, word=%h", r.word);
$finish;
end
endmodule
word=dead high=de low=ad after low=BE, word=debe /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpicfdbfw_/reg_union.sv:20: $finish called at 0 (1s)
Writing r.word and then reading r.b.high and r.b.low returns the two halves of the same value. Writing a single byte through r.b.low updates the shared storage, so r.word immediately reflects the change from dead to debe. Unions let you describe that aliasing once, in the type, instead of scattering hand-coded bit ranges across the design — a common source of off-by-one slicing bugs.
Using a struct as a module port
Passing a struct through a port is where the tidiness really shows. Define the type once — a package is the usual home so several modules can share the exact same definition — then declare a single port of that type. The decoder below accepts a whole req_t request instead of separate valid, addr and data ports.
package bus_pkg;
typedef struct packed {
logic valid;
logic [7:0] addr;
logic [15:0] data;
} req_t;
endpackage
module decoder (
input bus_pkg::req_t req,
output logic hit
);
assign hit = req.valid && (req.addr == 8'h40);
endmodule
module tb;
import bus_pkg::*;
req_t req;
logic hit;
decoder dut (.req(req), .hit(hit));
initial begin
req.valid = 1'b1;
req.addr = 8'h40;
req.data = 16'h1234;
#1;
$display("addr=%h hit=%b", req.addr, hit);
req.addr = 8'h7f;
#1;
$display("addr=%h hit=%b", req.addr, hit);
$finish;
end
endmodule
addr=40 hit=1 addr=7f hit=0 /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmphxtlpvpk/struct_port.sv:33: $finish called at 2 (1s)
The testbench drives the fields of req, and the decoder reads req.valid and req.addr straight from its port. When the address is 40 the hit line asserts; changing it to 7f drops it. Adding a new field to the request later means editing one struct definition in the package, not every port list along the path — a small change that pays off repeatedly across a large design, and keeps producer and consumer in lockstep.
Key takeaways
- typedef struct creates a reusable type you declare and pass like any other.
- Packed structs are contiguous bits — synthesizable, sliceable, ideal for RTL.
- Unpacked structs allow any member type and suit testbench records.
- A union aliases one storage across several member views.
- Access members with dot notation; a packed struct doubles as a flat vector.
- Structs as ports shrink wide interfaces down to one clean, maintainable connection.
Quick check
When must you use a packed struct instead of an unpacked one?
When the data must behave as a contiguous bit vector — for synthesizable RTL, bit-slicing, or casting the whole struct to a vector. Packed members must all be integral types.
How do you read a single field of a struct variable named pkt?
With dot notation, for example pkt.addr, and for a nested struct you chain it, like pkt.hdr.src. A packed struct can also be read as one vector.
What happens when you write one member of a union?
All members share the same storage, so writing one member changes the value every other member reads back. In a packed union the members are all the same width.
Why declare a struct type inside a package when using it as a port?
So multiple modules import one definition and agree on the exact type. Updating the interface then means editing a single typedef rather than every port list.
Share your question in comments or talk to our mentor team for batch guidance.
Compile and simulate this in the browser — no install. Edit the code and re-run instantly.
Learn Verilog with mentors, labs and placement support
This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.
Explore VLSI Courses