Bare-Metal Rust
Bare-metal Rust is Rust compiled for a target with no operating system: no files, no threads, no heap, no
mainsupplied by a runtime, and no one to catch a panic. You get it by adding two attributes —#![no_std], which unlinks the standard library, and#![no_main], which tells the compiler you will supply the entry point yourself — and by writing one function the compiler will otherwise refuse to link without, a#[panic_handler]. What survives iscore, and the surprise for most newcomers is how much that is: every primitive type and its methods, slices, iterators,Option/Result, traits, generics, closures, pattern matching,#[derive], formatting machinery, atomics, and the whole borrow checker. What you lose is exactly the part that needed an OS underneath it — allocation and I/O (The Embedded Rust Book, Ano_stdRust Environment).The reason this matters for a microkernel rather than being a curiosity is that the code closest to the hardware is the code where memory-safety bugs are least survivable and hardest to find, and it is the code that has historically been written in C precisely because C imposes no runtime. Rust’s bet is that you can keep the guarantees and drop the runtime. The bet mostly pays, and the interesting part of this note is the honest accounting of where it does not: raw memory-mapped I/O sits outside Rust’s aliasing model and has to be handled with
read_volatile/write_volatileand a discipline the compiler cannot check; interrupt handlers need a calling convention that is still unstable onrustc 1.98.0; and mutable global state — the substance of a kernel — now requires&raw mutor an interior-mutability wrapper, because&mut STATICbecame a hard error in edition 2024.Everything below was built and run on this machine:
rustc 1.98.0 (88d9e12ae 2026-08-18)with theriscv32imc-unknown-none-elftarget, linked against the script in Linker Scripts and Memory Layout, inspected withreadelf/objdump, and executed in a small RV32IC interpreter until it printed over a memory-mapped UART. The version-sensitive claims — the stability of the interrupt ABI, thestatic_mut_refsbehaviour across editions, the exact error text when a panic handler is missing — are all measured on that compiler, on 2026-09-04.
Mental Model — Rust With the Bottom Two Layers Removed
Rust’s library is a stack of three crates, and no_std is a statement about which of them you link.
core is the language’s own foundation: the primitive types (u32, bool, char, slices, arrays, references, raw pointers), the traits that make them work (Copy, Clone, Iterator, Ord, Fn), Option and Result, core::fmt, core::mem, core::ptr, and core::sync::atomic. Nothing in core allocates or performs I/O, so nothing in it needs an OS. It is, in the Embedded Rust Book’s words, “a platform-agnostic subset of the std crate” that “makes very few assumptions about the system the program will run on.”
alloc sits above it and provides Box, Vec, String, BTreeMap — anything that needs a heap. It is available in no_std if you supply a #[global_allocator]. A microkernel usually does not want one, at least not in its trap path.
std is core + alloc + everything else: files, sockets, threads, Mutex, process spawning, environment variables, and — critically — a runtime. The book is explicit about what that runtime does: “std also takes care of, among other things, setting up stack overflow protection, processing command line arguments and spawning the main thread before a program’s main function is invoked. A #![no_std] application lacks all that standard runtime, so it must initialize its own runtime, if any is required.”
That last sentence is the whole job. The startup sequence in Linker Scripts and Memory Layout — set sp, zero .bss, copy .data, call main — is your runtime. It exists because you wrote it.
flowchart TB subgraph STD["std — hosted"] S1["files · sockets · threads<br/>Mutex · process · env"] S2["runtime: stack guard,<br/>argv, spawn main thread"] end subgraph AL["alloc — needs a heap"] A1["Box · Vec · String<br/>BTreeMap · Rc · Arc"] A2["requires #[global_allocator]"] end subgraph CO["core — needs nothing"] C1["primitives · slices · arrays<br/>Option · Result · iterators"] C2["traits · generics · closures<br/>pattern matching · derive"] C3["core::ptr (volatile)<br/>core::mem · core::fmt<br/>core::sync::atomic"] C4["<b>the borrow checker<br/>and every ownership rule</b>"] end STD --> AL --> CO NS["#![no_std]"] -->|"unlinks"| STD NS -->|"unlinks unless you<br/>opt in with an allocator"| AL NS -->|"keeps <b>all</b> of"| CO CO --> HW["runs on anything with<br/>a code-generation backend<br/>— including your own SoC"]
What no_std actually removes. What it shows: three layers, and the attribute cuts between the top two and the bottom one. The insight to take: the box that survives is by far the largest. no_std is not “Rust with the safety turned off” or “Rust minus the good parts” — the borrow checker, lifetimes, Result, iterators and traits are all in core and all still there. You lose the heap and the I/O, which on a device with 64 KiB of RAM and a UART you were going to hand-roll anyway.
#![no_std] — What You Lose, and How Much core Still Gives You
The practical question is not “what is in core?” but “what will I miss?” Three things, in descending order of annoyance.
Dynamic allocation. No Vec, no String, no Box. Buffers become fixed-size arrays and a length; collections become arrays plus an index, or the heapless crate’s Vec<T, N> with capacity in the type. This is less painful in a kernel than elsewhere, because a kernel that allocates unboundedly in its trap path is a kernel with an unbounded worst-case latency, which is a design bug independent of language. Where you genuinely need a heap, alloc plus a small bump or buddy allocator behind #[global_allocator] is a well-trodden path.
I/O and println!. There is no std::io, so no println!. What survives is the formatting machinery: core::fmt::Write is a trait with one required method, write_str(&mut self, s: &str) -> fmt::Result. Implement it on a struct that pushes bytes at your UART and write!/writeln! work exactly as usual. This is the single highest-value thirty lines of code in a bare-metal project, because it converts your UART from “can print bytes” into “can print anything that implements Debug” — and #[derive(Debug)] is in core.
std::sync. No Mutex, no RwLock, no Arc. core::sync::atomic survives, which on RV32IMC is a genuine catch: the A (atomic) extension is not in IMC, so AtomicU32::fetch_add has no single instruction to compile to. On such targets the atomics are either lowered to libcalls, unavailable, or restricted to load/store — a real constraint on a kernel design, and one to check against your own core’s extension set rather than assume.
What is not lost is worth listing explicitly, because the fear that no_std means “C with different syntax” is common and wrong: ownership and borrowing, lifetimes, Option/Result and the ? operator, exhaustive match, iterators and closures (which monomorphise to the same code a hand-written loop produces), generics and trait bounds, #[derive(Debug, Clone, Copy, PartialEq)], const generics, and slice bounds checking. That last one has a bare-metal consequence, covered under the panic handler below.
#![no_main], Symbol Names, and the C ABI
#![no_main] tells the compiler not to emit the usual main shim. The Embedded Rust Book explains why it is needed rather than optional: “Rust’s main interface makes some assumptions about the environment the program executes in: For example, it assumes the existence of command line arguments, so in general, it’s not appropriate for #![no_std] programs.”
With main gone, something must provide the symbol your linker script’s ENTRY() names and, more importantly, the code at the reset address. Two attributes do the work:
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_start() -> ! {
// ...
}#[no_mangle] suppresses Rust’s name mangling so the symbol is literally rust_start rather than _RNvCs7v2hgJCvTDI_5dne3210rust_start. This matters because the caller is assembly, and assembly cannot spell a mangled name. In edition 2024 the attribute must be written #[unsafe(no_mangle)] — exporting an unmangled symbol can collide with something else in the link, which is a soundness hazard, so the attribute was moved behind the unsafe marker. Building the same code with #[no_mangle] under edition 2024 on rustc 1.98.0 is an error.
extern "C" fixes the calling convention. Rust’s default ABI (extern "Rust") is deliberately unspecified and free to change between compiler versions; extern "C" pins the function to the platform C ABI — on this target, RISC-V ilp32, where the first eight arguments go in a0–a7 and the return value comes back in a0. Any function called from assembly, or used as a handler address the hardware jumps to, must be extern "C" (or, for interrupts, something stronger — see below).
-> ! is the never type: this function does not return. The reset stub has nowhere to return to, so a function that could return would leave the compiler generating a ret into a garbage ra.
Here is the reset stub from the crate built for this note, written with global_asm! so the compiler emits it verbatim into a section the linker script places first:
core::arch::global_asm!(
".section .text.init, \"ax\"", // own section, so link.x can place it at the reset vector
".globl _start",
"_start:",
" .option push",
" .option norelax", // stop the assembler relaxing la into a gp-relative form
" la sp, _stack_top", // sp is undefined at reset; nothing may be called before this
" .option pop",
" call rust_start",
"1: wfi", // if rust_start ever returns, park the hart
" j 1b",
);.option norelax around la sp, _stack_top is not superstition. RISC-V linker relaxation can rewrite an la into a gp-relative addi — but gp has not been set up yet at this point in boot, so the relaxed form would load nonsense. riscv-rt guards its own la gp, __global_pointer$ the same way, for the same reason. Disassembly confirms the intended form survived:
20000000 <_start>:
20000000: 60010117 auipc sp,0x60010 # 0x20000000 + 0x60010000
20000004: 00010113 mv sp,sp # = 0x80010000 = _stack_top
20000008: 00000097 auipc ra,0x0
2000000c: 056080e7 jalr 86(ra) # 2000005e <rust_start>
The #[panic_handler] Requirement
Omit it and the compiler stops dead. Measured, on rustc 1.98.0:
$ rustc --target riscv32imc-unknown-none-elf --crate-type bin -O nopanic.rs
error: `#[panic_handler]` function required, but not found
The Rustonomicon states the contract: the attribute “must be applied to a function with signature fn(&PanicInfo) -> ! and such function must appear once in the dependency graph of a binary / dylib / cdylib crate” (#[panic_handler]). Exactly once — zero is the error above, two is a duplicate-symbol error, which is why pulling in two different panic-* crates transitively is a known ecosystem papercut.
Mechanically, the attribute exports the symbol rust_begin_unwind. Compiling a no_std crate with a handler and running nm on the object shows it directly:
00000000 T _RNvCs6rREvFdRhLb_7___rustc17rust_begin_unwind
U _RNvNtCsdqg0k85mmHs_4core9panicking18panic_bounds_check
The second line is the important one and is easy to miss. core::panicking::panic_bounds_check is undefined in that object — it comes from core, and it calls rust_begin_unwind, which is your handler. It appeared because the test program indexed a slice with a runtime value. Ordinary safe Rust reaches the panic path. Array indexing, integer division, unwrap(), slice split_at, arithmetic overflow in debug builds — all of them can land in your handler. In a kernel, that means the handler is not a formality you write once and forget; it is the last-resort error path of every line of safe code you write.
flowchart TB subgraph SAFE["ordinary safe Rust — no unsafe anywhere"] IX["buf[i]<br/>slice index"] DV["a / b<br/>integer division"] UW["opt.unwrap()"] OV["x + y<br/>overflow, debug builds"] end subgraph COREP["core::panicking — linked from libcore.rlib"] PB["panic_bounds_check"] PD["panic_const_div_by_zero"] PA["panic / panic_fmt"] end RBU["<b>rust_begin_unwind</b><br/><i>the symbol #[panic_handler] exports</i>"] YOURS["your fn panic(&PanicInfo) -> !<br/>halt · print · reset · signal"] IX --> PB DV --> PD UW --> PA OV --> PA PB --> RBU PD --> RBU PA --> RBU RBU --> YOURS YOURS --> NEVER["-> ! never returns<br/>there is nothing to return to"]
How safe code reaches your panic handler. What it shows: four everyday constructs, none of them unsafe, each with a path into the one function you wrote. The insight to take: nm on a no_std object shows U core::panicking::panic_bounds_check for a program whose only sin was indexing a slice — the panic path is not exceptional, it is the failure branch of ordinary code. In a kernel that makes the handler part of the design, not boilerplate: it is the behaviour of the machine when any assumption anywhere is violated.
Which raises the design question the topic actually turns on: what should panic do on a machine with no operating system? There is no process to kill, no stderr, no supervisor. Four honest answers, and the choice is a real engineering decision rather than a default:
| Strategy | Implementation | Good for | The cost |
|---|---|---|---|
| Halt | loop { wfi } | smallest possible; safe default | zero diagnosis; board just stops |
| Print, then halt | write PanicInfo to the UART via core::fmt::Write | development; you learn the file and line | pulls in core::fmt, kilobytes of code |
| Reset | trigger the watchdog or jump to the reset vector | field devices that must stay available | hides the bug; can loop-reset forever |
| Signal, then halt | raise a GPIO / write a magic word to a known RAM address | FPGA bring-up with a logic analyser | needs a working peripheral at panic time |
Panic strategies. What it shows: four defensible behaviours and the trade-off each makes between diagnosability and size. The insight to take: the swap is cheap — the Rustonomicon’s whole point about “panic crates” is that a crate containing only a #[panic_handler] can be switched by a #[cfg(debug_assertions)], giving you a chatty handler in development and a silent halting one in release. Decide once, early; retrofitting it after the board is in a case is much less fun.
The size argument is worth taking seriously, because core::fmt is genuinely large relative to a 64 KiB ROM. A handler that formats PanicInfo drags in the formatting machinery and, transitively, a good deal of core. The common compromise is a handler that prints only the Location (file and line, which are cheap &'static str and u32) and not the message.
One more configuration knob: panic = "abort" in the Cargo profile. Rust’s default panic strategy is unwind, which requires an eh_personality language item and unwinding tables — neither of which makes sense here. Setting panic = "abort" in [profile.release] removes the requirement and the .eh_frame tables with it. The no_std targets generally default to abort anyway, but stating it is free and makes the intent explicit.
Configuring the Build — .cargo/config.toml, Target, and Rustflags
Four files. Cargo.toml sets the profile; .cargo/config.toml sets the target and the linker arguments; link.x is the script; src/main.rs is the crate. Here is the working set, verbatim:
# Cargo.toml
[package]
name = "dne32"
version = "0.1.0"
edition = "2024"
[profile.release]
opt-level = "s" # optimise for size — ROM is the scarce resource
lto = true # cross-crate inlining; meaningful when core is the only dep
codegen-units = 1 # one unit lets LLVM see everything at once
panic = "abort" # no unwinder exists; drop eh_personality and .eh_frame
debug = true # DWARF costs nothing in ROM — it is not a loadable sectiondebug = true in a release profile looks wrong and is not: debug info lands in .debug_* sections, which are neither loadable nor allocatable, so objcopy -O binary discards them entirely. The ELF grows; the ROM image does not. In the build for this note the ELF is ~15 KB and the ROM image is 324 bytes.
# .cargo/config.toml
[build]
target = "riscv32imc-unknown-none-elf"
[target.riscv32imc-unknown-none-elf]
rustflags = [
"-C", "link-arg=-Tlink.x", # use our linker script
"-C", "link-arg=--gc-sections", # drop unreferenced sections (see KEEP() in link.x)
"-C", "link-arg=-Map=dne32.map", # always generate a map
]The [build] target line is what lets you type cargo build instead of cargo build --target riscv32imc-unknown-none-elf forever. The [target.<triple>] rustflags block passes the linker arguments only for that triple — build scripts and proc macros, which are built for the host, do not receive them.
The
RUSTFLAGSoverride footgun — measured, not theoreticalCargo documents “four mutually exclusive sources of extra flags… checked in order, with the first one being used”:
CARGO_ENCODED_RUSTFLAGS,RUSTFLAGS,target.<triple>.rustflags,build.rustflags(Cargo config reference). Mutually exclusive, not cumulative. So setting anyRUSTFLAGSin your shell or CI silently discards the config block, linker script included. Reproduced here with a flag that has nothing to do with linking:$ RUSTFLAGS="-C debuginfo=0" cargo build --release rust-lld: error: undefined symbol: _stack_top rust-lld: error: undefined symbol: _ebss rust-lld: error: undefined symbol: _sbssNothing in that message mentions a linker script. This is why
riscv-rtandcortex-m-rtemitcargo:rustc-link-argfrom abuild.rsinstead — build-script link args are additive and cannot be clobbered by an environment variable.
A second Cargo behaviour worth knowing: Cargo does not treat the linker script as a build input. Editing link.x and re-running cargo build produced Finished with no relink; the edit only took effect after touch src/main.rs. A build.rs emitting cargo:rerun-if-changed=link.x fixes it, which is again what the -rt crates do.
Finally, the linker itself. Rust’s riscv32imc-unknown-none-elf target uses rust-lld, not GNU ld — visible in Cargo’s own error output as "rust-lld" "-flavor" "gnu". It reads the GNU script language, and the script in Linker Scripts and Memory Layout linked correctly under both, but they are not identical (LLD assigned .bss an LMA equal to its VMA where GNU ld 2.46 put it in ROM). Know which one you are running before you debug a script.
flowchart TB SRC["src/main.rs<br/>#![no_std] #![no_main]<br/>#[panic_handler]"] CT["Cargo.toml<br/>[profile.release]<br/>opt-level=s, lto, panic=abort"] CC[".cargo/config.toml<br/>target = riscv32imc-unknown-none-elf<br/>rustflags = -Tlink.x, --gc-sections"] LX["link.x<br/>MEMORY + SECTIONS<br/>KEEP + AT>ROM"] RC["rustc 1.98.0<br/>LLVM backend<br/>→ RV32IMC object"] CORE["libcore.rlib<br/>libcompiler_builtins.rlib<br/>(precompiled for the target)"] LLD{{"rust-lld -flavor gnu"}} ELF["dne32 (ELF32)<br/>Entry 0x20000000<br/>.text .rodata .data .bss"] MAP["dne32.map"] BIN["rom.bin — 324 bytes<br/>objcopy -O binary"] SRC --> RC CT --> RC CC --> RC RC --> LLD CORE --> LLD LX --> LLD CC -.->|"link-arg passthrough"| LLD LLD --> ELF LLD --> MAP ELF --> BIN BIN --> RUN["Verilator testbench<br/>or FPGA block RAM"]
The bare-metal Rust build pipeline. What it shows: four inputs converge on rustc, whose output joins two precompiled rlibs at the linker, which consumes the script as a fifth input. The insight to take: libcore and libcompiler_builtins arrive precompiled for the target triple — that is what rustup target add riscv32imc-unknown-none-elf fetched. This is why a no_std build is fast and why an exotic target that has no prebuilt core needs -Z build-std and a nightly compiler. compiler_builtins is also where __mulsi3, __udivsi3 and friends live: on a core without the M extension, that rlib is what makes multiplication work at all.
A Real Build, Executed End to End
The full crate is short enough to read in one go. It is a no_std program that zeroes .bss, copies .data, prints a banner over a memory-mapped UART, touches one .data global and one .bss global, and parks.
#![no_std]
#![no_main]
use core::panic::PanicInfo;
use core::ptr::{read_volatile, write_volatile};
// --- the SoC memory map, as constants -------------------------------------
const UART_TX: *mut u8 = 0x1000_0000 as *mut u8;
const UART_STATUS: *const u32 = 0x1000_0004 as *const u32;
const UART_TX_READY: u32 = 1 << 0;
// --- three globals, one per section ---------------------------------------
static mut TICKS: u32 = 0xDEAD_BEEF; // .data — VMA in RAM, LMA in ROM
static mut SCRATCH: [u32; 256] = [0; 256]; // .bss — RAM only, no ROM cost
static BANNER: &[u8] = b"dne32 up\n"; // .rodata — stays in ROM
// --- symbols the linker script exports ------------------------------------
unsafe extern "C" { // edition 2024: extern blocks are unsafe
static mut _sbss: u32;
static mut _ebss: u32;
static mut _sdata: u32;
static mut _edata: u32;
static _sidata: u32; // = LOADADDR(.data), the ROM copy
static _stack_top: u32;
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_start() -> ! {
unsafe {
// Zero .bss.
let mut p = &raw mut _sbss;
let e = &raw mut _ebss;
while p < e { write_volatile(p, 0); p = p.add(1); }
// Copy .data from its load address in ROM to its run address in RAM.
let mut d = &raw mut _sdata;
let de = &raw mut _edata;
let mut s = &raw const _sidata;
while d < de { write_volatile(d, read_volatile(s)); d = d.add(1); s = s.add(1); }
}
main()
}
fn putc(c: u8) {
unsafe {
while read_volatile(UART_STATUS) & UART_TX_READY == 0 {}
write_volatile(UART_TX, c);
}
}
fn main() -> ! {
for &b in BANNER { putc(b); }
unsafe {
let t = &raw mut TICKS;
write_volatile(t, read_volatile(t).wrapping_add(1));
let s = (&raw mut SCRATCH) as *mut u32;
write_volatile(s, read_volatile(t));
}
loop { unsafe { core::arch::asm!("wfi") } }
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop { unsafe { core::arch::asm!("wfi") } }
}Two edition-2024 details in there that a 2021-era tutorial will not have. unsafe extern "C" { ... } — extern blocks themselves became unsafe in edition 2024, because declaring a foreign symbol’s type is an unchecked promise. And &raw mut _sbss rather than &mut _sbss; that change is the subject of its own section below.
Building and inspecting it. The section table shows every address landing where the script said:
$ cargo build --release # rustc 1.98.0, riscv32imc-unknown-none-elf
$ riscv64-linux-gnu-readelf -S -W target/.../dne32
[Nr] Name Type Addr Off Size Flg
[ 1] .text PROGBITS 20000000 001000 0000ac AX
[ 2] .rodata PROGBITS 200000ac 0010ac 00000c A
[ 3] .data PROGBITS 80000000 002000 000004 WA
[ 4] .bss NOBITS 80000004 002004 000400 WA
$ riscv64-linux-gnu-objdump -h target/.../dne32 | grep -A1 '\.data'
2 .data 00000004 80000000 200000b8 00002000 2**2
.data has VMA 0x80000000 and LMA 0x200000b8 — the split that the copy loop exists to close. And the bytes really are in ROM:
$ riscv64-linux-gnu-objdump -s -j .data target/.../dne32
Contents of section .data:
80000000 efbeadde
ef be ad de is 0xDEADBEEF little-endian: TICKS’s initializer, listed under the section’s VMA but physically living at the LMA in the ROM image.
Running it. Static addresses prove placement; they do not prove the program works. With no qemu-system-riscv32 or spike on this machine, a ~200-line RV32IC interpreter was written with this exact memory map and fed the objcopy -O binary output:
$ riscv64-linux-gnu-objcopy -O binary dne32 rom.bin && ./sim rom.bin
[sim] loaded 324 bytes at 0x20000000
dne32 up
[sim] wfi at pc=0x200000de after 875 instructions
[sim] RAM[0x80000000] (.data ticks) = 0xdeadbef0
[sim] RAM[0x80000004] (.bss scratch0) = 0xdeadbef0
[sim] sp = 0x80010000
Every claim in this section is in those six lines: the banner printed through write_volatile to 0x10000000; TICKS read as 0xDEADBEEF and incremented to 0xdeadbef0, proving the ROM-to-RAM copy ran; SCRATCH[0] was zeroed and then written; and sp is _stack_top. 875 instructions from reset to wfi, in 324 bytes of ROM.
Uncertain
Verify: that this binary behaves the same on the project’s real RV32IMC core. Reason: it was executed only in a purpose-built interpreter (RV32I plus the RV32C expansions this binary uses; no CSRs, no traps, a two-register UART stub), because no released RISC-V simulator is installed on this machine. To resolve: run the same
rom.binunder the project’s Verilator testbench orqemu-system-riscv32, and compare the UART output and the final.dataword.#uncertain
Startup Code — Rolling Your Own vs riscv-rt
The thirty lines above are a complete runtime for one hart on one board. The ecosystem’s answer, riscv-rt (0.18.0 as of 2026-09-04, per the crates.io index; MIT/Apache-2.0; MSRV 1.81), is roughly 1,200 lines and does considerably more. Reading its source is the fastest way to learn what a hand-rolled runtime is not handling.
Its boot path, from src/asm.rs in the published 0.18.0 crate, in order: an absolute jump out of .init so the rest can be linked anywhere; csrw mie, 0 / csrw mip, 0 to disable interrupts; csrr a0, mhartid so the hart ID is in the first argument register; csrw mtvec, _pre_init_trap so that a fault during boot lands somewhere defined rather than wandering; a hart-ID bounds check that jumps to abort on an out-of-range hart; la gp, __global_pointer$ under .option norelax; a per-hart stack computed as _stack_start − hartid × _hart_stack_size, then andi sp, t1, -16 to align the stack to 16 bytes as the ABI requires; a _mp_hook call so only the boot hart initializes RAM; the .data copy and .bss zero; and, on cores with floating point, setting mstatus.FS to Initial and clearing fcsr.
stateDiagram-v2 [*] --> Reset : pc = reset vector Reset --> AbsJump : lui ra / jr — leave .init<br/>so the rest links anywhere AbsJump --> MaskIrq : csrw mie, 0<br/>csrw mip, 0 MaskIrq --> HartId : csrr a0, mhartid HartId --> EarlyTrap : csrw mtvec, _pre_init_trap note right of EarlyTrap a fault during boot now lands somewhere defined instead of wandering into garbage end note EarlyTrap --> HartCheck : compare hartid with _max_hart_id HartCheck --> Abort : hartid too large HartCheck --> Gp : hartid ok Gp --> Stack : la gp, __global_pointer$<br/>(.option norelax) Stack --> MpHook : sp = _stack_start − hartid×size<br/>andi sp, sp, -16 MpHook --> CopyData : boot hart only MpHook --> Wait : other harts busy-loop CopyData --> ZeroBss : __sidata → __sdata.._edata ZeroBss --> Fpu : __sbss.._ebss Fpu --> Main : mstatus.FS = Initial, fscsr x0<br/>(only if F/D present) Main --> [*] : call hal_main → main Abort --> [*] Wait --> [*]
riscv-rt 0.18.0’s boot path as a state machine, read from src/asm.rs in the published crate. What it shows: eleven steps between reset and main, where a hand-written runtime typically has three. The insight to take: the two steps a first attempt almost always omits are csrw mtvec, _pre_init_trap and andi sp, sp, -16. The first is why a bug in your .bss loop produces a diagnosable trap in riscv-rt and total silence in a hand-rolled runtime; the second is an ABI requirement LLVM is entitled to assume, and violating it produces misaligned spills that fail only sometimes.
Three of those are things a first hand-written runtime almost never has and probably should: setting mtvec before anything else, so an early fault is diagnosable rather than silent; 16-byte stack alignment, which the RISC-V ABI requires and which LLVM is entitled to assume; and __global_pointer$, without which RISC-V linker relaxation cannot shorten .sdata/.sbss accesses.
Its trap ABI is the other thing worth stealing. riscv-rt’s TrapFrame struct contains ra, t0–t6 and a0–a7 — the caller-saved registers only, not all 31. That is not an oversight: _start_trap saves the caller-saved set, calls _start_trap_rust as an ordinary extern "C" function, and lets the compiler’s own prologue save any callee-saved registers it decides to use. Half the register file, saved for free by the ABI. This is a different trade-off from a context switch, which must save all 31 because it is resuming a different task — see Context Switching in a Microkernel.
| Hand-written runtime | riscv-rt 0.18.0 | |
|---|---|---|
| Lines to read | ~30 | ~1,200 Rust + ~250 script |
| Multi-hart | no | _mp_hook, per-hart stacks, hart-ID check |
mtvec set before init | you must remember | _pre_init_trap, always |
__global_pointer$ | you must add it | provided in link.x.in |
| Stack alignment | you must remember -16 | andi sp, t1, -16 |
| FPU enable | n/a on IMC | mstatus.FS, if F/D present |
| Alignment invariants | none unless you write ASSERT | 11 ASSERTs in the script |
| Trap dispatch | none | TrapFrame, ExceptionHandler, DefaultHandler, vectored mode |
| Failure mode when wrong | silent | a link error with a written explanation |
Own runtime versus the crate. What it shows: the gap is not “convenience” — it is a list of specific hardware invariants. The insight to take: write your own once, deliberately, because Stage 5 of definitely-not-esp32 MOC is about being able to debug a boot failure you constructed. Then read riscv-rt’s link.x.in and asm.rs line by line and port back the three or four things you forgot. Its ASSERTs alone — ASSERT(__sidata % 4 == 0, "BUG(riscv-rt): the LMA of .data is not 4-byte aligned") — are worth copying verbatim into a hand-written script.
For a microkernel there is a genuine argument for owning this code rather than depending on it. The runtime is where the kernel’s assumptions about privilege mode, hart topology, and trap entry are encoded, and a microkernel is going to have opinions about all three. riscv-rt has an s-mode feature and both direct and vectored trap modes, so it is not inflexible — but the crate boundary is in an awkward place if your trap entry is your scheduler entry.
Volatile MMIO, and Why Rust Makes It Harder Than C
This is where Rust is genuinely more interesting than C, and the reason is not syntax — it is that Rust has a memory model with teeth, and memory-mapped I/O sits outside it.
In C, volatile is a type qualifier: you declare volatile uint32_t *reg and every access through it is a real load or store the compiler may not elide, reorder relative to other volatile accesses, or fuse. In Rust, volatility is a property of the operation, not the type: core::ptr::read_volatile and core::ptr::write_volatile are functions on raw pointers. There is no volatile in Rust’s type system at all. The consequence is that you cannot make a struct volatile — you must ensure every single access goes through the right function, which is exactly the discipline the volatile-register/vcell newtype wrappers exist to enforce.
The deeper reason Rust cannot simply add a volatile qualifier is the aliasing rules. Rust’s core promise is mutability XOR aliasing: while an &mut T exists, no other pointer may be used to access that memory. A hardware register violates this on its face. A UART status register changes because a wire changed, not because anything in your program wrote to it. If you form an &mut u32 to it, you have asserted exclusivity over something the hardware is concurrently modifying — a lie the optimizer is entitled to believe.
The documentation for read_volatile addresses this case explicitly, and the wording is unusually generous:
“Volatile operations… may also be used to access memory that is outside of any Rust allocation. In this use-case, the pointer does not have to be valid for reads. This is typically used for CPU and peripheral registers that must be accessed via an I/O memory mapping, most commonly at fixed addresses reserved by the hardware… Here, any address value is possible, including 0 and
usize::MAX, so long as the semantics of such a read are well-defined by the target hardware. The provenance of the pointer is irrelevant.” (core::ptr::read_volatile)
So MMIO through raw pointers and volatile operations is explicitly blessed. Three constraints survive, and each has bitten real code:
- The pointer must be properly aligned. A
read_volatile::<u32>at0x1000_0002is undefined behaviour in Rust before the bus ever sees it. - The access must not trap and must not touch Rust-allocated memory. Reading a register that faults is UB, not a caught exception.
- Volatile is not atomic. The docs say it outright: “This access is still not considered atomic, and as such it cannot be used for inter-thread synchronization.” A volatile read/modify/write of a register that an interrupt handler also touches is a race, exactly as it would be in C. Interrupt masking or a real atomic is required.
There is a fourth trap that is Rust-specific and worth knowing: load splitting. The docs warn that “Exactly which hardware loads are performed by this function is, in general, highly target-dependent… For anything else, it will be split into multiple loads in some unspecified way… There is no stability guarantee on how that splitting happens.” A read_volatile of a 12-byte struct does not necessarily produce three word loads in order — and for a peripheral where reading a FIFO register has a side effect, “some unspecified way” is a bug waiting to happen. Read and write registers one scalar at a time. In the crate above, UART_TX is *mut u8 and UART_STATUS is *const u32, and the generated code is exactly one lw, one andi, one sb:
2000002e: 425c lw a5,4(a2) # read_volatile(UART_STATUS)
20000030: 8b85 andi a5,a5,1 # & UART_TX_READY
20000032: dff5 beqz a5,2000002e # spin — the load was NOT hoisted
20000036: 00e60023 sb a4,0(a2) # write_volatile(UART_TX, c)
The beqz branching back to the load is the whole point. A non-volatile read would have been hoisted out of the loop by LLVM and the program would spin forever on a stale value. This is the classic C volatile bug, and Rust has exactly the same one if you use *ptr instead of read_volatile(ptr) — the difference is that in Rust you cannot do it by accident from safe code, because dereferencing a raw pointer is unsafe in the first place.
And this is where Peripherals::take() comes from. The Embedded Rust Book’s Singletons chapter makes the argument: a static mut THE_SERIAL_PORT is “a mutable global variable, and in Rust, these are always unsafe to interact with. These variables are also visible across your whole program, which means the borrow checker is unable to help you track references and ownership.” The fix is to wrap every peripheral in an Option<T> inside one global struct and hand it out exactly once:
struct Peripherals { serial: Option<SerialPort> }
impl Peripherals {
fn take_serial(&mut self) -> SerialPort { self.serial.take().unwrap() }
}The second take_serial() panics. That single runtime check buys you something structural: the returned SerialPort is an owned value, so from that point on the borrow checker enforces exclusive access to the UART, across your whole program, for free. As the book puts it, “although interacting with this structure is unsafe, once we have the SerialPort it contained, we no longer need to use unsafe, or the PERIPHERALS structure at all… this small up-front cost allows us to leverage the borrow checker throughout the rest of our program.”
That is the pattern to understand, because it is the general shape of every good embedded-Rust abstraction: one small, audited unsafe region that converts a hardware fact into a Rust ownership fact, after which the type system does the work. It is also the pattern a microkernel wants for its capability table.
flowchart TB HW["hardware register<br/>0x10000004<br/>changes on its own"] subgraph WRONG["what Rust must not allow"] REF["&mut u32 to 0x10000004"] OPT["LLVM: 'I hold the only<br/>mutable reference'<br/>→ hoist the load<br/>→ infinite spin"] REF --> OPT end subgraph RIGHT["what the ecosystem does instead"] RAW["*mut u32 raw pointer"] VOL["read_volatile / write_volatile<br/>each access is a real bus cycle"] NT["newtype wrapper<br/>volatile-register / vcell"] SING["Peripherals::take()<br/>hands out the wrapper ONCE"] OWN["owned SerialPort value<br/><b>borrow checker now enforces<br/>exclusive access, safely</b>"] RAW --> VOL --> NT --> SING --> OWN end HW --> REF HW --> RAW OPT -.->|"undefined behaviour"| X["🚫"]
Why MMIO in Rust needs a ladder rather than a keyword. What it shows: the left branch is what a naive translation from C produces and why it is unsound; the right branch is the four-step climb from a raw address back into checked, safe code. The insight to take: the unsafe is not eliminated, it is concentrated — into the take() and the volatile accessors, which are a few dozen lines you can read in one sitting. Everything above that line is ordinary borrow-checked Rust. That concentration, not the absence of unsafe, is the actual product.
unsafe — Where It Is Unavoidable in a Kernel
It is worth being precise about what unsafe does, because the folk description is wrong. unsafe does not disable the borrow checker, turn off bounds checks, or change code generation. It unlocks exactly five abilities: dereferencing a raw pointer, calling an unsafe function, implementing an unsafe trait, accessing a union field, and mutating a static. Everything else — ownership, lifetimes, the type system — is still fully in force inside an unsafe block.
In a kernel, four things genuinely cannot be done without it, and they are worth enumerating so the rest of the codebase can be held to a higher standard:
- MMIO. Every peripheral access bottoms out in a raw-pointer dereference at an address the compiler cannot know is valid.
- The linker-script symbols.
_sbss,_sidataand friends areexterndeclarations whose types you are asserting; the.bsszero loop writes through pointers into memory Rust has no model of. - Inline assembly and CSR access.
csrw mtvec, t0has no safe expression.core::arch::asm!isunsafebecause the assembler can do anything. - Context switching. Saving 31 registers and resuming a different stack is, from Rust’s point of view, arbitrary control-flow and memory rewriting. There is no way to phrase it safely.
Two disciplines make this tractable. First, every unsafe block gets a // SAFETY: comment stating the invariant that makes it sound — not what the code does, but why it is allowed. Second, unsafe blocks are small and wrapped: the putc function above is safe to call from anywhere and contains a three-line unsafe block; nothing outside it needs to know the UART is at 0x10000000.
The honest framing: bare-metal Rust does not have less unsafe than C — the bottom layer is the same operations. It has unsafe in a marked, greppable, reviewable minority of lines, with the compiler enforcing that everything else stays out. grep -c unsafe on a kernel is a metric C cannot produce.
Interrupt Handlers and the ABI Question
An interrupt handler is not a function call, and pretending otherwise is a correctness bug rather than a style one. Two things differ:
It must save every register it touches, including the ones the ABI calls scratch. A normal extern "C" function is entitled to clobber t0–t6 and a0–a7 because its caller knew a call was happening and saved anything it needed. An interrupt has no caller. It lands between two arbitrary instructions of code that is not expecting anything, so every register it modifies must be restored.
It must return with mret, not ret. mret restores mstatus.MIE, drops the privilege mode back, and jumps to mepc. ret is jalr x0, 0(ra) and does none of that.
Here is what rustc 1.98.0 generates for a plain extern "C" handler — a two-line function that writes one byte to a UART:
plain_c_isr:
lui a0, 65536 # clobbers a0 without saving it
li a1, 67 # clobbers a1 without saving it
sb a1, 0(a0)
ret # NOT mret
Wrong on both counts. Wire that address into mtvec and the interrupted code resumes with two corrupted registers and the machine still in a trap-entry state.
Rust has a proper answer — the extern "riscv-interrupt-m" and extern "riscv-interrupt-s" calling conventions, which make the compiler save the full clobbered set and emit mret/sret. They are still unstable. Measured on this machine:
$ cargo build --release # rustc 1.98.0, riscv32imc-unknown-none-elf
error[E0658]: the extern "riscv-interrupt-m" ABI is experimental and subject to change
--> src/main.rs:6:12
= note: see issue #111889 <https://github.com/rust-lang/rust/issues/111889>
The unstable-book entry confirms the gate name: abi_riscv_interrupt, “Allows extern "riscv-interrupt-m" fn() and extern "riscv-interrupt-s" fn()”, tracking issue #111889. As of 2026-09-04 on stable rustc 1.98.0, this is nightly-only.
| Approach | Saves caller-saved regs | Returns with mret | Stable on 1.98.0 |
|---|---|---|---|
extern "C" wired straight to mtvec | ❌ | ❌ | ✅ (and wrong) |
extern "riscv-interrupt-m" | ✅ (compiler) | ✅ | ❌ nightly (#111889) |
global_asm! shim → extern "C" Rust fn | ✅ (your asm) | ✅ (your asm) | ✅ |
riscv-rt’s _start_trap | ✅ | ✅ | ✅ |
The four ways to write an ISR in Rust, and which actually work today. What it shows: the language-level answer exists and is not yet reachable from stable. The insight to take: on stable Rust the only correct options are the bottom two, and they are the same technique — a hand-written assembly shim that saves the caller-saved registers, calls an ordinary extern "C" Rust function, restores them, and executes mret. riscv-rt is that shim, written once and well. If you are writing your own, its TrapFrame is the list of registers to save, and the reason the list is only half the register file is that the extern "C" call boundary makes the compiler save the rest.
sequenceDiagram autonumber participant T as interrupted task participant HW as hardware participant SH as _start_trap<br/>(global_asm! shim) participant RS as _start_trap_rust<br/>(extern "C" Rust fn) participant H as your handler T->>HW: executing normally, all 31 regs live HW->>HW: IRQ asserted (mtip / meip) HW->>HW: mepc := pc, mcause := code<br/>mstatus.MPIE := MIE, MIE := 0 HW->>SH: pc := mtvec Note over SH: addi sp,sp,-N then store<br/>ra, t0-t6, a0-a7 — the CALLER-saved set SH->>RS: call, with a pointer to the TrapFrame Note over RS: the C ABI now obliges the COMPILER<br/>to save s0-s11 in this function's prologue RS->>RS: read mcause, split interrupt vs exception RS->>H: dispatch to ExceptionHandler / DefaultHandler H-->>RS: return RS-->>SH: return Note over SH: reload ra, t0-t6, a0-a7<br/>addi sp,sp,N SH->>HW: mret HW->>T: pc := mepc, mstatus.MIE := MPIE Note over T: resumes mid-instruction-stream,<br/>every register exactly as it was
What a correct Rust ISR actually looks like on stable. What it shows: the split of responsibility — the shim saves the caller-saved half, the extern "C" call boundary makes the compiler save the callee-saved half, and only the shim may execute mret. The insight to take: step 5 and the note under step 7 are the same trick viewed twice. Half the register file is saved by writing zero lines of code, purely because the shim performs a call rather than jumping. This is why riscv-rt’s TrapFrame is 17 fields and not 31, and it is the argument for why an extern "C" function alone — with no shim — is wrong: it saves nothing and returns with ret.
The reason the shim only needs to save ra, t0–t6 and a0–a7 is worth restating because it is a genuinely elegant piece of engineering: once the shim performs a call into Rust, the callee is bound by the C ABI, which obliges it to preserve s0–s11 and sp for its caller. So the compiler emits the other half of the save/restore automatically, in the handler’s own prologue and epilogue, and only for the registers it actually uses.
Note the contrast with Context Switching in a Microkernel, which does have to save all 31 registers. A trap handler returns to the same task; a context switch returns to a different one, so the ABI’s promise about callee-saved registers no longer helps — the values belong to another stack.
Static Mutable State and the Deprecation of static mut
A kernel is mutable global state: a task table, a run queue, a scheduler tick, a trap frame pointer. Rust has spent several editions making static mut progressively harder to use, and understanding why is not pedantry — it is the same aliasing argument as MMIO.
The rule, as the edition guide states it: “Merely taking such a reference in violation of Rust’s mutability XOR aliasing requirement has always been instantaneous undefined behavior, even if the reference is never read from or written to. Furthermore, upholding mutability XOR aliasing for a static mut requires reasoning about your code globally, which can be particularly difficult in the face of reentrancy and/or multithreading” (Rust 2024: static mut references).
“Reentrancy” is the word that matters here. An interrupt handler that touches the same static mut as main is reentrancy, and it is the normal case in a kernel, not an exotic one. If main holds an &mut TASKS and a timer interrupt fires and takes another, you have two live &mut to the same memory and the optimizer is free to assume that cannot happen.
The behaviour is now edition-dependent, and this was measured on rustc 1.98.0 with the same source file compiled twice:
$ rustc --edition 2024 ...
error: creating a mutable reference to mutable static
= note: mutable references to mutable statics are dangerous; it's undefined behavior
if any other pointer to the static is used or if any other reference is created
for the static while the mutable reference lives
= note: `#[deny(static_mut_refs)]` (part of `#[deny(rust_2024_compatibility)]`) on by default
help: use `&raw mut` instead to create a raw pointer
$ rustc --edition 2021 ...
warning: creating a mutable reference to mutable static # same text, warn not error
Hard error in edition 2024, warning in 2021. Note the precise scope: static mut itself is not removed and not deprecated — taking a reference to one is what is denied. The escape hatch the compiler suggests is &raw mut, which produces a *mut T without ever forming a reference, so no exclusivity is asserted and no UB occurs from the act of taking the pointer. (&raw mut is edition-2024-friendly syntax for what core::ptr::addr_of_mut! did before it; both compile on 1.98.0.) That is why the crate in this note reads &raw mut _sbss and let t = &raw mut TICKS; rather than the older &mut form.
The escape hatch is not a solution, though — it moves the reasoning from the compiler to you. The alternatives, in rough order of preference for a kernel:
| Approach | What it costs | When it fits |
|---|---|---|
| Don’t have globals — thread state through | plumbing | scheduler state owned by a Kernel struct passed by &mut |
static ATOMIC: AtomicU32 | needs the A extension or a libcall | counters, flags, tick counts |
static CELL: UnsafeCell<T> behind an audited API | one unsafe you own | the general case; what every HAL does |
critical-section / Mutex<RefCell<T>> | mask interrupts around access | shared between main and an ISR |
&raw mut STATIC | all the reasoning, none of the help | linker symbols, one-shot init |
Replacements for static mut. What it shows: four real options above the escape hatch. The insight to take: the top row is the one to reach for first and the one people skip. A microkernel’s scheduler state does not have to be global — it can be a struct the trap handler receives a &mut to, with a single UnsafeCell at the very bottom converting “the trap handler is the only thing running right now” from an argument into a type. That is the same concentrate-the-unsafe move as Peripherals::take(), applied to kernel state instead of hardware.
The critical-section row deserves a note because it is the standard embedded answer to the ISR-versus-main race: acquire a critical section (typically by masking interrupts), access the RefCell inside, release. It is correct and it is what the Embedded Rust Book’s concurrency chapter recommends — but on a kernel’s fast path, masking interrupts to read a variable is a latency cost you may not be willing to pay, and that tension is a real design decision rather than a solved problem.
Failure Modes
| Symptom | Cause | How to confirm |
|---|---|---|
error: #[panic_handler] function required, but not found | no handler in the dependency graph | add one, or link a panic-* crate |
duplicate rust_begin_unwind at link | two panic handlers pulled in transitively | cargo tree for competing panic-* crates |
rust-lld: error: undefined symbol: _stack_top | RUSTFLAGS in the environment discarded the config rustflags | env | grep RUSTFLAGS; move flags to build.rs |
| linker script edit had no effect | Cargo does not track link.x as an input | touch src/main.rs; add cargo:rerun-if-changed |
board silent, readelf -h entry looks right | wrong input section first at the reset address | objdump -d --start-address=<reset vector> |
| initialized global reads as 0 | .data never copied from its LMA | see Linker Scripts and Memory Layout; check _sidata |
| spin loop never exits | plain *ptr read instead of read_volatile | disassemble — is the load inside the loop? |
| corrupted registers after an interrupt | extern "C" fn wired directly to mtvec | the handler ends in ret, not mret |
error: creating a mutable reference to mutable static | edition 2024 static_mut_refs deny | use &raw mut, or restructure the state |
error[E0658]: the extern "riscv-interrupt-m" ABI is experimental | stable compiler | use an asm shim or riscv-rt |
| binary is huge for what it does | core::fmt pulled in by a formatting panic handler | cargo bloat, or nm --size-sort |
The failure catalogue. What it shows: the top half fails loudly at build time; the bottom half fails silently on hardware. The insight to take: the loud ones are Rust doing its job — a missing panic handler is a compile error in Rust and a runtime surprise in C. The silent ones are all at the boundary where Rust’s model stops and the hardware’s begins: linker symbols, volatile, and interrupts. That boundary is exactly where the unsafe blocks are, which is a useful coincidence: the list of places to look when the board misbehaves is the same as the list of places you already marked.
Two of these deserve elaboration. The duplicate panic handler is a real ecosystem papercut: because the requirement is “exactly once in the dependency graph,” adding a driver crate that itself depends on panic-halt breaks a binary that already had a handler. The convention is that libraries must never define one; only the final binary may. Check with cargo tree before blaming the compiler.
The binary is huge case is worth a number. core::fmt is the single largest thing a no_std binary accidentally pulls in, and the usual culprit is a panic handler that formats PanicInfo. On a 64 KiB ROM this can be a double-digit percentage of your budget for a feature you only need during bring-up. #[cfg(debug_assertions)] between a chatty handler and a loop {} one is the standard fix, and it is why the Rustonomicon frames panic behaviour as a linkable crate rather than a function you write inline.
Alternatives and When to Choose Them
C. The incumbent, and the honest comparison is narrower than either camp usually admits. At the very bottom — MMIO, inline assembly, the register save in a trap shim — the two languages produce the same instructions and offer the same guarantees, namely none. C’s advantages are real: every RTOS, vendor HAL and reference driver in existence targets it; volatile is a type qualifier so you cannot forget it on one access out of forty; and there is no borrow checker to argue with when the correct code genuinely does alias. Rust’s advantages are also real and start one layer up: bounds checking, Result instead of error codes, exhaustive match on interrupt causes, ownership for peripherals, and a build system that is not make.
Assembly. Unavoidable for the reset stub, CSR access, and the context switch — roughly a hundred lines in a small kernel. global_asm! and asm! make it pleasant to keep those lines inside the Rust crate rather than in a separate .S file, which means one build system and one place to look.
Zig. The most serious current alternative for this niche: no runtime, comptime instead of macros, first-class cross-compilation, and a volatile pointer qualifier that is closer to C’s ergonomics than Rust’s function-based approach. The trade is a much smaller ecosystem and a language that is still changing.
An existing RTOS or microkernel. Microkernel surveys the field; seL4 is formally verified C, and Zephyr and FreeRTOS are mature C. If the goal is a product, using one of these is almost always right. The goal of definitely-not-esp32 MOC is explicitly not that.
riscv-rt versus your own runtime is covered above and is the choice you will actually face. The short version: write your own to learn, then read riscv-rt and port back what you missed.
The Honest Assessment — What Rust Buys a Microkernel, and What It Costs
What it buys, concretely.
Bounds checking and Option/Result eliminate two of the three classic kernel bug families outright — buffer overruns and null dereferences — in every line that is not inside an unsafe block. In a microkernel that is most lines, because the design deliberately keeps drivers and filesystems out of the kernel. The third family, use-after-free, largely disappears with the heap.
Ownership maps unusually well onto kernel concepts. A capability is a value you have or do not have; a peripheral is a resource exactly one thing may hold; a message in synchronous IPC is moved, not copied. These are ownership, not analogies, and expressing them in the type system means the compiler checks the invariant that a C kernel documents in a comment.
The unsafe keyword is the underrated feature. It does not make the dangerous operations safe; it makes them findable. A code review that must cover every pointer dereference in a C kernel is intractable; a review that must cover the marked minority is a morning’s work. Concentrating unsafety behind a Peripherals::take() or a UnsafeCell-backed kernel struct converts a global invariant into a local one.
Exhaustive match on a trap cause is a small thing that pays repeatedly. Add a new exception code to your enum and every dispatcher that does not handle it fails to compile.
What it costs, concretely.
The interrupt ABI is not stable on rustc 1.98.0 (#111889), so every ISR needs an assembly shim today. That is a real, dated limitation, not a philosophical one — but it is the state of the world as of 2026-09-04.
Mutable global state is genuinely more awkward. static mut is now a hard error to reference in edition 2024, and the replacements each cost something: UnsafeCell costs an audited unsafe, critical-section costs interrupt latency, threading state through costs plumbing. A C kernel writes struct task tasks[N]; and moves on. This is the single largest day-to-day friction.
The ecosystem is small and moves fast. riscv-rt reached 0.18.0; the riscv crate is at 0.16.0. Blog posts rot quickly, and code written against a two-year-old tutorial frequently will not compile — #[no_mangle] becoming #[unsafe(no_mangle)] and extern "C" {} becoming unsafe extern "C" {} in edition 2024 alone will break most published examples.
Debugging is harder in one specific way: name mangling. _RNvCs7v2hgJCvTDI_5dne324main in a disassembly is not readable without rustfilt or objdump -C, and when you are staring at a bare address from a mcause/mepc dump, that friction is real.
And the compile-time guarantees stop at the unsafe boundary, which is where the hardest bugs live anyway. A miswired mtvec, an unaligned trap vector, a .data copy loop with an off-by-one — Rust catches none of these. It catches the hundred lower-stakes bugs above them.
The verdict for this project. For a microkernel whose entire architecture is “keep the trusted computing base small and push everything else out,” Rust’s value proposition lines up almost perfectly: the small trusted core is where you accept the unsafe and the friction, and everything outside it gets the full benefit of the type system for free. The cost is a few hundred lines of assembly and shim code that C would not need, and an ongoing tax on mutable global state. That is a good trade for a kernel and a bad one for a blinking-LED demo — which is a reasonable summary of when to reach for it at all.
See Also
- Linker Scripts and Memory Layout — the
link.xthis crate is built against, and where_sbss/_sidata/_stack_topcome from - Boot ROM and the Reset Vector — what happens before
_start, on the hardware side - The RISC-V Cross-Compilation Toolchain — the
objdump/readelf/nmkit, and why an LLVM target triple differs from a GCC one - The SoC Memory Map — where
0x10000000(UART),0x20000000(ROM) and0x80000000(RAM) come from - Context Switching in a Microkernel — the one place all 31 registers must be saved, and why a trap frame does not
- Microkernel — the architecture this runtime exists to carry
- RISC-V Trap Handling —
mtvec,mepc,mcause, and what the hardware does before your handler runs - Control and Status Registers — the CSRs an ISR shim reads and writes
- Synchronous IPC — where Rust’s move semantics map onto a kernel primitive
- Computer Architecture MOC — the concept hub
- definitely-not-esp32 MOC — this note is Stage 8 of that build ladder