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 mip CSR; 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 bit n of the bitmap controls whether source n is allowed to interrupt this context. Each context occupies 128 bytes; 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:

BlockOffsetSize
Priority registers0x0000_0000 .. 0x0000_0FFC4 KB
Pending bits0x0000_1000 .. 0x0000_107C128 B
Per-context enables0x0000_2000 .. 0x001F_1FFC~2 MB
Threshold + claim0x0020_0000 + 0x1000 × context64 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-extended property 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.MIE inside 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 its riscv,plic0 device-tree binding back to this layout.
  • QEMU virt machine instantiates the PLIC at base 0xC00_0000 with the SiFive layout. Linux’s drivers/irqchip/irq-sifive-plic.c is 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-CPU plic_handler structures hold the hart-specific threshold and claim register pointers, and a spinlock protects the non-atomic per-bit enable register updates (Linux drivers/irqchip/irq-sifive-plic.c, torvalds/linux).
  • OpenSBI handles the IRQ delegation: when a kernel is in S-mode, OpenSBI configures mideleg to 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’s mtimecmp. The PLIC only needs to be configured for the UART for the kernel to be usable.

See Also