Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • RTL Foundations36
  • Combinational Design25
  • Sequential Design27
  • FSM & Control27
  • Timing & Clocking39
  • Reset & CDC34
  • Memory & Buses57
    • SRAM and ROM
    • Register Files
    • Valid/Ready Handshake
    • AXI-Lite
    • AXI-Stream
    • Bus Integration
    • Synchronous FIFO
    • Register-Map and CSR RTL
      • CSR Contract and Address Map
      • Field Access Types
      • Hardware/Software Arbitration
      • Byte Strobes and Partial Writes
      • Atomic and Shadowed Updates
      • Interrupt Register Pattern
      • Adapter and Signoff
      • Generated Register Workflow
      • The Firmware Side: a C Driver for W1C, RC and Shadow Registers
    • Memory Reliability
    • Peripheral RTL Integration
    • Full AXI4: RTL Roadmap
  • Advanced RTL28
  • Senior & Interview76
  • Glossary and Notation
Loading lesson

‹Register-Map and CSR RTL
  1. Topics
  2. RTL Design
  3. Memory & Buses
  4. Register-Map and CSR RTL
  5. The Firmware Side: a C Driver for W1C, RC and Shadow Registers

Part 7 · Memory & Buses · Intermediate

The Firmware Side: a C Driver for W1C, RC and Shadow Registers

How firmware uses the packet-processor CSRs: a C header and driver for W1C acknowledge, read-to-clear, shadow commit and busy polling, checked against the RTL.

On this page

    Why this matters

    A one-line interrupt acknowledge, IRQ_STATUS |= DONE, re-reads the register and clears a fault that arrived while the handler was busy, though the RTL is correct. RTL designers who know how firmware uses their CSRs design fewer of these traps.

    After this lesson you can: write the C for a W1C acknowledge, an RC read, a shadow commit and a bounded busy poll, explain why each access is shaped that way, and spot the read-modify-write bug in review.

    An acknowledge that erases the next interrupt

    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.

    Diagram
    $ ./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 it
    One handler, two ways to acknowledge. A fault arrives after the handler's first read. Read-modify-write re-reads 1001, writes it back and clears the fault; writing back only the handled bit 0001 leaves 1000 pending

    Read 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 registers the driver touches

    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.

    The header: offsets, masks and volatile accessors

    c
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    /* 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.

    The driver, one pattern at a time

    c
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    /* 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:

    Diagram
    $ cc -std=c11 -Wall -Wextra -Werror -c pp_driver.c
    $ echo $?
    0
    • W1C 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.

    Checking the driver against the RTL

    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:

    cpp
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // 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.

    pp_irq_regs.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    // 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
    
    Diagram
    $ 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

    Warning

    volatile makes every access happen, in program order with respect to other volatile accesses. It is not a memory barrier: on a core with write buffers, or with device memory mapped as normal memory, use the platform's I/O accessors or barriers so the commit write cannot pass the shadow writes. The co-simulation models one in-order access per cycle.

    Common mistakes

    Common pitfall

    Polling a register through a plain pointer. Without volatile the compiler may load the busy bit once and spin forever on the stale value. Below, the same loop compiled at -O2 for arm64: without volatile there is one load and then b LBB0_2, a branch to itself; with it, the load is inside the loop.

    c
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #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.

    Diagram
    _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
    	ret

    The 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.

    c
    1
    2
    3
    4
    5
    6
    7
    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);
    }
    Check yourself The handler above reads IRQ_STATUS only once, so there is no race window. What is still wrong, and what is the one-word fix?

    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.

    Check yourself Why does pp_set_limit read LIMIT_COMMIT after writing the commit bit, instead of trusting that the write completed without error?

    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.

    Remember

    • Acknowledge W1C status by writing back only the bits you handled. Never |=, never write back the whole snapshot.

    • Read an RC register once and keep the value; a second read, or a debugger, sees what is left.

    • volatile goes in the accessors so every register access happens; check shadow_valid after a commit and bound every poll.

    Rule of thumb: on a W1C register, the only value firmware writes is the set of causes it has just serviced.

    How this was checked

    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.

    Keep going

    • Practice: Design a memory-mapped register interface with read and write support

    • Code lab: Maskable IRQ reg

    • Code lab: Interrupt aggregator

    • Interview: run a mock interview on this topic

    Related topics

    • Interrupt register pattern

    • Atomic and shadowed updates

    • Interrupts and events

    Previous

    231 / 350

    Next: Memory Reliability

    Up next

    Up nextMemory Reliability

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • CSR Contract and Address Map
    • Field Access Types
    • Hardware/Software Arbitration