0%

从2024年10月开始,我在郭老师和陈老师的指导下开始系统学习 Rust 操作系统,到目前为止已有半年时间,现总结如下。

学习过程

学习主要分为 3 个阶段:Rust 语言学习阶段,操作系统训练营阶段和 Starry 实践阶段。

Rust 语言学习阶段

2024年10月-11月,我参加了 Rust 学习赛。在此之前我对 Rust 的了解仅限于一些最基本的语法,没有用 Rust 写代码的经历。这次比赛于我而言是以赛促学,直接投入实践。我先是通过看训练营的课件和网上的一些资料,完成 rustlings 的练习,初步熟悉了 Rust 的语法和一些基本概念。然后参加比赛。初赛相对简单,只需要完成一些相对短的代码片段。复赛难度提高,特别是最后一题的内存分配器,我尝试了 c2rust 库,但由于需要一些改造才能适配,最终失败了。通过这一个多月的学习,我对 Rust 有了初步的了解。

操作系统训练营阶段

2024年11月-12月,我参加了操作系统训练营,学习了 rCore 的基本原理和实现和组件化操作系统的设计和实现,并且在项目阶段参加了对应用的支持。训练营的专业阶段和项目基础阶段中,我学习了如何从零开始实现一个 Rust 操作系统,以及在此之上拓展到 unikernel 和宏内核。我完成了这两个阶段的课后练习,加深对 Rust 和操作系统的理解。训练营的第四阶段中,我选择了在 new Starry 上支持 Python。经过一段时间的 debug 以及和同学的交流后,实现了在 new Starry 上运行最基本的 Python 测例。通过两个月的学习,我对 Rust 操作系统有了基本的认识,并且获得了一些实践的经验。

Starry 实践阶段

2025年1月-4月,我参与了 starry-next 的开发。一开始是熟悉 starry-next 的代码结构,通过运行并尝试补充实现2024年操作系统比赛的测例来加深理解,在这个过程中我也参考了其他同学的一些实现。开学之后的工作转移到在四种硬件架构上通过2025年操作系统比赛的测例,比2024年的测例多了许多。我与大实验和本科毕设的同学一起开发。3月中旬之前主要专注于 basic 测例的实现,一开始2024年版本的 starry-next 可以通过所有的测例,但 2025 年版本代码改动,所以花了一些时间去适配。之后专注于 libc-test、busybox、lua、iozone 测例。除了硬件层面的问题,大多数测例实质上是要完成一些 syscall,以及可能对操作系统的一些组件的改动。一开始我主要负责若干个文件系统相关的 syscall 的实现,但随着开发的进展,涉及的 syscall 也不限于文件系统。在这个过程中我学习了许多其他同学的实现,也向主线提了一些 PR。4月底,我一直在尝试实现 iozone,目前定位到原先无法通过测试的原因是共享内存和 select(实际 iozone 调用 select 是为了 sleep)未实现,而目前粗糙的实现依然存在 iozone 会不断调用 select 陷入死循环的问题,有待进一步排查。

学习成效

Rust 学习方面,通过了训练营 rustlings 的所有练习,通过了 Rust 比赛的初赛所有题目和复赛除了最后一题之外的所有题目,获得优秀奖。

操作系统方面,完成了训练营的所有练习,为 starry-next 和 ArceOS 提了若干个 PR,最终能通过2025年操作系统比赛初赛的大部分测例。

问题和解决

第一是语言问题,一开始学习 Rust 的时候虽然过了一遍内容,但并没有完全理解,在后续开发的时候依然会遇到一些看不懂的报错,只能上网搜索。但是语言的学习确实也只能这样,在运用的过程中记忆。

第二是环境和开发方式问题,我尝试了许多不同的环境,包括 WSL、GitHub codespace、阿里云服务器、实验室服务器等,以及在其上的 Docker 环境。starry-next 的配环境确实有点麻烦,另一方面也是我自己对于容器环境的了解不够深入。最终我选择在实验室服务器上自己配环境,以及在 GitHub codespace 用其他同学写好的配置。

第三是开发过程中遇到的各式各样的操作系统的问题,也是我需要实现的地方,比如 glibc 的 tmpfile 函数依赖 fcntl 函数的 F_GETFL 参数,比如 fstatat 函数中 to_string 函数会在字符串后 padding,导致 utf-8 解析错误,比如 glibc 的 fstat 函数调用的是 fstatat 函数,而代码中在unlink 之后依然用文件名访问等等。这些问题都在我阅读代码和 man 手册后得到解决。具体问题和解决过程见 starry-next 宏内核扩展实践记录

下一步

2025年4月开始,我补充学习了操作系统训练营中之前没有学的虚拟化章节,并且通过阅读 axvisor 的代码和文档来进一步学习虚拟化。接下来我将参与到组件化虚拟机方面的实习研究工作中,从 riscv 入手,逐步延伸到 aarch。

第一阶段总结

第一阶段写了rustlings,以前就写过一遍,不过这10个数据结构题还是挺折磨的,跟编译器搏斗。

第二阶段总结

第二阶段写了rCore的lab,以前看过一半xv6的lab,但是已有的代码看不太懂,最后烂尾了。感觉做这种OS lab最大的困难就是充分理解框架代码,好在rCore的指导书足够详细,代码也比xv6简单一些,最后做完了,写的过程比较顺利,除了最后一个检测信号量的时候,对信号量的更新和对检测用的数组的更新顺序顺手写反了,卡了一天半。。。看指导书的时候记了一些笔记在我的blog上,学到了很多写rust的技巧,闭包、链式调用之类的,以前对OS只有理论上的积累,写完之后也有了一些实践经验。

第三阶段总结

第三阶段正式接触到 ArceOS,写了6个小练习,感觉难度都不大,设置练习的目的应该是熟悉 ArceOS

  • print_color:在 putchar, println!, 或者 print-colormain 函数里改一下,加入颜色代码的部分就好了。
  • support_hashmap:考虑到手搓一个 hashmap 比较麻烦,而且这也不是OS训练营的重点,直接把 hashbrownhashmap 拿来用了。
  • alt_alloc:bump非常简单,byte_pos 从前往后,page_pos 从后往前,把对应的接口实现完就好了。
  • ramfs_rename:找到目录下名为 old_namenode,然后先从这个 dirindex 中删除 (old_name, node),再插入 (new_name, node)
  • sys_mmap:找到一块可用的内存,设置好映射,把文件的内容读取进来即可。
  • simple_hv:改一点异常处理函数的分支,跳过异常并把 sepc + 4,第一次了解到 hypervisor 都干了什么,以前只听说过这个名字。

然后又过了一遍 ArceOSdeepwiki,接触到了“模块化”这个概念,感觉挺有趣的,和rust的 module 很好的结合在了一起,降低了不同部分之间的耦合,用户程序通过用户库的 API 找到对应的 module。在构建内核时像搭积木一样选需要的部分,在一些嵌入式场景中可以量身定制,想要扩展成宏内核也很容易,可维护性也很高。

第一阶段

使用官方的 Rust 文档,跟着 rustlings 的评测机一道一道的解决,学会了点 Rust 基础基础知识。

第二阶段

这个阶段主要是通过一些基础的实验,去对用 Rust 语言写一个基于 RISC-V 架构的类 Unix 内核 rcore 进行了了解。在这过程中主要是参考官网提供的 rcore 文档对系统的各个重要组成部分进行了解,加上实验的辅助,对 rcore 的整体代码框架有了一定的掌握。实验难度不高,之前做过 xv6 的实验,对这类操作系统有一定的了解,在这过程中主要是一开始对代码框架不够熟悉,会有一困难。

第三阶段

这个阶段五一整个假期的时间完成了,时间长度不长,但是没有多余的事打扰,进度也是飞快。

本阶段开始对组件化内核的学习,刚接触,对内核了解不够深,一段时间后,组件化操作系统给我的感觉是一种体系结构清晰、明了,比之 rcore 这类宏内核,组件化由它名字一般,模块之间分隔明显,内部功能耦合度低,在实验中能快速的定位问题。

初始阶段的 Unikernel 内核,是组件化的基础,他与我们之前认识的宏内核有着很大的区别。

  • 应用和内核处于同一特权级,也就是没有用户态和内核态之分
  • 应用和内核共享同一地址空间,相互可见
  • 编译形成一个Image而后一体运行,Unikernel既是应用又是内核,是二者合体

这样的设计,应用和内核之间没有分割和切换,运行就变得简单高效,但缺点也很明显,安全的不到保障。

不过,作为基础阶段,简单是必须的,通过我们对模块的添加,内核会逐渐复杂。

示意图

根据我们不同的需求,开发对应的组件,我们的内核便可以跨越界限,实现不同的内核模式(如宏内核模式、Hypervisor模式)

示意图

示意图

组件是一个个可以复用、封装好的独立实体,内部功能对外部是不见得,但内核实现了对外部开放的API接口,通过这些公开的接口与外界通信。

这是实验完成后形成的组件化操作系统的结构:

示意图

arceos 总结

前言

arceos 是基于组件化的思想构建的,通过使用不同的组建,可以分别编译出Unikernel, Monolithic Kernel, Hypervisor三种内核架构。我是第一次深入了解Unikernel,Hypervisor这两种架构,几乎没有合适的书籍去展开这些内容,整一个五一假期下来,我对它们有了初步的理解和探索经验。

Unikernel

Unikernel 这部分是渐进式地学习,U.1~U.8一共8个Unikernel的内核,其中插入四个练习:

  1. 带颜色的输出
  2. 手写一个HashMap
  3. 实现bump内存分配算法
  4. 在axfs_ramfs下实现rename功能

第一个任务,只要理解了用户使用println!()的调用流就可以完成:

1
main --> axstd --> arceos_api --> axhal --> 硬件

这样的调用关系也基本和arceos中系统架构的层次关系吻合:

1
硬件 --> axhal --> ax_modules --> arceos_api --> axstd --> user

第二个任务,手写一个HashMap,这个任务其实很让人为难,因为它非常具有灵活性,简单的可以从OI wiki上面把哈希表的C++代码“翻译”成rust,再实现一个拉链法就完成了。复杂的可以参考rust的标准库,用裸指针操作,加上PhantomData标记生命周期和所有权。

我偷了点懒,实现了一个丐版的,我首先是参考xxhash实现了一个Hasher用于支持hash操作和随机数支持,然后包装了Vec用于内存分配和动态扩容的机制。避免hash冲突的方案,我选择了比较简单的线性探测法。

第三个任务,bump内存分配算法,基本原理就是维护一个指向内存起始区域的指针和一个偏移量,根据传入的Layout依次分配内存,无法单独释放内存块。只需要实现arlyAllocatortrait就可以接入内核中了。

第四个任务,虚拟文件系统会调用具体的文件系统进行操作,我最终找到调用的终点是axfs_ramfs下的DirNode结构体中,接下来就很简单了,就是修改两个DirNode下的BTreeMap<String, VfsNodeRef>

唯一一点胃痛的就是arceos/modules/axfs/src/root.rsrename函数中,src_path去除了挂载点的前缀,但是dst_path没去掉,这样实际上就不知道,dst_path是相对ramfs还是相对主文件系统根目录的了。

Monolithic Kernel

在宏内核中,基于Unikernel引入了用户态和系统调用的概念,可以看到相比U.8,M.1中增加了一些额外的模块。但是相比从零构建,我们大量复用了原有的模块,发挥了组件化内核的优势。

宏内核这部分只有一个练习,实现sys_mmap系统调用,并能够对文件进行内存映射。如果真的想要实现一个完整的sys_mmap,恐怕有些复杂。我们需要一个新的内存映射后端对文件进行映射,第一,要求能够从文件描述符中访问文件;第二,要求能够接受来自文件系统的信号,并及时将脏页同步到磁盘中。

这工作量确实有些大,所以我选择了参考提示信息“可以参考m_1_0中加载应用到用户地址空间的代码段”,先使用fd读取源文件的内容,使用alloc的后端分配内存,在查找用户态的页表获取对应的物理地址,最后写入原文件的内容。于是完成了本次练习。

Hypervisor

这一部分,在Unikernel的基础上引入RISC-V的H扩展,获得了虚拟化的能力。但是目前虚拟机的能力还非常的弱,准确来说只有两个对象,虚拟地址空间(aspace)和虚拟处理器(Vcpu)。

这一部分的练习中,我们只需要了解客户端在 vmexit_handler 中处理异常,然后针对我们的测试样例给客户端的a0a1寄存器进行赋值即可。

第三阶段总结

相比rcore,arceos复杂了许多,不过arceos中的内容在rcore中都有了一个丐版的实现,我做起来也快了不少。

在arceos中第一次了解到了组件化内核的概念,这三种不同类型的内核都可以通过像搭积木的方式,把需要的组件加上,再补齐特定的细节就可以实现从Unikernel到宏内核或者Hypervisor的跨越。

最后的最后,我谈谈自己的感受。开源操作系统训练营真的是一个非常优质的课程,有非常专业的教授老师,有积极回复的助教团队,在这里我一个普通的大学生感受到了深深的震撼,也许这命运的齿轮就在这里开始转动。

rcore 总结

前言

之前就对rust有一些了解了,跟着《Rust语言圣经(Rust Course)》学过一遍,也做了《Rust by Example》,所以rustling很快就做完了。不过rcore是需要把操作系统相关的知识利用起来的,做完之后是有很多感想和体会的。

ch1

第一个难点是,我第一次接触rust的裸机编程,不过很幸运,因为它和C/C++是很相似的,extern "C"声明一个弱引用符号, 就可以在rust内部获取到符号的地址,通过这种方式可以很轻松地与汇编进行交互了。

第二个难点是rust sbi, 首先我不太能理解SBI是什么,全称叫Supervisor Binary Interface,其次x86里面也没有这种东西可以参考。
我参考了《The RISC-V reader: an open architecture atlas》有关特权架构的部分,了解到riscv中,存在三层特权等级,从下往上依次是机器特权(M),监控特权(S),用户特权(U),SBI有一个类似于x86中bootlooder的作用,用于启动内核镜像,还有一个作用是管理最底层的硬件资源,包括电源、串口和时钟,给处于S模式的内核提供基本的支持。

ch2

这一个章节是一个批处理操作系统,从我们的视角上看,我们终于有了一个用户态和内核态的区分了。在这个操作系统中,一个内核和一个应用被打包到同一个镜像中,在开机时也同时加载到内存中,由内核来执行在内存中放置好的应用程序,结束后直接关机。

这一章节比较复杂的是build.rs,它的任务是加载应用程序并生成一个link_app.S文件,用于指导应用在内存中的布局,如何正确加载到合理的地方,并生成符号,让内核能够读取到应用的入口点。

这一个章节第一次加入了系统调用的概念,这样就将操作系统和用户程序之间联系起来了。在现代处理器中,为了安全,U态的应用是不能访问S态的内存的,这意味着用户应用将将永远不能调用内核的函数,修改内核的数据。但是内核将应用程序与机器隔离了,如果用户不能影响内核,那应用在内核的视角下,与一个while(true)是等价的。

所以系统调用显得尤为重要,用户要进入内核态,需要使用ecall进行调用,在riscv中ecall会使控制流会进入stvec指向的地址,这就是从用户态向内核态的转变入口了,我们在这个位置设置一个trap就可以进行捕获。内核工作结束后,内核调用sret在此返回用户态,只要保证切换前后必须的寄存器状态不变,那么应用就会像什么也没有发生一样正常工作了。

ch3

这一个章节是一个多道程序处理系统,这次需要处理两件事情,第一个是我们的系统需要一次性在内存中加载多个应用程序,第二个是实现分时多任务。

面对第一个问题,基本保持ch2中的代码不变,只需要依次把应用顺序地加载到内存相应的位置即可。唯一需要在意的就是用户的栈空间,这个只需要在内存的某个地方分配一个固定大小的空间即可。

但是第二个问题,需要考虑的就多的了。需要加入以下的内容:

  1. 进程的概念
  2. 上下文切换
  3. 时钟中断

这里的进程并不复杂,最好理解为一个应用程序的执行流,我们需要对这个执行流维护寄存器信息,来保证能够合理地进行上下文切换(switch)。switch操作是不用进入内核的,这个过程只需要保存原进程的寄存器到TaskContext中,再将目标进程的TaskContext恢复回当前的寄存器状态中,如此完成进程切换。

下来是时钟中断,对于时钟和时钟中断,我们需要有硬件的配合,这个任务主要交给SBI。但是要实现合理的时钟中断机制,还需要在trap_handler中添加对Trap的处理。

现在来完成题目,需要引入一个新的系统调用 sys_trace(ID 为 410)用来追踪当前任务系统调用的历史信息,并做对应的修改。

由于当前,我们的内核中还没有“虚拟地址”的概念,所以对于传入的指针可以直接进行读写,不用担心地址空间不同的问题,因此trace_request为0、1的情况可以轻松解决。对于trace_request为2的情况,我在每个进程的结构体中添加了BTreeMap<uszie, usize>来对每一个系统调用的此时进行映射,也由此完成了lab1。

ch4

这一章加入了地址空间的概念,这也是我做得最吃力的一章。我所遇到的核心难点如下:

  1. 如何对代码进行更好地debug?
  2. 用户空间和内核空间如何进行交互?

针对第一个难点,其实rcore里面已经给了很好的方案了,但是在gdb中看代码真的很痛苦!所以我选择使用vscode的codelldb插件,其实后端用的也是gdb,但是在vscode中看代码绝对会舒服很多。

想要用上vscode来debug,涉及两个文件

  1. .vscode/launch.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    {
    "version": "0.2.0",
    "configurations": [
    // 对 qemu 进行调试
    {
    "type": "lldb",
    "request": "launch",
    "name": "qemu-debug",
    // 从 ELF 文件中获取 debug 符号表
    "program": "${workspaceFolder}/os/target/riscv64gc-unknown-none-elf/release/os",
    "args": [],
    "processCreateCommands": ["gdb-remote 1234"],
    "cwd": "${workspaceFolder}"
    }
    ]
    }
  2. os/Cargo.toml

    1
    2
    [profile.release]
    debug = 2

配置完成后,直接启动

1
2
3
4
# 启动 gdbgdbserver
cd os
make gdbserver
# 在 vscode 调试页面启动 qemu-debug

实现了debug,接下来还需要处理用户空间和内核空间的交互问题。源代码中已经提供了一个translated_byte_buffer函数,用于获取用户态下,虚拟地址空间区域在物理地址空间的映射。因此,在内核态访问和修改用户态内存,可以通过处理返回的Vec<&'static mut [u8]>,逐个字节进行处理。接下来就可以对sys_tracesys_get_time通过这样的方式进行更新了。

ch5

这一章向操作系统添加了进程管理,前几章的task可以理解为一个执行流,并且其中只是维护了一个用于switch的上下文,缺少父子进程的概念,无法在进程中生成其他进程,同时也几乎没有任务调度的方案。

本章的难度并不大,通过参考已有的sys_forksys_exec系统调用,可以轻松添加sys_spawn

ch6

这一章向操作系统中添加了文件系统,这样,当我们运行任务时,我们不再需要将所有的应用加载到内存中,而是保存在更廉价的磁盘里。在easy-fs中特别值得关注的就是分层的思想,从下到上分五层:

  1. 磁盘块设备接口层
  2. 块缓存层
  3. 磁盘数据结构层
  4. 磁盘块管理器层
  5. 索引节点层

我对文件系统的认知基本来自于《操作系统导论》的VSFS(Very Simple File System),里面还没有硬链接的概念,而且并不区分Inode(面向用户操作的对象)和DiskInode(位于物理磁盘上)两者。也是这个原因,我在做实验时就很疑惑,因为我不清楚links需要在哪里进行维护。所以我还得找资料,我参考了《图解linux内核 基于6.x》中文件系统篇,最后将links放置在DiskInode中。这是这部分第一个需要考量的问题。

第二个问题是结构体的大小,layout.rs中部分结构体大小是需要严格划定的,因为那些结构体是磁盘上的映射,本质上是放在硬盘上的,所以存在一些对齐要求:

  1. DiskInode:125字节(由于#[repr(C)]进行4字节对齐,实际大小为128字节)
  2. DirEntry:32字节

所以如果放置在DiskInode中的links类型的大小大于3个字节,是会出现运行时异常的!参考《死灵书》:https://nomicon.purewhite.io/data.html

接下来的作业就不成问题了。

ch7

说真的ch7没怎么看,因为它也跑不起来,看报错大概是因为zerocopy库不支持nightly-2024-02-25的版本

1
2
3
4
5
[toolchain]
profile = "minimal"
# use the nightly version of the last stable toolchain, see <https://forge.rust-lang.org/>
channel = "nightly-2024-02-25"
components = ["rust-src", "llvm-tools-preview", "rustfmt", "clippy"]

有趣的是其他章节的版本用的工具链是nightly-2024-05-02

1
2
3
4
5
[toolchain]
profile = "minimal"
# use the nightly version of the last stable toolchain, see <https://forge.rust-lang.org/>
channel = "nightly-2024-05-02"
components = ["rust-src", "llvm-tools-preview", "rustfmt", "clippy"]

ch8

这一章节,可以看到rcore中的进程模型又发生了变化,和linux相似。线程作为最基本的调度单位只维护用于switch的上下文,而进程变成了多个线程的载体,维护所有线程的共享资源,主要就包括这一节添加的muxcondvarsemaphore

本节的练习是死锁检测,好巧不巧,我参考的《操作系统概念(原书第10版)》上就有对应的算法解析,类似于用于死锁避免的银行家算法。

最后这道题算是一个趣味题,像这样仅仅提供文字资料和要求的练习还是相对的考验人的!

第二阶段总结

第二阶段给我最大压力的应该就是虚拟地址和文件系统部分,其他章节还好。之前对于操作系统,基本停留在理论阶段,稍微看过一点xv6的代码,不过其实没有太深入,通过rcore的训练,我对操作系统的认识更加深入了。虽然每个地方都是做了一点点的训练,但是从ch1一直到ch8真的实实在在从零构建了一个较为完整的操作系统了。

这个过程中,最受益匪浅的还是查资料的过程,rcore的资料已经很丰富了,但是永远不能指望所有细节都能面面俱到,我参考了很多手册,从配置环境用的qemu9.0,需要修改sbi.rs参考的《RISC-V Supervisor Binary Interface》,还有需要深入rv39虚拟地址机制参考的《The RISC-V Reader: An Open Architecture Atlas》,这样的经历都非常的有价值。

最后是rcore的设计理念,最惊艳的还是文件系统中分层架构,借助rust的语言机制实现几乎零成本抽象,这个过程中,我也逐渐知道什么是好的代码,又一次对于“抽象”有了更深一层的理解。

总结

第一阶段

系统地过了一遍rust在操作系统开发中主要要用到的一些语法和对于rust来说tricky一点的简单数据结构

第二阶段

围绕rcore、riscv展开,从裸机多任务系统调用特权级内存管理文件系统以及并发,即使是初学对照文档也能够掌握操作系统的主要组成

第三阶段

该阶段围绕arceos。rcore更像是对传统操作系统的rust语言的重写,而arceos的项目组织更让人眼前一亮,借助rust现代化的语法,操作系统得以组件化而且通过一些rust宏的封装。在部件解耦的过程中并不牺牲一些处理或传参上的安全性,这使得在开发的过程中可以更多的将注意力摆到功能逻辑上,也能够更好适用于多元化的场景。通过6次练习,更是深切感受到了开发的便利。在这阶段还另外涉及了虚拟化相关的内容,更完善了我对现代操作系统的认知

操作系统对硬件而言是应用,对应用而言又是基础,这是一个承上启下的分支。在我的理解中这是一个十分复杂的“系统”,作为非专业必修,一直都不是很愿意下得了决心去学。训练营在统共十几个不长的章节就绘制出了这个复杂系统的整个框架,化繁为简,完成了我对操作系统认知的祛媚

学习历程

因为并非计算机专业出身,所以一直都有系统学习计算机底层原理的念头,趁着备研把理论性的知识学了一些.虽然现阶段只看了计算机组成原理,不过,早便萌生了自己动手去实践那些理论技巧的想法.去年秋冬季的夏令营也有报名,但由于时间安排仓促混乱,只得作罢.今年,虽然学习安排亦比较紧张,不过,想着动手得来的知识要比苦读书本记忆的更为深刻.

在第一阶段中,因为本身就有一些 Rust 开发经验,所以总体上是复习了一遍语言基础,前后并没有占用多少时间.第二阶段开始的时间稍晚,在 4 月下旬才腾出时间来专门研读 rCore 教程,也算是为了接下来学习操作系统做铺垫吧.一直拖沓的月底才把 rCore 教程 V3^1 看完,五月初到现在差不多 4 天的时间完成了各章节的实验题目.

主要收获

在阅读 rCore 教程的时候,始终有两个问题一直困扰我,在 rCore 的实现中,

  1. 内核虚拟内存空间和用户虚存被映射到了相同的地址段,这导致除了访问用户指针时需要先映射到实际的物理内存,稍有遗漏便可能导致访存异常,能否使用一个页表呢?
  2. 每个任务都有两个栈,即内核栈和用户栈,并且在调度任务时需要反复地在不同内核栈之间轮替,能否使用一个单独的栈解决问题呢?

单页表虚拟内存

单页表的实现在教程中有所提及,关于为何没有使用,文中给出的理由是单页表会导致熔断攻击.如果要避免熔断攻击,那么用户态就一定不能包含内核空间的页表项,这一点很容易想到.再稍加思考,只需要将地址空间一分为二,高位和低位分别留给内核和用户,然后再维护两个页表,其中一个只包含用户程序和必要的内核程序即可.后来,进一步研究了 Linux 的 PTI^2 机制后,发现基本的思路一致.并且从中了解到,在实现细节上,可以将两个页表放在物理内存的两个连续页面上,这样只需要若干寄存器运算就能实现换表.

起初我一直认为单页表无疑是比双页表更简洁方便的方案.后来,在落实时才发现,实现单页表的一大难点在于:内核被映射到虚拟内存的高位(比如 0xffffffc080200000),但实际上被加载到物理内存的低位运行(比如 0x80200000).这便到导致了内核链接时的基址和运行时的基址不一致,从而可能使得某些绝对寻址的访存错误,进一步引发难以察觉的 BUG.故,需要在内核启动时,需要手写一小段仅使用相对寻址的汇编代码来构造一个最小可用的内核页表,然后再进行更为细致地初始化流程.

在这个过程中,本打算参考 ArceOS^3 的实现,但此内核中直接在若干简短的 Rust 函数中完成了初始化.猜测应该是编译成了位置无关的代码,但总感觉这样有些”过度“依赖于编译器.因此,经过一番探索之后,用大约 50 行汇编代码完成了最小内核页表的初始化工作.这个过程是比较困难的,因为整个系统还没有启动,非常难以调试.不过也因此深化了对 GDB 等调试工具的理解和使用.

此外,在更进一步地思考后,发现每个程序的内核页表都是该程序初始化时的全局内核页表的一个拷贝,如果后续内核页表更新,可能会导致访存的不一致.对于此问题,最简单的办法就是预先分配好三级页表的全部项,这样后续就完全不必担心同步的问题.当然,这不可避免的占用了少量额外内存(更确切地,2MB),但这个“少量”对于嵌入式设备来说并非可接受的开销.因此,最好的方案似乎还是按需同步,考虑到内核页表不会变化太频繁,这应该是一个合理的选择.不过,最终该如何漂亮地解决这个问题,还需要进一步的调研.

单内核栈系统

刚开始学习多任务时,就一直纠结单内核栈该怎么实现.后来学习进程管理机制时,突然意识到,如果换一个角度看待用户程序:对操作系统来说,执行用户程序相当于调用一个函数,只不过,函数的返回依赖于陷入机制.这一点和用户角度很像,即不发生异常时,大多数时候,操作系统对于用户程序就是一个提供了许多系统调用的函数库.用户程序可以在一个栈上“不间断地”执行(陷入处理对用户透明),那么操作系统肯定也能实现类似的机制.

对于进一步的细节,相当于将 rCore 的上下文切换和用户执行态恢复整合一起:每次需要执行用户程序时,将当前的内核运行状态保存;而在处理用户陷入时,保存过用户的执行状态后,紧接着便加载先前保存的内核状态.整体上看,相当于内核把用户视为函数调用了一次,而在它返回后,内核便可以着手进行调度或者处理用户请求.

这样,一个最显著的优点便是使得内核的调度更加直观:从始自终,内核在一个循环中不断地“call”用户程序.并且,可以大幅减少全局变量的使用,更容易利用 Rust 所有权模型的优势,也有利好内核实现复杂的调度算法.

两阶段陷入处理

上述单内核栈的方案也引发了一个新问题:每次发生陷入都要保存大量寄存器,包括全部的用户通用寄存器、CSR 以及一部分调用上下文;而多内核栈的方案中,不发生调度时,相比可以减少约 2/3 的寄存器存取操作.因此,自然而然地,需要找到一种方法来减少上下文的保存操作.

由陷入机制入手,从本质上讲,陷入处理实际上是在任意位置插入一条函数调用,而各种处理器架构均定义了汇编层面的调用约定.既然陷入处理相当于函数调用,那么在陷入处理的入点,只需要保存调用者(caller-saved)寄存器和若干其它寄存器(CSR、tp、sp 等)即可.后来,偶然发现了 rCore 维护者提出的 fast-trap^4 快速陷入处理机制,这是一个相当通用(但有些复杂)的多阶段陷入处理机制.

深入研究之后,发现似乎两个阶段的陷入处理机制就能满足绝大部分的需求:

  1. 第一阶段,只需要保存约一半的寄存器(如上文所述),主要用于处理各种异常(包括内核和用户)以及一部分不需要切换上下文的系统调用.处理内核陷入仅需要此阶段即可.
  2. 第二阶段,保存完整的用户寄存器和内核调用上下文,沿用前文单内核栈方案的陷入处理机制.

在经过大量试错和调整之后,最终在不需要额外的访存操作的情况下,实现了一个相对通用的两阶段陷入处理方案.实现时,更进一步学习了许多 GDB 的调试技巧以及 Rust 声明宏的使用技巧.

总结

以上便是第二阶段中的主要收获,此外,关于文件系统和各种同步原语的实现,也偶有一些“灵光一现”,不过限于时间不足,并没来得及实践.对于下一阶段,打算集中精力攻克第三个选题,即基于 Rust 语言异步机制的内核改造.因为,此前一直对 Rust 的异步实现“耿耿于怀”,借此机会,可以更深入理解其工作原理.

Rust warm-up

  • April 4th to April 6th
  • Pattern match 和 Functional programming 好文明, 梦回 compiler。
  • Result<T,E> 很好封装
  • 后面忘了

rCore-OS

  • April 13th to April 20th

Lab 1 Time sharing multitasking

  • 按说明实现scheduling即可

Lab 2 virtual memory

  • 核心在于VA, VPN, PA, PPN之间转换,不要混淆

Lab 3 process

  • 结合execfork实现spawn

Lab 4 file system

  • OSInode, Inode, DiskInode三层封装
  • 实现 fstathardlink补充完成对应调用即可

Lab 5 thread

  • 死锁检测,按要求实现 Banker’s algorithm

ArceOS

  • April 22nd to April 29th

Lab 1 color print

  • DIY过bashrc的懂得都懂
  • pseudocode:
1
2
3
4
5
print_with_color(str):
if print with color:
print('\x1b[31m'+ str + '\x1b[0m')
else
print(str)

也可以换用别的颜色

Lab 2 hashmap

  • 照葫芦画瓢std::collections::HashMap

Lab 3 bump allocator

  • 没印象了

Lab 4 file rename

  • 更改parent dir中的name, 且本题只有一个rootdir

Lab 5 mmap

  • 因为测例也没有多次分配所以直接没写找freed memory的步骤,总体和rCore的sys_mmap思路一致,需要注意转换VA.

Lab 6 hypervisor

  • 改asm, 但我真不会汇编

rcore 总结

Lab1

最终设计思路

  1. 在每个task下用syscall_count:[u8;MAX_SYSCALL_ID]存下调用次数,
  2. mod.rs中为TaskManager实现incermentget方法

    踩坑记录

  3. 以为不同task在一起统计,后来询问群友得知分开统计。(感觉文档里最好应该说清楚)
  4. 一开始想用hashmap存,后面发现在no std下使用要用hashbrown库,但没有实现Clone,就不能用
  5. 在task中用一个数组存syscall的count,数组如果用usize不知道为什么write A B C的时候会卡住(可能太大了==???==),尝试用u8就成功了

lab2

最终设计思路

  1. sys_mmap:遍历vpns,使用translate建立vpn和ppn映射
  2. sys_unmmap:遍历vpns,使用memory_set.page_table.unmap(...)取消映射
  3. sys_get_timer:获取当前时间的微秒值并转换为 TimeVal 结构体获取其字节数组time_val_bytes,然后将其拷贝到用户空间的目标地址对应字节数组位置dst:Vec<& mut[u8]>
  4. sys_trace: 根据id得vpn+offset,使用translate获取其物理地址进行读写

踩坑记录

  1. 首先尝试实现sys_mmap,一开始想impl TaskManager中实现get_memoryset方法,尝试后发现返回&会有生命周期问题,试了很久后放弃了。直接在mod.rs实现一个map_vpns_to_ppns
  2. sys_get_time一开始没用translated_byte_buffer,用完后简化很多。然后遇到:
    1. 如何把time_val转为字节数组time_val_bytes。:core::slice::from_raw_parts
    2. 如何把time_val_bytes复制到dst:Vec<& mut[u8]>。 : core::ptr::copy_nonoverlapping
  3. 不小心直接把id转为ppn
  4. 没有设置用户空间地址上限

lab3

实现过程

  1. spawn 系统调用 结合 forkexec将两部分结合即可,试一次就成功了,没遇到什么问题
  2. stride 调度算法:主要需要在task里新加一个prio属性,然后修改fetch方法,一个坑是修改highest_tcb.stride是生命周期有问题要用一个{}块去限制

lab4

实现过程

  1. 主要是需要在inode里实现link unlink仿照clear方法,先定义op找到old_name节点,接着调用modify_disk_inode方法,在最后添加一项设置对应inode_idold的相同即可。要记得block_cache_sync_all()确保数据刷回磁盘
  2. 踩坑:一开始没有仔细看文档,easy-fs的文件全部挂在根目录下啊

lab5

算法记录

定义如下三个数据结构:
可利用资源向量 Available :含有 m 个元素的一维数组,每个元素代表可利用的某一类资源的数目, 其初值是该类资源的全部可用数目,其值随该类资源的分配和回收而动态地改变。 Available[j] = k,表示第 j 类资源的可用数量为 k。
分配矩阵 Allocation:n * m 矩阵,表示每类资源已分配给每个线程的资源数。 Allocation[i,j] = g,则表示线程 i 当前己分得第 j 类资源的数量为 g。
需求矩阵 Need:n * m 的矩阵,表示每个线程还需要的各类资源数量。 Need[i,j] = d,则表示线程 i 还需要第 j 类资源的数量为 d 。
算法运行过程如下:
设置两个向量: 工作向量 Work,表示操作系统可提供给线程继续运行所需的各类资源数目,它含有 m 个元素。初始时,Work = Available ;结束向量 Finish,表示系统是否有足够的资源分配给线程, 使之运行完成。初始时 Finish[0..n-1] = false,表示所有线程都没结束;当有足够资源分配给线程时, 设置 Finish[i] = true。
从线程集合中找到一个能满足下述条件的线程
1Finish[i] == false;
2Need[i,j] <= Work[j];
若找到,执行步骤 3,否则执行步骤 4。
当线程 thr[i] 获得资源后,可顺利执行,直至完成,并释放出分配给它的资源,故应执行:
1Work[j] = Work[j] + Allocation[i, j];
2Finish[i] = true;
跳转回步骤2
如果 Finish[0..n-1] 都为 true,则表示系统处于安全状态;否则表示系统处于不安全状态,即出现死锁。

实验过程

  1. enable_deadlock_detect只负责检查是否enable,具体实现在check_dead_mutexcheck_dead_sem还有banker
  2. 按照上面算法写即可

arceos 总结

1 基础调用流程

调用组件的流程 :app–>ulib:axstd–>arceos_api–>axhal–>axruntime–>app
axhal组件:_start()(初始化页表)–>rust_entry()–>
axruntime组件: rust_main()(打印logo+基本信息,初始化日志,显示kernel各各段范围,初始化内存分配器,platform初始化,初始化thread调度器,初始化设备和驱动,初始化文件系统网络系统,初始化中断,等待所有cpu启动)–>
apphello_world组件:main()–>
axstd组件: println(macros.rs)–>print_impl–>stdout::Write(io.rs)–>
arceos_api组件:ax_console_write_bytes–>
axhal组件:console write_bytes–>riscv64 put_char–>
sbi: putchar

使用features可以自由选择组件

print_with_color实验
直接在字符串两端加入颜色字符即可

2 动态内存分配支持

  1. 使用rust trait #[global_allocator]支持rust library中内存分配
  2. 支持hashmap思路:使用vec实现,根据key的hash值%hashmap容量作为位置存下value即可
  3. bump_allocator实现思路:[ bytes-used | avail-area | pages-used ] 比较简单记录下三个区域分开的位置即可

    3 ReadPFlash 引入页表

  4. PFlash的作用:Qemu的PFlash模拟闪存磁盘,启动时自动从文件加载内容到固定的MMIO区域,而且对读操作不需要驱动,可以直接访问。
  5. 为何不指定”paging”时导致读PFlash失败?ArceOS Unikernel包括两阶段地址空间映射,Boot阶段默认开启1G空间的恒等映射;如果需要支持设备MMIO区间,通过指定一个feature - “paging”来实现重映射。
  6. axhal提供体系结构无关的接口方法set_kernel_page_table写入根页表地址并启用分页

4 启动子任务


接口公开的是runqueue的对应方法

  1. spawn&spawn_raw:产生一个新任务,加入runqueue,处于Ready
  2. yield_now (协作式调度的关键)主动让出CPU执行权
  3. sleep&sleep_until睡眠固定的时间后醒来在timers定时器列表中注册,等待唤醒
  4. exit当前任务退出,标记状态,等待GC回收

5 协作式调度算法

  1. context_switch
  2. 协作式调度:任务之间通过“友好”协作方式分享CPU资源。具体的,当前任务是否让出和何时让出CPU控制权完全由当前任务自己决定。

6 抢占式调度

  1. 只有内外条件都满足时,才发生抢占;内部条件举例任务时间片耗尽,外部条件类似定义某种临界区,控制什么时候不能抢占,本质上它基于当前任务的preempt_disable_count。
  2. 只在 禁用->启用 切换的下边沿触发;下边沿通常在自旋锁解锁时产生,此时是切换时机。
  3. 推动内部条件变化(例: 任务时间片消耗)和边沿触发产生(例: 自旋锁加解锁)的根本源是时钟中断。
  4. CFS算法
    1. vruntime最小的任务就是优先权最高任务,即当前任务。计算公式:vruntime = init_vruntime + (delta / weight(nice))系统初始化时,init_vruntime, delta, nice三者都是0
    2. 新增任务:新任务的init_vruntime等于min_vruntime即默认情况下新任务能够尽快投入运行
    3. 设置优先级set_priority:只有CFS支持设置优先级,即nice值,会影响init_vruntime以及运行中vruntime值,该任务会比默认情况获得更多或更少的运行机会。
    4. 任务队列基于BtreeMap,即有序队列,排序基于vruntime

7 ReadBlock

  1. virtio设备的probe过程
  2. virtio驱动和virtio设备交互的两条路:
    1. 主要基于vring环形队列:本质上是连续的Page页面,在Guest和Host都可见可写
    2. 中断响应的通道主要对等待读取大块数据时是有用。
  3. 块设备驱动Trait - BlockDriverOps

8 加入文件系统

  1. 文件系统节点的操作流程:第一步:获得Root 目录节点 第二步:解析路径,逐级通过lookup方法找到对应节点,直至目标节点 第三步:对目标节点执行操作
  2. rename_ramfs实验
    1. 实验踩坑1:没有添加patch的部分axfs_ramfs={path="axfs_ramfs"}
    2. 实验踩坑2: axfs中rename函数有问题,没有考虑dst的挂载

9 引入特权级

  1. 分析从Unikernel基础到目标最小化宏内核需要完成的增量工作:
    1. 用户地址空间的创建和区域映射
    2. 在异常中断响应的基础上增加系统调用
    3. 复用Unikernel原来的调度机制,针对宏内核扩展Task属性
    4. 在内核与用户两个特权级之间的切换机制
  2. 实例
    1. 为应用创建独立的用户地址空间 涉及组件:axmm
    2. 加载应用程序代码到地址空间 涉及组件:axfs,axmm
    3. . 初始化用户栈 涉及组件:axmm
    4. 创建用户任务 涉及组件:axtask (with taskext)
    5. 让出CPU,使得用户任务运行 涉及组件:axtask,axhal
  3. 切使用系统调用时使用异常

10 缺页异常与内存映射

  1. 地址空间区域映射后端Backend,负责针对空间中特定区域的具体的映射操作, Backend从实现角度是一个Trait
  2. 如何让Linux的原始应用(二进制)直接在我们的宏内核上直接运行?
    在应用和内核交互界面上实现兼容。兼容界面包含三类:
    1. syscall
    2. procfs & sysfs等伪文件系统
    3. 应用、编译器和libc对地址空间的假定,涉及某些参数定义或某些特殊地址的引用
  3. elf格式加载
    1. 需要注意文件内偏移和预定的虚拟内存空间内偏移可能不一致,特别是数据段部分
  4. 初始化应用的栈
  5. 系统调用层次结构
  6. sys_mmap实现:先读到buf,在用户态页表找一片物理地址,转换为内核态地址,然后把buf里的东西复制过去。

    11 Hypervisor

  7. I型:直接在硬件平台上运行 II型:在宿主OS上运行
  8. Hypervisor支撑的资源对象层次:
    1. VM:管理地址空间;同一整合下级各类资源
    2. vCPU:计算资源虚拟化,VM中执行流
    3. vMem:内存虚拟化,按照VM的物理空间布局
    4. vDevice:设备虚拟化:包括直接映射和模拟
    5. vUtilities:中断虚拟化、总线发现设备等
  9. 最简Hypervisor执行流程:
    1. 加载Guest OS内核Image到新建地址空间。
    2. 准备虚拟机环境,设置特殊上下文。
    3. 结合特殊上下文和指令sret切换到V模式,即VM-ENTRY。
    4. OS内核只有一条指令,调用sbi-call的关机操作。
    5. 在虚拟机中,sbi-call超出V模式权限,导致VM-EXIT退出虚拟机,切换回Hypervisor。
    6. Hypervisor响应VM-EXIT的函数检查退出原因和参数,进行处理,由于是请求关机,清理虚拟机后,退出。
  10. Riscv64:M/HS/U形成Host域,用来运行I型Hypervisor或者II型的HostOS,三个特权级的作用不变。VS/VU形成Guest域,用来运行GuestOS,这两个特权级分别对应内核态和用户态。HS是关键,作为联通真实世界和虚拟世界的通道。体系结构设计了双向变迁机制。
    H扩展后,S模式发送明显变化:原有s[xxx]寄存器组作用不变,新增hs[xxx]和vs[xxx]
    hs[xxx]寄存器组的作用:面向Guest进行路径控制,例如异常/中断委托等
    vs[xxx]寄存器组的作用:直接操纵Guest域中的VS,为其准备或设置状态
  11. 为进入虚拟化模式准备的条件
    1. ISA寄存器misa第7位代表Hypervisor扩展的启用/禁止。对这一位写入0,可以禁止H扩展
    2. 进入V模式路径的控制:hstatus第7位SPV记录上一次进入HS特权级前的模式,1代表来自虚拟化模式。执行sret时,根据SPV决定是返回用户态,还是返回虚拟化模式。
    3. Hypervisor首次启动Guest的内核之前,伪造上下文作准备:设置Guest的sstatus,让其初始特权级为Supervisor;设置Guest的sepc为OS启动入口地址VM_ENTRY,VM_ENTRY值就是内核启动的入口地址,对于Riscv64,其地址是0x8020_0000。
  12. 从Host到Guest的切换run_guest每个vCPU维护一组上下文状态,分别对应Host和Guest。从Hypervisor切断到虚拟机时,暂存Host中部分可能被破坏的状态;加载Guest状态;然后执行sret完成切换。封装该过程的专门函数run_guest。
  13. VM-Exit原因
    1. 执行特权操作
    2. Guest环境的物理地址区间尚未映射,导致二次缺页,切换进入Host环境
  14. simple_hv实验:只需改变sepc寄存器,并将对应值存进对应寄存器

12 Hypervisor 两阶段地址映射

  1. 有两种处理方式:
    1. 模拟模式 - 为虚拟机模拟一个pflash,以file1为后备文件。当Guest读该设备时,提供file1文件的内容。
    2. 透传模式 - 直接把宿主物理机(即qemu)的pflash透传给虚拟机。
      优劣势:模拟模式可为不同虚拟机提供不同的pflash内容,但效率低;透传模式效率高,但是捆绑了设备。
  2. Hypervisor负责基于HPA面向Guest映射GPA,基本寄存器是hgatp;Guest认为看到的GPA是“实际”的物理空间,它基于satp映射内部的GVA虚拟空间。 GVA–> (vsatp)->GPA–> (hgatp) ->HPA
  3. Hypervisor的主逻辑包含三个部分:
    1. 准备VM的资源:VM地址空间和单个vCPU
    2. 切换进入Guest的代码
    3. 响应VMExit各种原因的代码
  4. 相比于宏内核多了vm-entry和vm-exit

13 虚拟时钟中断支持;虚拟机外设的支持

  1. 物理环境或者qemu模拟器中,时钟中断触发时,能够正常通过stvec寄存器找到异常中断向量表,然后进入事先注册的响应函数。但是在虚拟机环境下,宿主环境下的原始路径失效了。有两种解决方案:
    1. 启用Riscv AIA机制,把特定的中断委托到虚拟机Guest环境下。要求平台支持,且比较复杂。
    2. 通过中断注入的方式来实现。即本实验采取的方式。注入机制的关键是寄存器hvip,指示在Guest环境中,哪些中断处于Pending状态。
  2. 支持虚拟机时钟中断需要实现两部分的内容:
    1. 响应虚拟机发出的SBI-Call功能调用SetTimer
    2. 响应宿主机时钟中断导致的VM退出,注入到虚拟机内部
  3. 具体实现modules/riscv_vcpu/src/vcpu.rs
  4. 管理上的层次结构:虚拟机(VM),设备组VmDevGroup以及设备VmDev。Riscv架构下,虚拟机包含的各种外设通常是通过MMIO方式访问,因此主要用地址范围标记它们的资源。