0%

一阶段 Rust

Rust程序设计+Rust圣经+Gpt 看任意一本书基本题几乎都能做出来,个别疑难杂症问ai让它讲清楚当前的问题是什么。

二阶段 Rcore

这一部分内容重点在文档阅读,了解每一部分的数据结构设计以及底层接口实现,实验大都有类似的已经实现的功能函数作为参考。

Lab1

sys_trace:
当前所有任务均由任务管理器管理,每个任务均对应一个任务控制块。修改任务控制块数据结构新增系统调用记录数组,系统调用时修改对应任务控制块的系统调用数组。

Lab2

sys_get_time和sys_trace: :
这里引入了虚拟地址空间,存在地址空间隔离。该系统调用接收的地址是虚拟地址,需要通过访问当前任务页表获得真实的物理地址才能实现数据的有效写回和读取。
mmap 和 munmap 匿名映射:
map和umap的下一层接口均有实现,只需要找到然后调用。
踩坑点:这里的传入地址使用是start上取整end下取整形成一个range,这里看似下取整时多取了一页但是其实这是一个左开右闭的区间看起来多取的一页不会被使用。

Lab3

spawn 系统调用定义:
仔细观察fork和exec的实现可知道exec修改的东西很有限将这一部分修改直接替换到fork中就能实现spawn要求的功能。
stride 调度算法:
同样是修改任务控制块维护优先级stride等数据,优先级调度逻辑的具体实现在fetch函数中,当需要从任务队列中去出一个任务时先遍历一遍任务的stride,选取其中最小的来执行。

Lab4

sys_stat:
通过文件描述符获取对应的Osinde,然后向下访问获取信息。
sys_linkat:
在根目录下根据oldname找到对应的inode,复制一份inode并于newname构成新的目录项。
坑:这里维护链接计数是通过遍历根目录查找相同inode实现的,另一种方法是在磁盘的inode里面维护一个链接计数,但是没有找到相应的方法实现也没有实现思路遂没有采用。
unlinkat:
在根目录下直接删除对应项

Lab5

死锁检测机制:
感觉没法避免死锁只能避免进入死所的状态(可能理解有误)。

三阶段 Arceos

有roce的基础三阶段感觉还是比较轻松的

exercise print_with_color

使用 ANSI 转义序列即可

exerxise support_hashmap

这里是使用拉链法实现的一个hashmap

exersise alt_alloc

比较简单按照算法描述实现即可
坑:这里维护内存块的时候不要用Vec,使用数组或者链表即可

exercise ramfs_rename

在axfs_ramfs下作修改为DirNode 实现rename方法。实际上就是根据名字remove对应的inode,然后合并新名字再插入回去。
坑:这里首先要到根目录去patch一下axfs_ramfs依赖,修改依赖成功后报错仍然会是unsupport,实现rename方不报错。

exercise sys_map

先开一个缓存数组把文件内容读进来,然后在userspace中查找尚未分配的area,为该部分area的虚拟地址建立物理映射。然后调用函数将缓存区的文件内容写入到完成映射的虚拟地址空间。

exercise simple_hv

为sepc添加对应的指令偏移然后修改寄存器
坑:不知道为什么需要在两个trap下的代码完成后提前return false而不能等match结束返回false,不然就会卡死。

第一阶段

使用官方的 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 的异步实现“耿耿于怀”,借此机会,可以更深入理解其工作原理.

rcore第二阶段学习心得

以下是针对 rCore 操作系统各个技术方向的详细扩展叙述:

1. 应用程序与基本执行环境

rCore 的应用程序执行环境构建在硬件抽象层(HAL)之上,其核心依赖 RustSBI 提供的引导和硬件初始化服务。RustSBI 在启动阶段完成以下关键任务:初始化 CPU 核心、配置物理内存保护区域(如设置 PMP 寄存器)、启用时钟中断和外部设备中断(如 UART 串口)。应用程序以 静态链接的 ELF 格式 加载到内存中,内核解析 ELF 文件头获取代码段(.text)、数据段(.data)和未初始化数据段(.bss)的虚拟地址,并为其分配物理页帧,建立虚拟地址到物理地址的映射。

用户程序运行在 RISC-V U 模式(用户态),通过 ecall 指令触发特权级切换到 S 模式(监管态)执行系统调用。内核在初始化用户环境时,需配置用户栈空间(如分配 8KB 栈内存)并设置 Trap 上下文,确保异常处理时能正确保存和恢复寄存器状态。系统调用参数传递遵循 RISC-V ABI 规范:系统调用号存入 a7,参数依次存入 a0-a6,返回值通过 a0 返回。例如,sys_write 调用时,用户程序将文件描述符、缓冲区地址和长度分别存入 a0a1a2,内核通过 a0 返回实际写入的字节数。

为确保安全性,内核通过 内存保护机制 阻止用户程序直接访问内核空间。例如,用户程序若尝试访问高于 0x80000000 的内核地址,将触发页错误异常(scause=13),内核根据异常类型终止进程或按需分配物理页。


2. 批处理系统

批处理系统的核心设计是 顺序执行多个用户程序,每个程序独占 CPU 直至完成。内核在编译阶段将多个应用程序的 ELF 文件链接到内核镜像中,并在固定物理地址(如 0x80400000)依次加载。程序加载时,内核执行以下步骤:

  1. 内存清零:清除前一程序残留的数据,防止信息泄漏。
  2. 页表隔离:为每个程序创建独立的页表,仅映射其代码段、数据段和用户栈,避免越界访问。
  3. 上下文初始化:设置程序的入口地址(ELF 的 entry 字段)和初始栈指针(sp 指向用户栈顶部)。

当程序主动调用 sys_exit 或发生致命错误(如非法指令)时,内核通过 TaskManager 切换到下一程序。任务切换的关键在于保存当前程序的 任务上下文(TaskContext),包括 ra(返回地址)、sp(栈指针)和 s0-s11(保留寄存器),并通过汇编函数 __switch 切换到目标程序的上下文。批处理系统需确保 原子性切换,即在切换过程中屏蔽中断(通过 sstatus.SIE 位),防止时钟中断导致状态不一致。


3. 多道程序与分时多任务

分时多任务通过 时间片轮转算法 实现 CPU 资源共享。内核为每个任务维护一个 任务控制块(TaskControlBlock, TCB),包含以下元数据:

  • 任务状态:运行(Running)、就绪(Ready)、阻塞(Blocked)。
  • 时间片计数器:记录剩余时间片长度(如 10ms)。
  • 优先级:用于调度决策(如实时任务优先级高于普通任务)。
  • 上下文信息:包括寄存器快照和页表地址(satp 值)。

时钟中断(通过 sie.STIE 使能)触发调度器执行以下流程:

  1. 递减当前任务的时间片计数器,若归零则标记为就绪状态。
  2. 从就绪队列中选择下一个任务(如按优先级或轮询策略)。
  3. 调用 __switch 切换上下文,并更新 satp 以切换地址空间。

协作式调度 依赖任务主动调用 sys_yield,适用于 I/O 密集型任务减少切换开销;抢占式调度 则通过中断强制切换,适合 CPU 密集型任务提升吞吐量。内核需处理 临界区竞争,例如在修改任务队列时关闭中断,防止并发修改导致数据结构损坏。


4. 地址空间

地址空间通过 Sv39 分页机制 实现虚拟内存管理,支持 512GB 的虚拟地址范围。虚拟地址被划分为三级页表索引(VPN[2]、VPN[1]、VPN[0]),每级页表包含 512 个条目(8 字节/条目)。内核维护 全局页帧分配器,按需分配物理页帧并更新页表条目(PTE)的权限位(如 R/W/XU 位)。

用户程序访问虚拟地址时,若触发缺页异常(scause=12/13/15),内核执行以下处理:

  1. 检查访问地址是否合法(如位于用户代码段或堆栈范围内)。
  2. 分配物理页帧,建立虚拟地址到物理地址的映射。
  3. 刷新 TLB(通过 sfence.vma 指令),确保后续访问使用新页表。

内核自身采用 恒等映射(虚拟地址等于物理地址),简化直接内存访问(如操作外设寄存器)。用户程序通过动态映射访问特定设备(如 MMIO 区域),需在内核中注册设备内存范围并配置页表权限(如设置为不可执行)。


5. 进程与进程管理

进程是资源分配的基本单位,其控制块(PCB)包含以下信息:

  • 进程标识符(PID):唯一标识进程的整数。
  • 地址空间:指向页表的指针(satp 值)和内存映射信息。
  • 文件描述符表:记录打开的文件、管道或设备。
  • 父子关系:父进程 PID 和子进程链表。

进程创建 通过 fork 系统调用实现,内核复制父进程的地址空间(写时复制优化)和文件描述符表,并为子进程分配新的 PID。exec 系统调用则替换当前进程的地址空间,加载新程序的代码和数据。

进程调度 基于状态机模型:

  • 运行 → 就绪:时间片耗尽或被高优先级任务抢占。
  • 运行 → 阻塞:等待 I/O 完成或信号量资源。
  • 阻塞 → 就绪:资源可用或事件触发。

进程退出时,内核回收其物理内存、关闭打开的文件,并通过 waitpid 通知父进程回收终止状态。若父进程已终止,子进程由 init 进程 接管以避免僵尸进程。


6. 文件系统与重定向

rCore 的虚拟文件系统(VFS)抽象了 文件、目录和设备 的统一接口,支持以下操作:

  • 文件读写:通过 File traitreadwrite 方法实现,具体由设备驱动(如 UART)或块设备(如 virtio-blk)完成。
  • 目录管理:维护目录项(dentry)链表,支持 mkdirreaddir 操作。

重定向 通过复制文件描述符实现。例如,执行 echo hello > output.txt 时,shell 进程执行以下步骤:

  1. 打开 output.txt 并获取文件描述符 fd
  2. 调用 sys_dup 复制 fd 到标准输出(fd=1)。
  3. 子进程继承修改后的文件描述符表,sys_write 输出到文件而非控制台。

文件系统通过 页缓存 优化性能,将频繁访问的数据缓存在内存中,减少磁盘 I/O 操作。索引节点(inode)记录文件的元数据(如大小、权限和物理块地址),并通过 日志机制 确保崩溃一致性。


7. 进程间通信(IPC)

IPC 机制包括以下实现方式:

  • 共享内存:内核分配物理页帧,并映射到多个进程的地址空间。进程通过信号量或自旋锁同步访问。
  • 管道:基于环形缓冲区的 FIFO 队列,内核维护 pipe 结构体记录读写位置和等待队列。sys_pipe 创建管道后返回两个文件描述符(读端和写端),进程通过 readwrite 系统调用传输数据。
  • 信号:内核为每个进程维护信号处理函数表。当进程收到信号(如 SIGKILL)时,内核修改其 Trap 上下文,强制跳转到注册的处理函数。信号处理完成后,通过 sigreturn 系统调用恢复原执行流程。

消息队列 是另一种 IPC 方式,内核维护消息缓冲区,进程通过 msgsndmsgrcv 发送/接收结构化数据,支持阻塞和非阻塞模式。


8. 并发机制

内核并发通过以下机制管理:

  • 自旋锁(SpinLock):通过原子指令(如 amoswap)实现忙等待,适用于短期临界区(如修改任务队列)。使用前需关闭中断(sstatus.SIE = 0),防止死锁。
  • 互斥锁(Mutex):在锁竞争时让出 CPU,将当前任务加入阻塞队列,切换其他任务执行。解锁时唤醒等待任务。
  • 条件变量(Condvar):与互斥锁配合使用,通过 wait 释放锁并阻塞,notify 唤醒等待任务。适用于生产者-消费者模型。

用户态线程(协程)通过 异步运行时 实现,内核提供轻量级上下文切换(如 swapcontext)和非阻塞 I/O 系统调用(如 read_async)。Rust 的 async/await 语法糖将协程编译为状态机,运行时调度器根据事件(如 I/O 完成)切换协程执行。

内存安全 通过 Rust 的所有权系统和 Send/Sync trait 保障。Send 允许数据跨线程转移所有权,Sync 允许数据被多线程共享引用。编译器静态检查数据竞争,确保并发代码的安全性

rCore学习笔记

根据学习顺序从头梳理一下操作系统的发展历史:

原生之初

CPU做了什么?

CPU-Central Processing Unit,中央处理器,由IFU,EXU,MMU等等等组成,就不赘述了。以RV64IM指令集架构的角度来讲吧:

  • 门电路只能根据输入来生成输出

  • 一个运算模块的输入:ep1ep2 输出:res 取决于具体的指令

  • 以一个简单的只有加减的ALU为例:MUX(choose, res_add, res_sub)

  • module ALU (
        input choose,
        input [63:0] ep1,
        input [63:0] ep2,
        output [63:0] res
    );
        assign res = choose == 0 ? ep1 + ep2 : ep1 - ep2;
    endmodule
  • 电路只管res_addres_sub的计算,结果是都算出来的,但是最后的res被通过选择器来选择输出res_sub | res_add

  • CPU就是一个只管埋头干活的驴(有限状态机),而choose是CPU根据PC取值译码后得到的,用来选择哪个输入作为输入以及哪个输出作为输出

  • 所以在CPU看来他只要不停的根据时钟进行不停01摇摆就行,而操作系统关心的就多了,包括“特权级的转换”/“进程调度”/“IO”等等

  • CPU角度的函数调用:我不知道发生了啥,只知道gpr[rs1] = pc+4, pc = gpr[rs2],即:用了一下全加器,存了俩reg,调哪个函数全看pc指向内存里面的哪

  • CPU角度的设备读写:我不知道发生了啥,只知道某总线使用某协议按照某种规律发了一串特定的bit波形

  • CPU角度的异常处理:我不知道发生了啥,只知道某个被人类称为CSRs的寄存器组里面几个寄存器取了存,存了取

  • CPU角度的面向对象:我没有这个概念,只知道一个数字存哪取哪全靠选择器最后是否选中MEM,或者选中GPR?CSR?更高层次的汇编里面称这个决定选中哪里的一串01的数字为指针?

所以,CPU压根不知道一些奇奇怪怪的高级概念,更不知道什么奇奇怪怪的转换特权级,只知道,当前状态的输入,以及自己时钟边沿敏感后存下选中的输出。在CPU看来,只是一堆与或门在0101变,有的0101会连接到选择器的与或门上,决定了下一波存的0101而已。

仿佛充电就不停运动的牛马,优雅的被称为“程序是一个状态机”,不停的在状态之间兜兜转转

ISA做了什么?

​ 指令集架构,以上面的ALU为例,规定了choose == 0时选择res_add,否则选择res_sub。在真实的CPU中,ISA规定了每一条指令的格式,数据类型,寄存器,中断/异常,字节次序等等等。比如末尾后7位是opcode,opcode是0110011是基本的运算族指令,比如add,sub等等。简而言之就是上面的规定了choose == 0时选择res_add,还是选择res_sub

​ 它给人们提供了一套规范,可以有规范可查,遇到情况可以查相关的手册或者文档,有册可查,有规可依。

规定了你看见A就说1,看见B就说0,AABBA就是11001,让CPU的电路运行有据可查

SBI做了什么?

​ 一套二进制接口,封装特定操作的软件程序,刷在PC初始值位置,用于初始化CPU的各项寄存器(广义,包括GPR,CSR,PC等等)中的值,之后提供了一个函数映射表的位置,当某些情况下ecall指令的时候,就把PC设置为这个函数映射表的位置,执行所指向的执行流,之后某个时机再返回到某个特定的位置。

根据规范封装特定的指令,以及初始化指定的寄存器,初始化结束后,PC也被初始化到了OS所在的位置

OS做了什么?

​ 也相当于一套二进制接口,是一个软件程序,用来管理硬件的各项资源,大部分的操作通过调用SBI提供的接口,很少直接操作硬件。决定了某些寄存器的值,以改变PC的指向,从而改变用户眼中的当前执行的程序(执行流)。

因为遇事不决调SBICall,所以可以当是一个封装在特定SBI实现上的一个软件,或者调SBI实现的接口/库的一个程序

Syscall做了什么?

​ OS提供的一个函数映射表,当某些情况下ecall指令的时候,就把PC指向映射表的位置,映射表会根据参数和栈来决定下一步做什么,之后某个可能的时机再返回原来执行到的地方。

封装了一个函数映射表,可以根据参数的值选择要执行的操作

用户程序做了什么?

​ 依据各基于的标准库,来编写程序或更高层级的库,然后调用现有的依赖编写自己心目中的程序或者功能。游戏,网页,基础设施,编译器等等等,一切他们觉得让生产生活更方便的东西,比如:饥荒营火重生Mod,QQ,雷碧超市小程序……

使用操作系统提供的接口,或者在操作系统之上的虚拟机平台提供的接口,拼接,组装,变成自己需要的程序

所以他们做了什么?

​ 总结来看,仿佛就是一层调用一层的接口,一层接着一层的调包导库,实际上也是,不过是为了学习或使用方便而简化了一些东西而已。这些简化的东西在上层的使用者几乎不用关心,被称为“抽象”

​ ISA规定了硬件都有哪些寄存器,遇到哪些指令该怎么做,提供了UB以外的确定性硬件操作(不确定的比如/0才是UB吧,所以这句就是废话是吧…)

​ SBI初始化硬件提供的寄存器,之后跳转到OS所在位置,确保了OS的执行正确,不会遇到get_time的时候,一看,俩寄存器还是未初始化的随机脏值

​ OS将“资源”具象化,来分配,调度OS所知晓的内存空间,存储空间。同时,也创建了对于用户程序不可见的物理地址之下的虚拟地址,为系统安全提供保障。用户所编写的程序将不需要关心真实的物理地址在哪,程序会被安排到什么地方。OS:“给你这些你就用,其他的别管!”

​ 用户程序,在OS的抽象下,用户以为自己独占了一台物理机,当其需要操作系统的服务,比如获取当前系统时间的时候,调用Syscall

​ 而为了区分不同层级的程序,为了用户越界访问系统的时候,硬件能给点反应,触发中断,切回OS叫醒OS:“你用户越界了!”,部分CSR寄存器的某些位连接了译码器以及MMU的部分部件,为的就是检测到不对就警报进行中断。为了配合这个硬件机制,软件上就得设置这些位是0还是1。比如SBI初始化的mstatus,OS初始化的sstatus等等。这样子在特定位没被设置的时候,某不属于此特权级的指令的执行就会触发中断。但是如果软件不设防,一切运行在裸机(没有SBI,没有OS来管理调度资源的芯片),那么特权级将会失去意义。

​ 这一过程就像你MC(一个游戏)开服务器,你不设防,认为一切道法自然,那么熊孩子炸了你家你也不知道,你也只能接受。但是当你软件开启“游客权限:不准使用TNT”,“好友权限:可申请使用TNT”的时候,你也就划分了特权级的抽象:“USM” - “房主 好友 游客”。

所以,学的广不能学的深,调库侠不如底层佬什么什么的都是伪命题,都是在认知范围内合理利用工具而已

批处理

当我裸机能运行一个程序的时候,我就想让他运行两个,但是切换好麻烦,能不能一次输入,就能得到所有想要的输出?

​ 好,将两个程序拼接

万一前面的出错怎么办?

​ 将PC指向下一个程序,这程序就算运行结束了

有程序破坏系统篡改数据怎么办?

​ 我得规定程序的权限:不能随意访问空间,不能破坏系统

简而言之,批处理系统是操作系统的雏形,通过简单的程序二进制拼接起来,依次执行,进行了简单的错误处理以及特权级切换

多道分时

提高系统的性能和效率是操作系统的核心目标之一,一个个排着队,不论长短一直等着就不耐烦。为了解决这个问题,不同目的的操作系统有了不同的策略。执行流在被阻塞的状态下让出处理器是通用的。其余的,急者优先,先来先服务等等调度算法应用在了不同领域。但是最常见的还是基于时间平均分配时间片的分时复用算法。底层是每隔一小段时间触发一次时钟中断来切换执行流。

在CPU角度,CPU是一个状态机,下一步的结果只与当前状态有关,所以切换执行流实际上就是保存了CPU当前的状态,即:Context-上下文,之后找个时间再恢复。所以CPU当前的状态是什么?

取指的PC:关系到指令执行到哪了,该执行哪个了

ISA规定的寄存器组:32个寄存器,起码zero恒0,不用保存,sp要手动霍霍调度执行流,不保存

部分执行流相关csr:得按需保存,比如sstatus,sepc,scause等等,按需?比如裸机保存的就是mepc而不是sepc

地址空间

将程序按部就班刷入固定位置,每次有新程序还得重新安排程序所在的位置,且用户程序能直接访问物理地址本来就是不安全的。

所以地址空间思考的就是:如何使用一个抽象,让所有程序不必想自己得被安排到哪,且隔绝不同程序之间的地址空间?

PageTable一个是方便MMU硬件查找虚拟地址对应的物理地址,一个是维护了软件的接口,方便操作系统需要时霍霍。

[MapArea]保存了连续的虚拟地址的映射,以及此段地址的权限,分配和回收直接霍霍PageTable,方便了OS维护PT

此时程序看到的地址都是OS提供的抽象幻象,隔绝了程序的同时,用户编写程序也不用思考程序将会被放在哪了!

进程管理

之前的程序都是确定要运行的,被刷入的程序只能决定会被运行,不能决定自己什么时候不运行,什么时候去运行。人机交互差。所以一开始只执行一个负责和用户交互的程序,后续由用户选择执行特定的程序。提高了用户的自由度和体验。

现在的程序可以被用户选择是否要运行,以及什么时候开始运行。比起之前开机只是为了关机的死程序疙瘩多了人性化。

比死程序疙瘩多的操作也就是:如何将创建一个执行流的上下文,将上下文加入调度队列,以及获取上下文返回的信息

由于地址空间的支持,所有程序的起始地址都可以相同,应该说,都是相同的,所以每次创建执行流的时候,虚拟起始地址每个程序都一样,不一样的只是被映射到的物理地址。

文件系统

​ 一次性刷好所有的程序,有新的程序还得重新刷,关机内存的数据就没了,所以需要一种持久化存储数据/程序的方式。然后管理这种持久化存储的程序的系统就是文件系统,这种持久化的程序或者数据的抽象就是“文件”。但是文件平铺对于习惯分门别类的人来说阅读性太差,就有了“目录”。归结到底“目录”也是一种特殊的文件,其中存的内容是规则的目录项罢了。

​ 所以,目录就是一个目录项的容器,目录项就是描述一个目录或者文件的指针和属性的集合。而一切的起源就是根目录,就是目录树的根节点。通过一层层指针,就能找到最终指向的文件区域。

有了文件系统,可以很方便的持久化程序和数据。程序又何不是一种数据?

之后想运行程序的时候就可以在持久化的数据中寻找对应的文件,读入内存,后创建PCB,加入调度

管道通信

不同进程之间的通信,可以通过父进程的文件描述符来进行读写buffer。

并发和锁

​ 当进程变为线程的管理容器的时候,线程共享着进程的资源。由于线程异步执行的不确定性,一个内存区域(资源)被不同线程访问修改后不能保证原执行流的原子性。很有可能一个数据,比如一个u128的变量,在sd低64位的时候被调度了,导致写线程还没把高64位写进去,读线程读的时候会发现数据错误。所以就需要一种机制来保证这种边沿区域的读写原子性。没写完就读的,让读的先缓缓,切回写的,写完再把要读的放出来读就不会出错了。

​ 但是往往一个线程有时候并不只需要一个资源,有时候需要N个不同的资源。这时假设A线程拿到了资源1,B线程拿到了资源2,但是B要访问资源1,A要访问资源2,但A拿着资源1睡去了,B就只能干耗着,形成死锁。死锁的解锁方式很多,比如卡足够时间就释放,过段随机时间再请求等等。但是如果可以预防死锁,在可能发生死锁的时候拒绝分配,让可以安全执行完成的执行流先完成,那么就不会形成死锁。所以在ch8完成了银行家算法的化简版。

Read more »

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, 但我真不会汇编