Skip to content

Instantly share code, notes, and snippets.

@gjz010
Last active June 17, 2019 10:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gjz010/8ac0f7ae85938b4cdb7d75874d43efed to your computer and use it in GitHub Desktop.
Save gjz010/8ac0f7ae85938b4cdb7d75874d43efed to your computer and use it in GitHub Desktop.
lkm_report

rCore Loadable Kernel Module

为rCore提供“内核可加载模块”功能的支持。

实验背景

操作系统作为一种典型的复杂的大型软件系统,具有相当大的开发复杂度;而传统的操作系统都是由古老的、抽象层次较低的语言(例如汇编和C语言)编写而成;这些语言编写时因为缺少操作系统开发时需要的语言特性,给开发操作系统带来了额外的负担。

随着一系列新兴编程语言的出现,人们开始考虑:能否使用这些新兴编程语言来代替老的编程语言来作为一些大型软件系统的开发语言?于是便有了使用各种新兴语言实现操作系统的尝试,例如使用Golang实现的Biscuit,以及同样使用Rust实现的Redox OS。而rCore本身是一次使用Rust进行操作系统开发的尝试。

然而“没有银弹”原则在这里仍然适用:纯粹使用Rust进行操作系统开发,有其优势自然也有其弊端,在一些功能的实现上比C语言等还要吃力;这启示我们,使用单一的、通用的程序开发语言进行操作系统开发可能已经遇到了瓶颈,多种的、专用的开发语言或许能带来进一步的开发方式的质变。 一个最自然的实验思路是,我们希望能够使用多种语言,用于编写内核的不同模块。而自然地,这一实验需要一个可以扩展的内核作为依托:一个可以动态地加载外部的模块的内核。在内核支持可加载模块的情况下,我们可以使用不同的语言编写内核模块,并且把它们加载到rCore中,进行测试,并且从编写过程和测试结果中评价不同语言编写操作系统内核的效果。

实验目标

本实验旨在为rCore实现可加载内核模块的支持。而内核模块本身需要对内核进行可扩展性的改动以体现其作用,故本实验对内核进行了改动,增强了内核的可扩展性。

  1. 实现一个简单的“内核虚拟内存”机制:因为内核模块对内存页的读写属性有要求,所以不能直接将可加载模块放入内核堆中。我们模仿用户态虚拟内存管理,实现了一个内核可见的虚拟内存管理机制。
  2. 在内核中实现一个简单的链接器,可以解析内核模块ELF文件,并且将其需要的符号与内核已知的符号(内核本身的符号和已加载模块的符号)进行链接。
  3. 实现一个内核模块管理器,同时实现系统调用SYS_INIT_MODULE和SYS_DELETE_MODULE。
  4. 对内核的文件系统进行改造,使得其支持注册文件系统、挂载文件系统等高级特性,内核可以从模块中加载新的文件系统类型。
  5. 为内核添加设备文件支持,使得用户程序可以通过内核或者模块与硬件设备交流。
  6. 整合以上工作,并且在真机上运行联合测试Demo。

已有工作

Linux本身对内核可加载模块已经有很好的支持:开发者根据Linux Headers以及内核提供的各种内核API编写内核模块并编译;加载时,内核读取符号表,并且完成链接的工作。内核与模块之间的交互通过内核符号表和模块符号表完成。uCore的一个分支中也有类似的机制。

另外一个例子,作为同样用Rust实现的操作系统,Redox OS并没有内核模块的概念;相反,它采取了微内核的架构,将内核拆分成用户态的模块,模块之间的交互通过通信完成。

对于文件系统的改造:Linux本身与UNIX遵循相同的“万物皆文件”的哲学,因此本身是很好的例子;而Linux本身也允许基于file_operations实现设备文件的行为。

实现方案

内核虚拟内存机制

AArch64和x86_64都是常见的64位环境,二者均支持四级页表虚拟地址。因此,我们把顶页表的第509项(起始地址是0xffff_fe80_0000_0000)包含的512GiB空间作为内核虚拟内存的地址空间。地址空间分配上,由于我们用到的地址空间总和远小于512GiB,我们使用简单的“向前分配”策略,不回收被释放的地址空间。

在AArch64上,由于用户页表和内核页表独立存在,不需要特别的操作就可以实现该功能;而在x86_64上,内核页和用户页共用同一张页表,因此我们在复制页表的时候,同时复制页表的第509项;同时,在初始化时将页表的509项初始化,以保证随后出现的所有的页表的509项指向同一个二级页表页。

用户态的unmap操作可能需要考虑TLB缓存失效的问题,而这一点在内核虚拟内存上体现得尤其明显。我们为x86_64实现了一个简单的IPI(Inter-Processor Interrupt)机制,并且借助IPI机制实现了TLB Shootdown操作。

内核可加载模块管理机制

本实验实现了64位机型(AArch64和x86_64)的内核加载模块,能够从内核模块文件中读取符号表和所需符号,并将内核模块正确加载入内存中。

内核链接器

我们要求rCore的内核模块必须是“带元信息的动态链接库”的格式,而动态链接库一定是位置无关(Place-Independent Code)的,这使得我们可以将动态链接库直接加载到内核虚拟地址空间中并且按照ELF文件设置相应权限。此后最复杂的操作便是根据已知符号表链接刚刚加载的动态链接库。

链接器的实现模仿musl libc的动态链接库的实现,并且仿照musl实现了动态链接库中最常用的重定位方式(REL_OFFSET32、REL_SYMBOLIC、REL_GOT、REL_PLT、REL_RELATIVE)。动态链接库需要的所有符号都可以在JMPREL、REL和RELA中找到,只需要把这些符号同内核符号表(以及自身的符号表)进行链接。

内核符号表

内核需要知道自己所含有的符号,并且通过这些符号给内核模块提供服务。最开始编写时,实验采用了“手动导出符号”的办法,这使得内核模块的功能完全受限于导出的符号。随着使用Rust编写内核模块需求的被提出,我们迫切需要一种导出所有内核符号的机制。

通过调整编译选项(链接时使用-export-dynamic选项),我们可以导出内核的绝大多数符号;有一小部分符号属于“内核依赖但是没有使用”的,它们不会被导出;但是这些符号一定是与内核功能相距较远的,倘若内核模块需要这些功能,可以自行打包这些库的副本,故不造成功能上的影响。

实验采用了注入的办法将内核符号整合到内核中:rCore声明两个Data section,分别为rcore_symbol_table和rcore_symbol_table_size,存放符号表本身和符号表的实际大小;这两个section都会反映在ELF文件里,并且在内核加载时被复制进内存。在rCore构建完成后,再用工具(nm)导出符号表并且注入回内核镜像中。内核加载时,便可以从这个位置读取到正确的内核符号表。

这种方法的优点在于解决了需要人力手动导出符号的问题,并且借助Rust自身的符号链接机制使Rust编写内核模块成为可能;缺点在于符号表加载会占用较多的时间和空间,在小内存机型(Thinpad)上可能会导致内存不足问题。

内核模块管理

实验要求内核模块中包含一份纯文本的元数据,描述内核模块的名称、版本号、API版本号、导出符号和依赖关系。

内核模块在描述依赖关系时,根据的是“API版本号”而非“版本号”,这使得一些与API无关的模块更新可以不破坏模块的依赖关系,尽管这一点通常(尤其是在Rust下)也是危险的。

内核可以导出init_module符号,此时内核模块管理器会在模块被加载后,自动调用这个函数,进行内核模块的初始化工作(例如注册设备、注册文件系统等)。

内核文件系统改造

内核原有的文件系统是一个基于路径字符串拼接的简单文件系统,难以适应可扩展内核的需要:例如,为了实现“通过访问/proc/self/exe获取可执行文件”和“通过mmap /dev/fb来操作设备内存”等需求, 不得不使用硬编码来完成目标。

内核文件系统的改造主要分成两部分,其一是对现有的路径解析机制进行改写,使其符合path_resolution(7)描述的算法,成为一套基于相对路径的路径解析文件系统,从而支持挂载等复杂操作;其二是支持对INode的行为进行进一步的扩展,从而为设备文件以及非Posix文件(例如/proc下的符号链接)提供基础。

虚拟文件系统树

实验将“以ROOT_INODE为根的真实文件系统”扩展为“基于真实文件系统的虚拟文件系统”。初始的文件系统会被挂载到根路径下,但它并不是唯一的文件系统:真实的文件系统被置于一个结构体VirtualFS中,VirtualFS同时记录了目录的挂载点和下一层的VirtualFS,从而形成一棵挂载关系构成的目录树。VirtualFS满足以下原则:

  1. VirtualFS中包含一个Arc<FileSystem>,所有的VirtualFS构成一棵树,不允许出现环。
  2. 不同的VirtualFS中可以包含相同的FileSystem,这允许了Bind Mount的存在。
  3. 所有的INode中必须包含VirtualFS的信息(通过INodeContainer实现),因为由于Bind Mount的存在,同一个INode可能对应不同的VirtualFS。

路径解析

新的文件系统提供了一套基于相对路径解析的系统。路径解析接受当前路径(相对路径解析需要)、当前的根路径(为chroot提供基础)、需要解析的路径,通过逐级相对解析的方法,直到到达目标路径。

本实验重写了文件系统相关的Syscall的大部分代码,使得它们可以和现有的路径解析方法兼容。但是这样的改写和一些已有的做法是不兼容的。

  1. 现有的动态链接库依赖于可执行文件的路径:当发现可执行文件需要动态链接库时,通过重新执行linker的方式完成动态链接。本实验模仿Linux的行为修复了这一点,内核现在会加载两个ELF文件,并且通过aux vector把控制权转交给loader,从而避免了动态链接程序加载需要知道路径的条件。
  2. 现有的文件系统不支持设备文件,少数的设备文件通过硬编码路径来实现:我们完成了设备文件的支持,使得不再需要通过路径来获知设备的类型。
  3. 现有的文件系统不支持非Posix符号链接(例如procfs):我们提供了覆盖解析符号链接的功能,使得符号链接可以直接指向一个INodeContainer(只提供了接口,尚未实现),为procfs等特殊文件系统需要的功能提供支持。

设备文件

Linux基于file_operations定义设备文件的行为;而rCore习惯上使用INode定义“特殊文件”的行为:因此,我们对INode的行为进行扩充, 使得特殊的文件可以自行定义INode的行为(读、写、mmap、强行符号解析等)。

同时我们制定了注册设备文件的接口:设备文件注册时,需要提供major编号,而在打开时才会提供minor编号,与Linux行为一致。

联合测试

本实验与为树莓派提供SD卡支持小组和Audio support for rCore小组进行了合作,致力于将两个小组实现的功能从内核代码中剥离,成为独立的内核模块,并且在树莓派真机上加载运行。

页表更新的修复

在联合测试时,rCore中存在一个使得真机上页表不能正常工作的Bug:在页表一切正常的情况下,rCore会抛出页表权限错误。

我们使用了一个简单的方式绕过这一Bug:当发现无法处理的页错误时,我们对对应的页进行TLB Flush,然后直接返回:这会使出现真正的页异常时CPU无限循环,但是的确能够解决假异常的问题。

在这一修复后, BusyBox偶尔会出现内存访问错误,BusyBox的命令会随机失败,但是在顺利的情况下,我们可以执行完所有的命令,达到演示效果。

SD卡支持的内核模块化

SD卡支持是一个相对独立的部分,本身只涉及软件和SD卡提供的接口的交互,这部分代码本身可以原封不动地作为内核模块代码的一部分。

为了使得内核其它部分能使用SD卡模块的功能,SD卡把自己注册成了一个设备文件。同时,因为树莓派的SD卡要求带MBR分区表,我们提供了为设备文件添加MBR分区支持的工具,并且将其应用于SD卡上:SD卡设备文件的Minor代表了分区编号。

在经过分区处理之后,我们可以从SD卡挂载SFS文件系统。测试命令如下:

/busybox insmod ramfs.ko
/busybox insmod emmc.ko
/busybox mount -t ramfs ramfs /dev
/busybox mknod /dev/emmcblk0p2 c 179 2
/busybox mount -t sfs /dev/emmcblk0p2 /mnt
/busybox ls /mnt

音频支持的内核模块化

音频支持同样相对独立,可以将其抽出作为一个单独模块。

/busybox insmod ramfs.ko
/busybox insmod audio.ko
/busybox mount -t ramfs ramfs /dev
/busybox mknod /dev/dsp c 233 0
/biscuit/audio_hello_pwm

联合测试

联合测试的内容为从SD卡上执行audio_hello_pwm程序,以同时验证二者的可用性。该程序已经被提前写入SD卡中。

/busybox insmod ramfs.ko
/busybox insmod emmc.ko
/busybox insmod audio.ko
/busybox mount -t ramfs ramfs /dev
/busybox mknod /dev/emmcblk0p2 c 179 2
/busybox mknod /dev/dsp c 233 0
/busybox mount -t sfs /dev/emmcblk0p2 /mnt
/mnt/audio_hello_pwm

该程序加载较为缓慢(大约需要2分30秒),但是能够成功地在真机上播放声音。

实验结论与反思

我们比较成功地为rCore添加了内核可加载模块的支持,并且在真机上比较顺利地完成了测试。但是任务目标本身的问题,以及开发过程中暴露的问题不能被忽视,需要我们接下来解决。

关于“内核模块”本身的思考

内核模块加载的核心是基于符号的链接技术。基于符号的链接在C的世界里是非常自然的:所有的函数和变量都可以对应一个符号;而这一点在Rust的世界中不一定成立,尤其是在Rust并没有一个稳定的ABI的情况下。稳定ABI的缺失同时要求我们必须在内核构建的“建筑垃圾”(rlib文件和完全相同的编译器)基础上构建内核模块,使得内核模块构建可移植性下降。

同时内核模块本身已经暗示了“宏内核”:事实上宏内核更倾向于通过内核模块来进行扩展,而相对应的微内核则倾向于通过内核模块之间的通信进行扩展。事实上,很多功能不一定非得在内核态完成:例如一个驱动,为了实现功能,一般而言它只需要“在内核态处理中断”,甚至更简单的“在内核态屏蔽该设备的中断”,而其它的部分完全可以在用户态实现。因此,在一些场合下比起更危险的内核模块,使用用户态的模块可能会有更好的效果。

关于Rust作为操作系统编程语言的思考

Rust作为一种高级编程语言,虽然其抽象如其号称的一样“zero-cost”,但是仍然具有一个很高的抽象层次:高抽象层次使得一些问题变得简单,而使得另一些问题变得更加复杂。

rCore本身的开发就体现了这一点:一方面,利用Rust的所有权管理,rCore有着“PCB所有权属于TCB”等巧妙设计;但更多的代码则在不断使用Arc和unsafe绕开Rust的所有权管理(Rust的所有权管理导致其在实现带Back-reference的数据结构时尤其困难,例如双向链表),导致了代码结构的混乱;同时,Rust的其他一些缺陷(不彻底的函数式编程)也导致了在一些场合、尤其是靠底层场合的编码困难。

操作系统的编程有其内在的复杂性,不是使用单种语言能够解决的。使用多种编程语言编写操作系统的不同部分,有可能能够使操作系统的开发复杂度降低:而内核可加载模块则为实现这一目标提供了基础。

关于rCore开发流程的思考

rCore的开发现在正处于一个疯狂生长的阶段:不断有新功能被加入到内核中:这些功能大多是只经过了简单的测试,便被合并入主开发分支中。这导致了代码质量的下降。

rCore在扩展过程中,比较强调“对新功能的实现”:有些时候,这导致了内在代码结构的破坏。一个例子是,为了实现Socket,rCore破坏了File的统一性而引入了FileLike,这使得File的进一步扩展变得具有迷惑性:是在FileLike上继续扩展(FileLike缺少某些File的特性),还是直接修改File?为了不妨碍进一步开发,在实现功能的需求之外,对内核代码结构的维护也是必不可少的。

附录:简单的实验日志摘抄

  • 2019-4-8 (commit #e1b9317) 完成了基本的内核模块加载与卸载功能:但是内核本身的扩展性成为了阻碍内核发挥扩展性的问题。
  • 2019-4-29 文件系统的重构完成。现在文件系统支持mount操作:但是需要“设备文件”作为mount的来源。
  • 2019-5-6 设备文件的支持完成,需要RamFS作为一个测试用例。
  • 第二次展示:成功支持Rust编写的内核模块,并且用Rust实现了RamFS,作为简单的测试用例。
  • 第三次展示:修复真机上无法运行的bug,并且与两小组合作,在树莓派上完成了功能的整合。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment