MIT-OS2022 lab4 Traps陷阱指令和系统调用

第四章 陷阱指令和系统调用

有三种事件会导致中央处理器搁置普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上。一种情况是系统调用 ,当用户程序执行ecall指令要求内核为其做些什么时;另一种情况是异常 :(用户或内核)指令做了一些非法的事情,例如除以零或使用无效的虚拟地址;第三种情况是设备中断,一个设备,例如当磁盘硬件完成读或写请求时,向系统表明它需要被关注。本质都是 CPU 的「执行中断 + 控制权转移」

原本正在执行的代码(不管是用户程序还是内核代码)后续需要能恢复执行,而且完全不用感知到陷阱发生过------ 这就是「透明性」。对于中断尤其重要,中断代码通常难以预料。通常的顺序是陷阱强制将控制权转移到内核;内核保存寄存器和其他状态,以便可以恢复执行;内核执行适当的处理程序代码(例如,系统调用接口或设备驱动程序);内核恢复保存的状态并从陷阱中返回;原始代码从它停止的地方恢复。

xv6内核处理所有陷阱。这对于系统调用来说是顺理成章的。由于隔离性要求用户进程不直接使用设备,而且只有内核具有设备处理所需的状态,因而对中断也是有意义的。因为xv6通过杀死违规程序来响应用户空间中的所有异常,它也对异常有意义。

Xv6陷阱处理分为四个阶段:
1.RISC-V CPU 硬件操作 :陷阱发生的瞬间,CPU 自动做的底层操作(比如标记陷阱类型、暂停当前指令、跳转到固定的处理入口),是整个流程的「起点」;
2.汇编向量程序 :硬件跳转到入口后,先执行汇编代码 ------ 核心作用是「准备内核 C 代码的执行环境」(比如保存当前寄存器状态、切换到内核地址空间,因为 C 代码需要稳定的执行环境);
3.C 陷阱处理程序 :汇编准备好环境后,转去执行内核 C 代码 ------ 核心作用是「判断陷阱类型、分发处理」(比如识别出是系统调用就转去系统调用接口,是磁盘中断就转去磁盘驱动);
4.服务例程 :最终的具体处理代码(系统调用接口、设备驱动程序、异常处理逻辑),做完具体工作后,再按原路恢复状态、返回原程序。

xv6 陷阱处理的设计可以用一句话概括:用「统一的四阶段框架」覆盖所有陷阱的通用处理流程,用「按场景拆分的汇编 + C 处理代码」适配不同陷阱的个性化需求,既保证了设计的一致性,又兼顾了实现的简洁性和系统的安全性。

RISC-V陷入机制

每个RISC-V CPU都有一组控制寄存器,内核通过向这些寄存器写入内容来告诉CPU如何处理陷阱,内核可以读取这些寄存器来明确已经发生的陷阱。

stvec:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。

sepc:当发生陷阱时,RISC-V会在这里保存程序计数器pc(因为pc会被stvec覆盖)。sret(从陷阱返回)指令会将sepc复制到pc。内核可以写入sepc来控制sret的去向。

scause: RISC-V在这里放置一个描述陷阱原因的数字。

sscratch:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。

sstatus:其中的SIE位控制设备中断是否启用。如果内核清空SIE,RISC-V将推迟设备中断,直到内核重新设置SIE。SPP位指示陷阱是来自用户模式还是管理模式,并控制sret返回的模式。

satp:页表基址寄存器,指向当前正在使用的页表(改它 = 切换页表);

sscratch:RISC-V 为陷阱设计的 "临时寄存器",专门用来解决 "陷阱入口无可用寄存器" 的问题,内核会提前往里面存数据。

当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:

1.如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。

2清除SIE以禁用中断。

3.将pc复制到sepc。

4.将当前模式(用户或管理)保存在状态的SPP位中。

5.设置scause以反映产生陷阱的原因。

6.将模式设置为管理模式。

7.将stvec复制到pc。

8.在新的pc上开始执行。

从用户空间陷入

如果用户程序发出系统调用(ecall指令),或者做了一些非法的事情,或者设备中断,那么在用户空间中执行时就可能会产生陷阱。

xv6 的 2 个核心映射规则

蹦床页(trampoline page):一块固定大小的物理页,在「内核页表」和「所有用户页表」中,映射到完全相同的虚拟地址 TRAMPOLINE------ 内核能访问,所有用户进程也能访问,地址还一样;

陷阱帧(trapframe):每个用户进程独有,是一块保存 "用户所有寄存器值" 的内存,在用户页表中固定映射到虚拟地址 TRAPFRAME(就在 TRAMPOLINE 下方),同时内核页表也能通过进程结构体p->trapframe直接访问。

用户态运行时,satp 指向用户页表(这个页表只映射用户自己的内存,不映射内核代码 / 数据),但陷阱最终需要内核来处理(用户态不能自己处理陷阱,否则会有安全问题)。 矛盾点在于陷阱后还在用用户页表,怎么才能执行内核的处理代码?xv6 的整个处理流程,本质就是绕开硬件约束,安全解决这个矛盾,全程围绕 "先切到内核页表让内核处理,再安全切回用户页表返回" 展开。

步骤 1:陷阱入口 ------uservec(汇编):保存用户寄存器,为切内核做准备

uservec 的关键操作

1.csrrw a0, sscratch, a0:交换 a0 和 sscratch 的内容

原因:解决 "无可用寄存器" 的问题 ------ 原来 sscratch 里是TRAPFRAME 地址,交换后:a0=TRAPFRAME 地址(陷阱帧指针,能用了),sscratch = 用户原来的 a0 值(用户 a0 被保存了,不会丢);

结果:现在有了第一个可用寄存器 a0,能操作陷阱帧了。

2.把用户所有 32 个寄存器,保存到 a0 指向的 TRAPFRAME 中

原因:用户态的寄存器值必须完整保存 ------ 内核处理陷阱时会修改寄存器,返回时要恢复成原来的样子,否则用户程序会崩溃;

细节:包括从 sscratch 中读回 "用户原来的 a0",一起存到陷阱帧里(因为第一步交换后,用户 a0 在 sscratch 中)。

3.从陷阱帧中读取 4 个关键值:内核栈指针、当前 CPU 编号、usertrap 函数地址、内核页表地址

原因:这些是进入内核态必须的 ------ 内核需要自己的栈,需要跳转到 usertrap 处理逻辑,最终需要切到内核页表。

4.修改 satp 寄存器,指向内核页表(切换页表)

原因:终于能执行内核代码了 ------ 切换后,CPU 能访问内核的所有代码和数据(usertrap 就在内核里);

关键保障:蹦床页在内核页表和用户页表的地址完全相同,所以切换页表后,CPU 能继续执行蹦床页里的 uservec 代码(不会因为页表切换导致地址无效)。

5.跳转到 usertrap 函数(内核 C 代码,正式进入内核态)
步骤 2:陷阱处理 ------usertrap(C 代码):判断原因,实际处理

1.修改 stvec 寄存器,指向 kernelvec

原因:防止 "内核态触发陷阱时,又跳回 uservec"------ 现在已经在核态了,内核自己的陷阱(比如内核里的非法操作)需要由kernelvec处理,这是 "内核态陷阱的入口"。

2.再次保存 sepc 寄存器到陷阱帧

原因:sepc 是用户程序被中断时的程序计数器(指向 ecall 指令),是返回用户态的关键;如果陷阱处理中发生进程切换,sepc 可能被覆盖,所以要二次保存到陷阱帧(永久存储)。

3.判断陷阱原因,分情况处理

情况 1:系统调用(ecall 触发)→ 调用syscall()函数执行具体的系统调用(比如 fork/exec/read);

关键操作:把陷阱帧里的 sepc+4------ 因为 RISC-V 硬件会把 sepc 设为 ecall 指令的地址,返回用户态后需要执行 ecall下一条指令,否则会无限循环执行 ecall。

情况 2:设备中断(比如时钟中断、键盘中断)→ 调用devintr()函数响应中断(比如时钟中断触发进程调度);

情况 3:异常(比如用户态访问内核地址、非法指令)→ 内核直接杀死该进程(安全防护,防止恶意 / 错误的用户程序破坏系统)。

4.检查进程状态,决定是否让出 CPU

原因:如果是时钟中断(定时器触发),xv6 的调度策略会让当前进程 "主动让出 CPU",切换到其他进程运行;如果进程已经被杀死(比如异常),就不需要返回了。

5.处理完成,调用 usertrapret ()(进入返回准备阶段)
步骤 3:返回准备 ------usertrapret(C 代码):重置硬件,为切回用户态做准备

把硬件寄存器恢复到 "适合接收下一次用户态陷阱" 的状态,准备好所有返回需要的数据,交给汇编代码做最后一步。

1.修改 stvec 寄存器,切回指向 uservec

原因:为下一次用户态陷阱做准备 ------ 返回用户态后,用户再触发陷阱(比如再调用系统调用),CPU 需要再次跳转到 uservec 处理。

2.从陷阱帧中恢复 sepc 寄存器

原因:把步骤 2 中保存的 "用户程序计数器"(已经 + 4 的)恢复到 sepc------sret 指令会用这个 sepc 值,跳回用户态的正确位置。

3.准备陷阱帧的相关字段

原因:确保陷阱帧里的所有数据(用户寄存器、内核页表地址、TRAPFRAME 地址等)都是最新的,让后续的 userret 能正确使用。

4.调用 userret 汇编函数,并把 2 个参数传入 a0 和 a1:

a0 = 该进程的用户页表地址(要切回去的页表);

a1 = TRAPFRAME 地址(该进程的陷阱帧地址);

关键原因:调用 userret 的代码必须在蹦床页上执行 ------ 因为 userret 需要切换页表,而蹦床页在两个页表中地址相同,切换后能继续执行。
步骤 4:返回入口 ------userret(汇编):切回用户页表,恢复寄存器,sret 返回

1.从 a0 和 a1 中拿到用户页表地址、TRAPFRAME 地址(usertrapret 传过来的参数)

2.修改 satp 寄存器,切换到该进程的用户页表

关键保障:还是蹦床页的 "同地址映射"------ 切换后,CPU 还能继续执行蹦床页里的 userret 代码,不会地址无效。

3.把陷阱帧中保存的「用户原来的 a0」复制到 sscratch 寄存器

原因:为下一次陷阱做准备 ------ 下一次用户触发陷阱时,uservec 的第一步就是 "交换 a0 和 sscratch",提前把用户 a0 存到 sscratch,才能让下一次交换顺利进行。

4.从陷阱帧中,恢复用户的所有 32 个寄存器

原因:把步骤 1 中保存的寄存器值全部读回来 ------ 恢复成用户程序触发陷阱前的样子,保证返回后用户程序能继续正常运行。

5.最后一次执行:csrrw a0, sscratch, a0(交换 a0 和 sscratch)

结果:a0 恢复成用户原来的 a0 值(用户程序的寄存器完全复原),sscratch 重新保存为TRAPFRAME 地址(为下一次陷阱准备好入口指针)。

6.执行 sret 指令,返回用户态

硬件自动操作:sret 会把 CPU 从内核态切回用户态,同时把 sepc 寄存器的值加载到程序计数器 PC 中 ------ 用户程序就会从ecall 的下一条指令开始,继续正常运行。

一句话回顾整个过程就是:

用户触发陷阱后,硬件跳转到蹦床页的 uservec,通过 sscratch 拿到陷阱帧、保存用户寄存器、切到内核页表,交给内核 C 代码 usertrap 处理;处理完后,usertrapret 做好返回准备,再回到蹦床页的 userret,切回用户页表、恢复所有寄存器,最后用 sret 跳回用户态的下一条指令,全程靠 "蹦床页同地址映射" 和 "sscratch 寄存器交换" 解决硬件约束,安全完成跨特权级的陷阱处理。

从内核空间陷入

内核态陷阱的触发场景包括:设备中断(时钟中断、键盘中断、磁盘中断等)、内核异常(内核代码访问非法地址、执行非法指令、除零等)

步骤1:kernelvec(汇编)→ 保存所有寄存器到当前内核栈

步骤2:跳转到kerneltrap(C代码)→ 初始化、保存关键控制寄存器

步骤3:kerneltrap → 判断陷阱类型,分情况处理(中断/异常)

步骤4:kerneltrap → 处理完成,恢复关键控制寄存器

步骤5:跳回kernelvec(汇编)→ 从内核栈恢复寄存器 → sret返回被中断的内核代码

实验

Backtrace (moderate)

调试时,生成栈回溯(backtrace) 往往十分实用:它是一份函数调用列表,包含错误发生位置之上、栈中所有尚未返回的函数调用记录。为支持栈回溯功能,编译器会生成特定机器码,在栈中为当前调用链的每个函数维护一个栈帧(stack frame)。每个栈帧均包含返回地址,以及一个指向调用者栈帧的帧指针(frame pointer)。寄存器s0中存储着当前栈帧的指针(实际指向栈中已保存返回地址的内存地址加 8 字节的位置)。你需要实现的栈回溯功能,需通过帧指针向上遍历栈空间,并打印每个栈帧中保存的返回地址。

1、当前正在执行的函数的帧指针保存在s0寄存器,将下面的函数添加到kernel/riscv.h

c 复制代码
static inline uint64
r_fp()
{
  uint64 x;
  asm volatile("mv %0, s0" : "=r" (x) );
  return x;
}

2.栈帧布局图。注意返回地址位于栈帧帧指针的固定偏移(-8)位置,并且保存的帧指针位于帧指针的固定偏移(-16)位置,接下来实现backtrace,栈页面的顶部和底部使用PGROUNDDOWN(fp)和PGROUNDUP(fp)进行计算,通过这个看fp是否超过

c 复制代码
void backtrace(void)
{
  printf("backtrace: \n");
  uint64 fp = r_fp();
  uint64 up = PGROUNDUP(fp);
  uint64 down = PGROUNDDOWN(fp);
  while(fp < up && fp >= down){
    uint64 ra = fp - 8;
    uint64 pre = fp - 16;
    printf("%p\n",*(uint64*)ra);
    fp = *(uint64*)pre;
  }
}
  1. 在panic里面添加调用backtrace(),这个在for之前加上
    make qemu之后bttest

    另开一个终端运行 addr2line -e kernel/kernel

Alarm (hard)

相关推荐
z.q.xiao2 小时前
【镜像模式】WSL如何访问windows内网服务
linux·网络·windows·gitlab·wsl·dns
molaifeng2 小时前
万字长文解析:Redis 8.4 网络 IO 架构深度拆解
网络·redis·架构
学烹饪的小胡桃3 小时前
WGCLOUD使用指南 - 如何监控交换机防火墙的数据
运维·服务器·网络
Howrun7773 小时前
Linux网络编程_常见API
linux·运维·网络
小北方城市网3 小时前
Spring Cloud Gateway 动态路由进阶:基于 Nacos 配置中心的热更新与版本管理
java·前端·javascript·网络·spring boot·后端·spring
call me by ur name3 小时前
polymarket开发文档-Websocket+Gamma Structure+Subgraph+Resolution
网络·websocket·网络协议
阿豪学编程3 小时前
【Linux】Socket网络编程
linux·服务器·网络
阿钱真强道3 小时前
06 thingsboard-ubuntu20-rk3588-连通性-测试 MQTT HTTP COAP
网络·物联网·网络协议·http