内核模块与 no_std

本页目录

#![no_std] 表示该 crate 不自动链接 std,并使用 core 的 prelude。它不等于“没有操作系统”,也不等于“不能分配内存”:一个 no_std 库可以被普通 Linux 程序使用,也可以在提供合适运行支持的裸机环境里使用。

内核模块的难点也不只是去掉 std。它必须遵守内核的资源生命周期、分配上下文、锁规则与构建配置;把普通用户态 API 换个名字,并不能得到正确模块。

core语言基础、迭代器、Option/Result、可用的原子类型alloc(可选)Box、Vec、String 等;最终环境需提供分配支持目标运行环境入口、链接布局、panic 策略、设备与同步抽象no_std 改变库依赖,不自动建立裸机或内核运行环境std 应用也能使用 no_std 库;内核依赖它自己的 API 和构建系统。

core、alloc、std 分别提供什么

层次常见能力依赖条件
coreOption、Result、Iterator、基础类型、格式化接口不依赖堆;部分原子能力受 target 限制
allocBox、Vec、String、Rc、适用目标上的 Arc、BTreeMap需要最终程序的分配支持
std文件、网络、线程等平台接口,以及 core/alloc 的再导出目标平台需有对应实现

HashMap 不在 alloc 中;需要按目标选择额外实现或使用其他集合。format_args! 构造格式化参数,本身不要求分配 String;alloc::format! 才分配字符串。提供 core::fmt::Write 可以把格式化输出写入固定缓冲或设备,不等于必须先实现堆分配。alloc 文档

no_std 也不是一个“保证依赖图完全没有 std”的检查器。某个依赖的默认 feature 仍可能引入 std,需检查目标与 feature 组合;对于 std 根本不可用的目标,相关依赖会在构建阶段暴露出来。Rust crate 规则

no_std 去掉哪一层依赖?

从一个只解析切片的核心开始,逐项增加需求。固定缓冲格式化仍可以留在 core;增加 Vec 才引入 alloc 的责任,使用 std::fs 则需要平台接口。这些是依赖能力的变化,不能用 no_std 一个开关同时解释是否有堆、是否有操作系统和谁提供程序入口。

正在呈现知识画面
no_std 去掉哪一层依赖?

no_std 核心可以使用固定缓冲;Vec 需要 alloc 和最终分配支持,文件接口需要 std 或目标适配。

从不需要运行时的库开始

下面是一个无堆分配的解析函数。调用者提供输入切片,返回解析结果或明确错误:

#![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))
}

保存为 nostd.rs,用 rustc --edition=2024 --crate-type=rlib nostd.rs 编译。这里构建的是可供其他程序链接的库,不需要自己定义 _start、panic_handler 或链接脚本。可以由普通 std 测试程序调用它,验证空输入、短输入和正确大小端。

这种拆分有实际价值:协议解析、状态机和数据处理尽量放在可测试的核心库,设备访问、调度和分配策略放在目标适配层。no_std 是实现这种依赖边界的一种方式。

在 std 宿主上验证 no_std 核心

将前面的文件作为名为 parser 的库编译,另建下面的 check.rs。库仍不依赖 std,测试入口则使用宿主运行环境。

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

最后一个断言明确了这个接口只读前两个字节,允许后面还有输入;如果协议要求报文必须恰好两字节,应在入口另外检查长度。宿主执行验证解析契约,但不能代替目标上的对齐、原子能力、链接和硬件行为检查。

固定缓冲也可以使用格式化接口

下面单独保存为 fixed.rs,是完全不使用 alloc 的库。缓冲区只接受完整 UTF-8 片段;空间不足时当前 write_str 调用保持原值。字段私有,使调用者不能伪造 len 或向已使用区域写入非法编码。

#![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(())
    }
}

宿主程序使用这份库时,可把格式化结果写入固定空间,也可检验容量不足的结果:

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");
}

类似 parser,先以 --crate-name fixed --crate-type=rlib 编译库,再通过 --extern fixed=libfixed.rlib 链接宿主程序。TwoParts 有意分两次写出,说明“一次 write_str 失败不变”不等于“整个 write! 失败不变”:前一个片段已写入,后一个才溢出。

replace_fmt 先写入临时固定缓冲,成功才替换当前值,提供整个输出的提交边界;代价是临时多占 N 字节存储。无堆分配并不意味着内存使用可以忽略,大 N 的局部对象仍会挤压栈空间。这里的 fmt::Error 只表示格式化无法继续;若设备写入需要携带详细 I/O 错误,应额外设计错误保存或接口。core::fmt::Write

alloc 可选,但最终分配责任不会消失

一个使用分配的 no_std 库可以这样写:

#![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)
}

预留空间成功后,复制 u8 不需要进一步扩容。这个函数把容量/分配失败通过 Result 交给调用者,但不证明整个程序所有分配都可恢复。最终可执行环境仍需要满足 alloc 所需的全局分配等链接条件;普通 std 程序通常已经提供。

裸机自定义分配器还必须满足对齐、布局匹配、并发与中断上下文等约定。不能把一个 bump 指针示例当作已可用于任意多线程程序的 GlobalAlloc 实现。

no_main、panic 与硬件启动是另一组决定

#![no_main] 禁用通常的 main 入口安排,不由 no_std 自动隐含。裸机可执行文件通常需要目标启动代码、初始栈、链接布局、数据初始化和中断向量;这些取决于芯片及运行框架,不能用一个空 loop 代表所有启动工作。

最终无 std 程序通常要由某处提供 panic handler,而每个库都自行定义会导致重复。panic handler 可以按平台记录日志、复位或停止,并非天生不能输出;是否支持展开由目标、运行时和构建策略共同决定。Embedded Rust 的 no_std

同样,忙循环不等于低功耗等待,中断是否打开、看门狗是否复位都可能影响它的实际行为。这些属于目标运行环境,应在平台教程中明确。

Rust-for-Linux:以目标内核源码为接口

Linux 6.1 合入了初步 Rust 支持,不代表之后所有架构、配置或发行版都能直接构建任意 Rust 模块。内核 Rust API 持续演进,旧博客中的 kernel::file::Operations 等代码不能当作所有版本的可编译模板。

以 Linux v6.18 的 rust_minimal 为阅读起点:module! 声明模块元信息,Module::init 返回模块状态,Drop 处理退出时的清理;示例使用 KVec,并在 push 时显式传入 GFP_KERNEL、用 ? 传播失败。这比把用户态 Vec、文件操作 trait 与虚构签名拼起来更能反映实际接口。v6.18 最小模块

学习时先在目标内核源码树中核对依赖,再构建该版本自带样例:

# 在已准备好的目标内核源码树执行
make LLVM=1 rustavailable

随后按该树的配置依赖启用 Rust 与相应样例,使用内核构建系统完成构建。rustavailable 检查工具支持,不替代配置与实际编译;Rust、bindgen、LLVM 的要求应按目标版本文档读取,而不是固定写成“随便装一个最新版本”。内核 Rust Quick Start

内核中的所有权还受执行上下文约束

用户态直觉内核中还要追问
分配成功后再使用当前位置是否允许睡眠?分配失败怎样返回?
加 Mutex 即可当前是否处于中断/原子上下文?锁顺序如何保证?
Drop 自动清理对象是否仍被回调、工作队列或设备访问?清理能否阻塞?
一个引用计数就够设备移除、模块卸载与异步执行如何协调?

内核提供自己的分配、同步和错误抽象,不能用 std::sync::Mutex 或用户态线程模型直接代替。unsafe 封装必须把这些额外条件纳入契约,借用检查器不会自行理解硬件仍在 DMA 或某个 C 回调尚未结束。

内核 panic 行为也不能概括成“一定 BUG() 并停止整个内核”。实现随版本与配置变化,异常后的影响取决于具体内核路径;应查看目标版本的 panic handler 与运行策略。无论结果怎样,panic 都不是内核中处理普通可预期失败的替代品。内核 Rust 概览

纯逻辑库长度、溢出、状态不变量目标编译与链接目标 core / ABI / 符号目标环境执行启动、设备、中断、资源退出每层新增条件,也保留上一层的契约逐层验证:宿主通过不会自动补齐目标运行环境内核模块使用目标源码树的构建入口,不自行安装用户态全局分配器或启动代码。

把初始化和退出看成资源依赖关系

设一个设备对象先取得资源,再注册回调,最后允许工作队列使用它。初始化在第二步失败时,第一步的资源应由已建立的拥有者回收;注册成功后再失败,则还需取消注册并按子系统约定等待在途使用结束。退出时的关键不是机械地调用几个 free,而是先停止新增访问,再结束已有访问,最后释放被引用的状态。

借用与引用计数有助于表达软件对象的寿命,但 DMA、硬件中断和 C 保存的 context 不会自动变成 Rust 引用。必须由目标子系统的停机、同步和注销接口建立“最后一次访问已经结束”的事实,再允许 Drop 释放。某些注销会等待或睡眠,这又要求退出发生在允许阻塞的上下文。

检查结果已获得的证据尚未覆盖
no_std rlib 编译成功该库在此目标和 feature 下能编译最终入口、分配器和运行环境
std 宿主边界测试通过测试输入对应的逻辑行为目标硬件、中断与内核上下文
rustavailable 成功目标源码树认可当前 Rust 工具支持所需配置、模块链接和加载
模块完成构建所选源码/配置能生成模块产物设备交互、卸载竞态和压力行为

推荐的学习推进顺序

先把纯数据处理写成可在宿主测试的 no_std 库;再在目标运行框架中接入 panic、日志与分配;最后阅读目标内核版本中已存在的设备或子系统样例,逐项理解其生命周期。这样能区分语言问题、目标运行时问题和内核接口问题,避免一次承担三层未知条件。