如何用 Rust 编写一个 Linux 内核模块
编者按:近些年来 Rust 语言由于其内存安全性和性能等优势得到了很多关注,尤其是 Linux 内核也在准备将其集成到其中,因此,我们特邀阿里云工程师苏子彬为我们介绍一下如何在 Linux 内核中集成 Rust 支持。
2021 年 4 月 14 号,一封主题名为《Rust support》的邮件出现在 LKML 邮件组中。这封邮件主要介绍了向内核引入 Rust 语言支持的一些看法以及所做的工作。邮件的发送者是 Miguel Ojeda,为内核中 Compiler attributes、.clang-format 等多个模块的维护者,也是目前 Rust for Linux 项目的维护者。
Rust for Linux 项目目前得到了 Google 的大力支持,Miguel Ojeda 当前的全职工作就是负责 Rust for Linux 项目。
长期以来,内核使用 C 语言和汇编语言作为主要的开发语言,部分辅助语言包括 Python、Perl、shell 被用来进行代码生成、打补丁、检查等工作。2016 年 Linux 25 岁生日时,在对 Linus Torvalds 的一篇 采访中,他就曾表示过:
这根本不是一个新现象。我们有过使用 Modula-2 或 Ada 的系统人员,我不得不说 Rust 看起来比这两个灾难要好得多。
我对 Rust 用于操作系统内核并不信服(虽然系统编程不仅限于内核),但同时,毫无疑问,C 有很多局限性。
在最新的对 Rust support 的 RFC 邮件的回复中,他更是说:
所以我对几个个别补丁做了回应,但总体上我不讨厌它。
没有用他特有的回复方式来反击,应该就是暗自喜欢了吧。
目前 Rust for Linux 依然是一个独立于上游的项目,并且主要工作还集中的驱动接口相关的开发上,并非一个完善的项目。
项目地址: https://github.com/Rust-for-Linux/linux
为什么是 Rust
在 Miguel Ojeda 的第一个 RFC 邮件中,他已经提到了 “Why Rust”,简单总结下:
- 在 安全子集 中不存在未定义行为,包括内存安全和数据竞争;
- 更加严格的类型检测系统能够进一步减少逻辑错误;
- 明确区分
safe
和unsafe
代码; - 更加面向未来的语言:
sum
类型、模式匹配、泛型、RAII、生命周期、共享及专属引用、模块与可见性等等; - 可扩展的独立标准库;
- 集成的开箱可用工具:文档生成、代码格式化、linter 等,这些都基于编译器本身。
编译支持 Rust 的内核
根据 Rust for Linux 文档,编译一个包含 Rust 支持的内核需要如下步骤:
- 安装
rustc
编译器。Rust for Linux 不依赖 cargo,但需要最新的 beta 版本的rustc
。使用rustup
命令安装:
1 |
|
- 安装 Rust 标准库的源码。Rust for Linux 会交叉编译 Rust 的
core
库,并将这两个库链接进内核镜像。
1 |
|
- 安装
libclang
库。libclang
被bindgen
用做前端,用来处理 C 代码。libclang
可以从 llvm 官方主页 下载预编译好的版本。 - 安装
bindgen
工具,bindgen
是一个自动将 C 接口转为 RustFFI 接口的库:
1 |
|
- 克隆最新的 Rust for Linux 代码:
1 |
|
- 配置内核启用 Rust 支持:
1 |
|
- 构建:
1 |
|
这里我们使用 clang
作为默认的内核编译器,使用 gcc
理论上是可以的,但还处于 早期实验 阶段。
Rust 是如何集成进内核的
目录结构
为了将 Rust 集成进内核中,开发者首先对 Kbuild 系统进行修改,加入了相关配置项来开启/关闭 Rust 的支持。
此外,为了编译 rs
文件,添加了一些 Makefile
的规则。这些修改分散在内核目录中的不同文件里。
Rust 生成的目标代码中的符号会因为 Mangling
导致其长度超过同样的 C 程序所生成符号的长度,因此,需要对内核的符号长度相关的逻辑进行补丁。开发者引入了 “大内核符号”的概念,用来在保证向前兼容的情况下,支持 Rust 生成的目标文件符号长度。
其他 Rust 相关的代码都被放置在了 rust
目录下。
在 Rust 中使用 C 函数
Rust 提供 FFI( 外部函数接口 )用来支持对 C 代码的调用。Bindgen 是一个 Rust 官方的工具,用来自动化地从 C 函数中生成 Rust 的 FFI 绑定。内核中的 Rust 也使用该工具从原生的内核 C 接口中生成 Rust 的 FFI 绑定。
1 |
|
ABI
Rust 相关的代码会单独从 rs
编译为 .o
,生成的目标文件是标准的 ELF 文件。在链接阶段,内核的链接器将 Rust 生成的目标文件与其他 C 程序生成的目标文件一起链接为内核镜像文件。因此,只要 Rust 生成的目标文件 ABI 与 C 程序的一致,就可以无差别的被链接(当然,被引用的符号还是要存在的)。
Rust 的 alloc
与 core
库
目前 Rust for Linux 依赖于 core
库。在 core
中定义了基本的 Rust 数据结构与语言特性,例如熟悉的 Option<>
和 Result<>
就是 core
库所提供。
这个库被交叉编译后被直接链接进内核镜像文件,这也是导致启用 Rust 的内核镜像文件尺寸较大的原因。在未来的工作中,这两个库会被进一步被优化,去除掉某些无用的部分,例如浮点操作,Unicode 相关的内容,Futures 相关的功能等。
之前的 Rust for Linux 项目还依赖于 Rust 的 alloc
库。Rust for Linux 定义了自己的 GlobalAlloc
用来管理基本的堆内存分配。主要被用来进行堆内存分配,并且使用 GFP_KERNEL
标识作为默认的内存分配模式。
不过在在最新的 拉取请求 中,社区已经将移植并修改了 Rust的 alloc
库,使其能够在尽量保证与 Rust 上游统一的情况下,允许开发者定制自己的内存分配器。不过目前使用自定义的 GFP_
标识来分配内存依然是不支持的,但好消息是这个功能正在开发中。
“Hello World” 内核模块
用一个简单的 Hello World 来展示如何使用 Rust 语言编写驱动代码,hello_world.rs
:
1 |
|
与之对应的 Makefile
:
1 |
|
构建:
1 |
|
之后就和使用普通的内核模块一样,使用 insmod
工具或者 modprobe
工具加载就可以了。在使用体验上是没有区别的。
module! { }
宏
这个宏可以被认为是 Rust 内核模块的入口,因为在其中定义了一个内核模块所需的所有信息,包括:Author
、License
、Description
等。其中最重要的是 type
字段,在其中需要指定内核模块结构的名字。在这个例子中:
1 |
|
module_init()
与 module_exit()
在使用 C 编写的内核模块中,这两个宏定义了模块的入口函数与退出函数。在 Rust 编写的内核模块中,对应的功能由 trait KernelModule
和 trait Drop
来实现。trait KernelModule
中定义 init()
函数,会在模块驱动初始化时被调用;trait Drop
是 Rust 的内置 trait,其中定义的 drop()
函数会在变量生命周期结束时被调用。
编译与链接
所有的内核模块文件会首先被编译成 .o
目标文件,之后由内核链接器将这些 .o
文件和自动生成的模块目标文件 .mod.o
一起链接成为 .ko
文件。这个 .ko
文件符合动态库 ELF 文件格式,能够被内核识别并加载。
其他
完整的介绍 Rust 是如何被集成进内核的文章可以在 我的 Github 上找到,由于写的仓促,可能存在一些不足,还请见谅。
作者:苏子彬,阿里云 PAI 平台开发工程师,主要从事 Linux 系统及驱动的相关开发,曾为 PAI 平台编写 FPGA 加速卡驱动。