The APB checker

Write the rules of an APB transfer as properties and bind them inside every completer. Break each rule once, read the edge it names, and learn what a checker on the wires cannot tell you about a register block.

Lesson
P3.8
Time
30 min
Runs on
Verilator
Check
5 questions

Firmware wrote 0x66 to a register at offset 0x1c and read back 0x00, while its neighbour at 0x20 held the 0x66. The requester had moved PADDR forward by one word during a wait state, and the completer took the address it saw at the completion edge. Weeks of directed tests had never stalled that peripheral twice; one property names the edge.

The address that moved in a wait stateA write to 0x1c with two wait states; PADDR moves to 0x20 in the second one. Then two reads.
PSELPENABLEPADDRPREADYRequestermoves PADDR in a waitCompleter p0acts at the completion edge

A token flies at each completion edge: the write data to the completer, the read data back.

01234567clkpselpenablepwritepaddr1c201c20pwdata660preadyprdata066
  1. Cycle 0

    Setup of the write: PSEL high, PENABLE low, PADDR = 0x1c, PWDATA = 0x66. From here to the completion edge every one of these must stay put.

  2. Cycle 2

    The second access cycle, still waiting: PADDR is now 0x20. The property stable_ctl fails at this edge, and the procedural checker prints stable at the same edge.

  3. Cycle 3

    PREADY is 1: the completion edge. The completer acts on what it sees now, so 0x66 lands at 0x20.

  4. Cycle 5

    The read of 0x1c returns 0x00. The data was never written there.

  5. Cycle 7

    The read of 0x20 returns 0x66: the neighbour took the write.

Measured on the lesson's bench (edges 3 to 10, move_in_wait on, the completer with two wait states). The rule names the edge where the address moved; the data shows it only two transfers later.

Ten names, twelve properties

The checker of this module has two forms, as in P2.16. apb_checker.sv is procedural: one always block at every edge that prints RULE <name> broken at edge N. apb_props.sv says the same sentences as concurrent assertions with the same names, so the two can be read side by side. APB needs almost no bookkeeping: one transfer at a time, no IDs, no bursts. A property only has to know the previous cycle, which $past and $stable give it.

RuleThe sentenceBroken in the tour byFires at
setupAn access cycle follows a setup cycle or a waited access cyclePSEL and PENABLE rising together (phase 1); PENABLE held into the next transfer (phase 2)edges 16; 19
enable_dropPENABLE goes low after the completion edgePENABLE held into the next transfer (phase 2)edge 19
stablePSEL, PADDR, PWRITE, PPROT, PSTRB and the write data hold until PREADY; PENABLE stays highPADDR moved in a wait (phase 3); PSEL and PENABLE dropped by a requester that gave up (phase 7)edges 22; 54
strb_readEvery PSTRB bit is low during a readPSTRB = 1111 on a read (phase 4)edges 24, 25
setup_lenThe setup phase lasts one cyclea setup held for two cycles (phase 5)edge 27
pslverr (note)PSLVERR is recommended LOW outside the completion cyclea completer that raises it in setup and wait cycles (phase 6)edges 29, 30
timeoutA watchdog: PREADY within 16 access cyclesa completer that never answers (phase 7)edge 48
resetNo transfer while PRESETn is lowPSEL driven during reset (phase 8)edge 55
onehotAt most one PSELx at a timenot reachable inside one completer: bind it on the decoded bus with NSEL = 3
knownNo x on a control signal while PSEL is highthe procedural checker only: Verilator has no x

The rule table, measured by the tour below. At every edge the property and the procedural checker print together.

Two of the rows are not rules of the specification. timeout is a watchdog. APB lets a completer add any number of wait states, so only the system can say how many is too many; the 16 here is the bench's choice. The pslverr row is a recommendation: the specification makes PSLVERR meaningful only in the last cycle of a transfer and recommends, without requiring, that it is LOW elsewhere. Its copy counts notes, not failures.

Read apb_props.sv below the bench with this table beside it. The bind line in the bench puts one copy inside apb_periph, so every completer instance checks its own PSEL.

The sentence at every access edge

The setup sentence is the whole APB state machine in one line. An access cycle (PSEL and PENABLE high) must come straight after a setup cycle, or after an access cycle that was still waiting for PREADY. It is tried at every access edge, including every wait state, so a transfer with two waits is checked three times.

Two accesses after a setup, one withoutOne lane per access cycle for the setup sentence.psel && penable |-> $past(psel && (!penable || !pready))Measured on Verilator 5.052: one copy of the assertion per lane
attempt startspassfail
Two accesses after a setup, one withoutaccess @25, from edge 3 (t=25): passes at edge 3 (t=25). access @35, from edge 4 (t=35): passes at edge 4 (t=35). access @55, from edge 6 (t=55): fails at edge 6 (t=55).edgetime15215325435545655765clkpselpenablepreadyattemptsaccess @25access @25: passes at edge 3 (t=25)access @35access @35: passes at edge 4 (t=35)access @55access @55: fails at edge 6 (t=55)
  1. Edge 3 · t=25

    The first access at 25. The cycle before it was a setup (PSEL 1, PENABLE 0): pass.

  2. Edge 4 · t=35

    The access at 35 follows an access that was still waiting (PREADY was 0 at 25): pass. This is the same transfer, one wait state later.

  3. Edge 6 · t=55

    At 55, PSEL and PENABLE rise together. The cycle before was idle, so no setup came first: fail, at the access edge itself.

Read the lanes as text
  • access @25, from edge 3 (t=25): passes at edge 3 (t=25).
  • access @35, from edge 4 (t=35): passes at edge 4 (t=35).
  • access @55, from edge 6 (t=55): fails at edge 6 (t=55).

Sampled values, edge 1 to 7: psel 0 1 1 1 0 1 0; penable 0 0 1 1 0 1 0; pready 0 0 0 1 0 1 0.

The program behind the lanes

The lanes come from running this program on Verilator 5.052. Each lane is a separate copy of setup that can start only at that lane's edge, so every verdict and its edge are read from that copy's own output.

module tb;
  logic clk = 0;
  always #5 clk = ~clk;                 // rising edges at 5, 15, 25, ...
  logic psel = 0, penable = 0, pready = 0;

  setup: assert property (@(posedge clk) psel && penable |-> $past(psel && (!penable || !pready)));

  initial begin
    @(negedge clk); psel = 1;                          // setup at 15
    @(negedge clk); penable = 1;                       // access at 25, PREADY low: a wait
    @(negedge clk); pready = 1;                        // access at 35 completes
    @(negedge clk); psel = 0; penable = 0; pready = 0; // idle at 45
    @(negedge clk); psel = 1; penable = 1; pready = 1; // 55: PSEL and PENABLE rise together
    @(negedge clk); psel = 0; penable = 0; pready = 0;
    @(negedge clk);
    $finish;
  end
endmodule

Three attempts, one failure. $past is the previous sample, so the rule reads the state machine one cycle back.

The stable sentence uses |=>: it starts in a cycle where the transfer is not finished and checks the next one. Its failure is reported one edge after the attempt began, at the edge where the change is first visible.

The address that movedOne lane per wait cycle for psel && penable && !pready |=> $stable(paddr).psel && penable && !pready |=> $stable(paddr)Measured on Verilator 5.052: one copy of the assertion per lane
attempt startspassfail
The address that movedwait @25, from edge 3 (t=25): fails at edge 4 (t=35). wait @35, from edge 4 (t=35): passes at edge 5 (t=45).edgetime15215325435545655clkpselpenablepreadypaddr000100104attemptswait @25wait @25: fails at edge 4 (t=35)wait @35wait @35: passes at edge 5 (t=45)
  1. Edge 3 · t=25

    The first wait cycle at 25 starts an attempt. It looks at the next edge.

  2. Edge 4 · t=35

    At 35 PADDR is 0x104 instead of 0x100: the attempt from 25 fails here. The wait at 35 starts its own attempt.

  3. Edge 5 · t=45

    At 45 PADDR is still 0x104, so the second attempt passes. One move, one failure: the rule names the edge of the change, not every edge after it.

Read the lanes as text
  • wait @25, from edge 3 (t=25): fails at edge 4 (t=35).
  • wait @35, from edge 4 (t=35): passes at edge 5 (t=45).

Sampled values, edge 1 to 6: psel 0 1 1 1 1 0; penable 0 0 1 1 1 0; pready 0 0 0 0 1 0; paddr 000 100 100 104 104 104.

The program behind the lanes

The lanes come from running this program on Verilator 5.052. Each lane is a separate copy of stable_addr that can start only at that lane's edge, so every verdict and its edge are read from that copy's own output.

module tb;
  logic clk = 0;
  always #5 clk = ~clk;                 // rising edges at 5, 15, 25, ...
  logic psel = 0, penable = 0, pready = 0;
  logic [11:0] paddr = 0;

  stable_addr: assert property (@(posedge clk) psel && penable && !pready |=> $stable(paddr));

  initial begin
    @(negedge clk); psel = 1; paddr = 12'h100;         // setup at 15
    @(negedge clk); penable = 1;                       // access at 25, a wait
    @(negedge clk); paddr = 12'h104;                   // access at 35, a wait: the address moved
    @(negedge clk); pready = 1;                        // access at 45 completes
    @(negedge clk); psel = 0; penable = 0; pready = 0;
    @(negedge clk);
    $finish;
  end
endmodule

Measured on a hand-driven bus: the address moves in the second wait cycle, as move_in_wait does in the tour.

What the wires cannot say

Three faults of the library's completer pass every rule above, because they are faults of the register block, not of the bus. A completer with act_every_cycle acts at every edge its PSEL is high: a waited read of the FIFO pops three words and returns one. The handshake is perfect, and only the register contents show the damage. With late_rdata, PRDATA arrives one cycle after the completion edge, so every read returns the value of the read before it. The wires obey every timing rule; only the data is wrong. And a write that gets PSLVERR may or may not have changed the register. The specification allows either, so no checker may call one of them wrong.

This is the same split as in P2.16. The checker names the edge where a rule broke. The scoreboard keeps a model of the registers and catches the data that is legal on the wires and wrong in value. P3.10 runs both over every fault of the library.

Where to bind decides what is checked. A copy inside apb_periph watches that completer's own PSELx, which is the right place to check a requester or a bridge from the completer side. To check that a decoder never selects two completers, bind one copy to the decoded bus with NSEL equal to the number of select lines. That is the only place onehot can fire.

Asked in interviews: APB checkers
  1. Which properties would you write for APB?

    An access follows a setup or a waited access, and setup lasts one cycle. PSEL, PADDR, PWRITE, PPROT, PSTRB and the write data hold from setup until PREADY, and PENABLE stays high. PENABLE drops after the completion edge. PSTRB is zero on reads, one PSELx is high at a time, and nothing is selected in reset. Add a watchdog on PREADY with the system's own limit.

  2. Is PREADY low in the setup cycle a rule?

    No. PREADY may take any value while PENABLE is low, so a completer with no wait states can tie it high. A checker that demands PREADY low in setup rejects legal designs.

  3. Is PSLVERR outside the last cycle an error?

    No. It is only meaningful in the cycle where PSEL, PENABLE and PREADY are all high; driving it low elsewhere is a recommendation. Report it as a note if you report it at all.

  4. Why is a timeout not part of the protocol checker proper?

    Because APB allows any number of wait states. How long is too long is a property of the system, so the watchdog's limit comes from the peripheral's documentation, not from the specification.

  5. What can an APB checker never catch?

    Faults that keep the handshake legal: a completer that acts more than once per transfer, read data that arrives a cycle late, a register that ignores strobes. Those show only in the data, which needs a register model or scoreboard.

Try it

Nine phases, one rule each, the property line and the RULE line at the same edge. Phase 0 is correct traffic with one wait state per transfer and PSEL held between them, and nothing fires. Phase 6 prints notes, not failures: a completer that raises PSLVERR early breaks a recommendation, and the requester, which reads PSLVERR only at the completion edge, sees OKAY. The register was not written either, because this completer changes nothing on an error.

Then write a sentence of your own. Copy strb_read into the bench as a property that PPROT[2] (instruction) is 0 on every write. Break it with apb_write(32'h100, 32'h1, 4'hf, 3'b100); and read the edges it fires at. It fires at the setup edge and at the completion edge, because it is tried at every selected cycle.

RUNVerilator · simulator service
Edit the code, then run it
Expected: -- phase 0: correct traffic, back to back with one wait state e3-e5 WRITE 0x0100 data=0x00000011 strb=1111 waits=1 OKAY e6-e8 WRITE 0x0014 data=0x00000022 strb=1111 waits=1 OKAY e9-e11 READ 0x0100 data=0x00000011 waits=1 OKAY e12-e14 READ 0x0014 data=0x00000022 waits=1 OKAY -- phase 1: setup, PSEL and PENABLE rise together RULE setup broken at edge 16: PENABLE is 1 without a setup cycle (PSEL 1, PENABLE 0) before it property setup failed at edge 16: PENABLE is 1 without a setup cycle before it e16-e16 WRITE 0x0100 data=0x00000033 strb=1111 waits=0 OKAY -- phase 2: enable_drop, PENABLE held into the next transfer e17-e18 WRITE 0x0100 data=0x00000044 strb=1111 waits=0 OKAY RULE setup broken at edge 19: PENABLE is 1 without a setup cycle (PSEL 1, PENABLE 0) before it RULE enable_drop broken at edge 19: PENABLE is still 1 in the cycle after a completed transfer property enable_drop failed at edge 19: PENABLE still 1 after a completed transfer property setup failed at edge 19: PENABLE is 1 without a setup cycle before it e19-e19 WRITE 0x0018 data=0x00000055 strb=1111 waits=0 OKAY -- phase 3: stable, PADDR moves during a wait state RULE stable broken at edge 22: PADDR changed from 0x1c to 0x20 inside a transfer property stable failed at edge 22: PSEL, PADDR, PWRITE, PPROT or PSTRB changed inside a transfer e20-e23 WRITE 0x001c data=0x00000066 strb=1111 waits=2 OKAY -- phase 4: strb_read, strobes on a read property strb_read failed at edge 24: PSTRB is 1111 during a read RULE strb_read broken at edge 24: PSTRB is 1111 during a read property strb_read failed at edge 25: PSTRB is 1111 during a read RULE strb_read broken at edge 25: PSTRB is 1111 during a read e24-e25 READ 0x0100 data=0x00000044 waits=0 OKAY -- phase 5: setup_len, a setup phase that lasts two cycles RULE setup_len broken at edge 27: the setup cycle was not followed by an access cycle property setup_len failed at edge 27: the setup cycle was not followed by an access cycle -- phase 6: PSLVERR outside the completion cycle (a recommendation) note pslverr at edge 29: PSLVERR is 1 outside the completion cycle NOTE pslverr at edge 29: PSLVERR is 1 outside the completion cycle (ignored by the requester; recommended LOW) note pslverr at edge 30: PSLVERR is 1 outside the completion cycle NOTE pslverr at edge 30: PSLVERR is 1 outside the completion cycle (ignored by the requester; recommended LOW) e29-e31 WRITE 0x0104 data=0x00000001 strb=1111 waits=1 OKAY -- phase 7: timeout, a completer that never answers RULE timeout broken at edge 48: no PREADY after 16 wait cycles property timeout failed at edge 48: no PREADY within 16 access cycles e32-e53 READ 0x0100 no PREADY after 20 wait cycles, the bench gives up RULE stable broken at edge 54: PSEL dropped before PREADY property stable failed at edge 54: PENABLE dropped before PREADY property stable failed at edge 54: PSEL, PADDR, PWRITE, PPROT or PSTRB changed inside a transfer -- phase 8: reset, PSEL high while PRESETn is low property reset failed at edge 55: PSEL is 1 in reset RULE reset broken at edge 55: PSEL is 1 while PRESETn is low properties: 11 failure(s), 2 note(s) checker: 7 writes, 4 reads, 28 wait edges, 10 rules broken, 2 notes
Read included file: apb_periph.sv
`timescale 1ns/1ns
// apb_periph.sv: an APB4 completer (read-only). Offsets are paddr[11:0]:
//   0x000-0x0ff  RAM, 256 bytes, written lane by lane under PSTRB
//   0x100 CTRL   read/write, byte-strobed
//   0x104 STATUS read-only: the number of words in the FIFO
//   0x108 WCOUNT read-only: counts completed writes (a zero-strobe write counts too)
//   0x10c FIFO   a write pushes PWDATA, a read pops the oldest word (0 when empty)
//   0x110 ID     read-only, 0x41504234 ("APB4")
// With err_on (the default) the completer answers PSLVERR for an unmapped offset, an unaligned
// PADDR, a write to a read-only register and, with priv_ctrl, an unprivileged access to CTRL;
// a transfer that gets PSLVERR changes nothing here (IHI 0024E 3.4 allows either choice).
// Switches (set from the stimulus):
//   waits           wait states in every access (PREADY low for that many access cycles)
//   ready_in_setup  drive PREADY high outside the access phase (a don't-care: the spec allows it)
//   hang            never raise PREADY
//   err_on, priv_ctrl
// Broken twins:
//   act_every_cycle acts at every edge PSEL is high, not once at completion
//   late_rdata      PRDATA registered at completion, so it arrives one cycle late
//   err_early       PSLVERR during setup and wait cycles, low at completion
//   ignore_strobes  writes every lane of a RAM word or CTRL
//   ignore_psel     decodes PADDR and acts in access cycles without looking at its PSEL
module apb_periph #(parameter NAME = "p0") (
  input  logic        pclk,
  input  logic        presetn,
  input  logic        psel,
  input  logic        penable,
  input  logic        pwrite,
  input  logic [31:0] paddr,
  input  logic [31:0] pwdata,
  input  logic [3:0]  pstrb,
  input  logic [2:0]  pprot,
  output logic        pready,
  output logic [31:0] prdata,
  output logic        pslverr);
  int waits = 0;
  bit ready_in_setup = 0, hang = 0, err_on = 1, priv_ctrl = 0, trace = 0;
  bit act_every_cycle = 0, late_rdata = 0, err_early = 0, ignore_strobes = 0, ignore_psel = 0;

  logic [7:0]  mem [0:255];
  logic [31:0] ctrl = 0, wcount = 0;
  logic [31:0] fifo [0:15];
  int          fhead = 0, fcount = 0;
  int          wcnt = 0;                  // wait cycles spent in this access
  logic [31:0] prdata_q = 0;
  initial for (int i = 0; i < 256; i++) mem[i] = 0;

  localparam int RAM = 0, CTRL = 1, STATUS = 2, WCOUNT = 3, FIFO = 4, ID = 5, NONE = 6;
  function automatic int region(input logic [11:0] a);
    if (a < 12'h100) region = RAM;
    else if (a[11:2] == 10'h040) region = CTRL;
    else if (a[11:2] == 10'h041) region = STATUS;
    else if (a[11:2] == 10'h042) region = WCOUNT;
    else if (a[11:2] == 10'h043) region = FIFO;
    else if (a[11:2] == 10'h044) region = ID;
    else region = NONE;
  endfunction
  function automatic bit bad(input logic wr, input logic [11:0] a, input logic [2:0] prot);
    int r;
    r = region(a);
    bad = (r == NONE) || (a[1:0] != 0) || (wr && (r == STATUS || r == WCOUNT || r == ID))
          || (priv_ctrl && r == CTRL && !prot[0]);
  endfunction
  function automatic logic [31:0] value(input logic [11:0] a);
    case (region(a))
      RAM:     value = {mem[{a[7:2], 2'd3}], mem[{a[7:2], 2'd2}], mem[{a[7:2], 2'd1}], mem[{a[7:2], 2'd0}]};
      CTRL:    value = ctrl;
      STATUS:  value = fcount;
      WCOUNT:  value = wcount;
      FIFO:    value = fcount > 0 ? fifo[fhead] : 32'h0;
      ID:      value = 32'h41504234;
      default: value = 32'h0;
    endcase
  endfunction

  wire sel    = ignore_psel ? 1'b1 : (psel === 1'b1);
  wire access = sel && (penable === 1'b1);
  wire err    = err_on && bad(pwrite, paddr[11:0], pprot);
  assign pready  = hang ? 1'b0 : (!access ? (ready_in_setup ? 1'b1 : 1'b0) : (wcnt >= waits));
  wire done   = access && pready;
  assign pslverr = err_early ? (sel && !done && err) : (done && err);
  // The read value is taken at the setup edge (PSEL 1, PENABLE 0) and held on PRDATA until the next
  // read, the way most register blocks do it (the bus multiplexor decides who is heard);
  // late_rdata registers it at completion instead.
  logic [31:0] rdata_r = 0;
  always @(posedge pclk) if (sel && penable !== 1'b1 && pwrite === 1'b0) rdata_r <= value(paddr[11:0]);
  assign prdata  = late_rdata ? prdata_q : rdata_r;
  wire act    = act_every_cycle ? sel : done;

  always @(posedge pclk or negedge presetn) begin
    if (!presetn) begin
      ctrl <= 0; wcount <= 0; wcnt <= 0;
    end else begin
      wcnt <= (access && !pready) ? wcnt + 1 : 0;
      if (done && !pwrite) prdata_q <= value(paddr[11:0]);
      if (act && !err) begin
        if (pwrite) begin
          wcount <= wcount + 1;
          case (region(paddr[11:0]))
            RAM:  for (int l = 0; l < 4; l++) if (pstrb[l] || ignore_strobes) mem[{paddr[7:2], 2'b00} + l] <= pwdata[8*l +: 8];
            CTRL: for (int l = 0; l < 4; l++) if (pstrb[l] || ignore_strobes) ctrl[8*l +: 8] <= pwdata[8*l +: 8];
            FIFO: if (fcount < 16) begin fifo[(fhead + fcount) % 16] <= pwdata; fcount <= fcount + 1; end
            default: ;
          endcase
          if (trace) $display("  %0s e%0d: write 0x%03h <= 0x%08h strb=%b", NAME, ($time - 5) / 10, paddr[11:0], pwdata, pstrb);
        end else begin
          if (region(paddr[11:0]) == FIFO && fcount > 0) begin fhead <= (fhead + 1) % 16; fcount <= fcount - 1; end
          if (trace) $display("  %0s e%0d: read 0x%03h -> 0x%08h", NAME, ($time - 5) / 10, paddr[11:0], value(paddr[11:0]));
        end
      end else if (act && trace) $display("  %0s e%0d: error, %0s 0x%03h ignored", NAME, ($time - 5) / 10, pwrite ? "write" : "read", paddr[11:0]);
    end
  end
endmodule
Read included file: apb_checker.sv
`timescale 1ns/1ns
// apb_checker.sv: the rules of an APB interface, checked at every rising edge (read-only).
//   reset        PSEL is 1 while PRESETn is low;
//   known        PSEL is x, or a control signal, a strobed write byte or PREADY in access is x;
//   setup        PENABLE is 1 without a setup cycle (PSEL 1, PENABLE 0) before it;
//   setup_len    a setup cycle not followed by an access cycle;
//   stable       PSEL, PADDR, PWRITE, PPROT, PSTRB or the write data changed inside a transfer,
//                or PSEL or PENABLE dropped before PREADY;
//   enable_drop  PENABLE still 1 in the cycle after a completed transfer;
//   strb_read    PSTRB not zero during a read;
//   onehot       more than one PSELx at once;
//   timeout      no PREADY after TIMEOUT wait cycles (a liveness watchdog, not a protocol rule).
// A NOTE (not a broken rule): PSLVERR is 1 outside the completion cycle, which the spec allows
// and recommends against (IHI 0024E 3.4).
module apb_checker #(parameter int NSEL = 1, parameter int TIMEOUT = 16) (
  input logic            pclk,
  input logic            presetn,
  input logic [NSEL-1:0] psel,
  input logic            penable,
  input logic            pwrite,
  input logic [31:0]     paddr,
  input logic [31:0]     pwdata,
  input logic [3:0]      pstrb,
  input logic [2:0]      pprot,
  input logic            pready,
  input logic            pslverr);
  int broken = 0, notes = 0, writes = 0, reads = 0, waits = 0, edge_n = -1, stall = 0;
  logic            p_any = 0, p_en = 0, p_rdy = 0, p_wr = 0;
  logic [NSEL-1:0] p_sel = 0;
  logic [31:0]     p_addr = 0, p_wdata = 0;
  logic [3:0]      p_strb = 0;
  logic [2:0]      p_prot = 0;
  task automatic report(input string rule, input string msg);
    broken++;
    $display("RULE %0s broken at edge %0d: %0s", rule, edge_n, msg);
  endtask
  task automatic note(input string rule, input string msg);
    notes++;
    $display("NOTE %0s at edge %0d: %0s", rule, edge_n, msg);
  endtask
  always @(posedge pclk) begin
    logic any, en, rdy, bytes_x;
    edge_n = edge_n + 1;
    any = (|psel) === 1'b1; en = (penable === 1'b1); rdy = (pready === 1'b1);
    if (presetn !== 1'b1) begin
      if (any) report("reset", "PSEL is 1 while PRESETn is low");
      any = 0; en = 0;
    end else begin
      bytes_x = 0;
      for (int l = 0; l < 4; l++) if (pstrb[l] === 1'b1 && (^pwdata[8*l +: 8]) === 1'bx) bytes_x = 1;
      if ((^psel) === 1'bx) report("known", "PSEL is x");
      else if (any && (^{paddr, pwrite, penable, pstrb, pprot}) === 1'bx) report("known", "a control signal is x while PSEL is 1");
      else if (any && pwrite === 1'b1 && bytes_x) report("known", "a strobed byte of PWDATA is x");
      else if (any && en && pready === 1'bx) report("known", "PREADY is x in an access cycle");
      if (any && en && !(p_any && (!p_en || !p_rdy)))
        report("setup", "PENABLE is 1 without a setup cycle (PSEL 1, PENABLE 0) before it");
      if (p_any && !p_en && !(any && en))
        report("setup_len", "the setup cycle was not followed by an access cycle");
      if (p_any && (!p_en || !p_rdy) && any) begin
        if (psel !== p_sel) report("stable", $sformatf("PSEL changed from %b to %b inside a transfer", p_sel, psel));
        if (paddr !== p_addr) report("stable", $sformatf("PADDR changed from 0x%0h to 0x%0h inside a transfer", p_addr, paddr));
        if (pwrite !== p_wr) report("stable", "PWRITE changed inside a transfer");
        if (pprot !== p_prot) report("stable", "PPROT changed inside a transfer");
        if (pstrb !== p_strb) report("stable", "PSTRB changed inside a transfer");
        if (p_wr && pwdata !== p_wdata) report("stable", $sformatf("PWDATA changed from 0x%0h to 0x%0h inside a transfer", p_wdata, pwdata));
        if (p_en && !en) report("stable", "PENABLE dropped before PREADY");
      end
      if (p_any && p_en && !p_rdy && !any) report("stable", "PSEL dropped before PREADY");
      if (p_any && p_en && p_rdy && en) report("enable_drop", "PENABLE is still 1 in the cycle after a completed transfer");
      if (any && pwrite === 1'b0 && pstrb !== 4'b0000) report("strb_read", $sformatf("PSTRB is %b during a read", pstrb));
      if ($countones(psel) > 1) report("onehot", $sformatf("PSELx is %b: two completers selected", psel));
      if (any && en && rdy) begin
        if (pwrite === 1'b1) writes++; else reads++;
        stall = 0;
      end else if (any && en) begin
        waits++; stall++;
        if (stall == TIMEOUT) report("timeout", $sformatf("no PREADY after %0d wait cycles", TIMEOUT));
      end
      if (pslverr === 1'b1 && !(any && en && rdy))
        note("pslverr", "PSLVERR is 1 outside the completion cycle (ignored by the requester; recommended LOW)");
    end
    p_any = any; p_en = en; p_rdy = rdy; p_sel = psel; p_addr = paddr; p_wr = (pwrite === 1'b1);
    p_wdata = pwdata; p_strb = pstrb; p_prot = pprot;
  end
  final $display("checker: %0d writes, %0d reads, %0d wait edges, %0d rules broken, %0d notes", writes, reads, waits, broken, notes);
endmodule
Read included file: apb_tasks.svh
// apb_tasks.svh: the requester side of the bench, as tasks for the stimulus (read-only).
// apb_write(addr, data, strb, prot) and apb_read(addr, prot) run one transfer and leave the bus
// IDLE; q_write / q_read queue transfers and apb_run() sends the queue back to back with PSEL held
// high (ACCESS goes straight to SETUP). Each transfer prints one log line at its completion edge:
//   e3-e4 WRITE 0x0100 data=0x11223344 strb=1111 waits=0 OKAY
// (setup edge - completion edge). The result of a read is in prdata_q and pslverr_q; the results
// of a queue are in rdv[i] and errv[i]. `cycle` counts rising edges from 0.
int cycle = -1;
always @(posedge pclk) cycle = cycle + 1;
bit quiet = 0;
// Requester bugs, for the broken twins:
bit move_in_wait = 0;   // PADDR moves by 4 in the first wait cycle
bit enable_stuck = 0;   // PENABLE stays 1 into the next queued transfer (no setup cycle)
bit strb_on_read = 0;   // PSTRB = 1111 on reads
bit skip_setup   = 0;   // PSEL and PENABLE rise together
int give_up      = 24;  // the bench stops waiting after this many wait cycles

logic [31:0] prdata_q = 0;
logic        pslverr_q = 0;
int          waits_q = 0;
bit          stuck_now = 0;
bit          q_wr [0:63];
logic [31:0] q_addr [0:63], q_data [0:63], rdv [0:63];
logic [3:0]  q_strb [0:63];
logic [2:0]  q_prot [0:63];
bit          errv [0:63];
int          qn = 0;

// A text waveform for the trace blocks: one line per rising edge with the values sampled there.
bit watch = 0;
int wave_edge = -1;
always @(posedge pclk) begin
  wave_edge = wave_edge + 1;
`ifdef APB_DECODER
  if (watch) $display("wave e%0d: psel=%b pselx=%b penable=%b pwrite=%b paddr=%h pwdata=%h pstrb=%b pready=%b prdata=%h pslverr=%b",
                      wave_edge, psel, pselx, penable, pwrite, paddr, pwdata, pstrb, pready, prdata, pslverr);
`else
  if (watch) $display("wave e%0d: psel=%b penable=%b pwrite=%b paddr=%h pwdata=%h pstrb=%b pready=%b prdata=%h pslverr=%b",
                      wave_edge, psel, penable, pwrite, paddr, pwdata, pstrb, pready, prdata, pslverr);
`endif
end

// One transfer: SETUP for one cycle, then ACCESS until PREADY. last = 0 keeps PSEL high for the
// next transfer.
task automatic apb_xfer(input bit wr, input logic [31:0] addr, input logic [31:0] data, input logic [3:0] strb,
                        input logic [2:0] prot, input bit last);
  int s_edge, w;
  paddr = addr; pwrite = wr; pwdata = wr ? data : 32'h0;
  pstrb = wr ? strb : (strb_on_read ? 4'hf : 4'h0);
  pprot = prot; psel = 1;
  s_edge = cycle + 1;
  if (skip_setup || stuck_now) penable = 1;
  else begin
    penable = 0;
    @(posedge pclk); #1 penable = 1;
  end
  stuck_now = 0;
  w = 0;
  @(posedge pclk);
  while (pready !== 1'b1 && w < give_up) begin
    w++;
    #1 if (move_in_wait && w == 1) paddr = addr + 4;
    @(posedge pclk);
  end
  prdata_q = prdata; pslverr_q = (pslverr === 1'b1); waits_q = w;
  #1;
  if (!quiet) begin
    if (w >= give_up) $display("e%0d-e%0d %0s 0x%04h no PREADY after %0d wait cycles, the bench gives up", s_edge, cycle, wr ? "WRITE" : "READ ", addr[15:0], w);
    else if (wr) $display("e%0d-e%0d WRITE 0x%04h data=0x%08h strb=%b waits=%0d %0s", s_edge, cycle, addr[15:0], data, strb, w, pslverr_q ? "PSLVERR" : "OKAY");
    else $display("e%0d-e%0d READ  0x%04h data=0x%08h waits=%0d %0s", s_edge, cycle, addr[15:0], prdata_q, w, pslverr_q ? "PSLVERR" : "OKAY");
  end
  if (last) begin
    psel = 0; penable = 0; pwrite = 0; pstrb = 0;
  end else begin
    penable = enable_stuck ? 1 : 0;
    stuck_now = enable_stuck;
  end
endtask

task automatic apb_write(input logic [31:0] addr, input logic [31:0] data, input logic [3:0] strb = 4'hf, input logic [2:0] prot = 3'b000);
  apb_xfer(1, addr, data, strb, prot, 1);
endtask
task automatic apb_read(input logic [31:0] addr, input logic [2:0] prot = 3'b000);
  apb_xfer(0, addr, 0, 0, prot, 1);
endtask

// The queue: transfers sent back to back by apb_run().
task automatic q_write(input logic [31:0] addr, input logic [31:0] data, input logic [3:0] strb = 4'hf, input logic [2:0] prot = 3'b000);
  q_wr[qn] = 1; q_addr[qn] = addr; q_data[qn] = data; q_strb[qn] = strb; q_prot[qn] = prot; qn++;
endtask
task automatic q_read(input logic [31:0] addr, input logic [2:0] prot = 3'b000);
  q_wr[qn] = 0; q_addr[qn] = addr; q_data[qn] = 0; q_strb[qn] = 0; q_prot[qn] = prot; qn++;
endtask
task automatic apb_run();
  for (int i = 0; i < qn; i++) begin
    apb_xfer(q_wr[i], q_addr[i], q_data[i], q_strb[i], q_prot[i], i == qn - 1);
    rdv[i] = prdata_q; errv[i] = pslverr_q;
  end
  qn = 0;
endtask

task automatic idle(input int n);
  repeat (n) @(posedge pclk);
  #1;
endtask

// The completer's RAM, by bus address (the decoder bench picks the completer from bits 13:12).
function automatic logic [7:0] ram_byte(input int unsigned a);
`ifdef APB_DECODER
  case (a[13:12]) 2'd1: ram_byte = p1.mem[a[7:0]]; 2'd2: ram_byte = p2.mem[a[7:0]]; default: ram_byte = p0.mem[a[7:0]]; endcase
`else
  ram_byte = p0.mem[a[7:0]];
`endif
endfunction
// dump(addr, n): n bytes from addr, one 32-bit word per group, lane 0 (the lowest address) first.
task automatic dump(input int unsigned addr, input int n);
  string s;
  for (int a = addr & ~3; a < addr + n; a += 4) begin
    if ((a - (addr & ~3)) % 16 == 0) begin
      if (a != (addr & ~3)) $display("%0s", s);
      s = $sformatf("0x%04h  ", a[15:0]);
    end
    s = {s, $sformatf(" %h %h %h %h ", ram_byte(a), ram_byte(a + 1), ram_byte(a + 2), ram_byte(a + 3))};
  end
  $display("%0s", s);
endtask
// fifo_fill(n, first): put n words first, first+1, ... in completer 0's FIFO before the run.
task automatic fifo_fill(input int n, input logic [31:0] first);
  for (int i = 0; i < n; i++) p0.fifo[(p0.fhead + p0.fcount + i) % 16] = first + i;
  p0.fcount = p0.fcount + n;
endtask
// regs(): completer 0's registers as one line.
task automatic regs();
  $display("regs: CTRL=0x%08h STATUS=%0d WCOUNT=%0d", p0.ctrl, p0.fcount, p0.wcount);
endtask
Read included file: apb_props.sv
`timescale 1ns/1ps
// apb_props.sv: the rules of an APB interface as properties (read-only in this lesson).
// One spec sentence, one property, named like the rule of apb_checker.sv it mirrors. NSEL is the
// number of select lines the copy watches (1 inside a completer, 3 on a decoded bus). A property
// reports with $display, so a failure never stops the run; the PSLVERR sentence is a
// recommendation, so its copy counts notes, not failures.
module apb_props #(parameter int NSEL = 1, parameter int TIMEOUT = 16) (
  input logic            pclk,
  input logic            presetn,
  input logic [NSEL-1:0] psel,
  input logic            penable,
  input logic            pwrite,
  input logic [31:0]     paddr,
  input logic [31:0]     pwdata,
  input logic [3:0]      pstrb,
  input logic [2:0]      pprot,
  input logic            pready,
  input logic            pslverr
);
  int edge_n = -1, fails = 0, notes = 0;
  always @(posedge pclk) edge_n++;
  wire sel = (psel != 0);

  // Sentence: PSEL is low while PRESETn is low (no transfer during reset).
  reset_sel: assert property (@(posedge pclk) !presetn |-> !sel)
    else begin fails = fails + 1; $display("property reset failed at edge %0d: PSEL is 1 in reset", edge_n); end
  // Sentence: an access cycle (PSEL and PENABLE high) follows a setup cycle or a waited access cycle.
  setup: assert property (@(posedge pclk) disable iff (!presetn) sel && penable |-> $past(sel && (!penable || !pready)))
    else begin fails = fails + 1; $display("property setup failed at edge %0d: PENABLE is 1 without a setup cycle before it", edge_n); end
  // Sentence: the setup phase lasts one cycle; the next cycle is the access phase.
  setup_len: assert property (@(posedge pclk) disable iff (!presetn) sel && !penable |=> sel && penable)
    else begin fails = fails + 1; $display("property setup_len failed at edge %0d: the setup cycle was not followed by an access cycle", edge_n); end
  // Sentence: from setup until PREADY, the select, address, direction, protection, strobes and the
  // write data stay unchanged, and PENABLE stays high once it has risen.
  stable_ctl: assert property (@(posedge pclk) disable iff (!presetn) sel && (!penable || !pready) |=> $stable({psel, paddr, pwrite, pprot, pstrb}))
    else begin fails = fails + 1; $display("property stable failed at edge %0d: PSEL, PADDR, PWRITE, PPROT or PSTRB changed inside a transfer", edge_n); end
  stable_data: assert property (@(posedge pclk) disable iff (!presetn) sel && pwrite && (!penable || !pready) |=> $stable(pwdata))
    else begin fails = fails + 1; $display("property stable failed at edge %0d: PWDATA changed inside a write", edge_n); end
  stable_en: assert property (@(posedge pclk) disable iff (!presetn) sel && penable && !pready |=> penable)
    else begin fails = fails + 1; $display("property stable failed at edge %0d: PENABLE dropped before PREADY", edge_n); end
  // Sentence: at the end of a transfer PENABLE goes low.
  enable_drop: assert property (@(posedge pclk) disable iff (!presetn) sel && penable && pready |=> !penable)
    else begin fails = fails + 1; $display("property enable_drop failed at edge %0d: PENABLE still 1 after a completed transfer", edge_n); end
  // Sentence: the requester drives every PSTRB bit low during a read.
  strb_read: assert property (@(posedge pclk) disable iff (!presetn) sel && !pwrite |-> pstrb == 0)
    else begin fails = fails + 1; $display("property strb_read failed at edge %0d: PSTRB is %b during a read", edge_n, pstrb); end
  // Sentence: one PSELx at a time (a decoded bus).
  onehot: assert property (@(posedge pclk) disable iff (!presetn) $onehot0(psel))
    else begin fails = fails + 1; $display("property onehot failed at edge %0d: PSELx is %b", edge_n, psel); end
  // A watchdog, not a spec sentence: PREADY within TIMEOUT access cycles of a setup.
  timeout: assert property (@(posedge pclk) disable iff (!presetn) sel && !penable |=> ##[0:TIMEOUT-1] pready)
    else begin fails = fails + 1; $display("property timeout failed at edge %0d: no PREADY within %0d access cycles", edge_n, TIMEOUT); end
  // Recommendation: PSLVERR is driven low outside the completion cycle. A note, not a failure.
  pslverr_low: assert property (@(posedge pclk) disable iff (!presetn) pslverr |-> sel && penable && pready)
    else begin notes = notes + 1; $display("note pslverr at edge %0d: PSLVERR is 1 outside the completion cycle", edge_n); end

  final $display("properties: %0d failure(s), %0d note(s)", fails, notes);
endmodule

Check yourself

Predict the outputThree transfers: A with no wait, B with two wait states and PSEL kept high from A, C with PSEL and PENABLE rising together. The counter counts access cycles, which are the attempts of the property. What line is printed?
module tb;
  logic clk = 0;
  always #5 clk = ~clk;                 // rising edges at 5, 15, 25, ...
  logic psel = 0, penable = 0, pready = 0;
  int accesses = 0, failed = 0;
  always @(posedge clk) if (psel && penable) accesses++;   // every access cycle is an attempt
  setup: assert property (@(posedge clk) psel && penable |-> $past(psel && (!penable || !pready))) else failed = failed + 1;
  initial begin
    @(negedge clk); psel = 1;                               // transfer A: setup at 15
    @(negedge clk); penable = 1; pready = 1;                // access at 25, no wait
    @(negedge clk); penable = 0; pready = 0;                // transfer B: setup at 35, PSEL still high
    @(negedge clk); penable = 1;                            // access at 45, a wait
    @(negedge clk);                                         // access at 55, a wait
    @(negedge clk); pready = 1;                             // access at 65 completes
    @(negedge clk); psel = 0; penable = 0; pready = 0;      // idle at 75
    @(negedge clk); psel = 1; penable = 1; pready = 1;      // transfer C at 85: no setup
    @(negedge clk); psel = 0; penable = 0; pready = 0;
    @(negedge clk);
    $display("accesses=%0d failed=%0d", accesses, failed);
    $finish;
  end
endmodule
Spot the bugA write and a read back to back with PSEL kept high between them, the read with one wait state. The bus is correct. Which property reports a failure on it?
ConceptIn phase 6 the completer drives PSLVERR high in the setup and wait cycles and low in the completion cycle. Why do the property and the checker print a note and not a failure?
ConceptWhy is the timeout rule a watchdog with a bench-chosen limit and not a rule of the APB specification?
Read the waveformTwo writes are queued with PENABLE stuck high between them. At which cycle do the properties setup and enable_drop both fail?
PENABLE held between two transfers
0123pselpenablepaddr10018pready

Edges 17 to 20 of the tour, one value per cycle, sampled at the edge that ends the cycle.

Remember

  • APB's checker is short because APB is: one transfer at a time, no IDs, no bursts; the properties only need the previous cycle, through $past and $stable.
  • An access follows a setup or a waited access; setup lasts one cycle; everything the requester drives holds until PREADY; PENABLE drops after completion; PSTRB is zero on reads.
  • PREADY in setup and PSLVERR outside completion are not rules: one is a don't-care, the other a recommendation. A checker that enforces them rejects legal designs.
  • A timeout is a watchdog with the system's limit, because APB allows any number of wait states.
  • Faults that keep the handshake legal (acting twice, late read data, ignored strobes) show only in the data. Bind the checker inside each completer and keep a register model beside it.