用Rust构建Linux文件系统模块:从环境搭建到核心操作实现

用Rust构建Linux文件系统模块:从环境搭建到核心操作实现 用 Rust 写一个 Linux 文件系统模块听起来像是内核黑客的专属领域离普通开发者很远。但如果你正在寻找一个能深入理解 Linux VFS虚拟文件系统和 Rust 安全抽象的项目或者想为嵌入式或特定场景构建一个高性能、内存安全的定制文件系统那这个话题就非常值得一看。它解决的核心问题是能否用 Rust 的安全性和现代语言特性来替代 C 语言编写内核模块的传统高风险模式同时保持甚至提升性能与可靠性。这篇文章不是一篇理论综述而是一个从零开始的实操指南。我会假设你已经有基本的 Rust 和 Linux 内核开发知识然后带你走过从环境搭建、模块骨架编写、到实现核心文件操作如open,read,write的完整路径。最关键的是我会重点解释那些在纯 C 开发中容易被忽略但在 Rust 中必须小心处理的边界情况比如内存所有权、生命周期、错误处理如何与内核的 API 安全对接。你会发现用“几乎纯 Rust”构建意味着核心逻辑用 Rust但不可避免的与内核基础设施的交互点仍需一些 C 或bindgen生成的代码这正是挑战和乐趣所在。1. 环境准备你的 Rust 工具链需要和内核“对齐”在动手写代码之前环境是第一个拦路虎。你需要的不是一个普通的 Rust 开发环境而是一个能与目标 Linux 内核源码协同工作的定制化环境。1.1 内核源码与配置首先你必须获取与你当前运行内核版本一致的内核源代码。这是因为内核模块编译时必须针对特定的内核头文件和数据结构布局。# 查看当前内核版本 uname -r # 例如输出6.5.0-21-generic # 安装对应版本的内核头文件以Ubuntu/Debian为例 sudo apt install linux-headers-$(uname -r) # 内核源码通常位于 /lib/modules/$(uname -r)/build这是一个指向源码目录的符号链接 ls -l /lib/modules/$(uname -r)/build接下来你需要一个基本的.config文件。通常/boot/config-$(uname -r)就是你当前运行内核的配置。直接复制它到你的内核源码目录或上述build链接的目录作为起点。cp /boot/config-$(uname -r) /lib/modules/$(uname -r)/build/.config # 进入内核构建目录准备编译环境 cd /lib/modules/$(uname -r)/build make prepare这一步make prepare会基于.config生成编译模块所需的各种头文件和脚本至关重要。1.2 Rust for Linux 支持Linux 内核从 6.1 版本开始初步支持 Rust 作为第二语言。这意味着内核构建系统包含了 Rust 编译器 (rustc) 和支持库的基本集成。你需要确认两件事内核版本必须是 6.1 或更高。建议使用 6.6 的版本其 Rust 支持更成熟。内核配置必须启用CONFIG_RUST选项。你可以通过检查.config文件来确认grep CONFIG_RUST /lib/modules/$(uname -r)/build/.config # 期望输出CONFIG_RUSTy如果未启用你需要重新配置内核。一个相对安全的方式是使用make menuconfig在General setup-Rust support中启用它。对于初次实验你也可以直接修改.config文件将CONFIG_RUST设为y然后再次执行make prepare。1.3 Rust 工具链与目标你的系统 Rust 工具链通过rustup安装需要安装特定的目标 (target)用于编译内核模块。内核模块本质上是与内核链接的共享对象其目标架构是rustc不直接提供的。Linux 内核源码树中提供了一个脚本来设置正确的环境。关键是要使用内核源码树中的rustc版本要求而不是你系统上最新的 nightly。# 进入你的内核源码目录 cd /path/to/your/kernel/source # 使用内核提供的脚本安装正确的 Rust 工具链和目标 # 这通常会安装一个特定的 nightly 版本和必要的组件 make rustavailable # 或者手动查看所需版本 make rustversion执行后它会提示你所需的rustc版本和target。你需要用rustup安装对应的 nightly 版本和target。例如rustup install nightly-2024-03-01 rustup default nightly-2024-03-01 rustup component add rust-src # 添加内核模块目标例如对于 x86_64 rustup target add x86_64-unknown-none这里x86_64-unknown-none是一个“裸机”目标没有标准库 (std)这正是内核模块所需要的因为我们不能依赖用户空间的运行时。2. 项目骨架从 Makefile 到第一个“Hello World”模块环境就绪后我们开始创建项目。一个 Rust 内核模块项目结构与普通的 Rust 库项目不同它紧密依赖内核的 Kbuild 系统。2.1 创建项目目录与文件假设我们的文件系统模块叫myfs。mkdir myfs cd myfs创建以下核心文件Cargo.toml: 定义 Rust 包和依赖。src/lib.rs: Rust 模块的主入口。Makefile: 连接内核构建系统的桥梁。bindings.h(可选): 用于bindgen生成 Rust 绑定的 C 头文件。我们先看Cargo.toml。由于内核模块不能使用std我们必须使用#![no_std]并依赖core和alloc如果需要堆分配。更重要的是我们需要依赖内核的 Rust 辅助库 (kernelcrate)。[package] name myfs version 0.1.0 edition 2021 [lib] crate-type [rlib] # 编译为 Rust 静态库供内核链接 [dependencies] kernel { path /path/to/your/kernel/source/rust } # 指向内核源码内的 rust 目录注意kernelcrate 的路径必须指向你的内核源码树中的rust目录。这是内核提供的、包含安全包装的内核API的Rust库。2.2 实现最小的内核模块在src/lib.rs中我们从一个最简单的模块开始它只实现模块的初始化和退出函数。//! MyFS - A simple filesystem module in Rust. #![no_std] // 禁用标准库 #![feature(allocator_api, global_asm)] // 启用一些不稳定特性内核开发常用 extern crate alloc; // 启用 alloc 库用于堆分配如 Vec, String use kernel::prelude::*; // 导入内核常用宏和类型如 module!, pr_info! module! { type: MyFsModule, // 模块类型对应下面的结构体 name: myfs, // 模块名称 author: Your Name your.emailexample.com, description: A sample filesystem in Rust, license: GPL, // 必须使用 GPL 兼容协议 } struct MyFsModule; impl kernel::Module for MyFsModule { fn init(_name: static CStr, _module: static ThisModule) - ResultSelf { pr_info!(MyFS filesystem module loaded\n); Ok(MyFsModule) } } impl Drop for MyFsModule { fn drop(mut self) { pr_info!(MyFS filesystem module unloaded\n); } }这个模块现在什么文件系统功能都没有但它能正确加载和卸载。kernel::prelude::*引入了module!宏它负责生成符合内核要求的模块描述符。impl kernel::Module是必须的其中的init函数是模块的入口点。2.3 编写集成 Makefile这是最关键的一步它告诉内核的构建系统如何编译我们的 Rust 代码。Makefile内容如下# 指向内核源码顶层目录 KERNEL_DIR ? /lib/modules/$(shell uname -r)/build # 模块最终名称 obj-m myfs.o # 告诉 Kbuildmyfs.o 由我们的 Rust 静态库生成 myfs-objs : ./target/x86_64-unknown-none/release/libmyfs.a # 获取内核的 Rust 配置 include $(KERNEL_DIR)/rust/config.mk all: # 首先用 Cargo 编译 Rust 代码为静态库 # RUSTFLAGS 和环境变量由内核的 config.mk 提供确保与内核ABI兼容 cargo build --target x86_64-unknown-none --release $(RUST_FLAGS) \ -Z build-stdcore,alloc -Z build-std-featurescompiler-builtins-mem # 然后调用内核的 Makefile 来链接模块 $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean cargo clean .PHONY: all clean重点解释obj-m是内核构建系统的标准变量表示要构建一个模块。myfs-objs指定了构成myfs.ko的目标文件。这里我们直接指向 Cargo 生成的静态库 (libmyfs.a)。include $(KERNEL_DIR)/rust/config.mk引入了内核定义的 Rust 编译标志和环境变量如RUST_FLAGS这对于确保与内核的 ABI 兼容性至关重要。cargo build命令中的-Z build-stdcore,alloc是必须的因为我们为目标平台重新编译了core和alloc库。-Z build-std-featurescompiler-builtins-mem确保内存相关的内置函数可用。2.4 编译与加载测试现在在项目根目录执行make。如果一切顺利你会看到 Cargo 编译和内核 Kbuild 编译的过程最终生成myfs.ko。sudo insmod myfs.ko # 加载模块 sudo rmmod myfs # 卸载模块 dmesg | tail -5 # 查看内核日志应该能看到我们的加载和卸载信息如果看到MyFS filesystem module loaded和MyFS filesystem module unloaded恭喜你你的第一个 Rust 内核模块已经成功运行在内核空间了这证明了你的工具链、环境和项目骨架是完全正确的。这是后续所有复杂文件系统操作的基础。3. 定义文件系统挂载与超级块一个文件系统模块的核心是向内核注册自己并管理超级块 (super_block)。在 Rust 中我们需要用内核提供的安全抽象来包装这些 C 结构体。3.1 注册文件系统类型在 Linux 中每个文件系统类型如 ext4, btrfs都有一个file_system_type结构体。我们需要创建并注册它。首先需要在 Rust 中绑定这个结构体。虽然kernelcrate 可能已经提供了一些绑定但对于较新的内核或特定字段我们可能需要使用bindgen生成更完整的绑定。为了简化我们假设使用内核kernelcrate 提供的包装。在实际操作中你可能需要查看kernel::fs模块下提供了哪些类型。我们在src/lib.rs中新增代码。首先引入必要的模块use kernel::{fs, str::CString, c_str, error::Result};然后定义我们的文件系统类型结构体#[repr(C)] // 确保内存布局与C兼容 struct MyFsType { inner: fs::FileSystemType, } impl MyFsType { fn new() - ResultSelf { // 创建一个文件系统类型。关键参数名称、挂载回调函数、所有者模块。 let fs_type fs::registration::FileSystemType::builder() .name(c_str!(myfs))? .mount_callback(Self::mount)? .owner(THIS_MODULE)? .build()?; Ok(Self { inner: fs_type }) } // 挂载回调函数。当用户执行 mount -t myfs ... 时被调用。 extern C fn mount( fs_type: *mut bindings::file_system_type, flags: core::ffi::c_int, dev_name: *const core::ffi::c_char, data: *mut core::c_void, ) - *mut bindings::super_block { // 这个函数需要返回一个 super_block 指针。 // 这里我们简化处理先打印信息并返回错误。 pr_info!(MyFS mount attempted. Not fully implemented yet.\n); core::ptr::null_mut() // 返回空指针表示挂载失败 } }注意上面的mount函数是一个简化版。在实际的文件系统中你需要在这里解析挂载选项、读取设备如果是块设备、分配并初始化一个super_block结构体并设置其s_op超级块操作等。返回null_mut()会导致挂载失败。3.2 在模块初始化中注册我们需要在模块初始化时创建并注册这个文件系统类型并在模块退出时注销它。修改之前的MyFsModule结构体struct MyFsModule { fs_type: OptionMyFsType, // 使用 Option 以便在 drop 时安全注销 } impl kernel::Module for MyFsModule { fn init(_name: static CStr, _module: static ThisModule) - ResultSelf { pr_info!(MyFS filesystem module loaded\n); let fs_type MyFsType::new()?; // 注册文件系统类型 fs_type.inner.register()?; pr_info!(MyFS filesystem type registered\n); Ok(Self { fs_type: Some(fs_type) }) } } impl Drop for MyFsModule { fn drop(mut self) { if let Some(fs_type) self.fs_type.take() { // 注销文件系统类型 fs_type.inner.unregister(); pr_info!(MyFS filesystem type unregistered\n); } pr_info!(MyFS filesystem module unloaded\n); } }现在重新编译并加载模块。执行sudo insmod myfs.ko后你可以通过cat /proc/filesystems查看已注册的文件系统列表应该能看到myfs。这是一个重要的里程碑它意味着内核已经识别了你的文件系统类型。虽然还不能挂载但注册成功了。3.3 实现超级块操作要让挂载真正工作必须实现超级块操作 (super_operations)。这组函数定义了文件系统如何管理 inode、文件、目录等元数据。在 Rust 中我们需要定义一个结构体来实现SuperOperationstrait如果kernelcrate 提供了的话或者手动构建一个bindings::super_operations结构体。我们以手动构建为例展示如何将 Rust 函数挂接到 C 回调指针上。首先定义超级块操作结构体use core::mem::MaybeUninit; use kernel::bindings; static MYFS_SUPER_OPS: bindings::super_operations bindings::super_operations { alloc_inode: Some(myfs_alloc_inode), destroy_inode: Some(myfs_destroy_inode), // .write_inode, .evict_inode, .statfs 等其他操作可以先设为 NULL ..unsafe { MaybeUninit::bindings::super_operations::zeroed().assume_init() } };这里我们只设置了alloc_inode和destroy_inode两个最基本的操作。alloc_inode在需要为文件或目录创建新的 inode 时被调用destroy_inode在 inode 被释放时调用。其他字段用MaybeUninit::zeroed()初始化为零即 NULL。然后实现这两个回调函数。这些函数必须是extern C的并且符合内核的调用约定。extern C fn myfs_alloc_inode(sb: *mut bindings::super_block) - *mut bindings::inode { // 分配一个 inode 结构体。 // 在实际实现中你需要分配内存并初始化一个 myfs_inode_info包含内核 inode 和你自己的私有数据。 // 这里返回空指针表示失败。 pr_info!(myfs_alloc_inode called\n); core::ptr::null_mut() } extern C fn myfs_destroy_inode(inode: *mut bindings::inode) { // 释放 inode 占用的资源。 pr_info!(myfs_destroy_inode called\n); // 安全地释放 myfs_inode_info 等私有数据。 }最后在mount回调函数中你需要分配一个super_block并将其s_op字段设置为MYFS_SUPER_OPS。这涉及到调用内核的sget等函数在 Rust 中需要调用对应的 C 函数。这通常通过kernel::bindings中生成的原始函数来完成需要非常小心地处理内存安全和生命周期。由于这一步涉及大量不安全的原始指针操作并且严重依赖内核内部 API它通常是 Rust 文件系统模块中最复杂、最“不纯”的部分。你可能需要大量使用unsafe块并仔细研究内核源码来确保正确性。这也是为什么标题强调“几乎纯 Rust”——与内核核心数据结构交互的边界层目前还无法完全避免底层操作。4. 实现文件操作从 VFS 到 Rust 结构体注册了文件系统并设置了超级块操作后用户空间对文件的操作如open,read,write才会通过 VFS 层路由到你的模块。你需要为 inode 实现文件操作 (file_operations) 和目录操作 (inode_operations)。4.1 定义 inode 私有数据通常文件系统会在内核的inode结构体中添加自己的私有数据例如 ext4 的ext4_inode_info。在 Rust 中我们可以定义一个结构体来保存这些数据并安全地管理其生命周期。use kernel::sync::Arc; // 内核提供的引用计数智能指针 struct MyFsInode { // 这里可以存放你的文件系统特定的数据 // 例如文件大小、块列表、权限、时间戳等。 size: u64, blocks: Vecu64, // 假设存储文件数据块的编号 // 注意Vec 是堆分配的需要 alloc。 } impl MyFsInode { fn new() - Self { Self { size: 0, blocks: Vec::new(), } } }然后你需要一种方式将MyFsInode与内核的struct inode关联起来。一种常见模式是使用inode-i_private字段。在alloc_inode函数中你分配MyFsInode然后将其指针存储到inode-i_private。在需要时再从inode中取出。4.2 实现文件操作 (file_operations)文件操作结构体定义了open,read,write,llseek,release等函数。在 Rust 中我们同样需要构建一个静态的bindings::file_operations。use core::ffi::c_long; use kernel::file; use kernel::file::File; static MYFS_FILE_OPS: bindings::file_operations bindings::file_operations { open: Some(myfs_file_open), read: Some(myfs_file_read), write: Some(myfs_file_write), llseek: Some(myfs_file_llseek), release: Some(myfs_file_release), ..unsafe { MaybeUninit::bindings::file_operations::zeroed().assume_init() } }; extern C fn myfs_file_open(inode: *mut bindings::inode, filp: *mut bindings::file) - c_long { // 打开文件时调用。可以在这里进行权限检查、初始化 file-private_data 等。 // 返回 0 表示成功负数表示错误码。 pr_info!(myfs_file_open called\n); 0 } extern C fn myfs_file_read( filp: *mut bindings::file, buf: *mut core::ffi::c_char, count: usize, ppos: *mut bindings::loff_t, ) - isize { // 从文件读取数据。 // filp: 文件对象 // buf: 用户空间缓冲区需要 copy_to_user // count: 请求读取的字节数 // ppos: 指向当前文件偏移量的指针 pr_info!(myfs_file_read called, count{}\n, count); // 简化返回 0 表示 EOF 0 } extern C fn myfs_file_write( filp: *mut bindings::file, buf: *const core::ffi::c_char, count: usize, ppos: *mut bindings::loff_t, ) - isize { // 向文件写入数据。 // buf: 用户空间缓冲区需要 copy_from_user pr_info!(myfs_file_write called, count{}\n, count); // 简化返回写入的字节数 count as isize } // 实现 llseek, release 等...关键点read和write操作中的buf指针指向用户空间内存。绝对不能直接解引用必须使用内核提供的copy_from_user和copy_to_user函数来安全地拷贝数据。在 Rust 中你需要通过kernel::bindings调用这些 C 函数。返回值成功时返回读取/写入的字节数失败时返回负的错误码如-EINVAL。4.3 将操作关联到 inode最后你需要在创建文件或目录的 inode 时设置其默认的文件操作。这通常在alloc_inode或一个专门的inode初始化函数中完成。extern C fn myfs_alloc_inode(sb: *mut bindings::super_block) - *mut bindings::inode { // 调用内核函数分配一个 inode 结构体 let inode unsafe { bindings::new_inode(sb) }; if inode.is_null() { return core::ptr::null_mut(); } // 初始化 inode 的基本信息 unsafe { (*inode).i_mode 0o755 | bindings::S_IFREG; // 普通文件 (*inode).i_op MYFS_INODE_OPS as *const _ as *mut _; // 假设定义了 inode 操作 (*inode).i_fop MYFS_FILE_OPS as *const _ as *mut _; // 设置文件操作 (*inode).i_private /* 将 MyFsInode 对象转换为指针并存储 */; } inode }至此一个极其简化但结构完整的 Rust 文件系统模块骨架就搭建起来了。它能被内核加载、注册文件系统类型并能响应简单的文件打开、读、写操作虽然实际数据操作还未实现。你已经走完了从用户空间mount命令到内核模块回调函数的整个路径。5. 数据持久化与测试从内存到“磁盘”一个玩具文件系统可以只在内存中运行但一个实用的文件系统需要将数据持久化到存储介质如块设备、文件镜像。这涉及到更复杂的块设备 I/O、缓存和磁盘布局管理。5.1 选择后端存储对于实验和学习最简单的方法是使用一个文件作为虚拟块设备loop device。你可以创建一个固定大小的文件用mkfs之类的工具当然不是真的格式化只是填充初始结构初始化它然后将其作为块设备挂载。# 创建一个 100MB 的文件作为“磁盘” dd if/dev/zero ofmyfs.img bs1M count100 # 将其关联到一个循环设备 sudo losetup -fP myfs.img # 查看分配的循环设备例如 /dev/loop0 losetup -a在你的文件系统mount函数中dev_name参数可能就是/dev/loop0。你需要解析这个设备名打开它并从中读取超级块信息。5.2 实现磁盘布局与 I/O你需要设计磁盘上的数据结构。一个最简单的布局可能包括超级块位于磁盘开头包含魔数、块大小、inode 总数、空闲块位图位置等信息。inode 位图标记哪些 inode 已使用。数据块位图标记哪些数据块已使用。inode 表一个连续的 inode 数组。数据块区域存储文件实际内容。在 Rust 中你需要为这些结构定义对应的repr(C)结构体以确保磁盘和内存布局一致。#[repr(C, packed)] // packed 确保没有内存对齐的填充字节 struct MyFsSuperblock { magic: u32, block_size: u32, inode_count: u64, free_blocks_count: u64, // ... 其他字段 }I/O 操作需要通过内核的块设备层。你需要获取struct block_device然后使用bioBlock I/O请求来读写扇区。在 Rust 中这涉及到调用非常底层的 C 函数是另一个“不纯”的边界。一个更简单的实验方法是在用户空间将整个磁盘镜像文件映射到内核内存例如使用vmalloc然后直接操作内存。但这只适用于小镜像且失去了持久化的意义。5.3 测试策略测试内核模块尤其是文件系统需要谨慎。模块加载/卸载测试最基本确保没有内存泄漏。挂载/卸载测试mount -t myfs /dev/loop0 /mnt/myfs和umount /mnt/myfs。基本文件操作在挂载点内touch,echo,cat,ls,rm文件。并发与压力测试使用fsstress等工具进行模糊测试。崩溃恢复测试在写入过程中强制断电模拟然后重新挂载检查一致性。强烈建议在虚拟机中进行测试因为一个有 bug 的文件系统模块很容易导致内核崩溃panic。5.4 调试与日志pr_info!,pr_err!,pr_debug!是你的好朋友。可以在关键函数入口、错误路径添加日志。也可以使用dump_stack()来打印调用栈。Rust 的Result类型可以很好地与内核的错误码Error::from_errno结合提供更安全的错误传播。6. 安全、性能与生产化考量用 Rust 写内核模块安全是首要卖点但并非一劳永逸。6.1 安全边界unsafe的使用与内核 C API 交互必然使用unsafe。目标是将其限制在最小的、边界清晰的模块内例如一个raw_bindings模块核心逻辑如磁盘布局解析、缓存管理用安全的 Rust 编写。内存管理内核没有Box和Vec的默认全局分配器。你需要使用内核提供的分配器如kernel::prelude::Box::try_new或kernel::vec::Vec::try_with_capacity它们返回Result处理分配失败。并发控制文件系统操作天生是并发的。你需要使用内核提供的同步原语如Mutex,SpinLock,RwLock在kernel::sync中。Rust 的所有权系统可以帮助防止数据竞争但锁的使用仍需仔细设计。6.2 性能考量缓存实现 inode 缓存 (icache) 和页缓存是提升性能的关键。你可以利用内核的address_space机制。减少拷贝在read/write路径中尽量让数据在内核缓冲区如struct page和用户缓冲区之间直接传递而不是通过中间拷贝。异步 I/O对于支持异步 I/O 的文件系统需要实现-read_iter和-write_iter操作并可能涉及kiocb和iov_iter。6.3 从“玩具”到“可用”要让这个模块变得可用你还需要实现目录操作lookup,mkdir,rmdir,rename。符号链接与硬链接。扩展属性 (xattr)。文件锁。内存映射 (mmap)。每一个都是一项不小的工程。这也是为什么完整的、生产级的 Rust 文件系统如rustfs示例仍然处于探索阶段。7. 总结与下一步用“几乎纯 Rust”构建 Linux 文件系统模块目前仍然是一个前沿的、富有挑战性的领域。它带来的主要价值是在文件系统核心逻辑这一复杂且易错的层面利用 Rust 的内存安全和并发安全特性大幅减少 use-after-free、data race 等常见内核漏洞。然而与内核 VFS、块设备层、内存管理子系统交互的边界层仍然需要大量unsafe代码和对内核内部机制的深刻理解。对于想要尝试的开发者我的建议是从最小的例子开始不要一开始就想实现 ext4。先实现一个内存中的、只读的“玩具”文件系统比如把所有文件内容都硬编码在代码里。深入阅读内核文档特别是Documentation/filesystems/vfs.rst和 Rust for Linux 的文档 (Documentation/rust/)。借鉴现有示例Linux 内核源码中的samples/rust/目录下有一些简单的示例。rust目录下也有kernel/fs/的雏形。测试驱动为每个新功能编写对应的用户空间测试程序。使用libc的mount,open,read等系统调用进行测试。关注社区进展Linux 内核的 Rust 支持在快速演进API 在不断改进和稳定。关注邮件列表和补丁集。这条路并不轻松但它可能是未来高性能、高可靠内核模块开发的重要方向。每一次成功的mount和read都是对 Rust 系统编程能力和 Linux 内核理解的深度验证。