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, andplicinithart()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
1inplicinit - 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
devintrreadsscause- Supervisor external interrupt means the interrupt came through the PLIC
plic_claimasks the PLIC which device interruptedplic_completetells 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 devintrcallsuartintruartintrreads pending bytes from the UART- Each input byte is passed to
consoleintr consoleintrupdates the console input buffer and wakesconsolereadwhen input is ready
Virtio disk interrupt path:
- PLIC reports
VIRTIO0_IRQ devintrcallsvirtio_disk_intrvirtio_disk_intrmarks 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.
LSRregister tells whether input is ready.RHRregister is used to read received bytes.- Reading from
RHRremoves that byte from the UART FIFO.
- Transmit side:
- Software writes output bytes to
THR. - UART sends those bytes to the terminal/display.
- Software writes output bytes to
- Initialization entry point:
main()callsconsoleinit(). 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
sstcthroughmenvcfg, - allows supervisor mode to read the
timecounter throughmcounteren, and - schedules the first timer interrupt with
stimecmp. The hardwaretimecounter increments continuously. When it reachesstimecmp, 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 tostimecmp.
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.