写在前面
本实验报告将尽可能的按照 MOS 操作系统的各个功能分类编写,并且尽可能的精简。
同时我也写了以移植时间顺序编写的实验流程报告:Run MOS on RISC-V (移植过程) | TobyShiの博客。
Git Commit Graph:
代码仓库地址:Toby-Shi-cloud/Run-MOS-on-RISC-V | GitHub
Lab 0 环境配置
推荐使用常见的 Linux 发行版作为实验环境,不推荐在 Mac 或 Windows 上直接折腾,我使用了 GitHub 的 Codespaces 作为开发环境。
交叉编译器
首先安装交叉编译器,可以直接使用常见的包管理器安装,如 apt install gcc-riscv64-unknown-elf
QEMU
qemu
是本实验需要的模拟器,包管理器安装的版本可能较老,故需要下载源代码手动编译,提前安装 ninja
和 pixman-1
就可以编译成功。
OpenSBI
由于 debug 等需要,我也 clone 了 OpenSBI 的源代码并编译使用。
Lab 1 内核启动和 printk
实现
通过编译
这一步需要更改汇编代码,编译器和编译器选项。
经过不断试错,最终我选用编译选项是 --std=gnu99 -march=rv32gc -mabi=ilp32 -Wall -fno-pic -ffreestanding -fno-stack-protector -fno-builtin -Werror
,链接选项是 -static -nostdlib --fatal-warnings -melf32lriscv -Lsbi_bin
,同时我还链接了 OpenSBI 的编译产物 -lsbi -lplatsbi
,当然事实证明这是没有必要的,因为 OpenSBI 中提供的函数都是针对 M 态的,与我无用。
启动内核
OpenSBI 引导后会默认调转到 0x80200000
处,而非我们 target/mos
文件的 _start
处,故需要对汇编文件和链接文件做出更改:首先使用 .section
指令将 _start
放置在 .text.start
段,随后修改链接脚本将 .text.start
放置在 0x80200000
处。
Printk
修改 console.c
实现使用 ecall
与 OpenSBI 交互,对于 printcharc
通常我们可以使用 legacy ecall
中的 sbi_console_putchar
,当然我后来改用了 SBI_EXT_DBCN
中的 SBI_EXT_DBCN_CONSOLE_WRITE_BYTE
进行实现。对于 scancharc
使用 legacy ecall
实现时会和新版本的 OpenSBI 不兼容,必须更改 OpenSBI 源代码或使用较低版本才可以正常运行,所以使用 OpenSBI 最近新增的 SBI_EXT_DBCN
中的函数会更好。对于 halt
的实现,使用 legacy ecall
或 SBI_EXT_SRST
都可以。
console.c
中极可能出现两种错误:
asm (
"mv a0, %[a0]\n"
"mv a1, %[a1]\n"
"mv a2, %[a2]\n"
"mv a3, %[a3]\n"
"mv a4, %[a4]\n"
"mv a5, %[a5]\n"
"mv a6, %[fid]\n"
"mv a7, %[eid]\n"
"ecall\n"
"mv %[error], a0\n"
"mv %[value], a1\n"
: [error] "=r" (res.error),
[value] "=r" (res.value)
: [a0] "r" (arg0),
[a1] "r" (arg1),
[a2] "r" (arg2),
[a3] "r" (arg3),
[a4] "r" (arg4),
[a5] "r" (arg5),
[fid] "r" (fid),
[eid] "r" (ext)
);
上面这个内嵌汇编代码看起来没有什么问题,但是内嵌汇编代码必须表明汇编代码中敏感的寄存器,否则极其容易导致错误:譬如 fid
已经保存在 a0
中了,但是第一步会直接被覆盖掉,因此后续的 mv a6, %[fid]
无法获取正确的值。
static inline struct sbiret
sbi_ecall(u_int ext, u_int fid, u_int arg0, u_int arg1,
u_int arg2, u_int arg3, u_int arg4, u_int arg5);
void __attribute__((noreturn)) halt(void) {
sbi_ecall(
SBI_EXT_SRST, SBI_EXT_SRST_RESET,
SBI_SRST_RESET_TYPE_SHUTDOWN, SBI_SRST_RESET_REASON_NONE,
0, 0, 0, 0);
__builtin_unreachable(); // this ecall should not return
panic("unreachable code");
}
上面的代码也是看起来没有问题,但是由于 sbi_ecall
的返回值没有收到,并且 sbi_ecall
中没有显式改变程序状态,故可能在编译器 -O2
优化中将该函数当作无用代码优化掉,因此 halt
函数最终变成空壳,不会发挥作用。
Lab 2 MMU 设置和内存管理
我们使用的 SV32 虚拟内存管理方式和 MIPS 大同小异,可能唯一需要注意的就是权限位的不同以及 SV32 中只有低 10 位是权限位。
在这个 Lab 中,使用物理内存依然可以正常完成,但是需要注意为后续用户态以及进程切换做准备,所以务必建立虚拟内存。在内核态,我们之间使用恒等映射就可以了。在 Lab 5 后,我们还要为 virtio 设备的 MMIO 地址做映射,我的方案是将 MMIO 地址映射到 KSEG1
中,和原版 MOS 保持一致,方便移植。
Lab 3 异常处理和进程管理
时钟中断
这一步主要内容是开启时钟中断,获取当前时间(使用 rdtime
),调用 OpenSBI 接口设置下次中断。
进程管理
这一步主要内容是进行合理的上下文保存,合理的初始化页表。RISCV 与 MIPS 在这一步的明显差别之一是:在 MIPS 中我们不需要映射内核态地址到页表中,但是 RISCV 中必须要进行映射,否则每次陷入内核台都需要切换页表,非常麻烦。
缺页异常
我们的 MOS 系统中,只要用户访问的地址在用户态,那么一旦发生缺页一场,直接 passive_alloc
分配一页出去就可以了。
调试相关
进入用户态后,会发现 gdb 不能添加用户态断点,这就需要手动额外加在对应的 elf
文件。可以使用 add-symbol-file <filename> <address>
进行加载。需要注意的是,在用户态下无法访问内核断点,如果存在内核断点,可能导致 gdb 无法继续运行,需要取消内核断点后继续运行。
Lab 4 系统调用和 Fork
页表自映射
我采用的方案是在用户需要访问页表项时将页目录 copy 一份到 UVPT 区,这种做法只需要额外的一页内存,简单高效,唯一需要注意的是当页目录项发生改变时,必须无效化这份 copy,下次要使用时重新 copy。代码如下:
if (curenv->env_pgdir_copy_pa == 0) {
panic_on(page_alloc(&p));
p->pp_ref++;
curenv->env_pgdir_copy_pa = page2pa(p);
} else {
p = pa2page(curenv->env_pgdir_copy_pa);
}
u_int kva = page2kva(p);
pte = (Pte *)kva;
memcpy((void *)kva, (void *)cur_pgdir, BY2PG);
for (int i = 0; i < 1024; i++) {
if (pte[i] & PTE_V) {
pte[i] |= PTE_R | PTE_U;
}
}
pte[PDX(UVPT)] = ADDR_PTE(cur_pgdir) | PTE_V | PTE_R | PTE_U;
cur_pgdir[PDX(UVPT)] = ADDR_PTE(kva) | PTE_V;
int pte_idx = (addr - UVPT) / BY2PG;
if (!(cur_pgdir[pte_idx] & PTE_V)) {
panic("not a valid pte");
}
syscall
, fork
和 copy-on-write
实现页表自映射后,这些功能需要移植的内容较少,需要格外关注的是 RISCV 和 MIPS 的寄存器差别,tf->regs[2]
在 MIPS 中是返回值寄存器 $v0
,但在 RISCV 中是 sp
,如果没有改相关代码,直接移植过来,后果肯定是灾难性的。
Lab 5 文件系统
有关 virtio 的知识已经在另一篇报告中说明了,因此不再赘述。这里可能遇到的问题是收不到中断,我也被这个问题折磨了一天,如果确认其它部分没有问题的话(譬如物理地址使用是否正确),可能是 OpenSBI 没有打开外设中断吧。总之我最终采用了反复读权限位的用户态 busy waiting
方式判断硬件请求是否完成,某种意义上说,少陷入内核态也算是提高了性能吧。
Lab 6 Shell
Shell 没有什么好说的,都是用户态内容,基本不需要移植,copy 一遍就可以啦。
Optimize
spawnl
在 mips 中,mos 使用了 mips 的参数传递规范来优化 spawnl
的实现,这在 riscv 中不可行,如果在 riscv 中单开数组保证参数连续又显得很浪费,因此我采用以下汇编代码直接实现 spawnl
:
// int spawnl(char *prog, char *args, ...)
EXPORT(spawnl)
// Assuming all the arguments are char *
addi sp, sp, -32
sw ra, 0(sp)
sw a7, 28(sp)
sw a6, 24(sp)
sw a5, 20(sp)
sw a4, 16(sp)
sw a3, 12(sp)
sw a2, 8(sp)
sw a1, 4(sp)
addi a1, sp, 4
call spawn
lw ra, 0(sp)
addi sp, sp, 32
ret // directly ret spawn(...)
PTE_DIRTY
在 mips 中,页表没有 dirty 相关权限位,因此文件系统使用了自定义的权限位+手动 dirty 的方式实现按需写回。遗憾的是,这样做存在很多 bug,最经典的问题就是文件控制块没有 dirty,不一定会写回,且没有递归写回,且写回后也没有清楚 dirty 位,会造成多余的写回。
好消息是,在 riscv 中,我们有 mmu 实现的 dirty 位,可以完美解决上面的问题。因此我去除了 PTE_DIRTY
,使用了原生的 PTE_D
并且改进了上述 bug,这样文件系统便拥有了更优雅的实现和更快的速度。
其它优化?
还有很多其它可做优化。譬如 virtio 支持 IO 操作和 CPU 运行的并发,并且可以一次发送更长或者更多的 IO 请求,因此文件系统可以在这方面进行优化。当然这个优化幅度似乎不会很高,碍于时间原因我没有做这个优化。
另外 sfence.vma
并不是任何时候都需要使用,或者说,sfence.vma
可以支持只刷新部分快表,具体应当参考: 我完成了这个优化,并且至少在目前的测试和使用中没有发现 bug。不过我承认自己对底层的认识还是有很多不足的,不知道会不会在某天出现问题。
参考资料
衷心感谢以下文章对我的帮助:
- RISC-V嵌入式开发入门篇1:RISC-V GCC工具链的介绍_半斗米的博客-CSDN博客
- RISC-V 指令概况 - 计算机组成原理(2021年)
- Lab 1: RV64 内核引导 - 知乎
- riscv-sbi-doc/riscv-sbi.adoc at master · riscv-non-isa/riscv-sbi-doc · GitHub
- PolarFire® SoC MSS Technical Reference Manual
- 浙江大学22年秋操作系统实验
- 10.4 自制操作系统: risc-v 虚拟内存系统_sv39_richard.dai的博客-CSDN博客
- GitHub - ZJU-SEC/os22fall-stu: https://zju-sec.github.io/os22fall-stu/
- RISC-V Technical Specifications - Home - RISC-V International
- 如何在gdb中加载多个符号文件 - 问答 - 腾讯云开发者社区-腾讯云
- GitHub - QAQdev/onekos: A mini kernel of 2022 ZJU OS
- 0020 virtio-blk简易驱动 - 知乎
- 通过MMIO的方式实现VIRTIO-BLK设备 - 知乎
- 通过MMIO的方式实现VIRTIO-BLK设备(二) - 知乎
- xv6-riscv/virtio_disk.c at riscv · mit-pdos/xv6-riscv · GitHub
- docs.oasis-open.org/virtio/virtio/v1.0/cs04/virtio-v1.0-cs04.pdf