【重磅】用Rust重写Linux内核模块体验

本文来自知乎 https://zhuanlan.zhihu.com/p/137077998

作者 Kevin Wang

最近,我用Rust重写了一个2W+行C代码的linux内核模块。在此记录一点经验。我此前没写过内核模块,认识比较疏浅,有错误欢迎指正。

为什么要重写?

这个模块2W+行代码量看起来不多,却在线上时常故障,永远改不完。十多年的老代码,经手了无数程序员,没人能解决其中的内存安全问题。拿过来一看,代码中的确有不少会产生UB的写法,线上的故障从core来看都飘得太远,难以定位根本原因在哪里。所以我没有把握(没有能力)在原代码基础上能将所有线上故障修复。而Rust是一个现代的、高性能、无GC、内存安全的编程语言,我想它非常适合用来重写这个内核模块。

Hello World

首先来介绍下如何用Rust写linux内核模块吧。也可以参考这里, 该项目正在尝试写一个safe的rust内核框架,目前的状态还不实用,我没使用该框架,仅参考了其基本编译配置。

基本思路就是分别建立一个linux内核c工程和rust的hello world工程,把它们放到一块儿(不放到一块儿也行),文件分布如下:

├── Cargo.toml
├── Makefile
├── mydriver.c
└── src
    └── lib.rs

然后在linux内核模块的入口和出口函数分别调用rust中实现的入口和出口函数,rust中将入口、出口函数标记为extern "C",所有业务逻辑在Rust中完成。

// mydriver.c
// ... include headers

extern int my_drv_init(void); // defined in rust
extern void my_drv_exit(void); // defined in rust

static int _my_drv_init(void)
{
    printk("loading my driver\n");
    return my_drv_init();
}

static void _my_drv_exit(void)
{
        printk("exiting my driver\n");
    my_drv_exit();
}

module_init(_my_drv_init);
module_exit(_my_drv_exit);
// lib.rs
#[no_mangle]
pub extern "C" fn my_drv_init() -> i32 {
      KLogger::install();
      info!("loading my driver in rust");
      0
}

#[no_mangle]
pub extern "C" fn my_drv_exit() {
      info!("exiting my driver in rust");
}

Cargo.toml中需要配置输出staticlib

[lib]
name = "mydriver"
crate-type = ["staticlib", "rlib"]

模块的Makefile调用cargo编译rust库,然后将其和c一块儿链接成ko,大概这个样子:

MODNAME = mydriver

KDIR ?= /lib/modules/$(shell uname -r)/build
BUILD_TYPE = release
LIB_DIR = target/$(ARCH)-linux-kernel/$(BUILD_TYPE)

all:
    $(MAKE) -C $(KDIR) M=$(CURDIR)

clean:
    $(MAKE) -C $(KDIR) M=$(CURDIR) clean
    rm -rf target

rlib:
    # 目前需要nightly才能编译core和alloc.
    cargo +nightly build --$(BUILD_TYPE) -Z features=dev_dep,build_dep -Z build-std=core,alloc --target=$(ARCH)-linux-kernel


obj-m := $(MODNAME).o

$(MODNAME)-objs := mydriver.o mydriver.rust.o

.PHONY: $(src)/lib$(MODNAME).a
$(src)/lib$(MODNAME).a:
    cd $(src); make rlib
    cd $(src); cp $(LIB_DIR)/lib$(MODNAME).a .

%.rust.o: lib%.a
    $(LD) -r -o [email protected] --whole-archive $<
    $(src)/plt2pc.py [email protected] $@

可行性评估

用Rust写linux内核模块还是有些担忧,目前还没看到Rust内核模块相关的严肃开源项目,Demo倒是有两个。动手之前,咱们还是尽可能评估一下可行性。之前有了解到有工具C2Rust可以将C代码转换成Rust代码,所以,我的想法是先用C2Rust将原有C代码转成Rust,看能不能编译跑起来,各功能是否正常,看看有没有什么硬伤。如果能正常使用,则可以在转出的代码的基础上逐渐将unsafe rust重构为safe rust。

【重磅】用Rust重写Linux内核模块体验_第1张图片 C2Rust工作流

按照C2Rust相关文档操作下来,遇到几个问题:

  1. 转换时内核头文件的时候报错。

/usr/src/kernels/.../arch/x86/include/asm/jump_label.h:16:2: error: 'asm goto' constructs are not supported yet
        asm_volatile_goto("1:"
        ^
include/linux/compiler-gcc4.h:79:43: note: expanded from macro 'asm_volatile_goto'
# define asm_volatile_goto(x...)        do { asm goto(x); asm (""); } while (0)

据C2Rust文档介绍,需要最新的libclang才能支持此语法。

2. 转换后的代码编译报错。

编译错误大致分为memcpy宏、内联汇编错误、依赖libc crate几类。

以上错误中,libc的依赖仅仅使用了libc中定义的一些C语言基本类型,因此,可以写一个简单的libc crate替代。其它错误均通过临时修改内核头文件,将不支持的语法define成其他替代品规避。

3. 编译成功后的ko文件加载报错。

加载ko报如下错误:

insmod: ERROR: could not insert module mp.ko: Invalid module format

dmesg显示:

Unknown rela relocation: 4

这是由于Rust编译器(LLVM)生成的二进制中对于extern “C”函数的访问,采用的是R_X86_64_PLT32标记重定位,Linux4.15内核开始支持此标记,而我们使用的3.x内核仅支持R_X86_64_PC32标记。内核中相应提交可以看出内核对这两个标记是无区别对待的:

"PLT32 relocation is used as marker for PC-relative branches. Because
    of EBX, it looks odd to generate PLT32 relocation on i386 when EBX
    doesn't have GOT.

    As for symbol resolution, PLT32 and PC32 relocations are almost
    interchangeable. But when linker sees PLT32 relocation against a
    protected symbol, it can resolved locally at link-time since it is
    used on a branch instruction. Linker can't do that for PC32
    relocation"

  but for the kernel use, the two are basically the same, and this
  commit gets things building and working with the current binutils
  master   - Linus

因此,我们可以简单地将编译出的二进制文件中的PLT32标记替换为PC32就能解决此问题。readelf命令可以帮我们找出这些标记都在什么位置,故甚至都不需要了解elf文件结构,可以写脚本完成替换:

#!/usr/bin/env python

import sys
import os
import re

py3 = sys.version_info.major >= 3


def get_relocs(filename):
    """
    readelf output:

Relocation p '.rela.text' at offset 0x1e8 contains 1 entry:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
00000000000a  000a00000002 R_X86_64_PC32     0000000000000000 hello - 4

Relocation p '.rela.eh_frame' at offset 0x200 contains 1 entry:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000020  000200000002 R_X86_64_PC32     0000000000000000 .text + 0
    """
    relocs = []
    sec = ''
    idx = 0
    os.environ["LANG"] = ''
    f = os.popen('readelf -r "%s"' % filename)
    while True:
        line = f.readline()
        if not line:
            break
        if line.startswith('Relocation p'):
            arr = re.findall(r'0x[0-9a-f]*', line)
            sec = int(arr[0], base=16)
            idx = 0
            f.readline()
            continue
        off = idx * 24 + 8
        idx += 1
        arr = line.strip().split()[:4]
        if len(arr) != 4:
            continue
        offset, info, typ, val = arr
        if typ != 'R_X86_64_PLT32':
            continue
        relocs.append((sec, off, val))
    return relocs


def main():
    PLT32 = 4 if py3 else '\x04'
    PC32 = 2 if py3 else '\x02'

    infile = sys.argv[1]
    outfile = sys.argv[2] if len(sys.argv) == 3 else infile
    obj = list(open(infile, 'rb').read())
    for sec, offset, val in get_relocs(infile):
        goff = sec + offset
        assert obj[goff] == PLT32
        obj[goff] = PC32
    out_bin = bytes(obj) if py3 else ''.join(obj)
    open(outfile, 'wb').write(out_bin)


if __name__ == '__main__':
    main()

解决了reloc问题后模块就能正常加载了,且经测试,各项功能均和原版相同,连bug都一样。至此,我们用C2Rust完成了一个和原模块等效的Rust版本。如此顺利且真的等效有些出乎意料,相比其他语言中类似的工具(往往需要大量修改转换后源代码才能编译且很难做到等效),C2Rust还是很给力的(用C2Rust转换的代码包含2W+行模块主体代码和8W行的第三方库)。

用Rust重写

重构unsafe的痛

正如预期,用C2Rust转出来rust没有safe代码,一律unsafe。我们需要将其重构为safe代码。简短地实践下来,发现重构转换出的代码非常痛苦。

  • 例1,C中的宏调用会被展开,大部分宏展开的结果非常难看,这也直接导致生成的代码行数膨胀为原版的3-4倍。如,原版代码是这样:

do_something(ntohl(info->port), ntohl(info->event));

转换后变成这样:

do_something(if0!=0{
  (((*info).port&
    0xffaslibc::c_ulongas__u32)<<
    24aslibc::c_int|
    ((*info).port&
      0xff00aslibc::c_ulongas__u32)
    <<8aslibc::c_int|
    ((*info).port&
      0xff0000aslibc::c_ulongas
      __u32)>>8aslibc::c_int)|
  ((*info).port&
    0xff000000aslibc::c_ulongas
    __u32)>>24aslibc::c_int
  }else{__fswab32((*info).port)},
  if0!=0{
    (((*info).event&
      0xffaslibc::c_ulongas__u32)<<
      24aslibc::c_int|
      ((*info).event&
        0xff00aslibc::c_ulongas__u32)
      <<8aslibc::c_int|
      ((*info).event&
        0xff0000aslibc::c_ulongas
        __u32)>>8aslibc::c_int)|
    ((*info).event&
      0xff000000aslibc::c_ulongas
      __u32)>>24aslibc::c_int
  }else{__fswab32((*info).event)});
  • 例2, 大量的类型强转,让人看不清代码逻辑。如:

Temp0=do_something(Koeff0,Vk1_0<<1aslibc::c_int)-Vk2_0+
           *arraySamples.offset(iiasisize)aslibc::c_int;
Temp1=Temp1as__s16aslibc::c_int*Vk2_1as__s16aslibc::c_int;

每去除一个强转,都要去斟酌一下是不是和原版等效的(c2rust之所以这么写,是为了和C中默认的类型提升规则等效)。

  • 例3,每个c文件对应转换出一个独立的rs文件,包括C中引用的头文件中的各种声明和类型定义,都独立地在每个rs文件中重复、乱序地定义一份,难以整合。

  • 例4,Rust不支持goto语句,于是c2rust用许多的if/else来模拟c中goto语句,我是比较佩服这么机智的处理方法,但是要重构它就难以看清了。

  • ......

当然,c2rust有个refactor命令,里面许多实验性的工具来帮助减轻重构的负担,包括上面遇到的问题,不过使用下来感觉这些工具都不成熟,比较难用。于是,还是决定参照原版功能逻辑,重写一个吧。

垫脚层

rust程序要在内核工作少不了要和内核交互,这就需要ffi调用内核的一些“API”来完成特定工作。内核的API都声明在内核头文件中,理论上我们可以用rust-bindgen直接输出kernel-bindings.rs来使用这些API。

实践中,一方面,有少部分的类型bind后无法编译;另一方面,由于内核头文件有大量的参数宏和static inline函数,这些API目前无法通过rust-bindgen完成绑定,使得rust-bindgen的意义大大缩减。c2rust倒是可以处理static inline函数,但是c2rust目前绑死到了特定nightly版本上才能用。因此,我还是决定对要用到的内核函数封装一个垫脚层ksys.c中转一下,使用rust-bindgen绑定ksys.h,这样会比较简单稳定。例如,memcpy的绑定:

原始定义:

#define memcpy(dst, src, len)                   \
({                              \
    size_t __len = (len);                   \
    void *__ret;                        \
    if (__builtin_constant_p(len) && __len >= 64)       \
        __ret = __memcpy((dst), (src), __len);      \
    else                            \
        __ret = __builtin_memcpy((dst), (src), __len);  \
    __ret;                          \
})

ksys.h中:

void *ksys_memcpy(void *dest, const void *src, size_t n);

ksys.c中:

void *ksys_memcpy(void *dst, const void *src, size_t n) {
    return memcpy(dst, src, n);
}

binding结果:

extern"C"{
    pubfn ksys_memcpy(
        dest: *mutc_types::c_void,
        src: *constc_types::c_void,
        n: usize,
    )-> *mutc_types::c_void;
}

这样实现会导致Rust编译器不能inline这些函数,从而对性能有一定影响,后续等rust-bindgen完善了再切换过去。

造轮子

内核态写rust没有标准库可用,因此,需要造一些基础设施的轮子,以及内核API函数的安全封装。包括lock、channel、fs、net、thread、timer、logger等。当然,不造这些轮子也能实现功能,需要的地方直接调用内核API来完成相关功能就好了...这样的话,干嘛还用Rust呢?造轮子是常规操作,有大量crate可参考,就不细说了,channel部分遇到一个小坑,后文讲述。

栈溢出

程序写完运行起来遇到的第一个坑是栈溢出,Linux内核线程的栈很小(x86上16KB),容易溢出。debug编译模式就不说了,一句带格式的log就能把栈爆掉。我就只讲一下release模式,release编译的程序编译器会尽可能地优化栈空间的使用,也正是因为编译器的优化的存在,我们要从代码中肉眼找出栈空间使用的最深路径变得困难。幸运的是嵌入式工作组的老大@japaric开发了一个不起眼的工具cargo-call-stack专门用来分析栈空间的使用情况,效果如下图:

【重磅】用Rust重写Linux内核模块体验_第2张图片 cargo call-stack 输出

利用该工具,我们可以一瞬间找出栈使用最深点和量,然后顺腾摸瓜在代码中逐个优化掉。

至于哪些写法会影响编译器对栈的优化,我没有太细致的总结,就简短写一点吧。不用cargo-call-stack我们可以按照类似下面这样写来分析各种写法对编译优化的影响:

#![feature(test)]
#![feature(box_syntax)]

usestd::hint::black_box;

staticmutBOTTOM: usize =0;

#[inline(never)]
fn anchor_bottom(){
    letmutv=0;
    unsafe{BOTTOM=(&mutv)as*muti32as_};
}

#[inline(never)]
fn depth()-> usize {
    letmutv=0;
    unsafe{BOTTOM-((&mutv)as*muti32asusize)}
}

fn main(){
    anchor_bottom();// 标定栈底
test_entry();
}

#[inline(never)]
fn test_entry(){
    // 在这里测试各种写法的影响
letmutmsg=Message::new();
    println!("stack size = {}",depth());
    black_box(&msg);// 防止编译器认为msg无用而整体优化掉了。
}

struct Message{
    id: usize,
    data: [u8;1000],
}

implMessage{
    // inline影响探针的功能,禁掉
#[inline(never)]
    fn new()-> Self{
        letmutmsg=Self::default();
        println!("stack size in new = {}",depth());
        msg
    }
}

执行上面的代码执行结果:

// debug编译:
stack size in new = 2320
stack size = 1152
// release编译:
stack size in new = 1200
stack size = 1104

说明release下new里面的msg变量栈使用被优化了,Self::default()的返回值直接放到了test_entry这帧的msg里面。

这里主要想说两点:

  • Box::new(value)会先把value放到栈上,然后copy进堆里面,使用unstable的box关键字可以解决。

fn test_entry(){
    letmutv=Box::new(Message::new());
    println!("stack size = {}",depth());
    black_box(&v);
}
// output:
// 	stack size = 1056

换成box:

fn test_entry(){
    letmutv=boxMessage::new();
    println!("stack size = {}",depth());
    black_box(&v);
}
// output:
// 	stack size = 96
  • 把栈变量的地址传给ffi函数会阻止编译器优化该变量,例如,上面的new改成:

fn new()-> Self{
        letmutmsg=Self::default();
      	black_box(&msg);
        println!("stack size in new = {}",depth());
        msg
    }

则会变成:

stack size in new = 2224

cargo-call-stack番外

cargo-call-stack并不能拿来即用,安装一执行便报一行30MB的错误(没错,一行,30M):

Failure(("define internal fastcc void @_ZN3std10sys_common9backtrace28__rust_begin_short_backtrace17ha028a22ae68de0a6E(i8* ......

这是由于call-stack通过分析llvm IR来获得所有函数的调用关系,从而构图计算评估栈空间。而有些IR语法它并不能识别(工具太小众了照顾不全),只好自己动手添加不识别的语法支持,对于我遇到的几个不支持的语法,我已添加并提交了PR。

修完语法问题后就能输出call-stack图了,然而并没有得到其主页介绍的那美美的图片,得到的是这样:

【重磅】用Rust重写Linux内核模块体验_第3张图片 实践中cargo call-stack的输出

节点太多,根本无法动弹,换了几个软件均没有理想的查看效果。那就自己动手吧,给call-stack添加一个tui前端,这样浏览起来就方便多了:

【重磅】用Rust重写Linux内核模块体验_第4张图片

添加的cargo call-stack的tui前端

Rust的函数没有颜色

在支持类协程(如Rust的async/await)编程语言中存在这样一个问题:协程(async)函数中要避免调用阻塞函数,否则会影响协程的调度。而实践中编译器往往没有做到编译时检查出协程中调用阻塞而给出提示,完全依靠人小心避免。Rust社区有尝试从各种角度解决此问题,比如这里,这里,还有这里,目前没有什么进展。有人用函数的颜色来描述讨论此问题。

而到了内核里,类似的问题就更加凸显出来。

例如,在内核态,在中断上下文、获得spinlock等场景下不允许程序休眠(放弃CPU),否则会导致死锁或影响系统性能。和用户态的区别是用户态用错了影响一个服务的性能,而内核里用错了会整个系统垮掉。中断和spinlock都是写内核态程序常常要面对的,而内核的API中会sleep的函数里遍地都是,并且不会像用户态的libc有清晰规范的文档,这就导致完全依靠人为小心避免变得更困难。如果rust有某种机制,在编译时禁止或提示这类危险上下文调用某种颜色的函数是不是会更好呢

又例如,这次我踩到的一个坑:我一开始便使用spin这个crate实现了一个channel用于线程间通信,使用前还专门看了issue,安全审计团队对这个crate的安全性审计过了,因此比较放心。我把这个channel用在了定时器中给一个服务线程发消息,程序跑起来后就发现时而卡死(死锁)。看内核文档得知spinlock用于中断上下文是有文章的,道理很简单,内核态一个线程随时可能被中断服务程序中断了,去处理更紧急的事情,但如果被中断的线程正拿着一个锁,而此时中断服务也试图去获取同一个锁就会导致死锁。内核文档的描述:

The reasons you mustn't use these versions if you have interrupts that
play with the spinlock is that you can get deadlocks:

    spin_lock(&lock);
    ...
        <- interrupt comes in:
            spin_lock(&lock);

解决办法就是如果中断程序里面要获取一个锁,则所有获取该锁的代码都要先屏蔽中断,然后再去拿锁。内核中因此将spinlock的api分为了几组:

void spin_lock(spinlock_t *lock);
void spin_lock_irq(spinlock_t *lock);
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);

其中后两个是会屏蔽中断的,而Rust的spin crate并不会屏蔽中断,因此导致死锁。因此,放弃spin crate,封装一个内核版本spin解决了此问题。如果rust有某种机制,在编译时禁止或提示中断上下文中获取没有屏蔽中断的锁是不是会更好呢

有些语言中有Effect System来解决这类问题,例如nim语言允许我们对函数标记额外的副作用:

type IO = object # 定义IO副作用
proc readLine(): string {.tags: [IO].} = discard  # 标记readLine函数具有IO副作用

proc no_IO_please() {.tags: [].} = # 标记此函数不允许IO副作用
  # 编译器将拒绝此行代码
  let x = readLine()

避免感觉语法怪异,我将其翻译为rust风格的伪代码:

struct IO;// 定义IO副作用

#[tags([IO])]// 标记readline函数具有IO副作用
fn readline()-> String {
    todo!()
}  

#[tags([])]// 标记此函数不允许IO副作用
fn no_IO_please(){
    letx=readline();//编译器将拒绝此行代码
...
}

目前Rust里面函数只有safe/unsafe两种颜色,没有更多色深,感觉有些单调。Rust大佬们的讨论中也提到了此特性,但目前的情况看,应该短期不会有进展。

不过好在实践(我的)过程中,无论是中断还是spinlock上下文,代码都会非常简短,影响没那么大。只要脑子里知道这个知识点,一般就不会再出差错了。

多姿的内存分配函数

内核中为了提高效率,有各式各样堆内存分配函数选择,大块的/小块的、是否保证物理连续、是否会sleep、是否触碰文件系统......。不同的场景需要使用不同的API来分配堆内存。来瞧一瞧:

void *kmalloc(size_t size, gfp_t flags);
void *kcalloc(size_t n, size_t size, unsigned int __nocast gfp_flags);
void *kzalloc(size_t size, unsigned int __nocast gfp_flags);
void *vmalloc(unsigned long size);
void *kvmalloc(size_t size, gfp_t flags);
void *kvzalloc(size_t size, gfp_t flags);
void *kvmalloc_node(size_t size, gfp_t flags, int node);
void *kvzalloc_node(size_t size, gfp_t flags, int node);

其中flags又有这些选择:

#define GFP_ATOMIC  (__GFP_HIGH|__GFP_ATOMIC|__GFP_KSWAPD_RECLAIM)
#define GFP_KERNEL  (__GFP_RECLAIM | __GFP_IO | __GFP_FS)
#define GFP_KERNEL_ACCOUNT (GFP_KERNEL | __GFP_ACCOUNT)
#define GFP_NOWAIT  (__GFP_KSWAPD_RECLAIM)
#define GFP_NOIO    (__GFP_RECLAIM)
#define GFP_NOFS    (__GFP_RECLAIM | __GFP_IO)
#define GFP_USER    (__GFP_RECLAIM | __GFP_IO | __GFP_FS | __GFP_HARDWALL)
#define GFP_DMA     __GFP_DMA
#define GFP_DMA32   __GFP_DMA32
#define GFP_HIGHUSER    (GFP_USER | __GFP_HIGHMEM)
#define GFP_HIGHUSER_MOVABLE    (GFP_HIGHUSER | __GFP_MOVABLE)
#define GFP_TRANSHUGE_LIGHT ((GFP_HIGHUSER_MOVABLE | __GFP_COMP | \
             __GFP_NOMEMALLOC | __GFP_NOWARN) & ~__GFP_RECLAIM)
#define GFP_TRANSHUGE   (GFP_TRANSHUGE_LIGHT | __GFP_DIRECT_RECLAIM)
/* Convert GFP flags to their corresponding migrate type */
#define GFP_MOVABLE_MASK (__GFP_RECLAIMABLE|__GFP_MOVABLE)
#define GFP_MOVABLE_SHIFT 3

突然明白了为什么zig语言设计成处处调用需要手动传入一个allocator。

而Rust的alloc crate只有一个自定义接口,这就导致只能选择一种,并且需要人为避免在不合适的场景触发Rust的alloc导致的堆内存分配,其它场景的分配恐怕就要绕过alloc crate另外实现了。目前,为兼容大部分场景,暂且这样实现分配器:

usecrate::ffi;
usecore::alloc::{GlobalAlloc,Layout};

pubstruct KernelAllocator;

unsafeimplGlobalAllocforKernelAllocator{
    unsafefn alloc(&self,layout: Layout)-> *mutu8{
        // FIXME: kernel does not support custom alignment。
//    kmalloc has some sort of guarantee.
//    See: https://lwn.net/Articles/787740/
letsize=layout.size();
        ifsize<=PAGE_SIZE{
            returnffi::kmalloc(size,GFP_KERNEL);
        }else{
            returnffi::vmalloc(size);
        }
    }

    unsafefn dealloc(&self,ptr: *mutu8,layout: Layout){
        iflayout.size()<=PAGE_SIZE{
            returnffi::kfree(ptr);
        }else{
            returnffi::vfree(ptr);
        }
    }
}

这就需要人为避免在中断、spinlock等场景触发Rust的alloc crate中的内存分配。好在实践过程中没有遇到这些场景下需要分配堆内存的情况。

结语

虽然遇到一些小坑,但瑕不掩瑜,使用Rust最大的好处就是内存安全,写完这种安心的感觉会让人觉得上述那些过程中的坑、额外的工作都是小事儿。只要把好ffi这关,今后因为各队友的疏忽而引入各种难查的UB将难以再发生。

你可能感兴趣的:(【重磅】用Rust重写Linux内核模块体验)