Write a loadable kernel module in Rust
Create a nos kernel module in Rust with kmod and kcore, give it a shell command, build the .ko and load it into a running kernel.
By the end of this guide you will have a module of your own: a crate under src/rust/modules/, built into a .ko file, that registers a shell command. You will load it into a running nos with insmod and take it out again with rmmod, without rebuilding or rebooting the kernel.
You need:
- the nos tree and a working build, natively or in Docker (Build nos);
- a nos to load the module into, for example in QEMU (Run nos in QEMU);
- some Rust: modules are
#![no_std]crates.
What a module is
- Rust only. A module is a
staticlibcrate, linked into a position-independent ELF shared object: the.ko. - It reaches the kernel through one API. That API is the
extern "C"functions thefficrate declares, wrapped safely bykcore— the same API the drivers built into the kernel use. The loader binds a module against an export table generated from those declarations and refuses anything else. insmodruns the module's init;rmmoddrops the state it returned. The module'sDropis its exit: there is no exit function to write.- A module is for what a machine can run without. A server, a load generator or a diagnostic is a module:
sshd,netblk,netload,blkload,heapbenchand the hypervisor,hv, are all modules. - A module cannot be a disk's or a NIC's driver. Those register with their layer as Rust trait objects from inside the kernel image, and there is no C name for a module to bind to. A module uses disks and NICs; it does not provide them.
Read the smallest real one: hello
src/rust/modules/hello/ holds two files. Cargo.toml:
[package]
name = "mod-hello"
version = "0.1.0"
edition = "2021"
[lib]
name = "mod_hello"
crate-type = ["staticlib"]
[dependencies]
kmod = { path = "../../kmod" }
kcore = { path = "../../kcore" }
And src/lib.rs, with only its doc comment cut:
#![no_std]
extern crate alloc;
use alloc::boxed::Box;
use core::fmt::Write;
use core::sync::atomic::{AtomicU64, Ordering};
use kcore::cmd::Command;
static CALLS: AtomicU64 = AtomicU64::new(0);
struct Hello {
_cmd: Command,
}
impl kmod::Module for Hello {}
impl Drop for Hello {
fn drop(&mut self) {
kcore::trace!(0, "hello: unloaded after {} calls", CALLS.load(Ordering::Relaxed));
}
}
fn init() -> kcore::error::Result<Box<dyn kmod::Module>> {
let cmd = Command::register("hello", "hello [name] - a command from a loadable module", |args, out| {
let calls = CALLS.fetch_add(1, Ordering::Relaxed) + 1;
let who = if args.is_empty() { "world" } else { args };
let _ = writeln!(out, "hello, {} -- call {} since the module was loaded", who, calls);
})?;
kcore::trace!(0, "hello: loaded");
Ok(Box::new(Hello { _cmd: cmd }))
}
kmod::module!(name: "hello", init: init);
What each part does:
kmod::module!(name: "hello", init: init)names the module and says which functioninsmodruns. The name is whatlsmodshows andrmmodtakes, at most 31 printable characters; a longer one fails the build. The macro emitsnos_module_info, the header the loader looks for.initbuilds the module's state and returns it as aBox<dyn kmod::Module>. An error instead fails theinsmod, and the kernel log saysmodule init failed: ....Hello, the state, owns everything the module registered. Holding theCommandkeeps the command registered;rmmoddropsHello, and theCommand's drop unregisters it. Every unregister inkcorewaits out a callback still running on another CPU, so the code under the command is never freed while it runs. TheDropimpl here only adds a log line.impl kmod::Module for Hello {}requiresSend: the task that runsrmmoddrops what the task that raninsmodbuilt.Command::register(name, help, handler)puts a command in front of whoever runs the shell: the console, the UDP shell or an SSH session. The handler isFn(&str, &mut Output) + Send + Sync + 'static. It gets the rest of the command line and anOutputthat implementscore::fmt::Write. The kernel keeps 95 characters of the help text. A name that is already taken makesregisterfail, and the?turns that into a failedinsmod.static CALLSis the module's own. Each module carries its own copy ofkcore, so modules share no statics and cannot call each other.
kmod supplies the rest once for every module: the global allocator, which is the kernel heap; the panic handler, so a module that panics panics the kernel; and the header, with its magic, layout version, name, init, exit and the kernel interface digest.
Make your own
Copy hello and give the copy its own name. Here it is greet:
cp -r src/rust/modules/hello src/rust/modules/greet
In src/rust/modules/greet/Cargo.toml, rename the package to mod-<name> and the library to mod_<name>. The Makefile builds the package mod-greet and links libmod_greet.a:
[package]
-name = "mod-hello"
+name = "mod-greet"
version = "0.1.0"
edition = "2021"
[lib]
-name = "mod_hello"
+name = "mod_greet"
crate-type = ["staticlib"]
Add the crate to the workspace's members in src/rust/Cargo.toml (the line is abbreviated here). Leave default-members alone: it is what the kernel's own build compiles.
-members = [..., "modules/hv", "modules/heapbench"]
+members = [..., "modules/hv", "modules/heapbench", "modules/greet"]
Add the name to MODULES in the Makefile:
-MODULES = hello modtest slowexit blkload netblk netload sshd hv heapbench
+MODULES = hello modtest slowexit blkload netblk netload sshd hv heapbench greet
Finally, in src/rust/modules/greet/src/lib.rs, change the command's name and help, and the module's name in kmod::module!(name: "greet", init: init).
Reach the kernel through kcore
What a module can use is what kcore wraps. What kcore has is the full map. The parts a module reaches for most:
kcore:: |
What it gives a module |
|---|---|
cmd |
Command::register, and Output::read_input for a command that reads what an SSH user types |
trace |
trace!(level, ...), into the kernel log; level 0 is always visible |
sync |
locks that own what they guard: Mutex<T>, SpinLock<T>, IrqSpinLock<T> and others; Event, WaitGroup |
task |
tasks: spawn_with, spawn_on_with for a task that holds an Arc of the module's state; TaskHandle joins on drop; sleep_ms |
time, random, cpu |
the clock, the kernel's random pool, CPU ids and IPIs |
fs |
files: read, write, create, and File for a file held open |
net, tcp, block |
an existing NIC (UDP listeners, frames), TCP listeners and streams, an existing disk or partition |
timer, softirq |
a periodic timer; a soft IRQ handler, which has no unregister and makes the module permanent: rmmod refuses it |
When a command needs the module's state, keep the state in an Arc and move a clone into the handler. This is how the hypervisor module registers hv:
let for_cmd = state.clone();
let cmd = Command::register("hv", HELP, move |args, out| command(&for_cmd, args, out))?;
The rules a module lives by, from the project's conventions:
- Write it with no
unsafe. Reach for thekcoretype first. When the shape you need has none, add the type tokcore, once, with the reason it is sound.scripts/unsafe-count.py modules/greetcounts the sites. - Never allocate or free with a spin lock held. The page allocator shoots down every other CPU's TLB and waits for all of them to answer. A CPU spinning on your lock has interrupts off and can never answer, so the two wait for each other until the kernel panics, ten seconds later, naming the CPUs that never answered. Collect what needs freeing under the lock, unlock, then free.
- Mind what waits when it is dropped. A UDP listener, a
TaskHandleor an interrupt handle waits for its callbacks when dropped. Keep each one in its ownLock<Option<..>>:take()it under the lock and drop it after. Dropped under the lock its wait needs, it is a deadlock. - Never run
rmmodfrom the module's own command. The unload would wait for that command, which is waiting for the unload. - A module can call only what
ffideclares. A new kernel service takes three steps: the C++ function insrc/cpp/kernel/rust_ffi.cpp, its declaration insrc/rust/ffi, and the safe wrapper insrc/rust/kcore(Adding a kernel service). That changes the interface digest, so the kernel and every module are rebuilt together.
Build it
make modules
make modules ARCH=aarch64
These produce out/x86_64/modules/greet.ko and out/aarch64/modules/greet.ko. make and make nocheck build the modules too. On a Mac, build in the nos-builder container:
docker run --platform linux/amd64 --rm -v "$PWD:/src" -w /src nos-builder make modules
Each module is compiled on its own, with its own copy of core, alloc, kcore and kmod, into src/rust/target/modules, because it needs flags the kernel's Rust does not use:
- position-independent code, since nobody knows where it will be loaded;
- on x86, the small code model, where the kernel itself uses the large one;
- compiler-builtins' own
memcpyand friends, so the module imports nothing but the kernel's API.
ld.lld then links it as a shared object with -Bsymbolic, with every segment on pages of its own, and with a version script that exports nos_module_info and nothing else. A .nos_syms section with the module's demangled function names is added for backtraces. hello.ko comes out at around 25 KiB.
Build a module from the same tree as the kernel you load it into. The build hashes the
fficrate's sources into a digest that goes into every module and into the kernel, and the loader refuses a module whose digest is not the kernel's. A module built outside the Makefile carriesunsetand is refused too.
Get it onto the machine
-
On the root filesystem. Put the
.koin a directory and make a root image of it withscripts/mkrootfs.sh root.img 64 <dir>. Attach the image to QEMU as a virtio disk: the ISO'sroot=automounts a filesystem labellednoson/. The Linux guest guide has a complete QEMU command. -
Over HTTP from your machine. Serve the build directory and fetch the file from nos's shell. Under QEMU's user network your machine is 10.0.2.2:
python3 -m http.server 8000 --directory out/x86_64/moduleswget http://10.0.2.2:8000/greet.ko /greet.koWith no root disk,
/is a ramfs, and the file is gone at the next boot. -
From a release, for the modules in the tree. Every release carries each module as
<name>-x86_64.koand<name>-arm64.ko. Take it from the release the running kernel came from (versionnames it):wget https://github.com/irqlevel/nos/releases/download/<tag>/hello-x86_64.ko /hello.ko
On a server set up from the system image, /lib/modules belongs to the image and is replaced by every update, so keep your own modules elsewhere (Install nos on a server).
Load it, use it, unload it
A session with hello, as the project's documentation records it; your own module behaves the same way:
$ insmod /hello.ko
module: hello loaded at 0xFFFF8000...
$ hello nos
hello, nos -- call 1 since the module was loaded
$ lsmod
hello 24 KiB at 0xFFFF8000..., 7 kernel imports
$ rmmod hello
module: hello unloaded
- The module's log lines go to the kernel log, not to the shell: run
dmesg 5 helloto see them. - The five-second wait.
insmodandrmmodeach run in a task of their own and wait for it at most five seconds. An unload that takes longer, for example one waiting on a command of the module that is still running, carries on in the background.lsmodshows the module as unloading meanwhile, and the kernel log says how it ended. - Loading at boot. Add the line to
/etc/rcwithrc add insmod /greet.ko. The shell's task runs/etc/rconce the network is up. - Shutdown.
poweroffandrebootunload every module that can be unloaded, newest first, before the filesystems are unmounted.
What the loader checks
insmod hands the file to the loader, src/cpp/kernel/module.cpp, which works through these steps and refuses the module at the first one that fails:
- The file. It must be ELF64, little-endian, a shared object for this CPU, with its headers inside the file and no thread-local or interpreter segment. Every loadable segment must be page-aligned, inside the file and clear of the others, and never both writable and executable.
- The imports, before anything is mapped. Each undefined symbol is looked up in the kernel's export table. A module the kernel cannot satisfy is refused, with the list of everything it lacks.
- The image. It is mapped into pages of its own, 16 MiB at most with the function names.
- The relocations. Only three kinds are applied: nothing, load base plus addend, and symbol plus addend. Anything else is refused.
- The header, now that its pointers are real: the magic, the layout version, the name, an init and an exit that point into the module's own code, and the kernel interface digest.
- The page permissions. Each segment gets what its program header asks for: read-only, read-execute or read-write. Every CPU's TLB is shot down, and on arm64 the instruction caches are synchronised. The kernel stays W^X with a module in it.
- The init. A second module with the same name is refused before this step, and an init that fails leaves nothing behind.
What a refusal looks like:
| Message | What to do |
|---|---|
module: built against another kernel interface (ffi ..., this kernel ...) -- rebuild it from this tree |
Build the module from the kernel's tree, or take it from the kernel's release. |
module: needs <symbol>, which this kernel does not export |
The module calls something this kernel's ffi does not declare. Rebuild the kernel and the module together. |
module: segment N is both writable and executable |
Build the .ko with make modules, whose link gives every segment pages and permissions of its own. |
module: no nos_module_info -- not a module built with kmod::module! |
The crate never invokes kmod::module!. |
module: greet is already loaded |
rmmod greet first. |
module: greet: init failed |
init returned an error. dmesg shows module init failed: ... with the reason. |
module: greet is permanent: it imports ..., which nothing undoes |
The module registered a soft IRQ handler; it stays until the next boot. |
A frame in a module's code is named in backtraces like the kernel's own, with the module's name after it, for example mod_hello::init::{closure#0}+0x3c [hello]. A panic report ends with a Modules: line that says where each module sits.
Test it
- Every boot tests the loader. The boot self-test loads
modtest, a module embedded in the kernel image, on both architectures, then feeds the loader damaged copies of it: truncated, another machine's, an import the kernel lacks, no header, a bad magic, another kernel interface. The smoke boots therefore cover the loader (Run the gates). - After touching the loader or the
ffideclarations, runscripts/sshd-test.py. - Under the undefined-behaviour checks, build with
make nocheck RUSTUB=1. It rebuilds the modules withcore's checks of its unsafe preconditions and overflow checks on, so the first violation is a panic that names its site. - Your own module you test by hand. If it can fail in a way that looks like success, give it a gate of its own, as
netloadhasscripts/netload-test.py.
Troubleshooting
make modules does not build your module. Its name is missing from MODULES in the Makefile.
Cargo says no package matches mod-greet. The crate is missing from the workspace's members, or its package is not named mod-<name>.
insmod says init failed. Check dmesg for the module init failed line. A common cause is the command's name: register fails when a built-in command or another module's command already has it.
rmmod returns but lsmod still shows the module as unloading. A call of one of its commands is still running, and the unload waits for it. A command that never returns leaves its module unloading for good.
The kernel panicked in your code. A module that panics panics the kernel. The backtrace names your frames with [greet].
Next steps
- Loadable kernel modules: the reference, with the larger modules
blkload,netload,netblk,sshd,heapbenchandhvdescribed. - Rust in the kernel: the layers, where
unsafegoes, and whatkcorehas. - The real modules' source:
src/rust/modules. - Boot a Linux guest: the biggest module there is, the hypervisor.