MOS 体系架构移植任务实验报告


写在前面

本实验报告将尽可能的按照 MOS 操作系统的各个功能分类编写,并且尽可能的精简。

同时我也写了以移植时间顺序编写的实验流程报告:Run MOS on RISC-V (移植过程) | TobyShiの博客

Git Commit Graph:
image.png

代码仓库地址:Toby-Shi-cloud/Run-MOS-on-RISC-V | GitHub

Lab 0 环境配置

推荐使用常见的 Linux 发行版作为实验环境,不推荐在 Mac 或 Windows 上直接折腾,我使用了 GitHub 的 Codespaces 作为开发环境。

交叉编译器

首先安装交叉编译器,可以直接使用常见的包管理器安装,如 apt install gcc-riscv64-unknown-elf

QEMU

qemu 是本实验需要的模拟器,包管理器安装的版本可能较老,故需要下载源代码手动编译,提前安装 ninjapixman-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 ecallSBI_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, forkcopy-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 可以支持只刷新部分快表,具体应当参考:`sfence.vma` 我完成了这个优化,并且至少在目前的测试和使用中没有发现 bug。不过我承认自己对底层的认识还是有很多不足的,不知道会不会在某天出现问题。

参考资料

衷心感谢以下文章对我的帮助:

  1. RISC-V嵌入式开发入门篇1:RISC-V GCC工具链的介绍_半斗米的博客-CSDN博客
  2. RISC-V 指令概况 - 计算机组成原理(2021年)
  3. Lab 1: RV64 内核引导 - 知乎
  4. riscv-sbi-doc/riscv-sbi.adoc at master · riscv-non-isa/riscv-sbi-doc · GitHub
  5. PolarFire® SoC MSS Technical Reference Manual
  6. 浙江大学22年秋操作系统实验
  7. 10.4 自制操作系统: risc-v 虚拟内存系统_sv39_richard.dai的博客-CSDN博客
  8. GitHub - ZJU-SEC/os22fall-stu: https://zju-sec.github.io/os22fall-stu/
  9. RISC-V Technical Specifications - Home - RISC-V International
  10. 如何在gdb中加载多个符号文件 - 问答 - 腾讯云开发者社区-腾讯云
  11. GitHub - QAQdev/onekos: A mini kernel of 2022 ZJU OS
  12. 0020 virtio-blk简易驱动 - 知乎
  13. 通过MMIO的方式实现VIRTIO-BLK设备 - 知乎
  14. 通过MMIO的方式实现VIRTIO-BLK设备(二) - 知乎
  15. xv6-riscv/virtio_disk.c at riscv · mit-pdos/xv6-riscv · GitHub
  16. docs.oasis-open.org/virtio/virtio/v1.0/cs04/virtio-v1.0-cs04.pdf

评论
  目录