# 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](drivers.md), the matching rules in [devices.csv](devices-csv.md), and interrupts in [device interrupts](device-interrupts.md). 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//.zig` — kebab-case, abbreviations spelled out ([coding standards](../coding-standards.md)). 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: ```zig //! /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](drivers.md#the-capability-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](../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 `@import`s (the moral equivalent of its include list; an undeclared import is a compile error): ```zig 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), ```zig const intel_uhd_graphics_750_exe = b.dependency("intel-uhd-graphics-750", .{}).artifact("intel-uhd-graphics-750"); ``` ```zig .{ .path = "system/drivers/intel-uhd-graphics-750", .binary = intel_uhd_graphics_750_exe.getEmittedBin() }, ``` and the path entry in the root `build.zig.zon`: ```zig .@"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](devices-csv.md). 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: ```zig 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//system/services/device-manager.log` shows `spawned for device `, and `/var/log//system/drivers/.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](device-interrupts.md) 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](driver-model.md)). - **Children**: a bus driver publishes what it finds via `device_register` ([drivers.md — publishing children](drivers.md#publishing-children-device_register)). - **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.