A complete worked example — a real APB4 GPIO controller RTL from scratch. Register map design, the Completer interface logic, PREADY wait states, PSLVERR error reporting, PPROT access gating, byte-lane strobes, and a complete synthesisable SystemVerilog implementation that ties every concept from the series together.
We will design a complete APB4 GPIO controller — a 32-bit bidirectional GPIO peripheral accessible over APB. This is the most common APB peripheral type and uses every major APB concept: address decode, register reads and writes, byte-lane strobes, error responses for invalid addresses, and secure/non-secure access control.
The GPIO controller implements:
The GPIO controller occupies a 4 KB address window. The six registers are placed at the bottom of the address space. Accesses to addresses above 0x014 return PSLVERR=1.
module apb4_gpio #( parameter ADDR_W = 12, // 4 KB address window parameter DATA_W = 32 // 32-bit data bus ) ( // Clock and reset input logic PCLK, input logic PRESETn, // APB4 Completer interface input logic [ADDR_W-1:0] PADDR, input logic PSELx, input logic PENABLE, input logic PWRITE, input logic [2:0] PPROT, input logic [DATA_W-1:0] PWDATA, input logic [DATA_W/8-1:0] PSTRB, output logic [DATA_W-1:0] PRDATA, output logic PREADY, output logic PSLVERR, // GPIO functional interface output logic [DATA_W-1:0] gpio_out, // driven pad values output logic [DATA_W-1:0] gpio_oe, // output enable input logic [DATA_W-1:0] gpio_in, // sampled pad values output logic irq_out // interrupt to IRQ controller );
Address decoding compares PADDR to the defined register offsets. Only PADDR bits within the peripheral’s address window are relevant — the bridge has already decoded the upper bits and asserted PSELx for this peripheral.
// ─── Address decode ─────────────────────────────────────── // Use only the lower 12 bits (4 KB window) localparam REG_DATA = 12'h000; localparam REG_DIR = 12'h004; localparam REG_IEN = 12'h008; localparam REG_IPEND = 12'h00C; localparam REG_ICTRL = 12'h010; localparam REG_IMASK = 12'h014; wire [ADDR_W-1:0] addr = PADDR[ADDR_W-1:0]; wire sel_data = (addr == REG_DATA); wire sel_dir = (addr == REG_DIR); wire sel_ien = (addr == REG_IEN); wire sel_ipend = (addr == REG_IPEND); wire sel_ictrl = (addr == REG_ICTRL); wire sel_imask = (addr == REG_IMASK); // Any valid register hit wire addr_valid = sel_data | sel_dir | sel_ien | sel_ipend | sel_ictrl | sel_imask; // PPROT[1]=1 means non-secure — reject IMASK access wire ns_access = PPROT[1]; wire sec_violation = sel_imask & ns_access;
Registers are updated on the transfer completion cycle: when PSELx, PENABLE, PREADY, and PWRITE are all HIGH simultaneously. Each byte lane is independently gated by PSTRB. Writes to protected registers (IMASK from non-secure) are silently dropped — the PSLVERR is generated separately.
// ─── Register declarations ──────────────────────────────── logic [DATA_W-1:0] r_data, r_dir, r_ien; logic [DATA_W-1:0] r_ipend, r_ictrl, r_imask; // ─── Write enable — gated by transfer completion ────────── wire wr_en = PSELx & PENABLE & PREADY & PWRITE; // ─── Byte-lane write helper macro ───────────────────────── // Updates reg_r byte by byte, only where PSTRB=1 task automatic byte_write; input [DATA_W-1:0] data_in; ref [DATA_W-1:0] reg_r; integer b; begin for (b = 0; b < DATA_W/8; b++) begin if (PSTRB[b]) reg_r[(b*8)+:8] <= data_in[(b*8)+:8]; end end endtask // ─── Register write logic ───────────────────────────────── always_ff @(posedge PCLK or negedge PRESETn) begin if (!PRESETn) begin r_data <= '0; r_dir <= '0; r_ien <= '0; r_ictrl <= '0; r_imask <= '1; // default: all masked (safe) r_ipend <= '0; end else begin // Interrupt pending — set on gpio_in edge, clear on write-1 r_ipend <= (r_ipend | irq_set) & ~(wr_en & sel_ipend ? PWDATA : '0); if (wr_en & !sec_violation) begin if (sel_data) byte_write(PWDATA, r_data); if (sel_dir) byte_write(PWDATA, r_dir); if (sel_ien) byte_write(PWDATA, r_ien); if (sel_ictrl) byte_write(PWDATA, r_ictrl); if (sel_imask) // secure only — sec_violation gates this byte_write(PWDATA, r_imask); end end end
PRDATA is combinationally muxed from the register outputs based on PADDR. For a zero-wait-state peripheral this is purely combinational — PRDATA is valid in the same cycle that PENABLE is asserted. The read data must be valid by the rising edge where PREADY=1.
// ─── PRDATA mux — combinational ─────────────────────────── always_comb begin PRDATA = '0; // default: unimplemented = 0 if (PSELx & !PWRITE) begin case (1'b1) sel_data: PRDATA = gpio_in; // reads pad values sel_dir: PRDATA = r_dir; sel_ien: PRDATA = r_ien; sel_ipend: PRDATA = r_ipend; sel_ictrl: PRDATA = r_ictrl; sel_imask: PRDATA = ns_access ? '0 : r_imask; default: PRDATA = '0; endcase end end // ─── GPIO functional outputs ────────────────────────────── assign gpio_out = r_data; assign gpio_oe = r_dir; assign irq_out = |(r_ipend & r_ien & ~r_imask);
This GPIO controller uses zero wait states — all register accesses complete in the minimum two cycles (one Setup + one Access). PREADY is tied HIGH permanently. The spec notes: “Peripherals that have a fixed two cycle access can tie PREADY HIGH.”
// ─── PREADY — zero wait states ──────────────────────────── // Completer always ready immediately in Access phase assign PREADY = 1'b1; // ─── If wait states were needed (example for slow memory) ── // always_ff @(posedge PCLK or negedge PRESETn) begin // if (!PRESETn) pready_r <= 1'b0; // else if (!PSELx) pready_r <= 1'b0; // else if (PENABLE) pready_r <= mem_ack; // assert when memory responds // end // assign PREADY = PSELx ? pready_r : 1'b1;
PSLVERR is asserted at the transfer completion cycle for two error conditions: access to an unimplemented address, or a non-secure access to the secure IMASK register. PSLVERR is only valid when PSELx, PENABLE, and PREADY are all HIGH.
// ─── PSLVERR generation ─────────────────────────────────── // Two error conditions: // 1. Unimplemented address (addr_valid=0) // 2. Non-secure access to secure register (sec_violation=1) wire error_cond = !addr_valid | sec_violation; // PSLVERR valid only at transfer completion assign PSLVERR = PSELx & PENABLE & PREADY & error_cond; // Recommended: drive LOW when not at completion cycle // (above assign already does this — PSLVERR=0 when any of // PSELx, PENABLE, PREADY is deasserted)
wr_en never enables any register update — the write is silently ignored at the register level while PSLVERR informs the initiator.
Putting all sections together into the complete synthesisable module:
module apb4_gpio #( parameter ADDR_W = 12, parameter DATA_W = 32 ) ( input logic PCLK, PRESETn, input logic [ADDR_W-1:0] PADDR, input logic PSELx, PENABLE, PWRITE, input logic [2:0] PPROT, input logic [DATA_W-1:0] PWDATA, input logic [DATA_W/8-1:0] PSTRB, output logic [DATA_W-1:0] PRDATA, output logic PREADY, PSLVERR, output logic [DATA_W-1:0] gpio_out, gpio_oe, input logic [DATA_W-1:0] gpio_in, output logic irq_out ); // ─ Address decode ───────────────────────────────────────── localparam [ADDR_W-1:0] A_DATA = 'h000, A_DIR = 'h004, A_IEN = 'h008, A_IPEND = 'h00C, A_ICTRL= 'h010, A_IMASK = 'h014; wire s_dat = (PADDR == A_DATA), s_dir = (PADDR == A_DIR); wire s_ien = (PADDR == A_IEN), s_ip = (PADDR == A_IPEND); wire s_ic = (PADDR == A_ICTRL), s_im = (PADDR == A_IMASK); wire a_ok = s_dat|s_dir|s_ien|s_ip|s_ic|s_im; wire sec_v = s_im & PPROT[1]; // PPROT[1]=1: non-secure // ─ Registers ────────────────────────────────────────────── logic [DATA_W-1:0] r_dat,r_dir,r_ien,r_ip,r_ic,r_im; wire we = PSELx&PENABLE&PREADY&PWRITE&!sec_v; // Interrupt set logic (rising edge detector, simplified) logic [DATA_W-1:0] gpio_in_r; wire [DATA_W-1:0] irq_set = (gpio_in & ~gpio_in_r) & ~r_dir; always_ff @(posedge PCLK or negedge PRESETn) begin if (!PRESETn) begin {r_dat,r_dir,r_ien,r_ip,r_ic} <= '0; r_im <= '1; gpio_in_r <= '0; end else begin gpio_in_r <= gpio_in; r_ip <= (r_ip|irq_set) & ~(we&s_ip ? PWDATA:'0); for (int b=0; b<DATA_W/8; b++) begin if (we&PSTRB[b]) begin if(s_dat) r_dat[(b*8)+:8]<=PWDATA[(b*8)+:8]; if(s_dir) r_dir[(b*8)+:8]<=PWDATA[(b*8)+:8]; if(s_ien) r_ien[(b*8)+:8]<=PWDATA[(b*8)+:8]; if(s_ic) r_ic[(b*8)+:8] <=PWDATA[(b*8)+:8]; if(s_im) r_im[(b*8)+:8] <=PWDATA[(b*8)+:8]; end end end end // ─ PRDATA mux ───────────────────────────────────────────── always_comb begin PRDATA = '0; if (PSELx & !PWRITE) unique case (1'b1) s_dat: PRDATA = gpio_in; s_dir: PRDATA = r_dir; s_ien: PRDATA = r_ien; s_ip: PRDATA = r_ip; s_ic: PRDATA = r_ic; s_im: PRDATA = sec_v ? '0 : r_im; default: PRDATA = '0; endcase end // ─ APB outputs ──────────────────────────────────────────── assign PREADY = 1'b1; assign PSLVERR = PSELx&PENABLE&PREADY&(!a_ok|sec_v); // ─ Functional outputs ───────────────────────────────────── assign gpio_out = r_dat; assign gpio_oe = r_dir; assign irq_out = |(r_ip & r_ien & ~r_im); endmodule
Use this checklist when implementing any APB Completer:
| Item | Rule |
|---|---|
| Write enable condition | PSELx & PENABLE & PREADY & PWRITE — all four HIGH simultaneously |
| Read data valid at | Rising PCLK where PSELx+PENABLE+PREADY=1 and PWRITE=0 |
| PSLVERR valid at | Rising PCLK where PSELx+PENABLE+PREADY=1 only |
| PSLVERR drive rule | Drive LOW when PSELx=0 or PENABLE=0 or PREADY=0 |
| PSTRB byte write | if (PSTRB[n]) reg[(8n+7):(8n)] <= PWDATA[(8n+7):(8n)] |
| PSTRB on reads | Ignore entirely — bridge drives 0 on reads |
| Address decode | Only bits within your window — typically PADDR[N:2] for word-aligned regs |
| Unimplemented address | PSLVERR=1, PRDATA=0, no register side effects |
| PPROT[1]=1 on secure reg | PSLVERR=1, PRDATA=0 (don’t reveal register value), no write |
| Zero wait states | Tie PREADY=1 permanently — transfers always complete in 2 cycles |
| Variable wait states | Assert PREADY only when the slow operation (memory, CDC) completes |
| Reset defaults | Conservative: interrupts masked, outputs disabled, no unsafe enables |
| Read-only register write | Either PSLVERR=1 (strict) or silently ignore (lenient) — document your choice |
| Write-1-to-clear register | reg <= reg & ~(wr_en ? PWDATA : 0) |
| DATA register read | Returns gpio_in (actual pad values), not the written DATA register value |