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
eduthey are1234and11e8. On real hardware,lspci -nnunder 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:
| Rule | Matches |
|---|---|
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.
| Return | When |
|---|---|
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.logedu: version 1.0, liveness check passedFor 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-gatesThe 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
prioritybound 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_initorKArc::try_init. Allocate through the kernel'sKBox,KVecandKArc; the kernel crates don't useallocdirectly. - A dependency isn't there.
Deferredonly 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
DeviceShutdownand register it withdriver_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.