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.

AspectPacked structUnpacked struct
StorageContiguous bits, one vectorMembers stored separately
Member typesOnly integral (logic, bit, vectors)Any type, including real, string, arrays
Whole-struct opsAssign, slice, and cast as a vectorTreated as an aggregate only
SynthesisSynthesizable; maps to wiresMainly for testbench and modelling
Typical useRTL buses, instruction words, registersScoreboards, 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.

Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.
▶ Try it in the ChipXpert Lab

Compile and simulate this in the browser — no install. Edit the code and re-run instantly.

Open in Verification Lab

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
Next batch starts 18th November 2026
Start your VLSI career with ChipXpert

Live online and classroom batches in Hyderabad & Bengaluru. Fill in your details and a counsellor will call you back.

  • Real EDA tools in your browser: industry-standard EDAReal EDA tools in your browser
  • Recorded sessions on the elearn portalRecorded sessions
  • Placement assistance: resume, mock interviewsPlacement assistance
  • Merit scholarship up to 60% · EMI optionsScholarship up to 60%
Hi! Ask me about courses, fees, batches or discounts. ×
BENGALURU
HYDERABAD