The hart priority threshold is also set here.

  • PLIC_SPRIORITY(hart) selects this hart’s supervisor-mode threshold register
  • xv6 writes threshold 0
  • A pending interrupt is delivered only if its priority is greater than the threshold
  • UART and virtio were given priority 1 in plicinit
  • Since 1 > 0, this hart can receive those interrupts

External device interrupts then pass through devintr.

  • The CPU enters the supervisor trap path
  • devintr reads scause
  • Supervisor external interrupt means the interrupt came through the PLIC
  • plic_claim asks the PLIC which device interrupted
  • plic_complete tells the PLIC that the device interrupt has been handled

UART interrupt path:

  • PLIC reports UART0_IRQ
  • devintr calls uartintr
  • uartintr reads pending bytes from the UART
  • Each input byte is passed to consoleintr
  • consoleintr updates the console input buffer and wakes consoleread when input is ready

Virtio disk interrupt path:

  • PLIC reports VIRTIO0_IRQ
  • devintr calls virtio_disk_intr
  • virtio_disk_intr marks completed disk requests as done
  • Sleeping processes waiting for disk I/O are woken

After this, the current hart can receive external interrupts from the UART and virtio disk through the PLIC.

Interrupts and Device Drivers

Driver Architecture

Drivers manage specific hardware devices by configuring hardware, initiating operations, handling resulting interrupts, and interacting with waiting processes. Device interrupts are a class of traps routed through the kernel’s trap handling logic (e.g., devintr). Driver execution is structured into two concurrent contexts:

  • Top half: Executes within a process’s kernel thread via system calls (e.g., read, write). Asks the hardware to initiate operations and yields the CPU to wait for completion.
  • Bottom half: Executes asynchronously at interrupt time. Identifies completed operations, wakes waiting processes, and issues the next pending hardware command.

Separating device management into process-driven top halves and asynchronous bottom halves provides the architectural foundation for handling unpredictable external events, such as console input.

Initialization

xv6 configures the console/UART once during boot so later console input can be handled by interrupts instead of polling.

  • Purpose: xv6 uses the UART as the console device for keyboard input and terminal output.
  • QEMU setup: In QEMU, the UART is simulated. The keyboard/display are connected to xv6 through QEMU’s emulated UART.
  • UART hardware model: xv6 talks to a 16550 UART chip, emulated by QEMU.
  • Memory-mapped I/O: UART registers are exposed at physical addresses, not normal RAM.
  • UART base address: UART0 = 0x10000000
  • Control registers: UART registers are byte-wide and accessed using offsets from UART0.
  • Receive side:
    • UART stores received input bytes in an internal FIFO.
    • LSR register tells whether input is ready.
    • RHR register is used to read received bytes.
    • Reading from RHR removes that byte from the UART FIFO.
  • Transmit side:
    • Software writes output bytes to THR.
    • UART sends those bytes to the terminal/display.
  • Initialization entry point:
    • main() calls consoleinit().
  • What consoleinit() does:
    • Initializes the console lock.
    • Connects console read/write functions to xv6’s device switch table.
    • Calls uartinit() to initialize UART hardware.
  • What uartinit() configures:
    • Enables UART receive interrupts.
    • Enables UART transmit-complete interrupts.
    • Sets up UART so xv6 is notified when input arrives or output is ready for more bytes.

Input:

Output:

Concurrency

Driver data structures are vulnerable to three distinct concurrency vectors that require lock protection:

  • Simultaneous execution of top-half routines by multiple processes on different CPUs.
  • Hardware interrupting a CPU while it is mid-execution in a top-half routine.
  • Hardware delivering an interrupt on a secondary CPU concurrently with top-half execution on a primary CPU. Interrupt delivery creates a separate constraint: the process waiting for a device may not be the process running when the interrupt arrives, and there may be no current user process at all. Thus interrupt handlers cannot assume the interrupted process is the one waiting for the device or safely use its page table (e.g., copyout with the current process). Bottom halves therefore do minimal work: copy data into a kernel buffer, update device state, and wake the top half to finish the rest.

Timer Interrupts

  • Periodic timer interrupts drive the system clock and enforce preemptive thread scheduling via yield.
  • xv6 uses them to maintain time and to switch among compute-bound processes; the yield calls in usertrap and kerneltrap perform that switching.
  • Modern xv6 uses the RISC-V sstc extension, so supervisor mode can receive timer interrupts directly.
  • Early machine-mode setup prepares timer delivery before the kernel proper starts:
    • delegates interrupts and exceptions to supervisor mode,
    • enables supervisor external and timer interrupts,
    • enables sstc through menvcfg,
    • allows supervisor mode to read the time counter through mcounteren, and
    • schedules the first timer interrupt with stimecmp.
  • The hardware time counter keeps increasing.
  • When time reaches stimecmp, the hardware raises a supervisor timer interrupt.
  • The normal supervisor trap path handles the interrupt; the old machine-mode forwarding path through CLINT is not used here.
  • The next timer event is scheduled by writing a later value to stimecmp.

Real-World

Xv6 allows device and timer interrupts while running kernel code as well as user code. Timer interrupts may call yield even from kernel context, which helps time-slice compute-bound kernel threads. The cost is extra complexity: kernel code must tolerate being suspended and later resumed, possibly on a different CPU. A simpler kernel could permit interrupts only while running user code, but that would reduce fairness and responsiveness for long-running kernel work.

On real systems, drivers often account for more code than the core kernel because there are many devices, many features, and often poorly documented protocols.

  • Programmed I/O: The driver moves data by explicitly reading and writing device registers. This is simple, but too slow for high-rate devices.
  • DMA: The device transfers data directly to and from RAM. The driver prepares buffers in memory and kicks off the transfer with a control-register write.
  • Interrupt mitigation and polling:
    • High-speed devices often batch work and raise one interrupt for many completions.
    • Under heavy load, drivers may disable interrupts and poll device state instead.
    • Some systems switch dynamically between polling and interrupts based on load.

For very fast devices, copying data first into a kernel buffer and then into user space can be too expensive. Real systems may use zero-copy techniques, often built around DMA, to move data more directly between devices and user buffers. When applications need device-specific controls that do not fit read and write, Unix systems expose them through ioctl.

Xv6 is also unsuitable for hard or soft real-time work. Its scheduler is too simple, and some kernel paths keep interrupts disabled for too long to guarantee bounded response times.

PLIC Block Diagram


---
config:
  layout: dagre
---
flowchart LR

  classDef file fill:#FFFFFF,stroke:#111,stroke-width:3px,color:#111
  classDef source fill:#E9F1FF,stroke:#111,stroke-width:2px,color:#111
  classDef iface fill:#EDE7D4,stroke:#111,stroke-width:2px,color:#111
  classDef backend fill:#F8F8F8,stroke:#111,stroke-width:2px,color:#111

  subgraph DEV["external interrupt sources"]
    direction TB
      UART_HW["UART hardware<br/><br/>UART0_IRQ = 10"]:::source
      VIRTIO_HW["virtio disk hardware<br/><br/>VIRTIO0_IRQ = 1"]:::source
  end

  subgraph SETUP["PLIC setup interface"]
    direction TB
      PLICINIT["plicinit()<br/><br/>set device priorities"]:::iface
      PLICINITHART["plicinithart()<br/><br/>enable device IRQs for this hart<br/>set threshold = 0"]:::iface
  end

  subgraph PLIC_BOX["PLIC hardware state"]
    direction TB
      PLIC_STATE["PLIC<br/><br/>tracks pending external IRQs<br/>checks priority / enable / threshold<br/>routes IRQ to a hart<br/>supports claim + complete"]:::source
  end

  subgraph TRAP["trap-side interface"]
    direction TB
      CPU["RISC-V hart<br/><br/>supervisor external interrupt<br/>scause = external interrupt"]:::process
      DEVINTR["devintr()<br/><br/>classify interrupt<br/>external device / timer / unknown"]:::iface
      CLAIM["plic_claim()<br/><br/>read claimed IRQ number"]:::iface
      COMPLETE["plic_complete(irq)<br/><br/>mark IRQ as handled"]:::iface
  end

  subgraph BACKEND["driver backend after IRQ dispatch"]
    direction TB
      UARTINTR["uartintr()<br/><br/>console interrupt handler"]:::backend
      VIRTIOINTR["virtio_disk_intr()<br/><br/>disk interrupt handler"]:::backend
      UNKNOWN["unexpected irq<br/><br/>print warning"]:::backend
  end

  subgraph NONPLIC["not PLIC path"]
    direction TB
      TIMER["supervisor timer interrupt<br/><br/>clockintr()<br/>does not use PLIC"]:::backend
  end

  PLICINIT -->|"configures priorities"| PLIC_STATE
  PLICINITHART -->|"configures per-hart enables"| PLIC_STATE

  UART_HW -->|"raises IRQ 10"| PLIC_STATE
  VIRTIO_HW -->|"raises IRQ 1"| PLIC_STATE

  PLIC_STATE -->|"delivers external interrupt"| CPU
  CPU -->|"trap enters kernel"| DEVINTR
  DEVINTR -->|"external interrupt branch"| CLAIM

  CLAIM -->|"reads claim register"| PLIC_STATE

  CLAIM -->|"irq == UART0_IRQ"| UARTINTR
  CLAIM -->|"irq == VIRTIO0_IRQ"| VIRTIOINTR
  CLAIM -->|"other nonzero irq"| UNKNOWN

  UARTINTR -->|"handled"| COMPLETE
  VIRTIOINTR -->|"handled"| COMPLETE
  UNKNOWN -->|"handled / reported"| COMPLETE

  COMPLETE -->|"writes completion"| PLIC_STATE

  DEVINTR -.->|"timer branch bypasses PLIC"| TIMER