Guides

Add a driver

Write a device driver for SlopOS, using QEMU's teaching device as the example.

By the end of this page you will have a working PCI driver: the kernel will find the device at boot, hand it to your code, and your code will map the device's registers, check that it answers, and log its version. You'll also have tests for it that run without the device.

The example device is edu, a device QEMU provides for exactly this purpose: it has a version register and a register that hands back whatever you write to it, inverted, which is enough to prove the driver is talking to it. Its specification is one page. The driver below is ours, written for this guide in the shape of the drivers in the tree; the smallest real ones to compare it with are the PS/2 keyboard (drivers/src/ps2/platform.rs) and virtio-blk (drivers/src/virtio_blk.rs).

Drivers explains how the kernel finds devices and decides which driver gets each one. You don't need it to follow the steps, but it explains why they look the way they do.

What you need first

  • A working build and a passing just test. See Quickstart.
  • The device's identity. A PCI device announces a vendor number and a device number; for edu they are 1234 and 11e8. On real hardware, lspci -nn under Linux prints them.

Driver code can't use unsafe: the drivers crate forbids it. Everything that touches hardware directly (mapping device memory, reading I/O ports, memory the device reads and writes, interrupts) comes from the kernel's trusted core as safe types. If your device needs something those types can't express, add a new primitive to the trusted core, where it is reviewed as unsafe code, and call it from the driver. Unsafe code and FFI covers how.

1. Create the module

Make drivers/src/edu.rs and add it to drivers/src/lib.rs:

pub mod edu;

2. Say which devices the driver handles

A driver tells the kernel which devices it wants with a match table. At boot, the kernel walks the PCI bus and offers each device it finds to every driver whose table matches. You don't add a boot step or call anything: declaring the driver is enough to register it.

//! QEMU's `edu` teaching device: identify it and check that it answers.

use slopos_ostd::klog_info;

use crate::driver_core::BoundError;
use crate::pci::{BoundDevice, PciMatch, PciProbeError, ProbeOutcome};

const EDU_VENDOR: u16 = 0x1234;
const EDU_DEVICE: u16 = 0x11e8;

crate::pci_driver! {
    pub static EDU_DRIVER = {
        name: "edu",
        match_table: &[PciMatch::VendorDevice {
            vendor: EDU_VENDOR,
            device: EDU_DEVICE,
        }],
        probe: edu_probe,
    };
}

A rule can name one exact device, as here, or a whole kind of device:

RuleMatches
VendorDevice { vendor, device }One product
VendorClass { vendor, class }Every device of one class from one vendor
ClassSubclass { class, subclass }Every device of one kind, such as every NVMe controller
ClassOnly { class }Every device of a broad class, such as every storage controller

Two optional fields go before probe. priority (default 128, lower goes first) lets a driver for one specific device win it from a generic driver for its class. fallback is a function for a test that a table can't express; the DesignWare I2C driver uses one because its class also covers a flash controller it must not touch. Prefer table rules.

Platform devices, the ones the firmware describes in its ACPI tables rather than on the PCI bus, work the same way with platform_driver! and a table of ACPI IDs such as PlatformMatch::HidCid(b"PNP0303") (a PS/2 keyboard).

3. Write the probe

The probe is the function the kernel calls when it offers your driver a device. It decides whether to take the device, and if so, sets it up. Keep anything that doesn't touch the device, such as decoding a register, in a separate function so you can test it without hardware:

const REG_ID: usize = 0x00;
const REG_LIVENESS: usize = 0x04;

/// The identification register reads `0xRRrr00ed`: major version, minor
/// version, then a fixed `0x00ed`.
pub fn parse_id(id: u32) -> Option<(u8, u8)> {
    if id & 0xffff != 0x00ed {
        return None;
    }
    Some(((id >> 24) as u8, (id >> 16) as u8))
}

fn edu_probe(bound: &mut BoundDevice<'_>) -> Result<ProbeOutcome, PciProbeError> {
    // The device's registers sit in its first memory window (BAR 0).
    let regs = bound.map_bar(0, 0, 4096).map_err(map_bound_err)?;

    let Some((major, minor)) = parse_id(regs.read::<u32>(REG_ID)) else {
        return Ok(ProbeOutcome::Declined);
    };

    // The liveness register hands back the bitwise inverse of what we wrote.
    let sent: u32 = 0x5a5a_0f0f;
    regs.write::<u32>(REG_LIVENESS, sent);
    if regs.read::<u32>(REG_LIVENESS) != !sent {
        return Err(PciProbeError::DeviceFault);
    }

    klog_info!("edu: version {}.{}, liveness check passed", major, minor);
    Ok(ProbeOutcome::Bound)
}

fn map_bound_err(e: BoundError) -> PciProbeError {
    match e {
        BoundError::OutOfMemory => PciProbeError::OutOfMemory,
        _ => PciProbeError::Unsupported,
    }
}

The bound argument is how a probe gets anything from the device. Each resource you ask it for (a mapped register window here, but also memory the device can read and write, interrupt lines, I/O ports) is recorded against the device. If the probe returns early, by declining or with an error, everything it took so far is released in reverse order. You write no cleanup code. If it returns Bound, the resources stay for as long as the system runs.

ReturnWhen
Ok(ProbeOutcome::Bound)The device is yours. No other driver is offered it
Ok(ProbeOutcome::Declined)It matched, but it isn't yours: turned off by a boot option, absent, an unrecognised model. The next matching driver is offered it
Err(Deferred)Something this device depends on, on the same bus, isn't ready yet. The kernel tries once more after it has offered every device
Err(Mismatch)A closer look after the match ruled it out, such as failed feature negotiation
Err(Unsupported)Something the driver needs is missing, such as a usable memory window or interrupt
Err(DeviceFault), Err(OutOfMemory)The device misbehaved, or memory ran out

Decline a device that isn't yours; return an error for one that is yours but doesn't work. The kernel logs errors as PCI: edu declined device …: DeviceFault, so the difference shows up in the boot log.

A driver that needs interrupts asks bound for them in the probe too. driver_core::msi::setup_interrupts sets up one per queue where the device supports it and falls back to a single shared one; virtio-blk shows the pattern. Keep the interrupt handler short and wake a kernel thread to do the work, as virtio-net does.

4. Test the parts that need no device

Tests for drivers live in drivers/src/tests/. Create drivers/src/tests/edu_tests.rs:

use slopos_testing::{TestResult, fail, pass};

use crate::edu::parse_id;

pub fn test_edu_parses_qemu_id() -> TestResult {
    match parse_id(0x0100_00ed) {
        Some((1, 0)) => pass!(),
        other => fail!("expected version 1.0, got {:?}", other),
    }
}

pub fn test_edu_rejects_a_foreign_id() -> TestResult {
    match parse_id(0xffff_ffff) {
        None => pass!(),
        other => fail!("a foreign id parsed as {:?}", other),
    }
}

slopos_testing::stest!(name = test_edu_parses_qemu_id, suite = edu);
slopos_testing::stest!(name = test_edu_rejects_a_foreign_id, suite = edu);

and add pub mod edu_tests; to drivers/src/tests/mod.rs. Run them:

just test 'slopos_drivers::tests::edu_tests::*'

The run ends with both tests passing. If one fails, the harness prints the test's file and line and the fail! message; Write tests shows what a failure looks like.

For a driver with real protocol logic (command encodings, status parsing), put that logic in its own crate with no hardware access, like nvme-core. just test-host then runs it on your machine in seconds, with no QEMU.

If your match rules are subtle (a fallback, or a priority meant to beat a generic driver), add a case to drivers/src/tests/pci_binding.rs. Those tests run the kernel's real matching code over made-up devices and drivers, so you can check who wins a device without having one.

5. Try it on the device

The test machine doesn't include edu, so for a local experiment add it to the QEMU command line in scripts/qemu_run.sh, next to the other -device lines (and leave that edit out of your commit):

QEMU_ARGS+=(-device edu)

Then boot and look for the driver's line:

just boot-log
grep '^edu:' test_output.log
edu: version 1.0, liveness check passed

For devices the test machine does attach (virtio disks, network and GPU, several NVMe controllers), write the integration test as a kernel test too. If the device might be missing, log that and pass rather than fail, as the virtio-gpu tests do.

6. Check the whole change

just test
just build
just check-framekernel-gates

The gates check, among other things, that no driver code uses unsafe, that no function's stack frame exceeds the kernel's 2 KiB limit, and that the driver registry in the compiled kernel is well formed.

What usually goes wrong

  • The driver never runs. The match table doesn't match. Check the vendor and device numbers, and whether a driver with a lower priority bound the device first.
  • The stack-size gate fails. A large structure was built on the stack in the probe. Build it in place on the heap with KBox::try_init or KArc::try_init. Allocate through the kernel's KBox, KVec and KArc; the kernel crates don't use alloc directly.
  • A dependency isn't there. Deferred only helps for a dependency on the same bus. Platform devices are probed after all of PCI, so a platform driver whose parent is a PCI device (the I2C touchpad on its controller) should decline if the parent is missing.
  • The driver needs a boot option. Read it in the PCI boot step before probing and store it, as the Xe and touchpad drivers do; don't parse the command line inside a probe. Document the option on Boot options.
  • The device must be stopped before power-off. Implement DeviceShutdown and register it with driver_core::shutdown::register, as the NVMe driver does. The kernel calls it after the filesystems have been written back, for both power-off and reboot.
  • You expect the driver to be told the device went away. It won't be: SlopOS has no unbinding or hot-plug, so a bound driver keeps its device until shutdown.

Read next: Drivers for how binding works and what the trusted core provides, and Write tests for the test harness.

On this page