实现U模式的trap机制
好久没更新了,最近在摆烂,天天打瓦洛兰特………………..瓦洛兰特是真好玩啊hhhhh
1. 测试用户态的syscall
1.1 riscv特权级切换
RISC-V
架构中一共定义了 4 种特权级:
级别 | 编码 | 名称 |
---|---|---|
0 | 00 | 用户/应用模式 (U, User/Application) |
1 | 01 | 监督模式 (S, Supervisor) |
2 | 10 | 虚拟监督模式 (H, Hypervisor) |
3 | 11 | 机器模式 (M, Machine) |
其中级别数值越大,特权级越高,对硬件的控制能力越强。之前移植的Opensbi运行在M模式下,S模式下的程序通过ecall指令去调用Opensbi提供的服务,U模式下的程序同样也可通过ecall指令来获取S模式下提供的服务。
我们编写的os程序就是运行在S态的,向用户态提供的接口标准被称为ABI。用户态应用直接触发从用户态到内核态的异常的原因总体上可以分为两种:其一是用户态软件为获得内核态操作系统的服务功能而执行特殊指令;其二是在执行某条指令期间产生了错误(如执行了用户态不允许执行的指令或者其他错误)并被 CPU 检测到。特权切换的机制如下图:
1.2 riscv 系统调用简介
我们知道我们在linux
系统下编写的应用程序是去调用C库的函数去实现对应的功能,而C库呢会去使用内核提供的一组接口去访问硬件设备和操作系统资源,这组接口就被称为系统调用。
在X86
平台上,Linux在用int 0x80
进行系统调用时,调用号存在于EAX
中,第一个参数存在于EBX
,第二个参数存在于ECX
,第三个参数存在于EDX
。而在riscv
平台下,系统调用是通过ecall
指令来触发的,ecall
指令规范中没有其他的参数,Syscall
的调用参数和返回值传递通过遵循如下约定实现:
- 调用参数
a7
寄存器存放系统调用号,区分是哪个Syscall
a0-a5
寄存器依次用来表示Syscall
编程接口中定义的参数
- 返回值
a0
寄存器存放Syscall
的返回值
ecall
指令会根据当前所处模式触发不同的执行环境切换异常:
- in U-mode: environment-call-from-U-mode exception
- in S-mode: environment-call-from-S-mode exception
- in M-mode: environment-call-from-M-mode exception
Syscall
场景下是在 U-mode(用户模式)下执行 ecall
指令,主要会触发如下变更:
- 处理器特权级别由 User-mode(用户模式)提升为 Supervisor-mode(内核模式)
- 当前指令地址保存到
sepc
特权寄存器 - 设置
scause
特权寄存器 - 跳转到
stvec
特权寄存器指向的指令地址
1.3 riscv 系统调用测试
首先我在test
文件夹下新建了一个syscall
目录,里面新建了三个文件:
首先看Makefile
:
CC=riscv64-unknown-elf-gcc |
很简单就是编译两个源文件,生成write.out
然后是syscall.c
|
可以看见在这里定义了一个syscall
函数,传入的参数为系统调用号以及三个参数,通过内联汇编的形式将系统调用号写入了a7
寄存器,然后将传入的三个参数分别写入了a0,a1,a2
寄存器,然后调用ecall
指令进入内核的异常处理程序。再调用完成后内核会将返回值放在a0
寄存器中。
在这段代码中,”memory” 是一种内联汇编(inline assembly)中的约束(constraint)。内联汇编是一种在C或C++代码中嵌入汇编指令的技术,它允许直接在高级语言中嵌入底层的汇编代码。
在这里,”memory” 约束告诉编译器该内联汇编代码可能会读取或修改内存中的数据,因此编译器不能对与内存访问相关的操作进行优化或重排。
为什么需要这个约束呢?因为系统调用(syscall)可能会对内存中的数据进行读取或修改,而编译器在进行代码优化时通常会假设汇编代码不会影响内存中的数据。如果没有加上 “memory” 约束,编译器可能会错误地优化掉对内存的读写操作,导致系统调用出现问题。
test_write.c
|
这里就是去调用syscall函数去执行系统调用,它传递了四个参数:0x40
代表系统调用号64,它是write
系统调用的号码,在RISC-V下是用于输出信息到标准输出的;1
是标准输出的文件描述符,message
是要输出的字符串的地址,len
是要输出的字符串的长度。
我一直想找在RV64
的linux
系统下的系统调用号是多少的文档,找了一圈找不到,最后没办法只有去看linux
源码中的定义,在内核源码的arch/riscv/include/uapi/asm/unistd.h
中,如下:
打开上面红色箭头这个头文件就能找到系统调用号的定义:
可以看见write
的系统调用号是64
即0x40
。
编译,然后用qemu运行:
timer@DESKTOP-JI9EVEH:~/quard-star/test/syscall$ make |
可以看见输出了Hello, RISC-V!
,系统调用成功。qemu-riscv64
模拟了一个64
位的linux
系统,所以可以加载elf
格式的可执行文件运行。
2. 内核trap机制简介
首先先明确一下我们的目标是用户态的程序通过ecall指令陷入S态即我们的os,os需要对此次ecall进行处理,处理完毕后返回到用户态继续执行。应用程序被切换回来之后需要从发出系统调用请求的执行位置恢复应用程序上下文并继续执行,这需要在切换前后维持应用程序的上下文保持不变。应用程序的上下文包括通用寄存器和栈两个主要部分。由于 CPU 在不同特权级下共享一套通用寄存器,所以在运行操作系统的 Trap 处理过程中,操作系统也会用到这些寄存器,这会改变应用程序的上下文。因此,与函数调用需要保存函数调用上下文/活动记录一样,在执行操作系统的 Trap 处理过程(会修改通用寄存器)之前,我们需要在某个地方(某内存块或内核的栈)保存这些寄存器并在 Trap 处理结束后恢复这些寄存器。这里显而易见我们会使用栈来保存相关的寄存器。
2.1 与S模式相关的异常寄存器
与特权级无关的一般的指令和通用寄存器 x0
~ x31
在任何特权级都可以执行。而每个特权级都对应一些特殊指令和 控制状态寄存器 (CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态。当然特权指令不仅具有读写 CSR 的指令,还有其他功能的特权指令。
如果处于低特权级状态的处理器执行了高特权级的指令,会产生非法指令错误的异常。这样,位于高特权级的执行环境能够得知低特权级的软件出现了错误,这个错误一般是不可恢复的,此时执行环境会将低特权级的软件终止。
在RV64架构下,寄存器的长度是64位。
2.1.1 Supervisor Status Register (sstatus)
这个寄存器我们主要关注的是bit[8]:SPP
,该位表示cpu在进入S模式之前正在执行的特权级别。当接收到trap时,如果该trap来自用户模式,则SPP设置为0,否则设置为1。当执行一条SRET指令从trap处理程序返回时,如果SPP位为0,则特权级别被设置为U模式,如果SPP位为1,则特权级别被设置为S模式;SPP设置为0。
2.1.2 Supervisor Trap Vector Base Address Register (stvec
)
stvec寄存器用于设置发生trap时,异常处理程序的地址。
- MODE 位于 [1:0],长度为 2 bits;
- BASE 位于 [63:2],长度为 62 bits。
当 MODE 字段为 0 的时候, stvec
被设置为 Direct 模式,此时进入 S 模式的 Trap 无论原因如何,处理 Trap 的入口地址都是 BASE<<2
,CPU 会跳转到这个地方进行异常处理。当 MODE 字段为 1 的时候,异常触发后会跳转到以BASE字段对应的异常向量表钟,每个向量占4个字节。
2.1.3 Supervisor Scratch Register (sscratch)
sscratch寄存器是一个可读/写的辅助寄存器,通常,在hart执行用户代码时,sscratch用于切换上下文的栈。
2.1.4 Supervisor Exception Program Counter (sepc
)
sepc记录了 Trap 发生之前执行的最后一条指令的地址
2.1.5 Supervisor Cause Register (scause
)
scause寄存器记录了S模式下异常发生的原因,最高位为interrupt
字段,如下表所示,当interrupt
字段为1时,代表触发的异常类型为中断类型。否则为同步类型异常。
Interrupt | Exception Code | Description | |
---|---|---|---|
1 | 0 | Reserved | |
1 | 1 | Supervisor software interrupt | |
1 | 2–4 | Reserved | |
1 | 5 | Supervisor timer interrupt | |
1 | 6–8 | Reserved | |
1 | 9 | Supervisor external interrupt | |
1 | 10–15 | Reserved | |
1 | ≥16 | Designated for platform use | |
0 | 0 | Instruction address misaligned | |
0 | 1 | Instruction access fault | |
0 | 2 | Illegal instruction | |
0 | 3 | Breakpoint | |
0 | 4 | Load address misaligned | |
0 | 5 | Load access fault | |
0 | 6 | Store/AMO address misaligned | |
0 | 7 | Store/AMO access fault | |
0 | 8 | Environment call from U-mode | |
0 | 9 | Environment call from S-mode | |
0 | 10–11 | Reserved | |
0 | 12 | Instruction page fault | |
0 | 13 | Load page fault | |
0 | 14 | Reserved | |
0 | 15 | Store/AMO page fault | |
0 | 16–23 | Reserved | |
0 | 24–31 | Designated for custom use | |
0 | 32–47 | Reserved | |
0 | 48–63 | Designated for custom use | |
0 | ≥64 | Reserved |
2.1.6 Supervisor Trap Value (stval
) Register
当处理器陷入S模式时,stval寄存器记录了发生异常的虚拟地址。
更详细的寄存器解释可在这里看见:
2.2 特权级切换的软硬件控制机制
当 CPU 执行完一条指令(如 ecall )并准备从用户特权级 陷入( Trap )到 S 特权级的时候,硬件会自动完成如下这些事情:
sstatus
的SPP
字段会被修改为 CPU 当前的特权级(U/S)。sepc
会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。scause/stval
分别会被修改成这次 Trap 的原因以及相关的附加信息。- CPU 会跳转到
stvec
所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。这里会根据scause
中保存的异常原因进行分发处理
而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret
来完成,这一条指令具体完成以下功能:
- CPU 会将当前的特权级按照
sstatus
的SPP
字段设置为 U 或者 S ; - CPU 会跳转到
sepc
寄存器指向的那条指令,然后继续执行。
在具体执行trap处理程序时,由于执行完毕后我们需要恢复到原来的地址继续执行所以我们需要保存寄存器的值,需要恢复知情trap前后的上下文信息,因此需要定义一个栈段来保存用户态的寄存器的值。所以os需要做的软件工作如下:
- 应用程序通过
ecall
进入到内核状态时,操作系统保存被打断的应用程序的 Trap 上下文; - 操作系统根据Trap相关的CSR寄存器内容,完成系统调用服务的分发与处理;
- 操作系统完成系统调用服务后,需要恢复被打断的应用程序的Trap 上下文,并通
sret
让应用程序继续执行。
3. 为timer_os实现trap机制
我在os目录下新增了一个types.h的文件,里面声明了一些数据定义类型:
|
rv64
的寄存器是64
位的,用typedef
定义了一个reg_t
的类型用于定义使用的寄存器
3.1 寄存器读写
我在os的目录下新建了一个riscv.h的文件,此文件中定义了一些获取寄存器值的函数。
|
可以看见用内联汇编的方式来读写与S态相关的控制寄存器的值。
3.2 用户栈和内核栈定义
当应用程序在用户态执行ecall指令陷入内核时,内核需要保存应用程序的各个寄存器的值,在内核中我们可以定义一个栈段来进行保存,同时为了为了安全机制,让用户程序不会干扰到内核栈,我们当用户程序在执行时专门为用户程序分配一段栈。由此需要定义一个内核栈专门给S态的内核使用,专门定义一个用户栈给用户程序使用。我在os目录下新建了一个batch.c
的文件,在此文件中定义了KernelStack
和UserStack
|
KernelStack
和UserStack
的大小被定义为8kb。
3.3 Trap上下文执行流定义
Trap上下文执行流的数据就是寄存器中的数据,有x0~x31
总共32
个通用寄存器以及sstatus
和sepc
等控制寄存器需要保存。在os目录下新建了一个context.h
|
3.4 Trap上下文的保存和恢复
在os目录下新建了一个kerneltrap.S
的文件,此汇编文件中定义了两个函数:__alltraps 、__restore
。
首先来看__alltraps
:
.globl __alltraps |
__alltraps
函数就是发生异常时的处理函数,在此函数中:
- 第五行中将sscratch和sp的值进行了交换,在进入此函数时sp指向的是用户栈,sscratch中的值保存的是内核栈的栈顶。进行交换后,由于此时进入了S态,所以需要切换栈,由此就切换到了内核栈。
- 然后就是将寄存器的值保存进内核栈中,在上面上下文的定义可以看见pt_regs中定义了34个寄存器,所以通过
addi sp, sp, -34*8
指令来压栈,然后依次保存寄存器的值 - 最后两行将内核栈的sp保存进a0寄存器用于传参,所以将用户态寄存器保存进内核栈后,调用了
trap_handler
函数,在此函数中可通过a0传入的参数访问内核栈中储存的寄存器的值。
然后是__restore
函数,此函数需要将内核栈中的存储的寄存器的值恢复,然后通过sret
指令返回从S态到用户态继续执行。
.globl __restore |
__restore
函数的定义为__restore(pt_regs *next)
,所以在第一行传入内核栈地址,然后将内核栈中存放的寄存器的值恢复,然后切换sp,最后通过sret返回用户态继续执行- 在最后两行会将
sp
指向用户栈,sscratch
指向内核栈
3.5 编写应用程序测试
3.5.1 编写应用程序
我在batch.c
中新增了一段用户代码的程序:
size_t syscall(size_t id, reg_t arg1, reg_t arg2, reg_t arg3) { |
可以看见在testsys()
函数中,调用了三次syscall函数进行测试,每一次传入的参数都不同,仅用于测试。
3.5.2 trap.c
在上面的__alltraps
函数中,调用了trap_handler
函数对异常进行处理,因此我们需要实现此函数,我在os目录下定义了一个trap.c
的文件:
extern void __alltraps(void); |
这里定义了两个函数,其中trap_init
用于设置stvec
寄存器的值,这里是告诉cpu发生异常时处理函数的地址,将其设置为 __alltraps
的地址。
trap_handler
函数中打印了内核栈中储存的寄存器的值,在syscall中对a0,a1,a2,a7寄存器的值进行了修改,这些寄存器的值通过__alltraps
函数会保存在内核栈中,然后将内核栈的地址放入a0寄存器中作为函数参数传了出来,因为我们可以在此来进行异常的分发,这里只是打印传入的系统调用参数来验证。在系统调用的逻辑处理完后,需要将sepc 的值+8,然后调用
__restore
函数来恢复寄存器的值,同时切换栈指针到用户栈,并通过sret
返回到sepc+8
的地址处继续执行代码。
3.5.3 测试代码
我们直接看代码,再分析逻辑,在batch.c
中新增了如下代码:
代码测试逻辑是:伪造一个内核栈,然后通过__restore
函数从S态返回U态进行函数执行,返回的地址设置为testsys()函数的地址
extern void __restore(pt_regs *next); |
- 首先得到了用户栈的地址,因为栈是从高地址往低地址向下增长的,所以用户栈的地址为
&UserStack + USER_STACK_SIZE
- 然后调用
trap_init
函数来设置stvec
寄存器的值为__alltraps
,这里告诉cpu发生trap时去哪里执行 - 然后设置sstatus寄存器的SPP位为0。这是为啥呢?在上面对寄存器的介绍中提到“当执行一条SRET指令从trap处理程序返回时,如果SPP位为0,则特权级别被设置为U模式,如果SPP位为1,则特权级别被设置为S模式;”所以我们为了从S模式返回用户模式去执行testsys()中的代码,我们需要将SPP位设置为0。
- 然后就是事先构造一段内核栈,设置
sstatus
、sepc
、sp
的值,这里由于下一阶段为用户模式,所以sepc会设置成用户态程序的地址,sp设置为用户栈的地址。 - 设置完成后调用
__restore
函数,让其返回用户态执行程序。
3.5.4 编译测试
在main.c
中调用app_init_context();
函数:
|
修改Makefile
,添加新增的源文件
SRCS_ASM = \ |
Makefile
还修改了一个地方
os.elf: ${OBJS} |
这里新增了-Wl,-Map=os.map
选项,会在编译时生成一个os.map
的符号表用于调试。
回到timer@DESKTOP-JI9EVEH:~/quard-star$
目录,构建执行:
timer@DESKTOP-JI9EVEH:~/quard-star$ ./build.sh |
结果如下:
可以看见进行系统调用的参数都成功打印,验证成功。cause的值为8,对应上面的异常原因表可以看见是U模式的系统调用。
但是这里有个很奇怪的点就是,每次返回的sepc都是同一个地址,我很奇怪,按道理来说每次syscall都会去调用一次ecall指令,所以sepc应该会被设置成每次syscall的ecall的地址,由于我进行了多次syscall调用sepc会不同,但是实际上每次都sepc都被设置成了同一个ecall的地址。转念一想,编译器确实在处理syscall函数时,其中ecall这条指令的地址始终是不会变的,但是我疑惑为什么将此地址+8后,就能跳到下一条正确的地址执行…………
参考链接
说明一下,我的timer_os
其实是在复现并修改rCore
,rCore
是rust
编写的os
,我想把它改成c语言的用于个人学习。此篇文章对应的是rCore
第二章-批处理系统的内容