用Rust重寫Linux內核模塊體驗


https://zhuanlan.zhihu.com/p/137077998

最近,我用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 $@.tmp --whole-archive $< $(src)/plt2pc.py $@.tmp $@ 

可行性評估

用Rust寫linux內核模塊還是有些擔憂,目前還沒看到Rust內核模塊相關的嚴肅開源項目,Demo倒是有兩個。動手之前,咱們還是盡可能評估一下可行性。之前有了解到有工具C2Rust可以將C代碼轉換成Rust代碼,所以,我的想法是先用C2Rust將原有C代碼轉成Rust,看能不能編譯跑起來,各功能是否正常,看看有沒有什么硬傷。如果能正常使用,則可以在轉出的代碼的基礎上逐漸將unsafe rust重構為safe rust。

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 section '.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 section '.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 section'): 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(if 0 != 0 {  (((*info).port &  0xff as libc::c_ulong as __u32) <<  24 as libc::c_int |  ((*info).port &  0xff00 as libc::c_ulong as __u32)  << 8 as libc::c_int |  ((*info).port &  0xff0000 as libc::c_ulong as  __u32) >> 8 as libc::c_int) |  ((*info).port &  0xff000000 as libc::c_ulong as  __u32) >> 24 as libc::c_int  } else { __fswab32((*info).port) },  if 0 != 0 {  (((*info).event &  0xff as libc::c_ulong as __u32) <<  24 as libc::c_int |  ((*info).event &  0xff00 as libc::c_ulong as __u32)  << 8 as libc::c_int |  ((*info).event &  0xff0000 as libc::c_ulong as  __u32) >> 8 as libc::c_int) |  ((*info).event &  0xff000000 as libc::c_ulong as  __u32) >> 24 as libc::c_int  } else { __fswab32((*info).event) });
  • 例2, 大量的類型強轉,讓人看不清代碼邏輯。如:
Temp0 = do_something(Koeff0, Vk1_0 << 1 as libc::c_int) - Vk2_0 +  *arraySamples.offset(ii as isize) as libc::c_int; Temp1 = Temp1 as __s16 as libc::c_int * Vk2_1 as __s16 as libc::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" {  pub fn ksys_memcpy(  dest: *mut c_types::c_void,  src: *const c_types::c_void,  n: usize,  ) -> *mut c_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專門用來分析棧空間的使用情況,效果如下圖:

cargo call-stack 輸出

利用該工具,我們可以一瞬間找出棧使用最深點和量,然后順騰摸瓜在代碼中逐個優化掉。

至於哪些寫法會影響編譯器對棧的優化,我沒有太細致的總結,就簡短寫一點吧。不用cargo-call-stack我們可以按照類似下面這樣寫來分析各種寫法對編譯優化的影響:

#![feature(test)] #![feature(box_syntax)]  use std::hint::black_box;  static mut BOTTOM: usize = 0;  #[inline(never)] fn anchor_bottom() {  let mut v = 0;  unsafe { BOTTOM = (&mut v) as *mut i32 as _ }; }  #[inline(never)] fn depth() -> usize {  let mut v = 0;  unsafe { BOTTOM - ((&mut v) as *mut i32 as usize) } }  fn main() {  anchor_bottom(); // 標定棧底  test_entry(); }  #[inline(never)] fn test_entry() {  // 在這里測試各種寫法的影響  let mut msg = Message::new();  println!("stack size = {}", depth());  black_box(&msg); // 防止編譯器認為msg無用而整體優化掉了。 }  struct Message {  id: usize,  data: [u8; 1000], }  impl Message {  // inline影響探針的功能,禁掉  #[inline(never)]  fn new() -> Self {  let mut msg = 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() {  let mut v = Box::new(Message::new());  println!("stack size = {}", depth());  black_box(&v); } // output: // stack size = 1056 

換成box:

fn test_entry() {  let mut v = box Message::new();  println!("stack size = {}", depth());  black_box(&v); } // output: // stack size = 96 
  • 把棧變量的地址傳給ffi函數會阻止編譯器優化該變量,例如,上面的new改成:
 fn new() -> Self {  let mut msg = 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圖了,然而並沒有得到其主頁介紹的那美美的圖片,得到的是這樣:

實踐中cargo call-stack的輸出

節點太多,根本無法動彈,換了幾個軟件均沒有理想的查看效果。那就自己動手吧,給call-stack添加一個tui前端,這樣瀏覽起來就方便多了:

添加的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() {  let x = 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另外實現了。目前,為兼容大部分場景,暫且這樣實現分配器:

use crate::ffi; use core::alloc::{GlobalAlloc, Layout};  pub struct KernelAllocator;  unsafe impl GlobalAlloc for KernelAllocator {  unsafe fn alloc(&self, layout: Layout) -> *mut u8 {  // FIXME: kernel does not support custom alignment。  // kmalloc has some sort of guarantee.  // See: https://lwn.net/Articles/787740/  let size = layout.size();  if size <= PAGE_SIZE {  return ffi::kmalloc(size, GFP_KERNEL);  } else {  return ffi::vmalloc(size);  }  }   unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {  if layout.size() <= PAGE_SIZE {  return ffi::kfree(ptr);  } else {  return ffi::vfree(ptr);  }  } }

這就需要人為避免在中斷、spinlock等場景觸發Rust的alloc crate中的內存分配。好在實踐過程中沒有遇到這些場景下需要分配堆內存的情況。

結語

雖然遇到一些小坑,但瑕不掩瑜,使用Rust最大的好處就是內存安全,寫完這種安心的感覺會讓人覺得上述那些過程中的坑、額外的工作都是小事兒。只要把好ffi這關,今后因為各隊友的疏忽而引入各種難查的UB將難以再發生。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM