This log is the C interrupt handler from this lesson running against the IRQ_STATUS RTL. The first handler reads 0x1, reads again, gets 0x9 and writes 0x9. The datapath fault that arrived in between is gone: IRQ_STATUS is 0, the interrupt line is low, and no handler will ever see that fault. The second handler writes back only what it handled, and the fault is still pending.
$ ./obj_dir/cosim
pp_irq_handler_rmw handler:
read 0x0C -> 0x1
read 0x0C -> 0x9
write 0x0C <- 0x9
after: IRQ_STATUS = 0x0, irq = 0
pp_irq_handler handler:
read 0x0C -> 0x1
write 0x0C <- 0x1
after: IRQ_STATUS = 0x8, irq = 1
PASS: C driver against RTL: RMW lost the fault, W1C ack kept itRead each timeline left to right. Above the axis is what happened; below it is IRQ_STATUS bits 3 to 0 after that step. Bit 0 is done and bit 3 is datapath fault.
Steps 1 and 2: the packet finishes and the handler takes its snapshot, 0001. It will service done.
Step 3: a datapath fault sets bit 3 while the handler is busy. Nothing has gone wrong yet.
Step 4, top: the acknowledge status |= done is a read, an OR and a write. The read returns 1001, so the write puts a 1 on bit 3, and W1C clears it. The fault was never seen.
Step 4, bottom: the write carries 0001. A 0 on a W1C bit means leave it, so bit 3 stays set and the interrupt stays high.
The RTL did exactly what Field Access Types specifies; set-wins arbitration does not help, because the fault and the clear are on different edges. The bug is in the C, and it looks harmless.
The map is the canonical one from CSR Contract and Address Map, plus one read-to-clear register that is a teaching variant, not part of that map.
Offset | Register | Access | Firmware must |
|---|---|---|---|
0x00 | CONTROL | RW, start bit is a pulse | Set enable, then write start while idle. |
0x04 | STATUS | busy is RO | Poll busy with a bound, never forever. |
0x0C | IRQ_STATUS | W1C, sticky, set wins | Write back only the bits it handled. |
0x10, 0x14 | LIMIT_LO, LIMIT_HI | RW shadow words | Write both halves before committing. |
0x18 | LIMIT_COMMIT | commit reads 0; shadow_valid RO | Commit, then check shadow_valid is clear. |
0x24 | ERR_CAPTURE (variant) | RC | Read once and keep the value. |
/* pp_csr.h: packet-processor register map, as firmware sees it. */
#ifndef PP_CSR_H
#define PP_CSR_H
#include <stdint.h>
/* Register offsets from the block base address. */
#define PP_CONTROL 0x00u
#define PP_STATUS 0x04u
#define PP_IRQ_ENABLE 0x08u
#define PP_IRQ_STATUS 0x0Cu /* W1C, sticky, hardware set wins */
#define PP_LIMIT_LO 0x10u /* shadow word */
#define PP_LIMIT_HI 0x14u /* shadow word */
#define PP_LIMIT_COMMIT 0x18u
#define PP_ERR_CAPTURE 0x24u /* RC teaching variant: not in the canonical map */
/* Field masks. */
#define PP_CONTROL_ENABLE (1u << 0)
#define PP_CONTROL_MODE(m) (((uint32_t)(m) & 3u) << 1)
#define PP_CONTROL_START (1u << 8)
#define PP_STATUS_BUSY (1u << 0)
#define PP_IRQ_DONE (1u << 0)
#define PP_IRQ_CONFIG_ERROR (1u << 1)
#define PP_IRQ_TIMEOUT (1u << 2)
#define PP_IRQ_DATAPATH_FAULT (1u << 3)
#define PP_LIMIT_COMMIT_GO (1u << 0)
#define PP_LIMIT_SHADOW_VALID (3u << 1)
#ifdef PP_HOST_MODEL
/* Host build: accesses go to a simulation of the RTL instead of the bus. */
uint32_t pp_read(uintptr_t base, uint32_t off);
void pp_write(uintptr_t base, uint32_t off, uint32_t value);
#else
/* volatile: every call is one real bus access, never cached, merged or removed. */
static inline uint32_t pp_read(uintptr_t base, uint32_t off)
{
return *(volatile const uint32_t *)(base + off);
}
static inline void pp_write(uintptr_t base, uint32_t off, uint32_t value)
{
*(volatile uint32_t *)(base + off) = value;
}
#endif
#endif /* PP_CSR_H */Offsets and masks are unsigned constants named after the map, so a field change is one edit, and a generated header can replace this file line for line.
`volatile` lives in two places only, the accessors. Every pp_read and pp_write is then one real bus access: never cached in a register, merged with another or removed.
`PP_HOST_MODEL` swaps the accessors for functions supplied by a simulation. The co-simulation above uses it to run this driver against the RTL unchanged.
/* pp_driver.c: the four access patterns firmware needs for this block. */
#include "pp_csr.h"
/* 1. W1C: acknowledge only the causes this handler serviced. */
uint32_t pp_irq_handler(uintptr_t base)
{
uint32_t pending = pp_read(base, PP_IRQ_STATUS); /* one snapshot */
uint32_t handled = 0;
if (pending & PP_IRQ_DONE) {
/* ... collect the finished packet ... */
handled |= PP_IRQ_DONE;
}
if (pending & PP_IRQ_DATAPATH_FAULT) {
/* ... reset the datapath ... */
handled |= PP_IRQ_DATAPATH_FAULT;
}
pp_write(base, PP_IRQ_STATUS, handled); /* ones clear, zeros keep */
return handled;
}
/* The bug: read-modify-write on a W1C register. The second read picks up any
cause that arrived after the snapshot, and writing it back as 1 clears it. */
uint32_t pp_irq_handler_rmw(uintptr_t base)
{
uint32_t pending = pp_read(base, PP_IRQ_STATUS);
uint32_t handled = 0;
if (pending & PP_IRQ_DONE) {
/* ... collect the finished packet ... */
handled |= PP_IRQ_DONE;
}
if (pending & PP_IRQ_DATAPATH_FAULT) {
handled |= PP_IRQ_DATAPATH_FAULT;
}
pp_write(base, PP_IRQ_STATUS, pp_read(base, PP_IRQ_STATUS) | handled); /* BUG */
return handled;
}
/* 2. RC: read once, keep the value. A second read returns what is left, not what was. */
typedef struct {
uint32_t err_capture;
} pp_err_log_t;
void pp_capture_error(uintptr_t base, pp_err_log_t *log)
{
log->err_capture = pp_read(base, PP_ERR_CAPTURE); /* the only read */
}
/* 3. Shadow and commit: write both halves, then commit. Returns 0 on success. */
int pp_set_limit(uintptr_t base, uint64_t limit)
{
pp_write(base, PP_LIMIT_LO, (uint32_t)limit);
pp_write(base, PP_LIMIT_HI, (uint32_t)(limit >> 32));
pp_write(base, PP_LIMIT_COMMIT, PP_LIMIT_COMMIT_GO);
/* A successful commit clears shadow_valid; bits still set mean it did not take. */
return (pp_read(base, PP_LIMIT_COMMIT) & PP_LIMIT_SHADOW_VALID) ? -1 : 0;
}
/* 4. Start, then poll busy with a bound. Returns 0 when idle, -1 on timeout. */
int pp_start_and_wait(uintptr_t base, uint32_t mode, uint32_t max_polls)
{
pp_write(base, PP_CONTROL, PP_CONTROL_ENABLE | PP_CONTROL_MODE(mode));
pp_write(base, PP_CONTROL, PP_CONTROL_ENABLE | PP_CONTROL_MODE(mode) | PP_CONTROL_START);
while (max_polls-- > 0u) {
if ((pp_read(base, PP_STATUS) & PP_STATUS_BUSY) == 0u) {
return 0;
}
}
return -1;
}It compiles cleanly with every warning made an error:
$ cc -std=c11 -Wall -Wextra -Werror -c pp_driver.c
$ echo $?
0W1C acknowledge (`pp_irq_handler`): one read gives a snapshot, the handler decides from that snapshot, and the write carries exactly the bits it serviced. Causes it did not look at, including any that arrived after the read, keep their 1 and keep the interrupt asserted.
The bug (`pp_irq_handler_rmw`): the second pp_read inside the write is the race window. Any cause set after the snapshot is read back as 1 and cleared. IRQ_STATUS |= x compiles to the same three accesses.
Level causes are different: a cause such as RX-not-empty is cleared by draining the FIFO, not by a W1C write, as Interrupts and Events explains. The driver above only acknowledges sticky causes.
Read-to-clear (`pp_capture_error`): one read, stored in a struct. Logging, retry decisions and debug prints use the stored value. A second read returns only what has arrived since, usually 0.
Shadow and commit (`pp_set_limit`): low word, high word, then commit. The map makes an incomplete commit an OK no-op that keeps shadow_valid, so the bus response cannot say whether it took. Reading shadow_valid back can.
Start and poll (`pp_start_and_wait`): start is only accepted when enable was already 1, so enable goes in a write of its own first. The poll loop has a bound and returns -1, so a stuck block becomes a reported error instead of a hung CPU.
The co-simulation compiles pp_driver.c with -DPP_HOST_MODEL and links it to a Verilator model of the register block below. Each accessor drives the one-cycle access port and ticks the clock; the harness raises the datapath fault right after the handler's first access. This is the part of the harness that does it:
// excerpt of the co-simulation harness (cosim.cpp)
static Vpp_irq_regs *m;
static int accesses;
static void tick() { m->clk = 1; m->eval(); m->clk = 0; m->eval(); }
static void after_access() {
if (++accesses == 1) { m->event_i = 0x8; tick(); m->event_i = 0; } // fault arrives
}
extern "C" uint32_t pp_read(uintptr_t, uint32_t off) {
m->rd = 1; m->addr = off; m->eval();
uint32_t v = m->rdata;
tick(); m->rd = 0; m->eval();
printf(" read 0x%02X -> 0x%X\n", off, v);
after_access();
return v;
}
extern "C" void pp_write(uintptr_t, uint32_t off, uint32_t value) {
m->wr = 1; m->addr = off; m->wdata = value; m->eval();
tick(); m->wr = 0; m->eval();
printf(" write 0x%02X <- 0x%X\n", off, value);
after_access();
}The course's bench runner only runs SystemVerilog, so the bench below mirrors the same access sequence with tasks, one task per C accessor. It checks both outcomes and fails if the RMW ack does not lose the fault or the W1C ack does not keep it.
// IRQ_ENABLE and IRQ_STATUS of the packet processor: the set-dominant interrupt bank
// from Interrupt Register Pattern, behind a one-cycle access port (full-word writes).
module pp_irq_regs (
input logic clk, rst_n,
input logic wr, rd, // one accepted access per cycle
input logic [7:0] addr,
input logic [31:0] wdata,
output logic [31:0] rdata,
input logic [3:0] event_i, // done, config_error, timeout, datapath_fault
output logic irq_o
);
logic [3:0] pending_q, enable_q;
logic [3:0] w1c_clear;
assign w1c_clear = (wr && addr == 8'h0C) ? wdata[3:0] : 4'b0000;
assign irq_o = |(pending_q & enable_q);
assign rdata = !rd ? 32'h0 :
addr == 8'h08 ? {28'h0, enable_q} :
addr == 8'h0C ? {28'h0, pending_q} : 32'h0;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
pending_q <= '0;
enable_q <= '0;
end else begin
if (wr && addr == 8'h08) enable_q <= wdata[3:0];
pending_q <= (pending_q & ~w1c_clear) | event_i; // hardware set wins
end
end
endmodule
$ vvp -n irq.vvp
RMW ack: pending after ack = 0x0, irq = 0
W1C ack: pending after ack = 0x8, irq = 1
PASS: read-modify-write lost the fault; writing back only the handled bits kept it#include <stdint.h>
int wait_idle_plain(uintptr_t base) /* BUG: no volatile */
{
uint32_t *status = (uint32_t *)(base + 0x04u);
while (*status & 1u) {
}
return 0;
}
int wait_idle_volatile(uintptr_t base)
{
volatile uint32_t *status = (volatile uint32_t *)(base + 0x04u);
while (*status & 1u) {
}
return 0;
}Compiled with cc -std=c11 -Wall -Wextra -Werror -O2 -S -o poll.s poll.c; assembler directives are left out below.
_wait_idle_plain: ; @wait_idle_plain
ldrb w8, [x0, #4]
tbnz w8, #0, LBB0_2
mov w0, #0 ; =0x0
ret
LBB0_2: ; =>This Inner Loop Header: Depth=1
b LBB0_2
_wait_idle_volatile: ; @wait_idle_volatile
LBB1_1: ; =>This Inner Loop Header: Depth=1
ldr w8, [x0, #4]
tbnz w8, #0, LBB1_1
mov w0, #0 ; =0x0
retThe second mistake is the one at the top of this page: never read-modify-write a W1C register, whether as |=, as a read then write, or by writing back a whole snapshot.
void pp_isr(uintptr_t base)
{
uint32_t s = pp_read(base, PP_IRQ_STATUS);
if (s & PP_IRQ_DONE)
collect_packet();
pp_write(base, PP_IRQ_STATUS, s);
}It writes back the whole snapshot. If a timeout or fault was pending when it read, that 1 is written back and the cause is cleared without being serviced. Write back only what was handled: s & PP_IRQ_DONE.
An incomplete commit is an OK no-op in this map: the write succeeds but nothing is copied, and shadow_valid stays set. Only reading shadow_valid back tells firmware whether the new limit is active.
Tool: Apple clang 21.0.0 (cc), run on 2026-10-17. Command: cc -std=c11 -Wall -Wextra -Werror -c pp_driver.c, exit status 0, no diagnostics; also with -DPP_HOST_MODEL (exit 0). The six-line handler in the check-yourself compiled cleanly with the same flags plus a prototype for collect_packet. Assembly: cc -std=c11 -Wall -Wextra -Werror -O2 -S -o poll.s poll.c on arm64, directives removed for the page.
Co-simulation: Verilator 5.048, run on 2026-10-17. Command: verilator --cc --exe --build -Wno-fatal pp_irq_regs.sv cosim.cpp -LDFLAGS pp_driver_host.o -o cosim; ./obj_dir/cosim. The real pp_irq_handler and pp_irq_handler_rmw from the page ran against the Verilated pp_irq_regs (the page's RTL); the harness injects a datapath fault after the first access. Result: the log on the page and PASS.
Tool: Icarus Verilog 13.0, run on 2026-10-17. Commands: iverilog -g2012 -o irq.vvp irq_bench.sv; vvp -n irq.vvp. The bench mirrors the same C access sequence with one task per accessor (the course bench runner runs only SystemVerilog); result PASS, also under node scripts/tools/run-rtl-testbenches.mjs rtl/memory-buses/register-map-rtl.
Scope and limits: the register model has full-word writes and no byte strobes, and the co-simulation has one in-order access per cycle, so CPU write buffers, caches and bus reordering are not modelled. ERR_CAPTURE is a teaching variant, not part of the canonical map, and the RC, shadow and poll functions were compiled but not co-simulated.