编者按:近些年来 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
命令安装:rustup default beta-2021-06-23
-
安装 Rust 标准库的源码。Rust for Linux 会交叉编译 Rust 的
core
库,并将这两个库链接进内核镜像。rustup component add rust-src
-
安装
libclang
库。libclang
被bindgen
用做前端,用来处理 C 代码。libclang
可以从 llvm 官方主页 下载预编译好的版本。 -
安装
bindgen
工具,bindgen
是一个自动将 C 接口转为 RustFFI 接口的库:cargo install --locked --version 0.56.0 bindgen
-
克隆最新的 Rust for Linux 代码:
git clone https://github.com/Rust-for-Linux/linux.git
-
配置内核启用 Rust 支持:
Kernel hacking
-> Sample kernel code
-> Rust samples
-
构建:
LIBCLANG_PATH=/path/to/libclang make -j LLVM=1 bzImage
这里我们使用
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 绑定。
quiet_cmd_bindgen = BINDGEN $@
cmd_bindgen = \
$(BINDGEN) $< $(shell grep -v '^\#\|^$$' $(srctree)/rust/bindgen_parameters) \
--use-core --with-derive-default --ctypes-prefix c_types \
--no-debug '.*' \
--size_t-is-usize -o $@ -- $(bindgen_c_flags_final) -DMODULE
$(objtree)/rust/bindings_generated.rs: $(srctree)/rust/kernel/bindings_helper.h \
$(srctree)/rust/bindgen_parameters FORCE
$(call if_changed_dep,bindgen)
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
:
#![no_std]
#![feature(allocator_api, global_asm)]
use kernel::prelude::*;
module! {
type: HelloWorld,
name: b"hello_world",
author: b"d0u9",
description: b"A simple hello world example",
license: b"GPL v2",
}
struct HelloWorld;
impl KernelModule for HelloWorld {
fn init() -> Result<Self> {
pr_info!("Hello world from rust!\n");
Ok(HelloWorld)
}
}
impl Drop for HelloWorld {
fn drop(&mut self) {
pr_info!("Bye world from rust!\n");
}
}
与之对应的 Makefile
:
obj-m := hello_world.o
构建:
make -C /path/to/linux_src M=$(pwd) LLVM=1 modules
之后就和使用普通的内核模块一样,使用 insmod
工具或者 modprobe
工具加载就可以了。在使用体验上是没有区别的。
module! { }
宏
这个宏可以被认为是 Rust 内核模块的入口,因为在其中定义了一个内核模块所需的所有信息,包括:Author
、License
、Description
等。其中最重要的是 type
字段,在其中需要指定内核模块结构的名字。在这个例子中:
module! {
...
type: HelloWorld,
...
}
struct HelloWorld;
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 加速卡驱动。