本文是参加2025春夏季开源操作系统训练营时对第二阶段文档第3章做的笔记。
文档:https://learningos.cn/rCore-Camp-Guide-2025S
完整版文档:https://rcore-os.cn/rCore-Tutorial-Book-v3
感觉前两章的blog和官方文档重合的有点多,从现在开始将主要补充学习时遇到的知识点和对官方文档的补充。
前一章实现了自动加载程序的批处理系统,减少了手动替换的操作和时间。本章在前一章的基础上,依次实现了:
前一章中,所有应用程序都加载到同一个位置,上一个应用程序结束后下一个应用程序才被加载。因此在本节中,每个应用都需要按照它的编号被分别放置并加载到内存中不同的位置。这需要应用知道自己会被加载到某个地址运行,而内核也确实能做到将应用加载到它指定的那个地址,内核和应用需要达成一种协议。
具体上通过在编译每个应用时,根据它的编号修改链接脚本 linker.ld
,构建出起始地址不同的 .bin
文件。然后在操作系统启动的阶段将所有的应用都加载到内存中。每个应用的大小限制为最大为0x20000,也就是128KB。
加载之后与上一章的运行的过程类似,操作系统逐个运行已经在内存中的应用程序。不同的是,本章中每个应用程序都有自己的用户栈和内核栈,存在多个用户栈可以理解,存在多个内核栈的原因是让所有的任务安全地共享一个内核栈比较困难,为每个任务分配一个内核栈是比较简单的做法。到这里多道程序系统实现结束。
上一节刚实现的多道程序系统中,如果应用不出错或者主动退出,它会一直独占CPU,应用的执行顺序仍然与批处理系统相同,为了提高效率,要让应用程序可以主动放弃CPU,需要引入新的操作系统概念任务、任务切换、任务上下文。
本节所讲的任务切换是第二章提及的 Trap 控制流切换之外的另一种异常控制流,都是描述两条控制流之间的切换,如果将它和 Trap 切换进行比较,会有如下异同:
- 与 Trap 切换不同,它不涉及特权级切换;
- 与 Trap 切换不同,它的一部分是由编译器帮忙完成的;
- 与 Trap 切换相同,它对应用是透明的。
在任务切换中,任务上下文会被保存在任务控制块中。实际上会被保存在一个全局变量 TASK_MANAGER
中,位于内核的 .data
段中,如图
具体通过一个 __switch
函数实现:
1 | __switch: |
sp
,即当前任务的内核栈的位置,保存到 a0 + 8
处。ra
(返回地址寄存器)和 s0
到 s11
寄存器的状态。ra
存储在 a0
的偏移量为 0
的位置。通过 .rept 12
,宏 SAVE_SN
被重复执行 12 次,保存从 s0
到 s11
的寄存器。ra
寄存器和 s0
到 s11
寄存器的状态。ra
从 a1 + 0
处加载,s0
到 s11
通过 .rept 12
进行循环恢复。sp
,它存储在 a1 + 8
处。TaskContext
数据结构如下,上面汇编代码中的 a0 + 8
和 a0
的偏移量为 0
的位置即为 TaskContext
指针所指的数据结构中的 sp
和 ra
。
1 | pub struct TaskContext { |
如果要实现任务被切换出去后还有机会被切换回来,需要给任务添加一个运行状态。之后添加一个对应的系统调用,再通过任务控制块来管理,协作式系统的功能就实现了。
任务的运行状态:
1 |
|
任务控制块(Task Control Block)数据结构:
1 |
|
之后,还需要一个全局的任务管理器来管理这些用任务控制块:
1 | pub struct TaskManager { |
然后实现sys_yield
系统调用,即暂停当前的应用并切换到下个应用;修改sys_exit
系统调用,改成基于task
子模块的退出当前的应用并切换到下个应用。
暂停或结束当前的应用很简单,修改运行状态即可,切换到下个应用需要找到下一个可以加载的应用,run_next_task
的实现中调用find_next_task
方法尝试寻找一个运行状态为Ready
的应用并返回其ID。它的实现如下:
1 | fn find_next_task(&self) -> Option<usize> { |
map(|id| id % self.num_app)
将范围中的每个id
进行模运算(id % self.num_app
),确保任务索引是循环的。如果id
超过了self.num_app
,它会重新从0开始。
最后,在第一次从内核态进入用户态时,为每个任务构造单独的上下文,执行第一个应用时,声明一个空的上下文结构体,作为switch
函数的第一个变量传入,避免数据覆盖。
协作式调度中,如果一个应用一直不主动放弃CPU,下一个应用只能等到现在的这个应用出错或退出才有机会获得CPU,这样不太公平,也不够高效。现代的任务调度算法基本都是抢占式的,将时间片作为应用运行时长的单位,内核会在一个时间片后切换到下一个任务。
这需要能够对时间计时,然后再实现抢占的机制。
计时通过RISC-V
的时钟中断和计时器来实现,RISC-V
的机器模式下通过定时器控制寄存器(mtime
)和定时器比较寄存器(mtimecmp
)生成时钟中断。mtime
是一个 64 位的寄存器,表示自系统启动以来的时钟周期数。mtimecmp
是一个用于定时器中断的比较寄存器,当 mtime
达到或超过mtimecmp
的值时,系统会生成一个中断信号。导入的RISC-V
依赖中实现了相关的接口,直接调用即可。
之后实现抢占机制,内核既然能够将yield
调用开放给应用程序,自然也能自己调用(废话),在trap_handler
中加入对时钟中断的处理,然后调用上面实现过的函数暂停当前应用并切换到下一个。
在发生中断时,RISC-V
的硬件会把sstatus.sie
字段设为0,硬件会自动禁用所有同特权级中断;因此,在不手动改变寄存器的情况下,不会发生嵌套调用,目前的内核中,为了简单起见,不会响应S特权级的中断,也就不会发生嵌套调用。在执行第一个应用前需要执行一些初始化工作,调用enable_timer_interrupt()
初始化sie.stie
字段,使得S特权级时钟中断不会被屏蔽,再设置第一个计时器。