Zicsr Extension

The Zicsr extension defines six instructions that atomically read-modify-write a single Control and Status Register (CSR): csrrw, csrrs, csrrc, and their immediate variants csrrwi, csrrsi, csrrci. The CSR address is encoded as a 12-bit field, giving 4096 register slots per hart, of which the privileged spec assigns specific ones (mstatus, mtvec, mepc, mcause, … see Control and Status Registers). Zicsr was originally part of base I; it was retrospectively detached into its own extension in the v2.2 revision after the working group recognized that “CSRs might not be present on very low-end hardware” (Red Hat Research, RISC-V extensions). For any chip that wants traps, interrupts, timers, or privilege levels, Zicsr is mandatory in practice. The current ratified version is Zicsr v2.0, indexed alongside the base in the RISC-V Unprivileged ISA reference.

This note is about the instructions the extension defines and their semantics, the encoding, and the rationale for the split. The catalog of CSRs themselves (what mstatus.MIE controls, what mtvec points at, etc.) belongs to Control and Status Registers and the privilege model lives in RISC-V Privilege Modes.

Mental Model

flowchart LR
  subgraph INSN["Single CSR instruction"]
    OP["csrrw rd, csr, rs1"]
  end
  subgraph ATOMIC["Atomic read-modify-write"]
    R["1) read CSR -> tmp"]
    W["2) write rs1 -> CSR"]
    WB["3) write tmp -> rd"]
    R --> W --> WB
  end
  OP --> ATOMIC
  ATOMIC --> EFFECT["Single architecturally-visible step.<br/>No interrupt can split the RMW."]

A single Zicsr instruction is an atomic three-step on architectural state. What it shows: the value of the CSR is captured into a temporary, the new value from rs1 is written into the CSR, and the captured value is written to rd. No interrupt or exception can split the read from the write; both effects are either fully visible or fully not. The insight to take: Zicsr is the only way software touches privileged machine state in RISC-V. There is no MOV-from-CSR or MOV-to-CSR; every access goes through one of these six instructions and inherits their atomic RMW semantics.

The Six Instructions

All six are I-type encodings, with the 12-bit imm field repurposed as the CSR address (bits 31:20 of the instruction word) (RISC-V Unprivileged ISA, 2024-11-26 release).

 31         20 19    15 14   12 11    7 6      0
[  csr[11:0]  ][  rs1  ][funct3][  rd  ][opcode]   ; for csrrw, csrrs, csrrc
[  csr[11:0]  ][ uimm5 ][funct3][  rd  ][opcode]   ; for csrrwi, csrrsi, csrrci

The opcode is always 0b1110011 (SYSTEM, shared with ecall and ebreak which use different funct3/funct12 combinations). The funct3 field selects which of the six CSR instructions; values are 001/010/011 for csrrw/csrrs/csrrc and 101/110/111 for the immediate variants (RISC-V docs, Zicsr).

CSRRW: atomic swap

csrrw rd, csr, rs1 (CSR Read/Write) is the simplest of the three families (RISC-V docs, Zicsr):

  1. Atomically read the current value of csr into a temporary.
  2. Write rs1 into csr.
  3. Zero-extend the temporary to XLEN bits and write it to rd.

In one line: rd, csr = csr, rs1 performed atomically.

A common idiom is csrrw x0, csr, rs1, which writes without bothering to read. The spec calls this out explicitly: “If rd=x0, then the instruction shall not read the CSR and shall not cause any of the side effects that might occur on a CSR read.” This matters because many CSRs (counter registers, interrupt-pending registers) have read-side-effects (clear-on-read semantics, for example, do not exist in the base spec but can be implementation-defined for performance counters). By writing x0 as the destination, software signals it does not want the read at all; the hardware skips it.

CSRRS: atomic read-and-set-bits

csrrs rd, csr, rs1 (CSR Read and Set Bits) treats rs1 as a bitmask:

  1. Atomically read csr into a temporary.
  2. Compute csr | rs1 and write it back to csr.
  3. Zero-extend the temporary to XLEN bits and write to rd.

So any bit set in rs1 becomes set in csr (subject to writability constraints on per-bit basis), while bits not set in rs1 are unchanged.

The mirror idiom is csrrs rd, csr, x0, which reads but does not write. With rs1 = x0 the spec says: “the instruction will not write to the CSR at all, and so shall not cause any of the side effects that might otherwise occur on a CSR write, nor raise illegal-instruction exceptions on accesses to read-only CSRs”. This is the canonical read-only CSR access; the assembler exposes it as csrr rd, csr (a pseudoinstruction expanding to csrrs rd, csr, x0).

CSRRC: atomic read-and-clear-bits

csrrc rd, csr, rs1 is the dual: any bit set in rs1 becomes cleared in csr. Same atomic RMW, same rs1 = x0 behavior (no write, no side effect). This is how you disable interrupts: li t0, MIE_BIT; csrrc x0, mstatus, t0.

Immediate variants: csrrwi, csrrsi, csrrci

The i-suffixed forms take a 5-bit zero-extended unsigned immediate (in the position the rs1 field would otherwise occupy) instead of a register value (RISC-V docs, Zicsr). The immediate covers small bitmasks (0-31) which is exactly the range you need for things like “set bit 3 in this CSR” or “write value 7 to this 3-bit field”. For larger values you must use the register form.

The immediate variants exist because so many CSR operations need only a handful of distinct bit positions and burning a temporary register on li t0, 8 before every csrrs would be wasteful in tight trap-handling code.

Pseudoinstructions

The assembler exposes friendlier mnemonics for common patterns:

PseudoExpands toMeaning
csrr rd, csrcsrrs rd, csr, x0read only, no write
csrw csr, rs1csrrw x0, csr, rs1write only, no read
csrs csr, rs1csrrs x0, csr, rs1set bits, discard old
csrc csr, rs1csrrc x0, csr, rs1clear bits, discard old
csrwi csr, immcsrrwi x0, csr, immimmediate write
csrsi csr, immcsrrsi x0, csr, immimmediate set
csrci csr, immcsrrci x0, csr, immimmediate clear

These cover the vast majority of real CSR accesses. csrrw with both rd and rs1 non-zero is rare; the typical use case is bootstrapping (swap the trap vector into mscratch atomically while installing a new one).

Atomic Read-Modify-Write: What Atomic Means Here

The spec’s exact wording: “All CSR instructions atomically read-modify-write a single CSR, whose CSR specifier is encoded in the 12-bit csr field of the instruction held in bits 31-20.” (RISC-V Unprivileged ISA). What “atomic” means here is “within the hart’s own observation”: no trap, interrupt, or other CSR access on the same hart can interleave between the read and the write. Across multiple harts this is not a global memory-ordering primitive; CSRs are per-hart state, and the few inter-hart CSR effects (writing to interrupt-pending bits to signal another hart’s IPI) go through dedicated mechanisms outside Zicsr.

The atomicity matters most for trap handlers. Consider disabling interrupts: a naive read-modify-write sequence (csrr t0, mstatus; andi t0, t0, ~MIE; csrw mstatus, t0) has a window between the read and the write during which an interrupt could fire, change mstatus (the trap handler always saves and restores other bits), and lose the update. With csrrc x0, mstatus, MIE_BIT the entire RMW happens in a single architectural step. There is no window.

The implementation cost is small. CSRs are typically a small set of flip-flops or a tiny register file; a one-cycle read-modify-write is straightforward. The CSR access usually completes in a single cycle on the boundary between two pipeline stages (or in its own dedicated micro-op slot), with the write committing only on instruction retirement.

Program-Order Semantics

The spec adds: “Each RISC-V hart normally observes its own CSR accesses, including its implicit CSR accesses, as performed in program order. In particular, unless specified otherwise, a CSR access is performed after the execution of any prior instructions in program order whose behavior modifies or is modified by the CSR state and before the execution of any subsequent instructions in program order whose behavior modifies or is modified by the CSR state.” (RISC-V docs, Zicsr).

This is much stronger than the standard RVWMO memory model permits for ordinary memory accesses. CSR accesses are serialized against the surrounding instruction stream: a csrw mtvec, t0 cannot be reordered with a subsequent ecall that depends on the trap vector being installed. Out-of-order implementations have to flush or stall around CSR writes to maintain this; the result is that CSR-write-heavy code is one of the few RISC-V workloads that breaks an out-of-order engine. Trap handlers are not hot paths, so the cost is acceptable.

A handful of CSRs are explicitly relaxed from this rule (the floating-point status fcsr and rounding-mode bits, where serializing every FP operation behind an fcsr access would be expensive); see Chapter 11 of the manual.

Why Zicsr Is a Separate Extension

The Zicsr instructions used to live in the base I integer set. They were split out in the v2.2 revision (around 2017) along with fence.i (now Zifencei) (Red Hat Research, GCC mailing list discussion). The technical motivation was simple:

  • Some chips do not have CSRs at all. A pure-compute accelerator (think of a tensor-multiply unit instantiated as a RISC-V hart for compiler-targeting convenience) needs only add, mul, branches, and loads/stores. It has no privilege levels, no traps, no timer, and no interrupt sources, so it has no use for mstatus/mtvec/etc. Forcing such a chip to implement Zicsr would force it to implement at least some CSR semantics, even if they are read-as-zero. The split lets such cores claim conformance to rv32i without lying.
  • The split is retrospective. Most real chips implemented the original “base I plus CSRs” set, and toolchains assumed CSRs were available. The split caused a real toolchain headache: GCC 11 and earlier defaulted to ISA spec v2.2 (CSRs in base); GCC 12 bumped to v20191213 (CSRs in Zicsr). Code compiled with one and linked with the other can produce confusing “illegal instruction” errors at runtime (GCC mailing list). The current convention is to always spell out Zicsr explicitly in -march, for example -march=rv32imc_zicsr_zifencei.
  • Conformance independence. Once Zicsr is its own extension, it can be ratified, versioned, and amended independently. A future addition of new CSR instructions can go into Zicsr v2.1 without touching the base I spec.

The pragmatic upshot for everyone except micro-accelerator designers: always include Zicsr. The ESP32-C3, every Rocket variant, every Ibex variant, every Hazard3 variant, every Cortex-equivalent RISC-V core, all implement Zicsr. The “no CSRs” case is theoretical; the split exists for the spec’s cleanliness, not because pure RV32I cores are common in shipping silicon.

The CSR Address Space

The 12-bit CSR address field gives a 4096-entry space, partitioned by the privileged spec (RISC-V docs, Privileged CSRs). The top four bits encode access permissions:

  • Bits [11:10]: read/write disposition. 00, 01, 10 are read/write; 11 is read-only.
  • Bits [9:8]: minimum privilege level required to access. 00 is User-accessible, 01 is Supervisor, 10 reserved/Hypervisor, 11 Machine-only.

So a CSR at address 0x300 (binary 0011_0000_0000) is read/write, accessible at M-mode only. 0xC00 (binary 1100_0000_0000) is read-only and User-accessible (this is cycle, the cycle counter). Attempting to access a CSR your current privilege level does not authorize raises an illegal-instruction exception (RISC-V docs, Privileged CSRs). The hardware decode does not need to look at the CSR’s read/write disposition before checking the privilege bits; that ordering matters for spec compliance under speculative execution.

A small sampling of standard CSRs the Zicsr instructions reach:

AddressNameRole
0x300mstatusmachine-mode status, interrupt-enable, privilege stack
0x301misaISA and supported extensions
0x304miemachine interrupt enables
0x305mtvecmachine trap vector base
0x340mscratchmachine scratch register
0x341mepcmachine exception program counter
0x342mcausemachine trap cause
0x343mtvalmachine trap value (faulting address, etc.)
0x344mipmachine interrupt pending
0x3A0-0x3BFpmpcfg0-pmpcfg15PMP region configurations
0x3B0-0x3EFpmpaddr0-pmpaddr63PMP region addresses
0xB00mcyclecycle counter (RW)
0xB02minstretretired-instruction counter (RW)
0xC00cyclecycle counter (RO, user-visible)
0xC01timewall-clock time
0xC02instretretired-instruction counter (RO, user-visible)
0xF11mvendoridvendor ID (RO)
0xF14mhartidhardware thread ID (RO)

The full table is in the privileged spec (RISC-V Privileged ISA) and the per-CSR semantics are in Control and Status Registers.

Worked Examples

Disabling interrupts atomically

To clear the machine interrupt-enable bit (mstatus.MIE, bit 3) without losing concurrent updates to other mstatus fields:

    li   t0, 0x8              # bitmask for MIE
    csrrc x0, mstatus, t0     # atomically clear MIE in mstatus

The csrrc x0 form means “do not bother reading; just clear”. One instruction; no window. The equivalent on x86 would be the dedicated cli; here there is no dedicated instruction because the CSR access machinery is general.

Reading the cycle counter

    csrr a0, cycle            # a0 = current cycle count
                              # (expands to: csrrs a0, 0xC00, x0)

This works in User mode because cycle (CSR 0xC00) is User-accessible. M-mode could read mcycle (0xB00) instead, which is the writable mirror.

Saving and swapping the trap vector during boot

    la   t0, my_trap_vector       # t0 = new trap-vector address
    csrrw t1, mtvec, t0           # atomically: t1 = old mtvec; mtvec = t0

The previous trap vector is captured in t1 (for restoration later, perhaps to chain to a prior handler), and the new one is installed, in one atomic step. No window during which a trap would dispatch to a partially-written vector.

Setting up the machine-mode timer compare CSR

The CLINT exposes mtimecmp as memory-mapped state, not as a CSR. But to enable the timer interrupt the kernel writes mie:

    li   t0, 0x80                 # bit 7 = MTIE (machine timer enable)
    csrrs x0, mie, t0             # set bit 7 in mie; leave other bits alone

Returning from a trap

mret is not a Zicsr instruction (it is in the privileged spec, opcode SYSTEM with funct12 = 0x302), but it interacts directly with CSRs: it pops the mstatus.MPP/MPIE privilege stack and jumps to mepc. See RISC-V Trap Handling.

Failure Modes and Common Misunderstandings

  • “Reading is free.” Not for all CSRs. Performance counters can have read-side-effects; reading-then-writing one in two separate instructions (instead of using csrrw) can produce inconsistent values. Use the right form.
  • “The immediate variants take a register number.” No. They take a literal 5-bit zero-extended value. csrwi mstatus, 0x8 writes the constant 8 into mstatus; it does not write the contents of x8.
  • “You can fence around a CSR access.” You usually do not need to. The spec already serializes CSR accesses against program order. The exception is multi-hart inter-CSR interaction (e.g., one hart writing an MIP bit to signal another), which needs an explicit fence to guarantee visibility ordering.
  • csrrw x0, csr, x0 is a NOP.” Almost. It writes x0’s value (always 0) to the CSR. If the CSR has WARL (Write-Any, Read-Legal) semantics, this may silently fail to change anything, but it will trigger any write-side-effect. The actual NOP is omitted from the program.
  • “Zicsr provides full mutex semantics.” Only within one hart. CSRs are per-hart; cross-hart synchronization needs the A extension (load-reserved/store-conditional and AMOs) or memory-mapped synchronization primitives.
  • “All chips implement all listed CSRs.” No. The privileged spec defines the address space; each implementation chooses which CSRs to provide. The ESP32-C3 implements only a subset of the privileged spec; reading a non-implemented CSR raises an illegal-instruction exception. (Cross-referenced from secondary summaries of the Espressif TRM; see the ESP32-C3 section in RV32IMC for the provenance caveat.)

Alternatives in Other ISAs

The Zicsr design is recognizably a deliberate counter-example to two prior conventions:

  • x86’s MSRs. x86 puts model-specific registers behind RDMSR/WRMSR opcodes, which are unprivileged-illegal but otherwise just MOVs. There is no atomic RMW form; you read, modify, and write in two instructions, and the windows have caused real bugs. RISC-V’s choice to provide atomic forms eliminates that class of bug at the ISA level.
  • ARM’s system registers. ARM uses MRS (move-from-system-register) and MSR (move-to-system-register) as separate read and write operations. Again no atomic RMW; the canonical disable-IRQ idiom on ARMv8-A is a multi-instruction sequence. ARM compensates with extra instructions (MSR DAIFSet, #imm) for the most common cases, somewhat reproducing Zicsr’s immediate variants but without generality.

Zicsr’s atomic RMW with the bitmask immediates is, in this comparison, unusually clean: one instruction, no window, no special-case opcodes.

Production Notes

In the definitely-not-esp32 project the Zicsr implementation is one of the load-bearing pieces of the RTL. The CSR file is a flat array of registers (a few dozen of them; the chip does not implement the full 4096 address space), addressed by the 12-bit field; the decoder routes Zicsr instructions to it via a small mux that selects the right CSR; the read-modify-write happens in the Execute stage, and the writeback to rd happens in the standard Writeback stage; the side effects on, for example, mstatus.MIE are routed back to the interrupt-pending logic combinationally. The atomicity falls out of the in-order single-issue pipeline: only one CSR access is in flight at a time, and the entire instruction commits or does not.

The Rust embedded ecosystem provides the riscv crate which wraps Zicsr instructions in safe abstractions: riscv::register::mstatus::read() and riscv::register::mstatus::set_mie() compile down to single csrr/csrs instructions. The crate validates at compile time that the CSR access is at the right privilege level for the target. This is one of the cleaner places where Rust’s zero-cost abstractions earn their keep on bare metal.

A subtle real-world headache: when porting code between toolchain versions, the implicit Zicsr-in-base-I assumption from older GCC can cause silent breakage. The cure is the same as the diagnosis: always spell -march=rv32imc_zicsr_zifencei explicitly, never trust the toolchain default. Multiple distribution-maintainer complaints in the GCC and LLVM trackers concern exactly this confusion.

See Also