Platform-Level Interrupt Controller
The Platform-Level Interrupt Controller (PLIC) is the standard RISC-V SoC block that aggregates external interrupt sources (UARTs, GPIO, DMA engines, NICs), prioritizes them, and routes the highest-priority pending one to a designated hart context. It is built around three per-source state machines (a gateway, a pending bit, a priority register) and three per-context registers (an enable bitmap, a priority threshold, and a claim/complete register), all memory-mapped in a flat 64 MB region. A handler reads the claim register to atomically learn the source ID being delivered and clear its pending bit; it writes the same ID back to signal completion (RISC-V PLIC Specification, riscv/riscv-plic-spec). The PLIC is what backs the MEIP (machine external interrupt pending) bit of the
mipCSR; without one, the hart would have no way to distinguish “some external device wants service” from “which one” (Priv ISA v20211203, mip CSR).
What Problem the PLIC Solves
A RISC-V hart has exactly one external-interrupt input bit per privilege level. The Privileged ISA defines bit 11 of mip (MEIP, machine external interrupt pending) and bit 9 (SEIP, supervisor external interrupt pending), but says nothing about which device asserted that bit. The job of routing the dozens of device interrupt lines on a chip down to that single bit, deciding which one wins if two fire simultaneously, and giving software a way to ask “which source?” without polling every device, is delegated to a separate controller. That controller is the PLIC.
By design, the PLIC keeps the CPU side dumb. The hart only needs to handle a single interrupt entry point and let the PLIC tell it what to actually service. New interrupt sources can be added without touching the trap handler’s vector table.
Mental Model
flowchart LR subgraph SOURCES["Interrupt sources (up to 1023)"] SRC1["Source 1<br/>(UART RX)"] SRC2["Source 2<br/>(Timer aux)"] SRCN["Source N"] end subgraph GW["Gateways"] G1["GW 1"] G2["GW 2"] GN["GW N"] end subgraph PLIC["PLIC core"] PEND["Pending bits (1 per source)"] PRI["Priority registers (one 32-bit per source)"] EN["Enable bits (per-context bitmap)"] THR["Threshold (per context)"] SELECT{"max(pri) > thr ?"} end subgraph TARGETS["Targets = hart contexts"] HART0M["Hart 0 · M-mode"] HART0S["Hart 0 · S-mode"] HART1M["Hart 1 · M-mode"] end SRC1 --> G1 --> PEND SRC2 --> G2 --> PEND SRCN --> GN --> PEND PEND --> SELECT PRI --> SELECT EN --> SELECT THR --> SELECT SELECT -- "meip / seip" --> HART0M SELECT --> HART0S SELECT --> HART1M
Data flow from device pins to a hart’s external-interrupt bit. What it shows: each source has its own gateway that converts a raw interrupt signal (level or edge) into one request and holds it until the handler completes. The PLIC core maintains a pending bit, a priority register, and per-context enable bits and thresholds; whenever a context has at least one enabled, pending source whose priority exceeds the context’s threshold, the PLIC drives that context’s external-interrupt input. The insight to take: “context” is the right granularity, not “hart.” A single hart has separate machine and supervisor external-interrupt inputs (meip and seip in mip), so it counts as at least two contexts. The PLIC routes per context, not per hart.
Mechanical Walk-through
Interrupt sources, gateways, pending bits
A PLIC supports up to 1024 sources with IDs 1 through 1023; ID 0 is reserved to mean “no interrupt” so the claim register can use zero as the no-pending sentinel (PLIC spec, “Interrupt Source”). Each source has a private gateway, which is the small state machine that converts the raw external signal into a single request. The behaviour depends on whether the source is level-sensitive or edge-sensitive:
- Level-triggered gateway: “Gateway will convert the first assertion of the interrupt level into an interrupt request” and then refuse to forward any new request “until receiving completion notification” from the target (PLIC spec, gateway section). This avoids the classic “interrupt storm” where a level line that the handler has not yet cleared keeps re-firing.
- Edge-triggered gateway: converts each matching edge into a request, optionally tracking pending counts so a burst of edges is not lost.
- Message-signaled (MSI): the gateway decodes an incoming packet and treats it “similar to an edge-triggered interrupt” (PLIC spec).
Once the gateway has converted the input to a request, the PLIC sets the pending bit for that source. The pending bit array is exposed read-only to software starting at PLIC base + 0x1000, packed 32 sources per 32-bit word: source n’s bit is at bit n mod 32 of word n / 32. Source 0’s bit is hardwired to zero. Software cannot directly clear a pending bit; the only way to clear one is to claim the interrupt (see below).
Priorities and thresholds
Each source gets a 32-bit priority register at offsets 0x0000_0000 through 0x0000_0FFC (4 KB total, supporting all 1024 source IDs). Priority 0 means “never interrupt”; higher numbers are higher priority. The width of the priority field is implementation-defined; the FU540 only implements three bits, giving “7 levels of priority” (FU540-C000 section 10).
Each context (a (hart, privilege-level) pair, e.g. “hart 0, machine mode”) gets:
- A 32 × 32 = 1024-bit enable bitmap at offsets starting at
0x0000_2000, where bitnof the bitmap controls whether sourcenis allowed to interrupt this context. Each context occupies128bytes; up to 15872 contexts are supported. - A priority threshold register at the 4 KB-aligned base
0x0020_0000 + 0x1000 × context. “The PLIC will mask all PLIC interrupts of a priority less than or equal to threshold” (PLIC spec, “Threshold”). A threshold of zero permits every non-zero priority; a threshold of seven on a 3-bit priority chip silences everything. - A claim/complete register at threshold-base +
0x4.
The PLIC keeps each context’s external-interrupt output (its MEIP or SEIP into the hart) asserted whenever there exists at least one source that is (a) pending, (b) enabled for this context, and (c) of priority strictly greater than the context’s threshold. Ties are broken by source ID: “when priorities match, interrupts with the lowest ID have the highest effective priority” (PLIC spec, “Priority”).
The claim/complete handshake
This is the part that gives the PLIC its distinctive feel.
sequenceDiagram participant DEV as Device participant GW as Gateway participant PLIC as PLIC core participant HART as Hart context participant HND as Trap handler DEV->>GW: raise IRQ line GW->>PLIC: request (sets pending bit) PLIC->>HART: assert MEIP / SEIP (priority > threshold) HART->>HND: trap to mtvec / stvec HND->>PLIC: load claim register PLIC-->>HND: returns source ID; clears that pending bit HND->>DEV: read status, ack at device level HND->>PLIC: store source ID to claim register PLIC->>GW: completion notification GW->>GW: ready to convert next assertion
Claim and complete. What it shows: the gateway holds the request until completion, the PLIC atomically returns the highest-priority pending source on read and clears its pending bit, the handler is responsible for talking to the device, and the same address is written back to mark the source done. The insight to take: there are two separate acknowledgements at play. The handler must clear the device-level interrupt (e.g. read the UART’s LSR or write its interrupt-status register) and tell the PLIC the source is done. Forgetting either step leaves the system stuck.
The claim register read is the magic. The PLIC “will atomically determine the ID of the highest-priority pending interrupt for the target and then clear down the corresponding source’s IP bit. The PLIC core will then return the ID to the target” (PLIC spec, “Interrupt Claim Process”). The read returns 0 if no enabled-and-above-threshold source is pending, which the handler can use as a sentinel.
The completion write does not validate the ID. “The PLIC does not check whether the completion ID is the same as the last claim ID for that target. If the completion ID does not match an interrupt source that is currently enabled for the target, the completion is silently ignored” (PLIC spec, “Interrupt Completion”). This makes the protocol robust to handlers that nest claims or wait briefly before completing.
Memory map summary
The PLIC’s memory layout, condensed from the spec, is:
| Block | Offset | Size |
|---|---|---|
| Priority registers | 0x0000_0000 .. 0x0000_0FFC | 4 KB |
| Pending bits | 0x0000_1000 .. 0x0000_107C | 128 B |
| Per-context enables | 0x0000_2000 .. 0x001F_1FFC | ~2 MB |
| Threshold + claim | 0x0020_0000 + 0x1000 × context | 64 MB |
The FU540 places its PLIC base at 0x0C00_0000 and implements 53 interrupt sources with 7 priority levels, and gives each of its five harts both an M-mode and an S-mode context (except hart 0, which is the small E51 management core and only has M-mode) (FU540-C000 section 10.1). QEMU’s virt machine puts the PLIC at 0xC00_0000 too, matching the SiFive convention. All naturally aligned 32-bit memory accesses; “The PLIC memory map has been designed to only require naturally aligned 32-bit memory accesses” (FU540-C000 section 10.1).
Configuration / Code
A minimal Rust PLIC driver and trap handler for definitely-not-esp32. We assume a single-hart RV32 system where context 0 is hart 0 in M-mode, the PLIC base is 0xC00_0000, and the only enabled source is the UART at ID 10.
const PLIC_BASE: usize = 0x0C00_0000;
const UART_IRQ: u32 = 10;
const CTX_M0: usize = 0;
#[inline]
fn priority_reg(src: u32) -> *mut u32 {
(PLIC_BASE + 4 * src as usize) as *mut u32
}
#[inline]
fn enable_word(ctx: usize, src: u32) -> *mut u32 {
let bytes = 0x2000 + ctx * 0x80 + (src as usize / 32) * 4;
(PLIC_BASE + bytes) as *mut u32
}
#[inline]
fn threshold_reg(ctx: usize) -> *mut u32 {
(PLIC_BASE + 0x20_0000 + ctx * 0x1000) as *mut u32
}
#[inline]
fn claim_reg(ctx: usize) -> *mut u32 {
(PLIC_BASE + 0x20_0004 + ctx * 0x1000) as *mut u32
}
pub fn plic_init() {
unsafe {
// Give UART priority 1 (any non-zero priority is enough on
// a single-source design).
priority_reg(UART_IRQ).write_volatile(1);
// Enable UART_IRQ for context M0: set bit (UART_IRQ mod 32)
// in the word at enable_word(M0, UART_IRQ).
let bit = 1u32 << (UART_IRQ % 32);
let w = enable_word(CTX_M0, UART_IRQ);
w.write_volatile(w.read_volatile() | bit);
// Threshold 0: don't mask any non-zero priority.
threshold_reg(CTX_M0).write_volatile(0);
}
}
pub fn trap_external() {
unsafe {
let id = claim_reg(CTX_M0).read_volatile();
if id == 0 {
// Spurious / no source above threshold.
return;
}
match id {
UART_IRQ => crate::uart::on_rx(),
other => panic!("unknown PLIC source {}", other),
}
// Hand the source back to the gateway: completion.
claim_reg(CTX_M0).write_volatile(id);
}
}Line-by-line: priority_reg indexes the dense 32-bit priority array starting at PLIC base. enable_word packs sources 32 at a time into 32-bit words; context M0’s word for source 10 lives at offset 0x2000 + 0 × 0x80 + 0 × 4 = 0x2000 and bit 10. threshold_reg and claim_reg exploit the 4 KB stride between contexts. plic_init sets priority, enables the source for this context, and lowers the threshold. The trap handler reads the claim register: a zero return means “no enabled source above threshold,” which is the PLIC’s way of saying the trap was spurious or already serviced. We then dispatch by source ID, and a single store to the claim register notifies the gateway. The order matters: clearing the device-level interrupt (in on_rx, by reading the UART’s RBR / LSR) before the PLIC completion ensures the source line falls before the gateway is rearmed; otherwise we trap immediately on mret.
Failure Modes and Common Misunderstandings
- Completion order matters. Forgetting to clear the device’s own interrupt before writing the PLIC completion register leaves the source line asserted; on a level-triggered gateway the request fires again the moment the gateway is rearmed, and the handler retraps forever. Diagnose by hand: read the device’s interrupt-status register from a debugger and check it is zero.
- Don’t confuse “context” with “hart.” A single hart usually has two contexts, M and S. Mis-indexing the enable bitmap (using hart id where context id is expected) silently routes interrupts to the wrong privilege mode. The device tree’s
interrupts-extendedproperty is the source of truth: it maps a source ID to a(plic, context-id)pair. - The threshold is “strictly greater than,” not ”>=”. A source with priority 1 will fire when the threshold is 0 and will not fire when the threshold is 1 (PLIC spec).
- Reading the claim register is destructive. The pending bit is cleared as a side effect. So claiming under a debugger pauses can lose interrupts; on most cores this means the debugger must use the abstract command channel rather than ordinary loads.
- Priority ties go to the lowest source ID. Two sources at the same priority with simultaneous requests give the lower-numbered one the lock until it completes. Boards that put a chatty high-volume source (a 10 Mb/s UART) at ID 1 will starve everything else; reserve low IDs for low-volume sources.
- The PLIC does not nest. A handler must service a single claim before reading the claim register for another. RISC-V achieves nested interrupts at the CPU level by enabling
mstatus.MIEinside the handler; the PLIC itself remains single-issue per context.
Alternatives and When to Choose Them
The PLIC has known limitations: it does not support message-signaled interrupts cleanly, has no native concept of an interrupt level above the threshold model, and was designed before virtualization in RISC-V. RISC-V International addressed all of this with the Advanced Interrupt Architecture (AIA), a two-part specification: APLIC (Advanced PLIC), which is a refresh of the PLIC’s wired-interrupt model, and IMSIC (Incoming MSI Controller), which adds first-class message-signaled-interrupt support. AIA also “adds support for message-signaled interrupts (MSIs) from devices, guest OS control of device interrupts in virtual supervisor mode, additional standard local interrupts for RISC-V processors, priority intermixing between local and external interrupts, and conditional delegation of local interrupts to lower privilege levels and virtual machines” (RISC-V AIA repository, github.com/riscv/riscv-aia).
For a small embedded system without virtualization or PCIe, the original PLIC is enough and is the path of least resistance: Linux, the SBI, and every open-source RISC-V kernel speak it natively. For a server-class chip that wants to forward thousands of MSI vectors to a guest OS, AIA wins.
The other category of alternative is “no controller at all”: chips like Espressif’s ESP32-C3 (RV32IMC, single hart) bypass the PLIC and provide a custom interrupt matrix tightly bound to their peripherals. This is fine for a closed ecosystem with a hand-tuned BSP but means a Linux port has to write a new IRQ chip driver, which is exactly why the PLIC is preferred for anything that wants an upstream Linux story.
Production Notes
- SiFive FU540 is the reference: 53 sources, 7 priorities, 5 harts with mixed M/S contexts, PLIC at
0x0C00_0000(FU540-C000 section 10). Every Linux RISC-V port traces itsriscv,plic0device-tree binding back to this layout. - QEMU
virtmachine instantiates the PLIC at base0xC00_0000with the SiFive layout. Linux’sdrivers/irqchip/irq-sifive-plic.cis the driver; it walks the device tree to find sources and contexts and uses the claim/complete protocol exactly as the spec specifies. The driver supports the compatible strings"sifive,plic-1.0.0","riscv,plic0", plus a handful of vendor variants ("andestech,nceplic100","thead,c900-plic","ultrarisc,cp100-plic"), each with its own slight quirks; per-CPUplic_handlerstructures hold the hart-specific threshold and claim register pointers, and a spinlock protects the non-atomic per-bit enable register updates (Linuxdrivers/irqchip/irq-sifive-plic.c, torvalds/linux). - OpenSBI handles the IRQ delegation: when a kernel is in S-mode, OpenSBI configures
midelegto delegate SEIP to S-mode and configures the PLIC’s M-mode context to ignore S-mode sources. The kernel then uses its own S-mode claim register at the appropriate context offset. - In definitely-not-esp32’s v1.0 the PLIC is the only external IRQ controller. The UART RX line goes to source ID 10, the CLINT’s software interrupt is signalled directly via
msip(not through the PLIC), and the timer interrupt comes from the CLINT’smtimecmp. The PLIC only needs to be configured for the UART for the kernel to be usable.