C++ Part II · Module 27 · C++ for Semiconductor Verification
Modeling a Transaction in C++
A transaction is the smallest thing a verification environment passes around, and it is where the most leverage per line of code exists. The reason is that a transaction type is a gate: every expected result, every stimulus item, every scoreboard entry passes through it, so a constraint enforced there is enforced everywhere, and a constraint omitted there has to be re-checked by every consumer or silently not checked at all. The chapter is about moving as much of the protocol's legality into the type as the language will carry. Two measurements set the scale. A plain struct of three unsigned fields accepted a 4,096-beat burst at address 4 — a transaction that is illegal on every bus protocol in use — with zero diagnostics from a build using -Weverything; the same mistake, with one distinct type per field, became a compile error. And the invariants that cannot be expressed in the type system are checked once, in the constructor, where three of five attempted illegal transactions were refused at the point of creation rather than somewhere downstream.
Advanced20 min readC++Part IIVerificationModeling
Part II · Module 27 · Chapter 27.2 · Modeling a Transaction in C++
1. What a Transaction Type Is For
A transaction is one unit of protocol-level activity: a read, a write, a burst, a frame, a descriptor. In a verification environment it is the currency — the stimulus generator produces them, the driver consumes them, the reference model predicts from them, the scoreboard compares them.
Which means the type has a job beyond holding fields. It is the one place every one of those consumers passes through, so it is the cheapest possible place to put a rule, and the most expensive place to omit one.
Start with what almost everyone writes first:
struct Txn {
std::uint32_t addr;
std::uint32_t len;
std::uint32_t size;
};Three unsigned integers. It compiles, it is trivially copyable, it is easy to print, and it is wrong in two distinct ways that this chapter measures: the fields are interchangeable, so they can be supplied in the wrong order, and the protocol's rules are nowhere, so an illegal transaction is a valid object.
2. The Two Failures, Precisely
The fields are all the same type. addr, len and size are all std::uint32_t, so the compiler has no way to know which is which. Txn{4, 0x1000, 2} and Txn{0x1000, 4, 2} are both well-formed, and exactly one of them is what you meant.
The protocol's legality is not represented. Real buses constrain these fields against each other: an address must be aligned to the transfer size, a burst length has a maximum, a zero-length burst is meaningless, a wrapping burst must have a power-of-two length. None of that is in the struct, so either every consumer re-checks it — and they will not all remember — or nobody does.
The second failure is the one that produces the unpleasant debugging session, because the symptom appears far from the cause. The reference model predicts something strange, the scoreboard reports a mismatch, and the actual defect is that a transaction with len = 4096 was constructed four files away and nothing objected.
3. The Measurement
Here is the swap, compiled with every warning the compiler offers:
== 27.2 a plain struct accepts the swap; strong types do not ==
plain struct : exit=0 errors=0
strong types : exit=1 errors=1
txn_strong_swap.cpp:2:21: error: no matching constructor for initialization of 'Txn'| version | the same mistake | result |
|---|---|---|
plain struct of std::uint32_t, -Wall -Wextra -Wconversion -Weverything | Txn{4, 0x1000, 2} | exit 0, 0 diagnostics |
| one distinct type per field | positions swapped | exit 1, 1 error |
The plain struct built a 4,096-beat burst at address 4 and the compiler had nothing to say about it — not under -Wall, not under -Wextra, not under -Wconversion, and not under -Weverything, which Chapter 24.7 §2 inventoried as the loudest setting available and still not a substitute for a type. There is no warning to enable, because nothing is suspicious: three unsigned values were assigned to three unsigned fields.
The fix is to stop having three fields of the same type:
enum class Addr : std::uint32_t {};
enum class Len : std::uint32_t {};
enum class Size : std::uint32_t {};
class Txn {
public:
Txn(Addr a, Len l, Size s);
private:
Addr addr_; Len len_; Size size_;
};An enum class with no enumerators is the cheapest strong integer type C++ has: it is one word, it has no overhead at all, it converts to its underlying type only with an explicit cast, and — the point here — Len is not Addr, so an argument in the wrong position does not compile.
Two notes on the technique. enum class X : std::uint32_t {} deliberately declares no enumerators, so the only way to make one is Addr{value}, which reads as a conversion and is visible at the call site. And static_cast<std::uint32_t>(a) is the way back out, which is slightly verbose and is the reason people resist this — the verbosity is at the boundary, exactly where you want to be reminded.
4. The Invariants the Type System Cannot Carry
Strong types fix roles. They do not know that an address must be aligned to its transfer size, because that is a relation between two fields with a value-dependent rule. Those checks have to run, and the question is only where.
The answer is the constructor, for the reason Chapter 22.4 established for resources and Chapter 26.4 §6's callout re-derived for mutexes: a rule enforced where the object comes into existence is enforced for every object, and a rule enforced by convention is enforced for the objects whose authors remembered.
Txn(Addr a, Len l, Size s) : addr_{a}, len_{l}, size_{s}
{
const std::uint32_t bytes = 1u << (std::uint32_t)s;
if ((std::uint32_t)a % bytes) throw std::invalid_argument("address not aligned to size");
if ((std::uint32_t)l == 0) throw std::invalid_argument("zero-length burst");
if ((std::uint32_t)l > 256) throw std::invalid_argument("burst longer than 256");
}Five attempted constructions, measured:
addr 0x1000, len 4, size 2 (4 bytes) accepted (addr=0x1000 len=4)
addr 4 aligned to 4 bytes accepted (addr=0x4 len=8)
addr 6 NOT aligned to 4 bytes REJECTED: address not aligned to size
zero-length burst REJECTED: zero-length burst
4096-beat burst REJECTED: burst longer than 256Two accepted, three refused with the reason named. And look at the last row: the 4,096-beat burst is the same transaction the plain struct in §3 constructed silently. One version of this type makes it a compile-and-run; the other makes it an exception at the line that created it.
The consequence worth stating is what the consumers can now assume. A reference model receiving a Txn does not need to check alignment; it cannot be handed a misaligned one. That is the real payoff — not the error message, but the checks that no longer have to exist anywhere else.
5. Where the Check Should Not Be
Three tempting alternatives, each worse.
A validate() member that callers must remember to call. This is the manual close() of Chapter 22.4 §2 with a different name: correct when called, and nothing makes it called.
A check in the driver. By then the illegal transaction already exists, has been copied into a queue, and may already have been given to the reference model. The driver is also the wrong place architecturally — it is the component least likely to know the protocol's legality rules in full.
An assert. Disabled by NDEBUG in exactly the build where a long regression runs — Chapter 24.7 §6's two configurations are where that is spelled out; for a transaction the specific loss is that your overnight run is the one with the checks compiled out.
What about throwing from a constructor at all? It is correct — the object never comes into existence, so there is nothing half-built to clean up, and Chapter 16.5 §7 covers what happens when construction fails partway, and why the already-initialised members are destroyed. If the context forbids exceptions, the alternative is a static factory returning std::optional<Txn> or std::expected<Txn, Error>, with the constructor private so the factory is the only way in:
static std::optional<Txn> tryMake(Addr a, Len l, Size s); // private ctorThat keeps the invariant structural — there is still no path to an invalid Txn — while making the failure a return value. Chapter 22.7 §2's comparison of the two styles applies unchanged.
6. What Else Belongs in the Type
Beyond the fields and their invariant, four things earn their place.
A direction or kind, as an enum rather than a bool. bool isWrite reads badly at the call site (Txn{addr, len, size, true}) and cannot grow a third case. enum class Dir { Read, Write } reads correctly and extends.
Comparison, and it should be == default where the semantics allow. The scoreboard in Chapter 27.6 needs to compare transactions; writing that comparison by hand is a place to forget a field. C++20's defaulted operator== compares every member, and gains a new member automatically when someone adds one — which is the behaviour you want for a comparison whose purpose is to notice differences.
A printer, and exactly one. Every mismatch report, every log line and every debug dump should use the same formatting, because a scoreboard that prints transactions differently from the driver's log makes the two impossible to correlate. One format() or operator<<, used everywhere.
The payload, by value, in a container that owns it. A std::vector<std::uint8_t> for the data beats a pointer and a length: it cannot dangle, it carries its own size, and it survives being put in a queue. Chapter 25.4's dangling-reference failures are what the pointer version eventually produces, and in a verification environment the dangle usually happens because the transaction outlived the stimulus that created it.
7. The Cost of Doing This
The objection to all of the above is overhead, so it is worth being concrete about where there is and is not any.
The strong types cost nothing. An enum class with a specified underlying type is that type; sizeof(Addr) == sizeof(std::uint32_t), and the generated code is identical. There is no wrapper object, no indirection and no constructor to run.
The constructor's checks cost a few comparisons per transaction, which against the cost of a simulation step is not measurable. Chapter 27.10 §3's figure puts the scale beyond doubt: one interpreted Verilog step costs thousands of nanoseconds, and these checks are a handful of nanoseconds.
The real cost is verbosity at the boundaries, and it is a genuine cost: Txn{Addr{a}, Len{n}, Size{s}} is longer than Txn{a, n, s}. The defence is that those are exactly the sites where §3's mistake is made, so being slowed down there is the mechanism working rather than a side effect of it.
And the thing that is genuinely lost is aggregate initialisation and trivial copyability in the strictest sense. A class with a user-provided constructor is no longer an aggregate, so designated initialisers do not apply. If a transaction has fifteen optional fields, a builder or a defaulted-struct-plus-validating-factory is more pleasant than a fifteen-argument constructor — and Chapter 27.9 §6 hits exactly that case for protocol headers.
8. The Table
| the question | the answer | measured |
|---|---|---|
| what is a transaction type for | the one gate every consumer passes through | §1 |
| the two failures of a plain struct | interchangeable fields; no invariant | §2 |
| does the compiler catch a field swap | no — 0 diagnostics under -Weverything | §3 |
| what did it construct silently | a 4096-beat burst at address 4 | §3 |
| do strong types catch it | yes — exit 1, no matching constructor | §3 |
| what is the cheapest strong integer | enum class X : std::uint32_t {} | §3 |
| do they catch a wrong value | no — only a wrong role | §3 callout |
| where do the relational invariants go | the constructor | §4 |
| how many illegal constructions were refused | 3 of 5, each with the reason named | §4 |
why not a validate() member | nothing makes it called | §5 |
why not assert | compiled out of the long regression | §5 |
| what if exceptions are banned | private constructor + optional/expected factory | §5 |
| how should transactions compare | operator== = default, so new fields are included | §6 |
| what does the strong typing cost | nothing at run time; verbosity at the boundary | §7 |
9. What to Actually Do
- Give every field its own type when two fields share an underlying type and a swap would compile.
enum class X : std::uint32_t {}costs nothing. - Put every relational invariant in the constructor — alignment against size, length bounds, burst-kind rules.
- Throw, or return
std::optional/std::expectedfrom a factory with the constructor private. Either way there must be no path to an invalid object. - Never rely on a
validate()the caller must remember, and never put a protocol rule only in anassert. - Use an enum for direction and kind, never a
bool. - Default
operator==so a field added later is compared automatically. - Write exactly one printer and use it in the driver, the model and the scoreboard, so logs correlate.
- Own the payload in a
std::vector, not a pointer and a length. - Before reporting that a check did not fire, make it fire — the §3 callout is what that rule is for.
- Accept the boundary verbosity. It lands precisely where the mistakes are made.
10. Mistakes Worth Making Once
Three uint32_t fields and a constructor call in the wrong order. 0 diagnostics, under every warning flag.
Concluding from a clean compile that strong types do not help, when the test case had no swap left in it.
A validate() that three of five call sites forgot.
A protocol rule in an assert, compiled out of the overnight run.
Checking legality in the driver, after the transaction has already reached the model.
bool isWrite in a four-argument constructor call.
A hand-written operator== that someone's new field is not in.
Two printers, so the driver's log and the scoreboard's report cannot be correlated.
A payload as const std::uint8_t* and a length, outliving the generator that produced it.
A fifteen-argument constructor, because the invariant had to go somewhere.
11. Interview Relevance
The frozen curriculum marks this Critical, and the question is usually concrete: how would you represent an AXI transaction in C++? Listing the fields is the baseline. The answer that lands has two moves beyond that.
Make illegal states unrepresentable where the type system can. Distinct types per field so a length cannot be passed as an address, with the measurement to back it: a plain struct of three uint32_t accepted a 4,096-beat burst at address 4 with zero diagnostics even under -Weverything, and the same mistake with one type per field was a compile error. Adding that an enum class with a specified underlying type costs nothing at run time pre-empts the overhead objection.
Put every remaining invariant in the constructor, so no consumer has to re-check and no invalid object exists. Being specific about alignment-against-size, a length bound and the zero-length case shows protocol familiarity rather than language familiarity, and mentioning the exception-free alternative — a private constructor with an optional or expected factory — covers the follow-up about environments that ban exceptions.
If there is room for a third point, the one that distinguishes people who have maintained a scoreboard is defaulting operator== so that a field added next quarter is compared automatically, and keeping a single printer so the driver's log and the mismatch report can be lined up.
12. What You Should Be Able to Say Now
- That a transaction type is the one gate every consumer passes through, which makes it the cheapest place for a rule and the most expensive place to omit one.
- That a plain struct fails twice: interchangeable fields and no invariant.
- That a field swap in a plain struct produced 0 diagnostics under
-Weverythingand constructed a 4,096-beat burst at address 4. - That one distinct type per field turned the same mistake into exit 1,
no matching constructor. - That
enum class X : std::uint32_t {}is the cheapest strong integer — same size, no overhead, explicit conversions only. - That strong types catch a wrong role, not a wrong value — a narrower and more defensible claim.
- That relational invariants belong in the constructor, where 3 of 5 illegal transactions were refused with the reason named, including the one the plain struct accepted.
- That
validate(), a driver-side check andassertare all the wrong place, the last becauseNDEBUGremoves it from the long run. - That
std::optional/std::expectedfrom a factory with a private constructor is the exception-free equivalent. - That
operator== = default, a single printer, an enum for direction and an owning payload are the other four things the type should carry. - That a negative result is only evidence if the experiment could have produced a positive one — and that a finding which flatters your expectations gets the least scrutiny.
13. Exercises
Exercise 1 — the swap. Build a three-uint32_t transaction struct and construct it with the arguments in the wrong order under -Weverything. Count the diagnostics.
Exercise 2 — make the check fire first. Convert it to one enum class per field, and before anything else compile a deliberately swapped call and confirm the error. Only then test the correct call.
Exercise 3 — the invariant. Add alignment, zero-length and maximum-length checks to the constructor and drive it with five cases, two legal and three not. Confirm each rejection names its reason.
Exercise 4 — the exception-free version. Make the constructor private and add a tryMake returning std::optional<Txn>. Confirm there is no path to an invalid object.
Exercise 5 — the comparison that grows. Default operator==, write a test that compares two transactions, then add a field and confirm the test now compares it without being edited.
Exercise 6 — the payload. Build a transaction holding const std::uint8_t* and a length, let the generator that produced the buffer go out of scope, and run it under ASan.
14. Summary
- A transaction type is the gate every consumer passes through, so a rule placed there is enforced everywhere and a rule omitted there is enforced nowhere.
- A plain struct of same-typed fields fails two ways: the fields are interchangeable, and the protocol's legality is unrepresented.
- Measured: the field swap produced 0 diagnostics under
-Wall -Wextra -Wconversion -Weverything, silently constructing a 4,096-beat burst at address 4. With one distinct type per field the same mistake was exit 1, one error,no matching constructor. enum class X : std::uint32_t {}is the cheapest strong type — identical size and code, explicit conversions only — so the overhead objection does not survive.- Strong types catch a wrong role, not a wrong value. That narrower claim is the one the measurement supports.
- Every relational invariant goes in the constructor, where 3 of 5 attempted illegal transactions were refused by name, including the 4,096-beat burst the struct had accepted. The payoff is the checks the consumers no longer need.
- Not in a
validate(), not in the driver, not in anassert— the last because the overnight regression is the build withNDEBUGset. - Also in the type: an enum for direction, a defaulted
operator==, exactly one printer, and an owning payload. - And the methodological lesson: make a check fire before reporting that it did not. My first strong-typed experiment had no swap left in it, compiled cleanly, and would have been published as evidence that strong types catch nothing.
Chapter 27.3 takes the next step out: what a model built from these transactions has to guarantee before anyone is entitled to call it an oracle.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
