从零手写操作系统之RVOS抢占式多任务实现-06
- 多任务系统的分类
- 抢占式多任务的设计
- 代码
- 任务切换流程分析
- 系统启动
- 任务mepc初始化
- 首个被调度执行的任务
- 任务切换
- 兼容协作式多任务
- 软件中断
- 编码实现
- 测试
- 注意点
本系列参考: 学习开发一个RISC-V上的操作系统 - 汪辰 - 2021春 整理而来,主要作为xv6操作系统学习的一个前置基础。
RVOS是本课程基于RISC-V搭建的简易操作系统名称。
课程代码和环境搭建教程参考github仓库: https://github.com/plctlab/riscv-operating-system-mooc/blob/main/howto-run-with-ubuntu1804_zh.md
前置知识:
- RVOS环境搭建-01
- RVOS操作系统内存管理简单实现-02
- RVOS操作系统协作式多任务切换实现-03
- RISC-V 学习篇之特权架构下的中断异常处理
- 从零手写操作系统之RVOS外设中断实现-04
- 从零手写操作系统之RVOS硬件定时器-05
多任务系统的分类
抢占式多任务的设计
和协作式多任务实现思路类似,只不过将任务切换过程放到了trap_handler中完成。
在上一节的定时器处理流程基础上,我们在time_handler中新增对上下文切换的支持:
void timer_handler()
{
_tick++;
printf("tick: %d\n", _tick);
//重置时钟中断下一次触发时间
timer_load(TIMER_INTERVAL);
//进行任务调度
schedule();
}
//这个代码在之前协作式任务章节中给出过
void schedule()
{
if (_top <= 0) {
panic("Num of task should be greater than zero!");
return;
}
_current = (_current + 1) % _top;
struct context *next = &(ctx_tasks[_current]);
switch_to(next);
}
在协作式任务切换一节中的switch_to函数实现里面,我们采用的是ret指令进行的函数返回,ret指令执行后,会跳回到ret指令到ra寄存器保存的地址处继续执行。
而在抢占式多任务的实现中,我们的switch_to函数是在中断处理程序中执行的,所以函数返回靠的应该是mret指令,而非ret指令:
而对于mret指令而言,我们需要知道:
因此,和ret指令相比,也就是用于保存返回地址的寄存器改变了,一个是ra,一个是mepc。
代码
当我们把switch_to进程调度的逻辑放置到时钟中断处理程序中时,意味着进程A在进入时钟中断处理过程中后,会进行任务切换,切换到进程B执行,那么中断处理程序返回后,应该跳转到进程B的指令流中继续执行,如下图所示:
很明显,这里mepc的值是不同的,我们需要中断处理函数调用过程中保存进程A赋值后的mepc到当前进程从Context中,然后在switch_to任务切换函数中,从Context中恢复进程B寄存器相关值,包括mepc的值,从而达成进程A执行过程中触发定时器中断,在中断处理程序中进行任务调度,中断返回后,继续执行进程B的指令流。
- 首先,我们需要在trap_vector中断程序处理入口中,在之前处理逻辑基础上,新增对于mepc寄存器保存到当前进程Context上下文的逻辑
- 其次,我们需要在switch_to函数中,新增从要切换到的进程从Context上下文空间取出先前存储的mepc寄存器的值,进行恢复
- 最后,还有一点很重要,context结构体中新增pc属性,用于保存mepc的值
/* task management */
struct context {
/* ignore x0 */
reg_t ra;
reg_t sp;
reg_t gp;
reg_t tp;
reg_t t0;
reg_t t1;
reg_t t2;
reg_t s0;
reg_t s1;
reg_t a0;
reg_t a1;
reg_t a2;
reg_t a3;
reg_t a4;
reg_t a5;
reg_t a6;
reg_t a7;
reg_t s2;
reg_t s3;
reg_t s4;
reg_t s5;
reg_t s6;
reg_t s7;
reg_t s8;
reg_t s9;
reg_t s10;
reg_t s11;
reg_t t3;
reg_t t4;
reg_t t5;
reg_t t6;
// upon is trap frame
// save the pc to run in next schedule cycle
reg_t pc; // offset: 31 *4 = 124
};
任务切换流程分析
系统启动
先前在特权架构下的中断异常处理篇中介绍过,RISC-V系统启动时,默认是处于machine态下的,并且在发生trap时,RISC-V会使用mstatus.MPP位来保持进入trap前的特权级别,并更改当前特别级别为machine态,而在trap返回时,从MPP中取出先前的特权级别进行恢复。
mstatus的MPP位默认为0,也就是说第一次发生trap返回后,指令流将会执行在用户态下,我们可以通过在系统初始化时,设置MPP为3,让第一次及后续trap发生后,系统始终处于m模式下:
多任务切换实现篇中对start.s进行了详细解释,本节在该篇基础上新增了对mstatus中MPP位和MPIE位初始化设置。
课程给出的源码中,是将MPP位初始化为了3,也就是让后续任务始终执行在m模式下,同时设置MPIE为1,这是为了让trap返回后,将中断打开。
任务mepc初始化
任务创建的时候,需要初始化它的mepc寄存器,指向程序的入口地址:
/*
* DESCRIPTION
* Create a task.
* - start_routin: task routine entry
* RETURN VALUE
* 0: success
* -1: if error occured
*/
int task_create(void (*start_routin)(void))
{
if (_top < MAX_TASKS) {
ctx_tasks[_top].sp = (reg_t) &task_stack[_top][STACK_SIZE - 1];
//初始化当前创建任务的mepc
ctx_tasks[_top].pc = (reg_t) start_routin;
_top++;
return 0;
} else {
return -1;
}
}
而当任务第一次被调用的时候,也就是swtich_to函数在进行任务切换的时候,如果被切换的任务是第一次进行调用,我们必须在任务创建的时候设置好他的mepc寄存器,否则switch_to函数将无法通过任务上下文空间中保存的mepc值,借助mret指令跳到任务的程序入口地址处执行。
首个被调度执行的任务
要注意的是,首个任务的调度,是直接调用的schedule方法,而不是通过中断程序间接调用的:
/*
* implment a simple cycle FIFO schedular
*/
void schedule()
{
if (_top <= 0) {
panic("Num of task should be greater than zero!");
return;
}
_current = (_current + 1) % _top;
struct context *next = &(ctx_tasks[_current]);
//调用switch_to函数
switch_to(next);
}
因为没有采用中断调用,因此为了让switch_to函数能够像被中断调用那样执行,我们也需要提前将任务在上下文中间中的mepc寄存器值设置好才可以。
任务切换
- 进程A执行自己的指令流,执行到指令i+1时,发生异步时钟中断
- pc被设置为mtvec,同时mepc被设置为i+2
- 跳到trap handler中断函数处理入口地址,执行中断处理函数,此处mtvec.MODE=DIRECT
- 执行中断处理函数trap_vector
- 保存通用寄存器到当前进程上下文空间,保存mepc(A)到进程上下文空间
- 调用异常处理函数trap_handler,取出中断码,发现等于7,跳到时钟中断处理函数–timer_handler处执行
- timer_handler中重置时钟中断下一次触发时间,然后调用schedule,执行任务调度
- schedule方法通过轮询策略选择出一个进程,假设该进程为任务B,然后将任务B的Context上下文地址作为参数传入switch_to函数
- switch_to函数执行上下文切换,首先从任务B的上下文空间中取出mepc(B),赋值给当前的mepc寄存器,然后恢复任务B的执行上下文
- mret指令进行中断返回,其将mepc赋值给pc寄存器,然后跳转到pc指向的地址处继续执行
- 然后B任务执行一段后,执行到指令j+1时,再次发生时钟中断
- pc被设置为mtvec,同时mepc被设置为j+2
- 跳到trap handler中断函数处理入口地址,执行中断处理函数,此处mtvec.MODE=DIRECT
- 执行中断处理函数trap_vector
- 保存通用寄存器到当前进程上下文空间,保存mepc(B)到进程上下文空间
- 调用异常处理函数trap_handler,取出中断码,发现等于7,跳到时钟中断处理函数–timer_handler处执行
- timer_handler中重置时钟中断下一次触发时间,然后调用schedule,执行任务调度
- schedule方法通过轮询策略选择出一个进程,假设该进程为任务A,然后将任务A的Context上下文地址作为参数传入switch_to函数
- switch_to函数执行上下文切换,首先从任务A的上下文空间中取出mepc(A),赋值给当前的mepc寄存器,然后恢复任务A的执行上下文
- mret指令进行中断返回,其将mepc赋值给pc寄存器,然后跳转到pc指向的地址处继续执行
- 以此往复执行
兼容协作式多任务
先前章节中实现的兼容协作式多任务,是通过schedule函数内部调用switch_to函数,由ret指令跳转到ra寄存器保存的地址处继续执行,以此来实现任务切换执行。
但是本节抢占式多任务的实现中,我们已经改变了switch_to函数的工作逻辑,改为mret配合mepc实现任务切换执行。
因此,先前实现的task_yield主动让出cpu方法实现也需要做出相应调整:
/*
* DESCRIPTION
* task_yield() causes the calling task to relinquish the CPU and a new
* task gets to run.
*/
void task_yield()
软件中断
为了在抢占式多任务的实现中兼容协作式多任务,这就需要引出软件中断:
为什么需要使用软件中断来实现对协作式多任务的兼容呢?
- 抢占式多任务通过在定时器中断处理程序中增加任务调度逻辑实现,相当于周期性的打电话给我们的CPU,让其进行任务调度
- 而如果想要兼容协作式多任务的实现,也需要通过打电话的方式通知我们CPU,进行任务调度,只不过这个电话是在我们需要的时候拨通,而不是周期性拨通
- 本质是需要使用中断方式来实现协作式多任务切换,中断方式加上上面我们对trap_vector和switch_to的调整,可以帮助我们在实习协作式多任务切换时复用已有的mepc和mret处理流程
软件中断是由程序中的特殊指令或操作触发的中断。与硬件中断不同,软件中断是由软件控制的,而不是由外部设备或硬件信号引发的。
在RISCV中,具体实现如下:
根据RISC-V规范,mip.MSIP是一个中断挂起位,用于表示是否有来自软件的中断请求。当该位为1时,表示有一个软件中断请求待处理;当该位为0时,表示无软件中断请求。
在QEMU-virt模拟器中,将MSIP寄存器的最低位设置为非零值,会将相应的mip.MSIP位设置为1,从而触发软件中断请求。实际的硬件平台和操作系统可能会有不同的实现方式,但总体原理是类似的。
之所以设置CLIENT提供的MSIP寄存器最低位为1,就可以间接设置mip.MSIP位为1,原理是上图中的第二点:
- RISCV 规范规定,Machine 模式下的 mip.MSIP 对应到一个memory- mapped的控制寄存器。为此QEMU-virt提供MSIP,该MSIP寄存器 为32—bit,高31位不可用,最低位映射到mip.MSIP。
编码实现
- task_yield函数实现更改
/*
* DESCRIPTION
* task_yield() causes the calling task to relinquish the CPU and a new
* task gets to run.
*/
void task_yield()
{
/* trigger a machine-level software interrupt */
int id = r_mhartid();
//打开当前hart的软件中断使能位
*(uint32_t*)CLINT_MSIP(id) = 1;
}
- 新增对软件中断的处理
reg_t trap_handler(reg_t epc, reg_t cause)
{
reg_t return_pc = epc;
reg_t cause_code = cause & 0xfff;
if (cause & 0x80000000) {
/* Asynchronous trap - interrupt */
switch (cause_code) {
//处理软件中断
case 3:
uart_puts("software interruption!\n");
/*
* acknowledge the software interrupt by clearing
* the MSIP bit in mip.
*/
//清空MSIP寄存器的值,作为中断应答,否则会重复触发
int id = r_mhartid();
*(uint32_t*)CLINT_MSIP(id) = 0;
//执行任务调度
schedule();
break;
case 7:
uart_puts("timer interruption!\n");
timer_handler();
break;
case 11:
uart_puts("external interruption!\n");
external_interrupt_handler();
break;
default:
uart_puts("unknown async exception!\n");
break;
}
} else {
/* Synchronous trap - exception */
printf("Sync exceptions!, code = %d\n", cause_code);
panic("OOPS! What can I do!");
//return_pc += 4;
}
return return_pc;
}
switch_to函数实现复用抢占式多任务更改后的版本。
测试
#include "os.h"
#define DELAY 1000
void user_task0(void)
{
uart_puts("Task 0: Created!\n");
//测试主动让出cpu是否会触发软件中断
task_yield();
uart_puts("Task 0: I'm back!\n");
while (1) {
uart_puts("Task 0: Running...\n");
task_delay(DELAY);
}
}
void user_task1(void)
{
uart_puts("Task 1: Created!\n");
while (1) {
uart_puts("Task 1: Running...\n");
task_delay(DELAY);
}
}
/* NOTICE: DON'T LOOP INFINITELY IN main() */
void os_main(void)
{
task_create(user_task0);
task_create(user_task1);
}
- 系统启动函数
void start_kernel(void)
{
uart_init();
uart_puts("Hello, RVOS!\n");
page_init();
trap_init();
plic_init();
timer_init();
sched_init();
os_main();
schedule();
uart_puts("Would not go here!\n");
while (1) {}; // stop here!
}
注意点
对于通过中断调用schedule函数间接实现进程调度的程序而言,上面紫色部分下面是不会调回来继续执行的,因为switch_to函数中后半部分做了和trap_vector后半部分一样的事情
注意: 是任务A由于发生了中断,切换到任务B执行,但是即使下次再切换为任务A继续执行,上面紫色部分下半段代码也是不会执行下去的。
对于其他类型中断而言,trap_vector后半部分逻辑还是有必要的,因为需要依靠这段逻辑完成中断返回,继续执行源程序。