danos/docs/device-driver-development/new-driver-checklist.md

8.5 KiB

New driver: the minimum steps

The shortest path from "a device shows up in the boot log" to "my process is running with its registers mapped". This is the checklist; the reasoning behind every step lives in Writing a driver, the matching rules in devices.csv, and interrupts in device interrupts.

Worked example throughout: the Intel UHD 750 iGPU, which the boot log reports as

pci-bus: 0:2.0  bus=pci base=03 class=00 prog_if=00 vendor=8086 device=4C8A ...

1. Create the source file

system/drivers/<name>/<name>.zig — kebab-case, abbreviations spelled out (coding standards). The directory name, the binary name, and the devices.csv driver path must all agree; a mismatch fails silently (the device-manager logs the spawn failure, nothing else happens).

The complete minimal driver — claims its device, logs every resource, maps the register window, then sleeps in the harness loop:

//! /system/drivers/intel-uhd-graphics-750 — spawned by the device manager with
//! the device-tree id as argv[1]; claims that device and no other.

const std = @import("std");
const device = @import("driver");
const ipc = @import("ipc");
const memory = @import("memory");
const process = @import("process");
const service = @import("service");

/// No protocol yet: the kernel's IPC ceiling (MESSAGE_MAXIMUM) sizes the buffers.
const message_maximum = 256;

var controller_id: u64 = 0;
var register_base: usize = 0;

fn initialise(endpoint: ipc.Handle) bool {
    _ = endpoint; // needed later, for irq binding and timers

    if (!device.claim(controller_id)) {
        std.log.err("unable to claim device {d}", .{controller_id});
        return false;
    }

    // Fetch our own descriptor back for the device's resources.
    const buffer = memory.allocator().alloc(device.DeviceDescriptor, 64) catch return false;
    defer memory.allocator().free(buffer);
    const total = device.enumerate(buffer);
    const descriptor = for (buffer[0..@min(total, buffer.len)]) |d| {
        if (d.id == controller_id) break d;
    } else {
        std.log.err("device {d} not in the device tree", .{controller_id});
        return false;
    };

    // Log every resource BEFORE choosing one (see step 5).
    var register_index: u64 = 0;
    for (descriptor.resources[0..@intCast(descriptor.resource_count)], 0..) |resource, index| {
        std.log.info("resource {d}: kind={d} start=0x{x} len=0x{x}", .{
            index, resource.kind, resource.start, resource.len,
        });
        // The 16 MiB window is GTTMMADR, the register BAR (this device also has
        // a 256 MiB memory BAR, GMADR — "first memory resource" would be wrong).
        if (resource.kind == @intFromEnum(device.ResourceKind.memory) and
            resource.len == 16 * 1024 * 1024) register_index = index;
    }
    if (register_index == 0) {
        std.log.err("register BAR not found", .{});
        return false;
    }

    register_base = device.mmioMap(controller_id, register_index) orelse {
        std.log.err("mmio_map failed", .{});
        return false;
    };
    std.log.info("registers mapped at 0x{x}", .{register_base});
    return true;
}

fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
    _ = message;
    _ = reply;
    _ = sender;
    _ = capability;
    return 0; // no protocol yet; the zero-length ping is answered by the harness
}

pub fn main(init: process.Init) void {
    const argument = init.arguments.get(1) orelse {
        std.log.err("missing device id (argv[1])", .{});
        return;
    };
    controller_id = std.fmt.parseInt(u64, argument, 10) catch {
        std.log.err("malformed device id '{s}'", .{argument});
        return;
    };
    service.run(message_maximum, .{
        .init = initialise,
        .on_message = onMessage,
        // .on_notification only once an IRQ or timer is bound
    });
}

claim is the capability gate: MMIO mapping, DMA grants, and IRQ binding all require it, and it pins the IOMMU domain to this process (drivers.md — claim before touch).

2. Create the build package and register it in the root build

The driver directory is its own build package (build-packages-plan.md): a ~15-line build.zig plus a build.zig.zon beside the source. Copy both from an existing driver — system/drivers/pci-bus/ is the template — and adjust the name, root source file, and the import list. The list names EXACTLY the modules the driver's source @imports (the moral equivalent of its include list; an undeclared import is a compile error):

pub fn build(b: *std.Build) void {
    const exe = build_support.userBinary(b, .{
        .name = "intel-uhd-graphics-750",
        .root_source_file = b.path("intel-uhd-graphics-750.zig"),
        .imports = &.{ "driver", "ipc", "memory", "process", "service" },
    });
    b.installArtifact(exe);
}

The zon declares build-support, kernel (implicit in every binary: the root shim lives there), and the homes of the listed imports — for the minimal driver above that is kernel alone plus device (for driver); add protocol, client, ... only when an import comes from them (again, copy pci-bus's zon and adjust). For the .fingerprint field, leave the copied value in place and zig build will reject it and suggest the fresh one to paste.

Then three one-liners in the root build register the package: the dependency and a row in the boot-tree array in build.zig (search for virtio_gpu_package to land in the right places),

const intel_uhd_graphics_750_exe = b.dependency("intel-uhd-graphics-750", .{}).artifact("intel-uhd-graphics-750");
.{ .path = "system/drivers/intel-uhd-graphics-750", .binary = intel_uhd_graphics_750_exe.getEmittedBin() },

and the path entry in the root build.zig.zon:

.@"intel-uhd-graphics-750" = .{ .path = "system/drivers/intel-uhd-graphics-750" },

Without the boot-tree row the binary never reaches the image and the device-manager has nothing to spawn. (The package also builds standalone: cd system/drivers/intel-uhd-graphics-750 && zig build.)

3. Add the match rule to etc/devices.csv

One row: bus, class triplet, vendor/device, driver path. Copy the class triplet from the pci-bus boot log line, not from another row — for the iGPU above the correct rule is

pci,    03,  00,   00,     8086,  4C8A,  *,        *,        /system/drivers/intel-uhd-graphics-750

Field-by-field rules and the most-specific-wins policy: devices.csv. The registry is authoritative: an unmatched device is logged unbound, never guessed — so a wrong nibble here means the driver simply never starts.

4. First contact: read, predict, verify

Before writing any register, read one whose value you can predict from state the firmware already programmed (for a display controller: the pipe source size of the live mode). Registers are volatile loads at register_base + offset, where offset is what the device's manual lists:

fn read32(offset: usize) u32 {
    return @as(*volatile u32, @ptrFromInt(register_base + offset)).*;
}

A matching read proves the whole chain — CSV match, spawn, claim, BAR choice, mapping — with zero risk to the hardware.

5. Verify the plumbing

  • zig build test still passes.
  • On the image: /var/log/<boot-stamp>/system/services/device-manager.log shows spawned <name> for device <N>, and /var/log/<boot-stamp>/system/drivers/<name>.log holds the resource list and your first read.
  • If the driver did not spawn, diagnose in this order: binary on the image (step 2) → CSV row matches the log line exactly (step 3) → path identical in both (step 1).

Later, when the device needs them

  • Interrupts: MSI/MSI-X via the pci module, delivered as notifications to on_notification — see device interrupts and the xHCI driver's setupMsi (QEMU trap documented there: enable MSI-X before unmasking the device's own interrupt-enable bit).
  • DMA: grant-backed buffers, bounded by the IOMMU domain established at claim time (driver model).
  • Children: a bus driver publishes what it finds via device_register (drivers.md — publishing children).
  • A protocol: replace message_maximum with the protocol's own maximum and dispatch on the operation word in onMessage — every service under system/services/ is an example.