引言

我们之前都是假设指令可以成功执行

如果指令执行失败, 应该怎么办?

 

本次课内容:

  • 异常处理过程
  • 异常处理的硬件实现
  • 异常处理的模型
  • 异常处理的真实应用

异常事件

系统难免会出错

  • CPU译码时发现非法指令
  • 内存某些单元损坏了
  • 你写的程序访问了非法内存

 

计算机系统应该如何应对?

摆烂 - UB

例如整数除0

  • C语言标准 - 我不管, 发生什么都跟我没关系
  • MIPS处理器 - 我也不管, 随便给除法指令返回一个结果
  • 程序员 - 这…
    • MIPS-gcc - 有一个编译选项-mcheck-zero-division, 默认打开
      • 可以自动在除法前生成指令检查除数是否为0

 

  • C语言为了兼容各种机器的差异, 只能UB
  • 高层的新语言可以明确定义异常行为
    • 通过语言虚拟机(类似NEMU)屏蔽底层系统
      • 虚拟机发现除0, 就报错
    • 例如在Java中, 除零和数组越界都会抛出异常

但摆烂对用户来说并不友好

编译器发现语法错误, 悄悄生成错误的代码

  • 这么搞程序员都要折寿 😂
  • 我们希望编译器能准确地输出报错信息
  • 最好能自动把bug修了
    • 真的有这种研究工作
      • Guoliang Jin, Linhai Song, Wei Zhang, Shan Lu, and Ben Liblit. Automated atomicity-violation fixing. In Proceedings of the 32nd ACM SIGPLAN conference on Programming language design and implementation, pp. 389-400. 2011 (PLDI’11)

 

CPU作为数字电路, 要输出报错信息/自动修bug比较困难

  • 最低限度要告诉软件
    • 这里好像不太对, 要不你来看一下?

异常处理过程

需求

回顾: 程序/指令集/CPU都是状态机

需要把当前程序P的状态保存起来, 并跳转到异常处理程序进行诊断

  • 这里我们从指令集状态机\(S = \{<R, M>\}\)的视角来讨论
    • 异常处理过程不了解P中的变量, 不适合从程序状态机的视角入手
    • CPU状态机的电路细节又太多了

 

保存\(M\)的需求好像很奇怪: \(M\)这么大, 要保存到哪里?

 

一个观察: 异常处理程序和P是两个不同的程序, 它们使用不同的\(M\)

  • 只要异常处理程序不随意修改P的\(M\), 则不必进行实质性的保存操作

 

\(R\)只有一份, 异常处理程序也要用, 肯定要保存

保存\(R\)

要把P的\(R\)保存到哪里呢?

\(S = \{<R, M>\}\), 也只能保存到\(S\)里面了 😂

 

  • 保存到\(R\): 增加一组寄存器\(R_{save}\), 专门用来保存\(R\)
  • 保存到\(M\): 需要找一处空闲的内存区域
    • 最合适的是栈 - 只要栈上还有空间, 肯定是空闲的

 

谁来保存?

  • 硬件保存: 在CPU状态机的控制下保存
  • 软件保存: 通过指令控制CPU进行保存

保存\(R\)的设计

\(R\) \(M\)
硬件保存 硬件保存到\({R_{save}}^1\) 硬件保存到\(M\)
软件保存 软件保存到\(R_{save}\) 软件保存到\(M\)

流程: P发生异常 -> 硬件保存 -> 跳转到异常处理程序 -> 软件保存

注1: 异常处理程序诊断时需要读取\(R_{save}\), 故CPU还要添加相应指令

 

  • x86可以通过硬件TSS特性将\(R\)保存到\(M\)
    • 但开发者发现其性能不如软件保存, 而且不灵活(必须保存所有状态)
  • RISC架构一般通过软件将\(R\)保存到\(M\)
    • 采用已有的store指令即可
    • 不过PC无法用软件保存, 因为在这之前旧PC已经被覆盖了
      • RISC-V硬件将PC保存到mepc这个特殊的寄存器

控制状态寄存器(CSR, Control and Status Register)

用于控制和反映处理器状态的特殊寄存器(例如刚才的mepc)

  • 硬件发生某些事件时会自动更新CSR, 或者从CSR中读出值来使用
  • 软件也可以通过CSR指令来访问CSR
    • 所以每个CSR都有一个软件可见的编号(CSR地址空间)
      • RTFM
31                 20 19   15 14  12 11    7 6    0
+--------------------+-------+------+-------+------+
|        csr         |  rs1  |funct3|  rd   |opcode|
+--------------------+-------+------+-------+------+

+------+------+---------------------------------+
|Number|Name  |Description                      |
+------+------+---------------------------------+
|0x341 |mepc  |Machine exception program counter|
+------+------+---------------------------------+
|0x342 |mcause|Machine trap cause               |
+------+------+---------------------------------+
|...   |...   |...                              |

最简单的异常处理还需要的CSR

mtvec - 异常处理程序的入口地址

  • 发生异常时, CPU自动跳转到这个地址

 

一个最简单的异常处理程序(用了AM提供的运行时环境)

#include <klib.h>
void handler() {
  uintptr_t mepc;
  asm volatile ("csrr %0, mepc" : "=r"(mepc));
  printf("exception at mepc = %p\n", mepc);
  while (1);
}

int main() {
  asm volatile ("csrw mtvec, %0" : :"r"(handler));
  asm volatile (".word 0"); // illegal instruction
  printf("I am alive!\n");
  while (1);
}
NAME = simple-rv-handler
SRCS = simple-rv-handler.c
include $(AM_HOME)/Makefile

加个异常原因方便诊断

mcause - 异常原因

  • 发生异常时, CPU将异常号写入这个CSR
uintptr_t mepc, mcause;
asm volatile ("csrr %0, mepc" : "=r"(mepc));
asm volatile ("csrr %0, mcause" : "=r"(mcause));
printf("exception mcause = %p at mepc = %p\n", mcause, mepc);
qemu-system-riscv64 ... -d int  # 让QEMU输出异常相关的trace
# RTFM了解异常号的含义
 0 - Instruction address misaligned
 1 - Instruction access fault
 2 - Illegal Instruction
 3 - Breakpoint
 4 - Load address misaligned
 5 - Load access fault
 6 - Store/AMO address misaligned
 7 - Store/AMO access fault
 8 - Environment call from U-mode
 9 - Environment call from S-mode
11 - Environment call from M-mode
12 - Instruction page fault
13 - Load page fault
15 - Store/AMO page fault

从异常处理返回

若诊断后发现问题不大, P可以继续执行, 则需要从异常处理程序返回P

 

需要先恢复之前为P保存的状态(恢复\(R\)即可)

  • RISC架构通过load指令将\(M\)中保存的内容恢复到\(R\)

然后返回到P

  • 但异常处理程序和P是两个不同的程序, 不能通过ret指令返回
    • jalr指令需要先把返回地址写入一个寄存器, 但这样会改变P的状态
      • 如果返回后P需要使用这个寄存器, 就会出错
  • 需要添加一条特殊的返回指令mret, 跳转到mepc中存放的地址
uintptr_t mepc, mcause;
asm volatile ("csrr %0, mepc" : "=r"(mepc));
asm volatile ("csrr %0, mcause" : "=r"(mcause));
printf("exception mcause = %p at mepc = %p\n", mcause, mepc);
// 这里没有恢复寄存器, 仅限于演示
if (mcause == 2) asm volatile ("csrw mepc, %0; mret" : : "r"(mepc + 4));
while (1);

异常处理的硬件实现

在单周期NPC上实现异常

实现方案很直接, 没有特别困难的地方

  • 实现CSR
    • 其电路本质就是寄存器
  • 添加CSR读写指令
    • 执行时在GPR和CSR之间交换数据
  • 实现异常的触发
    • 识别异常事件, 例如译码时检查非法指令, 识别ecall指令等
      • RTFM按需实现
    • 识别到异常事件后, 通过电路更新mepc, mcause等CSR, 并跳转到mtvec中存放的地址
      • 复用PC更新逻辑相关的数据通路即可
  • 实现mret指令
    • 跳转到mepc中存放的地址

异常处理的模型

异常处理的状态机模型

  • 状态的扩展: \(R = \{PC, GPR, CSR\}\), \(M\)无需扩展
  • 状态转移的扩展
    • 执行指令不再总是成功: 定义一个函数\(f_{ex}: S \to \{0, 1\}\), 给定任意状态\(S\), \(f_{ex}(S)\)表示当前指令执行是否失败
      • \(f_{ex}(S) = 0\), 则按照当前指令的语义进行状态转移
      • \(f_{ex}(S) = 1\), 则执行一条特殊指令raise_intr 异常号, 并更新状态如下:
      // 以RISC-V为例
      CSR[mepc] <- PC
      CSR[mcause] <- 异常号
      PC <- CSR[mtvec]

异常处理的状态机模型(2)

\(f_{ex}\)函数是处处有定义的吗?

  • 说人话: 能否明确回答 “在任意状态下执行任意指令是成功还是失败”

 

能! RTFM!

  • 地址不对齐: addr % 访存位宽 != 0
  • 断点: inst == 0x00100073 (RISC-V的ebreak指令)
  • 非法指令: inst.opcode未在手册中定义
  • 甚至多个异常同时发生时应该先处理哪一个, 手册都定义了

 

一个重要的结论: 不考虑IOE的输入指令, 异常处理的行为是确定的!

  • 多次运行相同的程序, 都应该在相同的位置抛出相同的异常, 状态机应该进行相同的状态转移
    • 调试的福音

AM中的CTE

不同指令集的不同: 异常号及其含义/触发异常的条件/保存的状态

但上层软件真正关心的是: 发生了什么事件, 该如何处理

 

老规矩, 加个抽象层: CTE (ConText Extension)

  • 异常号及其含义不同 - 提供事件的抽象
    • abstract-machine/am/include/am.h中的事件定义
  • 触发异常的条件不同 - 提供事件处理的抽象
    • 回调函数Context* (*h)(Event ev, Context *ctx)
  • 保存的状态不同 - 提供上下文结构的抽象
    • “上下文”是操作系统的术语, 其实就是程序的状态
    • abstract-machine/am/include/arch/$ARCH.h中的Context结构体

riscv32-nemu CTE代码导读

  • CTE初始化
  • 异常处理过程
  • 回调函数

异常处理的真实应用

1.系统调用

现代计算机系统都支持多用户多任务

让用户程序直接访问系统中的资源并不是一个好主意

特权级 说明
M 机器模式
S 监管模式
U 用户模式
  • 程序员都需要了解底层资源的使用方式
  • 程序出bug = 系统崩溃
  • 多个程序可能竞争相同的资源, 没有协调者

 

解决方案 - 硬件提供特权级机制

  • 资源管理程序放在高特权级
    • 这就是操作系统
  • 用户程序放在低特权级, 只能
    1. 执行普通指令进行计算
    2. 发起系统调用请求提供服务
#include <stdio.h>
int main() {
  unsigned long csr;
#ifdef __riscv
  asm volatile ("csrr %0, mepc" : "=r"(csr));
#else // x86
  asm volatile ("mov %%cr0, %0" : "=r"(csr));
#endif
  printf("csr = %p\n", csr);
  return 0;
}

发起系统调用

唯一合法方式: 自陷类异常 - 执行一条无条件触发异常的指令

  • RISC-V提供ecall指令
  • 操作系统可以根据mcause得知该异常是合法的请求

 

为了让用户程序指定请求何种服务, 系统调用也需要传递参数

  • 最合适的方案是通过寄存器来传递
    • 操作系统的异常处理函数识别到系统调用请求后, 可从Context结构中读出系统调用的参数
    • RISC-V Linux约定采用a7寄存器传递系统调用号, a0, a1, …分别传递第1/2/…个参数
      • man syscall
  • 为什么不通过a0传递系统调用号?
    • Hint: 回顾RISC-V调用约定

系统调用示例

#include <stdio.h>
int main() {
  printf("Hello World!\n");
  return 0;
}
qemu-riscv64 -d strace,trace:guest_user_syscall a.out

 

int main() {
  asm volatile (
      "li a0, 1\n"
      "mv a1, %0\n"
      "mv a2, %1\n"
      "li a7, 0x40\n"
      "ecall\n"
      : : "r"("Hello World!\n"), "r"(13));
  return 0;
}
  • 更多的系统调用 - man syscalls
  • 系统调用号列表 - /usr/riscv64-linux-gnu/include/asm-generic/unistd.h

2.RISC-V的SBI调用

  • 系统调用 = U模式请求S模式提供服务
  • SBI调用 = S模式请求M模式提供服务
    • SBI = Supervisor Binary Interface, 是M模式和S模式之间的约定
    • SBI调用也通过ecall指令发起, 参数传递方式与系统调用类似

 

SBI调用涉及的功能(M模式才有权限进行的操作):

  • 定时器设置
  • IPI(处理器间中断)
  • 远程屏障
  • 硬件线程状态管理
  • 系统复位
  • 性能监控单元管理

SBI实现示例

BBL(Berkeley BootLoader)的SBI实现

//riscv-pk/machine/mtrap.c
void mcall_trap(uintptr_t* regs, uintptr_t mcause, uintptr_t mepc) {
  write_csr(mepc, mepc + 4);
  uintptr_t n = regs[17], arg0 = regs[10], arg1 = regs[11], retval, ipi_type;
  switch (n) {
    case SBI_CONSOLE_PUTCHAR: retval = mcall_console_putchar(arg0); break;
    case SBI_CONSOLE_GETCHAR: retval = mcall_console_getchar();     break;
    case SBI_SEND_IPI:        ipi_type = IPI_SOFT;                  goto send_ipi;
    case SBI_REMOTE_SFENCE_VMA:
    case SBI_REMOTE_SFENCE_VMA_ASID: ipi_type = IPI_SFENCE_VMA;     goto send_ipi;
    case SBI_REMOTE_FENCE_I:  ipi_type = IPI_FENCE_I;
send_ipi:
      send_ipi_many((uintptr_t*)arg0, ipi_type); retval = 0;        break;
    case SBI_CLEAR_IPI:       retval = mcall_clear_ipi();           break;
    case SBI_SHUTDOWN:        retval = mcall_shutdown();            break;
    case SBI_SET_TIMER:       retval = mcall_set_timer(arg0);       break;
    default:                  retval = -ENOSYS;                     break;
  }
  regs[10] = retval;
}

RTFM - RISC-V Supervisor Binary Interface Specification

3.指令模拟

RISC-V是模块化的

  • 如果执行了CPU不支持的扩展指令, 将抛出非法指令异常

让异常处理函数代替CPU执行

  • 读出这条 “非法”指令
  • 译码, 确定是否真的 “非法”
  • 若是合法扩展指令, 则执行它
    • Context存放了程序的状态
  • 更新PC(Context中的mepc)

这个过程和NEMU几乎一样

 

BBL可模拟浮点指令

  • 没有FPU也可以启动Debian!
//riscv-pk/machine/emulation.c
void illegal_insn_trap(uintptr_t* regs,
  uintptr_t mcause, uintptr_t mepc) {
  // ...
  insn = get_insn(mepc, &mstatus);
  if ((insn & 3) != 3)
    return emulate_rvc(regs, mcause, mepc, mstatus, insn);
  extern uint32_t illegal_insn_trap_table[];
  int32_t* pf = (void*)illegal_insn_trap_table + (insn & 0x7c);
  emulation_func f = (void*)illegal_insn_trap_table + *pf;
  f(regs, mcause, mepc, mstatus, insn);
  write_csr(mepc, mepc + 4);
}

//riscv-pk/machine/fp_emulation.c
void emulate_fmul(uintptr_t* regs, uintptr_t mcause,
  uintptr_t mepc, uintptr_t mstatus, insn_t insn) {
  if (GET_PRECISION(insn) == PRECISION_S) {
    uint32_t rs1 = GET_F32_RS1(insn, regs);
    uint32_t rs2 = GET_F32_RS2(insn, regs);
    SET_F32_RD(insn, regs, f32_mul(f32(rs1), f32(rs2)).v);
  } else if (GET_PRECISION(insn) == PRECISION_D) {
    uint64_t rs1 = GET_F64_RS1(insn, regs);
    uint64_t rs2 = GET_F64_RS2(insn, regs);
    SET_F64_RD(insn, regs, f64_mul(f64(rs1), f64(rs2)).v);
  } else {
    return truly_illegal_insn(regs, mcause, mepc, mstatus, insn);
  }
}

mtime的故事

在RISC-V早期, mtime是一个CSR

  • 后来架构师发现在多核场景下这个设计有问题
    • 现代处理器支持动态调频, 可能会导致多个核mtime增长速率不一致
  • 于是把mtime移到外设, 通过MMIO访问
    • 只实现一个副本, 所有核读出的mtime一致

这是一个不兼容旧版的改动, 那些读mtime CSR的程序无法正确执行了

 

解决方案: 让异常处理函数重定向

  1. 新处理器访问mtime CSR时抛出非法指令异常
  2. 异常处理函数对指令进行译码
  3. 若原指令读mtime CSR, 则访问MMIO的mtime, 将结果写入寄存器
  4. 更新PC, 从异常处理返回

4.处理不对齐访存

如果访存指令的地址不对齐, 将抛出不对齐访存异常

  • 和指令模拟类似, 也可以让异常处理函数代替CPU执行
    • 拆成多个单字节的访存
    • 不过这样就不保证访存的原子性了(但还是符合手册的约定)
Even when misaligned loads and stores complete successfully, these accesses might run
extremely slowly depending on the implementation (e.g., when implemented via an
invisible trap). Furthermore, whereas naturally aligned loads and stores are
guaranteed to execute atomically, misaligned loads and stores might not, and hence
require additional synchronization to ensure atomicity.

BBL处理不对齐访存

// riscv-pk/machine/misaligned_ldst.c
void misaligned_load_trap(uintptr_t* regs, uintptr_t mcause, uintptr_t mepc) {
  union byte_array val;
  uintptr_t mstatus;
  insn_t insn = get_insn(mepc, &mstatus);
  uintptr_t npc = mepc + insn_len(insn);
  uintptr_t addr = read_csr(mtval);

  int shift = 0, len;
  if ((insn & MASK_LW) == MATCH_LW)        len = 4, shift = 8*(sizeof(uintptr_t) - len);
  else if ((insn & MASK_LD) == MATCH_LD)   len = 8, shift = 8*(sizeof(uintptr_t) - len);
  else if ((insn & MASK_LWU) == MATCH_LWU) len = 4;
  else if ((insn & MASK_LH) == MATCH_LH)   len = 2, shift = 8*(sizeof(uintptr_t) - len);
  else if ((insn & MASK_LHU) == MATCH_LHU) len = 2;
  else {
    mcause = CAUSE_LOAD_ACCESS;
    write_csr(mcause, mcause);
    return truly_illegal_insn(regs, mcause, mepc, mstatus, insn);
  }

  val.int64 = 0;
  for (intptr_t i = 0; i < len; i++)
    val.bytes[i] = load_uint8_t((void *)(addr + i), mepc);
  SET_RD(insn, regs, (intptr_t)val.intx << shift >> shift);
  write_csr(mepc, npc);
}

5.妙用mtvec实现try-catch功能

C++在语言特性上支持异常处理

#include <stdio.h>
int f(int x, int y) { if (y == 0) throw y; else return x / y; }
int main() {
  try {
    int res = f(8, 2); printf("res = %d\n", res);
        res = f(4, 0); printf("res = %d\n", res);
  }
  catch(int) { printf("error of dividing zero\n"); return 1; }
  return 0;
}

借助mtvec可实现类似功能(当然只能用于M模式)

// riscv-pk/machine/minit.c
void setup_pmp(void) {
  // Set up a PMP to permit access to all of memory.
  // Ignore the illegal-instruction trap if PMPs aren't supported.
  uintptr_t pmpc = PMP_NAPOT | PMP_R | PMP_W | PMP_X;
  asm volatile ("la t0, 1f\n\t"
      "csrrw t0, mtvec, t0\n\t"   // 临时将mtvec更换成代码末尾
      "csrw pmpaddr0, %1\n\t"     // 尝试访问PMP CSR, 若失败, 则抛出非法指令异常
      "csrw pmpcfg0, %0\n\t"
      ".align 2\n\t"
      "1: csrw mtvec, t0"         // 在代码末尾恢复mtvec
      : : "r" (pmpc), "r" (-1UL) : "t0");
}

6.上下文切换

对回调函数进行巧妙的修改:

  • 保存当前程序的上下文
  • 并返回另一个程序的上下文
程序A发生异常 -> 保存A的Context -> 异常处理 -> 恢复B的Context -> 从异常返回

这样就实现了从A切换到B的效果 (操作系统的原型)

 

示例: am-kernels/kernels/yield-os/yield-os.c

 

RT-Thread本质上也是这样支持分时多任务

  • 但现代OS通过外部的时钟中断强制触发上下文切换(抢占多任务)
    • 而不是依靠程序执行yield()来触发(协同多任务)
  • 时钟中断是现代OS的根基: while (1)无法卡死整个系统了
for i in `seq 1 16`; do ./a.out & done

总结

异常处理: 一种特殊的跳转

  • CPU
    • 按照手册约定, 发生异常时跳转到一个预先设定的位置
    • 通过CSR保存部分状态
      • RTFM
  • AM的CTE抽象
    • 把异常抽象成事件, 提供事件处理模型
  • 上层软件
    • 决定如何处理事件

 

  • 真实应用
    • 系统调用, SBI调用, 指令模拟, 处理不对齐访存, 上下文切换…