【Create my OS】2 开启中断

November 15, 2024

Github代码仓库链接

本章会初步完成操作系统的中断处理机制。使得操作系统可以相应两种简单的中断:断点中断和时钟中断。率先完成中断也有助于我们的后续编程,如果出现了编码失误,可以快速定位错误的位置和原因。

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 的值写入 csr
  • csrr dst, csr
    将 csr 的值读到 dst
  • csrw 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分别对应外部中断、时钟中断、软件中断,插图可知对应位

alt text

  • SSTATUS_SIE、SSTATUS_UIE等分别对应监管者模式、用户中断使能,见插图

alt text

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() 函数且MODEDirect 模式,处理函数中
  • 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、中断上下文的保存与恢复

  • 将上下文临时保存在内核栈上,之后再跳转到处理函数进行处理,处理完成后从栈上恢复上下文,最后再跳转回原程序被打断的地方。由于涉及了大量寄存器读写,我们需要用到汇编。栈上下文结构:

alt text

# 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)

alt text

  • 同时,我们可以封装一下中断处理的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 **

Profile picture

Written by JokerDebug who works at Southeast University, Nanjing, China You can follow me on Github