Skip to content

调试现场

两条真实笔记,都压成「症状→根因→定位→修复→防复发」。它们恰好是抢占式上线时最常遇到的两种「看着能跑、其实没生效」。

案例一:时间片过长,抢占从未触发

症状是 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 没做,留作将来。

035_multi_terminal-45-gf25de18 · f25de18 · 2026-08-04