从零编写操作系统 14 - 抢占调度:时间片结束后怎样换一个任务
第 13 篇能够保存两条调用链,但 A 一旦进入不含 yield 的无限循环,已经就绪的 B 就无法运行。
本篇把第 08 篇的 PIT 时钟接入同一个调度器,让两个没有主动让出点的计算任务都取得进展。
时间片采用最小规则:每次时钟中断提出一次轮转请求,允许调度时在中断处理出口切换。
切换函数仍然只保存四个被调用者保存寄存器。
被时钟打断的完整寄存器与 EFLAGS 则留在原任务的中断栈帧里,等该任务恢复以后由 IRETD 还原。
实验另外检查允许中断但禁止抢占、真正关闭中断、嵌套恢复,以及屏蔽 IRQ0 后另一个任务停滞的区别。
时钟可以打断调用约定没有覆盖的位置
普通 task_yield() 是编译器可见的函数调用。
编译器会按 ABI 处理跨调用仍然有效的数据,第 13 篇因此只需在切换函数中保存 EBP、EBX、ESI、EDI 和栈位置。
时钟中断却可能发生在加法与后续条件跳转之间,也可能发生在 EAX 刚算出一个值、尚未写回内存的时候。
这种位置没有普通函数调用,不能允许中断处理随意丢弃 EAX、ECX、EDX 或条件标志。
若只保存四个寄存器,两个计数任务可能仍偶尔增长,但任务中的中间计算已经被破坏。
是否出现输出与是否正确恢复任意指令现场,需要分别验证。
当前系统继续运行在单 CPU、32 位保护模式、ring 0,共享第 11 篇建立的页表。
本篇保存的是这套无浮点、无 SSE/MMX 环境中的整数执行现场,不切 CR3,也不切换用户权限。
这些限制与第 13 篇一起保留,不能把定时切栈直接等同于完整进程切换。
68 字节中断现场由硬件和汇编共同形成
同 CPL 的中断入口由 CPU 保存 EFLAGS、CS、EIP。
IRQ0 不带硬件错误码,向量入口额外压入零错误码和向量号 32,再进入共用汇编入口。
入口用 PUSHAD 保存通用寄存器,并把四个段寄存器扩展成四字节槽保存。
Intel SDM Vol. 3A §6.12描述了中断入口与返回规则。
当前从 ring 0 打断 ring 0,没有权限切换产生的旧 SS、旧 ESP 尾部;不能按用户态进入内核的布局多读八字节。
kernel/exceptions.h 的结构体与汇编顺序逐项对应:
1 | |
PUSHAD_ESP 是 PUSHAD 所记录的中间栈指针,不是另外一个可直接加载的完整任务现场指针。
POPAD 恢复时跳过这一槽,最终 ESP 依靠对称出栈和 IRETD 回到被打断位置。
源码用静态断言锁定结构体总长、vector 偏移 48 和 eflags 偏移 64,防止 C 字段修改后汇编仍沿用旧偏移。
中断门在保存旧 EFLAGS 后清除 IF,因此 C 中断处理运行时 IF=0,但 frame 内仍有被打断代码的旧 IF。
共用入口还会 CLD,让 C 函数入口满足 DF=0;原 DF 已经保存在 frame 中,返回任务时必须恢复。
清除处理中使用的 DF 与保留任务原 DF 可以同时成立。
完整现场留栈,切换的仍是普通调用续点
调用链从硬件中断到任务切换保持同一份任务栈:
1 | |
在 A 的栈上,完整 frame 位于较高地址,中间有 C 函数调用帧,再向低地址延伸到 switch_context 保存的四寄存器现场。
tasks[A].saved_sp 指向最下面这份普通切换现场,不直接指向 68 字节 frame。
把它强制转换为 struct trap_frame * 会从错误位置读取向量、EIP 和 flags。
等某次时钟轮转再次选中 A,switch_context 先 RET 回 A 暂停的调度器调用点。
之后逐层返回到 task_trap_leave、trap_dispatch,再返回共用汇编入口。
入口恢复段寄存器与通用寄存器,跳过 vector/error,最后由 IRETD 消费 EIP、CS、EFLAGS。
1 | |
RET 负责恢复内核调度调用链,IRETD 负责恢复真正被中断的代码。
两层帧有不同布局和不同消费者,不需要把四寄存器切换函数扩展成第二份中断入口。
exception_common 用 EBX 保存原始 frame 指针,再向下对齐 ESP 并 CALL C。
EBX 属于被调用者保存寄存器,也在 switch_context 的保存集合里,因此跨任务挂起之后仍能找回同一个 frame。
若改为只用调用者可破坏的临时寄存器保存这根指针,普通 C 调用就可能使后续 mov esp, ... 恢复到错误地址。
EOI 必须在切走之前完成
时钟 handler 只做有界操作:递增 tick,记录调度请求,向主 PIC 发送 IRQ0 的 EOI。
它不会在更新 tick 的中间直接调用切换函数。
1 | |
timer_mode == 2 是第 08 篇故意遗漏 EOI 的诊断模式。
本篇调度入口重新初始化 timer mode,正常路径会发送 EOI;旧故障实验仍保留独立用途。
若先切到 B,等 A 再运行才发 EOI,PIC 的处理中状态就会跨任务保留。
后续定时投递可能因此受阻,而恢复 A 本身又依赖后续定时调度,最终出现只有一次切换或 tick 不再增长的现象。
完成设备确认与调度分离,使 B 开始运行时不会继续承担 A 尚未结束的 IRQ0 确认工作。
1 | |
trap_handle 返回意味着设备 handler 已完成,包括该路径需要的 EOI。
真正切换发生在统一出口,避免每个设备处理函数各自形成一套调度顺序。
时钟与调度热路径也不打印日志,减少中断栈深度和控制台重入机会。
每个任务分别记录中断深度和禁止抢占层数
任务对象增加两个字段:trap_depth 和 preempt_count。
前者记录尚未完成的 trap 处理层数,后者记录当前任务嵌套禁止抢占的层数。
这些状态属于具体调用链,不能把 A 的层数当成切到 B 后仍然适用的全局计数。
当前 handler 不会主动 STI,因此没有打开普通 IRQ 嵌套。
但统一出口仍显式管理深度,只在最外层处理已经完成时考虑调度,便于用断言限制调用位置。
这不表示已支持任意嵌套中断或 NMI 调度。
1 | |
深度在调度判断前减一。
因此,A 即使还保留着等待将来返回的物理 trap frame,其逻辑处理深度也已经在出口降为零。
这里的“最外层已完成”指设备处理和嵌套处理结束,不意味着汇编 frame 已经弹出。
interrupted_if 来自被打断现场的旧 EFLAGS,不是 C handler 当前总为零的 IF。
如果同步异常打断了原本 IF=0 的临界区,即使已有调度请求,也不能因为正在退出一个 trap 就擅自切走。
待原调用者恢复自己的中断状态,再由符合条件的出口处理请求。
调度请求在允许的出口兑现
每次 PIT tick 调用 task_tick(),抢占演示激活时将 need_resched 置一。
该标志只表示 CPU 有待处理的轮转请求,不统计欠了多少个时间片。
当前单 CPU 只执行一条调用链,多个禁止抢占期间到来的 tick 可以合并成一个待办请求。
1 | |
尚在 trap 内层则继续返回,不从中间切走。
禁止抢占计数非零则留下 need_resched,只累计延期检查次数。
满足条件后把当前任务放回 RUNNABLE,复用第 13 篇三个槽的环形扫描。
调度器同时断言当前 IF=0、当前任务的两种深度都为零,并在切栈之前清掉本次 need_resched。
如果等旧任务恢复后才清标志,新任务运行期间产生的新请求就可能被旧调用的收尾语句覆盖。
清除发生在消费本次请求的时刻,而不是某个无法预知何时恢复的返回点。
本篇每个 tick 提出轮转,没有优先级、按任务权重分配或多 tick 配额。
PIT 沿用第 08 篇分频值 11932;tick 是客体中断计数,不能直接拿调试器暂停前后的宿主时间当实际时间片长度。
当前短临界区和 handler 都有明确工作量,延迟仍取决于这些不可抢占区间。
首次启动也有独立协议:PIT 初始化完成并激活抢占演示后,新任务在 task_start() 内 STI。
第 13 篇的协作式演示保持原先 IF=0 的首次入口,switch_context 本身仍不无条件开中断。
已经运行过的抢占任务从旧中断调用链恢复,由自己的 IRETD 还原 IF。
禁止抢占保留设备中断
preempt_disable() 只增加当前任务的禁止抢占计数。
它会短暂保存 flags 并 CLI,保护计数修改,随后恢复调用者的 IF。
调用前 IF=1 时,函数返回后时钟仍然可以进入。
1 | |
演示连续调用两次 disable 后 STI,等待至少三个 tick。
预期 timer_ticks 持续增长,need_resched 被置一,而 preempt_switches 保持零。
这是“IRQ 已完成处理,但任务切换延期”的运行场景。
第一次 preempt_enable() 仅把计数从二减为一,仍不能切换。
第二次减为零,若进入 enable 时 IF=1,便立即兑现已有请求;以后 bootstrap 恢复这次调用,才执行自己的 flags 恢复。
1 | |
计数为零还调用 enable 属于配对错误,断言拒绝无符号下溢。
嵌套使用也不能用一个布尔值代替:内层结束时,外层仍可能依赖“不会切走”的约束。
只有最外层恢复才解除禁止抢占状态。
禁止抢占不排除 IRQ handler 对共享数据的访问。
若中断与任务都修改同一个队列,仅增加 preempt_count 仍可能让 handler 看到半完成状态,应使用匹配 IRQ 访问的临界区协议。
当前堆、页分配器和调度器继续遵守既有短 CLI 保护;演示没有把控制台变成可被多个任务任意并发调用的接口。
IF=0 时,IRQ 本身还没有进入
另一个检查先在禁止抢占但 IF=1 的状态下积累请求,再执行 CLI,随后调用最后一层 preempt_enable()。
虽然计数降到零,进入 enable 前的 IF 仍为零,因此它保留请求而不切换,也不替调用者 STI。
原调用者应当独立决定何时退出关中断区。
GDB 在 preempt_cli_sample 设置释放门,detach 后让客体自由运行一段宿主计时窗口。
窗口里 bootstrap 仍执行循环,但 tick 和切换计数应同时保持不变,IF 仍为零。
解除测试门后,代码还执行一段有界循环,再核对两项计数未变,最后恢复原 flags。
| 区间 | 时钟 handler 是否执行 | tick | 任务切换 |
|---|---|---|---|
| IF=1,preempt_count>0 | 可以 | 增长 | 请求保留,暂不切换 |
| IF=0,preempt_count=0 | 普通 IRQ 不能进入 | 不增长 | 暂不切换 |
| IF=1,preempt_count=0,最外层出口 | 可以 | 增长 | 可兑现请求 |
第二行不能描述成“每次时钟中断进入后判断禁止调度”。
时钟设备可能继续产生请求,但 CPU 的普通可屏蔽 IRQ 入口被 IF 阻止,软件没有执行 timer_irq()。
未处理的设备请求也不等价于每个物理脉冲都会被逐个补计,不能据此恢复精确经过时间。
CLI 不屏蔽 NMI,不防止异常,也不是 SMP 互斥机制。
普通持锁区仍不能主动 yield;当前调度入口对禁止抢占层数的断言,只覆盖进入接口后的协议检查,不提供任意锁的自动识别。
用真实忙循环证明没有依赖 HLT 或 yield
两个任务各自只递增自己的 preempt_counts[id]。
计算循环内部不调用函数、不执行 task_yield(),也不执行 HLT:
1 | |
两项阈值都满足以后,任务才允许离开计算阶段。
单看结束时 A、B 均超过阈值,还不足以排除一个任务先运行完,另一个任务随后才开始的弱实验。
因此脚本将 preempt_work_release 置零,让两任务在外部观察期间都保持忙循环。
GDB detach 后自由运行 0.3 秒再 attach 读取 A、B 和 tick,随后重复一次。
要求 A、B 在第二次都大于第一次,tick 也增长,之后才将释放门设回一,让任务进入寄存器检查阶段。
调试门控制的是退出条件,不调用调度器,也没有替任务添加让出点。
两次样本证明这两个计算任务在观察区间都获得了执行机会。
它不测量公平份额,也不保证每个精确宿主时间窗口中执行量相同;断点、TCG 执行与宿主调度都会影响计数。
32 位计数还存在回绕边界,脚本使用短窗口,不能把大小比较推广到无限运行时间。
本次两个自由运行样本中,A 从 23700735 增至 45216100,B 从 24763327 增至 48848681,tick 从 40 增至 70。
正常路径截图记录演示完成后的汇总,进展判断依据上述内存采样与无让出点源码路径。
HLT 探针单独检查完整寄存器和标志
计数增长不能确认被打断的七个普通通用寄存器都正确恢复。
preempt_register_probe(seed) 因此使用独立汇编路径,为 EAX、ECX、EDX、EBX、EBP、ESI、EDI 写入七个不同的基值,再加任务相关 seed。
A、B 的种子不同,避免把另一任务的现场装回来仍碰巧通过相同哨兵比较。
探针先保存调用者自己的寄存器与 flags,布置测试值,再设置受检的算术标志、IF 和 DF,进入 HLT 等待真实 IRQ。
中断处理会调用 C、轮转到别的任务,然后在原任务恢复时从 HLT 后续指令继续。
该任务立即执行 PUSHFD 和 PUSHAD,把返回值捕获到栈上,先于任何可能改变算术标志的运算。
探针随后 CLD,再按 16 字节约定对齐 C 调用栈,调用 preempt_probe_check()。
检查完成后恢复原寄存器与 flags,再正常返回任务代码。
这使故意设置的 DF=1 不会直接进入遵循 DF=0 约定的 C 检查函数。
标志检查使用掩码 0xed5,比较 CF、PF、AF、ZF、SF、IF、DF、OF,并再次显式核对 IF 已开启。
掩码只覆盖选定状态,不是对全部 EFLAGS 位作逐位不变承诺;保留位和当前未使用的处理器功能不在该断言里。
两个任务各执行四轮探针,预期共八次通过,每轮还要求切换计数比进入探针前增加。
preempt_probe_check() 在短 CLI 区间中更新共享通过计数,随后恢复进入时的 flags。
共享变量上的 ++ 是读、改、写序列,volatile 不使它自动免受任务交错影响。
GDB 额外从一个指定 owner 的探针入口追踪到 switch_context。
此时 current 已更新为下一个任务,CPU 的 ESP 尚在换出 owner 的栈上。
脚本沿 C 调用帧找到 trap_dispatch(frame),核对 frame 位于 owner 栈页内、向量为 32,七寄存器与 flags 都符合该任务的哨兵。
这一时刻不能简单根据新的 current 判断当前 ESP 属于谁。
再通过带 owner 条件的断点等同一个任务到达恢复检查点,确认它在切换次数增加后保留相同寄存器与标志。
这样检查的路径包含真实 IRQ0、一次任务选择变化和同一任务通过 IRETD 返回,而不只是直接调用普通切换 probe。
HLT 探针刻意等待中断,不能用它证明 CPU 忙任务已具备抢占能力。
前一节无 CALL、无 HLT、无 yield 的计数循环负责进展证明,本节负责选择好的寄存器与标志恢复证明。
本次观察中,owner=1 的完整 IRQ0 frame 位于 0x11ff44,调度器已经选中 current=2。
同一个 owner 通过 IRETD 恢复时,切换计数从 67 增至 70,七寄存器与受检 flags 均保留;整个演示共完成八次探针检查。
屏蔽 IRQ0 后再次观察就绪任务停滞
preempt-mask 变体让 A 首次运行时屏蔽 IRQ0,再进入无让出点的计数循环。
B 已创建并处于 RUNNABLE,但系统不再有这个轮转来源。
该场景保留 A 的正常计算,用于区分缺少调度事件与整个 CPU 停机。
脚本在两个 0.3 秒自由运行窗口分别读取计数,要求 A 增长、B 保持零,并确认 B 仍然就绪。
同时检查 tick 与切换次数没有变化,定位到时钟投递被屏蔽这条边界。
若只是等串口超时,无法排除 QEMU 停在断点、B 创建失败或入口异常等其他原因。
截图取得以后,GDB 将 preempt_mask_requested 清零,A 的代码观察到请求变化,调用 timer_mask(0) 解除设备屏蔽。
后续继续执行同一组正常检查,要求 B 恢复进展,最后两任务都完成并回收栈页。
这是一次可恢复的故障注入,不需要把恢复判断建立在重启后运行了另一张正常镜像上。
本次屏蔽窗口中,A 从 78017139 增至 155147236,B 保持零,tick=3、切换次数=1 均未改变。
解除屏蔽后,两计数重新取得进展,并完成后续现场与退出回收检查。
恢复门来自测试控制,任务调度仍由重新启用的真实 IRQ0 驱动。
退出回收与累计行为一起核对
任务完成忙循环与探针后返回入口,继续使用第 13 篇的 task_exit() 协议。
退出者只标 DEAD 并切走,bootstrap 等两者都结束后关闭时钟、停止抢占,再从自己的栈回收两张任务页。
回收前检查 canary、trap_depth 和 preempt_count,要求没有未完成处理层数或禁止抢占层数遗留。
填充扫描记录本轮栈上被写过的范围,仍要求小于 4096 - 256。
抢占使 task 栈额外包含完整 IRQ frame 与 C 处理调用链,所以不能沿用第 13 篇的栈使用数作为本篇结果。
扫描仍可能漏掉未写入的 ESP 下移或写回填充值;它不是 guard page,也不是最坏栈深度证明。
本次正常截图对应 A=45216101、B=48848682、切换 81 次、探针通过 8 次,禁止抢占测试观察到 3 个 tick。
两张栈的填充扫描结果均为 340 字节,回收 2 页后空闲页数恢复为 16065。
脚本还要求第 13 篇协作式检查标记保持成功,其独立切换计数仍为 387,再继续到 keyboard_ready。
抢占计数单独保存,避免把新实验次数混进旧版本的精确轮转断言。
下载与复现
下载第 14 篇完整源码。
源码冻结提交为 eeacce13e89cc6347645364095ecbafc7f4f2b1c;仓库实现目录为 examples/build-an-os,附件将该目录内容直接放在 os-day-14 内。
沿用第 01 篇的工具链镜像,按顺序运行:
1 | |
正常镜像为 build/mbr.img,故障注入镜像为 build/mbr-preempt-mask.img。
检查脚本位于 tools/check-preempt.sh,串口、GDB 和 QEMU 输出保存为 build/preempt-{normal,masked}-{serial,gdb,qemu}.txt。
各检查共享容器 loopback 的 1234 端口,应顺序执行。
本轮定向 check-preempt、累计 make check screenshot help 以及源码附件独立解压后的 make check 均退出 0。
源码内 docs/day14-evidence.md 保存实现边界与本轮运行记录。
时钟抢占解决不主动让出 CPU 的计算任务长期占用处理器的问题。
等待键盘输入的任务仍然没有必要反复消耗时间片检查队列;第 15 篇将增加阻塞与唤醒,让没有工作的任务暂时退出就绪集合。
练习
- 画出一次 IRQ0 抢占后 A 栈上的完整 frame、C 调用帧与四寄存器切换帧,分别标明 RET 与 IRETD 消费哪一部分。
- 将禁止抢占嵌套加深到三层,预测前两次 enable 后 tick、切换次数和计数状态,再调整独立实验验证最外层恢复。
- 对照屏蔽变体中“B 必须为 RUNNABLE”的断言,说明只检查 B=0 时,任务创建失败为什么可能被误判为抢占失效。
上一篇:13 - 保存与恢复执行现场。
下一篇:15 - 等待与唤醒。
参考资料
- Intel SDM Vol. 3A:§6.12 的中断入口、门类型与返回;同 CPL 与权限切换的栈布局必须分别处理。
- Intel386 System V psABI 1.0:普通 C 调用的寄存器保存、栈对齐和 DF 约定;这些约定用于 handler 的 C 调用,不替代任意指令处的中断现场。
- Intel Software Developer Manuals:CLI、HLT、PUSHAD、POPAD 与 IRETD 的指令语义。
- MIT xv6 RISC-V book rev5:调度调用续点与线程中断状态的概念参考;本篇保存布局依据实际 x86 入口,不照搬 RISC-V 寄存器。


