☰
进程切换与上下文切换:内核栈、调度器和切换开销实战解析
2026/9/30 8:42:19 网站建设 项目流程

1. 把"进程切换"这个词拆开:它到底在切什么

课本上给"进程切换"下的定义通常只有一句话:保存当前进程的上下文,恢复另一个进程的上下文。第一遍读觉得懂了,真到代码里加断点单步走一遍,才发现这句话里每一个词都需要展开——上下文具体指哪些寄存器、保存在哪里、谁负责恢复、恢复之后从哪条指令继续跑。课堂练习 3.4 这类题目,本质上就是逼你把这句话落到实处:能在内核里指出保存点、恢复点,能数出切换了几次,能解释清楚为什么这一次切换是必需的而不是多余的。

先把最容易含糊的三个概念区分开。模式切换(用户态进内核态,或者内核态返回用户态)只改变 CPU 特权级和栈指针,进程本身没换,页表没换,PCB 没有任何变化。上下文切换是保存和恢复 CPU 寄存器状态,可以发生在同一个进程的线程之间,也可以发生在不同进程之间。进程切换比前两者多了一步:除了寄存器和栈,还要切换地址空间(也就是页表基址寄存器)。这个区别在实际调优时非常关键——同一进程内的两个线程切换,因为共享页表,TLB 和 cache 的命中率影响小得多;跨进程切换则要承受地址空间切换带来的额外代价。

下面这张表是我自己在复习时整理的对照关系,做练习之前建议先把这张表默写一遍:

维度模式切换同进程线程切换跨进程切换
特权级变化有无有(返回用户态时)
通用寄存器保存发生(进内核时)发生发生
内核栈切换切到该进程的内核栈切到该线程的内核栈切到目标进程内核栈
地址空间(页表)切换不切不切切
TLB / cache 影响小小大
典型触发点系统调用、中断调度器选中同进程线程调度器选中其他进程

需要说明的是,不同教材的练习 3.4 要求不完全一样。有的是基于教学内核(比如 xv6、Linux 0.11 这类精简内核)加计数器并观察;有的是在真实 Linux 上写用户态程序测开销;也有的只是要求在纸上画出状态迁移图和栈布局。我下面按最常见的形式展开,你对照自己手上的题目裁剪即可。

1.1 切换从来不是你主动发起的

新手最常有的一个误解是:进程切换是某个"切换函数"主动调用的。实际上在绝大多数情况下,进程切换是被动发生的。真正的情况是——时钟中断来了,中断处理程序发现当前进程的时间片用完了,于是在返回用户态之前的那个时机,把"需要重新调度"的标志置上,然后在返回路径上检查这个标志,才进入调度器。整个过程像这样:

  1. 时钟芯片发出中断信号,CPU 打断当前执行的指令流;
  2. 硬件自动保存一部分现场(返回地址、标志位),跳到中断入口;
  3. 内核入口代码把通用寄存器压到当前进程的内核栈上;
  4. 中断处理逻辑更新统计、判断是否需要抢占;
  5. 在返回用户态的途中检查重新调度标志;
  6. 满足条件则调用调度器,选出下一个进程;
  7. 切换内核栈和地址空间,恢复目标进程的寄存器;
  8. 目标进程沿着它自己上次被打断的位置继续。

注意第 8 步的措辞——"它自己上次被打断的位置"。这就是为什么一个进程被换下去再换回来,它完全不知道自己中间被暂停过,就像什么都没发生一样。特权级和栈的切换发生在第 3 步之前,那些是你写代码时看不见的硬件行为。

另外还有一类是主动放弃 CPU:进程调用 sleep、等待 I/O、等待锁、读取管道没有数据,这些都会让进程从运行态转入阻塞态,然后在系统调用返回用户态之前直接调用调度器。这种情况下的切换是"自愿切换"。区分自愿和非自愿,是后面排查性能问题必须掌握的技能。

1.2 被保存下来的到底是什么东西

问题的关键在这里:切换时要保存的不是"进程的所有信息",因为进程的所有信息早就在 memory 里了,代码段、数据段、堆、栈都在。真正需要保存的,是只存在于 CPU 内部、一旦被别的进程改写就找不回来的那部分状态。

具体包括:

  • 通用寄存器:eax、ebx、ecx、edx、esi、edi 等,它们是进程计算的中间结果;
  • 栈指针:esp(32 位)或 rsp(64 位),指向当前内核栈的位置;
  • 指令指针:eip 或 rip,决定这个进程下次从哪条指令继续;
  • 标志寄存器:eflags / rflags,存着条件码和中断使能位;
  • 浮点和向量寄存器:现代 CPU 上这部分的体积比通用寄存器大得多,通常采用"惰性保存"策略,也就是只有真的用到了才存;
  • 地址空间标识:页表基址(cr3),或者 64 位下的 pcid 相关状态。

而下面这些通常不需要放进切换过程,因为它们本来就在内存里:打开的文件描述符表、内存映射信息、信号处理配置、进程优先级和调度参数、父子关系。这些统一放在进程控制块 PCB 里,切换时只是"指针换了一个",不需要逐字段拷贝。

这里有个很有意思的设计细节,值得单独说一下。在很多教学内核里,PCB 里并不直接内嵌一个巨大的寄存器快照,而是只放一个栈指针。因为所有需要保存的寄存器已经被压在当前进程的内核栈上了,只要把栈顶指针记下来,下次把这个栈顶指针恢复回去,再逐个弹栈,寄存器就全回来了。这就是为什么你翻 xv6 的struct context,会发现里面只有五六个字段,而不是几十个。

1.3 内核栈:切换真正的主角

如果把进程切换比作"换人上台",那么内核栈就是那个人的座椅——你离开时把身上的东西都掏出来放在椅子上,回来时从椅子上把东西拿回来。每个进程都有自己独立的内核栈,这是切换能够成立的基础。

为什么必须是独立的?因为内核代码执行到一半被打断时,局部变量、返回地址、保存的寄存器全都压在这段栈上。如果所有进程共用一个内核栈,下一个进程一进来就把上一个进程的栈内容踩掉了,等上一个进程被换回来,它的返回地址已经变成了别人的数据,直接执行到随机地址上去。

栈的独立性还带来一个推论:切换动作本身必须在栈上完成。你不可能在上一个进程的栈上恢复下一个进程的寄存器,所以这类切换代码总是用汇编手写,顺序是"先把当前栈指针存到旧 PCB、再把新 PCB 的栈指针装进 esp,然后开始弹栈"。一旦 esp 被改写,从下一行汇编开始,你已经在别人的栈上执行了。

2. 一次完整切换的时间线:从时钟中断到新进程的第一条指令

光看概念容易飘,我们把一次非自愿切换的全过程按时间顺序拆开。我建议你在读这一节的时候,打开自己的练习环境,在这些关键点上打上断点或者加打印,跟着走一遍。走一遍之后,你会对"上下文"这个词有完全不同的理解。

2.1 中断入口做了哪些脏活累活

中断发生的瞬间,CPU 做的事情极其有限:把当前标志寄存器和返回地址(也就是被打断的那条指令的地址)压到当前进程的内核栈上,然后根据中断号查表跳到对应的入口。注意,这里压栈用的是当前进程的内核栈,而不是某个全局栈——这个细节决定了后面一切都能对上号。

接下来是入口代码的活。不同架构写法不同,但逻辑一致:

  • 保存被调用者保存寄存器(以及部分调用者保存寄存器,取决于约定);
  • 保存段寄存器或者用户栈指针;
  • 从硬件状态里读中断原因,必要时切换数据段;
  • 调用 C 语言写的分发函数。

之所以要在汇编里做这一步,是因为 C 编译器在使用寄存器时不会替你保存现场,你必须在进入 C 代码之前自己把该压的压好。这段代码通常位于entry.S之类的文件里,形如一连串push指令。它们看起来枯燥,但每一次压栈都对应着后面"上下文"里的一个字段,是理解切换开销的直接依据。

中断上半场处理完之后,可能会做两件事:一是更新当前进程的时间统计,二是判断是否应该触发抢占。有的内核把判断逻辑做成一个宏,在中断返回路径上调用;有的直接在中断处理里就置标志位。不管哪种写法,最终都落到同一个地方——在返回用户态之前,检查是否需要调度。

2.2 调度器里的三件事:选谁、换栈、换地址空间

进入调度逻辑之后,事情反而清晰了,核心就三件:

第一件,选出下一个进程。这由调度策略决定:时间片轮转看剩余时间,CFS 看虚拟运行时间,实时调度看优先级和截止期。教学内核里这一步往往简单到几十行,遍历就绪队列比较优先级。真实内核里则是一棵红黑树加上一堆启发式规则。选不出来的时候,会退回到 idle 进程,让 CPU 进入低功耗状态等待下一个中断。

第二件,切换栈。这是整套机制的核心动作。伪代码大概长这样:

static void switch_to(struct task *prev, struct task *next) { if (prev == next) return; prev->state = READY; // 或由调用方提前设置 __switch(&prev->ctx, &next->ctx); // 保存 prev 的栈顶到 prev->ctx, // 装载 next->ctx 到 esp,开始弹栈 // 从这一行开始,"prev" 这个变量可能已经不是原来那个进程了 }

__switch是汇编函数,只干两件事:把寄存器压到旧栈上并把旧栈顶存进prev->ctx;把next->ctx装进栈指针,然后把寄存器弹回来。听起来简单,但里面藏着两个大坑,后面第 5 节会专门讲。

第三件,切换地址空间。如果两个进程不属于同一地址空间(比如不同进程而非同进程线程),就要更新页表基址寄存器。这一步是性能杀手:换了页表,绝大部分 TLB 表项立即失效,后面几条指令访问内存全都要走完整的页表遍历。64 位系统上有 PCID 这类机制来缓解,但代价依然存在。

三件事做完,__switch最后一条ret会把栈上保存的返回地址弹到指令指针里,CPU 跳过去继续执行——此时执行的已经是另一个进程了。

2.3 新进程为什么"总是从切换函数返回处继续"

这是练习里最值得琢磨的问题,理解了它,你对"上下文"的理解就到位了。

任何一个进程被换下去的时候,它正在执行的代码位置恰好就在__switch内部。所以它保存的返回地址,就是__switch里ret指令要用的那个地址。等它再被换上来,弹栈得到的就是同一个地址,于是它自然从__switch返回处继续。对进程来说,它调用的switch_to函数"刚刚返回",中间经过的时间它完全感知不到。

对于刚刚第一次被调度的新进程,情况要特殊一点。它的上下文是被人为构造的——在创建进程时,内核会在这段栈上"假装"压入一组初值,返回地址指向一个特殊的入口函数(xv6 里叫forkret,Linux 0.11 里是ret_from_sys_call相关路径)。所以新进程第一次被调度时,看起来就像它以前调用过__switch并且现在返回了一样,然后顺着这个入口一步步走到用户态,开始执行真正的程序。

Linux 0.11 用的是另一种思路。它借助 x86 的硬件任务切换:执行一条远跳转到目标进程 TSS 段选择子的指令,CPU 就会自动把当前所有寄存器写进旧 TSS,再从新 TSS 里读回全部寄存器,包括 cr3(所以地址空间也一起切了)。这个方案写代码极少,但硬件任务切换本身很慢(要访问内存里的 TSS,还要做一堆检查),所以现代 Linux 早就改成软件切换了。有意思的是,TSS 并没有被完全废弃——64 位下每个 CPU 还保留一个 TSS,但只用来在用户态陷入内核时告诉硬件"内核栈在哪",跟任务切换已经没关系了。在练习报告里把这段演进写清楚,通常是个加分项。

3. 照着代码走一遍:教学内核里的 switch_to 怎么写的

纸上谈兵没意义,这一节我们把两套经典实现拆开看。你可以挑一套对照自己的练习环境。

3.1 xv6 的 swtch:五行汇编,句句有讲究

xv6(32 位版本)的上下文结构只有一个字段很小的一家子:

struct context { uint edi; uint esi; uint ebx; uint ebp; uint eip; };

对应的切换代码大致是这样:

# void swtch(struct context **old, struct context *new); swtch: movl 4(%esp), %eax # eax = old(指向旧 context 指针的地址) movl 8(%esp), %edx # edx = new pushl %ebp pushl %ebx pushl %esi pushl %edi movl %esp, (%eax) # 把当前栈顶存进 *old movl %edx, %esp # 装载新栈顶 popl %edi popl %esi popl %ebx popl %ebp ret

四个push的顺序和结构体字段的顺序是反着的,这不是笔误。压栈是从高地址往低地址走,所以最后压入的 edi 在最低地址;而 C 结构体字段在内存里是从低地址往高地址排,所以 edi 排第一。两边一配,struct context的头几个字段正好和栈上的布局一一对应。这个细节在练习题里经常被拿出来考:"如果把 push 顺序改成 ebp、ebx、esi、edi,程序会怎样?"答案是会跑到错误的位置去,因为恢复时的 pop 顺序没变,读到的值全错位了。

栈布局对照表如下(以保存下来的栈顶为偏移 0):

偏移内容来源
0edi最后的 pushl %edi
4esipushl %esi
8ebxpushl %ebx
12ebppushl %ebp
16eipcall swtch 时由 call 指令压入的返回地址

只有五个字段,说明 xv6 的编译器约定里,调用者保存寄存器由调用者在调用前自己处理,swtch只需要管被调用者保存的那几个。这也是为什么切换开销在不同编译选项下会有差异——优化级别变了,调用者保存寄存器的使用情况就变了。

如果你用的是新版的 RISC-V 版本 xv6,结构体换成了ra、sp、s0到s11,切换代码在swtch.S里用sd/ld成对指令完成。逻辑完全一样:存栈指针到旧上下文、装新栈指针、恢复被调用者保存寄存器、ret。换架构不换思想,这句话在这里体现得特别明显。

3.2 Linux 0.11 的 switch_to:硬件任务切换的完整套路

Linux 0.11 的宏定义在sched.h里,核心是一条远跳转加上几个条件判断。它依赖几样硬件设施:

  • TSS(任务状态段):每个任务一个,里面存着全套寄存器值、内核栈指针、cr3;
  • GDT 里的 TSS 描述符:远跳转的目标;
  • ljmp 指令:触发硬件任务切换。

执行流程是:先把current指针交换成新任务,然后远跳到新任务的 TSS 选择子。CPU 一看目标是个 TSS 描述符,就自动保存现场、加载新的上下文。有个细节很妙——新任务恢复后的 eip 恰好是 ljmp 后面那条指令的地址,因为硬件在保存现场时,保存的是"下一条要执行的指令"的地址。所以新任务被第一次调度时,看起来就像它刚刚执行完那条 ljmp 一样,然后继续往下走。

这套方案还有个附带好处:地址空间切换是硬件顺带做的,因为 TSS 里有 cr3 字段,CPU 在加载任务状态时会连页目录基址一起换掉,不用写代码。缺点是慢,而且要占用 GDT 表项、有任务数量限制,所以后来被彻底抛弃了。

对比两套实现,能总结出一条很实用的判断原则:如果某项硬件功能提供的性能不如软件实现,那它最终一定会被软件取代,只保留那些绝对必要的部分。TSS 就是活生生的例子,硬件任务切换被删了,但 TSS 里"提供内核栈指针"这个功能被保留了下来。

3.3 手工把寄存器状态画出来

练习里经常有一道题:画出切换前后栈的变化。我自己的做法是在纸上画两条竖线代表两个内存区域,然后把每一次 push 都用一个小方格画出来,标注这个格子里放的是什么、是哪一行代码压进去的。这样做一遍,比看十遍代码都管用。

画的时候注意三个易错点:一是栈的增长方向(x86 上是向低地址),二是参数是压在哪条栈上的(32 位下 xv6 的参数压在被调用者的新栈上,这个顺序和 64 位寄存器传参完全不同),三是call指令压入的返回地址到底属于哪个上下文。把这三点搞清楚,你就具备了独立阅读任何一款内核切换代码的能力。

4. 课堂练习 3.4 的动手部分:观测、验证与量化

光理解不算完成练习,题目一般还要求你"观测并量化"。这一节给出三种由易到难的方案,你可以按自己的时间预算挑。

4.1 加打印加计数器:最土但最有效的观测手段

在教学内核里,最快的办法就是在调度函数里加一个全局计数器和一个打印。示意如下:

// 全局统计 static unsigned long switch_count = 0; static struct proc *last = 0; void scheduler(void) { for (;;) { for (int i = 0; i < NPROC; i++) { struct proc *p = &proc[i]; if (p->state != RUNNABLE) continue; if (p != last) { switch_count++; if (switch_count % 100 == 0) printf("[switch] %lu: %s -> %s\n", switch_count, last ? last->name : "(none)", p->name); last = p; } // ... 切换到 p } } }

有两个坑必须提前说:第一,不要在持有锁的情况下调用打印。很多教学内核的调度器是在持有进程表锁的循环里跑的,printf内部可能触发 I/O 等待进而触发调度,直接死锁。常见做法是先把要打印的内容存到缓冲区,等释放锁之后再输出。第二,计数器本身会改变被测对象。每多一条内存写,cache 行为就变一点。所以做定量分析时,务必把打印关掉只留计数,或者用统计采样的方式。

观测之后要回答的问题通常是:某个进程运行期间被切换了几次?每次切换前后系统处于什么状态?把这些问题回答清楚,比单纯的数字更有价值。

4.2 用 ftrace 看真实的内核切换流水

如果练习环境是完整的 Linux,ftrace是最好用的工具,不用写一行内核代码。打开调度事件追踪:

# 需要 root 权限,且内核开启了 CONFIG_FUNCTION_TRACER 等选项 echo 0 > /sys/kernel/debug/tracing/tracing_on echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable echo 1 > /sys/kernel/debug/tracing/tracing_on # 让某个程序跑一会儿,然后 cat /sys/kernel/debug/tracing/trace | head -50 echo 0 > /sys/kernel/debug/tracing/tracing_on

输出里每一行都会告诉你"上一个进程是谁、下一个是谁、优先级多少、因为什么被换下去"。我实测下来,这个工具最有用的一点是能直接看到切换的原因:是主动阻塞(比如等待 I/O),还是被抢占,还是退出。很多"程序卡顿"的排查思路就是从这些原因里来的。

再看统计信息:

grep ctxt_switches /proc/<pid>/status

你会看到两个数字:voluntary_ctxt_switches和nonvoluntary_ctxt_switches。前者是主动让出 CPU 的次数,后者是被强制抢占的次数。这个区分价值极高——一个进程自愿切换多,说明它在大量等待 I/O,是 I/O 密集型;非自愿切换多,说明它在被时间片频繁打断,可能是 CPU 密集且优先级配置不合理。

4.3 亲手测一次切换开销

想拿到具体的微秒数,最经典的做法是"两个进程用管道来回传递一个字节",用往返总时间除以切换次数。下面是我自己用的最小版本:

#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <time.h> static double now_us(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1e6 + ts.tv_nsec / 1e3; } int main(int argc, char **argv) { int n = argc > 1 ? atoi(argv[1]) : 100000; int p2c[2], c2p[2]; if (pipe(p2c) || pipe(c2p)) { perror("pipe"); return 1; } if (fork() == 0) { /* 子进程:收到就回,不做别的 */ char c; for (int i = 0; i < n; i++) { if (read(p2c[0], &c, 1) != 1) break; if (write(c2p[1], &c, 1) != 1) break; } _exit(0); } char c = 'x'; double t0 = now_us(); for (int i = 0; i < n; i++) { write(p2c[1], &c, 1); read(c2p[0], &c, 1); } double t1 = now_us(); printf("%d 次往返,平均 %.3f us/往返\n", n, (t1 - t0) / n); return 0; }

编译运行:

gcc -O2 -o ctsw ctsw.c # 绑到同一个核上,保证切换真的发生而不是并行执行 taskset -c 0 ./ctsw 200000

几个必须注意的点,不注意就会得出错误结论:

  • 一定要绑核。不绑核的话,父进程写完管道可能被子进程在另一个核上并行处理,你测到的就不是切换开销了;
  • 一次往返包含两次切换(父→子、子→父),还要叠加两次 read/write 系统调用的开销和管道缓冲区管理的开销,所以你算出来的是上界,不是纯粹的切换代价;
  • warm-up很有必要。第一轮循环可能会触发缺页、动态链接等一堆一次性开销,前几千次通常明显偏慢,建议丢掉前 10% 的数据再取平均;
  • 数字强依赖硬件。现代 x86 服务器上,一次纯切换的直接开销大致在 1 到 3 微秒量级,算上流水线重填、cache 和 TLB 失效的间接影响,端到端可能到 5 到 10 微秒。老机器、开了大量缓解措施的环境下会更慢。别照抄网上的数字,自己测一遍才有意义。

我建议你在练习报告里同时给出三组数:绑核与不绑核、开优化与关优化、单次与批量。三组数据之间的差异,本身就是一篇很好的分析。

5. 做这个练习最容易踩的五个坑

下面这些坑,我基本都亲手踩过至少一次。有些是概念不清导致的,有些是工具用错导致的,还有的是观测手段本身干扰了结果。

5.1 在不该切换的地方睡了

第一个坑最致命:在内核里调用了可能睡眠的函数,但当前处于不允许睡眠的上下文。

哪些上下文不允许睡眠?中断处理程序、软中断、持有自旋锁的临界区、显式关闭抢占的区域、RCU 读侧临界区。理由很直接:这些上下文没有可以保存和恢复的"进程上下文",或者说它们占用的资源在睡眠期间无法释放。你一旦在里面调用调度器,轻则内核发出警告、打印一大段栈回溯,重则直接死机。

识别方法很简单:凡是看到might_sleep、BUG: scheduling while atomic这类提示,基本就是这个毛病。修法也很直接——把可能阻塞的操作挪到可以睡眠的上下文里去,比如从中断处理里把工作丢给工作队列或者内核线程,从自旋锁临界区里挪出来,改用别的同步方式。

在教学内核里,这个坑往往表现为"加了打印就卡死"。因为打印函数内部可能等 I/O,而调度器正持有进程表锁。记住一句话:调度器是最不该被打扰的地方,任何在它里面执行的代码都必须是确定性的、非阻塞的。

5.2 switch_to 的"两次返回"陷阱

这个坑非常隐蔽,属于那种"不知道就永远想不明白为什么程序行为诡异"的类型。

切换函数执行完之后,看起来它"返回"了。但请仔细想:如果A调用switch_to换到B,那么当B后来又被换下去、A被换回来时,从A的视角看,那个switch_to调用刚刚返回。问题在于,这条返回路径可能由完全不同的执行流走到,而局部变量的值、当前 CPU、甚至prev指针指向的结构体内容,都可能已经变了。

在真实的 SMP 系统上这个问题更尖锐:A被换下去后,调度器可能让A跑到另一个 CPU 上去了,这时候prev指向的进程正在别的 CPU 上运行,甚至已经被销毁了。所以现代的切换宏必须处理"返回之后,我拿到的 prev 到底是谁"这个问题——通常的做法是让被调用者返回一个能标识上一个任务的数值,然后在宏里把这个返回值重新赋给局部变量,屏蔽掉编译器对局部变量做过期优化。

这个点在做课堂练习时通常用不上(单核、任务不迁移),但如果你在报告里能把这段说清楚,说明你真正理解了切换函数的语义边界,而不是只记住了汇编指令。

5.3 栈指针错位:段错误的最大来源

自己动手写切换代码时,最常见的崩溃原因是栈指针算错。典型症状是:编译通过、链接通过,一跑就 triple fault 或者跳到一个莫名其妙的地

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

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

立即咨询