A driver writes the DMA CTRL register and the write reaches the register five system clocks after the bridge accepts it, not one. The NanoSoC axi2apb bridge returns the write response (B) only after the APB access has completed (it is non-posted), so the instruction after the store does not run for about 125 ns for a clk_sys register and 281.25 ns for a clk_per register, the same path lengths as the reads. A reader who believes the fabric is a wire will be surprised that a store costs that much; a reader who knows the bridge is non-posted can rely on it: when the store retires, the register has the value. This lesson traces one write and one read through the bridges between the CPU and a peripheral register, so every later latency number has a visible cause.
The axi2apb bridge accepts one AXI beat, runs a normal APB access, then answers the AXI side. For a write it returns B only after the APB access completes, so software that waits for the store waits for the register. A normal access with the DMA-style slave takes three peripheral clocks: setup with psel high, one access cycle with penable high and pready low, and a second access cycle with pready high. The bridge adds one clock to accept the AXI beat and one to present the response, so a register on the 96 MHz APB segment costs five system clocks per 4-byte access: 4 bytes times 96 MHz divided by 5 gives 76.8 MB/s sustained, against 128.0 MB/s for the bus alone. The apb_cdc then carries the five slow peripherals to 24 MHz with a request and acknowledge handshake: two peripheral clocks to synchronise the request, two for the access, two system clocks to synchronise the acknowledge back, and two system clocks for the bridge to accept and respond: 208.33 ns in total, which is 4 bytes per 208.33 ns, 19.2 MB/s. Without the bridge's own accept and respond clocks the same path would take 187.5 ns.
Two rules complete the contract. APB3 has no PSTRB and the DMA accepts only full 32-bit writes, so the bridge answers an AXI write whose WSTRB is not 0xF with SLVERR and does not touch the register (the alternative, promoting a byte write to a 32-bit write, would corrupt the neighbouring bytes of a register that cannot be read-modified safely). An AXI burst longer than one beat is split into one APB access per beat with the address stepped by four, and the bridge returns one B after the last beat or one R beat per access; INCR bursts up to 16 beats are possible on S3, and the cost is 5 clk_sys per beat on the fast segment. These two rules are NanoSoC choices stated here, because the protocol leaves them to the bridge designer.
The waveform below is simulation output for the wave source of this topic: one write and one read on the APB segment, each with one wait state. One slot is one pclk cycle.
How to read it: psel rises with pwrite high for the write setup while penable stays low; penable rises next cycle with pready still low for the wait cycle; pready rises and the write completes, then all three fall for one idle cycle. The same shape repeats with pwrite low for the read. pwrite stays high in the idle slot between the write and the read, which is not a glitch: nothing samples it while psel is low, and it changes with the next setup. pslverr stays low throughout because both accesses hit a mapped offset; an unmapped offset would raise it in the completing cycle instead of data. The waveform is the APB side only: the bridge's accept and respond clocks (the five clocks of the text) are not drawn on it.
NanoSoC has no AHB layer: the AXI4 fabric reaches APB directly. A chain AXI to AHB to APB, common where an IP block only speaks AHB or where an older peripheral subsystem is reused, adds a second protocol conversion. Each conversion adds its own accept and respond clocks (illustrative: two more clk_sys, which would take the 125 ns fast-segment read to 145.8 ns), its own burst handling (AHB bursts and HREADY wait states instead of AXI beats), and one more place where an error response can be dropped. The review rule is the same as for any bridge: state the clocks per hop, whether writes are posted, and how the error response travels. The protocol itself is in the AHB lesson of the protocol course.
The skeleton below is the APB half of the bridge: it has no AXI side (no ID capture for BID and RID, no WLAST handling), so the AXI half only has to hand it one request at a time. It holds the whole APB contract in one module: accept one request, run setup then access, hold penable through wait states, answer exactly once, and give up after a timeout. A bridge that assumes zero-wait access or drops the error bit is a classic APB integration failure, and this shape is its fix. The testbench below the module drives a one-wait-state slave, an unmapped offset and a dead slave, and fails if the wait state is not held, if pslverr does not reach err, or if the watchdog does not end the dead access (the testbench uses a 16-cycle timeout so the run is short).
// NanoSoC teaching values: the APB half of the bridge, one request at a time.
// Holds penable until pready, propagates pslverr as err, answers once, and
// gives up after TIMEOUT clk_sys cycles (the watchdog) with err set.
module nano_axi2apb_skel #(
parameter int TIMEOUT = 256
) (
input logic aclk,
input logic aresetn,
input logic req,
input logic wr,
input logic [11:0] addr,
input logic [31:0] wdata,
output logic ack,
output logic [31:0] rdata,
output logic err,
output logic psel,
output logic penable,
output logic pwrite,
output logic [11:0] paddr,
output logic [31:0] pwdata,
input logic [31:0] prdata,
input logic pready,
input logic pslverr
);
typedef enum logic [1:0] {S_IDLE, S_SETUP, S_ACCESS, S_RESP} state_t;
state_t st;
logic [8:0] wd; // watchdog counter: counts access cycles, reset on every request
always_ff @(posedge aclk or negedge aresetn) begin
if (!aresetn) begin
st <= S_IDLE;
rdata <= 32'h0;
err <= 1'b0;
psel <= 1'b0;
penable <= 1'b0;
pwrite <= 1'b0;
paddr <= 12'h0;
pwdata <= 32'h0;
wd <= 9'd0;
end else case (st)
S_IDLE: if (req) begin
psel <= 1'b1;
pwrite <= wr;
paddr <= addr;
pwdata <= wdata;
wd <= 9'd0;
st <= S_SETUP;
end
S_SETUP: begin
penable <= 1'b1;
st <= S_ACCESS;
end
S_ACCESS: begin
wd <= wd + 9'd1;
if (pready) begin
penable <= 1'b0;
psel <= 1'b0;
rdata <= prdata;
err <= pslverr;
st <= S_RESP;
end else if (wd == TIMEOUT - 1) begin
// watchdog: the slave never answered (for example its clock is gated)
penable <= 1'b0;
psel <= 1'b0;
rdata <= 32'h0;
err <= 1'b1;
st <= S_RESP;
end
end
S_RESP: st <= S_IDLE;
default: st <= S_IDLE;
endcase
end
// ack is a single-cycle pulse in the response state.
always_ff @(posedge aclk or negedge aresetn) begin
if (!aresetn) ack <= 1'b0;
else ack <= (st == S_RESP);
end
endmodule
// ---- self-checking testbench: one wait state, an error offset, a dead slave ----
module tb;
logic aclk = 0, aresetn = 0;
always #5 aclk = ~aclk;
logic req = 0, wr = 0;
logic [11:0] addr = 0;
logic [31:0] wdata = 0;
wire ack, err, psel, penable, pwrite;
wire [31:0] rdata, pwdata;
wire [11:0] paddr;
logic dead = 0, waited = 0;
wire pready = psel && penable && waited && !dead;
wire pslverr = pready && (paddr == 12'h014); // unmapped offset
wire [31:0] prdata = {20'hCAFE0, paddr};
nano_axi2apb_skel #(.TIMEOUT(16)) dut (.aclk(aclk), .aresetn(aresetn), .req(req), .wr(wr),
.addr(addr), .wdata(wdata), .ack(ack), .rdata(rdata), .err(err), .psel(psel),
.penable(penable), .pwrite(pwrite), .paddr(paddr), .pwdata(pwdata),
.prdata(prdata), .pready(pready), .pslverr(pslverr));
always_ff @(posedge aclk) waited <= psel && penable && !waited;
int errors = 0;
task automatic access(input bit w, input [11:0] a, input [31:0] d, output int cycles);
@(negedge aclk); req = 1; wr = w; addr = a; wdata = d;
@(negedge aclk); req = 0;
cycles = 1;
while (!ack && cycles < 100) begin @(negedge aclk); cycles++; end
endtask
int c;
initial begin
repeat (3) @(negedge aclk); aresetn = 1;
access(1, 12'h004, 32'h1234, c); // write, one wait state
if (err || pwdata !== 32'h1234 || c < 5) begin errors++; $display("FAIL write err=%0d c=%0d", err, c); end
access(0, 12'h008, 0, c); // read
if (err || rdata !== 32'hCAFE0008) begin errors++; $display("FAIL read %h", rdata); end
access(0, 12'h014, 0, c); // unmapped: pslverr must reach err
if (!err) begin errors++; $display("FAIL error not propagated"); end
dead = 1;
access(0, 12'h008, 0, c); // slave never answers: watchdog
if (!err || rdata !== 0 || c > 16 + 6) begin errors++; $display("FAIL watchdog err=%0d c=%0d", err, c); end
if (errors == 0) $display("PASS: wait state held, pslverr propagated, watchdog ends a dead access");
$finish;
end
initial #20000 begin $display("FAIL timeout"); $finish; end
endmoduleThe bridge specification states the clocks-per-access for every path, including the accept and respond cycles, not just the bus cycles.
Wait-state handling is explicit: penable held until pready, with a watchdog (256 clk_sys cycles on NanoSoC) that is longer than the slowest documented slave access, 20 clk_sys at the bridge.
The error path is drawn end to end: PSLVERR from the APB slave becomes SLVERR on AXI, never silence. The bridge states what it does with partial strobes and bursts, and whether B is posted or not.
Randomise wait states in the APB slaves during integration simulation: a bridge that passes with zero-wait models and fails with one wait state ignores PREADY. Force PSLVERR on an unmapped offset and confirm SLVERR arrives on AXI with the right beat; a dropped error here corrupts silently. Then confirm the five-clock and 208.33 ns figures with a timestamped back-to-back access pair.
A bridge that returns B early (a posted write) lets the read overtake the write, or the read came from a path that did not wait for B. Return B only after PREADY, as the NanoSoC bridge does, or read the register back.