PCIe · Module 30
RTL Checklist — When Two Legal Events Collide
Endpoint RTL rarely fails on steady-state equations. It fails when an allocation and a retirement land in the same cycle, when a tag is freed while its Completion is still coming, and when clean lint certifies a counter that loses every simultaneous update.
30.1 asked whether the architecture is decisive. This gate asks whether the RTL is faithful to it — under every simultaneous event, reset, error and lifetime corner the architecture permitted.
1. What This Gate Owns
Most hard RTL bugs are not wrong steady-state equations; they are wrong ownership transitions when two legal events happen together.
Three readings, and the third decides how to run the review.
The steady-state path is the part everyone reviews and the part that works. A counter that increments correctly, an FSM that advances correctly, a queue that fills and drains correctly — these are visible in a waveform and get checked. The failure is in the cycle where both things happen.
Which means the review question is combinatorial, not sequential. For each piece of state: enumerate the simultaneous events, and check the design handles the cross product rather than each event alone. §5 is that idea applied to a counter; §6 to a tag table; §9 to configuration.
And this is emphatically not a Verilog style guide. Naming, formatting and lint are §14's subject, and §14's point is that they prove none of this. Every item here ties to a PCIe subsystem mechanism.
| This gate owns | Owned elsewhere |
|---|---|
| faithful implementation of 30.1's contracts | the contracts themselves — 30.1 |
| event ownership, lifetimes, simultaneity, widths, reset scope | whether the environment can disprove the design — 30.3 |
| SVA hooks the design must expose | SoC-facing contracts — 30.4 |
| the mechanisms of 23.1–23.6 applied as gates | re-teaching those mechanisms |
2. The Acceptance Event
The single highest-yield question in this checklist, and it is one sentence: what exact event means this happened?
| Thing | The event | The trap |
|---|---|---|
| request accepted | valid && ready | valid alone |
| queue entry allocated | the same accepted event | a separate decode of valid |
| tag allocated | the accepted event | allocation on decision to send |
| Completion consumed | consumed by the sink, not arrival | arrival at the input |
| credit consumed | at the defined consumption point (16.5) | at request generation |
| descriptor retired | after the write-back is visible | after it is issued |
| DMA "done" | the final byte retired | the last request issued (29.1 §7) |
The rule: one named wire per event, used everywhere. Every counter, allocation, trace write and assertion that concerns "a request was accepted" must reference the same signal. Two independent decodes of the same idea will diverge — under back-pressure, under reset, or after someone edits one of them.
3. Wrong RTL — valid Means Accepted
// WRONG. ILLUSTRATIVE. The requester allocates on valid. It is correct whenever
// the consumer is never busy, which is every early simulation.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
outstanding_q <= '0;
end else begin
// BUG: req_valid may be held for many cycles while req_ready is low.
// Every one of those cycles allocates again.
if (req_valid) begin
outstanding_q <= outstanding_q + 1'b1;
tag_live_q[next_tag] <= 1'b1;
trace_q[wr_ptr_q] <= req_desc;
wr_ptr_q <= wr_ptr_q + 1'b1;
end
end
endFailure — the timeline. One request offered; the consumer is busy for four cycles.
| Cycle | req_valid | req_ready | Real requests | outstanding_q | Trace entries |
|---|---|---|---|---|---|
| 0 | 0 | 1 | 0 | 0 | 0 |
| 1 | 1 | 0 | 1 offered | 1 | 1 |
| 2 | 1 | 0 | still the same one | 2 | 2 |
| 3 | 1 | 0 | still the same one | 3 | 3 |
| 4 | 1 | 1 | 1 accepted | 4 | 4 |
| 5 | 0 | 1 | — | 4 | 4 — for one request |
| later | — | — | 1 Completion arrives | decrements to 3 | — |
| end of test | — | — | 0 outstanding in reality | 3 — never drains | — |
First divergence: cycle 2 — the second allocation for a request that has not yet been accepted.
Root cause. valid is an offer; valid && ready is a transaction. The design treated a sustained offer as repeated events, and back-pressure is exactly when offers are sustained.
Three consequences, and the third is the dangerous one. The counter inflates and never drains, so tag exhaustion appears at a fraction of the real load. The trace fills with duplicates, so debug evidence is corrupted before the investigation starts. And the design works perfectly in any test where ready is always high — which is most early tests, so this survives to the lab.
Corrected — one wire, used everywhere.
// CORRECT. The accepted event, named once. Every consumer of "a request
// happened" references THIS wire — never re-derives it.
wire req_fire = req_valid && req_ready;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
outstanding_q <= '0; wr_ptr_q <= '0;
end else if (req_fire) begin
outstanding_q <= outstanding_q + 1'b1;
tag_live_q[alloc_tag] <= 1'b1;
trace_q[wr_ptr_q] <= req_desc;
wr_ptr_q <= wr_ptr_q + 1'b1;
end
end
// MANDATORY. English: payload and control must not change while an offer is
// pending. Catches the other half of the same misunderstanding — a producer
// that treats a stalled offer as retractable.
a_stable_while_stalled: assert property (
@(posedge clk) disable iff (!rst_n)
(req_valid && !req_ready) |=> (req_valid && $stable(req_desc))
);Six lenses. Architecture: one canonical event so no two consumers disagree. State: the counter, tag bitmap and trace pointer, all advanced by the same wire. Event: valid && ready, once. Contract: the producer must hold valid and keep the payload stable until accepted — which is what a_stable_while_stalled enforces, and which is the assumption the consumer is entitled to make. Failure: re-deriving the event in a second always block, which drifts the moment someone edits one. DV/debug: the mutation test in 30.3 §8 — change req_fire back to req_valid and confirm the environment fails.
4. Two Non-Blocking Assignments to One Register
The classic. Two legal events, two separate if statements, and the second silently wins.
// WRONG. ILLUSTRATIVE. Both branches are correct in isolation and the code
// reads as obviously right.
always_ff @(posedge clk) begin
if (alloc_fire) count_q <= count_q + 1'b1;
if (retire_fire) count_q <= count_q - 1'b1; // same cycle: THIS one wins
endWhen both fire in the same cycle, the count should be unchanged. It decrements. The error is −1 per coincidence, and coincidences get more frequent as the design gets busier — so the drift is proportional to load, and it presents as "we lose outstanding slots under stress."
// CORRECT. ILLUSTRATIVE. One next-state expression, width-safe, bounded, and
// with the "both" case handled by construction rather than by ordering.
localparam int N_TAG = 64;
localparam int CW = $clog2(N_TAG + 1); // +1 — the count reaches N_TAG
logic [CW-1:0] count_q;
wire [CW:0] count_nxt = // one extra bit for the check
{1'b0, count_q} + {{CW{1'b0}}, alloc_fire} - {{CW{1'b0}}, retire_fire};
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) count_q <= '0;
else count_q <= count_nxt[CW-1:0];
end
// MANDATORY. English: the count never wraps in either direction. Underflow is
// the more dangerous case — an unsigned count_q of 0 minus 1 becomes the
// maximum, and every "is there room?" test then answers yes forever.
a_count_no_wrap: assert property (
@(posedge clk) disable iff (!rst_n)
(count_q <= CW'(N_TAG)) and
(retire_fire |-> (count_q != '0 || alloc_fire))
);Six lenses. Architecture: simultaneity is expressed arithmetically, so no ordering question exists. State: one counter, one derivation. Event: both fire signals in one expression. Contract: callers may assume count_q is the true live count in every cycle — including cycles where both events occur. Failure: the wrong version drifts under load and, if it ever underflows, count_q becomes N_TAG-ish and the allocator believes it is full forever — or, with the comparison the other way, believes it has infinite room. DV/debug: coverage must include the both-fire-same-cycle bin explicitly; it will not arise reliably from random stimulus at low occupancy.
And $clog2(N_TAG + 1) is not a typo. A 64-entry structure needs a count that can represent 65 values, 0 through 64. $clog2(64) is 6 bits and cannot hold 64. This single off-by-one is one of the most common width bugs in queue logic, and it manifests as "the last entry can never be used" or as a wrap at full.
5. Tag Lifetime
A tag is not a number; it is a lease. The review reconstructs the lease's full life:
| Phase | Event | Question |
|---|---|---|
| allocated | req_fire | is the tag marked live in the same cycle it is used? |
| live | — | can anything free it besides retirement? |
| Completion matched | Completion accepted and tag live | what if it is not live? |
| partially complete | split Completion (13.3) | are bytes_left and status updated, not the live bit? |
| retired | final Completion consumed | is live cleared exactly once? |
| reusable | after retirement | can a stale Completion still arrive? |
The last row is the one that turns a tag into a generation. If the answer is "yes, after a local timeout", then the tag number alone cannot distinguish this lease from the previous one — and §7 is the timeline.
6. RTL — A Tag Table
// ILLUSTRATIVE. A tag table sized for review, not for completeness. Everything
// here exists to make the lease explicit: who holds it, since when, under which
// generation, and how much of it is left.
localparam int N_TAG = 64;
localparam int GW = 4; // generation width — see §7
typedef struct packed {
logic live;
logic [GW-1:0] gen;
logic [15:0] bytes_left;
logic [1:0] status_acc; // accumulated, never last-write-wins
logic [7:0] cfg_epoch; // which active config this was issued under
} tag_ctx_t;
tag_ctx_t ctx_q [N_TAG];
logic [GW-1:0] gen_q [N_TAG]; // NEXT generation for each tag
logic [31:0] stale_cpl_q; // Completions dropped as stale — observability
wire req_fire = req_valid && req_ready;
wire cpl_fire = cpl_valid && cpl_ready;
// A Completion is ours only if the tag is live AND the generation matches.
// Both conditions. The tag alone is not an identity across reuse.
wire cpl_match = cpl_fire
&& ctx_q[cpl_tag].live
&& (ctx_q[cpl_tag].gen == cpl_gen);
// Final Completion for this request: this Completion consumes the remainder.
wire cpl_final = cpl_match && (cpl_bytes >= ctx_q[cpl_tag].bytes_left);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int t = 0; t < N_TAG; t++) begin
ctx_q[t] <= '0;
gen_q[t] <= '0;
end
stale_cpl_q <= '0;
end else begin
// Allocation and retirement are written in ONE place per field, so an
// allocation of tag X and a retirement of tag Y in the same cycle cannot
// interfere — and an allocation and retirement of the SAME tag in one
// cycle is impossible by construction, because a tag cannot be allocated
// while it is live.
if (req_fire) begin
ctx_q[alloc_tag].live <= 1'b1;
ctx_q[alloc_tag].gen <= gen_q[alloc_tag];
ctx_q[alloc_tag].bytes_left <= req_bytes;
ctx_q[alloc_tag].status_acc <= 2'b00;
ctx_q[alloc_tag].cfg_epoch <= cfg_epoch_active;
end
if (cpl_match) begin
// Status ACCUMULATES — a later good Completion must not erase an earlier
// error. Last-write-wins here silently converts a failed transfer into a
// successful one.
ctx_q[cpl_tag].status_acc <= ctx_q[cpl_tag].status_acc | cpl_status;
if (cpl_final) begin
ctx_q[cpl_tag].live <= 1'b0;
ctx_q[cpl_tag].bytes_left <= '0;
// The generation advances at RETIREMENT, so any Completion that arrives
// after this point carries the old generation and cannot match.
gen_q[cpl_tag] <= gen_q[cpl_tag] + GW'(1);
end else begin
ctx_q[cpl_tag].bytes_left <= ctx_q[cpl_tag].bytes_left - cpl_bytes;
end
end else if (cpl_fire) begin
// Not ours. Counted, never applied. An unobservable invariant is an
// assumption (30.1 §11).
stale_cpl_q <= stale_cpl_q + 32'd1;
end
end
endArchitecture. One context per tag plus a next generation per tag. The generation is stored separately from the context because it must survive the context being cleared — that is the whole mechanism.
State. live, gen, bytes_left, status_acc, cfg_epoch. status_acc uses OR-accumulate, which is the difference between reporting a partially-failed transfer and silently reporting success.
Event. Allocation on req_fire; retirement on cpl_final; generation advance on retirement, not on allocation. Advancing on allocation would leave a window where the old generation is still current.
Contract. The Completion path must supply cpl_gen — an interface requirement this module cannot enforce alone, exactly the kind of cross-boundary contract 30.1 §4 makes a matrix row. If the responder cannot echo a generation, the design must instead guarantee no stale Completion is possible, and that guarantee needs its own argument.
Failure. cpl_match without the generation term → §7. Retiring on the first Completion rather than the final one → data loss on splits. Status last-write-wins → a failed transfer reported as good.
DV/debug. stale_cpl_q non-zero is a fact, not an alarm: it proves the defence engaged. A design that claims stale Completions are impossible and has no counter has an assumption, not an invariant.
7. Wrong RTL — Tag Freed While the Request Is Live
// WRONG. ILLUSTRATIVE. A local timeout frees the tag so the requester can make
// progress. Reasonable-sounding, and it creates an aliasing window.
if (local_timeout[t]) begin
ctx_q[t].live <= 1'b0; // BUG 1: freed, with no generation advance
// BUG 2: the peer was never told. Its Completion is still coming.
endFailure — the timeline.
| Time | Event | Tag 7 | Consequence |
|---|---|---|---|
| 0 | request A issued with tag 7 | live, gen 2 | — |
| 500 ns | A's Completion delayed (congestion — 29.6) | live | — |
| 1 µs | local timeout frees tag 7 | not live, gen still 2 | A abandoned locally |
| 1.1 µs | request B allocated tag 7 | live, gen still 2 | same identity as A |
| 1.4 µs | A's Completion arrives — tag 7, gen 2 | matches B | B retires on A's data |
| 1.5 µs | B's real Completion arrives — tag 7, gen 2 | B no longer live | dropped or double-retires |
| later | — | — | corrupt data, no error |
First divergence: 1.4 µs. Root cause: the tag was reused without the identity changing. BUG 1 is the missing generation advance; BUG 2 is the deeper architectural point — freeing a tag locally does not free it at the peer, and this is exactly the situation 30.1 §6's reset matrix row 1 exists to force a decision about.
The corrected policy, and its three acceptable forms. Advance the generation when the tag is freed by timeout, so a late Completion cannot match (the §6 code does this at retirement; a timeout path must do the same). Or quarantine the tag for longer than the maximum Completion latency before reuse. Or do not free on local timeout at all — escalate instead. All three are defensible; silently reusing is not.
And the exact timeout value is deliberately not stated here. 30.1 §11 assigns the timeout policy to architecture with a citation; this gate checks that the RTL implements whatever was decided, and that reuse is safe under it.
8. Reset — Per Register, Not Per Block
The anti-pattern is a single reset branch clearing everything, which encodes 30.1 §7's "reset clears everything" into silicon.
| Ask of every register | Fail criterion |
|---|---|
| which reset clears it? | "the reset" |
| does it survive link Recovery (18.5)? | unexamined |
| is it async-asserted / sync-deasserted? | mixed conventions in one module |
| does clearing it orphan work at the peer? | §7's BUG 2 |
| are statistics cleared by a functional reset? | evidence destroyed by an unrelated event |
| is first-fault preserved? | last-error-wins (30.1 §13 Q40) |
And the practical review technique is mechanical: list every register in the module, then for each of the four reset types mark clear / retain / reconstruct. Registers that end up in a different column from their neighbours are the interesting ones — they are usually right, and they are usually the ones a single reset branch got wrong.
9. Configuration — Shadow and Commit
// ILLUSTRATIVE. Software writes fields one at a time; the datapath must never
// observe a half-updated configuration. Shadow, validate, commit atomically,
// and stamp an epoch so in-flight work knows which config it belongs to.
logic [31:0] cfg_shadow_q, cfg_active_q;
logic [7:0] cfg_epoch_active_q;
logic cfg_valid_q;
wire cfg_consistent = check_cfg(cfg_shadow_q); // pure function, no state
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cfg_shadow_q <= CFG_RESET; cfg_active_q <= CFG_RESET;
cfg_epoch_active_q <= '0; cfg_valid_q <= 1'b0;
end else begin
// Software may write the shadow at any time. The datapath never reads it.
if (cfg_wr_fire) cfg_shadow_q <= cfg_wr_data;
// Commit is a single event, gated on quiescence AND validity. Committing
// with work in flight is the failure this whole structure prevents.
if (cfg_commit_fire && cfg_consistent && (outstanding_q == '0)) begin
cfg_active_q <= cfg_shadow_q;
cfg_epoch_active_q <= cfg_epoch_active_q + 8'd1;
cfg_valid_q <= 1'b1;
end
end
end
// MANDATORY. English: the active configuration is stable while any request is
// outstanding. This is the property the datapath depends on, stated directly.
a_cfg_stable_when_busy: assert property (
@(posedge clk) disable iff (!rst_n)
(outstanding_q != '0) |=> $stable(cfg_active_q)
);Six lenses. Architecture: separate requested from active so a partial write is never observable. State: shadow, active, epoch, valid. Event: commit is one gated event; cfg_wr_fire never touches active. Contract: software must write the shadow then commit — a two-step protocol that must appear in the programming model, or drivers will write and expect immediate effect. Failure: committing without the quiescence check lets a request issued under config N retire under config N+1, which is why cfg_epoch is in §6's context. DV/debug: the negative test is commit with work outstanding and confirm nothing changes; the assertion is the same statement.
And the quiescence requirement is a real cost worth naming. Requiring outstanding_q == 0 means a reconfiguration waits for a drain. If the architecture cannot accept that latency, the alternative is per-request cfg_epoch carried end to end — which is more RTL, and it is a decision 30.1 should have made, not one to discover here.
10. Widths, Parameters and Arithmetic
| Item | The trap | Correct |
|---|---|---|
$clog2(N) for a count | a 64-entry structure needs 65 values | $clog2(N + 1) (§4) |
$clog2(1) | evaluates to 0 — a zero-width signal | special-case, or $clog2(N) > 0 ? ... : 1 |
| unsigned subtraction | 0 - 1 becomes the maximum, and "room available?" answers yes forever | guard, saturate, or use a signed next-state |
| byte counters | wrap after a plausible run time | 48 bits and saturating (29.2 §6) |
| generation width | wrap inside one Completion's flight time | derive it (29.5 §8) |
| FIFO pointers | full and empty indistinguishable | extra bit, or a separate count |
| truncating casts | 16'(x) silently drops bits | size explicitly at the source |
| parameter-derived slices | [N-1:0] with N = 0 | elaboration-time $error |
The reusable technique is the elaboration check (29.3 §7): a configuration that cannot be correct should fail the build, not the lab. Every parameterised structure should carry one.
11. Combinational and FSM Review
| Check | Tie to this subsystem |
|---|---|
| every branch assigns every output, or defaults precede | a missing branch in the Completion decoder infers a latch on bytes_left |
unique/priority used only where genuinely true | a "unique" Completion-status case that is not unique masks an illegal status |
no combinational path from ready back to valid | a ready = f(valid) loop deadlocks the whole requester |
FSM has an explicit default returning to a safe state | a one-hot state corrupted by an X during reset never recovers |
| functions used in RTL are pure | check_cfg (§9) must not have side effects, or commit becomes order-dependent |
no X propagating from uninitialised memory | tag context read before written after reset |
And the ready/valid loop deserves its own emphasis because it is the one that produces a hang rather than a wrong value. The rule is directional: valid may not depend combinationally on ready. A producer that waits to see ready before asserting valid, facing a consumer that waits for valid before asserting ready, is a deadlock that no amount of stimulus will shake loose.
12. Clock and Reset Crossings
If the subsystem has more than one clock, every crossing is a review item.
| Crossing | Requirement |
|---|---|
| configuration writes | commit as one event — a multi-bit config crossing bit-by-bit is a partial config (§9) |
| status / counters | value must be sampled coherently, or a reader sees a mix of two states |
| single-cycle event pulses | treat as suspicious — a pulse narrower than the destination clock is lost |
| interrupt / doorbell events | must not be lost; handshake or toggle, never a bare pulse |
| queue ownership across domains | one owner per domain, with an explicit handoff |
| reset deassertion | synchronised per domain; a shared async deassert produces different first cycles |
The single-cycle pulse is the highest-yield item. A doorbell or completion event crossing as a one-cycle pulse into a slower domain is lost, not delayed — and the symptom is an occasional missing interrupt or a stuck queue, appearing at a rate that depends on the clock ratio. Ratio-dependent bugs are the hardest class to reproduce, because changing frequency to investigate changes the bug.
13. Assertions the RTL Must Carry
// English: a tag is never allocated while it is already live. The primary
// integrity property of the tag table, and an excellent formal target.
a_no_double_alloc: assert property (
@(posedge clk) disable iff (!rst_n)
req_fire |-> !ctx_q[alloc_tag].live
);
// English: a Completion is only applied to a live request with a matching
// generation. Catches §7 directly.
a_cpl_only_when_live: assert property (
@(posedge clk) disable iff (!rst_n)
cpl_applied |-> (ctx_q[cpl_tag].live && (ctx_q[cpl_tag].gen == cpl_gen))
);
// English: a request retires exactly once. Two-cycle window because retirement
// and a subsequent Completion are separate events.
a_retire_once: assert property (
@(posedge clk) disable iff (!rst_n)
cpl_final |=> !ctx_q[$past(cpl_tag)].live
);
// English: outstanding count equals the number of live tags. An identity that
// ties two representations together — the classic place they drift.
a_count_matches_table: assert property (
@(posedge clk) disable iff (!rst_n)
count_q == CW'($countones(live_vector))
);
// English: first fault is sticky until explicitly cleared.
a_first_fault_sticky: assert property (
@(posedge clk) disable iff (!rst_n || err_clear)
$rose(first_fault_valid) |=> always first_fault_valid
);Three readings.
a_count_matches_table is the highest-value one and the least often written. Designs carry a count and a bitmap because each is cheap for a different question — and nothing keeps them consistent. The identity catches §4's two-NBA drift, §3's over-allocation, and any future path that updates one and not the other.
a_first_fault_sticky needs err_clear in the disable term, for the same reason 29.4 §8 needed stat_clear: a legitimate clear must not fail the assertion, or the assertion gets deleted.
And the first four are strong formal targets — small state, bounded arithmetic, and each states design intent rather than restating the code. a_no_double_alloc in particular is usually provable in minutes and is worth more than any amount of random stimulus.
14. Lint Is Not Sign-Off
Clean lint proves a real and useful class of things: syntax, inferred latches, width mismatches, unreachable code, incomplete sensitivity.
It proves none of this:
| Not proven by lint | Where it is caught |
|---|---|
| the acceptance event is the right event | §3 — valid lints perfectly |
| simultaneous updates net correctly | §4 — two NBAs lint clean |
| tag lifetime is safe under reuse | §7 |
| reset scope matches the architecture | §8 |
| configuration is atomic with respect to traffic | §9 |
| ordering assumptions hold | 30.1 §9 |
The demonstration to keep in mind. §4's wrong version — two if statements, two non-blocking assignments — is perfectly clean under lint. Correct syntax, no latch, no width mismatch, no unreachable branch. It also loses one count on every coincidence, at a rate proportional to load. Lint has no opinion about whether the second assignment should have won.
So the review posture is: lint is a precondition, not evidence. A design that fails lint is not ready to review. A design that passes lint has earned the right to be reviewed for the things in this chapter.
15. The RTL Checklist — 48 Questions
Acceptance and events
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 1 | Is there one named wire per protocol event, used everywhere? | §2 | the wire, and all its uses | two decodes of the same idea |
| 2 | Is allocation on valid && ready, never valid? | §3 | the expression | valid alone, unjustified |
| 3 | Is "DMA done" the final byte retired, not the last request issued? | 29.1 §7 | the retirement event | issue-side signal |
| 4 | Is a Completion "consumed" at the sink, not at arrival? | back-pressure makes them differ | the consume event | arrival used |
| 5 | Is payload stable while an offer is stalled? | §3's assertion | a_stable_while_stalled | no assertion |
| 6 | Are credits consumed at the architecturally-defined point? | 16.5 | the point named and matched | generation-time |
Counters and arithmetic
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 7 | Does every counter use one next-state expression? | §4 | the expression | two NBAs |
| 8 | Are simultaneous increment and decrement correct? | §4 | the both-fire coverage bin | untested |
| 9 | Is $clog2(N + 1) used for counts of N items? | §4, §10 | the declaration | $clog2(N) |
| 10 | Can any unsigned counter underflow? | §10 | guard or saturate | bare subtraction |
| 11 | Are byte counters wide enough and saturating? | 29.2 §6 | width + saturation | 32-bit wrapping |
| 12 | Is $clog2(1) == 0 handled for degenerate parameters? | §10 | elaboration check | zero-width signal |
| 13 | Are all parameterised structures guarded by an elaboration check? | 29.3 §7 | $error on bad configs | none |
| 14 | Do casts size explicitly rather than truncate silently? | §10 | explicit widths | bare 16'(x) on wide data |
Queues and occupancy
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 15 | Is occupancy bounded by construction and asserted? | §13 | bound + assertion | comment only |
| 16 | Are FIFO full and empty distinguishable? | §10 | extra bit or count | ambiguous pointers |
| 17 | Is the back-pressure threshold derived from committed bytes? | 29.3 §5 | the inequality | a percentage |
| 18 | Is there a drop/overflow counter, even where overflow is impossible? | unobservable invariant = assumption | the counter | none |
| 19 | Can one class's queue block another's progress? | 29.6 §9 | separate pools or a floor | shared, unexamined |
Tags and identity
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 20 | Is a tag ever allocated while live? | §13 | a_no_double_alloc | no check |
| 21 | Is there a generation, and is its width derived? | §6, §7 | the derivation | absent or arbitrary |
| 22 | Does the generation advance at retirement? | §6 | the code path | at allocation |
| 23 | Can a local timeout free a tag whose Completion may still arrive? | §7 | quarantine, generation advance, or no local free | silent reuse |
| 24 | Is count_q tied to the live bitmap by an assertion? | §13 | a_count_matches_table | two independent representations |
| 25 | Are stale Completions counted rather than silently dropped? | §6 | stale_cpl_q | dropped silently |
Completions
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 26 | Is correlation by tag, never by arrival order? | 30.1 §9 · 13.4 | the match expression | FIFO matching |
| 27 | Are split Completions handled with bytes_left? | 13.3 | the decrement path | retire on first |
| 28 | Does status accumulate rather than last-write-wins? | §6 | the OR-accumulate | overwrite |
| 29 | Does retirement happen exactly once per request? | §13 | a_retire_once | no check |
| 30 | What happens to a Completion for a non-live tag? | §6 | counted, not applied | applied, or silently dropped |
Reset and recovery
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 31 | Is each register's reset scope stated individually? | §8 | the per-register table | one reset branch |
| 32 | Does anything clear on link Recovery that the peer still holds? | §7, 30.1 §6 | the matrix row | unexamined |
| 33 | Are statistics cleared by functional resets? | evidence loss | separate clear | shared |
| 34 | Is first fault preserved across the event that caused it? | §13 | a_first_fault_sticky | last-error-wins |
| 35 | Is reset deassertion synchronised per clock domain? | §12 | the synchroniser | shared async deassert |
Configuration
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 36 | Are requested and active configuration separate? | §9 | shadow + active | one register |
| 37 | Is commit a single gated event? | §9 | the commit expression | field-by-field |
| 38 | Is the active config stable while work is outstanding? | §9 | a_cfg_stable_when_busy | no check |
| 39 | Is the commit protocol in the programming model? | drivers must know | the document | RTL-only |
Combinational, FSM, CDC
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 40 | Any combinational path from ready to valid? | §11 — deadlock, not a wrong value | the analysis | present |
| 41 | Does every FSM have a safe default? | §11 | the default branch | none |
| 42 | Are unique/priority genuinely true? | §11 | the argument | used for optimisation only |
| 43 | Are single-cycle pulses crossed safely? | §12 | handshake or toggle | bare pulse |
| 44 | Are multi-bit config values crossed as one event? | §12 | the crossing scheme | bit-by-bit |
Observability and DV hooks
| # | Question | Why | Evidence | FAIL if |
|---|---|---|---|---|
| 45 | Are the 30.1 §13 Q11 observables actually implemented? | architecture named them | the registers | named but absent |
| 46 | Is there an outstanding high-water mark? | one register, ends a class of investigation | the register | none |
| 47 | Are all assertions in §13 present and enabled? | §13 | the assertions | commented out |
| 48 | Does the design expose what 30.3 needs to check independently? | a checker with no visibility cannot check | the interface | internal-only state |
16. Misconceptions
"Lint is clean, so the RTL is clean." §14: §4's two-NBA counter lints perfectly and loses a count on every coincidence.
"valid means the transaction happened." §3: it means an offer. Under back-pressure the offer persists, and the design counted four requests for one.
"It works in simulation." §3: with ready always high, the bug cannot appear. Most early tests hold ready high.
"A tag is just a number." §7: after reuse, the number is ambiguous. Identity requires a generation unless reuse is provably safe.
"Reset clears everything." §8 and 30.1 §7: which reset, and does the peer agree?
"Both assignments are correct, so the code is correct." §4: individually correct, jointly wrong — and the second one silently wins.
"Faster is safer for a single-cycle pulse." §12: it is lost, not delayed, and the loss rate depends on the clock ratio — so investigating by changing frequency changes the bug.
"We'll add assertions in DV." §13, and §15 Q48: assertions on internal state must live with the RTL; a testbench cannot see ctx_q.
17. Understanding Check
Q1. A design allocates on req_valid. Why does it pass every early test and fail in the lab?
Because valid is an offer and valid && ready is a transaction, and early tests hold ready high (§2, §3). With no back-pressure the two are identical. Under back-pressure a sustained offer is counted repeatedly: §3's timeline shows four allocations for one request across cycles 1–4, and the counter never drains — one Completion decrements it to 3 and it stays there. Three consequences follow, and the third is the worst: tag exhaustion appears at a fraction of the real load; the trace ring fills with duplicates, so debug evidence is corrupted before anyone starts investigating; and the design is correct in exactly the conditions under which it is usually tested. The fix is one named wire — req_fire = req_valid && req_ready — referenced by every consumer, never re-derived, plus a_stable_while_stalled to enforce the producer's half of the contract.
Q2. Two if statements each assign count_q non-blockingly. What is wrong and how does it present?
When both fire in the same cycle the second wins, so the count decrements when it should be unchanged (§4). The error is −1 per coincidence, and coincidences become more frequent as the design gets busier — so the drift is proportional to load and presents as "we lose outstanding slots under stress," which sounds like a capacity problem rather than an arithmetic one. The fix is one next-state expression covering all four cases by construction. Two details go with it: $clog2(N + 1), because a 64-entry structure needs 65 representable values and $clog2(64) is 6 bits; and an underflow guard, because an unsigned count_q going 0 − 1 becomes the maximum and every "is there room?" test then answers yes forever. Coverage must include the both-fire-same-cycle bin explicitly — random stimulus at low occupancy will not produce it reliably.
Q3. A local timeout frees tag 7 so the requester can proceed. What breaks?
The tag is reused with the same identity, so a late Completion for the abandoned request matches the new one (§7). Timeline: A holds tag 7; congestion delays its Completion; at 1 µs the local timeout frees the tag with no generation advance; B allocates tag 7 at 1.1 µs; A's Completion arrives at 1.4 µs, matches B, and B retires on A's data. B's real Completion then arrives against a dead entry. Corrupt data, no error.
Two bugs, and the second is architectural. The missing generation advance is the RTL defect; freeing a tag locally does not free it at the peer is the design defect — the peer never agreed to anything. Three fixes are defensible: advance the generation on the timeout path so a late Completion cannot match; quarantine the tag for longer than the maximum Completion latency; or do not free on local timeout at all and escalate instead. Silently reusing is the only unacceptable option. The timeout value is 30.1 §13 Q11's to decide; this gate checks that reuse is safe under whatever was decided.
Q4. Lint is clean and the block passes its directed tests. What has that established?
A precondition, not evidence (§14). Lint proves syntax, inferred latches, width mismatches, unreachable code and incomplete sensitivity — all real and all necessary. It has no opinion about which of two non-blocking assignments should win, so §4's broken counter passes it perfectly. It also cannot see that the acceptance event is the wrong event (§3), that a tag's lifetime is unsafe under reuse (§7), that reset scope contradicts the architecture (§8), or that configuration is not atomic with respect to traffic (§9).
Directed tests add little here for a structural reason: this chapter's failures are simultaneity failures, and directed tests exercise events one at a time by design. The evidence that counts is the cross product — the both-fire coverage bin, the stalled-offer case, tag reuse after a timeout, commit with work outstanding — plus the assertions in §13, of which a_count_matches_table earns its place by tying the count and the live bitmap together. Designs carry both representations because each is cheap for a different question, and nothing otherwise keeps them consistent.
18. What Comes Next
This gate asked whether the RTL is faithful. The next asks a harder question about the environment that judged it.
30.3 tests whether verification is capable of disproving the design, or has merely accumulated passing tests — including the case where the predictor's expected data descends from the DUT's own output, and every test agrees with itself forever.