In our last blog, we described how we implemented constrained randomization support for various array types in Verilator. Building on that, we’ve now extended this functionality to support structs as well, including more complex scenarios where structs and arrays are nested together.
The Core Challenge: The Missing Logic for Structs in SMT Solvers
SystemVerilog defines two types of structs: packed
and unpacked
. Packed structs behave like packed arrays, as they are laid out as continuous bitvectors and map naturally to the solver logic supported by SMT-LIB2’s bitvector logic (QF_BV
). As a result, constrained randomization for packed structs is straightforward: we treat the packed struct marked with rand
/randc
as a single variable, generate SMT-LIB2 format constraint expressions during the verilate phase, and solve it by calling an SMT-LIB2 solver in the simulate phase, exactly as we do for basic data types.
Unpacked structs, however, present a different challenge. When supporting arrays, we were able to switch Verilator’s SMT-LIB2 logic from QF_BV
to QF_ABV
, leveraging the standard array theory in SMT-LIB2 to enable element-level access via select
and store
. In contrast, SMT-LIB2 provides no dedicated logic or theory specifically for structs. While user-defined datatypes can be declared using declare-datatype
, they are not part of a well-integrated or widely optimized logic like arrays are. As such, there is no straightforward way to “extend” the existing logic to support structs in the same manner. This absence has been a key obstacle to implementing constrained randomization for structs in Verilator.
Despite this difference, the underlying idea is the same: both arrays and structs are aggregates. For arrays, our approach is to decompose them into elements, track their dimensions and indices, and pass each element individually to the solver. Applying the same logic to structs, we decompose them into individual members (fields). The only difference is that instead of using index-based access as in arrays, struct fields are accessed using the dot (.
) operator, which allows us to preserve hierarchical naming.
This unified view of aggregates, which treats both arrays and structs as collections of individually randomizable elements, formed the basis of the current implementation. The following table summarizes how Verilator handles different aggregate types in relation to SMT-LIB2 capabilities:
Category | Packed Struct / Packed Array | Unpacked Array | Unpacked Struct |
Behavior | Continuous bitvector | Indexed collection with dimensions | Loosely grouped named members (fields) |
SMT-LIB2 Support | Supported via bitvector (QF_BV) | Supported via bitvector + array logic (QF_ABV) | No dedicated bitvector-level logic extension |
Randomization | Treated as a single variable | Element-level access via select/store | Needs member-level flattening into separate variables |
Our Solution | Directly pass the whole variable to solver | Record dimensions and indices, pass each element to solver | Decompose via dot (. ) operator-based naming, register each member |
Decomposing Unpacked Structs
To enable constrained randomization for unpacked structs, we apply a decomposition strategy similar to arrays, breaking the aggregate into individually solvable elements. But instead of indices, we represent each member’s hierarchical position using the dot (.
) operator.
Take this example:
struct {
rand int a;
rand int b;
bit c;
} data_s;
This struct is decomposed into:
data_s.a
data_s.b
Only the rand
-qualified members are randomized; member c
is ignored. This reflects a core difference between unpacked structs and other types: according to IEEE standard 1800-2023, while arrays and basic types can be marked rand/randc
as a whole, unpacked structs support two randomization styles:
- The entire struct is marked
rand
— all members with randomizable types are randomized. - Only specific members are marked
rand/randc
— only those members are randomized.
Nested structs follow the same rule. Consider:
struct {
rand data_s ss;
rand int d;
} data_ss;
This is flattened into:
data_ss.ss.a
data_ss.ss.b
data_ss.d
To support this member-level control of constrained randomization in unpacked structs, we introduced two member functions:
markConstrainedRand()
— called to collect all randomized members.isConstrainedRand()
— called to check whether a given member needs to be randomized.
These two functions enable fine-grained control over which struct members participate in randomization.
The entire decomposition happens during the verilate phase. At this point, the unpacked struct is flattened into individual member variables, each registered separately. These member variables are then passed to the VlRandomizer.
During the simulate phase, VlRandomizer calls write_var() and hard() to emit SMT-LIB2 formatted expressions for each variable and its associated constraints. While doing so, it invokes helper functions defined inside the struct to retrieve metadata such as element size and member names. The solver then returns values based on these constraints. Refer to the previous article for a more detailed description of the verilate and simulation phases.
// Marking and Decomposing in Verilate Phase
// Detect and Mark
if field is marked 'rand':
if field is a struct:
// Case 1: full struct marked rand, mark all its members
for each member in field:
markConstrainedRand(member)
else if fromp is a struct:
// Case 2: struct member individually marked rand
markConstrainedRand(field)
// Decompose: resolve dot (.) operator-based naming
if isConstrainedRand(field):
dot_name = fromp_name + '.' + field_name
con_expr = convert_to_smtlib2_format(constraint_expr)
// Emit variable and constraint
write_var(dot_name)
hard(con_expr)and
Handling Nested Structs and Arrays
Once we had basic struct support in place, more complex use cases emerged, particularly involving nested combinations of structs and arrays. We focused on two key patterns:
Struct with Array Field
This case is relatively straightforward, because the struct itself is the main variable and one of its members happens to be an array. We can follow our existing logic for both struct and array:
- Verilate Phase — Decompose the struct as usual using dot (
.
) operator-based naming, and pass the entire array member as a single unit toVlRandomizer
. - Simulate Phase —
VlRandomizer
receives the array variable and continues using the existing array handling logic to process it.
No special handling is needed beyond one thing: during struct decomposition, we must extract some additional metadata about the array, such as its dimensions and widths. Apart from that, everything works as expected. Struct support just needs to extend its compatibility to array types as well. That’s it!
Array of Structs
This scenario required deeper work. The core issue lies in timing:
- Structs are decomposed in the verilate phase
- Arrays are decomposed in the simulate phase.
So when an array’s element type is a struct, that struct hasn’t been decomposed yet at the time we need it.
To resolve this, we extended the array-handling mechanism in the simulate phase:
- VlRandomizer now detects struct elements when randomizing arrays.
- If so, the array must first be decomposed to several elements as usual. (e.g.,
arr[1][3]
) - It then will dynamically decompose the struct element using dot (
.
) operator-based naming likearr[1][3].a
,arr[1][3].b
. - Each decomposed field is passed to
write_var()
for re-registration.
💡In other words:
the array is first split into elements, each being a struct, and then each element is decomposed into members and each member is written back to VlRandomizer.
We had already implemented a utility called record_arr_table
, which recursively handles various array types (unpacked, dynamic, associative) and their dimensions. To support array of structs, we built a parallel mechanism: record_struct_arr
, a new recursive handler. Using helper functions defined inside the struct, we register the unrolled variables via write_var()
.
With all of these in place, both nested models, struct with array field and array of structs, are now fully supported!
All Aggregates, Fully Randomized
In this blog, we covered:
- How constrained randomization is supported for structs
- How structs with array field are handled seamlessly
- How arrays of structs are enabled through new runtime mechanisms
All of these capabilities are now available in the Verilator repository.
Just pull the latest code from the master
branch and try it out locally!
🚀 Key Takeaway for Verilator Users (especially those working with randomization):
Verilator now supports constrained randomization across all aggregate data types, including structs, all kinds of arrays, and even unions.
For more technical deep dives, see our earlier blogs:
- Basic Randomization Support for Aggregate data types
- Constrained Randomization Support for All Types of Arrays
Thanks for reading, and stay tuned for our next post!