上下文切换本质
保存当前寄存器,恢复目标寄存器,切换栈,跳转到目标返回地址。
6.1810 / 6.1810 · 第 13 课 · rtm
xv6 如何实现进程/线程切换?本节课深入内核,通过 GDB 跟踪 timer 中断、yield()、sched()、swtch() 的完整流程,理解上下文切换的本质:保存和恢复寄存器。
想象正在以普通串行方式执行的代码。如果我们想暂停它、放到一边,做其他事,稍后再恢复那段代码——我们需要保存什么?
线程在执行代码和挂起时的保存状态之间交替。线程管理系统管理线程间的切换。
| 保存内容 | 说明 |
|---|---|
| 用户内存 | 进程的虚拟地址空间 |
| 用户保存寄存器(trapframe, TF) | 用户态寄存器,通过 trampoline 保存 |
| 内核栈 | 线程在内核态使用的栈 |
| 内核保存寄存器(context, CTX) | 内核态寄存器,通过 swtch 保存 |
| p->state | 进程状态(RUNNING/RUNNABLE/SLEEPING) |
| 状态 | 含义 |
|---|---|
| RUNNING | 正在 CPU 上执行(用户态或内核态) |
| RUNNABLE | 想执行,但未运行(在内核中挂起) |
| SLEEPING | 等待某事件(如 read(), wait()) |
用户态 → 内核态:保存用户寄存器到 trapframe
内核线程 → 调度器线程:保存内核线程寄存器到 context
调度器线程 → 内核线程:从 context 恢复内核线程寄存器
内核态 → 用户态:从 trapframe 恢复用户寄存器
struct proc {
struct trapframe *trapframe; // 保存的用户寄存器
struct context context; // 保存的内核寄存器
uint64 kstack; // 内核栈指针
enum procstate state; // RUNNING/RUNNABLE/SLEEPING
struct spinlock lock; // 保护 p->state 等
// ...
};
两个 CPU 密集进程 aaa 和 bbb,QEMU 单核,通过定时器中断驱动切换。
trap.c:85(定时器中断)设断点usertrap() → 发现是设备中断(定时器)yield()p->state = RUNNABLE——放弃 CPU 但想再次运行yield() 获取 p->lock,防止另一个 CPU 运行此线程!sched()——做 sanity checks,然后调用 swtch()kernel/swtch.S 的伪代码:
swtch(a0=旧上下文, a1=新上下文):
保存 ra, sp, s0-s11 到 a0(旧上下文的内存)
从 a1 加载 ra, sp, s0-s11
ret // 跳转到新上下文的 ra
swtch() 保存当前寄存器到旧进程的 context,从新进程的 context 恢复寄存器ra(返回地址)决定 ret 跳转到哪里sp(栈指针)同时切换了栈——每个线程有自己的栈sched(),如同该进程之前的 swtch() 返回了RISC-V 有 32 个寄存器,swtch 保存 ra, sp, s0-s11(14 个)。其他 18 个呢?
| 寄存器 | 为什么不保存 |
|---|---|
| zero, gp, tp | 恒定的或全局/线程指针 |
| t0-t6, a0-a7 | 临时参数/调用者保存(caller-saved),在函数调用时已被保存到栈 |
因为 ret 跳转到 ra——PC 实际上是通过 ra 隐式切换的。
yield(): scheduler():
acquire(&p->lock); swtch();
p->state = RUNNABLE; release(&p->lock);
swtch();
锁由不同线程释放!yield 在进程的内核线程中获取,scheduler 在调度器线程中释放。
p->state == RUNNABLE,直到寄存器保存完成p->state == RUNNABLE,直到停止使用线程的栈yield()(acquire 关闭了中断)是的!kerneltrap() 也调用 yield()。三套保存的寄存器:
| 保存内容 | 保存位置 | 保存者 |
|---|---|---|
| 用户寄存器 | trapframe | trampoline |
| 被打断的内核代码寄存器 | 栈 | kernelvec |
| 中断处理程序寄存器 | context | swtch |
不必要(内核没有无限 CPU 密集循环),但如果某些系统调用计算量大,或需要严格的线程优先级,则很有价值。
// 单核上的灾难场景:
P1: acquire(L1); P2: acquire(L2);
sched(); acquire(L1); // 等待 L1
// P2 自旋等待 L1,中断已禁用
// 无时钟中断 → P2 不 yield → P1 不运行
// P1 不释放 L1 → 死锁!
解决方案:禁止在持有锁时放弃 CPU!(除了 p->lock)
可能没有 RUNNABLE 线程(都在等待 I/O)。启用中断让设备有机会完成并唤醒线程,否则系统会冻结。
swtch()Q1:swtch() 如何实现线程切换?
Q2:为什么在 swtch 期间持有 p->lock?
Q3:为什么不允许持有自旋锁(除 p->lock)跨越上下文切换?
Q4:xv6 的内核抢占是如何工作的?
Q5:调度器启用中断(intr_on)的原因是什么?
保存当前寄存器,恢复目标寄存器,切换栈,跳转到目标返回地址。
每个 CPU 一个独立调度器线程,确保始终有栈可供调度循环运行。
yield 获取,scheduler 释放——保护从 RUNNABLE 设置到寄存器保存完成的整个过程。
内核可被抢占(通过 kerneltrap),但持有自旋锁时禁止 yield,避免死锁。