Devices that need attention from the operating system can usually be configured to generate interrupts, which are one type of trap. The kernel trap handling code recognizes when a device has raised an interrupt and calls the driver’s interrupt handler.

The PLIC routes external device interrupts to harts. Two functions configure it:

  • plicinit() sets device priorities, and
  • plicinithart() configures per-hart settings.

plicinithart() sets each hart’s priority threshold:

  • 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

When a device raises an interrupt, the PLIC delivers it to a hart. The hart enters the supervisor trap handler, which calls devintr to classify the interrupt:

  • 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

From devintr, the interrupt follows one of two device paths:

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

The following diagram traces the full interrupt path from device to handler:

---
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 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

UART

The UART (Universal Asynchronous Receiver/Transmitter) is xv6’s serial port device. The UART raises an interrupt when it receives input or is ready to transmit, and xv6 handles these interrupts through uartintr instead of polling the device.

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().
  • consoleinit():
    • Initializes the console lock.
    • Connects console read/write functions to xv6’s device switch table.
    • Calls uartinit() to initialize UART hardware.
  • uartinit():
    • 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.

The following sequence diagram shows console input flow from user typing to the shell reading:

sequenceDiagram
participant Shell as "Shell user process"
participant Read as "read syscall"
participant ConsoleRead as "consoleread"
participant ConsBuf as "cons buf"
participant Sched as "scheduler"
actor User as "User in QEMU"
participant UART as "UART hardware"
participant Trap as "trap handler"
participant Devintr as "devintr"
participant PLIC as "PLIC"
participant Uartintr as "uartintr"
participant Consoleintr as "consoleintr"
autonumber
Note over Shell: Mode U, satp User PT
Shell->>Read: read(fd, buf, n)
Note over Read,ConsoleRead: Mode S, satp Kernel PT
Read->>ConsoleRead: enter consoleread
ConsoleRead->>ConsoleRead: acquire cons.lock
loop while cons.r == cons.w
ConsoleRead->>Sched: sleep(&cons.r, &cons.lock)
Note over ConsoleRead: reader sleeps until console input is ready
end
Note over User,UART: External input event
User->>UART: type character in QEMU
UART->>Trap: raise external interrupt
Note over Trap: Mode S, satp Kernel PT
Trap->>Devintr: devintr()
Devintr->>PLIC: plic_claim()
PLIC-->>Devintr: UART0_IRQ
Devintr->>Uartintr: uartintr()
loop while (c = uartgetc()) >= 0
Uartintr->>UART: uartgetc()
UART-->>Uartintr: return received char
Uartintr->>Consoleintr: consoleintr(c)
alt Ctrl-U
Consoleintr->>Consoleintr: erase line using consputc(BACKSPACE)
else backspace/delete
Consoleintr->>Consoleintr: erase one char using consputc(BACKSPACE)
else normal character
Consoleintr->>Consoleintr: echo using consputc(c)
Consoleintr->>ConsBuf: cons.buf[cons.e++ % INPUT_BUF] = c
alt newline or Ctrl-D or buffer full
Consoleintr->>ConsBuf: cons.w = cons.e
Consoleintr->>Sched: wakeup(&cons.r), mark reader RUNNABLE
else line not complete
Note over ConsoleRead: reader remains asleep
end
end
end
Uartintr->>UART: uartstart(), resume pending UART output
Uartintr-->>Devintr: return
Devintr->>PLIC: plic_complete(UART0_IRQ)
Devintr-->>Trap: return
Trap-->>Sched: return to interrupted context
alt reader was awakened
Sched->>ConsoleRead: later schedule reader process
ConsoleRead->>ConsBuf: copy bytes from cons.buf
ConsoleRead->>ConsoleRead: release cons.lock
ConsoleRead-->>Read: return bytes
Read-->>Shell: read returns
Note over Shell: Mode U, satp User PT
else reader was not awakened
Note over ConsoleRead: still sleeping in consoleread
end

The following sequence diagram shows console output flow from the shell writing to bytes displayed on the terminal:

sequenceDiagram
autonumber
participant Shell as "Shell user process"
participant Write as "write syscall"
participant FileWrite as "filewrite"
participant ConsoleWrite as "consolewrite"
participant UartPutc as "uartputc"
participant TxBuf as "uart tx buf"
participant UART as "UART hardware"
participant QEMU as "QEMU terminal"
participant Trap as "trap handler"
participant Devintr as "devintr"
participant PLIC as "PLIC"
participant Uartintr as "uartintr"
Note over Shell: Mode U, satp User PT
Shell->>Write: write(fd, buf, n)
Note over Write,ConsoleWrite: Mode S, satp Kernel PT
Write->>FileWrite: filewrite(file, addr, n)
FileWrite->>ConsoleWrite: devsw[CONSOLE].write(user_src, addr, n)
loop for each byte in user buffer
ConsoleWrite->>ConsoleWrite: either_copyin(&c, user_src, addr+i, 1)
ConsoleWrite->>UartPutc: uartputc(c)
UartPutc->>UartPutc: acquire uart_tx_lock
alt UART TX buffer full
UartPutc->>UartPutc: sleep(&uart_tx_r, &uart_tx_lock)
Note over UartPutc: writer sleeps until uartstart advances tx_r
else UART TX buffer has space
UartPutc->>TxBuf: uart_tx_buf[uart_tx_w] = c
UartPutc->>TxBuf: uart_tx_w = uart_tx_w + 1
UartPutc->>UART: uartstart()
alt UART transmitter idle and tx buffer non-empty
UART->>TxBuf: take byte from uart_tx_buf[uart_tx_r]
UART->>TxBuf: uart_tx_r = uart_tx_r + 1
UART->>UartPutc: wakeup(&uart_tx_r)
UART->>UART: write byte to THR
UART->>QEMU: display transmitted byte
else UART transmitter busy
Note over TxBuf: byte remains queued for later TX interrupt
end
UartPutc->>UartPutc: release uart_tx_lock
UartPutc-->>ConsoleWrite: return
end
end
ConsoleWrite-->>FileWrite: return bytes written
FileWrite-->>Write: return bytes written
Write-->>Shell: write returns
Note over Shell: Mode U, satp User PT
opt later UART transmit interrupt for queued output
UART->>Trap: raise UART interrupt
Trap->>Devintr: devintr()
Devintr->>PLIC: plic_claim()
PLIC-->>Devintr: UART0_IRQ
Devintr->>Uartintr: uartintr()
Uartintr->>Uartintr: acquire uart_tx_lock
Uartintr->>UART: uartstart()
loop while UART idle and tx buffer non-empty
UART->>TxBuf: take next byte from uart_tx_buf[uart_tx_r]
UART->>TxBuf: uart_tx_r = uart_tx_r + 1
UART->>Uartintr: wakeup(&uart_tx_r)
UART->>UART: write byte to THR
UART->>QEMU: display transmitted byte
end
Uartintr->>Uartintr: release uart_tx_lock
Uartintr-->>Devintr: return
Devintr->>PLIC: plic_complete(UART0_IRQ)
Devintr-->>Trap: return
end

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. The process waiting for a device may not be running when the interrupt arrives, or there may be no user process at all. Interrupt handlers cannot assume a particular process context or safely use its page table (e.g., copyout). The 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

Timer interrupts drive the system clock and enforce preemptive scheduling. xv6 uses the RISC-V sstc extension so supervisor mode receives them directly, and yield calls in usertrap and kerneltrap perform the thread switch. Early machine-mode setup prepares timer delivery before the kernel 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 increments continuously. When it reaches stimecmp, the hardware raises a supervisor timer interrupt handled by the normal trap path (the old CLINT forwarding path is unused). The next timer event is scheduled by writing a later value to stimecmp.

Real World

Allowing interrupts in kernel code (not just user code) improves fairness for long-running kernel work but adds complexity: kernel code must tolerate being suspended and resumed, possibly on a different CPU.

On real systems, drivers often exceed the core kernel in size. Three common I/O patterns exist:

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

For very fast devices, kernel-to-user copying can be too expensive. Real systems use zero-copy techniques built around DMA. Device-specific controls that do not fit read and write are exposed through ioctl.

Xv6 is unsuitable for real-time work: its scheduler is too simple, and some kernel paths hold interrupts disabled too long for bounded response times.