1. 课程在讲什么:一个绕不开的核心机制
6.S081 这门课的 LEC 11 主题是 Thread switching,讲的是 xv6 里线程切换的完整实现。如果你学过前几节课,会发现前面讲系统调用、讲 trap、讲页表,其实都是在为这一节课铺垫。因为不管上层机制多花哨,最终 CPU 要来回切换不同线程的执行流,靠的就是 switch 这一套机制。
这节课的核心问题非常朴素:一个 CPU 只有一个程序计数器、一组寄存器、一个栈空间,但系统里同时存在多个线程。CPU 怎么从线程 A 的执行现场切到线程 B 的执行现场?切完之后怎么再切回来?切回来时现场还记得多少?这些小到不能再小的问题,构成了整个并发系统的地基。
这篇文章我打算把 LEC 11 的内容按自己的理解重新梳理一遍,重点放在这三件事上:context 结构体为什么长这样,swtch 汇编每一行到底在做什么,以及调度器 scheduler 究竟是怎么靠 "切出去" 和 "切回来" 两条路径转起来的。同时我也会把 xv6 里最容易让人犯迷糊的几个细节展开讲,比如为什么线程切换和进程切换在 xv6 里是同一件事,为什么持锁状态下不能切换线程,以及 sleep 和 wakeup 是怎么配合 switch 完成线程状态流转的。
如果你正在啃 xv6 源码,或者刚看完 LEC 11 的课程录像,这篇文章可以直接当补充笔记用。如果你只是对操作系统感兴趣,想搞清楚线程切换和函数调用的区别,这篇文章也不会让你白看。
2. 先搞清楚:xv6 里的“线程”到底是什么
2.1 线程的三件套:PC、寄存器、栈
讨论线程切换之前,得先明确线程在计算机里到底以什么形态存在。抽象地讲,线程是一条独立的执行流,而这条执行流体现为三样东西:
- 程序计数器(PC):当前执行到哪条指令
- 寄存器组:当前计算中间结果都存在这里
- 栈:函数调用的返回地址、局部变量、临时数据都在栈上
这三样东西合起来,叫线程的执行现场。一个线程被打断时,现场留在这三处;线程恢复时,也得把这三处恢复到原样。
你可以把线程想象成一本翻到一半的书。PC 是你读到哪一页,寄存器是脑海里记得的关键人物和情节,栈是书签和便签。放下这本书去读另一本时,要做的就是把当前页码、记住了什么、便利贴放在哪都记清楚,然后拿起另一本——下一回再翻回来时,还要能接得上。
函数调用看起来也像这么回事:调用一个函数时保存返回地址、压栈局部变量,函数返回时恢复。但线程切换和函数调用有一个本质差别。函数调用是深度嵌套的,callee 返回之后会老老实实回到 caller 继续往下走;而线程切换不是简单的嵌套返回,它是两条独立的执行流互相交换 CPU。A 切到 B 之后,A 的执行流就被冻结了,什么时候回来、从哪儿回来,完全取决于 B 那边的调度安排。
这就是为什么线程切换需要比函数调用更重的机制。
2.2 xv6 的进程模型:一个进程一个线程,外加隐藏的调度线程
xv6 里的进程结构直接沿用了 Unix 进程的经典图示:一个进程 = 一段地址空间 + 一个或多个线程。但 xv6 简化到极端——每个进程只有一个线程。这个线程在用户态运行用户代码,通过系统调用或中断进入内核态,在内核态继续执行内核代码。
但全局来看,系统的线程不止这些用户进程。xv6 里每个 CPU 还有一个叫 scheduler 的内核线程,也叫调度器线程。它不执行任何用户代码,专门负责一个循环:找出一个 RUNNABLE 的进程,切到那个进程的线程里执行;等那个进程的线程切回来时,调度器继续找下一个。
这意味着每个 CPU 上有两种线程身份在交替出现:
- 调度器线程:运行在 CPU 自己的内核栈上,执行 scheduler() 函数
- 进程内核线程:运行在进程自己的内核栈上,执行进程的内核代码
进程的用户态线程和内核态线程共用同一个线程实体,靠 trapframe 保存用户态寄存器。但调度器线程是独立的,永远不进入用户态。
很多初学者读 xv6 源码时第一个困惑点就是:进程切换我理解,可每个 CPU 上的调度器线程算什么?它的 context 放在哪?它什么时候创建的?
答案是:调度器线程的 context 放在 cpu 结构体里,每个 CPU 一个。它没有独立的进程结构,也不参与进程调度状态机。它就是一个在 scheduler() 函数里死循环的角色。任何进程线程要切走,都是切到这个调度器线程;调度器再从这个线程切到下一个进程线程。
所以 xv6 的线程切换图景,不是多个进程两两之间互相切换,而是所有进程线程都通过调度器线程中转。实际上代码里也确实是这么实现的——每个 CPU 只有一组调度器上下文(cpu->context),进程和调度器之间成对切换。
2.3 用户线程、内核线程、上下文切换——三个易混概念
这三个概念在 LEC 11 里是同时出现的,但也最容易混。这里用一句话区分:
- 用户线程:运行在用户态,寄存器现场保存在进程的 trapframe 里
- 内核线程:同一进程进入内核态后的执行流,寄存器现场保存在内核栈上(由编译器在函数调用/中断时自动保存)
- 上下文切换(context switch):内核态里从一个执行流切到另一个执行流的动作,保存的是内核态寄存器组和栈指针
注意一个细节。从用户态陷入内核态时,硬件会保存用户寄存器到 trapframe,这是我们前面 trap 课讲过的。但内核代码执行过程中,函数调用之间的寄存器保存靠的是编译器,遵循 ABI(应用二进制接口)规则——调用者保存寄存器和被调用者保存寄存器分工明确。
也就是说,在进程的同一个线程里,从用户态到内核态并不是线程切换,而是模式切换。用户寄存器在 trapframe 里,内核寄存器就是当前内核栈上的现场。整个过程没有创建新线程。
那什么时候才真正发生线程切换?答案很简单:当一个进程的内核代码决定放弃 CPU,主动调用 sched(),或者被中断处理程序强制调用 yield() 时。这时候才需要保存整个内核执行现场,切到调度器线程。
这正好解释了为什么 xv6 中一个进程 = 一个线程也能运行得很好:用户态一个线程,内核态同一个线程,模式切换不是线程切换,真正需要切换的是内核态的执行流。
3. context 结构体与 swtch 汇编:线程切换的心脏
3.1 context 结构体:为什么只保存 callee-saved 寄存器
先看 xv6 的 context 定义,在 kernel/proc.h 里:
// 每个进程/CPU 保存内核线程在切换点的寄存器现场 struct context { uint64 ra; uint64 sp; // callee-saved registers uint64 s0; uint64 s1; uint64 s2; uint64 s3; uint64 s4; uint64 s5; uint64 s6; uint64 s7; uint64 s8; uint64 s9; uint64 s10; uint64 s11; };仔细看这个结构体:它没有保存 pc,没有保存通用寄存器 a0-a7、t0-t6,也没有保存浮点寄存器。为什么?
关键就在于 RISC-V 的函数调用约定。
RISC-V 把寄存器分成两类:
- caller-saved(调用者保存):a0-a7、t0-t6 等。调用函数之前,调用者要把这些寄存器值保存到自己的栈帧里,因为被调函数会随意改动它们。
- callee-saved(被调用者保存):s0-s11、sp、ra。被调函数如果要用这些寄存器,必须先保存原值,返回前恢复原值。
context 结构体被用于 swtch 函数。当线程 A 调用 swtch 想切到线程 B 时,对 swtch 这个被调函数来说,调用者(线程 A 的代码)已经负责把 a 系、t 系寄存器保存到 A 的栈上了。swtch 只需要保存 callee-saved 寄存器,就能保证将来 A 恢复执行时,现场和调用 swtch 前完全一致。
至于 pc 没有单独保存,是因为 ra 寄存器(返回地址)本身就是 callee-saved 的一部分。函数返回时通过 ret 指令跳回 ra 指向的地址。线程恢复执行时,也是通过 ret 指令跳到新的 ra 地址——这就是线程切换后“从新位置继续执行”的实现原理。
也就是说,context 结构体不是完整保存寄存器,它只保存一个函数调用边界所必需的寄存器集合。mv 指令、auipc 等都可以不保存,因为这些属于 CPU 计算过程中的临时状态,编译器在函数边界已经妥善处理了。
3.2 swtch 汇编逐行拆解:从 a0 到 ret
swtch 函数在 kernel/swtch.S 里,完整代码如下(xv6 原始版本,略作注释):
.globl swtch swtch: sd ra, 0(a0) # 保存返回地址到旧 context sd sp, 8(a0) # 保存栈指针 sd s0, 16(a0) sd s1, 24(a0) sd s2, 32(a0) sd s3, 40(a0) sd s4, 48(a0) sd s5, 56(a0) sd s6, 64(a0) sd s7, 72(a0) sd s8, 80(a0) sd s9, 88(a0) sd s10, 96(a0) sd s11, 104(a0) ld ra, 0(a1) # 载入新 context 的返回地址 ld sp, 8(a1) # 载入新 context 的栈指针 ld s0, 16(a1) ld s1, 24(a1) ld s2, 32(a1) ld s3, 40(a1) ld s4, 48(a1) ld s5, 56(a1) ld s6, 64(a1) ld s7, 72(a1) ld s8, 80(a1) ld s9, 88(a1) ld s10, 96(a1) ld s11, 104(a1) ret # 跳转到新 ra逐行看,逻辑非常对称:先把自己(旧线程)的 ra、sp、s0-s11 存到 a0 指向的 context;再把新 context 里的这些值加载到寄存器;最后 ret。总共 28 条指令,纯粹的内存搬运加一个无条件跳转,没有任何魔法。
几个容易忽略的细节:
第一个,calling convention。swtch 是一个普通函数,被调用时 a0 和 a1 分别保存第一个和第二个参数。xv6 里调用 swtch 的代码长这样:
swtch(&p->context, &mycpu()->context);所以 a0 指向旧线程的 context,a1 指向新线程的 context。这两个地址由调用者提供,swtch 本身不关心你是谁。
第二个,sp 切换的后果。加载新的 sp 之后,当前函数的“栈”就变成新线程的栈了。此时所有后续的局部变量访问、函数调用都在新栈上进行。但注意,swtch 本身没有局部变量——它只用了 a0 和 a1,而 a 系寄存器是 caller-saved,不需要保存。所以切栈不会导致数据错乱。
第三个,ret 指令的语义。ret 从两个层面完成跳转:
- 硬件层面:PC 跳转到 ra 寄存器所指的地址
- 逻辑层面:新线程的执行流从上次被切换走的位置(也就是它自己的 swtch 调用点)继续往下执行
所以 ret 不只是在返回调用点,本质上它是"另一条执行流继续执行"的入口。你能从 backtrace 里看到一个线程从 swtch 返回后继续执行的奇妙景象:返回地址不是发起切换的那个函数,而是另一个线程曾经的调用点。
3.3 为什么 caller-saved 寄存器不需要在 context 里保存
上面说了编译器会在函数边界处理 caller-saved 寄存器。但这里有个更妙的推论:线程切换点实际上就是一个函数调用点。
假设线程 A 在内核代码的某处调用 swtch。编译时,编译器会把 A 当前用到的 a0-a7、t0-t6 等寄存器值压到 A 的内核栈上(如果后面还会用的话)。这些值保存在 A 的内核栈里,而不是 context 里。
等到 A 被重新调度、swtch 返回时,编译器会从栈上恢复这些 caller-saved 寄存器。整个过程的正确性由 RISC-V ABI 保证,与调度器的具体实现无关。
这就是为什么无论你在代码的哪个位置调用 swtch,切换都不会破坏当前函数的局部变量和临时值。反过来说,如果你在 swtch 之后还继续使用 a0 的旧值,恭喜你,这是未定义行为——但正常情况下编译器生成的代码不会这么干,它会规矩地把需要的旧值存在栈上。
这里也顺带说明一个经验:想手工验证线程切换是否正确,最直接的办法是看反汇编里调用 swtch 附近的代码,确认编译器有没有把必要寄存器压栈。
4. 调度器循环与进程状态机
4.1 scheduler() 函数是怎么转起来的
xv6 主调度逻辑写在 kernel/proc.c 的 scheduler() 里,核心部分如下:
void scheduler(void) { struct proc *p; struct cpu *c = mycpu(); c->proc = 0; for (;;) { // 关中断 intr_on(); for (p = proc; p < &proc[NPROC]; p++) { acquire(&p->lock); if (p->state == RUNNABLE) { // 选定 p 运行 p->state = RUNNING; c->proc = p; swtch(&c->context, &p->context); // 进程 p 主动切回来之后,回到这里继续 c->proc = 0; } release(&p->lock); } } }这段代码是每个 CPU 上独立跑的死循环。它做的事情:
- 关中断(intr_on 之后,实际上 schedule 会自己开)
- 遍历进程表,找 state 为 RUNNABLE 的进程
- 拿到进程锁后把状态改为 RUNNING
- 保存 CPU 调度器自己的 context,切换到目标进程的 context
切换动作发生在 swtch(&c->context, &p->context) 这一条调用上。切到目标进程之后,CPU 开始执行目标进程的内核代码。目标进程内核代码在某一时刻切回来,回到 swtch 返回点——也就是 scheduler() 循环里的 c->proc = 0 这行。
注意一个细节:scheduler() 里选中进程后,进程状态会改为 RUNNING,然后直接 swtch。这个进程被切换走时,也会通过 sched() 改回 RUNNABLE。所以 scheduler() 里 p->state == RUNNABLE 的检查始终能看到最新状态,不需要额外同步锁来更新进程表。
调度器循环的 spawn 和 reaper 职责都在别处,fork 和 exit 会处理。调度器只做一件事:不断找到可运行进程,切换过去,等切换回来,继续找下一个。
4.2 sched() 与 yield():进程主动放弃 CPU 的路径
进程侧真正发起切换的函数是 sched():
void sched(void) { struct proc *p = myproc(); // 各种断言 if (!holding(&p->lock)) panic("sched p->lock"); if (mycpu()->noff != 1) panic("sched locks"); if (p->state == RUNNING) panic("sched running"); if (intr_get()) panic("sched interruptible"); // 切到调度器 swtch(&p->context, &mycpu()->context); // 等调度器再次选中的时候,会从这一行继续执行 // 并且此时 p->lock 已经被调度器释放 }sched() 是在当前进程的内核栈上执行的。它先检查各种不变量,然后把 context 保存到 p->context,切到当前 CPU 的调度器 context。
进程什么时候会调用 sched()?可以分三类:
- 主动睡眠:sleep 里调用 sched()
- 主动让出 CPU:yield 里调用 sched()
- 退出:exit 里调用 sched()
yield 的代码也很简单:把进程状态从 RUNNING 改成 RUNNABLE,然后调 sched()。进程被重新调度回来自动执行 sched() 返回后的代码。这样就让了一个时间片。
4.3 yield 到重新运行:一个完整的时间片轮转
完整走一遍时间片轮转的路径,能把这个机制看得更透:
- 时钟中断触发
- 内核 trap 处理发现是定时器中断
- 中断处理函数调用 yield()
- 进程状态改为 RUNNABLE,调用 sched()
- sched() 里 swtch 切到 CPU 的调度器 context
- scheduler() 循环继续跑,扫描到另一个 RUNNABLE 进程,切换过去
- 新进程运行,直到它也 yield/睡眠/退出
- 调度器重新扫描到刚才那个进程(它的状态是 RUNNABLE)
- swtch 从调度器切回该进程的 context
- 该进程的 sched() 返回,yield() 返回,中断处理继续,回到用户态
第 10 步有个容易忽略的点:进程被切回来时,不是从任何"新建"的地方开始的,而是从它自己上次被切走的位置接着执行。sched() 的返回点就是 yield() 的返回点,然后一路返回到 trap 处理函数,最后通过 userret 回到用户态。
这就是为什么 xv6 不需要一个清晰的"进程启动点"——每个进程都有自己的 context,第一次运行时 context 由初始化代码构造,之后每次切换都是接力。
5. 线程切换和进程切换的边界:xv6 为什么能“一石二鸟”
5.1 线程切换 vs 进程切换:xv6 的统一实现
在大多数教科书里,线程切换和进程切换是两件事:线程切换只换执行流,不换地址空间;进程切换既要换执行流,也要换地址空间。xv6 的设计很取巧:每个进程只有一个线程,所以换进程 = 换线程 + 换页表 + 换 trapframe。
线程切换(swtch)只负责换内核执行流:寄存器组 + 栈。而地址空间切换由别的部分负责。看调度路径:
- 切换前,进程 P 的内核代码持有自己的页表,通过 satp 寄存器映射内核地址空间
- 所有进程的内核页表映射是相同的——xv6 的内核部分都映射到同一段高地址虚拟内存
- 所以内核线程切换期间,不需要切换页表也能执行内核代码
但用户页面不一样。每个进程的用户页表不同。进程切换时,真正重置用户地址空间的地方是 trapframe 的恢复和 trampoline 里的 userret 代码。简单说,swtch 切换的是内核执行流,而用户态的地址空间通过页表寄存器(satp)在返回用户态时被切换到目标进程的页表。
这样设计的一个好处是:调度器可以随意遍历进程表、选中目标进程,不需要关心目标进程的地址空间长什么样。因为调度器自己运行在内核地址空间里,而所有进程的内核页表共享同一份内核映射。
5.2 为什么切换时要持锁和关中断
xv6 的 sched() 里有个断言:mycpu()->noff == 1。noff 是当前 CPU 的关中断嵌套计数器。每个 acquire 会调用 push_off 关中断,release 会调用 pop_off 开中断(当嵌套计数归零时)。
这个断言的含义是:sched() 被调用时,当前 CPU 必须正好关着一层中断,而且这层中断是因为持有了某个锁。绝大多数情况下,这个锁就是进程自己的 p->lock。
为什么要持锁?因为进程的 state 变更和 swtch 必须是一个原子操作。如果进程把状态改成 RUNNABLE 但没有持锁,另一个 CPU 上的调度器可能同时看到 RUNNABLE 并把它调度到另一个核上执行。两个核同时运行同一个进程,世界就乱了。
所以 sleep/yield/exit 里都有类似的套路:
acquire(&p->lock); // 修改状态 // 调用 sched(),此时持有锁 // 切到调度器 // 切回来后,调度器已经释放了 p->lock release(&p->lock);注意看 sched() 的代码:切到调度器之后,当前进程的代码暂时不执行了,释放锁的任务交给调度器那边。这是 xv6 调度器必须持锁释放的标志性设计。调度器在 swtch 返回后立刻 release(&p->lock)。
相当于锁的持有权跨线程转移:进程线程持有锁,切到调度器,由调度器释放锁。如果切回时又持有锁,再由调度的释放逻辑重新获取。看似绕,但保证了进程状态和调度决策之间的一致性。
5.3 trapframe 与用户态恢复的配合
用户态恢复和线程切换是两套机制。用户态寄存器保存在 trapframe 里,恢复时由 trampoline 的 userret 代码把 trapframe 里的寄存器逐一加载进通用寄存器,然后 sret 回用户态。
这两套机制在时间上是怎么配合的?看一个完整周期:
- 用户态进程 P 运行用户代码
- 定时器中断,硬件跳到 trampoline,保存用户寄存器到 P 的 trapframe
- 内核 trap 处理函数运行,调用 yield(),sched(),swtch
- 切换走,P 的内核执行流冻结
- 调度器选到另一个进程 Q,切到 Q 的 context,Q 的内核代码恢复执行
- Q 的 trap 处理函数最终执行 userret,恢复 Q 的 trapframe,sret 回 Q 的用户态
所以 P 的 trapframe 和 context 是两个不相干的容器:trapframe 保存用户态寄存器,context 保存内核态寄存器。返回用户态时只有 trapframe 参与,profile 在内核态的上下文切换完全不用碰 trapframe。
这个分工让 xv6 可以优雅地处理"用户态被打断"和"内核态主动切换"两种场景。
6. sleep 与 wakeup:把线程切换接入同步机制
6.1 sleep 的完整流程
sleep 是理解线程切换怎么服务于上层同步机制的最佳案例。看 xv6 里 sleep 的核心代码(kernel/proc.c):
void sleep(void *chan, struct spinlock *lk) { struct proc *p = myproc(); acquire(&p->lock); release(lk); // 状态改成 SLEEPING p->chan = chan; p->state = SLEEPING; sched(); // 被唤醒后从这里继续 p->chan = 0; release(&p->lock); acquire(lk); }这段代码值得反复读。它完成了非常精巧的操作:
- 先获取进程锁,释放外部锁(lk)
- 把进程状态改为 SLEEPING
- sched() 切换到调度器,进程彻底睡过去
- 等 wakeup 唤醒,调度器把进程重新调度回来
- 恢复后释放进程锁,重新获取外部锁
关键点是:释放 lk 是在 sleep 修改状态之前。这个顺序保证了不会发生死锁:别人想唤醒这个进程时只需要 p->lock,不需要 lk;而 sleep 持有 p->lock 时修改状态,wakeup 持锁检查状态,两者互斥。
如果没有 p->lock 保护,可能出现:进程还没睡进 sched(),另一个线程已经把它唤醒了,然后两个线程同时操作它——这就是经典的"早唤醒"竞态。
6.2 wakeup 的流程
wakeup 更简单,遍历进程表,把 chan 匹配且状态为 SLEEPING 的进程改为 RUNNABLE:
void wakeup(void *chan) { struct proc *p; for (p = proc; p < &proc[NPROC]; p++) { acquire(&p->lock); if (p->state == SLEEPING && p->chan == chan) { p->state = RUNNABLE; } release(&p->lock); } }wakeup 并没有把进程立刻切换过来,只是把它的状态从 SLEEPING 改成 RUNNABLE。真正让它运行要等调度器在某个时刻扫描到它。
这种设计叫"轮询式唤醒",很简单,性能不是极致但正确性有保证。wakeup 的所有检查都在锁保护下,所以 sleep 和 wakeup 不会交错。
6.3 丢失唤醒问题与锁的作用
sleep/wakeup 接口绕了那么多,最大的坑就是丢失唤醒。经典场景:
- 线程 A 准备睡眠,但还没有把状态改成 SLEEPING
- 线程 B 调 wakeup,扫描到 A 的状态还是 RUNNING,跳过
- 线程 A 随后把状态改成 SLEEPING,sched(),永远没人再来唤醒它
xv6 的解决方案是什么?是 p->lock。sleep 里先持有 p->lock 再释放外部锁、改状态、sched。wakeup 里必须先持有 p->lock 才能检查状态。这样就能保证:如果 wakeup 已经扫描过 A 并看到它不是 SLEEPING,那么 A 还没来得及改状态;但由于 A 要改状态必须先持锁,wakeup 看到的旧状态说明 A 还没改,A 也不会被跳过——因为它改状态时 wakeup 已经因为锁而等待,等 wakeup 拿到锁再检查时,A 已经是 SLEEPING 了。
换句话说,p->lock 把"检查状态-改状态"变成了原子操作。睡眠和唤醒之间没有窗口。
理解了这一点,你再看 sched() 里的持有 p->lock 的断言就不会觉得多余了。锁在这个设计中不是简单的互斥,它是 sleep/wakeup 语义的一部分。
7. 常见问题与调试实录
7.1 context 未初始化:第一次调度的魔法
每个进程第一次被调度时,它的 context 是从哪来的?答案在 allocproc() 里(kernel/proc.c):
p->context.ra = (uint64)forkret; p->context.sp = p->kstack + KSTACKSIZE; p->context.s0 = 0; ...这里直接把 ra 设置成 forkret 函数的地址,sp 设置成内核栈的顶端。进程第一次被调度器切到时,swtch 加载这些值,ret 跳到 forkret。forkret 最终会执行 usertrapret 返回用户态。
这个"构造的 context"堪称线程切换里最优雅的一笔。它不需要一个真正的"进程入口",只要把返回地址指向一个合法函数,调度器就能通过统一的 swtch/ret 机制把进程跑起来。仿佛这个进程曾经被切换走过一样。
如果你想调试新进程的第一次运行,可以在 forkret 里下断点,看一下 backtrace。你会发现调用链不是我们熟悉的 swtch -> forkret,因为你看到的就是调度器 swtch 之后直接进入 forkret。
7.2 panic("sched locks") 的排查经验
sched() 里有一段经典断言,不满足会直接 panic:
if (mycpu()->noff != 1) panic("sched locks");noff 是当前 CPU 的关中断嵌套计数。正常运行时它应该等于 1,也就是持有 p->lock 带来的嵌套。
如果你看到这个 panic,说明当前进程持有多个锁的时候就试图切换线程。这几乎可以肯定是一个锁粒度问题——你在持有某个驱动锁或文件系统锁时调用了 sleep 或 yield。
这个问题的本质是死锁风险。如果进程持锁睡眠,而这个锁恰好被唤醒它的代码所需,那这个进程永远不会醒。更隐蔽的是,如果两个进程持锁互等,系统直接卡死。
排查方法:panic 时 GDB 查看 backtrace,定位到最后一个 acquire 是在哪把锁上。然后检查这个锁的获取位置和释放位置,看看是不是某个函数里 acquire 了锁但中途调用了 sleep。
7.3 多核调试时的经典竞态
xv6 支持多核(默认 3 个核),调试线程切换时最容易遇到的竞态是"进程同时出现在两个核上"。
症状:某个进程的 p->state 在 RUNNING 时被调度器选中,或者 backtrace 里一个进程跑在两个 CPU 上。
原因基本就一种:某个切换路径没有正确持有 p->lock。比如代码里走了 swtch() 但没有先 acquire(&p->lock),或者 sched() 返回后没有正确释放。
检查方法很简单,直接在 swtch 调用点前后加断言:
if (!holding(&p->lock)) panic("swtch without p->lock");7.4 调试线程切换的实用技巧
- 用 QEMU 的 monitor 命令 info registers 查看当前 CPU 寄存器,确认切换后的 sp 指向哪个栈
- 用 GDB 在 swtch 的 ret 指令上打断点,每次命中都打印 ra 和 sp,你能直观看到切换链
- 在 scheduler() 的 swtch 返回点打断点,理解调度器反复流转
- 遇到奇怪死锁时,先检查是不是在锁内 sleep
- panic 信息里的地址解析其实很有用,结合 addr2line 能定位到具体行
还有一个常用技巧,是在 sched() 和 scheduler() 里各加一个短日志,打印 pid 和当前栈顶:
printf("sched: pid=%d sp=%p\n", p->pid, r_sp());日志里如果出现同一个 pid 在两个 CPU 上同时运行,必定是锁没持对。
8. 结语:线程切换不是一个函数,而是一种思路
LEC 11 的标题是 Thread switching,但这一节课真正教的东西,不是一个 swtch 函数的实现细节,而是操作系统里最朴素也最核心的思路:任何并发问题,本质上都是执行流的暂停和恢复问题。把一个执行流的现场压缩成一段内存,再把另一段内存还原成执行流,CPU 就完成了"分身术"。
我个人在实际调试 xv6 时体会最深的一点是:不要试图"想象"线程切换的过程,而要依赖断点和寄存器值。切换发生后,每个寄存器的值都有明确来源,不是从某个函数调用栈里自然推出来的,而是从 context 里硬生生加载回来的。想通这一点,很多怪异现象就不怪异了。
如果再给你一个扩展方向:理解了 xv6 的单核调度器,可以继续看多核调度、优先级调度、甚至用户态线程库(比如 ucontext 或协程)的实现。它们的基本骨架和 swtch 都是同一套思路——保存现场、切换栈、恢复现场。只是保存的东西更多或更少。掌握了这节课,你等于掌握了所有并发执行流切换的通用原语。