Cinux是如何管理进程的 —— 上下文与调度

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 章就初始化了,mainsti 之后,IRQ0 每个 tick 都会进 pit_irq0_handler),只是那个 handler 目前只递增一个 tick 计数、还没接到调度器,所以不会强行打断线程。把调度器挂到时钟中断上做成抢占,是 020 的事。019 只回答最基础的一问:「怎么把 CPU 从一条执行流手里拿过来、递给另一条,而且两条都还活着。」

为什么现在需要它

先回答一个看代码时一定会冒出来的疑问:CpuContext 里只存了 8 个值------6 个 callee-saved(r15/r14/r13/r12/rbp/rbx)再加上 rsprip。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.STaskBuilder 之间的契约,偏移必须严丝合缝:

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.cppbuild() 里------这是 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。再切回来 → 跳到 .restoreret → 回到它当初调用 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 再切换,fromto 就指向同一个任务,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),让 0x10000000xFFFFFFFF81000000 指向同一片物理页。

但这件事和 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.cpphandle_pf:以前需求分页调 g_vmm.map(virt, phys, flags),默认映射进内核 PML4;现在先 read_cr3() 拿到当前地址空间的 PML4,把 &cur_cr3 传进去,让缺页页落在当前 空间里(否则一旦真有多地址空间,缺页修错了地方),而且映射失败时会 free_page 把物理页还回去(修了个小泄漏)。二是 vmm.cppwalk_level 多了大页拆分:当要下钻的区域恰好被一张 2 MB 大页盖住、而我们想要 4 KB 粒度时(比如给任务栈映射),它分配一张新页表,把那 2 MB 拆成 512 个 4 KB 项,再用新页表替掉大页项。这两处都是「为多地址空间铺路」的零碎活,019 顺手做了。

调试现场

这一章有两份真实笔记,都值得当案例,因为它们都是「看起来能跑、实际埋雷」的典型。

案例一:线程退出炸成 0xDEADC0DE

生产里两个线程交替打 5 轮后崩溃,串口吐出 RIP=00000000deadc0de,然后三重错误重启。根因是两个 bug 叠加

其一是 TaskBuilder 当初没在栈顶压返回地址------线程函数 returnret 弹空栈,一路弹到栈底那个 0xDEADC0DE 哨兵,把它当地址跳过去,CPU 跳到 0xDEADC0DE 当然炸。这正好解释了为什么现在 build() 里非要有那句「栈顶压 exit_current」:它就是给线程函数的 return 准备的落脚点。修复一:栈顶压 exit_current

其二是就算修了第一个 bug,exit_current 本身还有毛病:它current_ 改成下一个任务,context_switch(&current_->ctx, &next->ctx)------可这时 current_ 已经是 next 了,fromto 指向同一个任务,切换成了空操作,执行继续停在已经死亡的线程栈上,该崩还是崩。修复二:进 exit_current 第一件事先 Task* prev = current_;,用 prevfrom,保证 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.cppRoundRobinSchedulerTaskBuilderCpuContext 的逻辑在 host 侧重写了一份(不链内核代码,-O2 编、CINUX_HOST_TEST 门控),盯这些:空/满/单任务队列的 pick_nextdequeue 中间项、TaskBuilder 的字段默认值与 null entry 守卫、CpuContextsizeof 和各偏移:

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 的 Taskcontext_switch 是那一切的地基------地基先稳,上面才好盖。


参考
  • System V AMD64 ABI (x86-psABIs/x86-64-ABI):callee-saved 寄存器约定(rbxrbpr12--r15)------CpuContext 只存这 6 个 + rsp/rip 的全部依据;caller-saved 跨调用不保证存活,故无需保存。
  • xv6-riscv swtch() (mit-pdos/xv6-riscv):同样的「只存 callee-saved + 换栈 + ret」手法,可对照 Cinux context_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.cppcontext_switch.Sscheduler.hpp / scheduler.cppelf_loader.cpp(return saved_entry)、linker.ld(KERNEL_VMA/KERNEL_LMA);测试 test_scheduler.cpp(host 镜像)、test_scheduler.cpp(QEMU 机内)。

二、时钟到点,该换人了:抢占式调度(原 020)

019 造出了 Taskcontext_switchRoundRobin,可它是协作式的------线程不主动 yield 就霸着 CPU。这一章把调度器挂到那个从 011 章起就在跑的 PIT 时钟中断上,让 IRQ0 在固定节拍强行打断当前线程,也就是抢占式多任务;顺带把 idle 任务、TSS.RSP0 更新、PerCPU 占位和最朴素的 Spinlock 原语一并铺上。

导引:点亮什么、为什么、设计图

这一章我们要点亮什么

核心是把「换人」的发起者从线程自己,挪到时钟中断。具体说,020 交付五块:

  • 时钟驱动抢占 :Scheduler 新增 tick() / schedule() / is_initialized(),外加 DEFAULT_TIME_SLICE = 2PIT::irq0_handler 发完 EOI 之后调 Scheduler::tick();tick() 数节拍,每到 2 就 schedule() 一次。而 schedule() 把当前任务标回 Readypick_next 取下一个、先更新 TSS.RSP0、再 context_switch。最关键的一笔:yield() 从此不再自己挑下一个,而是直接转调 schedule()------也就是说,020 之后「主动让出」和「被时钟打断」走的是同一条切换路径。
  • context_switch.Ssti 修复 :在「换栈之后、jmp 到新任务之前」插一条 sti。这是本章最硬的一个 bug 修复,根因在中断门的硬件语义。
  • idle 任务 :队列空了不再 cli;hlt 停机,而是落到一个只 hltidle_task;PerCPU 占位同时落地,为多核先挖个坑。
  • TSS.RSP0 更新 :GDT::tss_set_rsp0(uint64_t) 在每次切到非 idle 任务时更新------接口先接上,等将来有 ring3 才真正生效。
  • sync.hppSpinlock 原语 :acquire / release / RAII guard 三件套,基于 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.cppirq0_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.cpptick() 很短,职责就一件------数节拍、到点喊 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.hppSpinlock 三件套:

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(addremove,state 变 Dead)、test_block_unblock(addblockBlockedunblockReady)。注意这三个是状态机/接口层面的验证,不触发真抢占------抢占只能靠下面的生产 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,把 MutexSemaphore、等待队列落地,让 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 __atomic Builtins (GCC 在线手册 https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html):__atomic_test_and_set(原子置 1 并返回旧值)、__atomic_clear(原子清 0)、__ATOMIC_ACQUIRE/__ATOMIC_RELEASE 内存序。支撑 sync.hppSpinlock 实现。
  • OSDev Wiki "Context Switching" / "Spinlock" (https://wiki.osdev.org/Context_Switchinghttps://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.cppcontext_switch.Sgdt.hpp / gdt.cpppit.cppper_cpu.hppsync.hppmain.cpp;测试 test_scheduler.cpp(QEMU 机内,节名 (020))。
相关推荐
hold?fish:palm1 小时前
redis中AOF 重写机制解析
数据库·c++·redis
-银雾鸢尾-2 小时前
C#中的协变和逆变
开发语言·c#
阿米亚波2 小时前
【C++ 异常处理】try-catch
开发语言·c++·笔记·try-catch
旖旎夜光2 小时前
C++(内存管理)
开发语言·c++·学习
皓月斯语2 小时前
P2858 [USACO06FEB] Treats for the Cows G/S
数据结构·c++·算法·动态规划
旖旎夜光2 小时前
LeetCode 397:整数替换(贪心问题) —— 题解
数据结构·c++·算法·leetcode·贪心算法
≮傷£≯√2 小时前
QT配置FFmpeg
开发语言·qt·ffmpeg
别动我齐刘海2 小时前
“三层同步审计”判定掉帧缺失
c语言·c++·人工智能·深度学习·学习·机器学习·机器人
三8442 小时前
RCE长度&字符限制绕过
开发语言·php