跳转到正文
Siolin'Log
返回

xv6 Trap 机制

Trap

在 RISC-V 中,Trap(陷阱)并不是 CSAPP 所述的 exception 的一种,其是一个总称:CPU 因为某个事件暂时打断当前指令流,转入更高特权级的处理程序。

也就是说,用户态和内核态的切换主要是通过 trap 来实现,通常包括三类:

  1. 系统调用:程序主动请求内核服务(如 read, fork)。用户程序执行 ecall 指令陷入内核。
  2. 异常:程序运行中产生的错误(如除零、缺页、非法指令)。CPU 自动触发异常处理流程。
  3. 硬件中断:外部设备或时钟等异步事件。

其次,切换过程中最关键的是上下文的保存与恢复。 但在 RISC-V/xv6 中,硬件只完成最小的一组动作:

xv6 对 trap 的处理存在通用的框架,通常的顺序是:

  1. trap 强制将控制权转移到内核中;
  2. 内核保存寄存器和其它状态;
  3. 内核执行处理程序代码;
  4. 内核恢复保存的状态并从 trap 中返回,原始代码从中断处继续。

但针对用户空间 trap内核空间 trap计时器中断这三种特殊情况,设计了独立的处理路径。

RISC-V 对 Trap 的硬件支持

每个 RISC-V CPU 都有一组特权寄存器,内核写入这些控制寄存器来告诉 CPU 如何处理 trap,并且内核可以读取这些寄存器来找出已发生的 trap。

重要特权寄存器概述:

上述寄存器与内核模式下处理的 trap 相关,并且不能在用户模式下读取或写入

RISC-V 硬件在 trap 发生时会执行以下最小动作:

  1. 中断屏蔽检查
    • 如果 trap 是由设备中断引发的,且 sstatus.SIE=0,则处理器会暂存该中断,暂缓执行
    • 如果是异常系统调用,会跳过该步骤
  2. 禁用中断
    • 设置 sstatus.SIE=0:防止 trap 处理期间被其他中断嵌套
  3. 保存上下文
    • sepc:保存当前 pc,以便 trap 返回时恢复执行
    • sstatus.SPP:保存当前特权模式(0=user, 1=kernel)
  4. 设置 trap 原因
    • scause:记录 trap 类型(中断或异常)和具体原因(如中断号或异常码)。
  5. 切换管理模式
    • 将当前模式设置为内核态,以便执行内核空间中的 vector
  6. 跳转到 trap handler
    • 将 stvec 的地址加载到 pc

以上步骤为硬件操作,在发生 trap 后自动执行,并没有显式代码。

未完成的步骤(需软件处理):

CPU 保留上述步骤交给软件处理是为软件提供灵活性,比如某些操作系统在某些情况下会省略页表切换,硬件仅提供最小必要的支持

在 xv6 中,来自用户态的 trap 由 trampoline 页中的 uservec 完成这些准备工作;来自内核态的 trap 则走 kernelvec,因为此时已经在内核页表和内核栈上。

User Trap

以下情况属于 user trap:

发生 trap 时,依次执行以下处理程序(执行完硬件操作后):

  1. uservec:保存程序状态、切换页表和栈指针、跳转到 usertrap
  2. usertrap:分类处理 trap,之后跳转到 usertrapret 进行返回
  3. 返回时,usertrapret:重置特权寄存器,准备返回用户模式
  4. 然后,userret:切换用户页表、恢复程序上下文,并通过 sret 真正回到用户态

用户虚拟地址空间: 用户虚拟地址空间

Trapframe

trapframe 用来保存“用户态现场”,这样 trap 处理完以后还能回到原来的用户程序继续跑。

其主要保存两类东西:

// 1. 用户寄存器
...

// 2. trap 返回/再次进入内核需要的信息
kernel_satp    内核页表
kernel_sp      当前进程的内核栈顶
kernel_trap    usertrap() 的地址
epc            用户程序返回地址
kernel_hartid  当前 CPU/hart id

每个进程都有自己的 trapframe,同时,这块物理内存会映射到该进程用户页表顶部附近的固定虚拟地址。

问题:为什么要映射到用户页表里?

用户态发生 trap 后,CPU 刚跳到 uservec 时,虽然已经处于 supervisor mode,但还在使用用户页表。这时还没切换到内核页表。

所以 uservec 如果要保存用户寄存器,必须能在当前页表下找到一块内存。这个地址就是 trapframe。之后再从 trapframe 中取内核页表进行切换。

xv6 把 trapframetrampoline 同时映射到用户页表和内核页表,并且地址都是相同的。这样切换页表前后,CPU 仍然可以连续执行以及读写数据。

Trampoline

trampoline 是 xv6 放在固定虚拟地址上的一小页汇编代码,用来完成用户态和内核态之间最危险的过渡。

它里面主要有两段代码:

uservec:用户态 trap 进入内核时执行
userret:内核返回用户态时执行

trapframe 是数据页,而 trampoline 则是代码页。

Uservec

uservec 核心任务是:在还没有真正进入普通内核 C 代码之前,把用户态现场保存好,并把执行环境切换成内核能正常运行的状态。

其会执行:

1. 用 sscratch 找到 TRAPFRAME
2. 保存用户寄存器到 trapframe
3. 从 trapframe 取 kernel_sp、kernel_satp、kernel_trap
4. 切换到内核栈
5. 切换到内核页表
6. 跳到 usertrap()

这里存在一个状态的转变:

所以它是“进入内核”的桥。

Userret

userret 负责返回用户态。

内核处理完 trap 后,usertrapret() 会准备好:

sepc = 用户程序返回地址
sstatus.SPP = user
sstatus.SPIE = 1
stvec = 下一次用户 trap 的 uservec 地址

然后跳到 trampoline 里的 userret

userret 做这些事:

1. 切换到用户页表
2. 从 trapframe 恢复用户寄存器
3. 用 sscratch 恢复用户 a0
4. 执行 sret

最后 sret 会:

pc = sepc
特权级 = user

于是回到用户程序。

也就是说:

所以它是“返回用户”的桥。

Usertrap

usertrap 是用户态 trap 进入内核后的分发器。

执行步骤:

  1. stvec 更新为 kernelvec(现在已经在内核里了,再次发生 trap 时应该由 kernelvec 处理)
  2. 保存 sepcp->trapframe->epc
    • 防止再次引发 trap 时覆盖用户返回地址
  3. 根据 scause 处理不同类型的 trap
    • 系统调用
      1. p->trapframe->epc += 4,指向 ecall 的下一条指令,后续由 usertrapret() 写回 sepc
      2. 启用中断
      3. 调用 syscall()
    • 设备中断:调用 devintr()
    • 异常:终止出错的进程
  4. 调用 usertrapret() 准备返回

Usertrapret

当 trap 处理完成后,调用 usertrapret 准备返回用户态

  1. 关闭中断:防止过渡状态被打断
  2. stvec 改回 uservec,确保下次用户态 trap 能够调到 uservec
  3. 设置 trapframe 中的内核字段,使其在下次触发 trap 时被使用
    • kernel_satp:内核页表地址
    • kernel_sp:内核栈指针
    • kernel_trapusertrap 的地址
  4. 设置 sstatus,决定 sret 返回到哪里
    • SPP = 0:回到用户态
    • SPIE = 1sret 返回用户态后重新启用中断
  5. 设置 sepc:将 p->trapframe->epc 写到 sepc(无论其是否被覆盖),方便后续 userret 返回到用户程序
  6. 准备用户页表 satp,后续传递给 userret
  7. 调用 userret 函数,并传递两个参数:trapframesatp
    1. satp,切到用户页表
    2. trapframe 恢复用户寄存器
    3. 恢复用户 a0
    4. 执行 sret


上一篇
Java ReadWriteLock:共享读、独占写与锁降级
下一篇
Java AQS:同步状态、等待队列与阻塞唤醒