Capstone: a tagged request/response channel

Build an assertion suite for a pipelined request/response channel with an abort, using tickets and IDs to pair responses, and read its caught-or-missed matrix over a fault bank.

Lesson
A8.2
Time
35 min
Runs on
Verilator
Check
5 questions

A responder lost one response in a burst of four. The range rule req |-> ##[1:6] rsp_valid stayed silent, because every request still saw some response inside its window. A suite that pairs each response with its request caught it twice: once when the wrong ID arrived, and once when a ticket ran out of time.

Fault 1: the third response is lost
0123456789time2535455565758595105115reqreq_id12340rsp_readyrsp_validrsp_id0124served0123tag fails: ID 4 where 3 is dueticket 3 misses its deadline

Sampled values from the example run. served counts taken responses, so ticket served is the one due next.

The channel

A requester sends req with a 3-bit req_id and an address. The responder answers with rsp_valid, rsp_id and rsp_data. Each response is ready a few edges after its request, the delay depending on the address, and responses leave in request order, so one can also wait behind earlier ones. A response is held while rsp_ready is low. An abort cancels every outstanding request, and a synchronous reset empties everything.

The specification adds one bound: every request is answered, or cancelled, within six edges. The testbench never has more than four requests in flight, so that bound covers the longest legal queue.

From specification to rules

Spec sentenceLabelRule
Each request is answered within six edges.ticket(req, t = issued) |-> ##6 (served + rsp_valid > t)
No response without an outstanding request.orphanrsp_valid |-> served < issued
A response carries the ID of the request it answers.tagrsp_valid |-> rsp_id == id_of[served % 8]
A stalled response holds its ID and data.holdrsp_valid && !rsp_ready |=> rsp_valid && $stable(rsp_id) && $stable(rsp_data)

Four rules, each one implication with a fixed-length antecedent. default disable iff (reset) covers all of them.

Each rule states one property of the pairing. ticket is about time, orphan about counts, tag about identity and hold about stability. A fault that breaks one of these properties should trip the matching rule, and the matrix below checks that it does.

The model carries the tags

The rules read three pieces of auxiliary state, built from the ports only. issued counts requests, served counts taken responses, and id_of records the ID of each ticket, indexed by ticket number modulo 8. The ticket due next is always served, because responses come back in order.

The abort lives in the model too. At an abort, served jumps to issued, so every cancelled ticket counts as served. The ticket rule then needs no special case, and a response that arrives after the abort finds nothing outstanding, which orphan reports.

This design choice is worth copying. Let the model decide what an event means for the protocol, and keep each property a plain statement about the model and the pins.

Why the rules are this plain

Every rule is one implication whose antecedent is decided at a single edge, and the only local variable is captured there and read at a fixed delay. That fits the simulator: on Verilator 5.052 these forms report failures exactly where the standard says.

It also helps whoever reads a failure. Each rule starts at most one attempt per edge, so a failure count is a count of bad edges, and a failure time points straight at the edge to inspect. When a rule needs to know more, such as which ticket is due, that knowledge goes into the model. There it can be printed and debugged like any other code.

The fault bank and the matrix

VariantticketorphantagholdRange rule
0: correct00000
1: third request dropped10100
2: one response sent twice01300
3: answers after an abort02200
4: data moves during a stall00010
5: stuck tag00500

Failure counts per rule, measured on Verilator 5.052 (the example below). The range rule is req |-> ##[1:6] rsp_valid, kept for comparison.

The suite is silent on the correct design and catches every fault. The untagged range rule catches none: each faulty variant still produces some response inside every window.

Reading the matrix

Fault 1 trips two rules, at two different places. tag fires at 85, the first response after the gap, and names the missing ID. ticket fires at 115, for the fourth request, because the counting has shifted by one. The count proves something is missing; the ID says what.

Fault 3 passes ticket, since the abort already marked every ticket served. The late response is caught by orphan, the rule about counts. Fault 2 is caught by tag first, because the repeated ID arrives where the next one was due. orphan fires only at the end of the burst, when the extra response finds nothing outstanding.

No rule here checks rsp_data against the request's address, so a responder that corrupts data would pass. Adding that rule is the natural next step, with an addr_of table beside id_of.

Try it

One rule module, bound into six responders, prints one line per variant. The correct responder is silent; every faulty one trips at least one paired rule, and the untagged range rule trips on none.

Change fault 1 to drop the fourth request instead of the third (nreq == 4) and predict its line. Then add the data rule and see which variants it catches.

RUNVerilator · simulator service
Edit the code, then run it
Expected: fault 0: ticket=0 orphan=0 tag=0 hold=0 | untagged=0 fault 1: ticket=1 orphan=0 tag=1 hold=0 | untagged=0 fault 2: ticket=0 orphan=1 tag=3 hold=0 | untagged=0 fault 3: ticket=0 orphan=2 tag=2 hold=0 | untagged=0 fault 4: ticket=0 orphan=0 tag=0 hold=1 | untagged=0 fault 5: ticket=0 orphan=0 tag=5 hold=0 | untagged=0

Check yourself

Predict the outputThree requests are sent and only the first two are answered. Predict the exact output.
module tb;
  logic clk = 0;
  always #5 clk = ~clk;                       // rising edges at 5, 15, 25, ...
  logic       req = 0, rsp = 0;
  logic [2:0] req_id = 0, rsp_id = 0;
  int unsigned issued = 0, served = 0;
  logic [2:0]  id_of [8];
  always @(posedge clk) begin
    if (req) begin id_of[issued % 8] <= req_id; issued <= issued + 1; end
    if (rsp) served <= served + 1;
  end
  int f_ticket, f_tag;
  property ticket_served;
    int unsigned t;
    @(posedge clk) (req, t = issued) |-> ##4 (served + rsp > t);
  endproperty
  ticket: assert property (ticket_served) else f_ticket++;
  tag:    assert property (@(posedge clk) rsp |-> rsp_id == id_of[served % 8]) else f_tag++;
  initial begin
    @(negedge clk); req = 1; req_id = 1;      // requests 1, 2, 3 at 15, 25, 35
    @(negedge clk); req_id = 2;
    @(negedge clk); req_id = 3;
    @(negedge clk); req = 0; rsp = 1; rsp_id = 1;   // responses at 45 and 55
    @(negedge clk); rsp_id = 2;
    @(negedge clk); rsp = 0;                  // the response for 3 never comes
    repeat (4) @(negedge clk);
    $display("ticket=%0d tag=%0d", f_ticket, f_tag);
    $finish;
  end
endmodule
Read the waveformThe suite has ticket, orphan, tag and hold, and four requests are outstanding at edge 1. Which rule fails first, and where?
A responder repeats one response
01234edge12345rsp_validrsp_readyrsp_id123served01234id_of[served]12340issued4

Sampled values at each rising edge, with the model's counters.

ConceptAn abort cancels every outstanding request. Where should the suite encode that?
ConceptFault 3 answers a cancelled request after an abort. Why does the ticket rule not notice?
ConceptA new variant returns the right IDs but wrong data. Which addition catches it?

Remember

  • Pair responses with requests in the model: ticket counters for order, an ID table for identity.
  • Give each rule one job: time (ticket), counts (orphan), identity (tag), stability (hold).
  • Let the model define protocol events such as an abort, and keep each rule a plain implication.
  • Counts prove a response is missing; IDs say which one.
  • An untagged range rule catches none of these faults.

Practice