This is your capstone for the course. Unlike the previous four homeworks, there is no template repository, no autograder, and no fill-in-the-blanks. You will set up a virtual machine, build an out-of-tree Linux kernel module in Rust from scratch, and publish it as a public GitHub repository — the kind of repository you can point a hiring manager at on day one of an internship search.
The work is closely modeled on what we built together in LN24's rustecho demo, except this time the module is yours: your name on the repo, your README, your design choices, your commits. Treat it as a portfolio piece, not a problem set.
The amount of new material for this homework is small. The lectures in LN23 and LN24 are the textbook. The links below are the references you'll want open in browser tabs while you work.
Re-read LN24.
Specifically the Building rustecho in Rust and C vs. Rust subsections, plus the addendum's Rust toolchain setup. The starting skeleton in this homework is the same shape as the lecture's rustecho — you'll re-type its module!, kernel::InPlaceModule, MiscDevice, and MiscDeviceRegistration patterns from memory and reference, then adapt them to your own module idea. Pay particular attention to the global_lock! idiom used to share state across open() calls — every path in this homework uses it.
Skim the Rust for Linux quick start.
The "Required toolchain" and "Building" sections are the most relevant. Don't try to build a full custom kernel — your VM's Ubuntu kernel already has Rust support if you're on a recent enough release. The quick-start tells you which rustc and bindgen versions you need.
Browse the in-tree samples/rust/ directory.
These are the canonical "hello world" Rust kernel modules — the same crate, the same APIs, the same structure you'll use. rust_misc_device.rs and rust_minimal.rs are particularly worth opening; you'll recognize patterns from rustecho. When something in your own module breaks, come back here and compare — the in-tree samples are the source of truth for whatever Rust-for-Linux version your kernel ships with.
Build, document, and publish your own Linux kernel module in Rust. Choose one of three module ideas below and ship it as a public GitHub repository you'd be happy to share.
You'll produce one public GitHub repository containing a working out-of-tree Linux kernel module written in Rust. The module will be a small character device — something that appears in /dev, accepts read() and write() syscalls from user space, and does something interesting on the kernel side.
The point is not the module's complexity. The point is the whole experience: standing up the toolchain, writing safe Rust that runs at Ring 0, testing it, hitting bugs, debugging through dmesg, and shipping a repository whose README does the talking when you're not in the room.
Three distinct modules are sketched below in Phase 2 — Pick Your Path. Read all three, then pick the one that excites you most. They're roughly equal in difficulty; they differ in which kernel idiom they showcase and which talking point they give you for a portfolio.
🧭 A note about pacing. Kernel modules are not user-space Rust. The code you write for this homework is shorter than HW3 or HW4. The workflow — toolchain setup, VM management, kernel debugging — is harder. Plan extra time, especially for Phase 1. Don't start the night before.
Phase 1 is shared by all three paths. Skip ahead to Phase 2 only after every step here works.
Kernel bugs are machine-level failures: a panic, a deadlock in interrupt context, or a corrupted kernel data structure means the entire system needs to restart. Do not develop kernel modules on your host machine. Use a Multipass VM the way LN23 and LN24 set up.
If you still have your ln23 (or ln24) VM, reuse it. Otherwise, create a fresh one:
multipass launch --name hw5 lts
multipass shell hw5
Everything from here on runs inside the VM. Any time you see a shell command in this homework, assume that's where it belongs. If your VM ever becomes unresponsive (most likely after a kernel panic), nuke it and recreate from scratch:
multipass stop hw5
multipass delete hw5 && multipass purge
multipass launch --name hw5 lts
🛟 Disposable means disposable. A VM you nuke twice a week is a sign you're learning. Your repository (which lives on GitHub, not in the VM) is the artifact you keep — not the VM itself.
You need four things: kernel headers (the C-side build infrastructure), the kernel-blessed Rust compiler (rustc-1.93 for Ubuntu 26.04 LTS), the matching Rust standard library source plus bindgen (for compiling core/alloc and generating Rust bindings to the kernel's C headers), and the standard kmod utilities (for insmod/rmmod/lsmod):
sudo apt update
sudo apt install -y build-essential linux-headers-$(uname -r) kmod tree
sudo apt install -y rustc-1.93 rust-1.93-src bindgen
sudo update-alternatives --install /usr/bin/rustc rustc /usr/bin/rustc-1.93 100
Verify everything is in place:
uname -r # which kernel you're running
rustc --version # should report 1.93.x
ls /lib/modules/$(uname -r)/build/rust # Rust support files for this kernel exist
⚠️ Rust-enabled kernel required. Building a Rust kernel module needs a kernel built with
CONFIG_RUST=y. Ubuntu 26.04 LTS already does this. If you are on an older series, the simplest fix is to recreate your VM withmultipass launch --name hw5 lts(which today maps to 26.04). The exactrustcversion matters — using a different one will fail with "the compiler differs from the one used to build the kernel" errors.
Your project lives in a single directory. Pick a name that matches the module you'll build (rustqueue, rustcounter, or rustguess):
mkdir -p ~/PROJECT && cd ~/PROJECT
The project needs only two files: a Makefile that hooks into the kernel's build system, and your .rs source file. Unlike user-space Rust, kernel modules don't use Cargo. The kernel's Kbuild system invokes rustc directly, with all the special flags Rust-in-the-kernel requires. There's no Cargo.toml.
Save this Makefile exactly:
obj-m += PROJECT.o
KDIR := /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
Replace PROJECT with your module's name (so obj-m += rustqueue.o if you're building rustqueue). Whatever name you pick, your source file must be named <that-name>.rs (so rustqueue.rs next to the Makefile). Kbuild matches them by filename.
📌 Why no Cargo? Kernel code runs in a
no_stdenvironment with its own allocator, its own panic handler, and a customkernelcrate provided by the kernel build itself. The standard Cargo toolchain assumes none of those. The kernel's Kbuild callsrustcdirectly with all the right flags. Out-of-tree modules inherit those flags viamake -C $(KDIR) M=$(PWD). This is unusual coming from user-space Rust, but it's the official build path.
Before you build anything interesting, build a do-nothing module that loads, prints a message to the kernel log, and unloads. This is your "the toolchain works" smoke test, exactly like the C skeleton in LN24's addendum but in Rust.
Create your <PROJECT>.rs with this skeleton, replacing the PROJECT placeholder with your project's actual name:
// SPDX-License-Identifier: GPL-2.0
//! PROJECT: minimal Rust kernel module smoke test.
use kernel::prelude::*;
module! {
type: HelloModule,
name: "PROJECT",
authors: ["Your Name"],
description: "PROJECT — short tagline for the module",
license: "GPL",
}
struct HelloModule;
impl kernel::Module for HelloModule {
fn init(_module: &'static ThisModule) -> Result<Self> {
pr_info!("module loaded\n");
Ok(HelloModule)
}
}
impl Drop for HelloModule {
fn drop(&mut self) {
pr_info!("module unloaded\n");
}
}
📌 About the
module!fields. The Ubuntu 26.04 kernel'smodule!macro accepts five fields, and they must appear in this exact order:type,name,authors,description,license. Reordering them produces anerror: keys are not ordered as expectedfrom the macro itself.authorsanddescriptionare both optional, but includingdescriptionsilences the otherwise-noisyWARNING: modpost: missing MODULE_DESCRIPTION()you'll see at link time, and includingauthorslets you record yourself in the resulting.kofile's metadata (modinfo PROJECT.kowill show it). The crate-level//!line silences Rust's ownmissing documentation for the cratewarning. If you've seen older Rust-for-Linux tutorials useauthor:(singular), note that the upstream API since merge isauthors:(plural, an array of strings).
📌 About
pr_info!. The kernel log automatically prefixes Rust module messages with the module name. If your module is namedPROJECT,pr_info!("module loaded\n")appears indmesgasPROJECT: module loaded. Do not includePROJECT:inside the string unless you wantPROJECT: PROJECT: ....
Build it:
make
If the build succeeds, you'll have a <PROJECT>.ko file next to your source. Load it:
sudo insmod <PROJECT>.ko
sudo dmesg | tail -3
You should see <PROJECT>: module loaded in the kernel log. Verify it appears in the loaded module list:
lsmod | grep <PROJECT>
Unload it:
sudo rmmod <PROJECT>
sudo dmesg | tail -3
You should now see <PROJECT>: module unloaded.
🔐 Why
sudo dmesg? Modern Ubuntu setskernel.dmesg_restrict=1, which gates the kernel ring buffer behind root.dmesgwithoutsudowill fail silently (or withOperation not permitted) for an unprivileged user. Everydmesg,insmod, andrmmodinvocation in this homework therefore wantssudo. You can also flipsudo sysctl kernel.dmesg_restrict=0if you really want to drop the prefix everywhere, but per-commandsudois the cleaner habit.
🩺 Sanity check. If those four commands all worked, your toolchain is healthy and the rest of this homework is purely Rust. Common failure modes:
makefails with "No rule to make target" → the Makefile'sobj-mline doesn't match your.rsfilename.insmodfails withOperation not permitted→ you may need to disable Secure Boot in your VM, or the kernel doesn't haveCONFIG_RUST=y(see the LN24 addendum for the custom-kernel path).dmesgshows nothing new → first make sure you're running it withsudo(see above). If you still see nothing, check that yourpr_info!strings have a trailing\n— the kernel's log buffer is line-buffered, so a missing newline is a common rookie mistake.
You're now ready to build something actually interesting.
You'll choose one of three modules to build. Each one is a small character device with read and write handlers. Each one tells a different story in your README:
| Path | Module | Talks well as... |
|---|---|---|
| A | rustqueue | "I implemented a kernel-level FIFO message queue in Rust" |
| B | rustcounter | "I demonstrated Rust's compiler-enforced data-race prevention with a kernel module" |
| C | rustguess | "I built a stateful number-guessing game that lives inside the Linux kernel" |
All three are roughly equal in code volume (~80–150 lines of Rust beyond the skeleton). They differ in which kernel idiom they showcase. Pick the one whose talking point appeals to you most. If none excite you, see Bringing your own idea at the end of Phase 2.
Each path below is self-contained — you only need to read the one you pick.
rustqueue: A FIFO Message Queuerustqueue is a character device that behaves like a bounded FIFO message queue. Each write() syscall enqueues a single message — the bytes you wrote in that one syscall. Each read() dequeues the front message and returns its bytes. An empty queue returns 0 bytes (which cat interprets as EOF). A full queue rejects further writes with -ENOSPC.
Conceptually it's a tiny in-kernel reimplementation of a System V message queue or a Unix pipe — a real IPC primitive at small scale. Two user-space processes can use it to pass messages without sharing memory.
The queue lives in a kernel-wide global_lock! static so that all opens of the device — every echo and every cat — see the same FIFO:
const MAX_MESSAGES: usize = 16;
const MAX_MSG_SIZE: usize = 4096;
kernel::sync::global_lock! {
// SAFETY: Initialized in module initializer before first use.
unsafe(uninit) static QUEUE: Mutex<KVec<KVec<u8>>> = KVec::new();
}
KVec<KVec<u8>> is the kernel-side equivalent of Vec<Vec<u8>> — the outer vector is the FIFO, each inner KVec<u8> is the bytes of one message. (We use a plain KVec plus pop_front()-by-remove(0) rather than VecDeque because the kernel alloc crate's VecDeque is not exported by the Ubuntu 26.04 kernel; for MAX_MESSAGES = 16 the difference is irrelevant.) global_lock! { static QUEUE: Mutex<...> } means: to touch the queue, you must call QUEUE.lock(); the lock is automatically released when its guard goes out of scope. You cannot accidentally touch the queue without locking it — the macro only exposes the data behind the guard.
The module itself wraps the device in a MiscDeviceRegistration and initializes the global lock at load, mirroring the rustecho pattern from LN24:
// SPDX-License-Identifier: GPL-2.0
//! rustqueue: a bounded FIFO message queue character device.
use kernel::{
fs::{File, Kiocb},
iov::{IovIterDest, IovIterSource},
miscdevice::{MiscDevice, MiscDeviceOptions, MiscDeviceRegistration},
new_mutex,
prelude::*,
sync::Mutex,
};
module! {
type: RustQueue,
name: "rustqueue",
description: "rustqueue — a bounded FIFO message queue",
license: "GPL",
}
const MAX_MESSAGES: usize = 16;
const MAX_MSG_SIZE: usize = 4096;
kernel::sync::global_lock! {
unsafe(uninit) static QUEUE: Mutex<KVec<KVec<u8>>> = KVec::new();
}
#[pin_data]
struct RustQueue {
#[pin]
_miscdev: MiscDeviceRegistration<RustQueueDevice>,
}
impl kernel::InPlaceModule for RustQueue {
fn init(_module: &'static ThisModule) -> impl PinInit<Self, Error> {
pr_info!("module loaded (capacity {} messages)\n", MAX_MESSAGES);
// SAFETY: Called exactly once during module init.
unsafe { QUEUE.init() };
let opts = MiscDeviceOptions { name: c"rustqueue" };
try_pin_init!(Self {
_miscdev <- MiscDeviceRegistration::register(opts),
})
}
}
#[pin_data]
struct RustQueueDevice {
// Per-open: the message we dequeued for this `cat` invocation, if any.
#[pin]
pending: Mutex<Option<KVec<u8>>>,
}
Nothing here needs explanation that LN24 didn't already cover. The interesting work happens in MiscDevice.
open() — fresh per-open stateEach open() creates a fresh RustQueueDevice whose pending slot starts empty. The first read() on this open will lazily pull one message off the global queue.
#[vtable]
impl MiscDevice for RustQueueDevice {
type Ptr = Pin<KBox<Self>>;
fn open(_file: &File, _misc: &MiscDeviceRegistration<Self>) -> Result<Pin<KBox<Self>>> {
KBox::try_pin_init(
try_pin_init! {
RustQueueDevice {
pending <- new_mutex!(None),
}
},
GFP_KERNEL,
)
}
// write_iter() and read_iter() below
}
write_iter() — enqueue one messagefn write_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize> {
let mut q = QUEUE.lock();
if q.len() >= MAX_MESSAGES {
pr_info!("queue full, rejecting write\n");
return Err(ENOSPC);
}
let mut msg: KVec<u8> = KVec::new();
let len = iov.copy_from_iter_vec(&mut msg, GFP_KERNEL)?;
if len > MAX_MSG_SIZE {
return Err(EINVAL);
}
q.push(msg, GFP_KERNEL)?;
*kiocb.ki_pos_mut() = 0;
pr_info!("enqueued {} bytes ({} in queue)\n", len, q.len());
Ok(len)
}
Walk through it:
QUEUE.lock() acquires the global mutex. The returned guard is a smart pointer that auto-releases when it goes out of scope. You cannot forget to unlock.ENOSPC (no space left on device). This is the standard error code for a full buffer.KVec::new() + iov.copy_from_iter_vec(&mut msg, GFP_KERNEL) allocates a fresh message buffer and copies the user's bytes into it. copy_from_iter_vec is the safe wrapper around the C copy_from_user we discussed in LN24 — the bounds and partial-read accounting are handled inside the iov machinery. GFP_KERNEL tells the allocator that this is a sleepable allocation context, which is true for a syscall handler.MAX_MSG_SIZE check rejects oversized messages with EINVAL (invalid argument). This is your guard against denial-of-service: a user can't make the kernel allocate arbitrary memory.q.push(msg, GFP_KERNEL)? moves ownership of the message buffer into the queue. After this line, the local msg variable can no longer be used — Rust's ownership model has transferred it. The fallible push (?) propagates an allocation failure rather than panicking the kernel.*kiocb.ki_pos_mut() = 0 resets the per-open file position so subsequent writes through the same descriptor start fresh.q is dropped at end of scope, automatically releasing the mutex.read_iter() — lazily dequeue, then stream the messagefn read_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize> {
let me = kiocb.file();
let mut pending = me.pending.lock();
// First read of this open: pull one message off the queue and remember it.
if pending.is_none() && *kiocb.ki_pos_mut() == 0 {
let mut q = QUEUE.lock();
if !q.is_empty() {
*pending = Some(q.remove(0)?);
pr_info!("dequeued ({} remaining)\n", q.len());
}
}
match pending.as_ref() {
None => Ok(0),
Some(msg) => iov.simple_read_from_buffer(kiocb.ki_pos_mut(), msg),
}
}
Walk through it:
kiocb.file() gives you a &Self for the per-open RustQueueDevice — the same pending slot lives across multiple read() calls on the same file descriptor.cat typically calls read() repeatedly until it returns 0. The very first call enters with ki_pos == 0 and pending == None. We take that opportunity to dequeue exactly one message off the global queue and stash it in pending. Subsequent reads on the same descriptor see pending == Some(msg) and just stream more bytes out of it.iov.simple_read_from_buffer(...) is the safe wrapper around copy_to_user. It advances kiocb.ki_pos_mut() for us, so multi-call reads work correctly. Once ki_pos reaches msg.len(), this returns 0 and cat sees EOF.pending is still None after the dequeue attempt, the queue was empty. We return Ok(0), which the VFS layer reports as EOF.📝 Trade-off note. This design returns exactly one message per
catinvocation. List "support reading multiple messages in one open" or "letread()return part of a message and leave the tail in the per-openpending" in your README's future-work section.
/dev/rustqueue is created with mode 0600 root:root (the default for MiscDevice nodes), so every read or write needs sudo. The sudo tee ... > /dev/null idiom is required because plain sudo echo "..." > /dev/rustqueue fails — the redirect is evaluated by your shell before sudo runs.
make clean && make
sudo insmod rustqueue.ko
ls -la /dev/rustqueue
echo "first message" | sudo tee /dev/rustqueue > /dev/null
echo "second message" | sudo tee /dev/rustqueue > /dev/null
echo "third message" | sudo tee /dev/rustqueue > /dev/null
sudo cat /dev/rustqueue # → "first message"
sudo cat /dev/rustqueue # → "second message"
sudo cat /dev/rustqueue # → "third message"
sudo cat /dev/rustqueue # → (empty, EOF — no output)
sudo dmesg | tail -10
sudo rmmod rustqueue
You should see:
/dev after loading.rustqueue: enqueued ... bytes (N in queue) lines in dmesg.cat invocation returns one message in FIFO order.cat returns nothing because the queue is empty.Try the full-queue path too:
sudo insmod rustqueue.ko
for i in {1..20}; do echo "message $i" | sudo tee /dev/rustqueue > /dev/null || echo "write $i FAILED"; done
sudo dmesg | tail -20
sudo rmmod rustqueue
After 16 successful enqueues, the next four should see rustqueue: queue full, rejecting write in dmesg, tee will report a No space left on device error, and the loop will print write 17 FAILED through write 20 FAILED.
open() handler to give each process its own queue, or each open file descriptor its own session.MAX_MESSAGES as a module parameter (module_param!) so users can tune it without recompiling.poll() so user-space select/epoll can wait for queue activity efficiently.
rustqueueis a Linux kernel module that exposes a bounded FIFO message queue at/dev/rustqueue, written in safe Rust. It demonstrates how Rust's ownership and locking discipline make a small in-kernel IPC primitive easy to write and structurally free of buffer-handling bugs.
rustcounter: An Atomic Counterrustcounter is a character device whose only state is a single u64. Each write() increments it (the contents of what you wrote don't matter; only the act of writing). Each read() returns the current value as ASCII.
The interesting twist: the counter is an atomic, not a mutex-protected integer. Two processes hammering the device with parallel writes produce an exact total, with no lock contention.
count++ and no lock would lose increments — and Rust forbids that pattern at compile time."use core::sync::atomic::{AtomicBool, AtomicU64, Ordering};
static COUNT: AtomicU64 = AtomicU64::new(0);
static CONSUMED: AtomicBool = AtomicBool::new(false);
Two atomics in static storage, no mutex.
COUNT is the actual counter. AtomicU64 provides hardware-supported atomic read-modify-write — on x86 this is LOCK XADD, on ARM64 it's ldaddal. One CPU instruction, no scheduler involvement.CONSUMED is a small trick to make cat work nicely. The first read() after a write returns the value and marks the buffer "consumed." Subsequent reads return 0 (EOF) until the next write resets the flag. Without this, cat would see the same bytes forever and loop endlessly.Because core::sync::atomic types are Sync and have const fn new, you can put them straight into static slots — there is no need for global_lock! or any pin-init dance. This is the simplest of the three paths.
Mutex and no global_lock!?A global_lock! { static COUNT: Mutex<u64> = 0; } would also be correct, but it would be wasteful. The only operation we need on the counter is "fetch current value and add 1." The CPU has a single instruction for this; you don't need a kernel-level mutex (which adds two memory fences plus potentially a scheduler interaction on contention) to do it. This is the moment in your Rust learning where the choice between a Mutex and an Atomic becomes load-bearing. Pick the cheapest synchronization that's correct.
// SPDX-License-Identifier: GPL-2.0
//! rustcounter: an atomic counter character device.
use core::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use kernel::{
fs::{File, Kiocb},
iov::{IovIterDest, IovIterSource},
miscdevice::{MiscDevice, MiscDeviceOptions, MiscDeviceRegistration},
prelude::*,
str::CString,
};
module! {
type: RustCounter,
name: "rustcounter",
description: "rustcounter — an atomic counter character device",
license: "GPL",
}
static COUNT: AtomicU64 = AtomicU64::new(0);
static CONSUMED: AtomicBool = AtomicBool::new(false);
#[pin_data]
struct RustCounter {
#[pin]
_miscdev: MiscDeviceRegistration<RustCounterDevice>,
}
impl kernel::InPlaceModule for RustCounter {
fn init(_module: &'static ThisModule) -> impl PinInit<Self, Error> {
pr_info!("module loaded\n");
let opts = MiscDeviceOptions { name: c"rustcounter" };
try_pin_init!(Self {
_miscdev <- MiscDeviceRegistration::register(opts),
})
}
}
struct RustCounterDevice;
write_iter() — increment#[vtable]
impl MiscDevice for RustCounterDevice {
type Ptr = Pin<KBox<Self>>;
fn open(_file: &File, _misc: &MiscDeviceRegistration<Self>) -> Result<Pin<KBox<Self>>> {
Ok(KBox::new(RustCounterDevice, GFP_KERNEL).map(KBox::into_pin)?)
}
fn write_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize> {
// We don't care WHAT was written — every write counts as one increment.
// Drain the user's bytes so the syscall completes cleanly.
let mut sink: KVec<u8> = KVec::new();
let n = iov.copy_from_iter_vec(&mut sink, GFP_KERNEL)?;
let new_count = COUNT.fetch_add(1, Ordering::SeqCst) + 1;
CONSUMED.store(false, Ordering::SeqCst);
*kiocb.ki_pos_mut() = 0;
pr_info!("incremented to {new_count}\n");
Ok(n)
}
// read_iter() below
}
The interesting line is COUNT.fetch_add(1, Ordering::SeqCst) + 1:
fetch_add returns the previous value and atomically adds 1. We add 1 ourselves to log the new value.Ordering::SeqCst ("sequentially consistent") is the strongest memory ordering. For a simple counter you could probably use Relaxed, but SeqCst is the safe default — pick it unless you can articulate why a weaker ordering is correct.Drainage of the input is needed because the syscall's contract says the kernel consumed n bytes — even though we ignore them. copy_from_iter_vec allocates and copies into a KVec that goes out of scope (and is freed) at end of function.
read_iter() — return current value as ASCIIfn read_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize> {
if CONSUMED.swap(true, Ordering::SeqCst) {
return Ok(0);
}
let n = COUNT.load(Ordering::SeqCst);
let formatted = CString::try_from_fmt(fmt!("{n}\n"))?;
let bytes = formatted.to_bytes();
let written = iov.simple_read_from_buffer(kiocb.ki_pos_mut(), bytes)?;
Ok(written)
}
Walk through it:
CONSUMED.swap(true, ...) atomically sets the flag to true and returns the previous value. If it was already true (we've already returned the value since the last write), return Ok(0) to signal EOF.COUNT.load(...) reads the current count atomically.CString::try_from_fmt(fmt!("{n}\n")) is the kernel-safe equivalent of format! — it formats into a kernel-allocated C-string. The ? propagates any allocation error.iov.simple_read_from_buffer(...) copies the formatted bytes back to user space, advancing kiocb.ki_pos_mut() so multi-call reads complete the message cleanly.Same as rustqueue: /dev/rustcounter is 0600 root:root, so every interaction needs sudo.
make clean && make
sudo insmod rustcounter.ko
ls -la /dev/rustcounter
sudo cat /dev/rustcounter # → "0"
echo bump | sudo tee /dev/rustcounter > /dev/null
sudo cat /dev/rustcounter # → "1"
echo bump | sudo tee /dev/rustcounter > /dev/null
echo bump | sudo tee /dev/rustcounter > /dev/null
sudo cat /dev/rustcounter # → "3"
sudo rmmod rustcounter
This is the demo you'll record for your README. Open two multipass shell hw5 terminals.
In terminal 1:
sudo insmod rustcounter.ko
ls -la /dev/rustcounter
sudo cat /dev/rustcounter # → "0"
for i in {1..1000}; do echo bump | sudo tee /dev/rustcounter > /dev/null; done
While terminal 1 is running its loop, in terminal 2:
for i in {1..1000}; do echo bump | sudo tee /dev/rustcounter > /dev/null; done
After both finish:
sudo cat /dev/rustcounter # → "2000"
sudo rmmod rustcounter
The count is exactly 2,000, every time, without locks, without contention, without lost increments. That number is the proof.
🤯 Why this is impressive in the README. Equivalent C code with
count++and no lock would lose increments — sometimes a few, sometimes a few hundred, depending on contention. Rust's type system would refuse to compile that code, requiring you to useAtomicU64::fetch_add(correct) or wrap the counter in aMutex(also correct, but slower). The compiler made the bug uncodeable. Capture this stress test as an asciinema recording or a paragraph in your README — it is your single best portfolio-talking-point screenshot.
/proc/rustcounter entry that reads the count without consuming. Lets a sysadmin observe the counter without affecting subsequent cat behavior. (Course callback: LN23's /proc walkthrough.)RESET\n, set the count to 0 instead of incrementing. Demonstrates parsing structured input.HashMap<pid, u64> keyed by the writing process's PID. Each PID sees its own count.u64::MAX instead of overflowing.
rustcounteris a Linux kernel module that demonstrates Rust's compiler-enforced data-race prevention. Concurrent writes through/dev/rustcounterare aggregated by anAtomicU64— thousands of parallel writes always produce the exact total, with no locks and no scheduler involvement. The same code written in C without explicit synchronization would silently lose increments; Rust would not let it compile.
rustguess: A Guessing Game in the Kernelrustguess is a number-guessing game that lives at /dev/rustguess. The module picks a secret at load time. Users echo numbers into the device; reading the device returns hints (higher, lower, or correct!). When you guess right, the module remembers — further guesses get a "you already won, reload to play again" message.
It is the most playful of the three paths and the most demoable. It also exercises the most kernel idioms in one place: stateful protocol parsing, a modal device, and (optionally) integration with the kernel RNG you traced in LN23.
init sets up state, write parses input and updates state, read returns state, Drop cleans up.file_operations dispatch table), LN23 (urandom_read_iter, callable for kernel RNG), LN24 (the structural impossibility of misusing the state machine).const SECRET: u64 = 42; // hardcoded for simplicity
const MAX_GUESS: u64 = 100;
struct GameState {
last_message: KVec<u8>,
consumed: bool,
tries: u64,
won: bool,
}
kernel::sync::global_lock! {
unsafe(uninit) static GAME: Mutex<GameState> = GameState {
last_message: KVec::new(),
consumed: true,
tries: 0,
won: false,
};
}
The last_message is the bytes the next read() will return. The consumed flag is the same EOF trick as in rustcounter. The tries and won fields make the game's "you got it in N tries" message possible. The whole game lives behind one global_lock! so every open of the device sees the same in-progress game.
// SPDX-License-Identifier: GPL-2.0
//! rustguess: a number-guessing game character device.
use kernel::{
fs::{File, Kiocb},
iov::{IovIterDest, IovIterSource},
miscdevice::{MiscDevice, MiscDeviceOptions, MiscDeviceRegistration},
new_mutex,
prelude::*,
str::CString,
sync::Mutex,
};
module! {
type: RustGuess,
name: "rustguess",
description: "rustguess — a number-guessing game character device",
license: "GPL",
}
const SECRET: u64 = 42;
const MAX_GUESS: u64 = 100;
struct GameState {
last_message: KVec<u8>,
consumed: bool,
tries: u64,
won: bool,
}
kernel::sync::global_lock! {
unsafe(uninit) static GAME: Mutex<GameState> = GameState {
last_message: KVec::new(),
consumed: true,
tries: 0,
won: false,
};
}
fn build_message(bytes: &[u8]) -> Result<KVec<u8>> {
let mut v: KVec<u8> = KVec::new();
v.extend_from_slice(bytes, GFP_KERNEL)?;
Ok(v)
}
#[pin_data]
struct RustGuess {
#[pin]
_miscdev: MiscDeviceRegistration<RustGuessDevice>,
}
impl kernel::InPlaceModule for RustGuess {
fn init(_module: &'static ThisModule) -> impl PinInit<Self, Error> {
pr_info!("module loaded (secret picked, get guessing!)\n");
// SAFETY: Called exactly once during module init.
unsafe { GAME.init() };
// Seed the welcome message into the global state.
if let Ok(welcome) = build_message(
b"Welcome! Guess a number between 1 and 100. \
`echo N > /dev/rustguess`, then `cat /dev/rustguess`.\n",
) {
let mut g = GAME.lock();
g.last_message = welcome;
g.consumed = false;
}
let opts = MiscDeviceOptions { name: c"rustguess" };
try_pin_init!(Self {
_miscdev <- MiscDeviceRegistration::register(opts),
})
}
}
The build_message helper centralizes the fallible "byte literal → owned KVec" allocation pattern. Every message produced by the game goes through it, so allocation failures propagate via ? rather than panicking.
open()Like rustqueue, each open() of /dev/rustguess gets a fresh RustGuessDevice whose only per-open state is a served flag — used to avoid cat looping on a fully-streamed message:
#[pin_data]
struct RustGuessDevice {
#[pin]
served: Mutex<bool>,
}
#[vtable]
impl MiscDevice for RustGuessDevice {
type Ptr = Pin<KBox<Self>>;
fn open(_file: &File, _misc: &MiscDeviceRegistration<Self>) -> Result<Pin<KBox<Self>>> {
KBox::try_pin_init(
try_pin_init! { RustGuessDevice { served <- new_mutex!(false) } },
GFP_KERNEL,
)
}
// write_iter() and read_iter() below
}
write_iter() — parse a guess, update game statefn write_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize> {
let mut buf: KVec<u8> = KVec::new();
let len = iov.copy_from_iter_vec(&mut buf, GFP_KERNEL)?;
let s = core::str::from_utf8(&buf).unwrap_or("");
let guess: Option<u64> = s.trim().parse().ok();
let mut state = GAME.lock();
if state.won {
state.last_message = build_message(
b"You already won! `rmmod rustguess && insmod rustguess.ko` to play again.\n",
)?;
state.consumed = false;
*kiocb.ki_pos_mut() = 0;
return Ok(len);
}
let response_bytes = match guess {
None => build_message(b"Couldn't parse your input as a number. Try again.\n")?,
Some(g) if g == 0 || g > MAX_GUESS => {
build_message(b"Out of range -- pick a number between 1 and 100.\n")?
}
Some(g) => {
state.tries += 1;
let formatted = if g < SECRET {
CString::try_from_fmt(fmt!("{g} is too low -- guess higher.\n"))?
} else if g > SECRET {
CString::try_from_fmt(fmt!("{g} is too high -- guess lower.\n"))?
} else {
state.won = true;
CString::try_from_fmt(fmt!("Correct! You got it in {} tries.\n", state.tries))?
};
build_message(formatted.to_bytes())?
}
};
state.last_message = response_bytes;
state.consumed = false;
*kiocb.ki_pos_mut() = 0;
pr_info!("tries={}, won={}\n", state.tries, state.won);
Ok(len)
}
Walk through it:
iov.copy_from_iter_vec(...) drains the user's bytes into a fresh KVec buffer.core::str::from_utf8(&buf).unwrap_or("") parses the bytes as UTF-8 and falls back to an empty string on failure (so non-UTF-8 input becomes "couldn't parse")..trim().parse().ok() trims whitespace (so echo 50 > /dev/rustguess works — echo adds a trailing newline) and tries to parse as u64. .ok() converts the Result into an Option.GAME.lock() acquires the global game mutex. After this point, only this code path can read or write game state until the guard goes out of scope.tries, compare to SECRET, and pick a response. The winning response sets state.won = true.consumed so the next read() returns it.The match is exhaustive — Rust's type system requires you to handle every Option variant. There is no path where you forget the "parse failed" case and dereference an uninitialized guess.
read_iter() — return the latest messagefn read_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize> {
let me = kiocb.file();
let mut served = me.served.lock();
let state = GAME.lock();
if state.consumed && *served {
return Ok(0);
}
let n = iov.simple_read_from_buffer(kiocb.ki_pos_mut(), &state.last_message)?;
if n == 0 {
*served = true;
}
Ok(n)
}
Walk through it:
me.served is the per-open "have I shown the latest message yet?" flag.GAME.lock() acquires the global game state.cat would loop forever on the same message.iov.simple_read_from_buffer(...) copies the message bytes back to user space and advances ki_pos. When ki_pos reaches last_message.len(), this returns 0, we mark this open as served, and cat exits cleanly.Same 0600 root:root story as the other two paths — every interaction needs sudo.
make clean && make
sudo insmod rustguess.ko
ls -la /dev/rustguess
sudo cat /dev/rustguess # welcome message
echo 50 | sudo tee /dev/rustguess > /dev/null
sudo cat /dev/rustguess # "50 is too high -- guess lower."
echo 25 | sudo tee /dev/rustguess > /dev/null
sudo cat /dev/rustguess # "25 is too low -- guess higher."
echo 42 | sudo tee /dev/rustguess > /dev/null
sudo cat /dev/rustguess # "Correct! You got it in 3 tries."
echo 50 | sudo tee /dev/rustguess > /dev/null
sudo cat /dev/rustguess # "You already won!..."
sudo rmmod rustguess
sudo insmod rustguess.ko # new game (still secret 42 in this build)
sudo cat /dev/rustguess # welcome message again
sudo rmmod rustguess
🎲 About the secret. This base version uses a hardcoded
SECRET = 42for simplicity — every load of the module picks the same secret. Your first future-work item in the README should be: "Use the kernel RNG to pick a fresh secret at module load." Rust-for-Linux exposeskernel::random::getrandom(&mut bytes)(the exact module path may vary by RFL version) which is the same kernel call you traced in LN23 when reading from/dev/urandom. Adding it is a small but meaningful upgrade.
kernel::random::getrandom) to pick a fresh SECRET at module load. Direct callback to LN23.open() of the device gets its own game — multiple users can play simultaneously without sharing a secret. Move the GameState out of the global lock and into the per-open RustGuessDevice (the same way rustqueue keeps its pending slot per-open).RANGE:1000\n to expand the search space to 1–1000 before guessing.HISTORY\n write — show the player how they narrowed in on the answer.REVEAL\n command that prints the secret. Demonstrates conditional compilation (#[cfg(debug_assertions)])./proc/rustguess view. A read-only window that shows the secret to the kernel sysadmin (root) for testing — exercises both /dev and /proc surfaces in one module.
rustguessis a Linux kernel module that runs a number-guessing game at/dev/rustguess— a small example of stateful, user-driven protocols at Ring 0, written entirely in safe Rust. The state machine is mutex-protected; malformed input is handled with a polite error message rather than a kernel panic.
If none of the three paths excite you and you have a different module idea of similar scope, pursue it. The LN24 addendum's "Extension Challenges" section lists six other ideas you can use as starting points:
module_param! settings/proc entryrwlock) instead of a simple mutexopen() with logging of denied attemptsOther small-but-fun ideas that would also work:
rustfortune — a character device that returns one random fortune from a built-in array on each read. (RNG callback to LN23.)rustrev — a character device that reverses any string written to it. Trivial but charming.rustcaesar — applies a Caesar cipher with a write-time-configurable shift to text written to it.rustlog — a bounded ring buffer of the last N log lines with a /proc window for reading.rustping — exposes a /proc entry that reports kernel uptime, current process count, and kernel version.Whatever you pick, the only requirements are:
You now have a working kernel module. The actual work of this homework — making it something a stranger on the internet would respect — starts here.
The single most-read file in any open-source project is the README. Most readers will never run your code; they'll skim the README and form a mental model of who you are as an engineer from how it's written. Treat the README as a product, not an afterthought.
Look at the READMEs of well-loved Rust systems projects — ripgrep, bat, tokio — and you'll see a recurring shape. Aim for these ingredients:
Title and tagline. One line that tells someone what this is, written so a non-Rust-programmer can still parse it.
# rustqueue — a bounded FIFO message queue as a Linux kernel module
A demo block, near the top. Either an asciinema recording, a 30-second GIF, a screenshot, or a copy-pasted terminal session. Something visual. A reader who scrolls only the first screen needs to see your module do its thing.
"What this is" paragraph. What problem does the module solve? What does it teach? Plain English, two or three sentences.
"Build & run" section. The exact commands, copy-pasteable. Tested. If your project requires a Rust-enabled kernel, say so explicitly with a link to the requirement.
"Code tour" section. Point readers at the interesting parts. "If you want to read the code, start at init (line 50) — that's where the device gets registered. Then look at write (line 80), which is where the lock is acquired and the queue is mutated." Hiring managers love this; it shows you understand your own code well enough to guide a stranger through it.
"Why this is interesting" or "Design notes" section. Why did you pick a Mutex over an RwLock? Why AtomicU64 instead of Mutex<u64>? Why a VecDeque instead of a fixed array? These are exactly the questions an interviewer would ask. Answer them in the README; you'll be answering them anyway, and writing them down forces clarity.
"Future work" section. Honest list of what's not yet there. This is engineering maturity — every real project has known limitations and an interesting roadmap.
License. GPL-2.0 to match the kernel. Mention it explicitly. (See 3.3.)
Three options, in roughly increasing polish:
peek (Linux) or licecap (Mac/Windows) to record a 20–30 second GIF of you running the module. Embed in the README.Option 3 is the gold standard for CLI projects. Option 1 is genuinely fine for a kernel module — the bar is "the reader sees it work," not "the reader watches a movie."
The Linux kernel is licensed GPL-2.0. Loadable kernel modules that link against kernel internals (which yours does, via the kernel crate) inherit this constraint. The first line of every .rs file in the kernel tree is:
// SPDX-License-Identifier: GPL-2.0
Keep that header on yours. Add a LICENSE file at the root of your repo containing the GPL-2.0 text — GitHub will detect it and label your repo automatically. The Free Software Foundation hosts the official text.
Mention the license in your README with one sentence: "Licensed GPL-2.0 to match the Linux kernel."
.gitignore and commit hygieneKernel module builds produce a lot of artifacts you don't want in your repo. A minimal .gitignore:
*.o
*.ko
*.mod
*.mod.c
*.mod.o
*.cmd
.*.cmd
*.symvers
*.order
modules.order
Module.symvers
Module.markers
.tmp_versions/
target/
Make meaningful commits. "Initial commit" and "more changes" tell the reader nothing. Compare:
You don't need to be a Git poet. You just need to write commit messages that you'd be willing to see on a public commit log next to your name.
You may hesitate at this point. Posting code publicly under your real name feels exposing — what if someone judges my early code?
A few honest framings:
The README is what readers actually see. Most visitors will not read your source code. They will read the README, glance at the demo, and form an opinion. Strong writing around modest code beats slick code with no writing every time.
Rough code with great documentation is a strength signal. It says: I can build something, and I can communicate about it. The combination is rarer than either skill alone, and it's exactly what hiring managers screen for.
Your first public repo is a teaching moment for future you. Six months from now, the experience of having built and shipped something will matter more than the specific quality of the code. Future-you will read the commit log and remember debugging the toolchain at 11pm — that memory is the actual value.
You're not the only person writing modest code. Open samples/rust/ in the kernel tree. The code is short, deliberate, sometimes plain. The reason it lives in the kernel is not because it's brilliant; it's because it's correct, well-explained, and well-scoped. That bar is reachable.
You can always iterate. A public repo is not a one-shot deliverable. After this homework ends, you can keep improving it — pick up one of the future-work items, polish the README, add the /proc entry, post it to r/rust and ask for feedback. Public repos grow. That's what makes them portfolio pieces in the first place.
If despite all that you find yourself stuck on the public-repo step, ship it anyway with a sentence in the README saying "this is my first kernel module and my first publicly shipped Rust project." Honesty in a README is itself a form of expertise — it tells the reader you know where you are on the journey.
When your repository is in a state you're proud of, share its URL. That's the deliverable.
Your repo should have:
<module>.rs source fileMakefile.gitignoreLICENSEREADME.md that does the talkingNothing else is required. Anything else is up to you.
Unlike the other optionals in this course, this one is not a coding project — you've already built one of those for HW5 itself! Instead, this is a small research and write-up exercise: read someone else's real-world Rust kernel code, then tell me what you learned.
The point is to see how the patterns you used in your own module — module!, kernel::InPlaceModule, MiscDevice, MiscDeviceRegistration, global_lock!, atomics — show up (or don't) in modules written by people who actually ship Rust into the kernel for a living. After spending a week being the author, spend an afternoon being the reader.
The in-tree Rust samples. The canonical "this is the Rust-for-Linux API surface" reference — same crate, same patterns, same APIs you used.
samples/rust/ in the Linux kernel treerust_minimal.rs, rust_print.rs, and rust_misc_device.rs.PuzzleFS — a Rust filesystem. A real out-of-tree Rust kernel module from the container/storage world. Much bigger than yours, but the entry points should look familiar.
Asahi Linux's Apple GPU driver. The most ambitious Rust-in-kernel project to date — Asahi Lina's GPU driver for Apple Silicon. The blog post below is a beautiful read about why she chose Rust over C for a complex driver.
Nova — the new NVIDIA GPU driver. The next major in-tree Rust driver effort. Worth skimming the LKML discussion to see how Rust kernel code gets reviewed by the broader kernel community.
Pick at least two of the modules above (the in-tree samples count as one collective pick). Read enough of the source — and any linked write-ups — to be able to answer the prompts below. Then submit a single 2–4 page Markdown or PDF write-up to Brightspace covering:
For each module you read: a one-paragraph summary of what it does, followed by a short list of the kernel APIs / Rust patterns you recognized from your HW5 work (e.g., "they use module! the same way I did, but their init returns a Pin<Box<...>> of three different registrations rather than one"). Quoting a few lines of source is fine and encouraged.
Comparison to your HW5 module. What did the professionals do that you didn't? Synchronization choices? Error handling style? Module-level state ownership? Pick two or three concrete patterns and compare honestly.
One thing you would steal. If you were to do HW5 over again with what you learned, what one design decision from these modules would you pull into your own?
One thing you found confusing. Pick a snippet you couldn't fully follow — quote it and write a paragraph about what you think it's doing and what specifically tripped you up. ("I don't know" is a totally acceptable conclusion; the goal is to surface a real question, not to fake an answer.)
There is no GitHub Classroom repo for this optional — just submit your write-up directly to Brightspace.
Earning points in this optional is not as rigid since it's a write-up. To earn full points your submission must:
Submit your write-up as a .md or .pdf to the Brightspace dropbox linked at the top of this section. There is no associated GitHub repo for this optional.