第 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
2
3
4
5
6
7
8
任务表 tasks[0]                 tasks[1]                  tasks[2]
bootstrap A B
启动时已有栈 独立 4 KiB 页 独立 4 KiB 页
调用 task_yield counter(0) counter(1)
│ │ │
└──── 保存 ESP ─────────────┴──── 保存 ESP ──────────┘

CPU 的 ESP 只指向当前任务;其余任务的 saved_sp 留在任务表中。

这里三个槽包含 bootstrap,即运行内核初始化与演示管理代码的原始执行流。
它也参加调度,所以“两个计数任务交替运行”并不意味着每次都从 A 直接切到 B。
正常轮转是 bootstrap → A → B → bootstrap,两个计数任务之间没有另一个计数任务插入。

栈彼此独立只保证调用链有不同的存储区域。
三个任务仍共享页表与内核权限,A 的错误指针仍可能写坏 B 的栈;本篇没有把任务变成相互隔离的进程。

任务对象保存能恢复执行的最小信息

kernel/task.c 中的任务对象包含保存的栈指针、栈页归属、状态和启动参数:

1
2
3
4
5
6
7
struct task {
uint32_t *saved_sp;
uint32_t kernel_stack;
enum task_state state;
void (*entry)(void *);
void *arg;
};

saved_sp 指向暂停现场的开头,切换时会变化。
kernel_stack 始终记录页面基址,供边界检查与最终回收使用;它不能被运行中的 ESP 代替。
entryarg 只负责第一次启动,任务恢复执行时不重新调用入口。

状态只定义 DEADRUNNABLERUNNING
新任务准备好初始栈以后成为 RUNNABLE;被选中后成为 RUNNING;主动让出时重新变为 RUNNABLE;入口函数结束后变为 DEAD。
本篇还没有阻塞状态,等待事件的任务暂时无处登记。

task_create() 先关中断并保存原 flags,再验证入口非空、存在可复用槽位、能够取得物理页。
任何一步失败都返回 0,并恢复调用者 flags;初始栈构造完成后才用结构体赋值发布 RUNNABLE。
这样调度器不会看见入口未填写或栈未完成的新任务。

可复用槽还要求 kernel_stack == 0
DEAD 表示已经停止执行,但栈页可能尚未回收,因此仅凭 DEAD 还不能覆盖任务对象。
演示创建 A、B 后再尝试创建第三个计数任务,要求失败,并检查空闲页只减少两张。
空入口与页分配失败是源码中的拒绝分支;槽满检查不能代替这两个分支的运行覆盖。

普通调用边界已经约定谁保存寄存器

切换函数的 C 声明为:

1
extern void switch_context(uint32_t **old_sp, uint32_t *new_sp);

调用者传入旧任务保存位置的地址,以及新任务保存的栈指针。
在普通函数调用边界,编译器已经按照调用约定处理寄存器生存期。
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
2
3
4
5
6
7
8
9
10
11
12
13
14
switch_context:
push ebp
push ebx
push esi
push edi
mov eax, [esp+20]
mov edx, [esp+24]
mov [eax], esp
mov esp, edx
pop edi
pop esi
pop ebx
pop ebp
ret

执行四次 PUSH 后,ESP 比入口低 16 字节。
原来的返回地址位于 [esp+16],第一个参数位于 [esp+20],第二个参数位于 [esp+24]
若忘记这四次压栈仍按入口偏移读取参数,就会把保存的寄存器或返回地址当指针使用。

1
2
3
4
5
6
7
保存后的 ESP → +0   EDI
+4 ESI
+8 EBX
+12 EBP
+16 返回到 schedule_locked 的地址
+20 old_sp 参数
+24 new_sp 参数

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
2
3
4
5
6
uint32_t *sp = (uint32_t *)(page + PAGE_BYTES);
*--sp = (uint32_t)task_trampoline;
*--sp = 0; /* EBP */
*--sp = 0; /* EBX */
*--sp = 0; /* ESI */
*--sp = 0; /* EDI */

栈向低地址增长,最后写入的 EDI 位于最低处,也正是 saved_sp 指向的位置。
恢复函数弹出四个零值,再由 RET 进入 task_trampoline
此时 ESP 已经回到页顶,初始化现场占用的 20 字节全部被消费。

不能直接把 C 函数地址放在这里就宣称满足调用约定。
页顶虽然是 16 字节对齐,RET 到达 C 函数时却没有新 CALL 压入的返回地址,C 入口的栈布局与普通调用不一致。
本篇使用汇编 trampoline 建立真正的 C 调用:

1
2
3
4
5
6
task_trampoline:
cld
and esp, -16
call task_start
call task_exit
ud2

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
2
3
4
5
6
7
8
9
10
11
12
unsigned old = current;
for (unsigned n = 1; n <= SLOTS; ++n) {
unsigned next = (old + n) % SLOTS;
if (tasks[next].state != RUNNABLE) continue;
current = next;
tasks[next].state = RUNNING;
if (next != old) {
++task_switches;
switch_context(&tasks[old].saved_sp, tasks[next].saved_sp);
}
return;
}

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
2
3
4
5
6
7
void task_yield(void)
{
uint32_t flags = irq_save();
tasks[current].state = RUNNABLE;
schedule_locked();
irq_restore(flags);
}

irq_save() 用 PUSHFL/POP 读出原 flags 后执行 CLI。
局部变量 flags 随调用续点保留在各任务自己的 C 栈中;另一个任务运行时,不会改写这份暂停的变量。
后来调度器切回 A,A 的 schedule_locked() 才继续返回,再由 A 的 irq_restore(flags) 恢复 A 进入时的值。

1
2
A:保存 IF=0 → CLI → 切走 ─────────────→ 恢复 A 的栈 → 恢复 IF=0
B: 保存 IF=1 → CLI → 切走 → …… → 恢复 IF=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
2
3
4
5
6
7
8
_Noreturn void task_exit(void)
{
(void)irq_save();
KASSERT(current != 0);
tasks[current].state = DEAD;
schedule_locked();
panic(__FILE__, __LINE__, "dead task resumed");
}

退出路径只把状态改为 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。
两张独立栈页基址分别为 0x11e0000x11f000,填充值扫描结果均为 260 字节。
该值来自当前编译与调用链的写入痕迹,不能由页容量反推,也不表示后续增加调用仍具有同样栈深度。

删除让出点后,就绪任务仍然无法运行

TEST_TASKSTARVE 变体让 A 在普通循环之前进入永久递增:

1
2
3
4
5
if (starvation && id == 0) {
kprintf("D13 NO-YIELD A running; B=0 (cooperative only)\n");
task_starving();
for (;;) ++task_counts[0];
}

B 在 A 第一次运行前已经创建成功,状态为 RUNNABLE,并且持有独立栈。
A 的无限循环没有 yield,没有阻塞,也不会返回入口,因而调度器没有重新获得执行机会。
这个变体沿用全部 PIC 屏蔽状态,没有定时中断替它请求调度。

一张写着 B=0 的截图只能说明打印那一刻的值。
检查脚本先在 task_starving 确认 current=1 且 B=0,再删除断点、detach,让 QEMU 自由运行一秒后重新 attach 采样。
随后重复一次,要求第二次 A 计数大于第一次,同时 B 始终为零、仍是 RUNNABLE,切换次数仍为 1、回收次数仍为 0。

A 不再让出 CPU 时的独立饥饿实验画面

两个一秒窗口由宿主侧计时,不是客体时间片,也不是指令吞吐基准。
本次截图检查对应的两次 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
2
3
4
unzip os-day-13.zip
cd os-day-13
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make build check-tasks
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make check

两条容器命令顺序运行;检查脚本共用容器 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 篇还需要从时钟中断返回路径进入调度,并保留任意指令处被打断的完整现场。

练习

  1. 从四次 PUSH 之前的 ESP 开始,手算 old_spnew_sp 的偏移,再算新任务首次进入 task_start 时的 (ESP + 4) % 16
  2. 在独立实验变体中让 B 只执行一轮后返回,预测 A 和 bootstrap 后续的轮转顺序;修改对应测试期望,检查 A 完成后两页是否仍能回收。
  3. 将四个寄存器 probe 的两任务 seed 改为相同值,说明哪些错误恢复可能因此不再被检出;保留原来的不同 seed 作为正式检查。

上一篇:12 - 内核堆

下一篇:14 - 抢占调度

参考资料