为了让内核态核用户态共用一张页表,我们需要对地址空间进行适当分割。这里我们直接利用 SV39 的机制:64 位虚拟地址只有低 39 位有效,[63 : 39] 这 25 位必须和第 38 位相同,即对于 SV39 机制来说,有效的地址范围为 0x0000000000000000
- 0x0000003fffffffff
和 0xffffffc000000000
- 0xffffffffffffffff
,这恰好分成两份,还能根据符号位(作为补码看)轻易辨别地址属于哪个区间。因此我们把用户地址空间映射到0x0000000000000000
- 0x0000003fffffffff
,把内核地址空间映射到0xffffffc000000000
- 0xffffffffffffffff
。
我们首先修改链接脚本,把内核代码映射到 0xffffffc080200000
,因为目前编译出的elf并不是位置无关的,而 rustsbi 默认跳转的位置是 0x80200000
,那怎么在能在启动的时候迅速把内核代码映射到高位呢,我们需要在启动时手写一个简单的页表,修改 satp
寄存器。
手写个三级页表显然太炸裂了,因此我们利用大页机制,只映射两个 1G 大页,也就是只使用一级页表,MMU 查到有效页表项就会自动停止。将 0x8000_0000
和 0xffff_ffc0_8000_0000
两个大页添加到页表项即可。
boot_pagetable:
# we need 2 pte here
# 0x0000_0000_8000_0000 -> 0x0000_0000_8000_0000
# 0xffff_fc00_8000_0000 -> 0x0000_0000_8000_0000
.quad 0
.quad 0
.quad (0x80000 << 10) | 0xcf # VRWXAD
.zero 8 * 255
.quad (0x80000 << 10) | 0xcf # VRWXAD
.zero 8 * 253
经过修改后内核可以正常启动进入,证明了映射的处理正确,当然内存管理经过修改显然存在严重问题,有待进一步修改。
仍然存在的疑问:为什么也需要映射用户态地址空间,进入内核时不是应该保持在高位运行吗
一个多核操作系统会需要一套完善的锁机制,由于锁模块与内核相对隔离,且使用较多,优先实现一部分锁的内容。首先我对代码架构进行了一定更新,然后添加了 MutexSupport
,用来为自旋锁、互斥锁等简单锁提供支持,随后实现了基础的自旋锁。接下来还会需要大幅重构锁机制的代码,逐步替换原先的锁,使它结构上更加独立,形成一个独立的锁模块。这里很大程度上参考了 Titanix 的实现和文档。
/// Low-level support for mutex(spinlock, sleeplock, etc)
pub trait MutexSupport {
/// Guard data
type GuardData;
/// Called before lock() & try_lock()
fn before_lock() -> Self::GuardData;
/// Called when MutexGuard dropping
fn after_unlock(_: &mut Self::GuardData);
}
修改了链接脚本的内核基址并且在内核入口中手写页表之后,通过内核映射前打印出的数据可知,内核的各个段还是被映射到了页表中的低地址,与链接脚本不符。这里我思考了我们是如何从链接脚本中获取代码段地址的,我认为函数名、汇编符号、函数代码入口地址这三者是同一回事(假设函数名未被混淆,实际上C++和Rust都会混淆,C不会),我们通过 extern "C"
引入外部C函数实际引入的是函数符号,也就是函数入口地址,因此我们就可以在Rust里调用C函数,同样,我们也可以用相同的方式获取某个符号的地址。这里我们直接在链接脚本中规定了各个代码段以及具体符号的位置,链接脚本直接指导链接器,也就绕过了编译器可能的混淆,保留了符号的原本名称,我们就可以通过 extern "C"
将其引入,从而得到了各个代码段的具体地址
经过一段时间(3个小时)的排查,我们发现虽然符号表中各个符号确实处于地址空间高位,但是代码段实际位置与符号表不符,仍然位于低地址空间。由于低地址空间和高地址空间完全对称,内核虚拟地址也只需要减去偏移量(0xffffffc000000000
)就是物理地址,所以我们可以在进入主函数 rust_main
之前,将跳转目标加上偏移量,使后续的代码在高地址空间执行,由于所有的跳转都被编译为相对 pc 跳转,因此不影响程序的正确性。
#[no_mangle]
pub fn fake_main() {
unsafe {
asm!("add sp, sp, {}", in(reg) KERNEL_SPACE_OFFSET << 12);
asm!("la t0, rust_main");
asm!("add t0, t0, {}", in(reg) KERNEL_SPACE_OFFSET << 12);
asm!("jalr zero, 0(t0)");
}
}
修改后获取的虚拟地址与符号表匹配了,但是创建新地址空间时卡死,使用gdb调试还会导致内核无限循环。通过检查又长又臭的 qemu log 发现,在访存内核代码段末尾,也就是 ekernel
时出现异常,而异常处理函数的位置,也就是 stvec
寄存器,被默认指向了内核开头 0x80200000
,这就是出现循环的原因。进一步检查代码发现 rCore 原生的地址转换逻辑有误,因为原先并没有考虑传入虚拟地址可能位于地址空间高位的情况,因此取物理地址时忽略了高8位,导致取出的地址不合法,重构地址转换逻辑即可解决。
我们把内核地址空间映射到高位之后,rCore原先的物理、虚拟地址,usize以及页号之间的转换规则就不对了,需要加以修正,按照SV39的要求维护好高位,这一层维护完成之后,加载应用程序前的所有工作就完成了,内核成功被映射到了高位,进入了加载文件系统阶段。
支持连续分配物理页。注意当前连续分配不会考虑 recycled 的物理页。
新的页表是被物理页帧随机映射直接放进内核地址空间部分的内存里。
还在尝试加载文件,现在是虚拟块设备炸了,目前不知道应该重构虚拟块设备还是添加初始页表映射部分。
完成了对虚拟块设备驱动的重构
现在新创建的页表确保了内核部分一致,这样页表的切换不会导致内核找不着了,同时重新安排了内核栈和用户栈以及用户堆的位置。现在用户栈放在应用程序代码段之后,用户栈之后间隔一页的位置放置用户堆。
改成单页表之后就不需要使用 trampoline
沟通内核和用户态,到这里 trampoline
彻底移出了内核。
在创建初始进程的时候需要向 TRAP_CONTEXT_BASE
中写入初始进程的 trap context
,确保接下来的跳转正确,而 TRAP_CONTEXT_BASE
虽然在初始进程的页表中映射了,却并没有在当前页表中映射,因此在这里访问会出现 PageFault ,所以我们要为当前正在使用的页表映射这块内存,只需要查新页表确认 trap context
的物理地址,然后在当前页表中将 TRAP_CONTEXT_BASE
映射到该物理地址即可。
初始进程对于操作系统来说能够省去大量麻烦,是去草台班子化的必经之路。由于比赛统一使用提供的系统镜像,因此我们选择将初始进程的代码段在编译时嵌入内核代码段,再把 elf 文件直接读出来执行,这样的处理非常高效,开发也很方便,rCore 在实现文件系统之前也是采用这种方式,后面会以 git module 的形式添加root fs。王老板把初始进程加进去之后启动其他程序出现严重问题,最后发现是按名称查找应用程序时忘记给应用程序名称字符串结尾加结束符 '\0' 了,难绷。
目前chaos的做法是在进入内核时手写临时页表以进入内核所在的地址空间高位,然后完整映射内核到新页表之后切换过去,进行初始化工作,随后再创建初始进程的页表并切换进去。接下来chaos将首先按照传统的处理方式由初始进程 fork 出所有应用程序,调度时相应切换页表,然后再升级为无栈协程,即切换到初始进程页表之后不再有页表切换。
决赛的实现并不要求进程的调度,调度也不能明显提升性能,反而留下大量干扰阅读的代码甚至造成bug,因此暂时停用,后续会逐步移除相关代码,其最初编写时耦合度也较高,不利于阅读代码。
rCore原先的逻辑是为内核的页表分配各个进程的内核栈,因为原先进入内核态需要切换到内核页表因此这样做没有问题,但是进入单页表之后,我们不再需要反复进入内核页表,内核页表的作用变为了固定保存内核地址空间,每创建一个新的页表就把内核部分复制过去,这样就能确保在切换页表时内核地址空间不受影响。但是在原本的rCore中是先创建页表再分配内核栈,此时内核部分已经完成复制,这样就会导致新的页表缺少队内核栈的映射,于是把内核栈的分配和映射提前到新页表创建之前,完美解决问题。
小bug,子进程fork出来要复制父进程的几乎所有内容,但是由于页表创建的时候已经复制了内核部分,所以再复制就会重映射,简单加一个 if 就行。
原先rCore的逻辑是切换进程统一先进入 trap_return
,通过进入 __restore
来切换 satp
,现在换成单页表了,trap处理不需要切换页表修改 satp
,所以为初始进程和其他进程设计了入口 initproc_entry
和 user_entry
,汇编代码也添加了对应的入口,这里其实两个入口的汇编没有任何区别,可以用同一个,不过在 gdb 的时候有两个不同符号区分还是方便一点的。
到这里一直进不去用户进程,急急急。debug搞了两天才意识到中断上下文应该被映射到用户地址空间,我也不知道我在想什么要把它挪到内核地址空间,修改了 TRAP_CONTEXT_BASE
常量就修好了,真就一行就修好了。
改成单页表之后一堆来自用户态的字符串参数等就不需要转译了,之前需要两个页表互相翻译,怪麻烦的,现在直接翻译成 String
就行,同时还注意要管理一下用户栈,把参数分门别类压栈对齐,处理完这些就能进入 execve
执行了。
open_file
的时候炸了,发现第一块虚拟块读不进来,会是 NotReady
的状态,而且虚拟块设备驱动由于使用了外部库,我也不太了解文件系统和 qemu 怎么搞的虚拟设备,所以完全无法debug也完全没有思路,暂时搁置。
这个问题非常难找也非常的精彩,从13号发现至今经过了足足三天的 gdb 不断调试才成功修复,期间理解也反复出错。
首先是发现在刚刚进入用户进程的时候,fork结束后的那次时钟中断会调度到初始进程然后panic,由于错误是 InstructionPageFault
,所以常规想法没有思路,只能开始gdb。因为是进程调度时取指报错,所以优先跟踪跳转地址,也就是 ra
寄存器以及保存 ra
寄存器的 task_cx
即任务上下文。ra
与任务上下文的交换只发生在汇编函数 __switch
中,因此断点也设在该符号处。
第一轮gdb之后,发现最后的跳转地址会进入 suspend_current_and_run
,并且在返回时报错,认为是 task_cx
中 ra
的维护出错导致问题。但是在仔细观察了rCore的处理和gdb结果后发现跳转流程实际上并没有问题。
rCore设计的进程切换方式非常巧妙,具体流程如下图所示。之所以会跳转进入 suspend_current_and_run
,是因为经过了时间片中断,在 suspend_current_and_run
调用的 __switch
中把当前 ra
的值保存到 idle
进程的上下文中,下一次 __switch
时又会恢复这个 ra
,所以函数返回时会跳转到 suspend_current_and_run
,这是完全正常的。
继续仔细观察跳转出错的原因发现,返回进入 suspend_current_and_run
后的第一条指令是按照栈指针加偏移量设置 ra
的值。
由此,猜测是栈指针遭到修改,继续进行gdb,观察栈指针的变化。
通过gdb很快发现栈指针遭到异常修改,看起来是一个非常地狱的bug,但是非常幸运的是,栈指针异常的数字是一个非常好的数字:正常情况下,进入异常前的栈指针是: 0xffffffc088008e50
, 但是在引发异常的那次跳转中,栈指针 sp 被修改成了 0xffffffc088011e50
,相差 0x9000
,这是什么?这恰好是一个进程内核栈的大小!!(8页内核栈加上一页 guard page )从这里我就可以绝对的肯定是 trap 上下文的维护有问题,因为用户内核栈的基址和内核栈的分配都只与 trap 上下文相关,而非之前关注的任务上下文 task_cx
。
重新关注 trap 上下文的维护,很快找到了原因:中断上下文放置在用户地址空间最高位,即 0x3fffffe000
处,每次更新时都会基于这个地址根据tid寻址后,获取对应虚拟地址的可变引用然后把数据直接写入。在原先的fork中,fork函数的结尾有一次目前看来似乎没有必要的写入,把新进程的内核栈写入中断上下文,因为原先的rCore是直接获取物理地址的直接引用,所以这样写入没有问题,但是现在我换为单页表之后不能这样做,所以我把它改成了获取虚拟地址,这就导致了fork结尾的这次写入没有写入到预想的、新进程的中断上下文中,而是覆盖了当前进程,也就是初始进程的中断上下文,也就导致了跳转的出错。
之所以我认为这一次写入没有意义,是因为在execve中也进行了中断上下文的初始化;至于rCore为什么可以获取物理地址的引用但我不行,我的理解是把虚拟地址翻译到了一张纯恒等映射的页表中,这样获取的虽然是虚拟地址,但是写入到的位置就是对应的物理地址,毕竟启用虚拟地址空间机制之后不可能再直接获取并修改物理地址。
完全没有头绪,有一个投机取巧的思路,就是第一个块既然 NotReady
,那我特判这个情况,然后重试,还没有实践过,值得一试。
如果失败就重试十次,看着效果还行
删了一些无用的代码和debug信息
参考了一些往届代码以及linux之后,我觉得如果想要改成单页表得重构一下,合并PCB和TCB,两个分开的数据结构虽然对于线程的处理方便了很多,但是对于大大复杂了各类代码写法,所有权和可变引用变得非常复杂,因此我决定重构。
TaskUserRes
的设计思路应该是按照 tid
分配用户栈和中断上下文,并且整合到一个 "res" 里,是一种典型的RAII思想。其实这种想法相当好,因为我后面发现确实不太好管理,正如Linux的注释里写的那样:
但是考虑到重构的复杂性和难度,以及这货前期给我带来的很多所有权麻烦(我是Rust低手),还是先把他移除。
鬼知道我经历了什么。没得说,纯纯苦力活,还留下了不少的坑,不过确实精简了很多代码。
我的思路是逐步添加,把PCB的功能整合进TCB。首先为TCB添加所有需要的成员,然后逐步补充PCB的重要函数,如 fork
exec
等,其中最难搞的就是添加这几个函数,要考虑很多东西,比如什么时候分配内核栈,什么时候分配用户栈,什么时候分配中断上下文又如何写入,如何安排内存空间等等等等。
我参考linux的做法,把pid作为任务的唯一标识,tid则作为线程标识符,主线程的tid与pid一致,子线程的tid则与主线程的pid相同,也就是同一个线程组的tid相同。同时原先按照tid分配的中断上下文的内存我也改为了按照pid分配(函数名还没改)。在原先的rCore中,每个进程享有独立的tid分配器,也就是每个进程的tid都是从零开始分配,tid为0就是主进程,一个进程一个虚拟地址空间,所以拿tid来分配用户资源非常的优雅。可惜,现在的的进程内对于各个线程并没有顺序的标识符,因此按照pid分配比较好管理,由于虚拟地址空间应该足够大,所以这种做法也不是不行。
rCore中对于中断上下文的管理方式并不是一个对象,而是放在一段固定的地址中然后直接获取地址的可变借用然后写入读取,这样的做法给我带来了不少麻烦,因为这导致我经常需要去写其他进程的地址空间,而这个虚拟地址因为按照全局唯一的pid分配,还没有被映射到当前地址空间的页表中,由此引起了不少次PageFault。我暂时没有优雅的解决方案,只是在映射完其他进程的中断上下文之后,为当前进程也复制一份映射(虚拟地址物理地址都要一致),确保可以访问。
过了编译之后遇到很多重复borrow mut的问题,主要是我重构代码时没仔细看上下文导致的,修复了之后成功运行出了重构前的水平。成功!
这个bug实际上非常铸币,因为这一点我早就想到得写,结果没有记下来,重构的东西一多就忘记了,导致了非常多的debug时间浪费。
首先是发现在进程切换回第一个用户进程要开始执行应用程序的时候,发生神奇的指令错误(InstructionPageFault),这个错误还不固定,有时候是一个 Unknow 的 Interrupt ,我一个头两个大,手册上也没写这可能是什么,scause
总不能骗我,更有时候是输出一堆乱码,看起来似乎是应用程序的二进制制转译文件内容,非常地狱;bug的发生位置也不固定,有时候是输出字符串时,有时候是打印log时。更离谱的是,有时候 sys_execve
甚至可能在 initproc
也就是初始进程里执行,fork出来的子进程也执行,无视if语句。后面发现这两个其实是一个bug,这就是内存bug的魅力。
这种问题显然是严重的内存错误,肯定是动了内存里不该动的东西,但是我从 init
排查到 fork
再到 trap_handler
以及所有汇编代码,没有发现任何严重问题。我决定从最明显的问题开始,即初始进程执行 execve
,这应该是因为在trap返回时, sepc
的值有误造成的。但就在我通过gdb仔细排查初始进程的中断上下文中 sepc
寄存器的变化的时候,我发现在进程切换时,地址空间没有跟着切换。
这其实是我忘了,因为现在只有 __user_entry
和 __init_entry
保留了页表切换,原先的 __restore
里没有了,因此在调度到对应进程的时候需要手动把调度后第一个跳转的地址指向这两个函数,即task_cx
的 ra
字段,这样才能在进程切换之后第一时间且换地址空间。在 run_task
里修改后解决。这个早就想到得写,还是忘了,所以想到重构计划立刻记录下来很重要(
上一个bug修复后成功运行了第一个应用程序,虽然退出还有问题但是解决优先级不高,先尝试多个应用程序轮流运行,结果发现新进程的pid提前drop了,导致pid被重复使用,中断上下文区域惨遭冲突映射,优化了一下rust写法解决,我的rust水平问题。