☰
进程切换到底切了什么?从ucontext到手写switch_to
2026/9/30 7:51:47 网站建设 项目流程

课堂练习3.4 的题目是"进程的切换",但是真正动手写一遍才会明白,进程切换根本不是"换个函数接着跑"这么轻巧的事。我前后写了三版:第一版用setjmp/longjmp硬凑,跑到第二轮就崩;第二版换成ucontext,切换确实跑通了,但打印出来的东西总比预期多一行;第三版把栈、寄存器、返回地址这些底层的零件一个一个对上,才算把它从"能跑"变成"讲得清"。这篇就按我当时踩坑的顺序来写:先从最小的用户态切换器跑通机制,再对照真实内核里schedule()和switch_to的位置,最后讲清楚那些课本上只写一句"保存现场、恢复现场"、实际却能让人调一晚上的细节。适合正在做操作系统课程实验、或者想真正搞懂"上下文切换到底切了什么"的同学,也适合已经会用pthread、但对底层一无所知、想补这一课的开发者。

1. 把"切换"这两个字拆开:到底切走了什么

1.1 函数调用、特权级切换、进程切换是三件不同的事

很多人第一次做这个练习,脑子里默认"切换"就是跳转。但你在代码里写一个call,CPU 干的事情只是把返回地址压栈、跳到目标地址,栈还是同一根栈,地址空间还是同一套,特权级也没变。这跟进程切换完全是两码事。

把三件事并排放在一起看,差别会非常直观:

事件栈的变化地址空间特权级谁发起
普通函数调用同一根栈,压入返回地址不变不变程序自己
系统调用/中断从用户栈切到内核栈不变ring3 到 ring0程序或硬件
进程切换从 A 的内核栈切到 B 的内核栈可能改变(CR3)内核态内部切换调度器

三者的共同点是"要保存一些东西",但保存的东西完全不是一个量级。函数调用依赖编译器约定,谁保存哪些寄存器是提前说好的;进程切换则要把"这台 CPU 现在正在用的一切"都记下来,因为接下来这个进程可能要等几十毫秒甚至几秒才会被重新调度,期间 CPU 会被别的进程用得一塌糊涂。你不能指望那么久以后寄存器里的值还在,所以必须存进内存。

这里有个特别容易混淆的点:系统调用引起的"用户态到内核态"并不等于进程切换。一次read()进内核,绝大多数时候只是进了内核走一圈,然后原路返回同一个进程,这中间根本没换过"当前进程"这个身份。只有当调度器决定"该换人了",才会发生真正的切换。课程练习里最常见的错误,就是把这两件事当成一件,于是在中断处理里到处乱切,最后状态全乱。

1.2 一份完整的硬件现场清单,以及哪些必须存

我建议你先把"现场"这个词具象化。一份完整的 x86 硬件现场,大致是这些:

类别具体成员是否必须保存不保存的后果
通用寄存器eax、ebx、ecx、edx、esi、edi、ebp部分必须局部变量、循环计数器错乱
段寄存器cs、ds、es、fs、gs、ss视实现而定数据访问段基址错误,直接崩
指令指针eip必须不知道该从哪里继续执行
栈指针esp必须栈彻底错位,无法返回
标志寄存器eflags必须条件跳转结果随机,IF 位丢失导致中断状态异常
页目录基址CR3进程切换必须,线程切换不必用的是别人的地址空间,越界访问
浮点/SIMDFPU、XMM 等进程必须,内核内部通常可延后浮点计算结果诡异漂移

看到这张表,你应该能理解为什么"保存现场"这四个字在课本里只有四个字,实际却是几十行代码。更关键的是,表里不是每一项都要在同一个地方存。在软件切换的实现里,你只重点保存"被调用者保存寄存器"(ebx、esi、edi、ebp)加上返回地址和栈指针,因为调用者保存寄存器在切换函数的调用点已经被编译器安排好了。这个取舍是整件事最精妙的地方,后面第 2 章会详细拆。

1.3 内核栈才是整个切换过程的真正锚点

我最开始一直想不通的一个问题是:进程那么多,每个进程的寄存器值存哪儿?如果都存在 PCB 的固定字段里,那 PCB 结构体岂不非常大?

后来才想通:寄存器值是压在自己那根栈上的,PCB 里通常只存一个栈顶指针。每个进程都有自己的内核栈,切换的时候,正在运行的进程把自己的寄存器按约定压到自己的内核栈顶,然后把当前 esp 写进 PCB;接着从下一个进程的 PCB 里读出它上次存下的 esp,把 esp 指过去,再按相反顺序把寄存器弹回来。栈指针一变,"现场"就整个换了一套。

这个设计的好处是:保存现场几乎零额外开销,不需要任何分配,压栈就行;PCB 结构体也保持得很小。代价是你必须保证每个进程的内核栈是独立且足够的,一旦栈溢出或者栈被复用,切换就会以各种莫名其妙的方式崩掉——我在第一版里就是死在这里,第 5 章会详细复盘。

2. 三十行代码先跑通:用户态里造一个最小切换器

2.1 为什么不建议一上来就写内核

课程练习的标题里只有"进程的切换"五个字,没有限定实现层级。我看到有同学直接去改引导扇区、写 GDT 和 TSS,折腾两周连屏幕输出都没搞定。其实完全可以换个顺序:先在用户态把一个"假的进程切换器"写出来,把保存什么、恢复什么、第一次怎么进入这些逻辑全部验证清楚,再去对应真实内核的结构,会快非常多。

用户态的好处是调试手段齐全,能打印、能 gdb、能 Valgrind;坏处是它终究只是"模拟",没有 MMU 隔离,没有真正的特权级切换。但对于理解"切换本身"这个机制,它足够了。我个人的经验是,用户态版本跑通之后,再看switch_to的汇编,几分钟就能读懂,因为你要看到的只是"同样的思路换了个实现"。

2.2 swapcontext 版本的骨架与逐行解释

先上一个能直接编译运行的骨架。它用ucontext做了两个协作式的任务,互相让出 CPU:

#define _GNU_SOURCE #include <ucontext.h> #include <stdio.h> #include <stdlib.h> #define STACK_SIZE (64 * 1024) #define NTASK 2 static ucontext_t main_ctx, task_ctx[NTASK]; static char *stk[NTASK]; static int cur; static void task_body(int id) { for (int i = 0; i < 3; i++) { printf("task %d: round %d\n", id, i); cur = (cur + 1) % NTASK; swapcontext(&task_ctx[id], &task_ctx[cur]); /* 让出 CPU */ } /* 函数返回时按 uc_link 回到 main_ctx */ } int main(void) { for (int i = 0; i < NTASK; i++) { stk[i] = malloc(STACK_SIZE); getcontext(&task_ctx[i]); task_ctx[i].uc_stack.ss_sp = stk[i]; task_ctx[i].uc_stack.ss_size = STACK_SIZE; task_ctx[i].uc_link = &main_ctx; makecontext(&task_ctx[i], (void (*)(void))task_body, 1, i); } cur = 0; swapcontext(&main_ctx, &task_ctx[0]); printf("back to main\n"); return 0; }

这段代码里有三个点必须理解到位,不然换个环境就会翻车。

第一,getcontext之后再设置uc_stack,顺序不能反。getcontext抓的是当前这个时刻的上下文快照,之后你再改uc_stack和uc_link,改的是"将来用这个上下文时要用的参数"。

第二,makecontext做的事情本质上是在指定的那块新栈上人为构造一个现场,让第一次swapcontext过去的时候,CPU 看起来像是"从这个函数中间返回出来的"。入口函数地址、参数、返回地址全是被手工摆上去的。这就是为什么新任务第一次执行,看起来像凭空跳进了task_body。

第三,malloc出来的栈在整个任务生命周期内绝对不能free。uc_link指向main_ctx,意味着任务函数返回后直接跳回主上下文,这时第二个任务可能还在swapcontext里等着,它的栈必须还活着。这个坑非常隐蔽,因为它不会立刻报错,而是在某个特定时序下才崩。

顺便说一句这个骨架的局限:两个任务的循环次数一样,所以恰好能收尾;如果你把某个任务的循环次数改成 4,就会发现它跑完就直接回 main 了,另一个任务永远醒不过来。要解决就得加一个真正的调度器,用done标记 +setcontext统一回到调度上下文,而不是靠uc_link直接跳回 main。

2.3 把 makecontext 换成手写 switch_to

ucontext帮你把现场保存在哪、怎么恢复都封好了,所以你会觉得"也没多难"。真正的理解发生在你把它换成自己写的汇编之后。下面这段是我照着经典教学内核(xv6 那一脉)写的简化版:

.text .globl switch_to .type switch_to, @function switch_to: movl 4(%esp), %eax /* 第一个参数:保存旧上下文的地址 */ movl 8(%esp), %edx /* 第二个参数:新上下文的地址 */ pushl %ebp pushl %ebx pushl %esi pushl %edi movl %esp, (%eax) /* 把旧栈顶写回旧上下文 */ movl %edx, %esp /* 切到新栈 */ popl %edi popl %esi popl %ebx popl %ebp ret /* 从新栈上弹出的返回地址继续执行 */

配套的上下文结构体只需要五个字段:

struct context { unsigned int edi; unsigned int esi; unsigned int ebx; unsigned int ebp; unsigned int eip; /* 不显式压栈,靠 ret 弹出 */ };

为什么只存这四个通用寄存器?因为它们属于 System V 调用约定里的"被调用者保存"寄存器。也就是说,调用switch_to的那个 C 函数,天然就假设 ebx、esi、edi、ebp 在调用前后保持不变,而 eax、ecx、edx 这些调用者保存寄存器,编译器在调用点早就自己处理掉了。你把切换函数当成一个普通函数来调用,编译器就会自动帮你打理一半的现场——这是整个设计里最省事的一步棋,也是最容易被人忽略的一句"为什么"。

同样的道理,x86 下 XMM 寄存器全是调用者保存的,所以在用户态这种实现里,你不存浮点寄存器也不会立刻出错。但注意,这只在"调用约定之内"成立。真到了内核里切换两个用户进程,浮点状态是用户可见的、必须完整保存的,那时就躲不掉了。

2.4 第一次切换为什么像"凭空跳进函数"

这段汇编里有个非常妙的点值得单独讲:switch_to里没有显式的"跳回",它最后一条指令是ret。而ret做的事情是"从当前栈顶弹一个地址,跳过去"。

当一个任务是被调度器第一次选中时,它的上下文里那个"栈"是我们手工摆的:先在高地址放一个入口函数地址,然后依次放上 ebp、ebx、esi、edi 的初值(通常是 0),最后把 esp 指到 edi 上。这样popl四次之后,栈顶正好是那个入口函数地址,ret一执行就直接跳进去了。整个过程没有"返回到某个函数"的语义,它更像是一次精心设计的跳转。

理解这一点之后,很多诡异现象就有解释了。比如"切过去第一次就段错误",八成是因为你手工摆的 esp 没做过对齐,或者摆错了槽位——把返回值放在了 ebp 的位置上,ret自然弹出了垃圾。我第二次调试时就是因为少压了一个寄存器,导致弹出来的地址指向一片数据段,报了个莫名其妙的错。

3. 从小玩具到真调度器:切换在真实内核里的位置

3.1 schedule() 只做决定,switch_to 才动手

真实内核里,这两件事是严格分开的。schedule()负责"选谁":扫描就绪队列,按照某种策略(时间片轮转、优先级、完全公平调度等等)挑出一个候选者,把它设成 current,然后把剩下"真的换过去"这件事交给switch_to。分层的好处是策略和机制解耦,换调度算法不动切换代码,反之亦然。

这个分层带来的一个实际约束是:切换点必然是函数调用边界。因为你要调用switch_to,编译器已经把调用者保存寄存器安排妥当了,switch_to只需要管好那四个被调用者保存寄存器。换句话说,你不能"在任意一条指令中间"切换,只能在编译器认为"寄存器状态可以重新约定"的位置切换。内核里所有可能导致切换的地方——时间片到、等待 I/O、主动让出——最后都会收敛到一次schedule()调用上。

3.2 硬件切换与软件切换:TSS 方案和栈方案的分水岭

教材上通常会给两种实现思路,但它们看起来特别不像,容易让人以为只有一种是对的。

早期 x86 教学内核(比如 Linux 0.11)走的是硬件切换路线:每个任务有一个自己的 TSS(任务状态段),切换时执行一条ljmp到目标 TSS 的选择子,CPU 硬件自动把当前所有寄存器写进旧 TSS、从新 TSS 读出来。代码极其简单,几行汇编搞定。

现代内核走的是软件切换路线:不用 TSS 做寄存器保存,寄存器压栈,PCB 里只存栈指针,ljmp那条路根本不走。

维度硬件切换(TSS + ljmp)软件切换(压栈 + switch_to)
保存位置CPU 自动写入 TSS手动压内核栈,PCB 只存 esp
保存范围全套寄存器与段寄存器按需,最少四个寄存器
切换耗时一次写上百字节,偏慢十几条指令,快
可移植性绑死 x86 的 TSS 机制抽象干净,可移植
调试友好度现场不用自己管,但错了难查自己管,错了容易定位

做课堂练习时如果题目没限定,我更推荐软件切换那条路,因为它逼你把"到底保存了什么"想清楚;TSS 方案太自动化,写完了你可能还是不知道寄存器都去哪儿了。但如果你的实验框架已经搭好了 TSS,那就顺着框架走,别自己造轮子。

3.3 特权级栈怎么换:TSS.esp0 与新进程的第一条指令

即使在软件切换方案里,TSS 也没彻底退休,它还有一件必须干的活:告诉 CPU,从 ring3 进 ring0 时该用哪根内核栈。

x86 的机制是这样的:当发生中断或陷阱导致特权级从 3 变到 0 时,CPU 会自动从当前任务的 TSS 里读取ss0和esp0,把用户栈的 ss、esp 压到新的内核栈上,最糟的是它会保存被中断的用户进程的段寄存器。而 TSS 是每个进程一份的,这就给了你一个关键的操作点:每次切换到某个进程时,必须把 TR(任务寄存器)指向它自己的 TSS,或者至少把它 TSS 里的esp0更新成它自己的内核栈顶。

这一步忘了会怎样?你的进程 A 在用户态触发一个时钟中断,CPU 却把现场压到了进程 B 的内核栈上。表面上看一切正常,等 100 次切换之后,某个进程的内核栈就被写穿了,然后以一种完全随机的方式崩溃。这种 bug 最难查,因为它和"谁先触发中断"强相关,换个时间跑就换一个死法。

至于"新进程的第一条指令",软件切换方案里通常是这么摆的:在进程创建时把上下文里的 eip 设成一个固定入口(很多内核叫forkret之类的名字),它的职责是把内核栈上早已准备好的 trapframe 恢复出来,然后执行iret,正式进入用户态。也就是说,新进程并不是从用户代码开始的,而是从内核里一段"专门负责第一次出内核"的胶水代码开始的。这个细节课本上一般一笔带过,但你自己写的时候如果漏了这段胶水,新进程就会以一个"内核栈状态不对"的身份直接跑用户代码,必崩。

3.4 中断上下文里的切换边界

还有一个必须明确的边界:中断上下文里不切换,只做标记。

时钟中断进来,处理程序把当前进程的时间片减一,如果减到 0,就给进程打一个"需要重新调度"的标记,然后正常返回。真正的切换发生在"中断返回用户态之前"那个窗口——内核检查标记,如果被设置,就调用schedule()。

为什么不在中断处理函数里直接切?因为中断处理函数本身还在用着当前进程的内核栈,它有自己的局部变量和调用链深度。你在这个深度上切走,这个进程下次被恢复时,就得从同样的深度恢复,这个耦合极其危险。把切换放在"即将返回用户态"的那个点上,栈深度是可预测的、稳定的,这才是"安全点"。

4. 让切换看得见:计数、计时与症状对照

4.1 自己埋点:计数器 + 时间戳

光看代码逻辑对没对,其实看不出来。我强烈建议在switch_to前后各加一次埋点,把切换次数和耗时都统计出来。第一步先加个计数器:

static volatile unsigned long switch_count; /* 每次切换 +1 */ /* 在调 switch_to 之前 */ switch_count++;

跑一遍,看看实际发生的切换次数是不是和你按逻辑推算的一致。这一步就能筛掉一大类 bug:如果次数远大于预期,说明有进程在自己让出自己,形成了空转;如果次数始终是 0,说明你根本没切成功,只是打印看着像切了。

第二步加计时,用 TSC 读周期数:

static inline unsigned long long rdtsc(void) { unsigned int lo, hi; __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi)); return ((unsigned long long)hi << 32) | lo; } static volatile unsigned long long switch_cycles; unsigned long long t0 = rdtsc(); switch_to(old, new); switch_cycles += rdtsc() - t0;

这里必须提醒一句:TSC 计的是 CPU 周期,要换算成时间得除以主频;而且在虚拟机和某些省电策略下 TSC 可能不稳定,测出来的数字只适合做相对比较,比如"切 CR3 比不切慢多少",不适合当成绝对性能数据往报告里写。我当年就是拿虚拟机里测出的数字去写实验报告,被助教一眼看穿,因为那个数量级明显不对。

4.2 在真实 Linux 上对照着看:/proc 与 pidstat

课堂练习里的计数器只能看到你自己内核的数据。想建立"量级感",最好再到真实系统上看一眼。Linux 把这些统计暴露得很直接:

grep ctxt /proc/stat # 系统启动以来的总上下文切换次数 pidstat -w 1 # 每个进程每秒的自愿/非自愿切换次数 grep ctxt_switches /proc/$$/status

pidstat -w的输出里有两列特别值得琢磨:cswch/s是自愿切换(进程自己在等 I/O、等锁、主动 sleep),nvcswch/s是非自愿切换(时间片用完被抢走)。你在自己写的调度器上对照这两类,就能理解为什么"抢占式"这三个字意味着什么——非自愿切换全是抢占造成的。

4.3 四类典型症状与根因对照表

这是我整理出来的一张"看到症状就能定位方向"的表,比一行行读代码高效得多:

症状最可能的根因先查哪里
打印内容比预期多一行或重复两个任务共用同一根栈;输出缓冲没刷新每个任务是否独立分配栈;printf后是否 flush
切过去第一次就段错误手工摆的栈槽位错、esp 未对齐对齐 esp;核对ret弹出的位置
跑几百次后随机崩溃内核栈/任务栈溢出或栈被复用栈顶放哨兵值,看是否被改写
任务跑完再也不回来缺统一调度器,靠uc_link收尾加 done 标记,统一setcontext回调度上下文
局部变量值的"记忆"不对setjmp回了已失效的栈换成自建栈的swapcontext或手写切换

这张表的价值在于:它把"代码看起来对但行为不对"这类玄学问题,压缩成了几个可检查的物理事实。

5. 课本不会提醒、但写下去就会撞上的五个坑

5.1 第二版崩掉的真正原因:栈被回收了

我第二版用的是setjmp/longjmp。写法是这样的:在 main 里对每个任务setjmp一次,把jmp_buf存进数组,然后从一个循环里根据当前任务号longjmp过去。逻辑看起来无懈可击,跑起来第一次切换也成功,第二次就崩。

原因在于:longjmp只会恢复寄存器和栈指针,它不会帮你重建栈上的内容。而你setjmp的那个点,位于 main 函数调用栈的某一层;当 main 继续往下执行、那层调用返回之后,那块栈空间已经被后面的调用覆盖了。这时候再longjmp回去,恢复的 esp 指向一片已经被写脏的内存,返回地址、局部变量全是垃圾。

这个坑的本质是:setjmp/longjmp是"同一根栈上的回跳",它天生不支持多个执行流。想在一个进程里模拟多个执行流,你必须给每个流独立的栈空间,这就是为什么ucontext要显式要求你提供uc_stack,也是为什么手写switch_to时必须自己malloc一块栈。我后来给自己总结了条经验:凡是看到setjmp出现在"协作式任务切换"的代码里,先默认它是错的,除非代码里每个任务都跑在独立栈上。

5.2 保存和恢复的顺序必须严格对称

压栈是"后来的在前",出栈必须反着来。push ebp, ebx, esi, edi之后,出栈顺序就必须是pop edi, esi, ebx, ebp。写成同样顺序,第一次切换可能碰巧没事(因为很多寄存器初值都是 0),到第二次才暴露,表现是某个任务的循环跳转乱掉或者返回地址被当成数据。

我的排查方法很土但很有效:在切换前后各打印一组寄存器的值,切换出去之前记录 ebx/esi/edi/ebp,切回来之后再打印一次,比对是否一致。不一致就是顺序或槽位错了。这个动作做一次,比盯着汇编看半小时管用。

5.3 编译器优化与 volatile

在协作式切换的场景下,switch_to的调用点对编译器来说是个"普通函数调用"。它会假设你在调用前后逻辑是连续的,于是可能把某些变量缓存在寄存器里(比如那个cur索引)。一旦切换发生,另一个执行流改了cur,而当前流还从寄存器里读旧值,轮转就错位了。

解决办法是把跨执行流共享的变量声明成volatile,或者在汇编里显式加内存屏障。另外,编译参数也要注意:-O2加上手写内联汇编时,如果 clobber 列表没写全,编译器可能把某个值一直放在寄存器里不落栈,导致恢复出来的现场是旧的。我一般的做法是,切换函数单独放一个.S文件,声明成普通外部函数,让编译器老老实实按调用约定处理,别让内联把优化空间留给它。

5.4 浮点与 SIMD 寄存器:谁来存

前面说过,用户态这种实现里 XMM 寄存器不用管,因为调用约定里它们是调用者保存的。但这里有个容易搞混的边界。

如果你的目标是真正切换两个用户进程,用户代码完全可能在 XMM 寄存器里放着一个正在算的浮点中间结果,而用户代码本身根本不知道自己被切换了,它不会保存任何东西。所以这时候浮点状态必须由内核在切换时保存——通常用fxsave/fxrstor一整块搬走。现代内核还用了个讨巧的懒加载策略:不是每个进程都立刻存,而是先设一个标志,等这个进程真的要执行浮点指令时再触发异常,那时候才存。这样纯整数运算的进程就完全不用付这笔开销。

所以你写练习的时候要先问自己一句:我这个切换器,切的是"同一进程内的执行流"还是"独立的进程"?答案不同,浮点要不要存就完全不同。

5.5 关中断窗口与就绪队列竞态

最后一个坑是自己写内核版本时才会遇到的。假设你在进程 A 的内核里准备切换,把 A 的状态改成"就绪"、挂回队列、选下一个 B——如果这个过程可以被中断,麻烦就来了:时钟中断可能在"已经把 A 挂回队列,但还没真正切走"的瞬间打进来,中断处理里又调了一次调度,于是 A 被选中、又切回 A,栈上出现两层嵌套的调度帧,状态机彻底错乱。

标准做法是把"改状态、选下一个、真正切走"这几步包在一个关中断的临界区里,而且这个临界区必须一直延续到switch_to真正完成栈切换为止——也就是说,中断的重新打开发生在下一个进程的上下文里,而不是当前进程的上下文里。这一点我第一次看真的觉得反直觉:一个"关中断",却在另一个进程里"开中断"。

理解它的钥匙还是回到第 1 章那句话——切换的本质是换栈。栈都换了,那么"这行代码执行完"的语义,自然也跟着换到了另一根栈上。

最后分享一个我自己验证切换逻辑是否真的正确的小办法:让每个任务在切换前后各输出它的任务号和一段它自己私有的、写在栈上的数组内容,跑上几千次之后比对。如果某个任务的私有数组在某次切换后出现了别的任务的数据,那不用怀疑,栈是共用的或者被踩了。这个办法笨,但它能让你对"栈隔离"这件事建立起肌肉记忆,比读十遍课本都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询