本章会初步完成操作系统的中断处理机制。使得操作系统可以相应两种简单的中断:断点中断和时钟中断。率先完成中断也有助于我们的后续编程,如果出现了编码失误,可以快速定位错误的位置和原因。
2.1 RISC-V 中断机制
1、中断根据中断源可以大致分为两类:内部中断和外部中断,内部中断又可以分为异常和陷阱。 2、中断相关的寄存器,可查:The RISC-V Instruction Set Manual, Volume II: Privileged Architecture | Five EmbedDev (five-embeddev.com)
stvec
(Supervisor Trap Vector Base Address Register) 设置 S-Mode 中断处理流程的入口地址,存储了一个基地址 BASE 和 处理模式 MODE。BASE 必须保证四字节对齐,即其最低两位都是 0,MODE 就存放在 stvec 的最低两位上。- MODE = 0 表示 Direct 模式处理中断,无论发生什么类型的中断都会跳转到 BASE 处。
- MODE = 1 表示 Vectored 模式处理中断,此时 BASE 存储一个向量,存储不同的地址,当中断发生时,还会根据中断类型 scause,跳转到
BASE + scause * 4
中存储的地址处。
sstatus
(Supervisor Status Register) S-Mode 控制状态寄存器,包含了全局中断使能标志 SIE,用来设置 S-Mode 是否响应中断sie
(Supervisor Interrupt Registers) Supervisor Interrupt Enable,控制具体的中断使能,如 SSIE 位控制软件中断,STIE 位控制时钟中断,SEIE 位控制外部中断。
当中断发生时,中断处理程序需要根据中断类型等信息来处理,处理器会自动设置一些寄存器来保存中断相关的信息。
scause
(Supervisor Cause Register) 记录中断发生的具体原因。sepc
(Supervisor Exception Program Counter) Supervisor Exception Program Count,内部中断发生时用于记录触发中断的指令地址。stval
(Supervisor Trap Value Register) 用于保存中断的进一步信息,辅助程序进行处理:- 硬件断点:当程序执行时遇到预设的硬件断点时,
mtval
会存储故障时的虚拟地址。 - 地址未对齐:当发生地址未对齐异常(例如对不满足对齐要求的内存地址进行 Load 或 Store 操作时),
mtval
会存储出错的虚拟地址。 - 访问故障(Access Fault):当访问的地址无效(例如非法地址或不允许访问的内存区域),
mtval
会存储故障的虚拟地址。 - 页故障(Page Fault):当发生页故障时(例如试图访问未映射的页或页权限不足),
mtval
存储的是导致页故障的虚拟地址
- 硬件断点:当程序执行时遇到预设的硬件断点时,
3、读写寄存器:以上介绍的这些寄存器在 RISC-V 中被称为 CSR(Control and Status Register,控制和状态寄存器),只有使用特殊的指令才可以读写这些寄存器。通常,这指令都是原子的。
csrrw dst, csr, src
将 csr 的值写入 dst,并将 src 的值写入 csrcsrr dst, csr
将 csr 的值读到 dstcsrw csr, src
将 src 的值写入 csr
4、与中断相关的指令:
ecall
触发环境调用,进入更高一级权限级的中断处理流程。U-Mode 下使用该指令进入 S-Mode 的中断处理流程,S-Mode 下使用该指令请求 M-Mode 的 OpenSBI 的服务。ebreak
手动触发一个断点,是指开发者设置的 暂停执行的标记。当程序的执行流遇到断点时,程序会暂停,调试器可以检查程序的状态。当程序执行到ebreak
指令时,它会触发一个 断点异常,并将控制权交给调试器或操作系统的异常处理程序。sret
从 S-Mode 返回 U-Mode,返回地址是 sepc 中的值。
2.2 触发断点
1、将读写CSR封装成一个头文件 kernel/riscv.h
:
#ifndef RISCV_H
#define RISCV_H
#include "types.h"
// 读取 scause 寄存器,中断原因
// static - 静态函数
// inline - 内联函数,编译器会直接将代码插入到调用程序中,提高性能,减小开销
static inline usize
r_scause()
{
usize x;
// 内嵌汇编,volatile 告诉编译器不要优化代码
// 读取scause到任意分配的寄存器,%0占位符即表示第一个输出寄存器
asm volatile("csrr %0, scause"
: "=r"(x)); // 将该任意寄存器输出到 x
return x;
}
// 读 sepc,中断返回地址
static inline usize
r_sepc()
{
usize x;
asm volatile("csrr %0, sepc" : "=r"(x));
return x;
}
// 中断处理函数地址处理方式
#define MODE_DIRECT 0x0 // 中断跳转到 stvec 的 BASE
#define MODE_VECTOR 0x1 // 中断跳转到 stvec 的 BASE + scause*4
// 写 stvec,设置中断处理函数 地址 和 模式
static inline void
w_spec(usize x)
{
asm volatile("csrw stvec, %0" : : "r"(x));
}
#define SIE_SEIE (1L << 9) /* 外部中断 */
#define SIE_STIE (1L << 5) /* 时钟中断 */
#define SIE_SSIE (1L << 1) /* 软件中断 */
// 读 sie,具体中断使能状态
static inline usize
r_sie()
{
usize x;
asm volatile("csrr %0, sie" : "=r" (x) );
return x;
}
// 写 sie
static inline void
w_sie(usize x)
{
asm volatile("csrw sie, %0" : : "r" (x));
}
#define SSTATUS_SUM (1L << 18)
#define SSTATUS_SPP (1L << 8) /* 上一个特权模式 */
#define SSTATUS_SPIE (1L << 5) /* 中断处理发生前的SIE值 */
#define SSTATUS_SIE (1L << 1) /* 监管者模式中断使能 */
#define SSTATUS_UIE (1L << 0) /* 用户模式中断使能 */
// 读 sstatus,全局中断状态
static inline usize
r_sstatus()
{
usize x;
asm volatile("csrr %0, sstatus" : "=r" (x) );
return x;
}
// 写 sstatus
static inline void
w_sstatus(usize x)
{
asm volatile("csrw sstatus, %0" : : "r" (x));
}
// 读 time,硬件时钟
static inline usize
r_time()
{
usize x;
asm volatile("csrr %0, time" : "=r" (x) );
return x;
}
#endif
SIE_SEIE、SIE_STIE、SIESSIE
分别对应外部中断、时钟中断、软件中断,插图可知对应位
SSTATUS_SIE、SSTATUS_UIE
等分别对应监管者模式、用户中断使能,见插图
2、中断处理程序
//kernel/interrupt.c
// 中断初始化
void
initInterrupt()
{
// 写 stvec 寄存器。设置中断处理程序入口 和 模式
w_stvec((usize)handleInterrupt | MODE_DIRECT);
printf("==== Init Interrupt ====\n");
}
// 中断处理函数
void
handleInterrupt()
{
usize cause = r_scause();
usize epc = r_sepc();
printf("Interrupt cause = %p\n epc = %p\n", cause, epc); // 按指针形式输出
panic("Interrupt handled!\n");
}
- 设置 stvec 使得所有中断都会跳转到
handleInterrupt()
函数且MODE
为Direct
模式,处理函数中 handleInterrupt()
函数近输出中断信息然后就panic()
退出- 在
main()
函数中初始化中断,并手动触发一个断电中断
void main()
{
initInterrupt();
asm volatile("ebreak" :::); // 手动触发一个中断
printf("return from ebreak\n");
while(1) {}
}
3、编译运行结果
- 编译运行一下可能会看到如下成功的结果:
....
***** Init Interrupt *****
Interrupt cause: 0x0000000000000003
epc: 0x0000000080200522
Interrupt handled!
- 处理结束后没能输出 “return from ebreak”。因为在处理过程中就触发 panic 关机了
- 也有可能会出现如下的错误结果:
....
***** Init Interrupt *****
***** Init Interrupt *****
***** Init Interrupt *****
....
- 内核在无限重启!这是怎么回事?
RISC-V 要求中断处理程序的入口需要四字节对齐。因为 stvec 寄存器的低两位被 MODE 占据了。如果中断处理程序的入口没有对齐,如 0x80200522,在存入 stvec 时低两位就会被舍去,中断处理是就会跳转到 0x80200520 处去!这个地址甚至可能不是一个完整指令的起始地址,CPU 无法解析残缺的指令,就会陷入错误,导致重启。
- 借助
__attribute__
机制来设置函数的属性,通过设置aligned
属性就可以指定函数的对其方式。修改函数如下:
// kernel/interrupt.c
__attribute__((aligned(4))) void
handleInterrupt()
{
usize cause = r_scause();
usize epc = r_sepc();
printf("Interrupt cause: %p\nepc: %p\n", cause, epc);
panic("Interrupt handled!\n");
}
2.3 中断上下文
1、处理中断前,需要保存所有可能被修改的寄存器,并在中断处理完成后恢复。
- 所有通用寄存器
- scause、sepc 和 stval 这三个会被硬件自动写入的寄存器
- sstatus 保存特权级状态
// kernel/context.h
// 中断上下文,scause 和 stval 作为参数传递,无需保存
typedef struct
{
// 32个通用寄存器
usize x[32];
// S-Mode 状态寄存器
usize sstatus;
// 中断返回地址
usize sepc;
} InterruptContext;
2、中断上下文的保存与恢复
- 将上下文临时保存在内核栈上,之后再跳转到处理函数进行处理,处理完成后从栈上恢复上下文,最后再跳转回原程序被打断的地方。由于涉及了大量寄存器读写,我们需要用到汇编。栈上下文结构:
# kernel/interrupt.S
# 启用替代宏.macro,允许编写宏替代复杂指令
.altmacro
# 寄存器宽度 8 字节
.set REG_SIZE, 8
# Context 大小为 34 个数据
.set CONTEXT_SIZE, 34
# 宏:保存寄存器reg到栈上
.macro SAVE reg, offset
sd \reg, \offset*8(sp) # 将reg寄存器存储到sp+offset*8 ("\"是宏参数的占位符)
.endm
# 宏:调用 SAVE 批量保存不同宏定义,n=1时,SAVE x1, 1 -> sd x1, 8(sp)
.macro SAVE_N n
SAVE x\n, \n
.endm
# 宏:从栈中恢复寄存器
.macro LOAD reg, offset
ld \reg, \offset*8(sp)
.endm
.macro LOAD_N n
LOAD x\n, \n
.endm
.section .text # 代码段
.globl __interrupt
.balign 4 # 中断处理函数需要 4 字节对齐
# 全局中断处理,保存 Context 并跳转到 handleInterrupt() 处
__interrupt:
# 移动栈指针,留出 Context 的空间
addi sp, sp, -34*REG_SIZE
# 保存通用寄存器,其中 x0 固定为 0
SAVE x1, 1
# 将原来的 sp 写入 2 位置
addi x1, sp, 34*REG_SIZE # 先计算原sp位置,再保存在x1中
SAVE x1, 2
# 循环保存 x3 至 x31
.set n, 3 # 定义一个符号常量 = 3
.rept 29
SAVE_N %n
.set n, n + 1
.endr
# 保存 CSR
csrr s1, sstatus
csrr s2, sepc
SAVE s1, 32
SAVE s2, 33
# 调用 handleInterrupt()
# 将 Context 的地址(栈顶)和 scause、stval 作为参数传入
mv a0, sp
csrr a1, scause
csrr a2, stval
jal handleInterrupt
.globl __restore
# 从 handleInterrupt 返回
# 从 Context 中恢复所有寄存器,并跳转至 Context 中 sepc 的位置
__restore:
# 恢复 CSR
LOAD s1, 32
LOAD s2, 33
csrw sstatus, s1
csrw sepc, s2
# 恢复通用寄存器
LOAD x1, 1
# 恢复 x3 至 x31
.set n, 3
.rept 29
LOAD_N %n
.set n, n + 1
.endr
# 恢复 sp,必须最后执行栈指针恢复(即释放栈指针),才不会影响上述LOAD宏的偏移位置
LOAD x2, 2
sret # 返回中断发生前位置
- 在
interrupt.S
开定义了保存寄存器到栈和从栈恢复寄存器的宏 - 将 sp、scause 和 stval 作为参数存入 a0、a1 和 a2,并调用
handleInterrupt()
函数 - 从
handleInterrupt()
返回后,会进入__restore
函数中,这时将栈上的 InterruptContext 恢复到寄存器中,并通过sret
指令返回。 - 在
interrupt.c
中引入这段汇编
asm(".include \"kernel/interrupt.asm\"");
3、从断点返回
- 修改中断入口程序为
interrupt.S
中的地址,并且由于已经在interrupt.S 中四字节对齐了,故无需对齐了
// 中断初始化
// __attribute__((aligned(4))) void
void
initInterrupt()
{
extern void __interuppt();
// 写 stvec 寄存器。设置中断处理程序入口 和 模式
w_stvec((usize)__interuppt | MODE_DIRECT);
printf("==== Init Interrupt ====\n");
}
- 修改中断处理函数接收
interrupt.S
传递过来的参数
// 中断处理函数,接受interrupt.S传递过来的三个参数 sp, scause, stval
// sp保存上下文向下移动34个usize,所以sp也是一个指向InterruptContext的指针!
void
handleInterrupt(InterruptContext *context, usize scause, usize stval)
{
switch(scause) {
case 3L: // 断点中断
breakpoint(context);
break;
default: // 未处理中断
printf("Unhandled interrupt!\nscause\t= %p\nsepc\t= %p\nstval\t= %p\n",
scause,
context->sepc,
stval
);
panic("");
}
}
- 通过
breakpoint()
函数来处理断点,这里只是跳过断点,继续执行下一条指令
void
breakpoint(InterruptContext *context)
{
printf("Breakpoint at %p\n", context->sepc);
// 跳过断点,继续执行下一条指令,修改中断返回地址指向下一条指令
context->sepc += 2; /* 注意:EV64位宽4字节,但指令并非全是4字节, ebreak 指令长度 2 字节 */
}
- 运行查看结果,可以发现此时成功运行了
main()
函数中的下一条指令
==== Init Interrupt ====
Breakpoint at 0x000000008020001c
return from ebreak
2.4 开启时钟中断
1、时钟中断是线程并发的基础。编写初始化时钟中断函数
// kernel/timer.c
// 初始化时钟中断
void
initTimer()
{
// 写 sie 时钟中断使能
w_sie(SIE_STIE);
// 写 scause 监管者模式中断使能(因为时钟中断还需打断内核线程)
w_sstatus(SSTATUS_SIE);
// 设置第一次时钟中断
setTimeout();
}
2、设置时钟中断:时钟中断是“一次性”的,当响应一次时钟后都需要设置下一次中断的时间。OpenSBI 已经提供了服务用于快速设置时钟中断。我们只需要调用即可。
// kernel/sbi.c
void
setTimer(usize time)
{
SBI_ECALL_1(SBI_SET_TIMER, time);
}
setTimer()
设置的 time 是一个具体的时间,而不是一个时间间隔。我们把它包装一下,定义一个固定的间隔INTERVAL
// kernel/timer.c
// 设置下依次时钟中断时间为 当前时间 + INTERVAL个CPU周期
void
setTimeout()
{
setTimer(r_time() + INTERVAL); // r_time() 读取硬件时间(kernel/riscv.h)
}
- 设置间隔时间为 100000 个 CPU 周期,更短的时间可以让调度更加精细,但是浪费在上下文切换的资源也会增加。
- 定义一个函数,来对时钟中断的触发次数进行记录,并且设置下一次触发时间:
// kernel/timer.c
static usize TICKS = 0;
void
tick()
{
setTimerout();
TICKS += 1;
if(TICKS % 100 == 0) {
printf("** TICKS = %d **\n", TICKS); // 每 100 次时钟中断输出 TICKS
}
}
3、设置时钟中断处理流程,只需要在 handleInterrupt()
中识别出时钟中断并调用 tick()
函数就可以了。查表可以发现时钟中断 scause 的 interrupt 会被置为 1,同时 Exception Code 被置为 5。即,此时 scause 寄存器中的值为 5L | (1L << 63)
。
- 同时,我们可以封装一下中断处理的
switch-default
处理函数,代码如下:
// kernel/interrupt.c
// 断点中断处理:输出断点信息并跳转到下一条指令
void
breakpoint(InterruptContext *context)
{
printf("Breakpoint at %p\n", context->sepc);
// 跳过断点,继续执行下一条指令,修改中断返回地址指向下一条指令
context->sepc += 2; /* 注意:EV64位宽4字节,但指令并非全是4字节, ebreak 指令长度 2 字节 */
}
// 时钟中断处理:设置下一次时钟中断时间
void
supervisorTimer()
{
extern void tick(); // 声明外部函数 kernel/timer.c
tick();
}
// 未知中断处理:直接打印信息并关机
void
fault(InterruptContext *context, usize scause, usize stval)
{
printf("Unhandled interrupt!\nscause\t= %p\nsepc\t= %p\nstval\t= %p\n",
scause,
context->sepc,
stval
);
panic("");
}
// 中断处理函数,接受interrupt.S传递过来的三个参数 sp, scause, stval
// sp保存上下文向下移动34个usize,所以sp也是一个指向InterruptContext的指针!
void
handleInterrupt(InterruptContext *context, usize scause, usize stval)
{
switch(scause) {
case 3L: // 断点中断
breakpoint(context);
break;
case 5L | (1L << 63): // 时钟中断
supervisorTimer();
break;
default: // 未知中断
fault(context, scause, stval);
break;
}
}
- 别忘了在
main()
函数中添加时钟中断初始化
void main()
{
extern void initInterrupt(); initInterrupt(); // 设置中断处理程序入口 和 模式
extern void initTimer(); initTimer(); // 时钟中断初始化
asm volatile("ebreak" :::); // 手动触发一个中断
printf("return from ebreak\n");
while(1) {}
}
- 运行代码得到输出:
==== Init Interrupt ====
Breakpoint at 0x0000000080200024
return from ebreak
** TICKS = 100 **
** TICKS = 200 **
** TICKS = 300 **