Writing a Driver
In this article, we will discuss how to write a device driver for Cosmos Gen3: how a driver is matched to a device, how it reaches the hardware, how it hands the kernel a device, and how it is tested without hardware.
If you find bugs or something abnormal, please submit an issue on our repository.
Experimental status
The driver kit is experimental: every public type under Cosmos.Kernel.HAL.DriverKit and Cosmos.Kernel.HAL.Devices, but the stable IBlockDevice and MacAddress, carries [Experimental("COSMOS0003")]. You can use them today, but they may change until they are promoted to the stable API. Referencing one is a build error until your project acknowledges it:
<PropertyGroup>
<NoWarn>$(NoWarn);COSMOS0003</NoWarn>
</PropertyGroup>
See Public API Tracking for how experimental APIs are promoted.
What the kit supports today:
- Bus kinds: platform, PCI, virtio, USB and PS/2 for real hardware, plus the synthetic bus for tests (Testing a driver over the synthetic bus).
- Device kinds: keyboard, pointer, network interface, block device and display. The kernel managers (
KeyboardManager,MouseManager,NetworkManager,StorageManager,DisplayManager) pick up whatever a driver publishes. - Shipped drivers: every kernel gets the drivers below through
Cosmos.Kernel.Drivers. They use the same public API as your drivers, so they are the best examples to read. Excluding and opting in shows how to drop one.
| Bus | Drivers |
|---|---|
| Platform | PciHostDriver, I8042Driver, VirtioMmioTransportDriver |
| PCI | PcieRootPortDriver, VirtioPciTransportDriver, XhciDriver, E1000EDriver, AhciDriver, NvmeDriver, VmwareSvgaDriver |
| Virtio | VirtioNetDriver, VirtioBlkDriver, VirtioGpuDriver, VirtioInputDriver |
| USB | UsbHubDriver, UsbKeyboardDriver, UsbMassStorageDriver |
| PS/2 | Ps2KeyboardDriver, Ps2MouseDriver |
Their sources are filed as <bus>/<category>/<driver>/ (for example Pci/Network/E1000E/), and the namespace follows the folder.
What a driver is
The kit is built on five concepts:
| Concept | What it is |
|---|---|
Node (DeviceNode) |
A piece of hardware the kernel can see: its identity on a bus, its resources and its interrupts. Buses and bus drivers publish nodes. |
| Bus kind | What a node looks like on a given bus (platform, PCI, virtio, USB, PS/2, synthetic) and how a driver matches it. |
Driver (Driver) |
Your class: it says which nodes it wants, and takes or declines each one it is offered. |
Binding (DeviceBinding) |
The link between one driver and one node. The driver reaches the hardware and the kernel only through it, and it remembers everything the driver acquired. |
| Device | What the driver hands the kernel: an IKeyboard, IPointer, INetworkInterface, IBlockDevice or IDisplay. |
When a node appears, the kit offers it to each matching driver in turn until one returns ProbeResult.Bound; whatever a declining driver acquired is released. When the node goes away, the kit tears the binding down for you: devices are withdrawn first, memory is freed last.
flowchart TD
Bus["Bus or bus driver"] -->|"publishes"| Node["Node (DeviceNode)<br/>identity, resources, interrupts"]
Node -->|"matched against every driver's Matches"| Candidates["Candidate drivers<br/>ordered by Priority, then specificity"]
Candidates -->|"offers the node with a new binding"| Probe["Driver.Probe(binding)"]
Probe -->|"Declined or Failed"| Release["The kit releases what the probe acquired"]
Release -->|"next candidate"| Probe
Release -->|"no candidate left"| Unbound["Node stays unbound"]
Probe -->|"Bound"| Binding["Binding (DeviceBinding)<br/>registers, DMA, interrupts, work items, threads"]
Binding -->|"PublishKeyboard, PublishNetwork, ..."| Device["Device<br/>IKeyboard, INetworkInterface, ..."]
Device -->|"consumed by"| Manager["Kernel manager<br/>KeyboardManager, NetworkManager, ..."]
Binding -->|"PublishChild (bus drivers)"| Node
Binding -.->|"node removed"| Teardown["Teardown: withdraw devices, disconnect interrupts,<br/>OnDetach, free memory"]
The driver, the binding and the node tree are in Cosmos.Kernel.HAL.DriverKit. What a binding hands out sits one namespace down, by concern: Cosmos.Kernel.HAL.DriverKit.Resources (register windows, mapped regions, DMA buffers), .Interrupts (interrupt handles, the handler contract) and .Threading (locks, events, threads, work items). Each bus kind has its own namespace under .Buses, and each device kind its category's under Cosmos.Kernel.HAL.Devices.
The smallest driver that compiles and binds:
using Cosmos.Kernel.HAL.DriverKit;
using Cosmos.Kernel.HAL.DriverKit.Buses.Synthetic;
namespace MyOS.Drivers;
[Driver]
public sealed class SampleDriver : Driver
{
private readonly DeviceMatch[] _matches = [SyntheticMatch.Key("sample")];
public override string Name => "sample";
public override ReadOnlySpan<DeviceMatch> Matches => _matches;
public override ProbeResult Probe(DeviceBinding binding)
{
binding.Log("bound");
return ProbeResult.Bound;
}
}
Name is shown in the log, Matches lists the nodes the driver wants, and Priority (0 by default) decides which matching driver is offered a node first. Probe is required; OnDetach is optional.
Each driver class is instantiated once and offered every node it matches, so its fields are shared across devices. Keep per-device state in an object created in Probe and stored in binding.DriverState.
Where a driver runs
The driver stage runs from Kernel.Start, once the heap, the interrupt controller and the scheduler are up and interrupts are enabled. The kit offers every node published so far, including the children a bus driver publishes from its probe, and returns when all of them have been offered. OnBoot and BeforeRun run after it, so every bound device is usable there.
The serial log shows each node, its candidate drivers and the result of the offer. An excerpt from an x64 kernel launched with cosmos run --nic virtio-net-pci:
[Kernel] Starting drivers...
[Drivers] manifest: PcieRootPortDriver(prio 0) VirtioPciTransportDriver(prio 0) ... VirtioBlkDriver(prio 0)
[Drivers] engine started, worker thread
...
[Drivers] platform:pci@cf8 candidates: PciHostDriver(prio 0, spec 1)
[Drivers] platform:pci@cf8 PciHostDriver: 6 functions on 1 buses
[Drivers] platform:pci@cf8 offer PciHostDriver -> bound
...
[Drivers] pci:0000:00:01.0 no driver
[Drivers] pci:0000:00:02.0 candidates: VirtioPciTransportDriver(prio 0, spec 1)
[Drivers] pci:0000:00:02.0 offer VirtioPciTransportDriver -> bound
...
[Drivers] virtio:pci:0000:00:02.0 candidates: VirtioNetDriver(prio 0, spec 1)
[Drivers] virtio:pci:0000:00:02.0 VirtioNetDriver published network "virtio-net" (consumed)
[Drivers] virtio:pci:0000:00:02.0 offer VirtioNetDriver -> bound
[Kernel] Calling OnBoot()...
A node published after boot, such as a USB device plugged in, takes the same path. See Kernel Startup for the rest of the boot sequence.
Probes, teardowns and work items run one at a time on the kit's worker thread, so a driver never sees two of them overlap. Only driver threads run concurrently.
A kernel built with CosmosEnableScheduler off has no worker thread: probing still works, but WorkItem.Schedule, TrySchedulePeriodic and TryStartThread return false. A driver that must work in such a kernel checks those results. The engine then runs inline: the thread that publishes or retracts a node drains the queue itself, and the log says engine started, inline (no worker).
The two execution contexts and the guard
Driver code runs in one of two contexts: thread or interrupt.
| Entry point | Context | May block | May allocate |
|---|---|---|---|
Probe, OnDetach, work items |
thread (the kit worker) | yes | yes |
| Driver threads | thread (their own) | yes | yes |
Device methods the kernel calls (Transmit, Flush, ...) |
thread (the caller's) | briefly | yes |
| Interrupt handlers | interrupt | no | no |
An interrupt handler should acknowledge the device and hand the rest to a work item. Its InterruptContext offers three calls: Mask() its own source, Signal(DeviceEvent) a waiting thread, and Schedule(WorkItem) work that needs thread context. It may also read and write its registers and DMA buffers and report through a sink, but it must not allocate (no new of a reference type, no string building, no capturing lambda) and must not block.
Calling a DeviceBinding method from a handler stops the kernel with a panic naming the member. Over the synthetic bus it throws instead: the kit records the fault on the node and masks the source, so a test can assert it.
The attribute and the manifest
The build registers every class marked [Driver]: a source generator writes DriverManifest.g.cs into the kernel project, which constructs each driver once at boot (Driver Manifest describes the generator). A driver class must derive from Driver, be concrete and non-generic, and have a parameterless constructor. It may be internal in the kernel project, but must be public in a driver library (A driver library).
The attribute takes two options:
[Driver(Feature = DriverFeature.Keyboard)]registers the driver only when the matching feature switch (CosmosEnableKeyboard) is on; otherwise it is trimmed from the kernel.[Driver(Default = false)]registers the driver only when the kernel opts into it by name (Excluding and opting in).
The manifest order is deterministic (the kernel's own drivers first, then those of referenced libraries) and is printed at boot.
Identity and matches
Every node has a DeviceIdentity, set by its bus: BusName, Address, and Describe() for the log. The node's Path joins the first two (pci:0000:00:03.0) and is how the log names the node.
A driver's Matches is a list of DeviceMatch. Each one tests an identity and has a Specificity: the number of fields it checks. Every bus kind has its own pair of types (PciIdentity and PciMatch, UsbIdentity and UsbMatch, ...), described with their buses below. On the synthetic bus:
// This device and no other: specificity 1.
private readonly DeviceMatch[] _matches = [SyntheticMatch.Key("kbd")];
// Every synthetic device: specificity 0.
private readonly DeviceMatch[] _matches = [SyntheticMatch.Any()];
When a node appears, the kit orders the matching drivers by Priority, then by specificity (highest first), then by manifest order, and offers the node to each until one returns Bound:
[Drivers] synthetic:prio candidates: HighPriorityDriver(prio 10, spec 1) LowPriorityDriver(prio 0, spec 1)
[Drivers] synthetic:prio offer HighPriorityDriver -> bound
To replace a shipped driver for the same hardware, give yours a Priority above 0. A node no driver takes is logged no driver and stays Unbound.
Probe and the binding
Probe is the only required entry point, and DeviceBinding is the driver's only way to reach the hardware and the kernel. Everything the driver acquires through it (register windows, DMA memory, interrupts, events, work items, threads, published devices, child nodes) is recorded on the binding and released by the kit, so a driver never writes cleanup code.
A probe reads the node (binding.Node.Resources, binding.Node.Interrupts, binding.Node.Access<T>()), acquires what it needs, creates its per-device state, publishes the device and returns. A full driver, modelled on the keyboard driver of the Drivers suite:
using Cosmos.Kernel.HAL.DriverKit;
using Cosmos.Kernel.HAL.DriverKit.Buses.Synthetic;
using Cosmos.Kernel.HAL.DriverKit.Interrupts;
using Cosmos.Kernel.HAL.DriverKit.Resources;
using Cosmos.Kernel.HAL.DriverKit.Threading;
namespace MyOS.Drivers;
[Driver(Feature = DriverFeature.Keyboard)]
public sealed class SyntheticKeyboardDriver : Driver
{
public const string Key = "kbd";
private readonly DeviceMatch[] _matches = [SyntheticMatch.Key(Key)];
public override string Name => "synthetic-keyboard";
public override ReadOnlySpan<DeviceMatch> Matches => _matches;
public override ProbeResult Probe(DeviceBinding binding)
{
if (binding.Node.Resources.Count == 0 || binding.Node.Interrupts.Count == 0)
{
return ProbeResult.Declined("no register window or no interrupt source");
}
RegisterWindow window = binding.MapRegisters(0);
DeviceEvent keyEvent = binding.CreateEvent();
KeyboardState state = new(binding, window, keyEvent);
binding.DriverState = state;
state.Sink = binding.PublishKeyboard(state);
if (!binding.TryRequestInterrupt(binding.Node.Interrupts[0], state.OnInterrupt, out InterruptHandle? handle))
{
return ProbeResult.Failed("interrupt 0 could not be connected");
}
state.Handle = handle;
// The handler is live from here on, so the device is armed last.
window.Write8(KeyboardState.ControlOffset, KeyboardState.EnableBit);
return ProbeResult.Bound;
}
public override void OnDetach(DeviceBinding binding, DetachReason reason)
{
if (reason.HardwarePresent && binding.DriverState is KeyboardState state)
{
state.Window.Write8(KeyboardState.ControlOffset, 0);
}
}
}
The state object is a plain class, not a Driver: there is one per device, and it implements the device contract, owns the handler, and holds every kit object the probe acquired:
using Cosmos.Kernel.HAL.Devices.Input;
using Cosmos.Kernel.HAL.DriverKit;
using Cosmos.Kernel.HAL.DriverKit.Interrupts;
using Cosmos.Kernel.HAL.DriverKit.Resources;
using Cosmos.Kernel.HAL.DriverKit.Threading;
namespace MyOS.Drivers;
public sealed class KeyboardState : IKeyboard
{
public const int ControlOffset = 0x00;
public const int ScanCodeOffset = 0x10;
public const int FlagsOffset = 0x11;
public const byte EnableBit = 1;
public const byte ReleasedFlag = 1;
private readonly DeviceBinding _binding;
private KeyboardLeds _leds;
public KeyboardState(DeviceBinding binding, RegisterWindow window, DeviceEvent keyEvent)
{
_binding = binding;
Window = window;
KeyEvent = keyEvent;
KeyWork = binding.CreateWorkItem(OnKeyWork);
}
public string Name => "synthetic-kbd";
public RegisterWindow Window { get; }
public DeviceEvent KeyEvent { get; }
public WorkItem KeyWork { get; }
public KeyboardSink? Sink { get; set; }
public InterruptHandle? Handle { get; set; }
public void SetLeds(KeyboardLeds leds)
{
_leds = leds;
}
// Interrupt context: reads two registers, reports the key, wakes the
// thread and hands the rest to the work item. Allocates nothing.
public void OnInterrupt(InterruptContext context)
{
byte scanCode = Window.Read8(ScanCodeOffset);
bool released = (Window.Read8(FlagsOffset) & ReleasedFlag) != 0;
Sink?.Report(scanCode, released);
context.Signal(KeyEvent);
context.Schedule(KeyWork);
}
// Thread context, on the kit worker.
public void OnKeyWork()
{
_binding.Log("key work ran");
}
}
Neither class allocates pages, programs an interrupt controller, registers the keyboard with a manager or frees anything: the binding does all of it. The sections below cover its members; their samples are pieces of a driver's Probe or of its state object.
Register windows
binding.MapRegisters(resourceIndex) maps one of Node.Resources and returns a RegisterWindow, with Read8 to Read64 and Write8 to Write64 at a byte offset. Every access is bounds-checked and carries the barriers it needs, so a doorbell write always lands after the DMA stores before it. The same code works over a memory window, a synthetic node's RAM window and an x64 port range. The accessors are safe from an interrupt handler, and throw once the binding is torn down.
Bulk regions
binding.MapRegion(resourceIndex, RegionCaching) maps a resource as plain memory, for a framebuffer or a command queue, and returns a DeviceRegion. Unlike a register window, its accesses are neither checked nor ordered:
// In Probe:
DeviceRegion framebuffer = binding.MapRegion(1, RegionCaching.WriteCombining);
framebuffer.Span.Fill(0);
Span<uint> pixels = framebuffer.As<uint>();
pixels[0] = 0x00FF0000;
The second argument tells the CPU how to cache the region. Pick it by what the region holds:
RegionCaching.Devicefor commands the device reads, such as a command queue: every read and write goes straight to the device, in order.RegionCaching.WriteCombiningfor a framebuffer: writes are grouped into bursts, which is much faster for drawing pixels.RegionCaching.Normalfor ordinary RAM shared with the device.
Today every memory window is mapped as Device, whatever you ask. Pick the right value anyway: the kit records it and will apply it once the platform supports it.
DMA memory
binding.AllocateDma(length, alignment) returns a DmaBuffer: zeroed, physically contiguous memory, with PhysicalAddress for the device and Span for the CPU.
DMA memory usually holds a ring: a fixed array of descriptors that the driver and the device use as a circular queue. Each descriptor points at a data buffer and says who owns it. The driver fills descriptors, hands them to the device and writes a register (the doorbell) to say new ones are ready; the device processes them in order, marks each one done, and wraps back to the first after the last. A network card, for example, has a receive ring the device fills with incoming frames and a transmit ring the driver fills with outgoing ones.
For a device that only addresses 32 bits, TryAllocateDma takes a constraint and returns false instead of throwing:
// In Probe:
if (!binding.TryAllocateDma(RingBytes, 4096, DmaConstraints.Addressable32Bit, out DmaBuffer? ring))
{
return ProbeResult.Declined("no DMA memory below 4 GiB");
}
DMA is coherent, so only ordering matters. Call DmaBuffer.WriteBarrier() between filling a descriptor and handing it to the device, and DmaBuffer.ReadBarrier() between reading a flag the device wrote and reading the data it guards. Register writes carry their own barrier:
// When sending, for example in Transmit:
Span<byte> descriptors = ring.Span;
descriptors[8] = 0x01; // fill the descriptor
DmaBuffer.WriteBarrier();
descriptors[0] = OwnedByDevice; // then hand it over
window.Write32(DoorbellOffset, 1); // no barrier needed
Never hand a device a managed array: the garbage collector does not know the device holds it.
Interrupts
binding.TryRequestInterrupt(source, handler, out handle) connects a handler to one of Node.Interrupts. It returns false when the platform cannot deliver that interrupt, so the driver can decline or poll instead. The handler is live as soon as the call returns true, which lets a probe send a command and wait for the interrupt; arm the device last if it must not interrupt earlier.
// In Probe:
if (!binding.TryRequestInterrupt(binding.Node.Interrupts[0], state.OnInterrupt, out InterruptHandle? handle))
{
return ProbeResult.Declined("the interrupt cannot be delivered");
}
state.Handle = handle;
The handler runs in interrupt context: it acknowledges the device and leaves the real work to a work item. With an OnInterrupt method on the state object:
// In the state object, in interrupt context:
public void OnInterrupt(InterruptContext context)
{
uint cause = Window.Read32(InterruptCauseOffset);
Window.Write32(InterruptCauseOffset, cause); // acknowledge
context.Schedule(ReceiveWork);
}
The InterruptHandle masks and unmasks the source (Mask(), Unmask()) from any context. A handler that throws leaves its source masked.
Events and waiting
binding.CreateEvent() returns a DeviceEvent. A handler signals it with context.Signal(evt); a thread waits with binding.Wait(evt, timeoutMilliseconds), which returns true on a signal, and false on timeout or once teardown has started.
A probe can send a command and wait for the device to answer. The handler calls context.Signal(CommandDone), and the probe waits on the same event:
// In Probe:
state.CommandDone = binding.CreateEvent();
// ... connect the interrupt, then send the command
window.Write32(CommandOffset, ResetCommand);
if (!binding.Wait(state.CommandDone, 100))
{
return ProbeResult.Failed("the device did not answer the reset");
}
For short waits in thread context, binding.Delay(microseconds) busy-waits and binding.Sleep(milliseconds) gives up the CPU:
// In Probe or a work item:
window.Write32(ControlOffset, ResetBit);
binding.Delay(10); // the device needs 10 µs before the next access
Work items
binding.CreateWorkItem(callback) returns a WorkItem whose callback runs on the kit worker when scheduled. context.Schedule(item) from a handler, or item.Schedule() from anywhere, queues it without allocating; it returns false when the item is already queued, cancelled by teardown, or the kernel has no worker. Create work items in Probe and schedule them later:
// In Probe: create the work item once.
state.ReceiveWork = binding.CreateWorkItem(state.DrainReceiveRing);
// In the handler: queue it.
context.Schedule(ReceiveWork);
// On the kit worker, in thread context: may allocate, wait and report.
public void DrainReceiveRing()
{
// walk the receive ring and hand each frame to Sink.Receive
}
Periodic work
binding.TrySchedulePeriodic(intervalMilliseconds, item) runs a work item at an interval until teardown, rounded to the timer's tick. It returns false when the kernel has no timer or no scheduler. The kit never polls on a driver's behalf. With an OnPoll method on the state object:
// In Probe:
WorkItem poll = binding.CreateWorkItem(state.OnPoll);
if (!binding.TrySchedulePeriodic(20, poll))
{
return ProbeResult.Failed("no timer to poll with");
}
Driver threads
binding.TryStartThread(name, entry, out thread) starts a thread for work that has to wait, and returns false when the scheduler is not running. The thread loops on the binding's events and returns once IsDetaching is true; teardown wakes every waiter, then joins the thread:
// In the state object, on the driver thread:
public void ThreadMain()
{
while (!_binding.IsDetaching)
{
if (_binding.Wait(KeyEvent, 50))
{
// a key arrived; drain the device in thread context
}
}
}
// In Probe:
DrainState state = new(binding);
binding.DriverState = state;
if (!binding.TryStartThread("drain", state.ThreadMain, out DriverThread? thread))
{
return ProbeResult.Failed("no scheduler to run the drain thread on");
}
Device locks
binding.CreateLock() returns a DeviceLock, for a device entered from several contexts at once, such as a Transmit the network stack calls while the driver's work item drains the receive ring. Acquire() disables interrupts until the scope is disposed. The lock is not reentrant, and must never be held across Sleep, Wait, Delay, a sink call or a publish:
// In the state object, called by the network stack:
public bool Transmit(ReadOnlySpan<byte> frame)
{
using (_lock.Acquire())
{
// check the ring, copy the frame, write the descriptor,
// DmaBuffer.WriteBarrier(), then advance the tail register
}
return true;
}
Publishing a device
A device kind is a small interface the driver implements, plus a sink the kit hands back for the driver to report events through:
| Kind | The driver implements | Publish call | The driver reports through |
|---|---|---|---|
| Keyboard | IKeyboard (Name, SetLeds) |
PublishKeyboard |
KeyboardSink.Report(scanCode, released) |
| Pointer | IPointer (Name) |
PublishPointer |
PointerSink.ReportRelative(...), ReportAbsolute(...) |
| Network | INetworkInterface (Name, MacAddress, LinkUp, Transmit) |
PublishNetwork |
NetworkSink.Receive(frame), LinkChanged(up) |
| Block | IBlockDevice |
PublishBlockDevice |
nothing |
| Display | IDisplay (Name, Mode, Framebuffer, Flush) |
PublishDisplay |
DisplaySink.ModeChanged() |
| Audio | IAudioOutput (Name, Format, SampleRate, TrySetFormat, Start, Stop, Write) |
PublishAudio |
AudioSink.BufferCompleted(), FormatChanged() |
Each kind's types are in its category's namespace under Cosmos.Kernel.HAL.Devices, the same categories as the drivers' folders: the keyboard and pointer types in Cosmos.Kernel.HAL.Devices.Input; the network ones in Cosmos.Kernel.HAL.Devices.Network, beside MacAddress, the type INetworkInterface.MacAddress returns; the display ones in Cosmos.Kernel.HAL.Devices.Display; the audio ones in Cosmos.Kernel.HAL.Devices.Audio, beside AudioFormat; and IBlockDevice in Cosmos.Kernel.HAL.Devices.Storage.
The kernel's manager for that kind picks the device up as soon as it is published, and teardown withdraws it before releasing anything else. Sinks never allocate, and drop reports once the device is withdrawn.
With a NicState state object implementing INetworkInterface, the probe publishes it and keeps the sink it gets back:
// In Probe:
NicState state = new(binding);
binding.DriverState = state;
state.Sink = binding.PublishNetwork(state);
// In the receive work item, for each frame the device received:
Sink?.Receive(frame);
The log records both ends, here for the keyboard sample above:
[Drivers] synthetic:kbd synthetic-keyboard published keyboard "synthetic-kbd" (consumed)
[Drivers] synthetic:kbd synthetic-keyboard withdrew keyboard "synthetic-kbd"
A few rules per kind:
- Network: call
NetworkSink.Receivefrom thread context, never from the handler; hand received frames to a work item. - Block:
PublishBlockDeviceregisters the disk withStorageManagerand scans its partitions before returning, so the device must be ready. If the manager refuses it, the probe fails. - Display: publish the display as you found it;
Modeis empty until something sets one, andFramebufferisnullwhen the CPU cannot draw into it. The same object may also implementIDisplayModesto switch modes andIHardwareCursorfor a hardware cursor. The bootloader's framebuffer is published as the firmware display at boot, and withdrawn when a driver binds the PCI function it lives in. - Audio:
Writecopies whole frames into the device's buffer, as many as fit, and never blocks; a short write is how the device says it is full.BufferCompletedmay be called from the interrupt handler.
See Graphics, Audio and File System for the consuming side.
Publishing child nodes
A bus driver publishes the devices it finds as child nodes: binding.PublishChild(identity, resources, interrupts, access) adds one beneath its node, to be offered to drivers like any other, and binding.RetractChild(child, hardwarePresent) removes it. Children go away with their parent, and their drivers see DetachCause.ParentRetracted.
A bus driver either uses a bus kind the kit defines (the virtio transports publish VirtioIdentity nodes) or brings its own identity and match pair:
using Cosmos.Kernel.HAL.DriverKit;
namespace MyOS.Drivers;
public sealed class ChildIdentity : DeviceIdentity
{
public ChildIdentity(string name)
{
Name = name;
}
public string Name { get; }
public override string BusName => "mybus";
public override string Address => Name;
public override string Describe()
{
return string.Concat("child ", Name);
}
}
public sealed class ChildMatch : DeviceMatch
{
private readonly string _name;
public ChildMatch(string name)
{
_name = name;
}
public override int Specificity => 1;
public override bool Matches(DeviceIdentity identity)
{
return identity is ChildIdentity child && string.Equals(_name, child.Name);
}
}
// In the bus driver:
public override ProbeResult Probe(DeviceBinding binding)
{
DeviceResource[] resources =
[
DeviceResource.MemoryWindow(0xFEB00000, 0x1000), // the child's registers, index 0
];
DeviceNode child = binding.PublishChild(new ChildIdentity("port0"), resources, [], null);
binding.DriverState = child;
return ProbeResult.Bound;
}
A bus driver may also publish a child of a bus kind the kit already defines: the shipped virtio transport drivers publish their device with the kit's VirtioIdentity, no resources, the nine interrupt sources of a VirtioAccess and that access as the access object (Virtio devices), and the kit's USB enumeration core publishes a device's interfaces beneath the xHCI or hub driver's node with a UsbIdentity, no resources, no interrupt sources and a UsbAccess (USB devices).
Resources are built with DeviceResource.MemoryWindow, PortRange, RamWindow or None (an unassigned slot that keeps the indices after it). Interrupts are InterruptSource subclasses the bus provides.
Logging
binding.Log(message) writes one line to the serial log from thread context: [Drivers] synthetic:kbd synthetic-keyboard: message.
Declining and failing
Probe returns one of three results:
| Result | When to return it |
|---|---|
ProbeResult.Bound |
The driver took the device. |
ProbeResult.Declined(reason) |
The device is not one the driver serves after all (an unknown revision, a missing feature). |
ProbeResult.Failed(reason) |
The device is one the driver serves, but bringing it up did not work. An exception escaping Probe counts as a failure. |
On anything but Bound, the kit releases everything the probe acquired, then offers the node to the next candidate. The reason is printed in the log:
[Drivers] synthetic:decline offer DecliningDriver -> declined: declined on purpose
[Drivers] synthetic:decline offer AnyKeyDriver -> bound
[Drivers] synthetic:throw offer ThrowingDriver -> failed: probe threw on purpose
Teardown and OnDetach
When a node goes away (the device is unplugged, its parent is removed, or a test retracts it), the kit tears its binding down in a fixed order:
IsDetachingturns true, and the binding refuses any new acquisition.- Child nodes are removed.
- Published devices are withdrawn, so the kernel stops using them.
- Interrupts are disconnected, and work items, periodic work and driver threads are stopped.
OnDetachruns.- Memory is freed: DMA buffers, register windows and regions.
OnDetach is optional. It is the place to stop the hardware, because interrupts and threads are already stopped and the registers are still mapped. Check reason.HardwarePresent first: it is false when the device was unplugged, and the registers must not be touched then.
// In the driver:
public override void OnDetach(DeviceBinding binding, DetachReason reason)
{
if (reason.HardwarePresent && binding.DriverState is KeyboardState state)
{
state.Window.Write8(KeyboardState.ControlOffset, 0); // disable the device
}
}
reason.Cause says why: DetachCause.Retracted when the node itself was removed, DetachCause.ParentRetracted when its parent was.
[Drivers] synthetic:kbd synthetic-keyboard withdrew keyboard "synthetic-kbd"
[Drivers] synthetic:kbd retracted
Buses
Buses nest. The machine description publishes the root nodes, and bus drivers publish the devices they find beneath them. On an x64 machine, for example:
platform:i8042@60 I8042Driver
├── ps2:kbd Ps2KeyboardDriver
└── ps2:aux Ps2MouseDriver
platform:pci@cf8 PciHostDriver
├── pci:0000:00:02.0 VirtioPciTransportDriver
│ └── virtio:pci:0000:00:02.0 VirtioNetDriver
└── pci:0000:00:04.0 XhciDriver
└── usb:1-1:0 UsbKeyboardDriver
A driver only sees its own node: a virtio driver never learns which transport carries its device, and a USB driver never sees the controller or the hubs above it.
Each bus kind has its own namespace, Cosmos.Kernel.HAL.DriverKit.Buses.<Bus> (Platform, Pci, Virtio, Usb, Ps2, and Synthetic for tests), and names its types the same way: <Bus>Identity for what a node is, <Bus>Match for what a driver binds, and <Bus>Access for how the driver reaches the device, on every bus but the platform one, whose drivers map the node's resources (the PCI host node alone carries an access object, PciHostAccess).
Platform nodes
Platform nodes are the roots: devices nothing can enumerate, which the machine description publishes at boot. A kernel cannot publish its own.
| Machine | Node | Compatible |
|---|---|---|
| x64 | platform:i8042@60, the PS/2 controller |
pnp0303 |
| x64 | platform:pci@cf8, the PCI host |
pci-host-legacy |
| ARM64 | platform:pci@<base>, the PCI host |
pci-host-ecam-generic |
| ARM64 | platform:virtio_mmio@<base>, one per occupied slot |
virtio,mmio |
On ARM64 they come from ACPI, the device tree, or the virt machine's fixed layout. A driver matches a platform node by one of its compatible strings:
// In the driver:
private readonly DeviceMatch[] _matches = [PlatformMatch.Compatible("virtio,mmio")];
PlatformMatch.Any() matches every platform node.
The PCI host driver
PciHostDriver binds the PCI host node, scans every bus behind it, following bridges, and publishes one pci: node per function it finds. The functions behind a PCI Express hot-plug slot are published by PcieRootPortDriver instead, when a device is plugged in. A function no driver matches is left as the firmware set it up.
PCI devices
A PCI node is one function: its identity read from the configuration header, six resources (its base address registers), its interrupt sources (the legacy line, then its MSI-X messages) and a PciAccess for its configuration space. The shipped E1000EDriver is a good model, and the samples below are its steps.
Identity and match
PciIdentity holds the header fields a driver matches on: VendorId, DeviceId, SubsystemVendorId, SubsystemId, ClassCode, Subclass, ProgIf and Revision, plus the function's address. The node path is pci:<segment>:<bus>:<device>.<function>, for example pci:0000:00:03.0.
PciMatch takes any of those fields; every field given must be equal, and the specificity is the number of fields given:
// In the driver:
using Cosmos.Kernel.HAL.DriverKit.Buses.Pci;
private readonly DeviceMatch[] _matches =
[
new PciMatch(vendorId: 0x8086, deviceId: 0x10D3), // one chip: specificity 2
new PciMatch(classCode: 0x02), // every network controller: specificity 1
];
A match must set at least one field. Prefer the device ids you tested over a class match: the shipped E1000E matches eight Intel device ids and nothing else.
The access object
binding.Node.Access<PciAccess>() reaches the function's configuration space:
ReadConfig8/16/32andWriteConfig8/16/32read and write a register at an offset.FindCapability(id)returns the offset of a capability, or 0.EnableMemorySpace,EnableIoSpaceandEnableBusMasteringturn on what the driver uses: decoding makes the registers answer, bus mastering lets the device reach DMA memory.Barsdescribes the six base address registers (next section).
Before the first probe, the kit turns bus mastering and interrupts off on the function, so a probe turns on what it needs itself. If every driver declines, the kit restores what the firmware had set.
BAR resources
Node.Resources of a PCI node always has six entries, one per base address register: a MemoryWindow, a PortRange, or None for a register that is not assigned (or is the upper half of a 64-bit one). Mapping a None entry throws, so check pci.Bars[i] first:
// In Probe:
PciAccess pci = binding.Node.Access<PciAccess>();
PciBar bar0 = pci.Bars[0];
if (!bar0.IsAssigned || bar0.IsIo || bar0.Length < 0x20000)
{
return ProbeResult.Declined("BAR0 is not a memory window of 128 KiB");
}
RegisterWindow registers = binding.MapRegisters(0);
pci.EnableMemorySpace(true);
pci.EnableBusMastering(true);
The legacy line
Node.Interrupts[0] is the function's legacy interrupt line. It is only routed on x64, and never shared with another device, so TryRequestInterrupt often returns false. Even when it connects, the routing has only been validated on QEMU, so pair it with a periodic drain that keeps the device working if the line never fires:
// In Probe:
WorkItem drain = binding.CreateWorkItem(state.Drain);
state.DrainWork = drain;
bool hasLine = binding.TryRequestInterrupt(binding.Node.Interrupts[0], state.OnInterrupt, out _);
bool polling = binding.TrySchedulePeriodic(50, drain);
if (!hasLine && !polling)
{
return ProbeResult.Declined("no interrupt and no timer to poll with");
}
The handler acknowledges the device and schedules the same drain, which must therefore be safe to run at any time.
Message interrupts
When the function supports MSI-X, Node.Interrupts[1] onwards are its messages, up to 32. They need memory decoding on, because the MSI-X table lives in a BAR, and they return false where the platform cannot route messages (ARM64 without a GICv3 ITS). Connecting a message turns the legacy line off, so the two are never live together:
// In Probe:
pci.EnableMemorySpace(true); // before requesting a message
bool hasMessage = binding.Node.Interrupts.Count > 1
&& binding.TryRequestInterrupt(binding.Node.Interrupts[1], state.OnInterrupt, out _);
Hot-plug slots
A function behind a PCI Express hot-plug slot is published when a device is plugged in, and retracted when it is removed; PcieRootPortDriver handles the slot. If the firmware left the function's registers unassigned, the kit places them before the function is offered, so a driver needs nothing special: it checks Bars as usual, and leaves the registers alone in OnDetach when reason.HardwarePresent is false.
Virtio devices
A virtio node is one virtio device, published by a transport driver over PCI or MMIO. It has no resources, nine interrupt sources and a VirtioAccess that speaks the virtio protocol, so a driver works the same over both transports. The shipped VirtioNetDriver is a good model, and the samples below are its steps.
Virtio identity and match
VirtioIdentity carries the DeviceType (VirtioDeviceType.Network, Block, Gpu, Input, ...) and the transport, which shows in the node path (virtio:pci:0000:00:02.0, virtio:mmio:a003e00) but never matters to the driver. A driver matches on the device type:
// In the driver:
using Cosmos.Kernel.HAL.DriverKit.Buses.Virtio;
private readonly DeviceMatch[] _matches = [VirtioMatch.DeviceType(VirtioDeviceType.Network)];
The virtio access object
binding.Node.Access<VirtioAccess>() is the device. A probe uses it in this order:
NegotiateFeatures(requested, out negotiated)with the feature bits the driver understands. It returnsfalsewhen the device rejects them.TryCreateQueuefor each queue (Queues).ReadConfig8/16/32andWriteConfig8for the device's own configuration, such as a network card's MAC address.TryRequestInterrupton the queue and configuration sources (Virtio interrupt sources).SetDriverOk(), after which the device is live.
The kit resets the device before every probe, so a probe starts at the negotiation:
// In Probe:
VirtioAccess dev = binding.Node.Access<VirtioAccess>();
if (!dev.NegotiateFeatures(FeatureMac | FeatureStatus, out uint features))
{
return ProbeResult.Failed("the device rejected the feature set");
}
Reset() stops the device; a driver calls it in OnDetach.
Queues
A virtqueue is a ring (DMA memory) the driver and the device share. dev.TryCreateQueue(binding, index, preferredSize, out Virtqueue? queue) allocates one and activates it on the device. A Virtqueue offers:
TryAllocateDescriptor(out index)andFreeDescriptor(index).SetDescriptor(index, physicalAddress, length, flags):VirtqueueDescriptorFlags.Writefor a buffer the device fills,Nextto chain descriptors.Submit(head)hands a descriptor chain to the device, andNotify()rings the doorbell.TryTakeUsed(out id, out length)takes back what the device finished.
A queue is not thread-safe: guard it with a DeviceLock when Transmit and a work item share it. Buffers may be submitted before SetDriverOk, but Notify only after:
// In Probe:
if (!dev.TryCreateQueue(binding, ReceiveQueue, 128, out Virtqueue? receiveQueue))
{
return ProbeResult.Failed("no receive queue");
}
// One buffer per descriptor, for the device to write received frames into.
DmaBuffer receiveBuffers = binding.AllocateDma(receiveQueue.Size * BufferBytes, 4096);
for (int i = 0; i < receiveQueue.Size; i++)
{
receiveQueue.TryAllocateDescriptor(out ushort slot);
ulong physical = receiveBuffers.PhysicalAddress + (ulong)(slot * BufferBytes);
receiveQueue.SetDescriptor(slot, physical, BufferBytes, VirtqueueDescriptorFlags.Write);
receiveQueue.Submit(slot);
}
// ... connect the interrupts ...
dev.SetDriverOk();
receiveQueue.Notify();
state.Sink = binding.PublishNetwork(state);
Virtio interrupt sources
dev.QueueInterrupt(n) is the interrupt of queue n, and dev.ConfigInterrupt fires when the device's configuration changes. When the transport could not route them (ARM64 without a GICv3 ITS, for example), TryRequestInterrupt returns false and the driver polls instead:
// In Probe:
WorkItem drain = binding.CreateWorkItem(state.Drain);
bool hasInterrupt = binding.TryRequestInterrupt(dev.QueueInterrupt(ReceiveQueue), state.OnInterrupt, out _);
if (!hasInterrupt && !binding.TrySchedulePeriodic(50, drain))
{
return ProbeResult.Declined("no interrupt and no timer to poll with");
}
In OnDetach, reset the device so it stops writing into memory the kit is about to free, then give DMA in flight a moment to land:
// In the driver:
public override void OnDetach(DeviceBinding binding, DetachReason reason)
{
if (reason.HardwarePresent)
{
binding.Node.Access<VirtioAccess>().Reset();
binding.Delay(100);
}
}
The transport drivers
VirtioPciTransportDriver binds every virtio PCI function (vendor 0x1AF4), using the modern interface and MSI-X messages only. VirtioMmioTransportDriver binds the virtio,mmio platform nodes on ARM64. A leaf driver never deals with either.
The shipped leaf drivers
| Driver | Device type | Publishes |
|---|---|---|
VirtioNetDriver |
Network |
a network interface, virtio-net |
VirtioInputDriver |
Input |
a keyboard, virtio-keyboard, or a pointer, virtio-mouse |
VirtioBlkDriver |
Block |
a block device, vblk<n> |
VirtioGpuDriver |
Gpu |
a display, virtio-gpu (2D only) |
The display drivers
| Driver | Hardware | Notes |
|---|---|---|
VirtioGpuDriver |
virtio-gpu, over PCI or MMIO | 2D: the guest draws, the host composites |
VmwareSvgaDriver |
VMware SVGA II, PCI, x64 only | Switches modes and draws a hardware cursor; replaces the firmware framebuffer, which lives in its VRAM |
The storage drivers
| Driver | Hardware | Disk names |
|---|---|---|
AhciDriver |
SATA controllers in AHCI mode (disks only, no CD-ROM) | sata<n> |
NvmeDriver |
NVMe controllers | nvme<controller>n<namespace> |
VirtioBlkDriver |
virtio-blk, over PCI or MMIO | vblk<n> |
A USB stick is handled by UsbMassStorageDriver (The USB class drivers). Every disk is registered with StorageManager when it is published.
USB devices
A USB node is one interface of a USB device, published by the host controller driver for a device on a root port, or by the hub driver for a device behind a hub. It has no resources, no interrupt sources and a UsbAccess for everything a class driver does. A device with several interfaces gives several nodes. A class driver never sees the controller or the hubs above it; the shipped UsbKeyboardDriver and UsbMassStorageDriver are good models.
USB identity and match
UsbIdentity holds the device's VendorId, ProductId and class, and the interface's class, subclass and protocol. The node path is the port chain and the interface number: usb:1-1:0 is interface 0 of the device on root port 1 of the first controller, and usb:1-2.1:0 a device on port 1 of a hub plugged into root port 2.
UsbMatch works like PciMatch: every field given must be equal, and the specificity is the number of fields given. A class driver usually matches the interface class, subclass and protocol:
// In the driver:
using Cosmos.Kernel.HAL.DriverKit.Buses.Usb;
private readonly DeviceMatch[] _matches =
[
new UsbMatch(interfaceClass: UsbClassCode.Hid, interfaceSubclass: 0x01, interfaceProtocol: 0x01), // a HID boot keyboard: specificity 3
];
The USB access object
binding.Node.Access<UsbAccess>() is the interface. Its members run in thread context:
Endpointslists the interface's endpoints, andFindEndpoint(type, isIn)finds one.ControlIn,ControlOutandGetDescriptorsend control requests to the device. Each returns aUsbTransferStatus:Success,Stall,Timeout,ErrororDisconnected.OpenInterruptPipe(binding, endpoint, handler, out pipe)opens an interrupt IN endpoint and calls the handler with every report the device sends.OpenBulkPipe(binding, endpoint, out pipe)opens a bulk endpoint, andBulkInandBulkOutmove data through it and wait for the transfer.IsDisconnectedturns true once the device is unplugged; every transfer then returnsDisconnected, so check it rather than retrying.
Pipes are recorded on the binding, so the kit closes them when the probe declines or the device goes away. The mass storage driver opens two bulk pipes; if the second fails, the kit still closes the first:
// In Probe:
UsbAccess usb = binding.Node.Access<UsbAccess>();
UsbEndpoint? bulkIn = usb.FindEndpoint(UsbEndpointType.Bulk, isIn: true);
UsbEndpoint? bulkOut = usb.FindEndpoint(UsbEndpointType.Bulk, isIn: false);
if (bulkIn is null || bulkOut is null)
{
return ProbeResult.Declined("no bulk endpoints");
}
if (!usb.OpenBulkPipe(binding, bulkIn, out UsbPipe? inPipe) || !usb.OpenBulkPipe(binding, bulkOut, out UsbPipe? outPipe))
{
return ProbeResult.Failed("could not open the bulk pipes");
}
A report handler may run in interrupt context, so it follows the rules of an interrupt handler: no blocking, no allocation, and no call to UsbAccess or DeviceBinding. It may call a sink, DeviceEvent.Signal and Interlocked. The keyboard driver publishes its keyboard first, so the handler always has a sink, then opens the report pipe:
// In Probe:
UsbEndpoint? reports = usb.FindEndpoint(UsbEndpointType.Interrupt, isIn: true);
if (reports is null)
{
return ProbeResult.Declined("no interrupt IN endpoint");
}
state.Sink = binding.PublishKeyboard(state);
if (!usb.OpenInterruptPipe(binding, reports, state.OnReport, out UsbPipe? pipe))
{
return ProbeResult.Failed("could not open the report pipe");
}
// In the state object, for every report:
public void OnReport(ReadOnlySpan<byte> report)
{
// compare with the previous report and hand each key change to Sink.Report
}
The host controller contract
A host controller driver binds the controller's own node and implements UsbHostController and UsbDevice, with its pipes derived from UsbPipe. It creates a UsbBus, whose Attach(port, speed) and Detach(port) enumerate a device and publish or retract its interface nodes. XhciDriver is the reference implementation; a class driver never deals with any of this.
Hubs
UsbHubDriver binds every hub interface. It hands the devices on the hub's ports to the same enumeration through ConfigureAsHub, AttachChild and DetachChild, and watches the ports from a thread, so a device behind a hub is offered like any other, as usb:1-2.1:0.
The xHCI driver
XhciDriver binds every xHCI controller. It enumerates the devices already plugged in during its probe, so a USB stick is ready before OnBoot, and handles hot-plug from a thread. It uses an MSI-X message where the platform routes one and polls every 20 ms otherwise. Isochronous endpoints, interrupt OUT endpoints and streams are not supported.
The USB class drivers
| Driver | Matches | Publishes |
|---|---|---|
UsbKeyboardDriver |
HID boot keyboards | a keyboard, usb-keyboard |
UsbMassStorageDriver |
Mass storage over bulk-only transport (sticks, card readers, USB disks) | a block device per unit, usb<n> |
UsbHubDriver |
Hubs | the devices behind the hub, as child nodes |
A USB stick present at boot, for example:
[Drivers] pci:0000:00:03.0 XhciDriver: usb 1-1: 46f4:0001 SuperSpeed, 1 interface(s)
[Drivers] pci:0000:00:03.0 offer XhciDriver -> bound
[Drivers] usb:1-1:0 candidates: UsbMassStorageDriver(prio 0, spec 3)
[StorageManager] usb0 registered by UsbMassStorageDriver (primary)
[Drivers] usb:1-1:0 UsbMassStorageDriver published block "usb0" (consumed)
[Drivers] usb:1-1:0 offer UsbMassStorageDriver -> bound
PS/2 devices
A PS/2 node is one port of the 8042 controller, published by I8042Driver: ps2:kbd for the keyboard port and ps2:aux for the mouse port. It has no resources, one interrupt source and a Ps2Access to talk to the device. The class driver finds out what is plugged in. PS/2 exists on x64 only.
PS/2 identity and match
A driver matches one port, never both, since a keyboard and a mouse speak different protocols:
// In the driver:
using Cosmos.Kernel.HAL.DriverKit.Buses.Ps2;
private readonly DeviceMatch[] _matches = [Ps2Match.Port(Ps2Port.Keyboard)];
The PS/2 access object
binding.Node.Access<Ps2Access>() is the port:
TryCommand(command, arguments, reply, out replyLength, timeoutMilliseconds)sends a command and its arguments, waits for the device to acknowledge each byte, and collects the reply. Thread context.TryReceive(out value)takes the next byte the device sent on its own, such as a scan code. Any context, so the handler uses it.DeliversUnattendedisfalsewhen the controller can neither interrupt nor be polled; decline the port then.
Bytes that answer a command go to TryCommand, never to TryReceive. A keyboard probe resets the device, publishes the keyboard, connects the port's interrupt, then starts scanning:
// In Probe:
Ps2Access ps2 = binding.Node.Access<Ps2Access>();
if (!ps2.DeliversUnattended)
{
return ProbeResult.Declined("no interrupt and no timer to poll with");
}
Span<byte> reply = stackalloc byte[1];
if (!ps2.TryCommand(Reset, [], reply, out int count, 1000) || count == 0 || reply[0] != SelfTestPassed)
{
return ProbeResult.Declined("no keyboard on the port");
}
state.Sink = binding.PublishKeyboard(state); // before scanning starts
if (!binding.TryRequestInterrupt(binding.Node.Interrupts[0], state.OnInterrupt, out _))
{
return ProbeResult.Failed("the port's interrupt could not be connected");
}
if (!ps2.TryCommand(EnableScanning, [], [], out _, 100))
{
return ProbeResult.Failed("enable scanning was not acknowledged");
}
// In the state object, in interrupt context:
public void OnInterrupt(InterruptContext context)
{
while (_ps2.TryReceive(out byte value))
{
// decode the scan code and hand it to Sink.Report
}
}
The controller contract
Ps2Controller is what a controller driver implements to send bytes to a port and hand back the bytes it reads. I8042State is the only implementation, and a class driver never uses it.
The 8042 driver
I8042Driver binds platform:i8042@60. It tests the controller and its two ports, connects IRQ 1 and IRQ 12 (or polls every 20 ms when it cannot), and publishes one node per working port. It is the only code that touches ports 0x60 and 0x64.
The PS/2 class drivers
| Driver | Port | Publishes |
|---|---|---|
Ps2KeyboardDriver |
ps2:kbd |
a keyboard, ps2-keyboard (AT and MF2 keyboards, with their indicators) |
Ps2MouseDriver |
ps2:aux |
a pointer, ps2-mouse (standard and wheel mice) |
Both are published during the driver stage, so a kernel finds them in OnBoot. PS/2 has no hot-plug.
[Drivers] ps2:kbd Ps2KeyboardDriver published keyboard "ps2-keyboard" (consumed)
[Drivers] ps2:kbd Ps2KeyboardDriver: MF2 keyboard (id ab 41), scanning
[Drivers] ps2:kbd offer Ps2KeyboardDriver -> bound
[Drivers] ps2:aux Ps2MouseDriver published pointer "ps2-mouse" (consumed)
[Drivers] ps2:aux Ps2MouseDriver: wheel mouse (id 03), 4-byte packets, reporting
[Drivers] ps2:aux offer Ps2MouseDriver -> bound
Observing drivers
The kit reports what it does in two places. The serial log has one line per event, prefixed [Drivers], so you can follow a boot. The DriverDiagnostics class in Cosmos.Kernel.System.Diagnostics gives code the same facts, for a test or a shell command.
The lines you will see most, in the order a device's life produces them:
| Line | Meaning |
|---|---|
manifest: A(prio 0) B(prio 10) |
The registered drivers, once at the start of the driver stage |
synthetic:k candidates: A(prio 10, spec 1) B(prio 0, spec 1) |
A node is offered to these drivers, in this order |
synthetic:k offer A -> bound / -> declined: reason / -> failed: message |
What each probe returned |
synthetic:k no driver |
No driver matched the node |
synthetic:k A published keyboard "name" (consumed) / (no consumer) |
A device was published, and whether a kernel manager took it |
synthetic:k A: message |
The driver called binding.Log |
synthetic:k A interrupt handler threw: message |
A handler threw; its interrupt is masked |
synthetic:k A work item threw: message |
A work item threw; it is cancelled |
synthetic:k A withdrew keyboard "name" |
Teardown withdrew a device |
synthetic:k retracted |
The node was removed |
Bus drivers add their own lines through binding.Log: the xHCI driver logs each USB device it finds, the root port driver each hot-plug event. The storage manager adds [StorageManager] lines when it registers or drops a disk.
DriverDiagnostics only reads, so it never changes the kit and never throws:
DriverCount,NodeCountandDeviceCount, withTryGetDriver,TryGetNodeandTryGetDeviceto read one entry by index.TryGetOffer(nodeIndex, offerIndex, out DeviceOfferInfo)replays the offers a node received: the driver, its priority and specificity, theOutcome(Bound,Declined,Failed) and theReason.- A node (
DeviceNodeInfo) gives itsPath,DriverName,State(Pending,Bound,Unbound,Retracted) and counters such asHeldResourceCount,FaultCountandLastFault.
This prints every node with the driver that took it, and the offers that led there:
// In the kernel:
using Cosmos.Kernel.System.Diagnostics;
for (int i = 0; i < DriverDiagnostics.NodeCount; i++)
{
if (!DriverDiagnostics.TryGetNode(i, out DeviceNodeInfo node))
{
continue;
}
Console.WriteLine($"{node.Path}: {node.DriverName ?? "no driver"} ({node.State})");
for (int j = 0; j < node.OfferCount; j++)
{
if (DriverDiagnostics.TryGetOffer(i, j, out DeviceOfferInfo offer))
{
Console.WriteLine($" {offer.DriverName} -> {offer.Outcome} {offer.Reason}");
}
}
}
The snapshots are taken without locking, so a node that is being offered or torn down at that moment may read one step behind.
Testing a driver over the synthetic bus
The synthetic bus lets you test a driver with no hardware. A test kernel publishes a fake node, the kit offers it to your driver as it would a real device, and the test plays the hardware's side. It behaves the same on x64 and ARM64.
SyntheticBus has four members:
| Member | What it does |
|---|---|
Publish(key, data, interruptCount, windowBytes) |
Publishes a node at synthetic:key with interruptCount interrupt sources and, when windowBytes is set, a RAM-backed register window of up to one page as resource 0. Returns once the node was offered |
RaiseInterrupt(node, index) |
Runs the handler connected to that source, as a real interrupt would. Returns false when nothing is connected or the source is masked |
WaitForQueuedJobs() |
Waits until every work item queued so far has run, so the test can check what it did |
Retract(node, hardwarePresent) |
Removes the node and returns once the binding was torn down. hardwarePresent is what OnDetach sees; it defaults to false, a hot-unplug |
The test sees the device through the node's SyntheticAccess: Window is the register window's memory, so the test writes what the driver will read and reads what the driver wrote.
A test of the keyboard driver above:
// In the test kernel:
using Cosmos.Kernel.HAL.DriverKit;
using Cosmos.Kernel.HAL.DriverKit.Buses.Synthetic;
using Cosmos.Kernel.System.Diagnostics;
using Cosmos.TestRunner.Framework;
using MyOS.Drivers;
// In BeforeRun:
DeviceNode node = SyntheticBus.Publish(SyntheticKeyboardDriver.Key, [], interruptCount: 1, windowBytes: 64);
SyntheticAccess access = node.Access<SyntheticAccess>();
Assert.Equal(KeyboardState.EnableBit, access.Window[KeyboardState.ControlOffset], "the probe should arm the device");
// Play a key press: fill the registers, then raise the interrupt.
access.Window[KeyboardState.ScanCodeOffset] = 0x1E;
access.Window[KeyboardState.FlagsOffset] = 0;
Assert.True(SyntheticBus.RaiseInterrupt(node, 0), "the handler should run");
SyntheticBus.WaitForQueuedJobs(); // the work item the handler scheduled has run too
// Unplug: the kit releases everything the probe acquired.
int heldBefore = DriverDiagnostics.GetTotalHeldResourceCount();
SyntheticBus.Retract(node);
Assert.True(DriverDiagnostics.GetTotalHeldResourceCount() < heldBefore, "teardown should release the binding");
Assert.False(SyntheticBus.RaiseInterrupt(node, 0), "nothing is connected any more");
Reach the driver's own state through node.Binding.DriverState to check what the handler did, and use DriverDiagnostics (see Observing drivers) to check the node's state and offers. A node published in the kernel's constructor, before the driver stage, tests the boot path; one published in BeforeRun tests hot-plug. See Testing for how to build and run a test kernel.
Project settings
A driver inside the kernel project
A kernel project built with Cosmos.Sdk only needs to acknowledge the experimental kit. The driver class may be internal:
<!-- In the kernel's .csproj: -->
<PropertyGroup>
<NoWarn>$(NoWarn);COSMOS0003</NoWarn>
</PropertyGroup>
A driver library
To ship drivers on their own, put them in a class library. It references Cosmos.Kernel.HAL for the kit and Cosmos.Kernel.System for the kernel API. Its [Driver] classes must be public, so the kernel's manifest generator can see them:
<!-- The library's .csproj: -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<CosmosDriverAssembly>true</CosmosDriverAssembly>
<NoWarn>$(NoWarn);COSMOS0003</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cosmos.Kernel.HAL" />
<PackageReference Include="Cosmos.Kernel.System" />
<PackageReference Include="Cosmos.Build.Analyzer.Patcher" />
</ItemGroup>
</Project>
The versions are left to central package management. Without a Directory.Packages.props, give each reference the version your kernel uses.
CosmosDriverAssembly, with the analyzer package, checks at build time that the library only uses the public API:
NAOT0007: it names nothing fromCosmos.Kernel.Core, only whatCosmos.Kernel.HALandCosmos.Kernel.Systemoffer.NAOT0008: it uses no[UnsafeAccessor]to reach internals.NAOT0009: no Cosmos assembly grants itInternalsVisibleTo.
The shipped drivers in Cosmos.Kernel.Drivers are built the same way, so they use nothing your own driver can't.
The kernel references the library with a ProjectReference or PackageReference and adds its own NoWarn line. The library's drivers join the manifest after the kernel's own.
Excluding and opting in
Which drivers a kernel carries is set in its .csproj, by full type name:
<!-- In the kernel's .csproj: -->
<ItemGroup>
<!-- Drop a driver the build would otherwise register, here the shipped Intel network driver. -->
<CosmosDriverExclude Include="Cosmos.Kernel.Drivers.Pci.Network.E1000E.E1000EDriver" />
<!-- Register a driver declared [Driver(Default = false)]. -->
<CosmosDriverInclude Include="Acme.Drivers.ExperimentalGpu" />
</ItemGroup>
- Excluding a bus driver also leaves the devices behind it unbound: without
PciHostDriver, no PCI node is published, so no PCI driver binds. - To drop a whole kind of device, turn its feature switch off instead:
CosmosEnableNetworkset tofalsekeeps every network driver out. - A name that matches no driver is reported as
COSMOSGEN002. - The generated
DriverManifest.g.csunderobj/shows what the kernel carries.
Checklist
- Derive from
Driver, mark the class[Driver], and give itName,Matchesand, when it must win a device from another driver,Priority. - Keep the driver class stateless across devices: create a state object in
Probeand hang it offbinding.DriverState. - In
Probe, decline early on what the node is not (ProbeResult.Declined), acquire everything through the binding, publish the device, connect the interrupt, and arm the device last; fail on bring-up (ProbeResult.Failed) and let the kit unwind. - Keep the handler to registers, DMA memory, sinks and the three
InterruptContextmembers; hand everything else to a work item. Never allocate or block there: the guard stops the call and masks the source. - Order DMA with
DmaBuffer.WriteBarrier()before handing a descriptor over andDmaBuffer.ReadBarrier()after reading a device-written flag; register accesses carry their own. - Check the
Tryresults:TryRequestInterrupt,TryAllocateDma,TrySchedulePeriodic,TryStartThreadall sayfalsewhen the platform or the kernel's switches cannot provide it. - For a PCI function, decline on
PciAccess.Barsbefore mapping (a function that arrived behind a hot-plug slot has had its registers placed by the kit, so the same check holds), turn on decoding and bus mastering throughPciAccessyourself, request its message sources only after decoding is on, and pair the legacy line with a periodic drain, since neither the line nor the messages are routable everywhere. - For a virtio device, negotiate, create the queues, submit the buffers and connect the queue sources first, then
SetDriverOkand only thenNotify; reset the device inOnDetach. - For a USB interface, find the endpoints through
UsbAccess, open pipes on your own binding and let the kit close them, checkIsDisconnectedand theDisconnectedstatus rather than retrying, and keep a report handler to a sink,InterlockedandDeviceEvent.Signal. - For a PS/2 port, talk to the device through
Ps2Accessonly:TryCommandfor every command with its acknowledgement,TryReceivefrom the handler for the stream, and publish before you enable scanning or reporting. - Write driver threads as a loop on
IsDetachingaroundbinding.Wait, and return promptly once it turns true. - Put hardware quiescing in
OnDetach, and only whenreason.HardwarePresentis true. - Write a test kernel over the synthetic bus: publish, raise,
WaitForQueuedJobs, retract, and assert throughDriverDiagnosticsand the driver's own state. - Add
<NoWarn>$(NoWarn);COSMOS0003</NoWarn>to the project, and<CosmosDriverAssembly>true</CosmosDriverAssembly>to a library, with its driverspublic.