前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >从零手写操作系统之RVOS抢占式多任务实现-06

从零手写操作系统之RVOS抢占式多任务实现-06

作者头像
大忽悠爱学习
发布2023-10-11 08:38:41
3580
发布2023-10-11 08:38:41
举报
文章被收录于专栏:c++与qt学习
从零手写操作系统之RVOS抢占式多任务实现-06

本系列参考: 学习开发一个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中新增对上下文切换的支持:

代码语言:javascript
复制
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的值
代码语言:javascript
复制
/* 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的值。

  • 特权级别第一次切换发生在第一次trap发生时
  • 其实也不一定非要发生trap,在m态下调用mret指令也可以完成特权级别的切换
  • 因为发生trap需要确保开启了全局中断,但是操作系统在初始化工作还没结束前,全局中断不应该被打开
  • 所以我们也可以在m态下通过手动调用switch_to函数,完成初始化任务调度执行,并借助switch_to函数的mret指令,完成特权级别的切换

多任务切换实现篇中对start.s进行了详细解释,本节在该篇基础上新增了对mstatus中MPP位和MPIE位初始化设置。

课程给出的源码中,是将MPP位初始化为了3,也就是让后续任务始终执行在m模式下,同时设置MPIE为1,这是为了让trap返回后,将中断打开。

同上,由于mstatus的MIE位默认为0,即全局中断默认关闭,如果我们想要打开全局中断,可以在系统启动时,设置MIE位为1

  • 操作系统初始化工作还没结束前,全局中断不应该被打开,所以从第8节开始,也就是本节开始,改为通过在start.s汇编文件中设置MPIE位为1,然后在操作系统初始化工作结束后,调用schedule函数,让其调度1号任务执行,在schedule函数内部会调用switch_to函数,该函数返回的指令也由ret改为了mret:
  • 此时,我们借助的就是mret,帮助我们在不发生trap的情况下,完成特权级别切换和全局中断MIE位打开。

任务mepc初始化

任务创建的时候,需要初始化它的mepc寄存器,指向程序的入口地址:

代码语言:javascript
复制
/*
 * 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方法,而不是通过中断程序间接调用的:

代码语言:javascript
复制
/*
 * 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方法实现也需要做出相应调整:

代码语言:javascript
复制
/*
 * 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函数实现更改
代码语言:javascript
复制
/*
 * 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;
}
  • 新增对软件中断的处理
代码语言:javascript
复制
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函数实现复用抢占式多任务更改后的版本。


测试

代码语言:javascript
复制
#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);
}
  • 系统启动函数
代码语言:javascript
复制
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后半部分逻辑还是有必要的,因为需要依靠这段逻辑完成中断返回,继续执行源程序。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-06-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 从零手写操作系统之RVOS抢占式多任务实现-06
  • 多任务系统的分类
    • 抢占式多任务的设计
      • 代码
        • 任务切换流程分析
          • 系统启动
          • 任务mepc初始化
          • 首个被调度执行的任务
          • 任务切换
        • 兼容协作式多任务
          • 软件中断
          • 编码实现
        • 测试
        • 注意点
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档