Kernel Modules and no_std

On this page

#![no_std] indicates that the crate does not automatically link to std and uses the core prelude. It does not mean "no operating system," nor does it mean "cannot allocate memory": a no_std library can be used by standard Linux programs or in bare-metal environments that provide appropriate runtime support.

The difficulty of kernel modules lies not just in removing std. They must adhere to the kernel's resource lifecycle, allocation contexts, locking rules, and build configurations; simply renaming user-space APIs does not yield a correct module.

coreLanguage basics, iterators, Option/Result, available atomic typesalloc (optional)Box, Vec, String, etc.; the final environment must provide allocation supportTarget Runtime Environmentno_std changes library dependencies, but does not automatically establish a bare-metal or kernel runtime environmentEntry point, link layout, panic strategy, device and synchronization abstractionsstd applications can also use no_std libraries; kernels rely on their own APIs and build systems.

What core, alloc, and std Provide

LayerCommon CapabilitiesDependencies
coreOption, Result, Iterator, primitive types, formatting interfacesNo heap dependency; some atomic capabilities are target-limited
allocBox, Vec, String, Rc, Arc (on suitable targets), BTreeMapRequires allocation support from the final program
stdPlatform interfaces like files, network, threads, and re-exports of core/allocTarget platform must have corresponding implementations

HashMap is not in alloc; you need to choose additional implementations based on the target or use other collections. format_args! constructs formatting arguments and does not require allocating a String; only alloc::format! allocates a string. Providing core::fmt::Write allows writing formatted output to a fixed buffer or device, which does not imply that heap allocation must be implemented first. alloc documentation

no_std is also not a checker that "guarantees the dependency graph has absolutely no std." A dependency's default features might still introduce std; you need to check the target and feature combinations. For targets where std is fundamentally unavailable, relevant dependencies will be exposed during the build phase. Rust crate rules

Which dependency layer does no_std remove?

Start with slice parsing and add requirements one at a time. Fixed-buffer formatting can stay in core; Vec introduces allocation responsibility; std::fs requires platform APIs. These are distinct dependency choices: no_std alone does not determine heap availability, operating system presence or program entry.

Preparing the visual
Which dependency layer does no_std remove?

A no_std core can use fixed buffers; Vec needs alloc and allocation support, while files need std or a target adapter.

Start with Libraries That Don't Need a Runtime

The following is a heap-free parsing function. The caller provides an input slice, and the function returns the parsed result or a clear error:

#![no_std]

pub fn read_be_u16(input: &[u8]) -> Option<u16> {
    let bytes: [u8; 2] = input.get(..2)?.try_into().ok()?;
    Some(u16::from_be_bytes(bytes))
}

Save this as nostd.rs and compile with rustc --edition=2024 --crate-type=rlib nostd.rs. This builds a library linkable by other programs, without needing to define _start, panic_handler, or link scripts. It can be called by a standard std test program to verify empty input, short input, and correct big-endian behavior.

This separation has practical value: protocol parsing, state machines, and data processing should be placed in testable core libraries, while device access, scheduling, and allocation strategies belong in the target adaptation layer. no_std is one way to enforce this dependency boundary.

Verifying no_std Core on an std Host

Compile the previous file as a library named parser, and create the following check.rs separately. The library still does not depend on std, but the test entry point uses the host runtime environment.

fn main() {
    assert_eq!(parser::read_be_u16(&[]), None);
    assert_eq!(parser::read_be_u16(&[0x12]), None);
    assert_eq!(parser::read_be_u16(&[0x12, 0x34]), Some(0x1234));
    assert_eq!(parser::read_be_u16(&[0x12, 0x34, 0xff]), Some(0x1234));
}
rustc --edition=2024 --crate-name parser --crate-type=rlib nostd.rs -o libparser.rlib
rustc --edition=2024 check.rs --extern parser=libparser.rlib -o check
./check

The last assertion clarifies that this interface only reads the first two bytes, allowing additional input afterwards; if the protocol requires packets to be exactly two bytes, length should be checked separately at the entry point. Host execution verifies parsing contracts but cannot replace checks for alignment, atomic capabilities, linking, and hardware behavior on the target.

Fixed Buffers Can Also Use Formatting Interfaces

The following, saved separately as fixed.rs, is a library that uses absolutely no alloc. The buffer only accepts complete UTF-8 fragments; if space is insufficient, the current write_str call leaves the value unchanged. Fields are private, preventing callers from forging len or writing invalid encodings into already-used regions.

#![no_std]
use core::fmt::{self, Write};

pub struct FixedText<const N: usize> {
    bytes: [u8; N],
    len: usize,
}
impl<const N: usize> FixedText<N> {
    pub fn new() -> Self { Self { bytes: [0; N], len: 0 } }
    pub fn as_str(&self) -> &str {
        core::str::from_utf8(&self.bytes[..self.len]).expect("UTF-8 invariant")
    }
    pub fn replace_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result {
        let mut next = Self::new();
        next.write_fmt(args)?;
        *self = next;
        Ok(())
    }
}
impl<const N: usize> Write for FixedText<N> {
    fn write_str(&mut self, text: &str) -> fmt::Result {
        let end = self.len.checked_add(text.len()).ok_or(fmt::Error)?;
        let destination = self.bytes.get_mut(self.len..end).ok_or(fmt::Error)?;
        destination.copy_from_slice(text.as_bytes());
        self.len = end;
        Ok(())
    }
}

When a host program uses this library, it can write formatting results into fixed space or verify results when capacity is insufficient:

use core::fmt::{self, Write};
use fixed::FixedText;

struct TwoParts;
impl fmt::Display for TwoParts {
    fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
        out.write_str("ok")?;
        out.write_str("toolong")
    }
}
fn main() {
    let mut text = FixedText::<8>::new();
    write!(&mut text, "n={}", 42).unwrap();
    assert_eq!(text.as_str(), "n=42");
    assert!(text.write_str("中中").is_err());
    assert_eq!(text.as_str(), "n=42");

    let mut partial = FixedText::<4>::new();
    assert!(write!(&mut partial, "{TwoParts}").is_err());
    assert_eq!(partial.as_str(), "ok");

    let mut committed = FixedText::<4>::new();
    committed.write_str("old").unwrap();
    assert!(committed.replace_fmt(format_args!("{TwoParts}")).is_err());
    assert_eq!(committed.as_str(), "old");
}

Similar to parser, first compile the library with --crate-name fixed --crate-type=rlib, then link the host program via --extern fixed=libfixed.rlib. TwoParts intentionally writes in two parts to demonstrate that "one write_str failure leaves everything unchanged" does not equal "the entire write! failure leaves everything unchanged": the first fragment was already written, and the second caused an overflow.

replace_fmt first writes to a temporary fixed buffer and only replaces the current value upon success, providing a commit boundary for the entire output; the cost is temporarily occupying N bytes of storage. No heap allocation does not mean memory usage can be ignored; large local objects with large N will still squeeze stack space. Here, fmt::Error only indicates that formatting cannot continue; if device writing needs to carry detailed I/O errors, additional error saving or interfaces should be designed. core::fmt::Write

alloc is Optional, but Final Allocation Responsibility Does Not Disappear

A no_std library that uses allocation can be written like this:

#![no_std]
extern crate alloc;

use alloc::vec::Vec;

pub fn copy_bytes(input: &[u8])
    -> Result<Vec<u8>, alloc::collections::TryReserveError>
{
    let mut out = Vec::new();
    out.try_reserve_exact(input.len())?;
    out.extend_from_slice(input);
    Ok(out)
}

After successfully reserving space, copying u8 does not require further expansion. This function passes capacity/allocation failures to the caller via Result, but it does not prove that all allocations in the entire program are recoverable. The final executable environment still needs to satisfy the global allocation and other link conditions required by alloc; standard std programs usually already provide this.

Bare-metal custom allocators must also satisfy conventions regarding alignment, layout matching, concurrency, and interrupt contexts. You cannot treat a bump pointer example as a GlobalAlloc implementation ready for use in any multi-threaded program.

no_main, panic, and Hardware Startup Are Another Set of Decisions

#![no_main] disables the usual main entry arrangement and is not automatically implied by no_std. Bare-metal executables usually require target startup code, initial stack, link layout, data initialization, and interrupt vectors; these depend on the chip and runtime framework and cannot be represented by an empty loop for all startup work.

Final no-std programs usually require a panic handler provided from somewhere, and having each library define its own leads to duplication. A panic handler can log, reset, or halt depending on the platform; it is not inherently unable to output; whether unwinding is supported is determined by the target, runtime, and build strategy. Embedded Rust's no_std

Similarly, a busy loop does not equal low-power waiting; whether interrupts are enabled or the watchdog resets can affect its actual behavior. These belong to the target runtime environment and should be clarified in platform tutorials.

Rust-for-Linux: Using Target Kernel Source as Interface

Linux 6.1 merged initial Rust support, but this does not mean all subsequent architectures, configurations, or distributions can directly build arbitrary Rust modules. The kernel Rust API continues to evolve; code like kernel::file::Operations from old blogs cannot be treated as compilable templates for all versions.

Use Linux v6.18's rust_minimal as a starting point for reading: module! declares module metadata, Module::init returns module status, and Drop handles cleanup on exit; the example uses KVec and explicitly passes GFP_KERNEL on push, propagating failures with ?. This reflects actual interfaces better than piecing together user-space Vec, file operation traits, and fictional signatures. v6.18 minimal module

When learning, first verify dependencies in the target kernel source tree, then build the sample included in that version:

# Execute within the prepared target kernel source tree
make LLVM=1 rustavailable

Subsequently, enable Rust and corresponding samples according to the tree's configuration dependencies, and complete the build using the kernel build system. The rustavailable check tool supports verification but does not replace configuration and actual compilation; requirements for Rust, bindgen, and LLVM should be read from the target version documentation, rather than fixed as "just install any latest version." Kernel Rust Quick Start

Ownership in the Kernel is Also Constrained by Execution Context

User-space IntuitionQuestions to Ask in the Kernel
Use after successful allocationIs sleeping allowed at the current location? How to return on allocation failure?
Just lock with MutexAre you currently in an interrupt/atomic context? How is lock ordering guaranteed?
Drop automatically cleans upIs the object still referenced by callbacks, workqueues, or device access? Can cleanup block?
One reference count is enoughHow to coordinate device removal, module unloading, and asynchronous execution?

The kernel provides its own allocation, synchronization, and error abstractions; you cannot directly replace them with std::sync::Mutex or user-space thread models. unsafe encapsulations must incorporate these additional conditions into the contract; the borrow checker does not automatically understand that hardware is still performing DMA or that a C callback has not yet finished.

Kernel panic behavior cannot be generalized as "always BUG() and stop the entire kernel." Implementations vary by version and configuration; the impact after an exception depends on the specific kernel path; you should check the target version's panic handler and runtime strategy. Regardless of the outcome, panic is not a substitute for handling ordinary, expected failures in the kernel. Kernel Rust Overview

Pure Logic LibraryLength, overflow, state invariantsTarget compile and linkTarget core / ABI / symbolsRun on the targetStartup, devices and teardownEach layer adds conditions, while preserving the contracts of the previous layerLayered Verification: Passing on the host does not automatically fill in the target runtime environmentKernel modules use the build entry of the target source tree, and do not install user-space global allocators or startup code themselves.

Treat Initialization and Exit as Resource Dependencies

Consider a device object that first acquires resources, then registers callbacks, and finally allows workqueues to use it. If initialization fails at the second step, resources acquired in the first step should be reclaimed by the established owner; if it fails after successful registration, unregistration is also required, and waiting for in-flight usage to end according to subsystem conventions is necessary. The key during exit is not mechanically calling a few free functions, but first stopping new access, then ending existing access, and finally releasing referenced state.

Borrowing and reference counts help express the lifetime of software objects, but DMA, hardware interrupts, and C-saved contexts do not automatically become Rust references. The "last access has ended" fact must be established by the target subsystem's shutdown, synchronization, and unregistration interfaces before allowing Drop to release. Some unregistrations wait or sleep, which again requires exit to occur in a context that allows blocking.

Check ResultEvidence ObtainedNot Yet Covered
no_std rlib compiles successfullyThe library can compile under this target and feature setFinal entry point, allocator, and runtime environment
std host boundary tests passLogical behavior corresponding to test inputsTarget hardware, interrupts, and kernel context
rustavailable succeedsTarget source tree recognizes current Rust tool supportRequired configuration, module linking, and loading
Module build completesSelected source/config can generate module artifactsDevice interaction, unload races, and stress behavior

First, write pure data processing as a no_std library that can be tested on the host; then integrate panic, logging, and allocation in the target runtime framework; finally, read existing device or subsystem samples in the target kernel version, understanding their lifecycle item by item. This distinguishes language issues, target runtime issues, and kernel interface issues, avoiding taking on three layers of unknown conditions at once.