Core Local Interruptor

The Core Local Interruptor, or CLINT, is the RISC-V SoC block that holds the machine-mode timer and the machine-mode software interrupt for each hart. It is a flat memory-mapped region that the kernel writes (to set the next timer deadline or to fire a cross-hart interrupt) and that the hardware reads (to compare the running time against the deadline and pull the corresponding bit of mip). Originally a SiFive convention exposed by the FU540 chip and inherited by QEMU’s virt machine, the design has been formalized by RISC-V International as the Advanced CLINT (ACLINT) specification, which splits the block into three smaller standardized devices: MTIMER, MSWI, and SSWI (ACLINT spec, riscv/riscv-aclint; SiFive FU540-C000 Manual section 9; QEMU virt machine memory map, hw/riscv/virt.c).

Why a CLINT Exists at All

The RISC-V privileged ISA defines an abstract “machine timer interrupt” and an abstract “machine software interrupt.” Section 3.1.9 of the Privileged Architecture defines the mip (machine interrupt pending) CSR; bit 7 of mip is MTIP (machine timer interrupt pending) and bit 3 is MSIP (machine software interrupt pending) (RISC-V Privileged Architecture v20211203, Chapter 3). The ISA does not specify how these bits get set. The platform spec says only that “Platforms provide a real-time counter, exposed as a memory-mapped machine-mode read-write register, mtime” and “a 64-bit memory-mapped machine-mode timer compare register (mtimecmp). A machine timer interrupt becomes pending whenever mtime contains a value greater than or equal to mtimecmp” (Priv v20211203, section 3.2.1).

A CLINT is the device that makes those abstract registers concrete. It is what every RISC-V chip vendor places in its memory map so an operating system can schedule a tick.

Mental Model

flowchart TB
  subgraph CLINT["CLINT  (0x0200_0000 on canonical maps)"]
    MTIME["mtime  (64-bit)<br/>increments at RTCCLK"]
    CMP0["mtimecmp[0]"]
    CMP1["mtimecmp[1]"]
    MSIP0["msip[0]  (32-bit, only bit 0 used)"]
    MSIP1["msip[1]"]
    CMP_GT["GE comparator"]
    MTIME --> CMP_GT
    CMP0 --> CMP_GT
    CMP1 --> CMP_GT
  end
  CMP_GT -- "mtip[hart]" --> HART_MIP["mip CSR · MTIP bit"]
  MSIP0 -- "msip[hart]" --> HART_MIP_S["mip CSR · MSIP bit"]
  HART_MIP --> TRAP["Trap handler (M-mode)"]
  HART_MIP_S --> TRAP

The CLINT data flow. What it shows: one shared 64-bit mtime counter feeds a per-hart greater-than-or-equal comparator against each hart’s mtimecmp; when the comparator fires, it directly drives the MTIP bit of that hart’s mip CSR. Each hart also has its own one-bit msip register that mirrors directly into the MSIP bit of its mip. The insight to take: the CLINT does no scheduling, no prioritization, no claim/complete handshake; it is a counter and a small bank of comparators. All policy lives in software. The kernel’s tick handler sets the next mtimecmp value; the kernel’s inter-processor signaling code writes 1 to another hart’s msip to ping it.

Mechanical Walk-through

The machine timer

The privileged spec is precise about mtime’s contract. It must “increment at constant frequency, and the platform must provide a mechanism for determining the period of an mtime tick.” It is 64 bits wide on both RV32 and RV64 (so it does not wrap in any realistic system lifetime: a 1 MHz tick takes about 585 thousand years to overflow). The compare register mtimecmp is also 64 bits. The interrupt is purely combinational: “A machine timer interrupt becomes pending whenever mtime contains a value greater than or equal to mtimecmp, treating the values as unsigned integers.” The interrupt clears the moment mtimecmp becomes greater than mtime, which the kernel arranges by writing a larger value than the current mtime (Priv v20211203, section 3.2.1).

The spec also warns that “Writes to mtime and mtimecmp are guaranteed to be reflected in MTIP eventually, but not necessarily immediately. A spurious timer interrupt might occur if an interrupt handler increments mtimecmp then immediately returns” (Priv v20211203 section 3.2.1). The handler must tolerate the occasional re-entry. The reason the timer is memory-mapped rather than a CSR is that “Accurate real-time clocks (RTCs) are relatively expensive to provide (requiring a crystal or MEMS oscillator) and have to run even when the rest of system is powered down… It is thus more natural to expose mtime as a memory-mapped register than as a CSR” (Priv v20211203 section 3.2.1).

The software interrupt

Each hart also gets a msip register. The spec at the ISA level only requires the MSIP bit of mip; the CLINT provides the memory-mapped backing. SiFive’s FU540 manual describes the exact behaviour: “Machine-mode software interrupts are generated by writing to the memory-mapped control register msip. Each msip register is a 32-bit wide WARL register where the upper 31 bits are tied to 0. The least significant bit is reflected in the MSIP bit of the mip CSR” (FU540-C000 section 9.2). The note adds: “Software interrupts are most useful for interprocessor communication in multi-hart systems, as harts may write each other’s msip bits to effect interprocessor interrupts.”

A single-hart microcontroller still gets a use for msip: the kernel itself can post a self-interrupt to defer work to the trap handler, which is the RISC-V equivalent of a Linux softirq.

The canonical legacy memory map

The de facto layout that every RISC-V OS treats as default came from SiFive’s CLINT and is exactly what QEMU’s virt machine implements. The FU540 manual, Table 36, defines the map as (FU540-C000 section 9.1):

AddressWidthRegister
0x0200_00004 Bmsip for hart 0
0x0200_00044 Bmsip for hart 1
4 Bmsip for hart N
0x0200_40008 Bmtimecmp for hart 0
0x0200_40088 Bmtimecmp for hart 1
8 Bmtimecmp for hart N
0x0200_BFF88 Bmtime (shared)

The relative offsets, then, are: msip[hart] = 0x0000 + 4 × hart, mtimecmp[hart] = 0x4000 + 8 × hart, mtime = 0xBFF8. QEMU’s hw/riscv/virt.c places the CLINT at base 0x0200_0000 with size 0x10000 (QEMU virt.c), which is precisely the FU540 layout. This is why almost every RISC-V boot loader (OpenSBI, the boot ROM in countless soft cores, Linux’s riscv,clint0 device-tree binding) “just works” against this address: the layout is a defacto standard.

The ACLINT split

When RISC-V International promoted this defacto layout to an official spec, they split it into three smaller modular devices and gave the result a new name: ACLINT, for Advanced Core Local Interruptor (ACLINT spec, github.com/riscv/riscv-aclint).

  • MTIMER: holds the shared mtime counter and an array of per-hart mtimecmp registers. Within an MTIMER device, mtimecmp[hart] lives at offset 0x0000 + 8 × hart and the shared mtime lives at offset 0x7FF8. The platform is free to put MTIMER at any base address; what matters is the layout within the device.
  • MSWI: the machine-mode software-interrupt block, holding the array of per-hart msip registers at offsets 0x0000 + 4 × hart. Each register is “32-bit wide WARL register where the upper 31 bits are wired to zero. The least significant bit is reflected in MSIP of the mip CSR” (ACLINT spec).
  • SSWI: a supervisor-mode software-interrupt block, identical in layout to MSWI but driving the SSIP bit of mip so an S-mode kernel can ping itself or another hart without going through M-mode firmware. Registers are named setssip[hart] for clarity.

The ACLINT spec gives explicit compatibility instructions: “One SiFive CLINT device is equivalent to two ACLINT devices: MSWI at offsets 0x00000000 - 0x00003FFF, MTIMER at offsets 0x00004000 - 0x0000BFFF” (ACLINT spec). Walking the arithmetic: the MTIMER device sits at offset 0x4000 within the legacy block; inside MTIMER, mtime lives at MTIMER-base + 0x7FF8; so the global mtime address is 0x0200_0000 + 0x4000 + 0x7FF8 = 0x0200_BFF8, which matches the legacy table exactly.

The hart-private semantics

A CLINT is “core local” only in name; physically the CLINT is one device shared by every hart on the chip, and it is reached over the regular SoC bus (Wishbone Bus in our case). What makes it core-local is that each hart has its own slot in the mtimecmp and msip arrays, and the hardware wires that slot’s pending state directly into that hart’s mip CSR. So even though the device is shared, the effect of a write is per-hart. This is why a multi-hart kernel typically reserves one slot per hart in the device tree and indexes by hart id when setting the next deadline.

Configuration / Code

A clean Rust skeleton for the definitely-not-esp32 kernel’s tick handler, assuming a single-hart RV32IMC and the canonical memory map:

//! Minimal CLINT driver for one hart on the canonical RISC-V memory map.
 
const CLINT_BASE: usize     = 0x0200_0000;
const MSIP_OFFSET: usize    = 0x0000; // msip[0]
const MTIMECMP_OFFSET: usize = 0x4000; // mtimecmp[0]
const MTIME_OFFSET: usize   = 0xBFF8; // shared mtime
 
/// Read the 64-bit shared timer. On RV32, this is two 32-bit reads with
/// the well-known "loop until the high word matches" trick.
pub fn read_mtime() -> u64 {
    let mtime = (CLINT_BASE + MTIME_OFFSET) as *const u32;
    loop {
        let hi1 = unsafe { mtime.offset(1).read_volatile() };
        let lo  = unsafe { mtime.offset(0).read_volatile() };
        let hi2 = unsafe { mtime.offset(1).read_volatile() };
        if hi1 == hi2 {
            return ((hi1 as u64) << 32) | (lo as u64);
        }
    }
}
 
/// Set the next deadline for *this* hart. Uses the spec-recommended
/// "write -1 to the low half first" sequence to avoid spurious interrupts.
pub fn set_next_deadline(target: u64) {
    let cmp_lo = (CLINT_BASE + MTIMECMP_OFFSET) as *mut u32;
    let cmp_hi = unsafe { cmp_lo.offset(1) };
    unsafe {
        cmp_lo.write_volatile(u32::MAX);        // never less than old or new
        cmp_hi.write_volatile((target >> 32) as u32);
        cmp_lo.write_volatile(target as u32);
    }
}
 
/// Fire a software interrupt at hart `n`.
pub fn raise_software_interrupt(hart: usize) {
    let msip = ((CLINT_BASE + MSIP_OFFSET) as *mut u32).wrapping_add(hart);
    unsafe { msip.write_volatile(1) };
}
 
/// Clear the software interrupt for the current hart.
pub fn clear_software_interrupt(hart: usize) {
    let msip = ((CLINT_BASE + MSIP_OFFSET) as *mut u32).wrapping_add(hart);
    unsafe { msip.write_volatile(0) };
}

Line-by-line: the three offsets are the legacy CLINT layout. read_mtime uses the canonical 32-bit mtime read sequence so a high-word rollover between the two 32-bit reads cannot be observed; this is the same trick Linux uses on RV32. set_next_deadline follows the Priv-spec recommended sequence verbatim (Priv v20211203 figure 3.29): write -1 to the low half so the comparand cannot momentarily fall below the old value; then write the new high half; then write the new low half. Without the first write, a non-monotonic comparand can briefly become smaller than mtime and fire a spurious interrupt. raise_software_interrupt writes 1 to bit 0 of the target msip; the upper 31 bits are WARL-tied to zero so the value written into them is ignored.

Failure Modes and Common Misunderstandings

  • The spurious interrupt warning is real. The Priv spec calls it out: a handler that bumps mtimecmp and mrets may immediately retrap because MTIP “might not yet have fallen in the interim” (Priv v20211203 section 3.2.1). The fix is to write mtimecmp, then poll mip.MTIP only if it really matters; in practice, “It is almost always more performant to incur an occasional spurious timer interrupt than to poll MTIP until it falls” (same paragraph).
  • mtime ticks at the RTC clock, not the CPU clock. Counting CPU cycles requires mcycle, not mtime. Using mtime to measure short code sequences silently aliases at the RTC granularity (often 1 MHz, so 1 microsecond per tick). The FU540’s mtime is driven by the RTCCLK input “described in Chapter 7” (FU540-C000 section 9.3).
  • RV32 must read mtime atomically. A naive load pair across a high-word rollover gives a value 4 G ticks off. The Linux-style retry loop in the code listing above is mandatory.
  • The legacy CLINT vs ACLINT confusion. The brief listed “mtimecmp[hart] at 0x4000 + 8*hart, mtime at 0xBFF8” using the legacy layout; in pure ACLINT terms, mtimecmp is at 0x0000 + 8 × hart and mtime is at 0x7FF8, but only within the MTIMER device. The bridging convention places the MTIMER device at offset 0x4000 of the legacy block, which makes the two views consistent. New designs that want to be ACLINT-compliant should expose MTIMER, MSWI, and SSWI as separate device-tree nodes, each at its own base.
  • Don’t poll mtime in a tight loop on a multi-hart system. Every hart reading mtime is hitting the same memory-mapped register; on a chip where the CLINT lives in a different clock domain (the FU540 explicitly puts it in tlClk), each read costs a clock-domain crossing.

Alternatives and When to Choose Them

The most direct alternative is the Advanced Interrupt Architecture (AIA), formalized as the riscv-aia specification. AIA replaces (or augments) the PLIC for external interrupts and introduces message-signaled interrupts; it does not, however, replace the CLINT for the timer and software interrupts. So in an AIA-equipped system, MTIMER and MSWI live alongside the new APLIC/IMSIC infrastructure rather than being subsumed by it (RISC-V AIA repo, github.com/riscv/riscv-aia).

For an in-order single-hart microcontroller, a simpler design is to skip the memory-mapped block entirely and provide only the CSRs time and timeh (which the Priv spec defines as read-only shadows of mtime) with a fixed-frequency tick. That works for the simplest case but loses cross-hart software interrupts and the ability for M-mode software to set mtimecmp for an S-mode kernel through the SBI. For anything serious, the ACLINT memory map wins.

Some chip vendors layer their own custom timer block on top of the CLINT; for instance, several Espressif ESP32-class RISC-V chips have their own systimer with extra features, but Linux still expects the CLINT-style mtime semantics, so the chip exposes both. The lesson: even where “the CLINT” is technically optional, the contract it implements is unavoidable.

Production Notes

  • QEMU virt at riscv64-softmmu is the de facto reference platform; it implements the legacy CLINT at 0x0200_0000 with size 0x10000 and the PLIC at 0xC00_0000 (QEMU hw/riscv/virt.c), exactly the FU540 layout. Booting Linux on QEMU and on a SiFive HiFive board uses the same device-tree CLINT entry.
  • OpenSBI (the canonical M-mode firmware that sits below an S-mode Linux kernel) treats the CLINT as the primary mechanism for both the timer SBI call and inter-processor interrupts. The ACLINT MSWI driver lib/utils/ipi/aclint_mswi.c shows the exact protocol: to fire an IPI at hart n, OpenSBI calls writel_relaxed(1, &msip[n - first_hartid]); to clear it, the target writes 0 to its own slot. The same file registers an sbi_ipi_device named “aclint-mswi” so that all higher-level OpenSBI IPI calls funnel through these two register accesses (OpenSBI aclint_mswi.c, riscv-software-src/opensbi).
  • Multi-hart Linux uses MSIP for SMP cross-call wake-ups, which is why the ACLINT split MSWI vs SSWI matters: with SSWI an S-mode Linux kernel can directly post supervisor-mode software interrupts to other harts without trapping into M-mode for every IPI, a measurable performance improvement on big-system designs.
  • In the definitely-not-esp32 kernel, the CLINT is the only timer source. The scheduler tick is set by set_next_deadline(read_mtime() + TICK_PERIOD) from the trap handler; MSIP is used to post a deferred-work interrupt from the UART RX path so the RX driver can run in M-mode trap context without preempting whoever holds the bus.

See Also