实验5: RV64 时钟中断处理
1 实验目的
- 学习 RISC-V 的异常处理相关寄存器与指令,完成对异常处理的初始化。
- 理解 CPU 上下文切换机制,并正确实现上下文切换功能。
- 编写异常处理函数,完成对特定异常的处理。
- 调用 OpenSBI 提供的接口,完成对时钟中断事件的设置。
2 实验环境
- Ubuntu 20.04, 22.04
3 背景知识
3.0 前言
在 lab4
中我们成功的将一个最简单的 OS 启动起来, 但还没有办法与之交互。我们在课程中讲过操作系统启动之后由事件(event
)驱动,在本次实验中我们将引入一种重要的事件 异常, 异常给了 OS 与硬件、软件交互的能力。在 lab4
中我们介绍了在 RISC-V 中有三种特权级 ( M 态、 S 态、 U 态 ), 在Boot阶段, OpenSBI 已经帮我们将 M 态的异常处理进行了初始化,这一部分不需要我们再去实现,因此本次试验我们重点关注 S 态的异常处理。
3.1 RISC-V 中的 Interrupt 和 Exception
3.1.1 什么是 Interrupt 和 Exception
We use the term exception to refer to an unusual condition occurring at run time associated with an instruction in the current RISC-V hart. We use the term interrupt to refer to an external asynchronous event that may cause a RISC-V hart to experience an unexpected transfer of control. We use the term trap to refer to the transfer of control to a trap handler caused by either an exception or an interrupt.
上述是 RISC-V Unprivileged Spec 1.6 节中对于 Trap
、 Interrupt
与 Exception
的描述。总结起来 Interrupt
与 Exception
的主要区别如下表:
Interrupt | Exception |
---|---|
Hardware generate | Software generate |
These are asynchronous external requests for service (like keyboard or printer needs service). | These are synchronous internal requests for service based upon abnormal events (think of illegal instructions, illegal address, overflow etc). |
These are normal events and shouldn’t interfere with the normal running of a computer. | These are abnormal events and often result in the termination of a program |
上文中的 Trap
描述的是一种控制转移的过程, 这个过程是由 Interrupt
或者 Exception
引起的。这里为了方便起见,我们在这里约定 Trap
为 Interrput
与 Exception
的总称。
在下文中 我们用 异常 代指 Trap
3.1.2 相关寄存器
除了32个通用寄存器之外,RISC-V 架构还有大量的 控制状态寄存器 Control and Status Registers(CSRs)
,下面将介绍几个和异常机制相关的重要寄存器。
Supervisor Mode 异常相关寄寄存器:
sstatus
( Supervisor Status Register )中存在一个 SIE ( Supervisor Interrupt Enable ) 比特位,当该比特位设置为 1 时,会对所有的 S 态异常响应, 否则将会禁用所有 S 态异常。sie
( Supervisor Interrupt Eable Register )。在 RISC-V 中,Interrupt
被划分为三类Software Interrupt
,Timer Interrupt
,External Interrupt
。在开启了sstatus[SIE]
之后,系统会根据sie
中的相关比特位来决定是否对该Interrupt
进行处理。stvec
( Supervisor Trap Vector Base Address Register ) 即所谓的”中断向量表基址”。stvec
有两种模式:Direct 模式
,适用于系统中只有一个中断处理程序, 其指向中断处理入口函数 ( 本次实验中我们所用的模式 )。Vectored 模式
,指向中断向量表, 适用于系统中有多个中断处理程序 ( 该模式可以参考 RISC-V 内核源码 )。scause
( Supervisor Cause Register ), 会记录异常发生的原因,还会记录该异常是Interrupt
还是Exception
。sepc
( Supervisor Exception Program Counter ), 会记录触发异常的那条指令的地址。
Machine Mode 异常相关寄寄存器:
- 类似于 Supervisor Mode, Machine Mode 也有相对应的寄存器,但由于本实验同学不需要操作这些寄存器,故不在此作介绍。
以上寄存器的详细介绍请同学们参考 RISC-V Privileged Spec
3.1.3 相关特权指令
ecall
( Environment Call ),当我们在 S 态执行这条指令时,会触发一个ecall-from-s-mode-exception
,从而进入 M 模式中的中断处理流程( 如设置定时器等 );当我们在 U 态执行这条指令时,会触发一个ecall-from-u-mode-exception
,从而进入 S 模式中的中断处理流程 ( 常用来进行系统调用 )。sret
用于 S 态异常返回, 通过sepc
来设置pc
的值, 返回到之前程序继续运行。
以上指令的详细介绍请同学们参考 RISC-V Privileged Spec
3.2 上下文处理
由于在处理异常时,有可能会改变系统的状态。所以在真正处理异常之前,我们有必要对系统的当前状态进行保存,在异常处理完成之后,我们再将系统恢复至原先的状态,就可以确保之前的程序继续正常运行。
这里的系统状态通常是指寄存器,这些寄存器也叫做CPU的上下文 ( Context
).
3.3 异常处理程序
异常处理程序根据 scause
的值, 进入不同的处理逻辑,在本次试验中我们需要关心的只有 Superviosr Timer Interrupt
。
3.4 时钟中断
时钟中断需要 CPU 硬件的支持。CPU 以"时钟周期"为工作的基本时间单位,对逻辑门的时序电路进行同步。而时钟中断实际上就是“每隔若干个时钟周期执行一次的程序”。下面介绍与时钟中断相关的寄存器以及如何产生时钟中断。
mtime
与mtimecmp
( Machine Timer Register )。mtime
是一个实时计时器, 由硬件以恒定的频率自增。mtimecmp
中保存着下一次时钟中断发生的时间点,当mtime
的值大于或等于mtimecmp
的值,系统就会触发一次时钟中断。因此我们只需要更新mtimecmp
中的值,就可以设置下一次时钟中断的触发点。OpenSBI
已经为我们提供了更新mtimecmp
的接口sbi_set_timer
( 见lab4
4.4节 )。mcounteren
( Counter-Enable Registers )。由于mtime
是属于 M 态的寄存器,我们在 S 态无法直接对其读写, 幸运的是 OpenSBI 在 M 态已经通过设置mcounteren
寄存器的TM
比特位, 让我们可以在 S 态中可以通过time
这个只读寄存器读取到mtime
的当前值,相关汇编指令是rdtime
。
以上寄存器的详细介绍请同学们参考 RISC-V Privileged Spec
4 实验步骤
4.1 准备工程
- 此次实验基于 lab4 同学所实现的代码进行。
- 在
lab4
中我们实现的puti
puts
使用起来较为繁琐,因此在这里我们提供了简化版的printk
。 从repo
同步代码。还需要将之前所有print.h puti puts
的引用修改为printk.h printk
。同步后代码的目录结构如下所示1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
. ├── Makefile ├── arch │ └── riscv │ ├── Makefile │ ├── include │ │ ├── defs.h │ │ └── sbi.h │ └── kernel │ ├── Makefile │ ├── clock.c │ ├── entry.S │ ├── head.S │ ├── sbi.c │ ├── trap.c │ └── vmlinux.lds ├── include │ ├── printk.h │ ├── stddef.h │ └── types.h ├── init │ ├── Makefile │ ├── main.c │ └── test.c └── lib ├── Makefile └── printk.c
-
修改
vmlinux.lds
以及head.S
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 原先的 vmlinux.lds ... .text : ALIGN(0x1000){ _stext = .; *(.text.entry) *(.text .text.*) _etext = .; } ... >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 修改之后的 vmlinux.lds ... .text : ALIGN(0x1000){ _stext = .; *(.text.init) <- 加入了 .text.init *(.text.entry) <- 之后我们实现 中断处理逻辑 会放置在 .text.entry *(.text .text.*) _etext = .; } ...
* 修改1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 原先的 head.S extern start_kernel .section .text.entry <- 之前的 _start 放置在 .text.entry section .globl _start _start: ... >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 修改之后的 head.S extern start_kernel .section .text.init <- 将 _start 放入.text.init section .globl _start _start: ...
init/test.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 原先的 test.c ... while(1) {} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 修改之后的 test.c ... unsigned long record_time = 0; while (1) { unsigned long present_time; __asm__ volatile("rdtime %[t]" : [t] "=r" (present_time) : : "memory"); present_time /= 10000000; if (record_time < present_time) { printk("kernel is running! Time: %lus\n", present_time); record_time = present_time; } }
4.2 开启异常处理
在运行
start_kernel
之前,我们要对上面提到的 CSR 进行初始化,初始化包括以下几个步骤: -
设置
stvec
, 将_traps
(_trap
在 4.3 中实现 ) 所表示的地址写入stvec
,这里我们采用Direct 模式
, 而_traps
则是中断处理入口函数的基地址。 - 开启时钟中断,将
sie[STIE]
置 1。 - 设置第一次时钟中断,参考
clock_set_next_event()
(clock_set_next_event()
在 4.5 中介绍 ) 中的逻辑用汇编实现。 - 开启 S 态下的中断响应, 将
sstatus[SIE]
置 1。
按照下方模版修改 arch/riscv/kernel/head.S
, 并补全 _start
中的逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
Debug 提示:
- 可以先不实现 stvec 和 first time interrupt, 先关注 sie 和 sstatus 是否设置正确。
- 在QEMU中,
mtime
和mtimecmp
的实现是通过 MMIO(Memory-mapped I/O) 的方式实现的,在QEMU的默认设置中mtime
的地址位于0x200bff8
,读这个地址的值就是mtime
的值(实验中是一个64bit的量),mtimecmp
的地址在0x2004000
。
4.3 实现上下文切换
我们要使用汇编实现上下文切换机制, 包含以下几个步骤:
- 修改
arch/riscv/kernel/
目录下的entry.S
文件。 - 保存CPU的寄存器(上下文)到内存中(栈上)。
- 将
scause
和sepc
中的值传入异常处理函数trap_handler
(trap_handler
在 4.4 中介绍 ) ,我们将会在trap_handler
中实现对异常的处理。 - 在完成对异常的处理之后, 我们从内存中(栈上)恢复CPU的寄存器(上下文)。
- 从 trap 中返回。
按照下方模版修改 arch/riscv/kernel/entry.S
, 并补全 _traps
中的逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Debug 提示: 可以先不实现 call trap_handler, 先实现上写文切换逻辑。通过 gdb 跟踪各个寄存器,确保上下文的 save 与 restore 正确实现并正确返回。
4.4 实现异常处理函数
- 修改
arch/riscv/kernel/
目录下的trap.c
文件。 - 在
trap.c
中实现异常处理函数trap_handler()
, 其接收的两个参数分别是scause
和sepc
两个寄存器中的值。1 2 3 4 5 6 7 8 9 10 11
// trap.c void trap_handler(unsigned long scause, unsigned long sepc) { // 通过 `scause` 判断trap类型 // 如果是interrupt 判断是否是timer interrupt // 如果是timer interrupt 则打印输出相关信息(即 4.6 节中输出的[S] Supervisor Mode Timer Interrupt), 并通过 `clock_set_next_event()` 设置下一次时钟中断 // `clock_set_next_event()` 见 4.5 节 // 其他interrupt / exception 可以直接忽略 # YOUR CODE HERE }
4.5 实现时钟中断相关函数
- 修改
arch/riscv/kernel/
目录下的clock.c
文件。 - 在
clock.c
中实现 get_cycles ( ) : 使用rdtime
汇编指令获得当前time
寄存器中的值。 - 在
clock.c
中实现 clock_set_next_event ( ) : 调用sbi_ecall
,设置下一个时钟中断事件。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// clock.c // QEMU中时钟的频率是10MHz, 也就是1秒钟相当于10000000个时钟周期。 unsigned long TIMECLOCK = 10000000; unsigned long get_cycles() { // 使用 rdtime 编写内联汇编,获取 time 寄存器中 (也就是mtime 寄存器 )的值并返回 # YOUR CODE HERE } void clock_set_next_event() { // 下一次 时钟中断 的时间点 unsigned long next = get_cycles() + TIMECLOCK; // 使用 sbi_ecall 来完成对下一次时钟中断的设置 # YOUR CODE HERE }
4.6 编译及测试
由于加入了一些新的 .c 文件,可能需要修改或添加一些 Makefile 或 .h 文件,请同学自己尝试修改,使项目可以编译并运行。
下面是一个正确实现的输出样例。( 仅供参考 )
1 2 3 4 5 6 7 8 9 |
|
思考题
-
在我们使用make run时, OpenSBI 会产生如下输出:
通过查看1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
OpenSBI v0.9 ____ _____ ____ _____ / __ \ / ____| _ \_ _| | | | |_ __ ___ _ __ | (___ | |_) || | | | | | '_ \ / _ \ '_ \ \___ \| _ < | | | |__| | |_) | __/ | | |____) | |_) || |_ \____/| .__/ \___|_| |_|_____/|____/_____| | | |_| ...... Boot HART MIDELEG : 0x0000000000000222 Boot HART MEDELEG : 0x000000000000b109 ......
RISC-V Privileged Spec
中的medeleg
和mideleg
解释上面MIDELEG
值的含义,如果实验中mideleg没有设定为正确的值结果会怎么样呢? -
机器启动后time、cycle寄存器分别是从0开始计时的吗,从0计时是否是必要的呢?(有关
mcycle
寄存器的内容可以参考手册) -
阅读The RISC-V Instruction Set Manual Volume I: Unprivileged ISA (V20191213)中第1.2章节 RISC-V Software Execution Environments and Harts,谈谈如何在一台不支持乘除法指令扩展的处理器上执行乘除法指令。
作业提交
同学需要提交实验报告以及整个工程代码,提交时请注意如下几点:
- 报告的pdf放在外面,压缩包只放代码。
1 2 3
提交文件 ├── report.pdf └── code.zip
- 提交前请使用
make clean
清除所有构建产物。