VHDL · Chapter 2.11 · Data Types
Record Types
An array repeats one element. A record does the opposite. It groups different fields, an address, a valid bit, and a data word, under a single name, exactly the way you think of a bus as one bundle of related wires. Records are how a real VHDL design keeps interfaces readable, because instead of threading eight loose ports through every level of hierarchy you pass one request record. This lesson shows how to declare and access a record, the difference between named and positional aggregates, and the two things engineers most need to know. A record costs nothing in hardware, since the netlist is identical to flattened signals, and every field of a record port shares one direction, which is why a bidirectional bus is split into a request record and a response record.
Foundation15 min readVHDLRecordsInterfacesBus BundleAggregatesTypes
1. Intuition — a type that bundles unlike fields
An array (lesson 2.10) repeats one element. But most interfaces are not repetition — they are a small set of related but different signals that always travel together. A write request on a simple bus is an address, a data word, and a valid flag: three different types, one logical thing. A record is the type for exactly that — a named bundle of fields, each with its own type:
library ieee;
use ieee.std_logic_1164.all;
type wr_req_t is record
addr : std_logic_vector(31 downto 0); -- a bus
data : std_logic_vector(31 downto 0); -- a bus
valid : std_logic; -- a single bit
end record;
signal req : wr_req_t;
req.addr <= x"4000_0010"; -- access a field with a dot
req.valid <= '1';Where an array index picks the n-th identical slot, a record field name picks a specific, named part. The two are complementary: arrays for repetition, records for composition.
2. Aggregates — building a whole record at once
You can assign every field in one shot with an aggregate. Prefer the named form
(field => value) over the positional form, because named aggregates do not break when the record
later grows or its fields are reordered:
-- named (robust): each field is labelled, order is irrelevant
req <= ( addr => x"4000_0010",
data => x"0000_00FF",
valid => '1' );
-- a clean "idle" value with others
req <= ( valid => '0', others => (others => '0') );
-- positional (fragile): relies on declaration order — avoid in real code
req <= ( x"4000_0010", x"0000_00FF", '1' );Records nest, too: a transaction record can contain a wr_req_t field, and you reach in with chained
dots (txn.req.addr). That is how a complex interface stays one typed object instead of a loose pile
of signals.
3. Where records belong — bundling an interface
The dominant use of a record is grouping a bus into one port. Compare an entity that lists every wire against one that passes a single record — the second is the same hardware, far more readable, and adds a field in one place instead of editing every port list in the hierarchy:
-- a request bundle and a response bundle (see Section 5 for why two)
type bus_req_t is record
addr : std_logic_vector(31 downto 0);
wdata : std_logic_vector(31 downto 0);
we : std_logic;
valid : std_logic;
end record;
type bus_resp_t is record
rdata : std_logic_vector(31 downto 0);
ready : std_logic;
end record;
entity peripheral is
port ( clk : in std_logic;
req : in bus_req_t; -- one downstream bundle
resp : out bus_resp_t ); -- one upstream bundle
end entity;One req and one resp carry the whole interface. Wiring two blocks together is u1.resp -> u2
and u2.req, not a dozen hand-matched signal connections — fewer places to mistype a name.
4. Hardware — a record costs nothing
The single most important fact about records: they add no hardware. A record is a source-level
grouping. Synthesis flattens it, and the resulting netlist is identical to declaring every field as a
separate signal. req.addr is the same 32 wires whether it lives in a record or stands alone.
Because the grouping is compile-time only, a record has no timing behaviour of its own — there is
nothing about a record that a waveform would show. Each field behaves exactly as its own type does
(a std_logic field is a wire, a std_logic_vector field is a bus); the record itself is just the
name you filed them under. That is why this lesson uses a structural diagram and no waveform — the
concept here is composition, not timing.
5. The real caveat — one record, one direction
The trap that bites everyone: when a record is used as a port, every field inherits the port's
mode. A record port declared in makes all its fields inputs; declared out, all outputs. So a
bidirectional bus — where the request travels downstream and the response upstream — cannot be one
record port:
-- WRONG: one record cannot carry both directions through a port
-- type bus_t is record
-- addr : std_logic_vector(31 downto 0); -- wants to be IN at the slave
-- rdata : std_logic_vector(31 downto 0); -- wants to be OUT at the slave
-- end record;
-- port ( bus : in bus_t ); -- forces rdata to be 'in' too — broken
-- RIGHT: group BY direction — a request bundle and a response bundle
port ( req : in bus_req_t; -- everything flowing into the slave
resp : out bus_resp_t ); -- everything flowing out of the slaveThis is the design rule for record interfaces: group fields by who drives them. A clean bus is a
downstream record (master → slave) and an upstream record (slave → master). (VHDL-2019 adds interface
views / mode view to mix directions in one record, but the portable, universally-supported pattern
is two records — use it unless your whole toolchain is on 2019.)
6. Debugging example — the positional-aggregate trap
The classic record bug is a positional aggregate that silently rots. You initialise a record by position, then a colleague adds or reorders a field — and every positional aggregate now assigns the wrong values, with no error because the types still happen to match:
type ctrl_t is record
enable : std_logic;
mode : std_logic_vector(1 downto 0);
end record;
signal c : ctrl_t;
c <= ('1', "10"); -- positional: enable='1', mode="10" (fine, today)
-- Later, someone inserts a field at the front:
-- type ctrl_t is record
-- irq_en : std_logic; <-- new field, same type as 'enable'
-- enable : std_logic;
-- mode : std_logic_vector(1 downto 0);
-- end record;
-- The aggregate ('1', "10") now needs THREE elements and fails to compile —
-- or, if the new field had matched, it would silently shift every value by one.The fix is to always use named aggregates so each value is bound to a field by name, immune to order changes:
c <= ( enable => '1', mode => "10" ); -- order-independent, self-checking
-- after the refactor, just add: irq_en => '0' — nothing else movesThe second classic is an uninitialised field: a record signal with no reset leaves every field
at its type's default (a std_logic field at 'U'), so a forgotten req.valid shows up as 'U'
propagating downstream. Reset the whole record to a known idle value with one aggregate.
7. Common mistakes & what to watch for
- Mixing directions in one record port. Every field inherits the port's mode. Split into a request record and a response record, grouped by who drives them.
- Positional aggregates. They break (or silently misassign) when fields are added or reordered.
Use named aggregates (
field => value) everywhere. - Assuming a record adds hardware. It does not — it flattens to its fields. There is no area, delay, or wire cost to grouping.
- Forgetting to reset a field. An unassigned field sits at its type default (
'U'forstd_logic). Reset the whole record to a known idle aggregate. - Comparing whole records carelessly.
req1 = req2compares all fields, including ones you did not mean to (a stalevalid). Compare the specific fields you care about. - Over-bundling. A record is for signals that genuinely travel together. Don't bundle unrelated signals just to shorten a port list — it couples things that should stay separate.
8. Engineering insight
A record separates the logical interface from the physical wires. Logically you have one
req — an address, data, and a valid bit that mean nothing apart; physically you have 65 wires that
synthesis lays down exactly as if you had typed them out. That separation is pure leverage: an
interface becomes self-documenting (the field names are the protocol), refactor-safe (add a field in
one type, not in every port list down the hierarchy), and connectable in one line — all at zero gate
cost. The one discipline the abstraction demands is honesty about direction: because a port has a
single mode, you model a real bus as the request bundle and the response bundle it physically is.
Pair this with arrays — array for repetition, record for composition — and you can describe any
structured interface in the type system itself.
9. Summary & next step
A record groups unlike fields under one name — the type of a bus bundle. Access fields with a dot
(req.addr), build whole records with named aggregates (field => value) that survive
refactors, and nest records for layered interfaces. A record costs nothing in hardware: it flattens
to its fields, the netlist identical to separate signals, which is why it carries no timing of its own
and needs no waveform. The one rule that matters is direction — every field of a record port shares
the port's mode, so a bidirectional bus is a request record plus a response record, grouped by who
drives each field.
You can now describe values that are names (enums), values that repeat (arrays), and values that compose (records). The next lesson sharpens all of them with subtypes and range constraints — how to take a base type and pin down exactly which values are legal, so the compiler enforces your intent and synthesis sizes the hardware to fit.