ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

进程切换到底切了什么?从ucontext到手写switch_to

进程切换到底切了什么?从ucontext到手写switch_to 课堂练习3.4 的题目是进程的切换但是真正动手写一遍才会明白进程切换根本不是换个函数接着跑这么轻巧的事。我前后写了三版第一版用setjmp/longjmp硬凑跑到第二轮就崩第二版换成ucontext切换确实跑通了但打印出来的东西总比预期多一行第三版把栈、寄存器、返回地址这些底层的零件一个一个对上才算把它从能跑变成讲得清。这篇就按我当时踩坑的顺序来写先从最小的用户态切换器跑通机制再对照真实内核里schedule()和switch_to的位置最后讲清楚那些课本上只写一句保存现场、恢复现场、实际却能让人调一晚上的细节。适合正在做操作系统课程实验、或者想真正搞懂上下文切换到底切了什么的同学也适合已经会用pthread、但对底层一无所知、想补这一课的开发者。1. 把切换这两个字拆开到底切走了什么1.1 函数调用、特权级切换、进程切换是三件不同的事很多人第一次做这个练习脑子里默认切换就是跳转。但你在代码里写一个callCPU 干的事情只是把返回地址压栈、跳到目标地址栈还是同一根栈地址空间还是同一套特权级也没变。这跟进程切换完全是两码事。把三件事并排放在一起看差别会非常直观事件栈的变化地址空间特权级谁发起普通函数调用同一根栈压入返回地址不变不变程序自己系统调用/中断从用户栈切到内核栈不变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_toucontext帮你把现场保存在哪、怎么恢复都封好了所以你会觉得也没多难。真正的理解发生在你把它换成自己写的汇编之后。下面这段是我照着经典教学内核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/$$/statuspidstat -w的输出里有两列特别值得琢磨cswch/s是自愿切换进程自己在等 I/O、等锁、主动 sleepnvcswch/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 章那句话——切换的本质是换栈。栈都换了那么这行代码执行完的语义自然也跟着换到了另一根栈上。最后分享一个我自己验证切换逻辑是否真的正确的小办法让每个任务在切换前后各输出它的任务号和一段它自己私有的、写在栈上的数组内容跑上几千次之后比对。如果某个任务的私有数组在某次切换后出现了别的任务的数据那不用怀疑栈是共用的或者被踩了。这个办法笨但它能让你对栈隔离这件事建立起肌肉记忆比读十遍课本都管用。
返回列表