组件化操作系统设计与实现
oscamp 第三节阶段总结. 由于部分操作系统原理性质的内容在二阶段中已经学过了, 因此总结主要针对两部分:
- 新概念: ArceOS 在传统内核上的创新
- 老概念: 与 rCore 的实现不同的部分, 以及分析原因
为什么要组件化?
ArceOS 的优势区间在于快速针对特定领域构建出一个最合适的内核, 主要解决的痛点就是”从头开发一个操作系统太繁琐”, 而”现成的方案并不完全适用于应用场景”. 一些操作系统的可扩展性通过内核编译选项或者配置文件来实现, 但是这种方法无法在更深层次修改组装一个操作系统内核, 因此 ArceOS 采用了组件化的方案灵活组装某些功能.
ArceOS 的主要结构 (Unikernel)
- ArceOS 提供
axhal
(Hardware Abstraction Layer) 封装了硬件相关的接口和driver
, 整理统一为axhal
组件对外的接口 - 基于
axhal
, 我们有axruntime
作为运行时. - 应用与内核处于同一特权级, 编译为一个 Image 运行, 在实际应用中, 非传统 PC 上的操作系统往往不需要支持多种多样的任务(包括这个实验到最后也没有并行的实现, 只有任务并发调度)
- 应用于内核的交互在当前 Unikernel 阶段下是通过
axstd
(对标 Rust 标准库), 而axstd
会使用arceos_api
提供的操作系统 API - 在未来宏内核拓展时则是通过实际的系统调用, 经过 Trap 在中断向量表中异常处理程序调用
arceos_posix_api
提供的操作系统 API, POSIX 标准的 API 是为了能够让glibc
/musl-libc
的 Linux 应用程序能够被兼容
- 应用于内核的交互在当前 Unikernel 阶段下是通过
ArceOS 的引导过程(Unikernel)
- 链接脚本指定的起始地址
_start
, 这个过程里:1. 先建立栈, 以便可以调用函数 2. 准备页表, 启用 MMU 3. 切换地址空间后偏移一下栈指针 4. 调用 `axhal` 中的 `rust_entry`
axhal
组建的rust_entry
, 这个过程:- 清理
bss
段 - 设置中断向量表
stvec
寄存器 - 调用
axruntime
的rust_main
- 清理
axruntime
的rust_main
, 这个过程:- 相当于内核正式启动, 打印 logo 和必要信息等
- 重映射, 以及一些必要的初始化(如设备和中断初始化)
- 调用应用程序中的
main()
函数
ArceOS 中一些特定功能的实现
组件组装编译(通过 Rust 的 feature 进行条件编译)
分页机制
还是熟悉的 SV39. 但是注意 Hypervisor 下拓展了两位, 不过我们暂时并没有管这两位.
内核启动早期恒等映射 SBI 和 Kernel, 然后再把这一段映射到高地址(0xffff_ffc0_0000_0000
以后), 目的是为未来宏内核拓展留下足够空间, 让 Unikernel 在高地址运行. 同时 pc
, sp
也同样要增加偏移.
物理页帧分配与动态内存分配
ArceOS 中的虚拟页面是通过 MemoryArea::new(start, size, flags, Backend::new_alloc(populate))
来将 [start, start + size)
映射到 Backend
新创建的物理页面上的, 而 Backend 则是:
1 | // axmm/src/backend/alloc.rs |
通过动态内存分配器分配一段地址(返回的是虚拟地址), 再将虚拟地址转换为物理地址返回, 这样就新 allocate 了一个物理页面.
ArceOS 的动态内存分配器提供两个功能: 按字节分配和按页分配. 后者是通常的动态内存调用, 前者是为了操作系统本身服务的, 相当于 rCore 的 FrameAllocator
, 对物理地址进行管理, 分配页面. 字节分配器不够了也会向页分配器发送请求要求追加内存.
ArceOS 的动态内存分配器框架向下封装算法, 向上暴露接口. 常见的(字节)动态内存分配算法有: TLSF, Buddy, Slab 等. 设计动态内存分配算法是挑战题目之一. 但我还一点没做 (页分配是通过 Bitmap
管理的)
任务与任务调度
数据结构方面和 rCore 差不多, 不过似乎没有内部可变性模式了, 不知道是出于什么考虑.
ArceOS 的调度框架由 “当前可被调度的任务队列”run_queue
和 “当前阻塞中不可被调度的任务队列” wait_queue
和一个 “现在在运行的任务” task
组成 (系统还有 idle
待机任务 和 gc
回收清理任务); 向下需要 TaskContext
切换和特定调度算法的支持, 向上暴露 yield
sleep
spawn
等 API. 大部分 API 都非常直观, 操作相应含义的队列和任务控制块即可. 因此我们主要介绍向下需求的算法: 上下文切换和调度算法.
上下文切换与 rCore 几乎一致, 保存
ra
,sp
和 RISCV 调用约定规定的通用寄存器, 保存并切换到下一个任务的上下文即可协作式 FIFO 调度算法: 字面意思, 按照队列的先进先出模式按照特定顺序调度
抢占式 Round Robin 调度算法:
被抢占 = 内部条件 && 外部条件
外部条件是当前任务的抢占开关, 在”禁用抢占”->”启用抢占”的临界边缘触发. 这里我们在时钟中断时执行的
on_time_tick
里更新当前任务的外部抢占开关.内部条件是内部设置的抢占 guard, 以及自旋锁等, 防止在任务不希望的时候被打断.
新任务仍会在队尾加入, 调度顺序不变, 只是多了一个抢占机制.
抢占式 CFS 调度算法(Completely Fair Scheduled, “绝对公平”的调度算法)
$\text{vruntime} = \text{init_vruntime} + \frac{\delta}{weight(\text{prior})}$
- $\text{vruntime}$ 最小的任务优先执行
- 新任务的 $ \text{init_vruntime}$ 初始化为 $min(\text{vruntime}_{tid})$, 以便让新任务尽快被调度
- 每次触发时钟中断对当前任务的 $\delta$ 按照上述式子递增, $\text{prior}$ 是优先级, 优先级越高递增越缓慢也就越容易处在前列并被调度; 然后把 $\text{vruntime}$ 最小的任务放在队首
存储设备(以及其他设备)的管理框架 (与 rCore 不同)
存储底层设备是 QEMU PFlash
模拟的闪存磁盘, 通过 MMIO 方式将文件映射到特定内存地址(在 SBI 起始的 0x80000000
之前, 在 qemu 的设备树之后)
也可以从块设备读取其数据 (drv_block
和 drv_virtio
模块)
代码结构上, AllDevices
管理 block
net
display
等所有的设备, 设备用 struct AxDeviceContainer<D>(Vec<D>)
统一管理, 可以管理不同类型的 device.
驱动是在 axruntime::rust_main
调用的 init_drivers
中初始化的, 先基于总线 probe
设备, 然后通过宏生成代码放入 AllDevices
文件系统
文件设备基于块设备, 块设备的行为描述为 trait BlockDriverOps
.
块设备以上的层次结构如下:
ArceOS 的文件设备相对 rCore 来说更加贴近现实, 如目录和挂载的概念: 目录项是一个 DirNode
, 可以把一个 fs
文件系统挂载到这个目录上, 挂载 (mount) 的意义实际上就是把磁盘上扁平的数据关系建立为树形结构
宏内核支持
目前为止我们的 Unikernel 架构 ArceOS 与宏内核 (Monolithic) 架构八竿子打不着还有很大差别:
- 没有特权级切换
- 没有应用自己的地址空间
- 没有实现系统调用
- 没有实现加载应用
那么逻辑就很清楚了, 我们要按照上面的点增量构建一个宏内核(这也是组件化的有点, 高复用性):
- 先创建用户地址空间: 一切操作都需要对数据进行, 而数据需要在地址空间上
- 加载应用数据并建立用户栈到地址空间
- 伪造现场(临时上下文)并放入新任务的内核栈上(此时仍然在内核态)
- 调用
sret
从 S 特权级返回 U 特权级, 进入用户态, 从刚刚加载的应用入口开始执行 - 用户应用调用了系统调用, 通过异常中断向量表 Trap 进 S 特权级, 内核处理这个调用
用户地址空间的内存分布
- 地址空间的高地址区域是内核空间(内核栈等可以共享)
- 在内核根页表中只有高区域, 低地址区域是空
- 以这个内核根页表为模板为应用进程复制了低地址区域的应用空间后, 所有页表中高地址那部分的内核空间的虚拟地址映射到相同的物理地址, 实现了共享.
加载用户数据
读到内存缓存区, 再到内核地址空间, 再在用户地址空间中与内核地址空间存应用的那部分映射到相同的部分
任务控制块扩展
宏内核需要一些 Unikernel 不具备的任务信息, 如用户地址空间, 上下文等. 这里用 def_task_ext!
宏注册扩展任务控制块的结构体类型.
这样, 记下 sepc
寄存器值, 标记 sstatus
CSR 为”切换前来自用户态”(因为 RISCV 不存在专门从内核态到用户态的切换的指令, 只能假装当前在内核态是因为刚刚从用户态过来的, 然后返回回去)
系统调用
ArceOS Monolithic 中系统调用的实现路径是 Trap 进内核态后, 由 axhal::arch::trap
通过 link_me
从应用程序的 syscall 处理函数中调用到 arcesos_posix_api
来实现功能的
link_me
: 对系统调用的响应函数通过 #[register_trap_handler(SYSCALL)]
注册.
Linux App 支持
我们已经有了一个最小化的宏内核, 但是还不能直接跑 Linux ELF 可执行文件:
地址空间太 plain 了, 没有逻辑分段 (
.text
.data
等都要模仿 Linux)逻辑段实现上是一个
BTreeMap<address, area>
, 通过mmap
映射一段地址到一段页面上(以及设置访问权限等). 映射后端包含Linear
和Alloc
两种方式, 前者是带偏移的连续地址映射到一个段, 后者是可能不连续的多个物理页帧的集合映射到一个段.Linux App 大多数需要与
glibc
或musl-libc
进行参数协同加载 ELF 文件, 然后只需要读取
r-x
和rw-
的部分 (.text
和.data
), 即 Type 为Load
的两个段.内存中和文件中代码段的长度和地址一般是恒等映射的, 毕竟就在第一个
但是由于
BSS
段的存在, 需要重新计算并映射.bss
和.data
段作为数据段初始化用户栈, 把命令行参数加入进去(参数和环境变量), 这个二阶段管道那一章 ch7 有说过
musl-libc
启动较为简洁, 需要实现的系统调用较少.其他缺少于 Linux 的部分:
procfs
,sysfs
.
Hypervisor
Hypervisor 是 Guest 与 Host 相同指令集情况下高效(虚拟化消耗可忽略)地对硬件资源进行管理的虚拟机
虚拟化模式的寄存器相关变化
misa[7] = 1
表示启用 Hypervisor 虚拟化s*
寄存器作用不变(sstatus
stvec
sepc
), Host 新增hs*
寄存器用于对 Guest 的路径控制(异常/中断委托等)vs*
寄存器操作 Guest 中的 S 特权级寄存器, 如vsepc
要设置为0x80200000
,vsstatus
要设置为初始 S (VS) 特权级hstatus[7]
记录上一次进入 HS 特权级前的模式sret
根据这个决定是返回虚拟化用户态还是 Host 中
控制流
虚拟化实际就是在 Guest 和 Host 直接来回转换, 之所以要切回 Host 是因为有些操作必须让 Host 来执行(比如 sbi_call
或 中断注入)
每个 vCPU
(在这里为了简单, 仅对应一个 CPU 核心) 维护一组上下文状态, Guest 或者 Host.
切换到另一个状态时保存当前上下文并切换到对应的上下文, Guest 到 Host 是 VM_Exit
, 反之是 sret
分页处理: 嵌套分页: GVA -> (vsatp
) -> GPA -> (hgatp
) -> HPA
虚拟设备: 透传模式或模拟模式
中断处理(中断注入): 只有 hvip
的对应位被设置为 1, Guest 的 vstvec
才会被触发, 否则 Guest 完全不知道有中断发生. vstvec
是 Guest 的中断入口, 但它仅响应 hvip
中已注入的虚拟中断, 而不是物理中断.