| name | osdev-device-drivers |
| description | Common OS device driver patterns. PCI enumeration, UART 16550 serial, PS/2 keyboard and mouse, timers (PIT, APIC, HPET, RTC), and DMA. Use when writing device drivers, scanning PCI bus, setting up serial output, keyboard input, or system timers. |
| origin | MCC |
Device Drivers for x86 Kernels
Complete reference for PCI bus enumeration, serial output, keyboard input, system timers, and DMA in bare-metal x86 and x86-64 kernels.
When to Use
- Enumerating PCI devices to find controllers (AHCI, NVMe, NICs)
- Setting up serial output for early kernel debugging
- Writing a PS/2 keyboard driver for console input
- Configuring the PIT for a system tick or sleep function
- Calibrating the APIC timer against PIT or HPET
- Understanding DMA for disk or network drivers
PCI Bus Enumeration
PCI is how you discover hardware. Every device on the bus has a 256-byte configuration space with vendor ID, device ID, class codes, and Base Address Registers (BARs). You need PCI enumeration before you can write drivers for AHCI, NVMe, or network cards.
Config Space Access (Mechanism #1)
Two IO ports control PCI config reads and writes:
- 0xCF8 -- CONFIG_ADDRESS (write the address you want to access)
- 0xCFC -- CONFIG_DATA (read/write the 32-bit value at that address)
The CONFIG_ADDRESS register format:
Bit 31: Enable (must be 1)
Bits 30-24: Reserved
Bits 23-16: Bus number (0-255)
Bits 15-11: Device number (0-31)
Bits 10-8: Function number (0-7)
Bits 7-0: Register offset (must be 4-byte aligned, low 2 bits = 0)
Reading Config Space
uint32_t pci_config_read(uint8_t bus, uint8_t device, uint8_t func, uint8_t offset) {
uint32_t address = (1 << 31)
| ((uint32_t)bus << 16)
| ((uint32_t)device << 11)
| ((uint32_t)func << 8)
| (offset & 0xFC);
outl(0xCF8, address);
return inl(0xCFC);
}
Why 0xFC mask: Config space is accessed in 32-bit chunks. The low 2 bits select a byte within the DWORD, so the hardware ignores them. Mask them off to avoid surprises.
Brute-Force Scan
The simplest approach: try every bus/device/function combination. A non-existent device returns 0xFFFF as vendor ID.
void pci_scan(void) {
for (uint16_t bus = 0; bus < 256; bus++) {
for (uint8_t dev = 0; dev < 32; dev++) {
uint32_t reg0 = pci_config_read(bus, dev, 0, 0);
uint16_t vendor = reg0 & 0xFFFF;
if (vendor == 0xFFFF) continue;
uint16_t device_id = reg0 >> 16;
check_device(bus, dev, 0, vendor, device_id);
uint32_t header = pci_config_read(bus, dev, 0, 0x0C);
if ((header >> 16) & 0x80) {
for (uint8_t func = 1; func < 8; func++) {
reg0 = pci_config_read(bus, dev, func, 0);
if ((reg0 & 0xFFFF) != 0xFFFF)
check_device(bus, dev, func, reg0 & 0xFFFF, reg0 >> 16);
}
}
}
}
}
Why check multi-function: A single PCI slot can host up to 8 logical functions (e.g., a combined audio+modem card). Bit 7 of the Header Type register (offset 0x0E) indicates multi-function. If you skip this check, you miss devices.
BAR Decoding
BARs tell you where a device's registers live (IO port space or memory-mapped). Bit 0 distinguishes them:
- Bit 0 = 0: Memory BAR. Bits 1-2 encode type (00 = 32-bit, 10 = 64-bit). Mask low 4 bits for base address.
- Bit 0 = 1: IO BAR. Mask low 2 bits for base port.
64-bit BARs consume two BAR slots. A 64-bit memory BAR at BAR0 means BAR1 holds the upper 32 bits. BAR1 is not an independent BAR.
See references/pci-enumeration.md for the full header layout, BAR sizing algorithm, and class code table.
UART Serial Port
Why Serial First
Serial is the first output channel you should bring up. It works before you have VGA, before you have a framebuffer, and QEMU/Bochs can redirect it to your terminal. When your kernel triple-faults before the screen initializes, serial output is the only way to see what happened.
COM1 Register Map
COM1 base address is 0x3F8. All registers are 8-bit, accessed as offsets from the base:
| Offset | DLAB=0 Read | DLAB=0 Write | DLAB=1 |
|---|
| +0 | RBR (receive) | THR (transmit) | Divisor low byte |
| +1 | IER (interrupt enable) | IER | Divisor high byte |
| +2 | IIR (interrupt ID) | FCR (FIFO control) | -- |
| +3 | LCR (line control) | LCR | -- |
| +4 | MCR (modem control) | MCR | -- |
| +5 | LSR (line status) | -- | -- |
| +6 | MSR (modem status) | -- | -- |
| +7 | Scratch register | Scratch register | -- |
DLAB (Divisor Latch Access Bit) is bit 7 of LCR. When set, offsets +0 and +1 switch to the baud rate divisor instead of data/interrupt registers.
Initialization
#define COM1 0x3F8
void serial_init(void) {
outb(COM1 + 1, 0x00);
outb(COM1 + 3, 0x80);
outb(COM1 + 0, 0x03);
outb(COM1 + 1, 0x00);
outb(COM1 + 3, 0x03);
outb(COM1 + 2, 0xC7);
outb(COM1 + 4, 0x0B);
}
Why 38400 baud: Divisor = 115200 / desired_baud. Divisor 1 = 115200 baud (fastest), divisor 3 = 38400 (reliable default). QEMU does not care about baud rate, but real hardware does.
Transmit and Receive
void serial_putchar(char c) {
while (!(inb(COM1 + 5) & 0x20));
outb(COM1, c);
}
char serial_getchar(void) {
while (!(inb(COM1 + 5) & 0x01));
return inb(COM1);
}
See references/uart-serial.md for interrupt-driven serial, FIFO configuration, and the full LSR/MSR bit definitions.
PS/2 Keyboard
Controller Ports
The PS/2 controller (Intel 8042) uses two IO ports:
- 0x60 -- Data port (read scan codes, write commands to device)
- 0x64 -- Status register (read) / Command register (write)
Status register bits:
- Bit 0: Output buffer full (1 = data ready to read from 0x60)
- Bit 1: Input buffer full (1 = controller is processing, wait before writing)
IRQ 1 Handler Pattern
The keyboard generates IRQ 1 on every key press and release. Your handler reads the scan code from port 0x60:
void keyboard_handler(void) {
uint8_t scancode = inb(0x60);
if (scancode & 0x80) {
} else {
char c = scancode_to_ascii[scancode];
if (c) buffer_put(c);
}
pic_send_eoi(1);
}
Why read port 0x60 immediately: The controller holds the scan code until you read it. If you do not read it, the controller will not send more interrupts for new key presses. Always read 0x60 in the IRQ handler, even if you discard the value.
See references/ps2-keyboard.md for scan code set 1 table, extended scan codes (0xE0 prefix), and PS/2 mouse protocol.
Timers
PIT (Programmable Interval Timer)
The oldest and simplest timer. Three channels driven by a 1.193182 MHz oscillator. Channel 0 connects to IRQ 0 -- this is your system tick source.
IO Ports:
| Port | Purpose |
|---|
| 0x40 | Channel 0 data (read/write) |
| 0x41 | Channel 1 data (unusable on modern systems) |
| 0x42 | Channel 2 data (PC speaker) |
| 0x43 | Mode/Command register (write only) |
Setting a frequency:
void pit_init(uint32_t frequency) {
uint16_t divisor = 1193182 / frequency;
outb(0x43, 0x36);
outb(0x40, divisor & 0xFF);
outb(0x40, (divisor >> 8) & 0xFF);
}
Mode 2 vs Mode 3: Mode 2 (rate generator) produces a short low pulse. Mode 3 (square wave) produces a 50% duty cycle output. Both trigger IRQ 0 on the rising edge. Mode 3 is more common for system ticks because the output stays high longer, which matters for some chipsets.
Why the PIT still matters: Even if you use the APIC timer, you need a known-frequency clock source to calibrate it. The PIT is always present and runs at a fixed frequency. Use it as a calibration reference.
APIC Timer
Each Local APIC has a built-in timer. Unlike the PIT, it is per-CPU (essential for SMP). See osdev-interrupts for APIC enable sequence. The APIC timer must be calibrated because its frequency depends on the bus clock, which varies per machine.
HPET (High Precision Event Timer)
HPET is ACPI-discovered (look in the HPET ACPI table for its MMIO base address). It offers sub-microsecond precision with a guaranteed minimum frequency of 10 MHz. More complex to set up than the PIT but far more accurate.
RTC (Real-Time Clock)
Accessed via CMOS ports 0x70 (index) and 0x71 (data). Provides wall-clock time (year, month, day, hour, minute, second) and a periodic interrupt source. Register 0x0A bits 6-4 indicate an update in progress -- wait for this to clear before reading time registers or you get torn reads.
See references/timers.md for PIT mode details, HPET register map, RTC register layout, and APIC timer calibration code.
DMA
ISA DMA
Legacy ISA DMA uses dedicated DMA controller channels (0-7). The CPU programs the DMA controller with a buffer address and byte count, and the DMA controller transfers data directly between the device and memory without CPU involvement. Channels 0-3 are 8-bit, channels 4-7 are 16-bit. ISA DMA is limited to the first 16 MB of physical memory and 64 KB per transfer.
PCI Bus Mastering
Modern devices (AHCI, NVMe, network cards) use PCI bus mastering instead. The device itself controls DMA -- the OS provides physical buffer addresses, and the device reads/writes memory directly. This is far faster and has no 16 MB limitation.
What the OS must do:
- Allocate physically contiguous buffers (or use scatter-gather lists)
- Ensure buffers are not cached or use cache-coherent memory
- Write buffer physical addresses to the device's registers
- Enable bus master bit in the PCI Command register (bit 2 of offset 0x04)
Why physical addresses: DMA bypasses the CPU's virtual memory system. The device sees physical bus addresses, not virtual addresses. If you give a device a virtual address, it will read/write garbage memory locations.
Driver Architecture Pattern
A consistent pattern for all device drivers:
- Detect -- scan PCI bus or check known IO ports for the device
- Allocate state -- create a driver struct to hold per-device state
- Configure -- program the device's registers (reset, set modes, allocate DMA buffers)
- Register IRQ handler -- install your ISR for the device's interrupt vector
- Enable -- flip the device's "go" bit to start operation
typedef struct {
uint32_t io_base;
uint8_t irq;
void *dma_buffer;
} device_state_t;
Why this order matters: If you enable the device before installing the IRQ handler, the device may fire an interrupt that goes unhandled, causing a spurious IRQ or missed events. If you allocate DMA buffers after enabling the device, it may DMA to uninitialized pointers.
Common Pitfalls
| Pitfall | Consequence | Fix |
|---|
| Not masking PCI config reads to 0xFC | Reads wrong register offset | Always mask offset: offset & 0xFC |
| Missing multi-function check in PCI scan | Hidden devices not discovered | Check header type bit 7 |
| 64-bit BAR treated as two BARs | Second BAR read as garbage device | Skip next BAR slot for 64-bit memory BARs |
| Serial not initialized before first print | Output goes nowhere, debugging blind | Initialize serial as first driver |
| Not reading keyboard scan code in IRQ handler | Keyboard stops sending interrupts | Always read port 0x60 in handler |
| Giving virtual addresses to DMA devices | Device corrupts random physical memory | Always convert to physical before DMA |
| PIT divisor of 0 | Means 65536, not zero -- ~18.2 Hz | Intentional for BIOS default, but know it |
Related Skills
osdev-interrupts -- PIC/APIC setup, ISR stubs, exception handlers
osdev-storage -- ATA PIO, AHCI, NVMe disk drivers (use PCI enumeration from this skill)
osdev-memory -- page tables, physical memory allocator (needed for DMA buffers)
osdev-boot -- GDT, IDT setup, entering protected/long mode
osdev-toolchain -- cross-compiler, linker scripts, QEMU debugging