从零编写操作系统 13 - 保存与恢复执行现场:先实现协作式任务
第 12 篇已经能按字节分配内核对象,但执行路径仍沿着同一份调用栈推进。
普通函数 A 调用 B 后,只有 B 返回,A 才能继续;如果 B 一直计算,其他工作就无法通过这条调用链取得 CPU。
本篇为两个内核任务各分配一张栈页,让任务在 task_yield() 处暂停,并在以后从同一次调用之后继续。
正常实验要求 A、B 各完成 128 轮计数,跨切换保留局部数组、被调用者保存寄存器和各自的中断允许状态,退出后归还两张栈页。
另一个镜像让 A 永远不调用 yield,检查 B 虽然已经就绪,计数仍然停在零。
这套实现只在单 CPU、ring 0、普通函数调用边界切换,没有时钟抢占,也没有独立用户地址空间。
暂停的调用链需要独立栈
函数调用会把返回位置放到栈上,局部变量与被保存的寄存器也可能占用栈空间。
若把一个执行中的函数暂停,再让另一个函数在同一片空间从头建立调用栈,前一个函数恢复时就可能读到被覆盖的数据。
独立栈使两条尚未结束的调用链能够同时保存在内存里;单 CPU 在任意时刻仍只执行其中一条。
第 10 篇的 page_alloc() 每次返回一张 4096 字节物理页。
第 11 篇保留低 64 MiB 恒等映射,因此栈页的物理地址可以按本实验约定转换为同值线性地址访问。
每个任务恰好使用一页,不依赖连续两次分配得到相邻页面,也不需要第 12 篇堆提供超过单对象上限的大块。
1 | |
这里三个槽包含 bootstrap,即运行内核初始化与演示管理代码的原始执行流。
它也参加调度,所以“两个计数任务交替运行”并不意味着每次都从 A 直接切到 B。
正常轮转是 bootstrap → A → B → bootstrap,两个计数任务之间没有另一个计数任务插入。
栈彼此独立只保证调用链有不同的存储区域。
三个任务仍共享页表与内核权限,A 的错误指针仍可能写坏 B 的栈;本篇没有把任务变成相互隔离的进程。
任务对象保存能恢复执行的最小信息
kernel/task.c 中的任务对象包含保存的栈指针、栈页归属、状态和启动参数:
1 | |
saved_sp 指向暂停现场的开头,切换时会变化。
kernel_stack 始终记录页面基址,供边界检查与最终回收使用;它不能被运行中的 ESP 代替。
entry 和 arg 只负责第一次启动,任务恢复执行时不重新调用入口。
状态只定义 DEAD、RUNNABLE、RUNNING。
新任务准备好初始栈以后成为 RUNNABLE;被选中后成为 RUNNING;主动让出时重新变为 RUNNABLE;入口函数结束后变为 DEAD。
本篇还没有阻塞状态,等待事件的任务暂时无处登记。
task_create() 先关中断并保存原 flags,再验证入口非空、存在可复用槽位、能够取得物理页。
任何一步失败都返回 0,并恢复调用者 flags;初始栈构造完成后才用结构体赋值发布 RUNNABLE。
这样调度器不会看见入口未填写或栈未完成的新任务。
可复用槽还要求 kernel_stack == 0。
DEAD 表示已经停止执行,但栈页可能尚未回收,因此仅凭 DEAD 还不能覆盖任务对象。
演示创建 A、B 后再尝试创建第三个计数任务,要求失败,并检查空闲页只减少两张。
空入口与页分配失败是源码中的拒绝分支;槽满检查不能代替这两个分支的运行覆盖。
普通调用边界已经约定谁保存寄存器
切换函数的 C 声明为:
1 | |
调用者传入旧任务保存位置的地址,以及新任务保存的栈指针。
在普通函数调用边界,编译器已经按照调用约定处理寄存器生存期。
Intel386 System V psABI §2.2.1–2.2.2规定 EBX、EBP、ESI、EDI 由被调用者保存,EAX、ECX、EDX 可以被调用破坏;ESP 也需要维持调用所要求的栈状态。
因此,switch_context 必须使每条恢复的调用链看见原来的 EBX、EBP、ESI、EDI,以及对应的栈。
跨调用仍然需要的 EAX、ECX、EDX 内容由调用者按 ABI 自行保存,不能假定它们调用后不变。
完整实现位于 kernel/switch.asm:
1 | |
执行四次 PUSH 后,ESP 比入口低 16 字节。
原来的返回地址位于 [esp+16],第一个参数位于 [esp+20],第二个参数位于 [esp+24]。
若忘记这四次压栈仍按入口偏移读取参数,就会把保存的寄存器或返回地址当指针使用。
1 | |
mov [eax], esp 把旧现场写回旧任务对象。
mov esp, edx 之后,后续 POP 和 RET 已经从新任务的栈读取数据。
RET 弹出的返回地址由那份栈决定,所以它能够返回到另一条调用链曾暂停的位置。
这里没有专门的 EIP 字段,因为普通 CALL 压入的返回地址已经保存了继续执行的位置。
恢复的是 switch_context 的调用续点;它再逐层返回到 task_yield(),最终回到任务代码。
旧任务尚未返回的函数帧一直保留在旧页中,没有复制到新栈,也没有通过重新执行入口来重建。
这份布局依赖当前固定段环境以及 no-SSE、no-MMX、soft-float 编译约束。
任意指令处发生中断时,EAX 等临时寄存器可能仍持有有效计算结果,不能用普通调用约定判定其可丢弃。
第 07 篇的 trap frame 与本篇四寄存器切换帧用途不同;抢占还需要完整保留被打断现场。
第一次运行先合成一个能被 RET 消费的栈
新任务从未调用过 switch_context,没有自然形成的返回地址。
创建函数必须在页顶构造一份与恢复顺序一致的现场:
1 | |
栈向低地址增长,最后写入的 EDI 位于最低处,也正是 saved_sp 指向的位置。
恢复函数弹出四个零值,再由 RET 进入 task_trampoline。
此时 ESP 已经回到页顶,初始化现场占用的 20 字节全部被消费。
不能直接把 C 函数地址放在这里就宣称满足调用约定。
页顶虽然是 16 字节对齐,RET 到达 C 函数时却没有新 CALL 压入的返回地址,C 入口的栈布局与普通调用不一致。
本篇使用汇编 trampoline 建立真正的 C 调用:
1 | |
and esp, -16 使 CALL 前 ESP 为 16 的倍数。
CALL 再压入四字节返回地址,因而 task_start 第一条指令处满足 (ESP + 4) % 16 == 0。
这是当前采用的 i386 psABI 入口要求,不是笼统声称所有 i386 环境都只需四字节对齐。
CLD 清除方向标志 DF,使 C 入口符合 ABI 对字符串操作方向的约定。
GDB 在 *task_start 的精确入口断下,避免函数序言已经改变 ESP 后再检查错误位置。
脚本同时验证两份栈各自的页边界、入口对齐以及首次进入时 DF=0、IF=0。
task_start() 根据 current 取入口和参数,调用 entry(arg)。
入口正常返回后,task_start 返回 trampoline,再由 trampoline 调用不返回的 task_exit()。
UD2 留在最后作为异常路径:退出协议若错误地返回,应触发非法指令,而不是继续执行附近未知字节。
用固定任务表完成轮转
就绪集合直接存放在三个任务槽里,不另外分配链表节点。
调度器从当前槽的下一个位置开始环形扫描,最多检查三个槽,选中第一个 RUNNABLE:
1 | |
task_yield() 在扫描前把当前任务改为 RUNNABLE。
若另外两个任务都已结束,扫描最终会选回自己,只把状态恢复成 RUNNING,不执行切栈,也不增加切换计数。
这使 bootstrap 在清理后仍能调用 yield 验证 flags 恢复,而不需要人为创造另一个可运行任务。
bootstrap 在整个演示期间不会退出,也不会阻塞。
因此,其他任务退出时总有一个可运行的执行流接管 CPU;若扫描仍找不到任务,代码进入 panic,报告 bootstrap 不变量已被破坏。
这只是当前演示的无就绪任务处理,还没有适用于阻塞调度的 idle 任务。
轮转顺序只在任务实际进入调度器时生效。
扫描算法即使每次公平地选择下一个槽,也无法约束两次 yield 之间运行了多久。
一个任务永远不让出,其他任务就永远到不了这次扫描。
flags 属于暂停的调用续点
修改任务状态与更换 current 期间需要关闭普通可屏蔽中断。
但切换函数没有保存 EFLAGS,不能把一次无条件 STI 放在出口,当作所有任务的统一恢复行为。
A 进入 yield 前可能 IF=0,B 则可能 IF=1,两者必须恢复自己的状态。
1 | |
irq_save() 用 PUSHFL/POP 读出原 flags 后执行 CLI。
局部变量 flags 随调用续点保留在各任务自己的 C 栈中;另一个任务运行时,不会改写这份暂停的变量。
后来调度器切回 A,A 的 schedule_locked() 才继续返回,再由 A 的 irq_restore(flags) 恢复 A 进入时的值。
1 | |
调度器的挂起位置始终位于 IF=0 的区间,恢复后也先保持 IF=0,直到对应调用者执行自己的恢复语句。
新任务没有这样的旧调用者,因此首次启动明确保持 IF=0。
正常演示让 B 自行 STI,A 保持 IF=0,并在每次实际跨任务返回之后检查对应的 IF 值。
此时前面的中断演示已经把主从 PIC 屏蔽字恢复为 0xff。
B 的 STI 用于验证 flags 协议,不会在任务演示中启用未知设备 IRQ,也不能据此声称已经测试了真实时钟抢占压力。
Intel SDM规定 CLI 不屏蔽 NMI;它也不阻止异常,更不能排除另一个 CPU 的访问。
即使能够保存 IF=0,普通持锁区也不能随意调用 yield。
假设 A 持锁切到 B,而 B 等待同一把锁,B 又不让出,A 就无法重新运行以释放锁。
当前接口只用于约定好的普通任务调用边界,不用于任意 IRQ handler,也不把任意关中断临界区都变成可切换区域。
退出者切走以后才能释放栈
任务入口返回,仍有 task_start、trampoline 和 task_exit 正在使用该页。
如果在 task_exit 中先 page_free(),当前 ESP 仍然指向已经允许重新分配的页面。
另一次分配一旦改写它,退出路径自己的返回地址或局部数据就会被破坏。
1 | |
退出路径只把状态改为 DEAD 并切走,调度扫描不会再选择它。
bootstrap 等到 A、B 都为 DEAD,才检查两张栈并调用 page_free(),随后把 kernel_stack 清零。
此时执行回收操作所用的栈属于 bootstrap,与正在释放的两页不同。
创建时页面填充为 0xa5,底部四字节另写 canary 0x51accafe。
回收前先核对 canary,再从低地址向上扫描未改动的填充值,计算本次运行留下的修改范围。
断言要求该范围小于 4096 - 256,给当前调用链保留诊断余量。
填充扫描得到的是写入痕迹,不是严格的最大栈深度。
只下移 ESP 而没有写入、写入值恰好仍为 0xa5,都可能使统计低估实际使用范围。
底部 canary 也不是 guard page,无法在越界发生当时阻止写入;本篇不据此保证任意未来调用链都适合 4 KiB 栈。
正常实验同时检查数据、寄存器与回收
两个计数任务各持有一个 volatile uint32_t local[8]。
A 的数组为 0x1000 + j,B 的数组为 0x2000 + j,其中 j 从 0 到 7。
不同内容可以发现错误恢复到另一份栈,而只检查两个相同的计数终值会漏掉这种情况。
每轮先检查自身计数等于循环变量 i,另一个任务的计数等于 i + id,再递增自身计数。
A 的 id=0,要求两者同处第 i 轮;B 的 id=1,要求 A 已先完成这一轮递增。
随后调用汇编 task_register_probe(id + 1),由 probe 真正调用 task_yield()。
probe 为 EBP、EBX、ESI、EDI 分别设置不同哨兵,再加上任务相关的 seed。
恢复以后逐个比较,通过才返回 1;它自己最后还会恢复调用 probe 之前的寄存器。
如果两任务的寄存器哨兵完全相同,错误地使用另一任务的值可能仍然通过比较,因此 seed 是检查的必要部分。
任务从 probe 返回后还检查 IF 和八个局部值。
GDB 在两个计数都为 1 时直接读取两份尚未退出的数组,验证数组分别位于对应页内,且全部元素与预期相同。
这一观察发生在回收前,避免只凭已经释放的页面残留推断活跃调用栈正确。
脚本要求两个任务各完成 128 轮、退出后回收两页、空闲页数恢复到创建任务之前,并继续到 keyboard_ready。
当前固定演示的切换次数预期为 387:启动切到 A 一次,128 轮各包含 A→B、B→bootstrap、bootstrap→A 三次,最后 A、B 退出各一次。
后面的 bootstrap 自选检查不会实际切栈,因而不计入这个数。
本次检查中,A、B 均完成 128 轮,实际切换 387 次,回收 2 页,空闲页数恢复为 16066。
两张独立栈页基址分别为 0x11e000、0x11f000,填充值扫描结果均为 260 字节。
该值来自当前编译与调用链的写入痕迹,不能由页容量反推,也不表示后续增加调用仍具有同样栈深度。
删除让出点后,就绪任务仍然无法运行
TEST_TASKSTARVE 变体让 A 在普通循环之前进入永久递增:
1 | |
B 在 A 第一次运行前已经创建成功,状态为 RUNNABLE,并且持有独立栈。
A 的无限循环没有 yield,没有阻塞,也不会返回入口,因而调度器没有重新获得执行机会。
这个变体沿用全部 PIC 屏蔽状态,没有定时中断替它请求调度。
一张写着 B=0 的截图只能说明打印那一刻的值。
检查脚本先在 task_starving 确认 current=1 且 B=0,再删除断点、detach,让 QEMU 自由运行一秒后重新 attach 采样。
随后重复一次,要求第二次 A 计数大于第一次,同时 B 始终为零、仍是 RUNNABLE,切换次数仍为 1、回收次数仍为 0。
两个一秒窗口由宿主侧计时,不是客体时间片,也不是指令吞吐基准。
本次截图检查对应的两次 A 采样为 352221837 → 697965053,B 始终为 0 且保持 RUNNABLE。
32 位计数在足够长的运行中会回绕,短窗口内的大小比较不构成无限时间的单调保证。
如果 B 不增长但 A 也不增长,应先排查 CPU 是否仍停在断点或故障位置。
如果 B 已经为 DEAD 或根本没有成功创建,也不能称作“就绪任务饥饿”。
这里通过自由运行期间 A 的增长和 B 的 RUNNABLE 状态,分别排除客体整体停住与任务不存在这两种解释。
下载与复现
下载第 13 篇完整源码。
源码冻结提交为 2bc725e0e33f08f79e1c0ef6f648a012f867344a,仓库实现目录为 examples/build-an-os,附件将该目录内容直接放在 os-day-13 内。
沿用第 01 篇的 localhost/build-an-os:day01 工具链镜像:
1 | |
两条容器命令顺序运行;检查脚本共用容器 loopback 的 GDB 1234 端口。
正常镜像为 build/mbr.img,饥饿变体为 build/mbr-taskstarve.img,故意停滞的实验不会覆盖正常镜像。
串口、GDB 与 QEMU 日志分别保存在 build/tasks-{normal,starvation}-{serial,gdb,qemu}.txt。
check-tasks 在正常路径检查首次 C 入口、活跃数组、寄存器、flags、退出回收与后续键盘入口,在饥饿路径执行两次自由运行采样。
源码内的 docs/day13-evidence.md 记录实现边界与复现命令。
本轮 make build check-tasks check screenshot help 退出 0,定向实验、累计回归与截图检查均通过。
源码附件独立解压后运行 make check 也退出 0,完整累计检查通过。
独立栈和返回续点使一个尚未结束的普通调用可以暂停以后继续。
要让没有主动让出点的计算任务也取得轮转机会,第 14 篇还需要从时钟中断返回路径进入调度,并保留任意指令处被打断的完整现场。
练习
- 从四次 PUSH 之前的 ESP 开始,手算
old_sp、new_sp的偏移,再算新任务首次进入task_start时的(ESP + 4) % 16。 - 在独立实验变体中让 B 只执行一轮后返回,预测 A 和 bootstrap 后续的轮转顺序;修改对应测试期望,检查 A 完成后两页是否仍能回收。
- 将四个寄存器 probe 的两任务 seed 改为相同值,说明哪些错误恢复可能因此不再被检出;保留原来的不同 seed 作为正式检查。
上一篇:12 - 内核堆。
下一篇:14 - 抢占调度。
参考资料
- Intel386 System V psABI 1.0:§2.2.1–2.2.2 的寄存器保存、函数入口栈对齐与 DF 约定。
- Intel Software Developer Manuals:CALL/RET、CLI/STI,以及中断、异常和处理器状态保存规则。中断栈帧可对照 Vol. 3A §6.12。
- MIT xv6 RISC-V book rev5:第 8–9 章讨论调度续点与并发约束;其 RISC-V 寄存器和锁协议不能直接替换本篇 32 位 x86 实现。


