Capstone: an APB peripheral with a register map
Build a GPIO-and-timer peripheral on APB from a register map written once. Decode, read mux, write enables and status bits are all named from the same table, with a write-1-to-clear status and an interrupt line.
- Lesson
- R8.1
- Time
- 40 min
- Runs on
- Your browser
- Check
- 5 questions
A GPIO-and-timer block had its register map in a spreadsheet and its RTL written from memory. Two status bits were swapped between them. The driver enabled the timer interrupt at bit 0, as the map said, and the interrupt fired when pin 0 rose instead.
| Event (IRQEN = TMR only) | The map says | Shipped RTL | RTL that names the map |
|---|---|---|---|
| Pin 0 rises | STATUS 0x02, irq stays 0 | STATUS 0x01, irq rises at edge 12 | STATUS 0x02, irq stays 0 |
| The timer expires | STATUS 0x01, irq rises | STATUS 0x02, irq stays 0 | STATUS 0x01, irq rises at edge 24 |
| Write 0x01 to STATUS | TMR clears, irq falls | nothing clears: STATUS still 0x02 | STATUS 0x00, irq falls at edge 29 |
Measured on the lab bench. The protocol checker reports 0 rules broken for both designs: every wire was legal.
One table, read four ways
A peripheral is a function (pins, a timer) behind a register map: a table of addresses, fields and access types that software reads and writes. The map is the agreement between the hardware, the driver and the testbench. Write it once, then derive everything from it.
| Offset | Register | Access | Fields | Reset |
|---|---|---|---|---|
| 0x00 | DIR | read/write | [7:0] 1 drives the pin from OUT | 0x00 |
| 0x04 | OUT | read/write | [7:0] the value driven | 0x00 |
| 0x08 | IN | read-only | [7:0] the pins, through a two-flop synchronizer | pins |
| 0x0c | CTRL | read/write | [0] EN: the timer runs | 0 |
| 0x10 | PERIOD | read/write | [7:0] cycles from one timer event to the next | 0x00 |
| 0x14 | STATUS | write-1-to-clear | [0] TMR timer expired, [1] PIN pin 0 rose | 0 |
| 0x18 | IRQEN | read/write | [0] TMR, [1] PIN: which STATUS bits drive irq | 0 |
The brief's register map. Fields sit in byte 0 and change only when PSTRB[0] is 1.
In the RTL the table becomes constants: one localparam per address and one per bit position. Then each part of the completer reads a row of it.
localparam logic [7:0] A_DIR = 8'h00, A_OUT = 8'h04, A_IN = 8'h08, A_CTRL = 8'h0c,
A_PERIOD = 8'h10, A_STATUS = 8'h14, A_IRQEN = 8'h18;
localparam int TMR = 0, PIN = 1; // bit positions in STATUS and IRQEN
wire known = (a == A_DIR) || (a == A_OUT) || /* ... one term per row */ (a == A_IRQEN);
if (wr && a == A_PERIOD) period_q <= pwdata[7:0]; // a write enable per writable row
case (a) A_STATUS: prdata[1:0] = status_q; /* ... a read arm per row */ endcase
if (tmr_ev) status_d[TMR] = 1'b1; // an event sets its named bit- Program
A write to PERIOD completes; its write enable, named from the map, loads the register. A write to CTRL starts the timer.
Follow the calls, values and waits
- data write PERIOD: APB completer → DIR, OUT, CTRL, PERIOD, IRQEN
- data write PERIOD: DIR, OUT, CTRL, PERIOD, IRQEN → Timer
- Event
At the last cycle of a period the timer raises its event. The event sets
status_d[TMR], the bit the map calls TMR.Follow the calls, values and waits
- data timer event: Timer → STATUS
- Interrupt
When an enabled STATUS bit is 1, irq rises one edge later. It stays high until software writes 1 to that bit.
Follow the calls, values and waits
- data timer event: STATUS → irq
Read the diagram as text
- APB completer: decode from the map; act at completion
- DIR, OUT, CTRL, PERIOD, IRQEN: read/write rows
- GPIO pins: sync2 on the inputs; pin 0 rise
- Timer: counts to PERIOD (R2.7)
- STATUS: write-1-to-clear; events set bits
- irq: registered OR of STATUS & IRQEN
- APB completer → DIR, OUT, CTRL, PERIOD, IRQEN: write enables
- DIR, OUT, CTRL, PERIOD, IRQEN → GPIO pins: DIR, OUT
- DIR, OUT, CTRL, PERIOD, IRQEN → Timer: EN, PERIOD
- GPIO pins → STATUS: PIN event
- Timer → STATUS: TMR event
- STATUS → irq: enabled bits
The completer of R3.5 in front, the timer of R2.7 and the pins of R3.5.1 behind.
An access type is one line of RTL
| Access type | A write does | The line |
|---|---|---|
| read/write | stores the written bits | if (wr && a == A_OUT) out_q <= pwdata[7:0]; |
| read-only | nothing, or an error: the brief decides | bad = ... || (pwrite && a == A_IN); |
| write-1-to-clear | clears each bit written 1, leaves bits written 0 | status_d = status_d & ~pwdata[1:0]; |
| set by hardware | nothing: an event sets the bit | if (tmr_ev) status_d[TMR] = 1'b1; |
Write-1-to-clear (W1C) lets software clear one status bit without a read first, so it never clears an event it has not seen.
The order of the two STATUS lines matters. The clear comes first and the events second, so an event at the same edge as its clear wins. Measured with the timer firing every cycle, a clear of TMR leaves it at 1. Losing an event is worse than seeing it twice.
A write to the read-only IN register needs a decision. The APB specification lets a completer refuse a transfer with PSLVERR or support no errors at all. This brief refuses: a driver that writes IN has a bug, and an error shows it at once.
tmr_ev is the timer's event; w1c marks the ACCESS cycle of the write of 0x01 to STATUS. Values in the cycle after edges 21 to 29.- Cycle 1
After edge 22 the counter is in the last cycle of its period, so
tmr_evis 1. - Cycle 2
Edge 23 sets STATUS bit 0, TMR. The interrupt register has not seen it yet.
- Cycle 3
Edge 24:
irqrises, one edge after STATUS, because it is the registered OR of STATUS and IRQEN. - Cycle 6
The write of 0x01 to STATUS is in its ACCESS cycle. Edge 28 is its completion edge.
- Cycle 7
Edge 28 clears TMR.
irqis still 1: it follows STATUS one edge later. - Cycle 8
Edge 29:
irqfalls. The line stayed high from the event until software cleared its bit.
Measured on the lab bench with the corrected design. The log prints e24 irq=1 and e29 irq=0.
The interrupt is a level: it stays high while any enabled bit is set. A flop on it keeps glitches off a wire that leaves the block, at the cost of one cycle.
The lab: derive it from the table
| Port | Direction | Width | Meaning |
|---|---|---|---|
| pclk | input | 1 | APB clock |
| presetn | input | 1 | APB reset, asynchronous, active-low |
| psel | input | 1 | APB select |
| penable | input | 1 | APB access phase |
| pwrite | input | 1 | APB write |
| paddr | input | [31:0] | APB address |
| pwdata | input | [31:0] | APB write data |
| pstrb | input | [3:0] | APB write strobes |
| pprot | input | [2:0] | APB protection |
| pready | output | 1 | APB ready |
| prdata | output | [31:0] | APB read data |
| pslverr | output | 1 | a write to IN or any unmapped address: PSLVERR, no change |
| gpio_in | input | [7:0] | the pins, asynchronous to pclk |
| gpio_out | output | [7:0] | the pin values driven out |
| gpio_oe | output | [7:0] | the pin output enables |
| irq | output | 1 | registered: 1 while an enabled STATUS bit is 1 |
- Reset
- PRESETn asynchronous, active-low; every register 0
- Latency
- no wait states; every effect at the completion edge;
irqone edge after STATUS - Parameters
- Registers: the map above; fields in byte 0, written only when PSTRB[0] is 1. Timer: while EN is 1, an event every PERIOD cycles (0 and 1: every cycle)
- Corner cases
- an event and its clear at the same edge (the event wins); a write of 0 to STATUS; PSTRB[0] at 0
The bench drives the pads so a driven pin reads back on IN. It enables TMR only, makes pin 0 rise, runs the timer for one event and clears STATUS. Run the shipped design: irq rises at edge 12 for the pin and never for the timer.
Bits typed from memory
if (tmr_ev) status_d[1] = 1'b1;
if (pin_ev) status_d[0] = 1'b1;
The timer sets PIN and the pin sets TMR. Lint: TMR and PIN are unused.
Bits named from the map
if (tmr_ev) status_d[TMR] = 1'b1;
if (pin_ev) status_d[PIN] = 1'b1;
STATUS matches the map: irq rises at edge 24 and falls at edge 29.
Press Lint before you read the code. Verilator 5.052 reports UNUSEDPARAM for TMR and PIN: the map's names exist and nothing uses them. Replace the numbers by the names and run again. Synthesis does not change: 249 cells, 55 flops and 16 levels of logic on this flow, for both.
Asked in interviews
How do you implement write-one-to-clear?
Next status = status AND NOT the written data, so bits written 1 clear and bits written 0 stay. Then OR in the events of the same cycle, so an event that coincides with its clear is kept.
How do you keep a register map and the RTL consistent?
Write the map once and derive the rest: constants for addresses and bit positions, or code generated from a machine-readable map. The driver headers and the testbench model come from the same source.
How is an interrupt line built?
Status bits are set by events and cleared by software; an enable register masks them. The line is the OR of status AND enable, registered, and it stays high until every enabled bit is cleared.
Try it
IN reads 0xa5: pins 3 to 0 are driven with 0x5, pins 7 to 4 come from outside as 0xa. The pin event sets bit 0 and irq rises at edge 12, with only TMR enabled. The timer event sets bit 1, which no enable selects, and the clear of bit 0 leaves it set.
With the bits named, STATUS reads 0x02 after the pin and 0x01 after the timer. The log gains e24 irq=1 and e29 irq=0 and loses the two early irq lines; the last STATUS read is 0x00.
Synthesize the design to measure its cells and inspect the mapped gates.
e3-e4 WRITE 0x00000018 data=0x00000001 strb=1111 waits=0 OKAY e5-e6 WRITE 0x00000000 data=0x0000000f strb=1111 waits=0 OKAY e7-e8 WRITE 0x00000004 data=0x00000005 strb=1111 waits=0 OKAY e11-e12 READ 0x00000008 data=0x000000a5 waits=0 OKAY e12 irq=1 e13-e14 READ 0x00000014 data=0x00000001 waits=0 OKAY e15-e16 WRITE 0x00000014 data=0x00000003 strb=1111 waits=0 OKAY e17 irq=0 e17-e18 WRITE 0x00000010 data=0x00000003 strb=1111 waits=0 OKAY e19-e20 WRITE 0x0000000c data=0x00000001 strb=1111 waits=0 OKAY e23-e24 WRITE 0x0000000c data=0x00000000 strb=1111 waits=0 OKAY e25-e26 READ 0x00000014 data=0x00000002 waits=0 OKAY e27-e28 WRITE 0x00000014 data=0x00000001 strb=1111 waits=0 OKAY e29-e30 READ 0x00000014 data=0x00000002 waits=0 OKAY e31-e32 WRITE 0x00000008 data=0x000000ff strb=1111 waits=0 ERROR checker: 9 writes, 4 reads, 0 wait edges, 0 rules broken, 0 notesOriginal design · 249 cells · 55 flip-flops · 0 latches · 16 logic levels. Synthesis uses Yosys; counts depend on the toolchain.
Read included file: tb.sv
`timescale 1ns/1ps
module tb;
logic pclk = 0, presetn = 0;
always #5 pclk = ~pclk; // rising edges at 5, 15, 25, ...
logic psel = 0, penable = 0, pwrite = 0;
logic [31:0] paddr = 0, pwdata = 0;
logic [3:0] pstrb = 0;
logic [2:0] pprot = 0;
logic pready, pslverr;
logic [31:0] prdata;
logic [7:0] gpio_out, gpio_oe, ext = 8'ha0; // ext: what the outside world drives on the pins
logic irq;
wire [7:0] gpio_in = (gpio_oe & gpio_out) | (~gpio_oe & ext); // the pads: a driven pin reads back
logic irq_seen = 0;
always @(posedge pclk) begin #1; // one line whenever irq changes (unless quiet)
if (irq !== irq_seen && !quiet) $display("e%0d irq=%b", cycle, irq);
irq_seen = irq;
end
gpio_timer dut (.pclk(pclk), .presetn(presetn), .psel(psel), .penable(penable), .pwrite(pwrite), .paddr(paddr),
.pwdata(pwdata), .pstrb(pstrb), .pprot(pprot), .pready(pready), .prdata(prdata), .pslverr(pslverr), .gpio_in(gpio_in), .gpio_out(gpio_out), .gpio_oe(gpio_oe), .irq(irq));
apb_checker chk (.pclk(pclk), .presetn(presetn), .psel(psel), .penable(penable), .pwrite(pwrite), .paddr(paddr),
.pwdata(pwdata), .pstrb(pstrb), .pprot(pprot), .pready(pready), .pslverr(pslverr));
`include "rtl_apb_tasks.svh"
initial begin
repeat (2) @(posedge pclk); #1 presetn = 1; // reset released after edge 1
@(posedge pclk); #1; // the first transfer may start after edge 2
apb_write(32'h18, 32'h01); // IRQEN: TMR only (bit 0 in the map)
apb_write(32'h00, 32'h0f); // DIR: pins 3..0 are driven
apb_write(32'h04, 32'h05); // OUT: pin 0 rises
idle(2);
apb_read(32'h08); // IN
apb_read(32'h14); // STATUS
apb_write(32'h14, 32'h03); // clear both bits
apb_write(32'h10, 32'h03); // PERIOD: a timer event every 3 cycles
apb_write(32'h0c, 32'h01); // CTRL: the timer runs
idle(2);
apb_write(32'h0c, 32'h00); // CTRL: and stops
apb_read(32'h14); // STATUS
apb_write(32'h14, 32'h01); // clear TMR: write 1 to bit 0
apb_read(32'h14);
apb_write(32'h08, 32'hff); // IN is read-only: refused
repeat (3) @(posedge pclk);
$finish(0);
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: rtl_apb_tasks.svh
// rtl_apb_tasks.svh: the requester side of the bench (read-only).
// apb_write(addr, data, strb) and apb_read(addr) run one transfer: SETUP for one cycle, ACCESS until
// PREADY, then the bus goes idle. Each transfer prints one line at its completion edge:
// e3-e4 WRITE 0x00000000 data=0x0000005a strb=1111 waits=0 OKAY
// (setup edge - completion edge). A read leaves its data in prdata_q and its error in pslverr_q.
// quiet = 1 turns the log off; watch = 1 prints the bus at every edge.
// cycle is the number of the last rising edge; it updates after the edge's active events, so at an
// edge every reader (the tasks, the watch line) sees the same old value and names the edge cycle + 1.
int cycle = -1;
always @(posedge pclk) cycle <= cycle + 1;
bit quiet = 0, watch = 0;
logic [31:0] prdata_q = 0;
logic pslverr_q = 0;
int waits_q = 0, give_up = 24;
always @(posedge pclk) if (watch)
$display("wave e%0d: psel=%b penable=%b pwrite=%b paddr=%h pwdata=%h pstrb=%b pready=%b prdata=%h pslverr=%b",
cycle + 1, psel, penable, pwrite, paddr, pwdata, pstrb, pready, prdata, pslverr);
task automatic apb_xfer(input bit wr, input logic [31:0] addr, input logic [31:0] data, input logic [3:0] strb);
int s_edge, w;
paddr = addr; pwrite = wr; pwdata = wr ? data : 32'h0; pstrb = wr ? strb : 4'h0; pprot = 3'b000;
psel = 1; penable = 0; // SETUP
s_edge = cycle + 1;
@(posedge pclk); #1 penable = 1; // ACCESS
w = 0;
@(posedge pclk);
while (pready !== 1'b1 && w < give_up) begin w++; @(posedge pclk); end
prdata_q = prdata; pslverr_q = pslverr; waits_q = w;
if (!quiet) begin
if (wr && pslverr) $display("e%0d-e%0d WRITE 0x%08h data=0x%08h strb=%b waits=%0d ERROR", s_edge, cycle + 1, addr, data, strb, w);
else if (wr) $display("e%0d-e%0d WRITE 0x%08h data=0x%08h strb=%b waits=%0d OKAY", s_edge, cycle + 1, addr, data, strb, w);
else if (pslverr) $display("e%0d-e%0d READ 0x%08h data=0x%08h waits=%0d ERROR", s_edge, cycle + 1, addr, prdata, w);
else $display("e%0d-e%0d READ 0x%08h data=0x%08h waits=%0d OKAY", s_edge, cycle + 1, addr, prdata, w);
end
#1 psel = 0; penable = 0; pwrite = 0; pstrb = 0; pwdata = 0; // idle
endtask
task automatic apb_write(input logic [31:0] addr, input logic [31:0] data, input logic [3:0] strb = 4'hf);
apb_xfer(1, addr, data, strb);
endtask
task automatic apb_read(input logic [31:0] addr);
apb_xfer(0, addr, 32'h0, 4'h0);
endtask
task automatic idle(input int n);
repeat (n) @(posedge pclk); #1;
endtask
Check yourself
Remember
- Write the register map once and derive everything from it: localparams for addresses and bit positions, one decode term, write enable and read arm per row.
- Each access type is one line: read/write stores, read-only refuses or ignores as the brief says, write-1-to-clear is status AND NOT data.
- Put the events after the clear, so an event at the same edge as its clear is kept.
- The interrupt is a registered OR of STATUS AND IRQEN: a level that stays high until software clears every enabled bit.