Cinux是如何管理进程的 ------ 上下文与调度
如果您是想尝试 Cinux,并对一些驱动、前沿细节的实现感兴趣的朋友,请移步到下面的仓库:
https://github.com/Awesome-Embedded-Learning-Studio/Cinux
如果您对手写一个现代 C++ 操作系统感兴趣的朋友,请到这里:
https://github.com/Awesome-Embedded-Learning-Studio/Cinux-Book
或者,直接访问文档站开始阅读:https://awesome-embedded-learning-studio.github.io/Cinux-Book/
如果上面的内容,对您的学习和实际的开发哪怕有一丝帮助,都是笔者极大的荣幸!喜欢的话,麻烦小小的赏一个 ⭐(QAQ)。自己的知识仍不精湛,文章必然还有很多错误,还请各位大佬批评斧正!
一、让内核长出第二条执行流:进程上下文(原 019)
018 把「地址空间」这块地基铺好了,可生产路径里它一个实例都没造------因为还缺一样东西:执行流。这一章造出「任务(
Task)」抽象,写一段 70 来行的汇编让 CPU 能在两条执行流之间瞬间跳来跳去,再搭一个最朴素的轮转调度器,顺带把大内核扶正回 higher-half。
导引:点亮什么、为什么、设计图
这一章我们要点亮什么
核心是一件:让内核从「一条 main 流」变成「多条可切换的内核线程」。
具体说,019 交付四块:
- 任务抽象 :
TaskState(运行/就绪/阻塞/死亡四种生命周期)、CpuContext(一段 64 字节的寄存器快照)、Task(任务控制块 TCB,装着上下文、状态、栈、名字)、TaskBuilder(流式构造器)。一个Task就是「一条可以被挂起、又被恢复的执行流」的全部载体。 - 上下文切换 :context_switch.S------一段汇编,保存当前任务的寄存器、恢复下一个任务的寄存器、换栈、一跳。这一段是整章的灵魂,也是后面所有「并发」的物理基础。
- 调度器骨架 :scheduler.cpp------
RoundRobin轮转队列 +Scheduler静态门面(init/add_task/yield/exit_current/run_first)。负责「下一个该轮到谁」。 - higher-half 收口:把大内核从恒等映射地址扶正回它链接的 higher-half 地址,顺带修一个缺页处理的小毛病。这块不是「新功能」,但它是「进程隔离能成立」的前提。
合起来,这一章给了内核「同时持有多个活动、并在它们之间切换」的能力。但要把期望放正:019 是协作式 多任务------线程不主动 yield(),它就独占 CPU 到天荒地老;切换只由手动 yield 触发,没有抢占、没有优先级生效、没有用户态。这里有个容易误会的地方得说清:时钟中断这时候其实在跑 (PIT 从 011 章就初始化了,main 里 sti 之后,IRQ0 每个 tick 都会进 pit_irq0_handler),只是那个 handler 目前只递增一个 tick 计数、还没接到调度器,所以不会强行打断线程。把调度器挂到时钟中断上做成抢占,是 020 的事。019 只回答最基础的一问:「怎么把 CPU 从一条执行流手里拿过来、递给另一条,而且两条都还活着。」
为什么现在需要它
先回答一个看代码时一定会冒出来的疑问:CpuContext 里只存了 8 个值------6 个 callee-saved(r15/r14/r13/r12/rbp/rbx)再加上 rsp 和 rip。x86-64 有 16 个通用寄存器,那其余的 caller-saved(rax/rcx/rdx/rsi/rdi/r8--r11)为什么不全存?
答案是 System V AMD64 调用约定。它把寄存器分成两类:
- caller-saved(调用方保存) :
rax rcx rdx rsi rdi r8 r9 r10 r11。约定说:这些寄存器在函数调用过后不保证 还是原值,谁要用谁自己存。所以编译器在调用别的函数之前,如果这些寄存器里有还要用的值,会主动把它们存到栈上;调用完再取回来。 - callee-saved(被调方保存) :
rbx rbp r12 r13 r14 r15。约定说:这些寄存器在函数调用过后保证还是原值------谁改了谁负责恢复。
而我们的上下文切换,永远发生在「函数调用边界」上:yield() 调 context_switch(),context_switch() 又调回去。在 yield 调进 context_switch 的那一刻,按约定,caller-saved 寄存器里的值本来就是「不保证存活」的------编译器要么已经把它们存到 yield 的栈帧上了,要么根本不在乎。所以我们根本不需要 替调用方保存它们。我们只需要保存 callee-saved 这 6 个(因为它们「承诺」跨调用不变,我们必须把当前值藏好,等恢复时还回去),再加上定义「执行流此刻在哪」的两个:rsp(栈顶,决定这条流的调用链在哪)和 rip(下一条指令,决定这条流接下来干什么)。
这就是 CpuContext 只有 64 字节的全部理由。存多了是浪费,存少了会破坏调用约定、把调用方的 callee-saved 寄存器改花。xv6 的 swtch() 用的是同一招------只存 callee-saved 那一组,换栈,然后 ret。这不是巧合,而是「在函数边界做切换」这个约束下,唯一省事又正确的做法。
至于「为什么先做协作式,再做抢占式」------因为抢占式(靠时钟中断强行打断)要求一件事:中断能在任意 指令处把 CPU 接管走。那意味着中断现场(InterruptFrame)里必须能完整重建任意时刻的执行流,而不仅仅是函数边界。019 先把「函数边界切换」这条最干净的路走通,验证 Task + context_switch + RoundRobin 这套骨架是对的;020 再把调度器接到那个已经在跑 的时钟中断上(让 irq0 handler 在固定节拍调用调度、强制切走当前线程),yield 就不再需要线程主动调了。一步一步来,每步只加一个变量。
设计图
先看 CpuContext 的内存布局------它是 context_switch.S 和 TaskBuilder 之间的契约,偏移必须严丝合缝:
text
struct CpuContext (alignas 16, 共 64 字节)
┌──────────────────────────────────────┐
│ offset 0: r15 callee-saved │
│ offset 8: r14 callee-saved │
│ offset 16: r13 callee-saved │
│ offset 24: r12 callee-saved │
│ offset 32: rbp callee-saved (帧指针)│
│ offset 40: rbx callee-saved │
│ offset 48: rsp ← 栈顶: 执行流的"调用链"│
│ offset 56: rip ← 下一条指令: 接着干什么 │
└──────────────────────────────────────┘
process.hpp 用 8 条 static_assert 锁死这些偏移
context_switch.S 用同样的数字 (0/8/.../56) 读写
再看 context_switch 干了什么。它是一个「不对称」的函数:进去时在 A 的栈上,出来时在 B 的栈上,而且「出来」可能发生在很久以后:
text
context_switch(from=A.ctx, to=B.ctx) 调用者: yield() / run_first()
① 把当前 CPU 的 callee-saved 存进 from(A.ctx):
A.ctx.{r15..rbx} = 当前寄存器
A.ctx.rsp = 当前 rsp
A.ctx.rip = .restore 标号地址 ← 关键: 记下"回来时从这儿继续"
② 从 to(B.ctx) 把寄存器恢复出来:
当前寄存器 = B.ctx.{r15..rbx}
当前 rsp = B.ctx.rsp ← 换栈! 这一刻执行流切到 B
③ jmp *B.ctx.rip ← 不是 call, 直接跳
┌─ B 是"全新的"任务(第一次被切到): rip=线程入口, rsp 指向干净栈(顶上压着 exit_current)
│ → 跳进线程函数, 从头跑; 函数 return 时弹栈 → exit_current 干净退场
│
└─ B 是"被打断过"的任务(之前切走时 rip 被存成了 .restore):
→ 跳到 .restore → ret → 回到当初调用 context_switch 的地方
(yield / run_first), 但此刻跑在 B 自己的栈上, 可能是"很久以后"
最后是协作式调度的实际节奏。两个线程各跑 5 轮,每轮打一行就 yield:
text
时间 →
boot ──run_first──► A.it0 ──yield──► B.it0 ──yield──► A.it1 ──yield──► B.it1 ── ... ──► A.done ──(return→exit_current)──► B.it4 ──► B.done ──► (空)halt
│ │ │ │
切到 A 切到 B 切到 A 切到 B
串口看到:
[A] thread_a iteration 0
[B] thread_b iteration 0
[A] thread_a iteration 1
...
[A] thread_a done
[SCHED] Task tid=1 'thread_a' exited
[B] thread_b done
[SCHED] Task tid=2 'thread_b' exited
[SCHED] No more tasks, halting.
注意最后那行 halting------它揭示了一个 019 的真实特性,后面调试现场会展开:这个调度器没有 idle 任务 ,活儿干完就直接 cli;hlt 把机器停住,而不是回到引导它的 boot 流程。
代码路线:从 CpuContext 到 Scheduler
CpuContext:64 字节的执行流快照
process.hpp 里,CpuContext 的定义极其克制:
cpp
struct alignas(16) CpuContext {
uint64_t r15, r14, r13, r12, rbp, rbx; // 6 个 callee-saved
uint64_t rsp; // 栈顶
uint64_t rip; // 下一条指令
};
static_assert(offsetof(CpuContext, r15) == 0, "r15 at offset 0");
// ... 另外 7 个 offsetof 断言同理 (共 8 个, 锁死每个字段偏移); 下面再锁总大小
static_assert(sizeof(CpuContext) == 64, "CpuContext must be 64 bytes");
alignas(16) 不是装饰:这条结构体会被汇编按固定偏移读写,也经常被一次性拷贝,16 字节对齐既配合 SSE 之类的要求,也防止结构体里出现意外的填充(padding)把偏移打乱。后面那串 static_assert 才是命门------它让「C++ 这一侧的布局」和「汇编那一侧写死的 0/8/16/.../56 偏移」在编译期就被绑定。谁要是手滑在中间加了个字段,编译直接红,而不是等运行时切到一半寄存器全错、查三天。
TaskState 是个简单的枚举:Running / Ready / Blocked / Dead。如实说:019 只用到前两个和最后一个------Ready(在队列里等着)、Running(正占着 CPU)、Dead(已退场、待回收)。Blocked 这个值在这一章定义了但没人用,它是给以后「线程等 I/O / 等锁」留的坑。看到枚举里有它,不代表功能已经在了。
context_switch.S:换栈,就是切换
这段汇编(context_switch.S)按 System V 约定拿参数:%rdi = from,%rsi = to。它分三段。
第一段,把当前任务的现场存进 from:
asm
context_switch:
movq %r15, 0(%rdi) # from->r15
movq %r14, 8(%rdi)
movq %r13, 16(%rdi)
movq %r12, 24(%rdi)
movq %rbp, 32(%rdi)
movq %rbx, 40(%rdi)
movq %rsp, 48(%rdi) # 存当前栈顶
leaq .restore(%rip), %rax # 算出"恢复点"的地址
movq %rax, 56(%rdi) # 把它当成 from->rip 存下来
最后那两行是整段的巧思。我们存 from->rip 时,存的不是「当前真正的下一条指令」,而是 .restore 这个标号的地址。含义是:「当这个任务将来被切回来时,请从 .restore 那里继续。」 这样一来,context_switch 对调用者来说就「像普通函数一样会返回」------只不过返回可能发生在很久以后、并且在它自己的栈上。
第二段,从 to 把现场恢复出来,并换栈:
asm
movq 0(%rsi), %r15 # to->r15
movq 8(%rsi), %r14
movq 16(%rsi), %r13
movq 24(%rsi), %r12
movq 32(%rsi), %rbp
movq 40(%rsi), %rbx
movq 48(%rsi), %rsp # ← 换栈: 从这一刻起, 跑在 to 的栈上
movq 48(%rsi), %rsp 这一行就是「切换」本身。CPU 的执行流,说到底就是「一段栈 + 一个 rip」。把 rsp 换成 to 的栈顶,这条流的整个调用链就变成了 to 的调用链;后面所有的 ret、所有的局部变量,全在 to 的栈上发生。
第三段,跳过去:
asm
jmp *56(%rsi) # 跳到 to->rip
.restore:
ret # 切回来时从这里继续, ret 回到调用者
为什么是 jmp 不是 call?因为我们已经亲手把 to->rip 准备好了,不需要 call 再往栈上压返回地址------那条返回地址我们自己管(下面 TaskBuilder 会压 exit_current)。jmp *56(%rsi) 一跳,要么进了一条全新线程的入口,要么落到了某个任务当初存下的 .restore------后者会执行 ret,干净利落地「返回」到当初调用 context_switch 的地方(yield / run_first),仿佛这个函数刚执行完一样,只是栈和时机都变了。
TaskBuilder.build:第一次切换,和以后的不一样
context_switch 跳到 to->rip。这就引出一个问题:一个全新 的任务,它的 ctx.rip 该是什么?它的栈上又该有什么?答案藏在 process.cpp 的 build() 里------这是 019 最精妙也最容易写错的一段:
cpp
Task* TaskBuilder::build() {
// ... 从堆 new 出 TCB、从 PMM 要 4 页(16 KB)做内核栈、把栈映射进高半区 ...
*reinterpret_cast<uint64_t*>(stack_virt) = STACK_MAGIC; // 栈底写 0xDEADC0DE (溢出哨兵)
task->ctx.rsp = stack_virt + stack_size - 8; // rsp 指向"栈顶 - 8"
*reinterpret_cast<uint64_t*>(task->ctx.rsp) =
reinterpret_cast<uint64_t>(&Scheduler::exit_current); // ← 在栈顶压 exit_current
task->ctx.rip = reinterpret_cast<uint64_t>(entry_); // rip = 线程入口
task->ctx.r15 = task->ctx.r14 = ... = task->ctx.rbx = 0; // callee-saved 清零
// ...
}
把这段拆成「它制造了什么效果」来看。当一个全新任务第一次被 context_switch 切到时:汇编恢复它全 0 的 callee-saved、把 rsp 设成它的栈顶、jmp entry_------于是线程函数从头开始跑,寄存器干干净净。这没问题。
妙的是线程函数 return 之后会发生什么 。context_switch 是用 jmp 跳进 entry_ 的,不是 call------所以线程函数的栈帧底下,没有一个「正常的返回地址」。如果什么都不做,线程函数一 return,ret 就会弹出栈顶那个值当返回地址;而栈顶此刻是我们故意压在那儿的 exit_current 的地址 。于是 return → ret → 跳进 exit_current()------线程干完活,自动走进调度器的退场流程,把自己标记为 Dead、移出队列、切给下一个。这个「栈顶压 exit_current」的小动作,是协作式线程能干净退出的关键。
对比一下两种任务的 ctx.rip:
- 全新任务 :
rip = entry_(线程函数),rsp指向压了exit_current的干净栈。第一次切换 → 从头跑线程。 - 被打断过的任务 :它上次被切走时,汇编把
.restore存进了它的rip。再切回来 → 跳到.restore→ret→ 回到它当初调用yield的地方继续。
同一个 context_switch,靠 to->rip 里存的是什么,自动区分「第一次启动」和「恢复执行」------这就是 019 上下文切换的二元性。栈底那个 0xDEADC0DE 不是装饰:它是栈溢出哨兵,如果某个线程把栈用爆了,这个 magic 会被改写,以后能据此报警。调试现场里你会看到它另一种意外出场方式。
RoundRobin + Scheduler:谁下一个
scheduler.cpp 里,RoundRobin 是个定长环形队列(64 槽)。它的 pick_next 有个值得看清的细节:
cpp
Task* RoundRobin::pick_next() {
if (count_ == 0) return nullptr;
Task* task = run_queue_[head_];
head_ = (head_ + 1) % MAX_TASKS; count_--; // 出队头
task->state = TaskState::Running;
run_queue_[tail_] = task;
tail_ = (tail_ + 1) % MAX_TASKS; count_++; // 又塞回队尾
return task;
}
它出队头之后,立刻把同一个任务塞回队尾 ------这就是「轮转」:被选中的任务轮到队尾排队,下一圈再轮到它。返回的任务此刻状态是 Running,但还在队列里(在队尾)。
Scheduler 是个静态门面,把上面这些粘起来。三个关键入口:
cpp
void Scheduler::yield() { // 协作式: 线程主动让出
Task* next = current_->sched_class->pick_next();
if (next == nullptr || next == current_) return; // 没别人, 不切
Task* prev = current_;
current_ = next;
context_switch(&prev->ctx, &next->ctx);
}
void Scheduler::exit_current() { // 线程 return 后走到这里
Task* prev = current_; // 先存!
prev->state = TaskState::Dead;
prev->sched_class->dequeue(prev); // 彻底移出队列
Task* next = default_rr_.pick_next();
if (next == nullptr) { // 队列空: 没人可切
kprintf("[SCHED] No more tasks, halting.\n");
while (1) __asm__ volatile("cli; hlt"); // 永久停机, 不返回
}
current_ = next; // 仅 next != nullptr 时走到这
context_switch(&prev->ctx, &next->ctx); // from != to
}
exit_current 第一行 Task* prev = current_; 看着多余,其实是上一版 bug 的直接修复------调试现场会讲:如果先 current_ = next 再切换,from 和 to 就指向同一个任务,context_switch 变成空操作,执行继续停在已经死亡的线程栈上,最终炸成 0xDEADC0DE。先存 prev,保证 from != to。
run_first 是引导:它拿一个栈上的临时 boot_task (tid=0,从不入队)当起点,pick_next 取出第一个真任务,切过去。从此 CPU 再也不回到这个 boot_task------它只是个跳板。
higher-half 收口:内核该待在高半区
最后这一块不是「新功能」,是「把上一章埋的雷拆了」。看 elf_loader.cpp 末尾,019 之前是这么返回入口的:
cpp
// 旧(错): 把 higher-half 入口剥回物理地址
constexpr uint64_t HIGHER_HALF_BASE = 0xFFFFFFFF80000000ULL;
uint64_t entry = saved_entry;
if (entry >= HIGHER_HALF_BASE) entry = entry - HIGHER_HALF_BASE; // 0xFFFFFFFF81000000 → 0x1000000
return entry;
// 019(对): 直接返回链接时的 higher-half 入口
return saved_entry;
得结合 linker.ld 才看得懂为什么这是 bug:
text
KERNEL_VMA = 0xFFFFFFFF80000000 (higher-half 虚拟基址)
KERNEL_LMA = 0x1000000 (物理加载地址, 16 MB)
. = KERNEL_VMA + KERNEL_LMA → 内核 .text 链接在 0xFFFFFFFF81000000
大内核是按 higher-half 地址 0xFFFFFFFF81000000 链接 的------它内部所有符号地址、所有绝对地址引用,都指望自己跑在这个地址上。可旧的 ELF 加载器把入口剥成了 0x1000000,然后 mini-kernel 跳过去。这之所以「能跑」,纯粹是因为引导加载程序顺手建了一条恒等映射 (PML4[0] → 物理,盖住 0x1000000),让 0x1000000 和 0xFFFFFFFF81000000 指向同一片物理页。
但这件事和 018 的地址空间设计正面冲突 。回忆 018:AddressSpace 的设计是「内核半区 PML4[256..511] 跨所有空间共享,用户半区 PML4[0..255] 每个空间私有」。内核理应待在共享的 高半区,这样无论切到哪个地址空间,内核映射都在。可旧的加载器让内核跑在 PML4[0](恒等映射,落在用户半区 )------这正是每个地址空间各自私有、要重新建的那一半。于是麻烦来了:一旦开始给不同进程造独立地址空间,内核待在「本该私有」的那一半里,页表子树就被多个空间错误地共享,一个空间里建的页表项会顺着共享的 PDPT 子树泄漏 到别的空间------进程隔离形同虚设。019 的调试笔记 001_higher_half_fix 记录了这条症状。
修复就一句:return saved_entry;,让内核回到它链接的 higher-half 地址,待在共享的高半区------隔离的地基这才算稳。(顺带一提,main.cpp 里那行 [BIG] Big kernel running @ 0x1000000 是个遗留字符串 ,它打的是物理基址,不代表修复后的运行地址;别被它误导以为内核还跑在 0x1000000。)
同一次收口里,还有两处配套小修。一是缺页处理 exception_handlers.cpp 的 handle_pf:以前需求分页调 g_vmm.map(virt, phys, flags),默认映射进内核 PML4;现在先 read_cr3() 拿到当前地址空间的 PML4,把 &cur_cr3 传进去,让缺页页落在当前 空间里(否则一旦真有多地址空间,缺页修错了地方),而且映射失败时会 free_page 把物理页还回去(修了个小泄漏)。二是 vmm.cpp 的 walk_level 多了大页拆分:当要下钻的区域恰好被一张 2 MB 大页盖住、而我们想要 4 KB 粒度时(比如给任务栈映射),它分配一张新页表,把那 2 MB 拆成 512 个 4 KB 项,再用新页表替掉大页项。这两处都是「为多地址空间铺路」的零碎活,019 顺手做了。
调试现场
这一章有两份真实笔记,都值得当案例,因为它们都是「看起来能跑、实际埋雷」的典型。
案例一:线程退出炸成 0xDEADC0DE
生产里两个线程交替打 5 轮后崩溃,串口吐出 RIP=00000000deadc0de,然后三重错误重启。根因是两个 bug 叠加。
其一是 TaskBuilder 当初没在栈顶压返回地址------线程函数 return 时 ret 弹空栈,一路弹到栈底那个 0xDEADC0DE 哨兵,把它当地址跳过去,CPU 跳到 0xDEADC0DE 当然炸。这正好解释了为什么现在 build() 里非要有那句「栈顶压 exit_current」:它就是给线程函数的 return 准备的落脚点。修复一:栈顶压 exit_current。
其二是就算修了第一个 bug,exit_current 本身还有毛病:它先 把 current_ 改成下一个任务,再 调 context_switch(¤t_->ctx, &next->ctx)------可这时 current_ 已经是 next 了,from 和 to 指向同一个任务,切换成了空操作,执行继续停在已经死亡的线程栈上,该崩还是崩。修复二:进 exit_current 第一件事先 Task* prev = current_;,用 prev 当 from,保证 from != to。两个 bug 都修,两个线程才能干净利落地各打 5 轮、各打一句 done、各自 [SCHED] ... exited,最后队列空了打 No more tasks, halting.。
案例二:higher-half 没扶正,进程隔离失效
上面代码路线讲过:旧加载器剥掉 higher-half 偏移,让内核跑在恒等映射的 PML4[0](用户半区),破坏了「内核待在共享高半区」的设计,导致地址空间之间页表子树泄漏。这条 bug 在 019「只有内核线程、还没真造多个用户地址空间」时不会立刻发作------因为演示的两个线程共享内核地址空间。但它是颗定时炸弹:一旦 020 之后真给进程造独立地址空间,这颗雷就会以「A 进程的页表项莫名出现在 B 进程里」的形态爆出来。019 把加载器扶正,等于在雷爆之前拆了引信。这种「当下不发作、但迟早要命」的 bug,是做内核时最值得记进笔记的一类。
收尾:验证 + 下一站 + 参考
验证
调度逻辑(队列轮转、入队出队、CpuContext 布局、TaskBuilder 字段)在 host 上镜像着测。test_scheduler.cpp 把 RoundRobin、Scheduler、TaskBuilder、CpuContext 的逻辑在 host 侧重写了一份(不链内核代码,-O2 编、CINUX_HOST_TEST 门控),盯这些:空/满/单任务队列的 pick_next、dequeue 中间项、TaskBuilder 的字段默认值与 null entry 守卫、CpuContext 的 sizeof 和各偏移:
bash
ctest --test-dir build -R scheduler --output-on-failure
真正的 context_switch(真汇编换栈)和真正的任务构造(真 PMM/VMM/Heap 出栈)只能在 QEMU 里验。test_scheduler.cpp 在机内跑一连串场景:TaskBuilder 能造出合法任务(tid 从 1 起、state=Ready、ctx.rip 指向入口、栈非 0------这一项用的是真 PMM/VMM/Heap 建出来的栈),null entry 返回 nullptr,init 注册默认 RoundRobin,RoundRobin 的 enqueue/dequeue/pick_next 轮转与出队中间项,CpuContext 布局(sizeof==64、各 offset);而专门验 context_switch 本身的那一项用的是两个裸 CpuContext + 静态栈缓冲区 (不经过 TaskBuilder),纯测汇编换栈能不能让两个上下文来回切、状态在 Ready/Running 间正确流转:
bash
cmake --build build --target run-big-kernel-test
机内会打 [SCHED] Scheduler initialised with RoundRobin class,test section Scheduler/Process Tests (019) 全过、末尾 ALL TESTS PASSED,就说明这套任务/切换/调度在真硬件语义下成立。
最后是生产 demo 本身的现象:直接跑大内核(cmake --build build --target run,或对应 QEMU 目标),串口应该看到 thread_a / thread_b 严格交替的 5 轮、各自一句 done、各自 [SCHED] Task tid=N '...' exited,最后 No more tasks, halting.------交替说明 context_switch 真的把 CPU 在两条流之间递来递去,干净退出说明「栈顶压 exit_current」那条设计是对的。
下一站
到这里,内核第一次有了「多条活着的执行流」。Task 把一条执行流的所有可挂起状态装进 64 字节;context_switch.S 用「存 callee-saved + 换栈 + 跳 rip」在它们之间瞬切;RoundRobin 轮流点名;higher-half 扶正让隔离地基稳了。
但你会发现 019 的痛:它是协作式 的。线程要是不主动 yield,它就霸着 CPU 不放------thread_a 如果忘了调 yield,thread_b 永远没机会跑。真实的系统不能指望每个线程都自觉。下一站(020)就治这个:把调度器接到那个已经在跑 的时钟中断上,让 irq0 handler 在固定节拍打断当前线程、强制切走------也就是抢占式 调度。那会引入新的难题(中断可以在任意指令处发生,不再是干净的函数边界;切走时要保存的现场更重;多个 CPU 各自的当前任务怎么管),于是 020 还会带来 per_cpu 和最基本的同步原语。019 的 Task 和 context_switch 是那一切的地基------地基先稳,上面才好盖。
参考
- System V AMD64 ABI (x86-psABIs/x86-64-ABI):callee-saved 寄存器约定(
rbx、rbp、r12--r15)------CpuContext只存这 6 个 +rsp/rip的全部依据;caller-saved 跨调用不保证存活,故无需保存。 - xv6-riscv
swtch()(mit-pdos/xv6-riscv):同样的「只存 callee-saved + 换栈 + ret」手法,可对照 Cinuxcontext_switch的设计。 - Intel SDM Vol.3 (本地
document/reference/intel/SDM-Vol3A-*.pdf):通用寄存器集、rip/rsp如何定义执行流、规范地址(higher-half 的由来),可用pdf-reader搜 "general-purpose" / "canonical" 复核。 - 018 章 · 给每个世界一套页表:地址空间:
AddressSpace的「内核半区PML4[256..511]共享、用户半区私有」设计------higher-half 收口之所以必要,就是为了对上这个设计。 - 本 tag 源码:process.hpp / process.cpp、context_switch.S、scheduler.hpp / scheduler.cpp、elf_loader.cpp(
return saved_entry)、linker.ld(KERNEL_VMA/KERNEL_LMA);测试 test_scheduler.cpp(host 镜像)、test_scheduler.cpp(QEMU 机内)。
二、时钟到点,该换人了:抢占式调度(原 020)
019 造出了
Task、context_switch、RoundRobin,可它是协作式的------线程不主动yield就霸着 CPU。这一章把调度器挂到那个从 011 章起就在跑的 PIT 时钟中断上,让 IRQ0 在固定节拍强行打断当前线程,也就是抢占式多任务;顺带把idle任务、TSS.RSP0更新、PerCPU占位和最朴素的Spinlock原语一并铺上。
导引:点亮什么、为什么、设计图
这一章我们要点亮什么
核心是把「换人」的发起者从线程自己,挪到时钟中断。具体说,020 交付五块:
- 时钟驱动抢占 :
Scheduler新增tick()/schedule()/is_initialized(),外加DEFAULT_TIME_SLICE = 2。PIT::irq0_handler发完 EOI 之后调Scheduler::tick();tick()数节拍,每到 2 就schedule()一次。而schedule()把当前任务标回Ready、pick_next取下一个、先更新TSS.RSP0、再context_switch。最关键的一笔:yield()从此不再自己挑下一个,而是直接转调schedule()------也就是说,020 之后「主动让出」和「被时钟打断」走的是同一条切换路径。 context_switch.S的sti修复 :在「换栈之后、jmp到新任务之前」插一条sti。这是本章最硬的一个 bug 修复,根因在中断门的硬件语义。idle任务 :队列空了不再cli;hlt停机,而是落到一个只hlt的idle_task;PerCPU占位同时落地,为多核先挖个坑。TSS.RSP0更新 :GDT::tss_set_rsp0(uint64_t)在每次切到非 idle 任务时更新------接口先接上,等将来有 ring3 才真正生效。sync.hpp的Spinlock原语 :acquire/release/ RAIIguard三件套,基于 GCC__atomic内建。
边界要摆正,免得读者误会:020 的 Task 全是内核线程 ,Task::addr_space 仍然没人填,没有用户态、没有 ring3、没有系统调用;RoundRobin::pick_next 不读 priority (Task::priority 字段和 idle_task 的 255 一样,只是「为以后留」);block / unblock 虽然实现了 API,但没有任何调用方 真拿它们做阻塞同步;Spinlock 只定义 了、还没人用 ;PerCPU 是单核静态全局、不是 GS 相对寻址的真 per-CPU 区。这些都是「接口先到位、能力后到位」的诚实状态,不是已经发挥作用。
为什么现在需要它
019 的局限一句话:线程要是不主动 yield,CPU 就永远是它的。演示里我们得靠每个 worker 循环里手动调 yield() 才能看到两个线程交替;真把 yield() 注释掉,thread_b 永远没机会跑。这种「靠自觉」的调度,在跑一个死循环的坏线程面前直接失效。
把换人的发起者交给时钟,听着只是「换个触发源」,但它带来了一个 019 没有的麻烦:切换点从干净的函数边界,挪到了「中断可以打断的任意指令处」 。019 的 context_switch 之所以只存 callee-saved(r15/r14/r13/r12/rbp/rbx)+ rsp + rip 这 8 个值、不碰 RFLAGS,有一个隐含前提:切换永远发生在函数调用边界上,而 System V AMD64 ABI 保证「调用前后 RFLAGS 不是被调方的义务」------RFLAGS 不在 callee-saved 之列(rbx/rbp/r12-r15),编译器要么已经替调用方把要用的 flag 存好了,要么根本不在乎。协作式正好踩在这条假设上,所以不存 RFLAGS 没事。
可一旦切换是被时钟中断逼出来的,调用 context_switch 的就不再是 yield() 这个普通函数,而是 IRQ0 的中断处理程序。CPU 一进中断门,硬件会先把 RFLAGS(连同 CS/RIP)压栈、并清掉 IF ------而 context_switch 只换 callee-saved 和 rsp/rip,根本不恢复 RFLAGS。结果就是:从这个中断上下文切出去的新任务,继承了 IF=0 ,从此屏蔽了所有可屏蔽中断,再也不会被下一次时钟打断。这个坑在本章「调试现场·案例二」里会以具体症状出现,sti 那条修复就是为它准备的。换句话说,「把调度挂到时钟」逼着我们重新审视 context_switch 对中断状态的态度------这是 cooperative 迈向 preemptive 时一个经典且几乎必踩的陷阱。
顺带回答两个「为什么」。为什么需要 idle 任务?因为 019 队列空了就 cli;hlt 永久停机,生产 demo 跑完直接把机器卡死;有了 idle,所有真任务都退场后还有地方歇,而且它不进就绪队列、不会反过来抢真任务。为什么现在就先把 Spinlock 定义出来却不急着用?因为 020 把切换挂到了时钟上之后,调度器、就绪队列、PIT 这些共享数据理论上已经可能被「中断打断 + 新任务」的路径碰到------虽然单核 + 中断门语义下「真并发」还没发生,但 021 一旦要审查并发安全,手边就得有这么一把原语可用。先备着,不演示加锁路径。
设计图
先看抢占是怎么被触发起来的。这是 020 的主轴,也是 sti 那条修复落点最清楚的一张图:
text
IRQ0 到来(每 10ms @ 100Hz)
│ CPU 进 ISR: 压 SS/RSP/RFLAGS/CS/RIP 到被中断任务的栈, 清 IF(中断门语义)
▼
irq0_stub (IF=0) ──call──► pit_irq0_handler(frame)
│
tick_count_++
PIC::send_eoi(0) ◄── EOI 先发, 保证下一个 IRQ 能到
│
Scheduler::tick()
│ current_slice_++; 到 DEFAULT_TIME_SLICE(=2) 就:
▼
Scheduler::schedule()
│ ① prev=current_, Running 标回 Ready
│ ② next=RoundRobin::pick_next()
│ ③ 空/同则回落 idle 或原任务(直接 return, 不切)
│ ④ 同步 current_ / g_per_cpu.current / current_slice_
│ 切到非 idle 则 GDT::tss_set_rsp0(next->kernel_stack_top)
▼
context_switch(&prev->ctx, &next->ctx)
│ 换栈后、jmp 前: sti ◄── 本章核心修复
▼
┌──────────────────┴──────────────────┐
▼ ▼
next 是全新任务 next 是被打断过的任务
ctx.rip=入口, jmp 进线程函数 ctx.rip=.restore, ret 链回到 ISR stub
sti 让它以 IF=1 起跑, 能收到下一次时钟 → IRETQ 还原被压栈的原始 RFLAGS(IF=1)
(sti 对它是无害冗余)
两条「退路」都通向「中断重新打开」:全新任务靠那条 sti,被抢占过的任务靠 IRETQ 把压栈的旧 RFLAGS(IF=1)还回去。这正是 sti 只加一处、却能让所有任务都正常的关键。
再看时间片轮转的实际节奏,和 019 的严格交替形成对比:
text
019(协作式):线程自己 yield 才切, 严格交替
A0 A1 A2 A3 A4 done │ B0 B1 ... B4 done │ halt
020(抢占式):6 线程被时钟在忙循环中间打断, 谁先到 2 个 tick 谁让位
A.it1 ~~~ [tick][tick] ▶ B.it1 ~~~ [tick][tick] ▶ C.it1 ~~~ ...
(A 的忙循环没跑完就被切走; 稍后轮回来从 .restore 继续)
串口看到的不再是 A 整段跑完才轮到 B, 而是 A/B/C/... 被 20ms 节拍交错打断
最后是 TSS.RSP0 在切换里的角色,得诚实标注它的现状:
text
切到新任务前: GDT::tss_set_rsp0(next->kernel_stack_top)
└─► 直接写 g_gdt.tss_.rsp[0]
TSS.RSP0 的语义(SDM §6.12.1): 特权级升高(ring3→ring0)时, 硬件从 TSS 取新栈顶
┌─────────────────────────────────────────────────────────┐
│ 020 现状: 全程 ring0 内核线程, 不发生特权级变化 │
│ → 这条更新现在其实不会触发硬件换栈 │
│ → 但接口先接上是「对的」, 等 ring3/用户进程来了就生效 │
└─────────────────────────────────────────────────────────┘
(切到 idle 时跳过 tss_set_rsp0: idle 没有独立内核栈要登记)
代码路线:时钟驱动抢占与配套地基
tick 与 schedule:让时钟来点名
pit.cpp 的 irq0_handler 末尾只多了一行(加一个 include),但这一行就是协作→抢占的总开关:
cpp
void PIT::irq0_handler(InterruptFrame* /*frame*/) {
tick_count_++;
PIC::send_eoi(0); // 先 EOI: 让 PIC 准备好送下一个 IRQ
cinux::proc::Scheduler::tick(); // 再调度: 在 tick 里可能切走当前任务
}
顺序为什么是「先 EOI 再 tick」?因为 tick() 一旦走到 schedule(),就可能 context_switch 切到另一个任务,很久不回来。如果先切再 EOI,PIC 还以为上一个 IRQ 没处理完,下一个时钟就送不进来------抢占直接哑火。先把 EOI 发了,再让调度器去折腾切人,这条时序不能反。
scheduler.cpp 的 tick() 很短,职责就一件------数节拍、到点喊 schedule():
cpp
void Scheduler::tick() {
if (!initialized_ || current_ == nullptr) return; // 没就绪/没任务就不动
tick_count_++;
current_slice_++;
if (current_slice_ >= DEFAULT_TIME_SLICE) { // DEFAULT_TIME_SLICE = 2
current_slice_ = 0;
schedule();
}
}
current_ == nullptr 那条守卫不是多余的:main.cpp 里必须先 Scheduler::init() + 建好所有任务、再 PIC::unmask(0) + sti。顺序颠倒,时钟中断会在 current_ 还没就位时炸进来,schedule 里拿 prev = current_ 就空指针了。生产代码里那行 sti 出现在「6 个任务都 add_task 之后」,正是这个顺序约束的体现。
真正干活的是 schedule(),它把「标回 Ready → 取下一个 → 同步状态 → 换栈」串成一条:
cpp
void Scheduler::schedule() {
if (current_ == nullptr) return;
Task* prev = current_;
if (prev->state == TaskState::Running)
prev->state = TaskState::Ready; // 让出 CPU, 回就绪队列里等着
Task* next = default_rr_.pick_next();
if (next == nullptr || next == prev) { // 没别人 / 只剩自己
if (prev->state != TaskState::Blocked && prev->state != TaskState::Dead) {
prev->state = TaskState::Running; // 自己接着跑, 不切
return;
}
if (idle_task_ != nullptr && idle_task_ != prev) {
next = idle_task_; // 真没活儿了, 落到 idle
} else {
return;
}
}
current_ = next;
g_per_cpu.current = next; // 同步 PerCPU 占位
current_slice_ = 0; // 新任务重新计时
if (next != idle_task_)
cinux::arch::GDT::tss_set_rsp0(next->kernel_stack_top);
context_switch(&prev->ctx, &next->ctx); // 真切: 进去 prev, 出来在 next 栈上
}
几个点值得停一下。next == prev 那条分支是为了「只有自己一个任务」时不做无谓切换------RoundRobin::pick_next 会把唯一的任务轮到自己头上,这时与其假切一次,不如原地继续。落到 idle 的判断放在「prev 已经 Blocked/Dead」之后:当前任务只是普通让出、队里又有别人,不会走到 idle;只有真的没人可切、且自己又不能继续(阻塞或死亡),才把 CPU 交给 idle。current_slice_ = 0 看着琐碎,却是公平的关键------不归零,新任务一上来就可能因为 prev 残留的计数被立刻切走。
而 019 里那个自己挑下一个的 yield(),020 里退化成了 schedule() 的别名:
cpp
void Scheduler::yield() {
if (current_ == nullptr) return;
schedule(); // 主动让出和被时钟打断, 走同一条路
}
这是一笔重要的简化:从此「谁下一个」的逻辑只有一份(schedule),不管触发源是 yield 还是 IRQ0。少一条路径,就少一种「两处逻辑不一致」的 bug。
context_switch.S 的 sti:从中断上下文切出去,必须把中断打开
这段是本章的灵魂。context_switch.S 在恢复完 callee-saved、换完栈之后,跳转之前,新加了一条 sti:
asm
movq 48(%rsi), %rsp # to->rsp → %rsp: 换栈, 执行流从此切到新任务
sti # 开中断 ------ 本章核心修复
jmp *56(%rsi) # 跳到 to->rip(全新任务的入口, 或被打断任务的 .restore)
为什么协作式不写这条、020 非要写?根在「谁在调用 context_switch」。019 的调用者是 yield() ------一个普通函数,调用前后 IF 不变,context_switch 进来时 IF 该是多少还是多少,跳进新任务时继承的也是这个值,没问题。020 的调用者是 IRQ0 的中断处理程序------而 CPU 一进中断门,硬件会清掉 IF (SDM Vol.3A §6.12.1.3 原文:经中断门访问 handler 时,处理器清 IF 标志以防止其它中断干扰当前 handler;陷阱门则不清)。所以从中断上下文里调 context_switch,进来时 IF=0,换栈、jmp 进新任务后,新任务继承了 IF=0------它再也收不到下一次时钟,抢占在它身上永久失效。
sti 在这里干两件事。对全新任务 (第一次被切到,ctx.rip 是线程入口):它以 IF=1 起跑,时钟能正常打断它。对被抢占过的任务 (恢复运行,ctx.rip 是 .restore):它恢复后沿 ret 链一路退回 IRQ0 stub、由 IRETQ 把压栈的旧 RFLAGS(IF=1)还回来------这条 sti 对它是个无害的冗余,因为紧接着 IRETQ 会重写 IF。
sti 紧贴 jmp、中间不夹别的指令,不是随手排的。STI 有一条「延迟一拍」的硬件语义:执行 STI 之后,中断要等下一条指令执行完 才被响应。这一点 SDM Vol.2B 在 STUI 条目里用对比写明了------它说 STUI 的效果「立即生效,这与 STI 相反,后者的效果会延迟一条指令」。把 sti 直接接在 jmp 前,意味着「换栈 + 跳转」这一瞬不会被中断从中间劈开,切换是原子的;jmp 一落地,中断窗口才重新打开。这就是这条 sti 既能修 bug、又不会在切换中途给自己添乱的原因。
诚实说一句:这套方案不是完美无瑕。被抢占的任务恢复后,从 .restore 一路 ret 退到 IRQ0 stub、再到 IRETQ,这段退栈路径上 IF 已经被 sti 打开了------理论上存在一个极短的窗口,期间可能被嵌套中断命中。笔记 002 自己算过:100Hz 时钟间隔 10ms,这段退栈是微秒级,命中概率可忽略,但不是零 。更精细的做法是把 RFLAGS 纳入 CpuContext、用 pushfq/popfq 在切换点显式保存恢复中断状态------那是将来的事,020 没做。本章只交付「一条 sti 修掉 IF 丢失」这个最简洁的版本,并保留这层诚实。
idle 任务:队列空了也有地方歇
init() 用 TaskBuilder 造一个 idle 任务,入口只做一件事------死循环 hlt:
cpp
void Scheduler::idle_entry() {
while (true) {
__asm__ volatile("hlt"); // 没活儿就睡, 等下一个中断(时钟)唤醒
}
}
// init() 里:
idle_task_ = TaskBuilder()
.set_entry(idle_entry)
.set_name("idle")
.set_priority(255) // 最低优先级(虽然 020 还没读它, 留个语义)
.build();
if (idle_task_ != nullptr)
idle_task_->state = TaskState::Ready;
两个细节。第一,idle 不进就绪队列 ------注意 init() 里只 build() 了它,没有 add_task(idle_task_)。为什么?因为 RoundRobin::pick_next 是个公平轮转,如果 idle 在队里,它就会和真任务一起被轮流选中,反过来抢占真任务的 CPU 时间。idle 只在 schedule / exit_current 发现「队里没人」时被显式地 当作兜底选中(next = idle_task_),而不是从队列里冒出来。第二,切到 idle 时跳过 tss_set_rsp0 ------前面 schedule 里那句 if (next != idle_task_) 守的就是这个:idle 没有要登记的「下次进内核态用的栈」,它的 kernel_stack 是 TaskBuilder 默认给的那份,从不被硬件换栈路径用到(当前全程 ring0,见下一节)。
有了 idle,019 那个「队列空了就 cli;hlt 永久停机」的粗暴收尾就被替换掉了:exit_current 里真没任务时落 idle 而不是停机,机器保持可响应(还能收键盘中断、还能被时钟唤醒),而不是死掉。
TSS.RSP0 与 GDT::tss_set_rsp0
gdt.cpp 新增一个直写的静态方法:
cpp
void GDT::tss_set_rsp0(uint64_t rsp0) {
g_gdt.tss_.rsp[0] = rsp0; // 直接写 TSS 里 ring0 的栈顶槽
}
TSS 结构体(gdt.hpp)按 SDM Vol.3A Figure 8-11 / §8.7「Task Management in 64-bit Mode」摆好 104 字节(源码注释里把它标成「Table 8-2」,但 SDM 实际以 Figure 8-11 呈现这张表),rsp[3] 是三个特权级的栈顶(ring0 用 rsp[0])。每次 run_first / schedule / exit_current 切到非 idle 任务,都调一次 tss_set_rsp0(next->kernel_stack_top)。
为什么要这么干?SDM §6.12.1 说:当 handler 要在更低特权级 (数值更大,即 ring3→ring0)执行时,处理器会从当前任务的 TSS 取 handler 要用的新栈顶(SS:RSP)。也就是说,RSP0 是「下一次从用户态掉进内核态时,硬件自动换上的那个内核栈顶」。既然每个任务有自己的内核栈,切到新任务时就得把 RSP0 指向新任务的内核栈顶,否则将来真有用户态进程时,缺页、系统调用掉进内核会用到上一个任务的内核栈,栈错位直接炸。
但必须如实说:020 全程是 ring0 内核线程,不发生任何特权级变化,所以这条 tss_set_rsp0 现在其实不会触发硬件换栈------硬件压根没走到「从 TSS 取栈」那一步。它是个「接口先接上、等将来 ring3 来了再真正生效」的动作。写它、调它,是为了将来有用户进程时这块不用再回来补;不是因为它现在已经在保护什么。
PerCPU 占位:为多核先挖个坑
per_cpu.hpp 整个文件就这么点东西:
cpp
struct PerCPU {
Task* current; // 当前在跑的任务
uint64_t kernel_stack; // 内核栈顶(留给将来 RSP0 登记)
};
extern PerCPU g_per_cpu; // scheduler.cpp 里定义: PerCPU g_per_cpu{nullptr, 0};
每次切换,schedule / run_first / exit_current 都同步一句 g_per_cpu.current = next;。得诚实讲清楚它不是 什么:它不是 GS 基址相对寻址的真 per-CPU 区,也不是每 CPU 独立运行队列,就是一个单核静态全局变量 。020 只有一个 CPU,放它纯粹是为了让「将来 current 从全局迁移到 per-CPU」时改动小------先把读取入口统一到 g_per_cpu.current,将来换成 GS 相对寻址时,只动这一个定义,调用点不用大改。别把它说成 SMP 地基,它现在连第二份实例都没有。
sync.hpp:Spinlock 原语,先定义着
sync.hpp 的 Spinlock 三件套:
cpp
class Spinlock {
public:
void acquire() {
while (__atomic_test_and_set(&locked_, __ATOMIC_ACQUIRE)) // 原子置 1 并返回旧值
__asm__ volatile("pause"); // 自旋提示, 降功耗、避免乱序违例
}
void release() {
__atomic_clear(&locked_, __ATOMIC_RELEASE); // 原子清 0
}
[[nodiscard]] auto guard() { return Guard(this); } // RAII: 构造 acquire, 析构 release
private:
volatile bool locked_ = false;
class Guard { /* 构造 acquire / 析构 release / 禁拷贝禁赋值 */ };
};
__atomic_test_and_set 是「把目标字节原子地置 1、并返回它的旧值」的标准内建,在 x86 上编译成带 LOCK 前缀的 xchg 或等价指令;__ATOMIC_ACQUIRE / __ATOMIC_RELEASE 配对保证「拿到锁之后读到的内存视图」和「释放锁之前的写」按正确的可见序传递。pause 是给超线程 CPU 的提示:告诉硬件「我在自旋,别把整个流水线占满」,顺便避免一段长自旋触发内存序违例惩罚。[[nodiscard]] auto guard() 让调用方写成 auto g = lock.guard();,出了作用域自动释放,忘不了。
定性很重要:020 只定义 了 Spinlock,没有任何代码用它。调度器、就绪队列、PIT 计数器------全都还是裸的、没加锁。它是为 021「立刻审查现有组件的并发安全性」备的原语,本章不演示一段加了锁的调度路径。看到这个类存在,不等于它已经在保护什么。
调试现场
两条真实笔记,都压成「症状→根因→定位→修复→防复发」。它们恰好是抢占式上线时最常遇到的两种「看着能跑、其实没生效」。
案例一:时间片过长,抢占从未触发
症状是 3 个线程各跑 5 轮,完全顺序执行,串口上 A 整段跑完才轮到 B,B 跑完才轮到 C,没有任何交错------和 019 的协作式 demo 看起来一模一样,仿佛时钟根本没接上调度器。
根因不在调度器,而在「时间片和负载的配比」。PIT 配 100Hz(每 tick 10ms),当时 DEFAULT_TIME_SLICE = 10,也就是 100ms 才触发一次抢占 。而忙循环 for (volatile int j = 0; j < 1000000; j++) {} 在 QEMU TCG 模式下极快,单次迭代不到 5ms,5 轮加起来 < 50ms------线程在自己的 100ms 时间片之内就跑完了,时钟压根没机会在它跑的过程中打断它。所以现象是「顺序跑完」,但原因不是「没抢占」,而是「负载太轻、时间片太长,抢不到点上」。
修复两处一起改:DEFAULT_TIME_SLICE 从 10 调到 2(20ms 时间片),忙循环从 100 万次提到 2000 万次(让每个线程的工作量明显跨过多个时间片)。两者都改,是为了从两头把「线程在片内跑完」的可能挤掉。
防复发的教训:在虚拟化环境里,简单的 CPU 密集循环比裸机预期快得多。测抢占时,要么把负载做大 、要么把时间片做小,让定时器有机会在任务执行中途介入------否则你看到的「顺序执行」会骗你以为抢占没生效,而去查调度器,其实调度器一直好好的。
案例二:context_switch 丢了 IF,后续线程中断全关
这个比案例一阴险得多。症状是 6 个线程 × 10 轮 × 2000 万忙循环:第一次抢占成功了(A 跑了 2 次后被切到 B),但之后 B/C/D/E/F 全部顺序跑完、再也没被抢占过------只有 A 这一个被抢占过的任务能恢复中断,其余新启动的线程都带着 IF=0 一路跑到底。
根因要接上前面代码路线讲过的硬件语义。context_switch.S 只存 callee-saved + rsp/rip,不碰 RFLAGS------这在协作式下没问题(切换在函数边界,IF 不变)。但抢占的调用链是:
text
IRQ0 → ISR stub(CPU 进中断门, 清 IF) → pit_irq0_handler → Scheduler::tick → schedule → context_switch
CPU 一进中断门就清 IF(SDM §6.12.1.3),所以 context_switch 是在 IF=0 的状态下被调用的。它换栈、jmp 进新任务时,把 IF=0 一起带过去了。于是两种任务的命运分叉:
- 全新任务 (
ctx.rip= 入口,第一次被切到):jmp直接跳进线程函数,IF 仍为 0,再也收不到时钟中断 → 永不被抢占。这正是 B--F 的遭遇。 - 被抢占过的任务 (
ctx.rip=.restore):恢复后ret一路退回 IRQ0 stub,由IRETQ把压栈的旧RFLAGS(IF=1)还原(SDM §6.12.1:IRET 把保存的标志恢复进 EFLAGS)→ 中断恢复。这正是 A 能正常的原因。
所以只有第一个被抢占的 A 靠 IRETQ 救了回来,后面所有新启动的线程全带着 IF=0。定位的关键就是认出「只有被抢占过的任务正常、全新任务都哑」这个非对称------它直接指向「切换瞬间中断状态没保证」。
修复就是在 context_switch.S 换栈之后、jmp 之前加一条 sti,让新任务以 IF=1 起跑。为什么对被抢占过的任务也安全?因为它们随后会走 IRETQ 重写 IF,sti 是冗余;为什么不会引入新麻烦?因为 sti 紧贴 jmp,STI 的「延迟一拍」语义保证换栈+跳转这一瞬不被中断劈开(见代码路线那节)。
防复发的教训:协作式的 context_switch 设计时不碰 RFLAGS 是合理的 ------它永远在明确的调用点切,IF 不变。可一旦从中断上下文里复用同一个 context_switch ,就必须保证新任务的中断状态正确。这是 cooperative 迈向 preemptive 的经典陷阱,凡是「把现成的协作式切换直接塞进中断 handler」的实现,几乎都要在这一条上栽一次。更彻底的修法是把 RFLAGS 纳入 CpuContext、用 pushfq/popfq 显式保存恢复------020 没做,留作将来。
收尾:验证 + 下一站 + 参考
验证
先说 host 侧的覆盖现状,得如实:020 没有新增独立的 host 单元测试 。019 那组镜像 test/unit/test_scheduler.cpp(测 RoundRobin/Scheduler/TaskBuilder/CpuContext 的纯逻辑)在 020 的 diff 里未改动,host 侧覆盖仍停留在那一组。020 新增的 tick/schedule/block/unblock 这些没有 host 镜像 ------它们要么依赖 context_switch 的真汇编换栈,要么依赖中断/PIT 的真硬件语义,只能在 QEMU 里验。
bash
# host 侧(019 那组, 020 未变)
ctest --test-dir build -R scheduler --output-on-failure
真正的验证在 QEMU 机内。test_scheduler.cpp 跑在真 PMM/VMM/Heap 之上,节名从 019 的 (019) 改成了 (020),并在原有用例之后新增 Test 9 test_scheduler_new 三个用例:
bash
cmake --build build --target run-big-kernel-test
机内 TEST_SECTION("Scheduler/Process Tests (020)") 应全过,其中新增三项分别盯:test_is_initialized(init() 后 is_initialized()==true)、test_remove_task(add 后 remove,state 变 Dead)、test_block_unblock(add 后 block→Blocked、unblock→Ready)。注意这三个是状态机/接口层面的验证,不触发真抢占------抢占只能靠下面的生产 demo 用肉眼验。
最后是生产 demo,这是抢占是否真生效的唯一肉眼验证。跑大内核:
bash
cmake --build build --target run
串口应该看到 [A] / [B] / [C] ... 六个线程被时钟中断交错 打断------不再是 019 那种 A 整段跑完才轮到 B 的严格顺序,而是某个线程的忙循环跑到一半就被切走、换另一个线程的输出插进来、稍后再轮回来。每个线程最后各自打一句 done。如果你看到的不是交错、而是「只有第一个线程被抢占、其余顺序跑完」,那就是撞上了案例二那条 IF 丢失的 bug ------sti 没接上,回去查 context_switch.S。
下一站
020 把切换挂到了时钟上,「谁让出 CPU」不再靠线程自觉。可代价也摆在眼前:调度器、就绪队列、PIT 计数器这些共享数据,理论上已经暴露在「被中断打断、又被新任务碰」的并发路径下了------虽然单核 + 中断门语义下「真并发」还没发生,但只要再多一个执行源(多核、或者中断里真去动队列),竞态就会冒头。而我们现在连一把锁都没真正用上。
下一站(021)就治这个:基于 020 备好的 Spinlock,把 Mutex、Semaphore、等待队列落地,让 block/unblock 真正派上用场(线程因为等 I/O、等锁而阻塞,被唤醒源重新 enqueue),并立刻对现有组件做一遍并发安全审查。PerCPU 也还要从「单核全局」长成「真 per-CPU」;至于更高半区的 ring3、系统调用、独立地址空间切换,再往后。020 的时钟 + sti + idle 是那一切的节奏地基------节奏先稳,上面才好盖并发。
参考
- Intel SDM Vol.3A §6.12.1 "Exception- or Interrupt-Handler Procedures" (本地
document/reference/intel/SDM-Vol3A-System-Programming-Guide-Part1.pdf,PDF 第 209 页 / 书内 6-13 页,已读到正文):进入 handler 时处理器把EFLAGS/CS/EIP压栈,特权级变化时 handler 栈「从当前任务的 TSS 获得」;IRET「把保存的标志恢复进 EFLAGS」。支撑「中断门进入时 IF 被清、IRETQ还原 IF」与「tss_set_rsp0的硬件依据」两条。 - Intel SDM Vol.3A §6.12.1.3 "Flag Usage By Exception- or Interrupt-Handler Procedure"(同 PDF,第 213 页 / 书内 6-17 页,已读到原文):「经中断门访问 handler 时,处理器清 IF 标志以防止其它中断干扰当前 handler......后续 IRET 把 IF 恢复为栈上保存值;陷阱门不影响 IF」。支撑「IRQ0 stub 一进去就是 IF=0」这条根因,以及案例二的非对称现象。
- Intel SDM Vol.2B STI 的「延迟一拍」语义 (本地
document/reference/intel/SDM-Vol2B-Instruction-Reference-M-U.pdf,STUI 条目 PDF 第 691 页 / 书内 4-683 页,已读到正文):STUI 条目以对比方式写明「STI 的效果延迟一条指令」。支撑「sti紧贴jmp,换栈+跳转这一瞬不被中断劈开」的设计正确性。 - GCC
__atomicBuiltins (GCC 在线手册https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html):__atomic_test_and_set(原子置 1 并返回旧值)、__atomic_clear(原子清 0)、__ATOMIC_ACQUIRE/__ATOMIC_RELEASE内存序。支撑sync.hpp的Spinlock实现。 - OSDev Wiki "Context Switching" / "Spinlock" (
https://wiki.osdev.org/Context_Switching、https://wiki.osdev.org/Spinlock,域名 200 在线):从中断 handler 里触发 schedule 的通用思路、test_and_set+pause的自旋锁写法,概念性对照。 - xv6-riscv (仓库
https://github.com/mit-pdos/xv6-riscv):时钟中断在 trap 处理里触发yield的对照------切换点从函数边界挪到中断返回路径,与本章设计同源。 - System V AMD64 ABI (
https://gitlab.com/x86-psABIs/x86-64-ABI):callee-saved(rbx/rbp/r12-r15)约定------CpuContext只存这 6 个 +rsp/rip,而RFLAGS不在 callee-saved 之列,这正是「协作式context_switch本不碰 RFLAGS、抢占式才要补sti」的根。延续 019 章已核引用。 - 001 章 · 让内核长出第二条执行流:进程上下文 :
Task/context_switch/RoundRobin/higher-half 地基,本章直接接续;CpuContext布局与 callee-saved 论证亦出自此。 - 本 tag 源码:scheduler.hpp / scheduler.cpp、context_switch.S、gdt.hpp / gdt.cpp、pit.cpp、per_cpu.hpp、sync.hpp、main.cpp;测试 test_scheduler.cpp(QEMU 机内,节名
(020))。