从零手写操作系统之RVOS系统调用实现-09
- 系统模式:用户态和内核态
- 如何让任务运行在用户态下
- 系统模式的切换
- 用户模式下访问特权指令测试
- 系统调用
- 系统调用执行流程
- 系统调用传参规范
- 系统调用封装
- 系统调用完整流程解析
- 执行测试
本系列参考: 学习开发一个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
- 从零手写操作系统之RVOS抢占式多任务实现-06
- 从零手写操作系统之RVOS任务同步和锁实现-07
- 从零手写操作系统之RVOS软件定时器实现-08
系统模式:用户态和内核态
在之前章节中,我们的程序其实一直都运行在Machine态下,但是RISC-V是支持3种不同的运行模式的,如下图所示:
本节中,想要实现的目标就是改造我们的RVOS系统,使其能够支持M和U模式,也就是U模式作为用户态,M模式作为内核态。
在支持虚拟内存的类Linux操作系统中,内核态可能指的是的S模式
在抢占式任务实现篇中,我们详细分析了上图start.s启动汇编中那几行代码,其作用简单来说就是:
- 设置mstatus的MPP和MPIE位为1
在start_kernel函数中,通过schedule函数手动切换到初始任务执行,该过程会调用switch_to函数完成指令流的切换执行。
switch_to函数最后会调用mret指令,该指令会将MPP保存的特权级恢复为当前特权级别,MPIE保存的中断使能位,恢复为当前中断使能位,效果就是设置当前任务也运行在M态下,并且打开全局中断使能。
如何让任务运行在用户态下
那么如何设置让任务运行在U态下呢?
- 由于mstatus的MPP位默认为0,所以我们只需要在start.s汇编文件中,去掉对MPP位的设置即可:
当switch_to第一次被手动调用时,执行mret指令,该指令将MPP保存的特权级恢复为当前特权级别,此时当前特权级别为用户态。
随后,跳转到任务入口地址处执行,这样就可以确保任务运行在用户态下。
系统模式的切换
用户模式下访问特权指令测试
当我们的用户程序跑在用户态下的时候,其访问M态下才能访问的资源时,就会受到限制,那么如何解决呢?
我们首先来测试看看在用户态下,执行特权指令是否会触发异常:
- 首先看一下start.s中的更改
- 在来看一下user.c中的更改
void user_task0(void)
{
uart_puts("Task 0: Created!\n");
unsigned int hid = -1;
/*
* if syscall is supported, this will trigger exception,
* code = 2 (Illegal instruction)
* 在用户模式下,尝试读取mhartid寄存器内容,会抛出非法指令异常,错误码为2
*/
hid = r_mhartid();
printf("hart id is %d\n", hid);
while (1){
uart_puts("Task 0: Running... \n");
task_delay(DELAY);
}
}
/* which hart (core) is this? */
static inline reg_t r_mhartid()
{
reg_t x;
asm volatile("csrr %0, mhartid" : "=r" (x) );
return x;
}
- 测试希望效果
- 测试结果符合预期
注意: makeFile文件不要忘记携带SYSCALL宏定义
系统调用
RISC-V处于安全考虑,不允许用户态程序直接执行部分特权指令,因此只能采用间接的方式进行访问,也就是通过系统调用的方式进行特权资源访问。
所谓系统调用,就是通过一条特殊的ecall指令,帮助我们从用户态切换到内核态执行,然后通过一条eret指令,从内核态再切换回用户态执行:
ecall指令执行本质就是触发一次异常:
- 用户态下调用ecall指令,触发得到的错误码为8
- S态下,为9
- M态下,为11
异常产生时,epc寄存器的值存放的是ECALL指令本身的地址,因此,我们需要注意将epc值更改为ECALL下一条指令的地址,否则就会触发死循环,不断执行ECALL指令。
系统调用执行流程
因为ECALL指令本质是主动触发一次异常,所以ECALL指令的执行流程和前面讲过的统一异常处理流程是一致的,这里不再过多展开。
为了解决用户态下无法直接读取mhartid寄存器来获取当hart Id的问题,我们需要编写一个系统调用函数gethid,让用户程序通过调用该函数,完成上面的需求。
整个系统调用流程如下图所示:
- gethid函数中通过ecall指令进行系统调用,主动触发一次异常
- hart跳到mvetc指向的中断程序入口地址处执行,同时MPP保存进入trap前的特权级别,MPIE保存进入trap前的全局中断使能位
- trap_vector进行上下文保存,然后调用trap_handler中断处理程序
- trap_handler中断处理程序中,发现此次发生的trap是异常,又根据错误码发现此次发生的异常实际是一次系统调用
- 执行系统调用函数
- 将返回地址加上4个字节,也就是跳到发生异常的下一条指令去执行,而非重试异常指令,避免陷入死循环
- mret进行中断返回,将当前特权级别恢复为MPP,当前全局中断使能恢复为MPIE
为了能在中断处理程序中访问到当前任务上下文,我们新增了将任务上下文地址作为参数传入中断处理程序的逻辑:
中断处理程序函数中新增一个context参数,用于接收当前任务上下文地址:
系统调用传参规范
ecall指令用来触发一次系统调用,但是ecall这条指令本身并没有提供额外的位用于存放标记,来区分不同的系统调用,如: write系统调用,read系统调用 ,open系统调用…
为了区分这些系统调用,我们需要给每个系统调用分配一个号码,称为系统调用号,系统调用号在本系统中存放于a7寄存器中。
虽然系统调用传参规则由不同的系统自己决定,但是也要遵循RISC-V函数传参规范
系统调用本质也是一个函数,也需要有参数,但是不同的系统调用需要的参数个数未必一样,所以我们这里规定系统调用参数使用寄存器范围在a0-a5之间。
系统调用返回值放在a0中,用于表示成功还是失败,成功一般为0,如果失败了,则使用负数来表示不同的错误码。
系统调用封装
为了让用户程序能够访问特权资源,我们可以借助ecall系统调用指令,并借助于系统调用号区分不同的系统调用。
我们的系统所要做的就是提供不同的系统调用,每个系统调用由系统调用号和系统调用处理函数组成,系统调用号存放于一个单独的syscall.h头文件中,而具体的系统调用函数实现则存放于syscall.c文件中。
同时,为了让用户程序调用我们的系统调用,我们需要编写一份相同的syscall.h头文件,该头文件列举了当前系统支持的所有系统调用号,同时编写对应的usys.S文件,为每个系统调用封装一层函数,用于向用户屏蔽通过ecall指令加系统调用号来调用底层系统调用函数的处理过程。
我们将上图中左部分存放于C库中,暴露给用户程序访问,而右部分存放于内核中,作为系统调用具体实现,这种分离的做法,也是Linux操作系统采用的策略。
- 暴露给用户的库文件
syscall.h
// System call numbers
#define SYS_gethid 1
usys.S
#include "syscall.h"
.global gethid
gethid:
//将系统调用号,加载到a7寄存器中
li a7, SYS_gethid
//执行系统调用
ecall
ret
- 操作系统内核中驻留的系统调用实现相关库文件
syscall.h
// System call numbers
#define SYS_gethid 1
syscall.c
#include "os.h"
#include "syscall.h"
//获取当前hart id的系统调用
int sys_gethid(unsigned int *ptr_hid)
{
printf("--> sys_gethid, arg0 = 0x%x\n", ptr_hid);
if (ptr_hid == NULL) {
return -1;
} else {
//hart id存放于传入内存地址处
*ptr_hid = r_mhartid();
return 0;
}
}
//根据系统调用号,完成系统调用分发处理
void do_syscall(struct context *cxt)
{
//从当前任务的上下文中获取系统调用号
uint32_t syscall_num = cxt->a7;
//根据系统调用号完成系统调用任务执行的派发
switch (syscall_num) {
case SYS_gethid:
//进行获取hart id的系统调用,结果存放于a0寄存器中
//hart id存放于a0寄存器保存的内存地址处
//a0寄存器这里即作为函数调用参数,又作为函数返回值进行传递
cxt->a0 = sys_gethid((unsigned int *)(cxt->a0));
break;
default:
//错误码使用负数表示,这里简单起见,系统调用出错都返回-1
printf("Unknown syscall no: %d\n", syscall_num);
cxt->a0 = -1;
}
return;
}
trap返回时,会将当前任务的上下文进行恢复,这样用户程序就可以从a0寄存器中取出系统调用的结果了。
系统调用完整流程解析
- 编写任务0,在该任务中执行我们编写的系统调用
void user_task0(void)
{
uart_puts("Task 0: Created!\n");
unsigned int hid = -1;
/*
* if syscall is supported, this will trigger exception,
* code = 2 (Illegal instruction)
*/
//hid = r_mhartid();
//printf("hart id is %d\n", hid);
//携带该宏定义,进行系统调用测试
#ifdef CONFIG_SYSCALL
int ret = -1;
//执行系统调用
//结果存放于hid变量中
ret = gethid(&hid);
//ret = gethid(NULL);
if (!ret) {
printf("system call returned!, hart id is %d\n", hid);
} else {
printf("gethid() failed, return: %d\n", ret);
}
#endif
while (1){
uart_puts("Task 0: Running... \n");
task_delay(DELAY);
}
}
- 执行系统调用包装函数
3. ecall指令触发异常,错误码为8 (当前处于U态下)
trap_vector中断处理程序入口代码基本没有变动,只是额外新增了当前任务上下文地址作为参数进行传递。
4. trap_handler函数根据错误码完成异常转发
5. do_syscall函数根据系统调用号再次进行转发
6. do_syscall函数返回 , a0存放返回值,即中断调用结果,但是注意此时a0的值是存放于当前任务的上下文中
7. trap_handler函数返回,返回值为mepc+4,返回值存放于a0寄存器中
8. trap_vector函数返回, 将a0赋值给mepc,恢复当前任务的上下文,此时a0中存放的是系统调用的返回结果,然后利用mret指令跳到mepc地址处执行 —> gethid函数的ret指令,即ecall指令的下一条指令
9. gethid函数返回,此时a0寄存器存放的是系统调用结果
10. user_task0任务中拿到系统调用执行结果
执行测试
在前面代码基础上,只对user_task0号任务进行修改:
void user_task0(void)
{
uart_puts("Task 0: Created!\n");
unsigned int hid = -1;
/*
* if syscall is supported, this will trigger exception,
* code = 2 (Illegal instruction)
*/
//hid = r_mhartid();
//printf("hart id is %d\n", hid);
#ifdef CONFIG_SYSCALL
int ret = -1;
ret = gethid(&hid);
//ret = gethid(NULL);
if (!ret) {
printf("system call returned!, hart id is %d\n", hid);
} else {
printf("gethid() failed, return: %d\n", ret);
}
#endif
while (1){
uart_puts("Task 0: Running... \n");
task_delay(DELAY);
}
}
测试结果如下: