本系列参考: 学习开发一个RISC-V上的操作系统 - 汪辰 - 2021春 整理而来,主要作为xv6操作系统学习的一个前置基础。
RVOS是本课程基于RISC-V搭建的简易操作系统名称。
课程代码和环境搭建教程参考github仓库: https://github.com/plctlab/riscv-operating-system-mooc/blob/main/howto-run-with-ubuntu1804_zh.md
前置知识:
和协作式多任务实现思路类似,只不过将任务切换过程放到了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的指令流。
/* 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模式下:
这里隐藏着一个关键信息: 操作系统启动时,默认运行在machine态下,如果想要让后续任务运行在user态下,我们可以设置MPP为0(其实,不用设置,因为默认为0),这样当第一次trap发生后,在trap返回时,当前特权级别会被调整为MPP的值。
多任务切换实现篇中对start.s进行了详细解释,本节在该篇基础上新增了对mstatus中MPP位和MPIE位初始化设置。
课程给出的源码中,是将MPP位初始化为了3,也就是让后续任务始终执行在m模式下,同时设置MPIE为1,这是为了让trap返回后,将中断打开。
同上,由于mstatus的MIE位默认为0,即全局中断默认关闭,如果我们想要打开全局中断,可以在系统启动时,设置MIE位为1
任务创建的时候,需要初始化它的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寄存器值设置好才可以。
先前章节中实现的兼容协作式多任务,是通过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()
为了在抢占式多任务的实现中兼容协作式多任务,这就需要引出软件中断:
为什么需要使用软件中断来实现对协作式多任务的兼容呢?
软件中断是由程序中的特殊指令或操作触发的中断。与硬件中断不同,软件中断是由软件控制的,而不是由外部设备或硬件信号引发的。
在RISCV中,具体实现如下:
根据RISC-V规范,mip.MSIP是一个中断挂起位,用于表示是否有来自软件的中断请求。当该位为1时,表示有一个软件中断请求待处理;当该位为0时,表示无软件中断请求。
在QEMU-virt模拟器中,将MSIP寄存器的最低位设置为非零值,会将相应的mip.MSIP位设置为1,从而触发软件中断请求。实际的硬件平台和操作系统可能会有不同的实现方式,但总体原理是类似的。
之所以设置CLIENT提供的MSIP寄存器最低位为1,就可以间接设置mip.MSIP位为1,原理是上图中的第二点:
/*
* 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后半部分逻辑还是有必要的,因为需要依靠这段逻辑完成中断返回,继续执行源程序。