1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
|
Implementing a Device Driver
============================
This guide walks through the core of TeachOS's device/driver model — the four types that make up the device tree, how a driver gets matched and bound to a device, and a complete worked example already living in the tree.
It assumes the facet-dispatch mechanism (``facet_id``, ``device.facet<T>()``, ``query_facet()``) is already familiar; if it is not, read *Technical Brief 0003, "Facet-Based Capability Dispatch in the Device Model"* first (:doc:`/briefs/tb0003-facet-based-capability-dispatch`) — this guide builds directly on it and does not re-derive it.
Two related topics — the declarative resource model (``kapi::devices::resource``, for MMIO/port/IRQ/DMA assignment, :doc:`/briefs/tb0005-device-resources`) and the linker-section-based driver self-registration mechanism (:doc:`/briefs/tb0006-compile-time-driver-self-registration`) — are used here only as much as the worked example needs.
The Four Building Blocks
--------------------------
Every part of the device tree is built from exactly four types, all in ``kapi::devices`` (``kapi/kapi/devices/*.hpp``, included together as ``<kapi/devices.hpp>``):
* **A device** (``device``) is an inert tree node. It carries an identity (a tree name, not a stable one — see below), a lifecycle state, a weak back-pointer to its parent, an optional bound driver, opaque driver-owned data, and a list of assigned resources. It does no work on its own; every behavior a concrete device type has beyond that comes from the facets it implements (:doc:`/briefs/tb0003-facet-based-capability-dispatch`).
* **A bus** (``bus : device``) is a device that owns children. It holds them by ``shared_ptr`` (a bus's destructor tears down its whole subtree), and it optionally carries a ``bus_protocol *``. A bus with no protocol can still hold children (attached directly, one at a time, by whatever constructs it) but cannot autonomously enumerate them or match drivers against them.
* **A driver** (``driver``) is stateless logic: ``probe()`` to claim and initialize a device, ``unbind()`` to undo that, and ``suspend()``/``resume()`` for power management. A driver instance is never itself a node in the tree — it can be bound to any number of devices, one at a time, over its lifetime.
* **A bus protocol** (``bus_protocol``) is the bus-specific behavior a ``bus`` delegates to: ``enumerate()`` discovers a bus's children, and ``match()`` scores how well a given driver fits a given device. This is the one piece of the model every new kind of bus (ISA, a future PCI, TeachOS's own pseudo-device bus below) has to write for itself; everything else is reused as-is.
.. code-block:: text
root (bus)
└── pseudo (bus, kernel::bus::pseudo, has a bus_protocol)
├── "null" (device, publishes kernel::bus::pseudo_signature)
│ bound to: kernel::drivers::pseudo::null (a driver)
└── "zero" (device, publishes kernel::bus::pseudo_signature)
bound to: kernel::drivers::pseudo::zero (a driver)
A device's tree name (the string passed to its constructor) is unique only within its parent bus, not globally — it is what shows up in logs and in ``kapi::devices::device_registry::find()``, not a stable identifier a filesystem or a persistent record should depend on.
The Device Lifecycle
-----------------------
``kapi::devices::state`` (``device.hpp``) is a small, linear state machine, and every transition happens at one specific, findable call site:
.. code-block:: cpp
enum struct state
{
uninitialized, // constructed, not yet attached to a bus
present, // attached to a bus, not yet matched to a driver
probing, // a driver's probe() is currently running
bound, // a driver successfully claimed this device
failed, // every candidate driver's probe() failed
removed, // detached from its bus; the object may still be kept alive elsewhere
};
* ``uninitialized → present`` happens inside ``bus::add_child()``, immediately after the device is registered with ``device_registry`` and pushed into the parent's child list.
* ``present → probing → (bound | failed)`` happens inside ``driver_registry``'s matching loop, described next.
* ``(bound | failed | present) → removed`` happens inside ``bus::do_remove_child()``, which also calls the bound driver's ``unbind()`` (if any), withdraws every facet the device published (``facet_registry::withdraw_all_for()``), and unregisters the device from ``device_registry`` — all before the device object itself is necessarily destroyed. A device can stay alive past ``removed`` if something else still holds a ``shared_ptr`` to it (mirroring the reasoning in ``kapi::devices::facet_registry``'s own documentation about weak references outliving a device's tree membership).
Matching and Binding
------------------------
Attaching a device to a bus does not, by itself, bind a driver to it. That is ``driver_registry``'s job (``kapi::devices::driver_registry``, a single global instance), and it runs the same algorithm from two different triggers:
* ``driver_registry::add(driver)`` — a new driver was just registered; try it against every currently-unbound device in the whole tree.
* ``driver_registry::device_attached(device)`` — a new device was just attached to a bus (called automatically from ``bus::add_child()``); try every currently-registered driver against it.
Both funnel into ``try_bind()``:
.. code-block:: cpp
auto driver_registry::try_bind(kstd::shared_ptr<device> const & device) -> void
{
if (!device || device->state() == state::bound) { return; } // sticky binding
auto parent = device->parent();
auto protocol = parent ? parent->facet<bus_protocol>() : nullptr;
if (!protocol) { return; } // no bus_protocol, e.g. a plain bus with no matching story
// ... collect {priority, driver} for every registered driver where protocol->match() succeeds ...
std::ranges::stable_sort(candidates, std::ranges::greater{}, &candidate::priority);
for (auto const & candidate : candidates)
{
device->bind_driver(candidate.driver_handle);
device->set_state(state::probing);
if (candidate.driver_handle->probe(*device))
{
device->set_state(state::bound);
return; // done — the highest-priority driver that actually probed successfully wins
}
device->bind_driver(kstd::weak_ptr<driver>{}); // that candidate failed; try the next
}
if (!candidates.empty()) { device->set_state(state::failed); }
}
Three things worth being deliberate about, because each one is a real design decision, not an accident of implementation:
* **Priority, not registration order, decides the winner.** Every driver whose ``match()`` returns a value (rather than an error) is a candidate; candidates are sorted by priority, highest first, and tried in that order. Registration order only breaks exact ties (``stable_sort``). A bus with only one plausible driver per device (ISA, the pseudo bus below) always returns a fixed priority from ``match()`` — see ISA's own ``match()`` in :doc:`/briefs/tb0003-facet-based-capability-dispatch` for why that is a legitimate degenerate case, not unfinished code.
* **A failed ``probe()`` falls through, not fails outright.** If the highest-priority candidate's ``probe()`` returns an error, the device is unbound again and the *next* candidate is tried — a driver can lose at the probe step for a reason ``match()`` had no way to see (a resource already claimed by something else, say).
* **Binding is sticky.** ``try_bind()`` returns immediately for a device already in ``state::bound``. A driver registered after the fact never steals an already-bound device, no matter what priority it would have scored — rebinding is not something this loop ever does on its own.
``match()``'s error channel distinguishes "not a candidate" from "something actually went wrong": ``kapi::devices::driver_match_errc::no_match`` (and ``no_device_signature``) both map to ``kstd::errc::not_supported`` via their category's ``default_error_condition()``, and ``try_bind()`` only logs a warning for an error that maps to something *else* — the ordinary case of twenty unrelated drivers all declining a device produces no log spam, while a genuine probing failure inside ``match()`` itself still surfaces.
A Worked Example: the Pseudo Device Bus
-------------------------------------------
TeachOS's simplest real bus, ``kernel::bus::pseudo`` (``kernel/kernel/bus/pseudo.{hpp,cpp}``), backs ``/dev/null`` and ``/dev/zero``. It has no real hardware behind it at all, which makes it a better first example to trace than ISA — there is nothing to get right beyond the device model itself.
**The identification pair.** Exactly the ``*_signature``/``*_claim`` pattern from :doc:`/briefs/tb0003-facet-based-capability-dispatch`, this time matching by an arbitrary name rather than a fixed ISA identifier:
.. code-block:: cpp
struct pseudo_signature
{
constexpr auto static id = kapi::capabilities::facet_id{"sig.dev.pseudo"};
virtual ~pseudo_signature() = default;
[[nodiscard]] virtual auto name() const noexcept -> std::string_view = 0;
};
struct pseudo_claim
{
constexpr auto static id = kapi::capabilities::facet_id{"clm.drv.pseudo"};
virtual ~pseudo_claim() = default;
[[nodiscard]] virtual auto supported_names() const noexcept -> std::span<std::string_view const> = 0;
};
**The bus and its protocol.** ``pseudo_protocol::match()`` (an anonymous-namespace type local to ``pseudo.cpp``) checks whether the device's signature name appears in the driver's claimed names, and returns a fixed priority (``0u``) on a hit — there is never more than one candidate driver per pseudo device, so there is nothing to rank:
.. code-block:: cpp
struct pseudo final : kapi::devices::bus
{
pseudo() : kapi::devices::bus{"pseudo", protocol_instance} {} // protocol_instance: a static pseudo_protocol
};
**The device — a third way to implement a facet.** :doc:`/briefs/tb0003-facet-based-capability-dispatch` shows a facet implemented by multiple inheritance (``pit`` directly *is* an ``isa_signature``) and by composition through the facet registry (``ram_disk``'s driver-owned ``block_node``). ``kernel::devices::pseudo`` uses a third, equally valid shape: a facet implementation held as a plain **member**, with ``query_facet()`` handing out its address:
.. code-block:: cpp
struct pseudo_signature final : kernel::bus::pseudo_signature
{
explicit constexpr pseudo_signature(kapi::devices::device const & device) : m_name{device.name()} {}
[[nodiscard]] auto name() const noexcept -> std::string_view override { return m_name; }
private:
std::string_view m_name;
};
struct pseudo final : kapi::devices::device
{
explicit pseudo(kstd::string name);
protected:
auto query_facet(kapi::capabilities::facet_id facet) -> void * override;
private:
pseudo_signature m_signature{*this};
};
auto pseudo::query_facet(kapi::capabilities::facet_id facet) -> void *
{
if (facet == kernel::bus::pseudo_signature::id) { return &m_signature; }
return device::query_facet(facet);
}
This is worth choosing deliberately, not by default: multiple inheritance is right when the device type and the facet are the same concept end to end (``pit`` *is* its own ISA identity); a member is right when the facet's implementation needs its own small piece of state derived from the device (here, just the device's own name, captured once at construction) without the device type itself needing to satisfy the facet's interface through inheritance. Both forward to ``device::query_facet()`` for anything they don't recognize — the delegation rule from :doc:`/briefs/tb0003-facet-based-capability-dispatch` applies identically regardless of which shape you pick.
**The driver.** ``null`` (``kernel/kernel/drivers/pseudo/null.{hpp,cpp}``) implements ``pseudo_claim`` and the two lifecycle methods every driver must provide:
.. code-block:: cpp
auto null::probe(kapi::devices::device & device) -> kstd::result<void>
{
auto signature = device.facet<kernel::bus::pseudo_signature>();
if (!signature) { return kstd::failure(kapi::devices::driver_match_errc::no_device_signature); }
if (signature->name() != "null") { return kstd::failure(kapi::devices::driver_match_errc::invalid_device_signature); }
auto implementation = kstd::make_shared<null_node>(); // null_node : kapi::filesystem::character_special_file
auto published = kapi::devices::publish_facet<kapi::filesystem::character_special_file>(
device.shared_from_this(), "null", implementation.get());
if (!published) { return published; }
device.set_driver_data(std::move(implementation)); // keeps null_node alive for as long as the binding lasts
return kstd::success();
}
auto null::unbind(kapi::devices::device & device) -> void
{
device.set_driver_data(nullptr); // drops the driver's last reference to null_node
}
Two things to notice, both general rules rather than quirks of this one driver:
* **``probe()`` re-checks identity even though ``match()`` already checked it.** ``match()``'s job is to *rank* candidates before any one of them is committed to; ``probe()``'s job is to actually claim the device, and it is the one place a driver can fail for a reason specific to itself (here, a name mismatch would mean ``match()`` and ``probe()`` disagree — a bug worth catching defensively, distinguished from "no signature at all" by a separate ``driver_match_errc`` value). Do not assume ``probe()`` is only ever called on a device that already passed every check you might imagine — check what you depend on.
* **``unbind()`` must undo exactly what ``probe()`` did, and nothing this driver does bypasses it.** ``null`` published a facet and stored driver data; ``unbind()`` clears the driver data (which drops the last strong reference to the published implementation) and nothing else — there is no separate facet withdrawal call here because ``bus::do_remove_child()`` already calls ``facet_registry::withdraw_all_for()`` unconditionally on removal. A driver that needs to unpublish facets *without* the device being removed from the tree (an explicit unbind requested by something other than removal) would need to call ``facet_registry::get().withdraw()`` itself — ``null`` never hits that path today, but a driver that can be unbound while its device stays attached should not assume ``unbind()`` is always followed by tree removal.
**Registering the driver.** ``null`` never appears in a hand-maintained list anywhere. A small, file-local descriptor and a ``constexpr`` instance are enough:
.. code-block:: cpp
namespace
{
struct descriptor final : kapi::devices::driver_descriptor
{
[[nodiscard]] auto name() const noexcept -> std::string_view override { return "dev_null"; }
[[nodiscard]] auto make_instance() const -> kstd::shared_ptr<kapi::devices::driver> override
{
return kstd::make_shared<null>();
}
};
[[gnu::used]]
constexpr auto registration = kapi::devices::kernel_driver<descriptor>{};
}
``kernel_driver<Type>`` places a pointer to this descriptor into a dedicated linker section (``kernel_drivers``); at boot, ``kernel::drivers::init()`` walks that section, constructs one instance of every driver found in it via ``make_instance()``, and registers each with ``driver_registry::get().add()`` — the mechanism itself (why a linker section, how the section boundaries are exposed to C++, why this needs no runtime constructor to work, and how the identical idiom is reused for filesystem drivers) is covered in full in :doc:`/briefs/tb0006-compile-time-driver-self-registration` and is not repeated here. The practical rule for writing a new driver: add exactly this ``descriptor``/``kernel_driver<descriptor>`` pair (or ``platform_driver<descriptor>`` for a driver that belongs to platform rather than generic-kernel code) to your driver's ``.cpp`` file, and nothing elsewhere needs to know your driver exists.
**Putting a device on the bus.** All of the above only takes effect once a device actually exists and is attached — which, for the pseudo bus, is three lines in ``kernel::devices::init()`` (``kernel/kernel/devices/init.cpp``):
.. code-block:: cpp
auto init() -> void
{
auto root = kapi::devices::get_root_bus();
auto pseudo_bus = kstd::make_shared<kernel::bus::pseudo>();
root->add_child(pseudo_bus);
pseudo_bus->add_child(kstd::make_shared<devices::pseudo>("null"));
pseudo_bus->add_child(kstd::make_shared<devices::pseudo>("zero"));
}
Each ``add_child()`` call runs the full sequence from *The Device Lifecycle* and *Matching and Binding* above: the device is registered, marked ``present``, and immediately offered to ``driver_registry::device_attached()``. Because ``kernel::drivers::init()`` (which registers ``null`` and ``zero`` via their ``kernel_driver<descriptor>`` self-registration) runs *before* ``kernel::devices::init()`` in ``kernel::main`` — a real ordering constraint, not an implementation accident, derived in full in :doc:`/briefs/tb0006-compile-time-driver-self-registration` — both drivers already exist in ``driver_registry`` by the time these two ``add_child()`` calls fire, so each pseudo device is matched and bound synchronously, inline, before ``kernel::devices::init()`` returns.
Resources, Briefly
----------------------
A device can also carry a list of declarative ``kapi::devices::resource`` values (``mmio_range``, ``io_port``, ``interrupt_line``, ``dma_channel``) — assigned by whatever constructs the device, before it is attached, and requested inside ``probe()``:
.. code-block:: cpp
auto port = device.request_resource<kapi::devices::resource_type::port>();
if (!port) { return kstd::failure(port.error()); }
This keeps a driver from hard-coding an address or a port number that really belongs to whatever bus discovered the device (see the ISA PIT driver, ``arch/x86_64/arch/drivers/pit.cpp``, for a complete example). The resource type system itself — the tagged-union shape of ``resource``, how a bus assigns resources during enumeration, and its real drawbacks (no overlap detection, no automatic release on unbind) — is out of scope for this guide; see :doc:`/briefs/tb0005-device-resources` for the full treatment.
Suspend and Resume, Briefly
--------------------------------
``driver::suspend()``/``resume()`` default to no-ops, so a driver that has nothing to do on a power transition needs no overrides at all. ``kapi::devices::suspend_tree()``/``resume_tree()`` (``kapi/kapi/devices/power.hpp``) walk a bus's subtree depth-first — children suspended before their parents, and a failed suspend rolls back everything already suspended in that same walk, in reverse order, before reporting the error. A driver that overrides ``suspend()`` must leave the device either fully suspended or exactly as it found it; there is no partially-suspended state this mechanism is prepared to resume from.
Checklist
------------
* Every ``query_facet()`` override ends with a call to its immediate base's ``query_facet()`` for anything it doesn't recognize — see the delegation rule in :doc:`/briefs/tb0003-facet-based-capability-dispatch`.
* ``probe()`` re-validates what ``match()`` already checked once, rather than trusting that a successful ``match()`` guarantees a successful ``probe()``.
* ``unbind()`` undoes exactly what ``probe()`` did — released resources, withdrawn facets your driver published explicitly (tree removal withdraws the rest for you), cleared driver data.
* A new facet type needs no changes anywhere in ``kapi::devices``; a new driver needs no changes anywhere except its own ``.cpp`` file, via ``kernel_driver<descriptor>``/``platform_driver<descriptor>``.
* Adding new source files means updating ``CMakeLists.txt`` — easy to forget, and nothing here catches it for you at review time.
|