9.8 多任务机制与进程调度
多任务并发是现代操作系统的基石。内核通过在时间维度上对处理器算力进行精密复用,在单核或多核硬件之上交替推演多个执行流,使上层应用获得宏观上并行推演的执行体验。
在多任务子系统的构建中,内核需要协同解决特权级隔离、执行流上下文保护、硬件栈切换以及调度算法等多层技术挑战。
1. 系统内核进程与空闲任务(IDLE)
在 x86 的四级特权环模型中,内核进程常驻于最高特权级 Ring 0(R0)。这类进程享有无限制的硬件寻址与控制权限,负责执行驱动中断底半部、内存回收与常驻内核服务。
其中,最为特殊的系统进程当属空闲进程(IDLE 进程):
- 自举首发:IDLE 进程是内核引导过程中由系统手工初始化构建的第 0 号进程(PID 0),它是整机唯一不通过
fork机制克隆衍生的特权执行实体; - 调度垫底:IDLE 拥有系统中的最低调度优先级。当就绪队列中所有就绪任务均陷入等待或执行完毕时,调度器才将 CPU 算力分派给 IDLE;
- 节能循环:操作系统完成全局硬件初始化后,IDLE 进程并不会退出销毁,而是在核内主循环中反复执行
hlt汇编指令,挂起处理器流水线以大幅降低 CPU 功耗与发热,直到下一个硬件中断将其唤醒; - 对称多核绑定:在对称多处理(SMP)架构下,主核心 CPU0 初始化完成后,会为每一个从核心(AP)分别拉起独立的 IDLE 任务实例,以便各核心在空闲时独立休眠。
2. 用户态进程与特权隔离
运行在最低特权级 Ring 3(R3)的进程构成了上层应用程序的执行主体。处于 R3 的代码受到硬件 MMU 与 CPU 特权控制器的双重严密防护:既无法直接执行特权指令(如 cli、sti、hlt、lgdt 等),也无法触碰被标记为内核特权级的虚拟地址区间。
2.1 特权级跃迁与内核栈切换
在 x86 体系结构下,CPU 无法通过普通的跳转或调用指令直接在 Ring 3 与 Ring 0 之间切换,必须通过受控的硬件机制完成跨特权级跃迁:
- 从 R3 陷入 R0(中断与系统调用):当用户程序触发软中断(如
int 0x80)、系统调用或产生硬件外部中断时,CPU 必须立即离开不受信任的用户栈,切换至受保护的内核栈。为了防止用户栈指针非法导致内核崩溃,CPU 会自动查询当前任务状态段(TSS)中预先填写的ss0与esp0,切换至专属于该进程的内核栈,并在该栈上自动依序压入用户态的ss、esp、eflags、cs与eip寄存器镜像。 - 从 R0 下降至 R3(特权降级):新进程创建后,内核通常在进程内核栈中预先伪造一个中断返回栈帧(包含用户程序的入口地址
eip、用户栈顶esp、属性标志以及 RPL=3 的代码段与数据段选择子)。随后,内核调用switch_to_user_mode辅助例程清理现场,并执行iret(中断返回)指令。硬件根据栈上弹出的段选择子检测到特权级发生跨越,自动切换特权级并激活 R3 执行流。
2.2 TSS 在现代内核中的实际分工
任务状态段(Task State Segment,简称 TSS) 是 x86 硬件设计中专用于硬件级任务切换的古老结构,其内部包含了所有通用寄存器、段寄存器以及 cr3 的硬件镜像槽位。
在现代操作系统设计中,硬件任务切换机制由于无法跨架构移植、执行开销过大且难以支持丰富的软件策略已被全面弃用,取而代之的是由软件维护的进程控制块(Process Control Block,简称 PCB)。PCB 仅在必要时保存少数字符与通用寄存器,同时承载了诸如 PID、虚拟内存空间映射、打开文件描述符表以及时间片计数等软件属性。
但在 x86 保护模式下,TSS 结构依然不可或缺:内核仍必须在 GDT 中注册至少一个 TSS 描述符并使用 ltr 指令将其装入 CPU。在现代系统中,TSS 的核心使命被收敛为单一职责:在 R3 陷入 R0 时,为硬件提供合法的内核栈基址(esp0 与 ss0)。
3. 任务调度策略与上下文切换
任务调度器是操作系统的中央指挥部,负责在就绪队列中裁定下一个获取处理器执行权的任务:
- 时间片轮转(Round-Robin):内核定时器(如 PIT 或 APIC Timer)以恒定频率周期性向 CPU 发送时钟中断。中断处理函数递减当前任务的时间片计数,一旦时间片耗尽,即唤醒调度器执行任务轮换。
- 上下文切换的两大支柱:
- 寄存器上下文迁移:通过软件汇编将当前任务执行现场的通用寄存器、程序计数器与栈指针压入旧任务的内核栈或保存至 PCB,再从新任务的栈或 PCB 中恢复新上下文;
- 地址空间切换:若切换发生在不同的进程之间,调度器必须将新进程的页目录物理基地址加载到
CR3寄存器。这一操作会触发 MMU 的硬件转换后援缓冲器(TLB)局部刷新,使 CPU 的线性地址空间瞬间切换为新进程的内存布局。
4. 进程与线程的抽象分野
从底层资源视角审视,进程与线程的核心差异体现在地址空间的分立与共享:
- 进程(Process) 是系统进行资源分配和内存隔离的独立单元。每个进程拥有专属的页目录(独立
CR3)。即使两个不同进程访问数值完全一致的线性虚拟地址(例如0x08048000),经由各自独立的二级页表走查后,也将映射到完全不同的物理页框中,从而从硬件层面实现内存隔离。 - 线程(Thread) 是 CPU 进行时间片调度的最小执行单元。属于同一进程的所有线程共享相同的页目录与地址空间,各个线程拥有独立的私有栈空间与寄存器上下文,但在访问全局数据与堆内存时彼此可见。因此,线程间的上下文切换省去了重载
CR3这一耗时操作,显著提升了并发交互的吞吐效率。
5. 习题
这一页讲了三个层次:特权级(R0 与 R3 怎么切换)、进程的描述结构(TSS 与 PCB 的分工)、以及调度(时间片与上下文切换)。真机上验证这些要写引导程序和中断存根,代价太大;下面四道题把它们搬到主机上,用结构体和数组模拟 CPU 状态,照样练到切换路径上的每个细节。前两道读结构与算栈布局,后两道写能跑的调度器和地址空间隔离实验。
5.1 巩固
32 位任务状态段(TSS)的字段顺序与 Intel 手册一致,段寄存器部分按 32 位槽位存放,只用到低 16 位:
struct tss32 {
uint32_t prev_tss;
uint32_t esp0, ss0;
uint32_t esp1, ss1;
uint32_t esp2, ss2;
uint32_t cr3;
uint32_t eip, eflags;
uint32_t eax, ecx, edx, ebx;
uint32_t esp, ebp, esi, edi;
uint32_t es, cs, ss, ds, fs, gs;
uint32_t ldt;
uint16_t trap, iomap_base;
} __attribute__((packed));2
3
4
5
6
7
8
9
10
11
12
13
(1) 用 offsetof 打印 esp0、ss0、cr3、eip、eflags 的偏移与 sizeof(struct tss32)。实测依次是 4、8、28、32、36,总长 104。逐个解释前五个偏移为什么是这些值,以及 104 与 Intel 手册里 32 位 TSS 的标准长度是什么关系。
(2) 正文说现代内核不再用 TSS 做任务切换,只用它切换内核栈地址。从 R3 陷入 R0 时,CPU 从 TSS 的哪两个字段取新栈?如果 ss0 的选择子写成内核数据段但 RPL 填 0 而不是 3,会发生什么?
(3) 正文的 switch_to_user_mode 用 iret 进入 R3。选择子写作 (index << 3) | rpl。定义 struct iret_frame { uint32_t eip, cs, eflags, esp, ss; },按 eip = 0x00401000、cs = sel(3,3)、eflags = 0x202、esp = 0xB0F00000、ss = sel(4,3) 填好,把五个双字按内存顺序打印,应当得到 00401000 0000001B 00000202 B0F00000 00000023。
(4) eflags 里 0x202 的 bit 9 是 IF。改写成 0x2 会怎样?用户程序第一次被时钟中断打断时会发生什么?
(5) GDT 若按「空、内核代码、内核数据、用户代码、用户数据」排列,用户代码段的下标是多少?cs = 0x1B 与 cs = 0x0B 分别指向哪一项,各在哪一级特权?
验收:第 (1) 问的五个偏移与总长全部吻合;第 (3) 问的五个值与上面逐字一致;第 (4) 问能指出后果是「中断被永久关闭」。
用主机程序模拟「时钟中断 → 时间片用完 → 切换任务」,验证 change_task_to 里那组寄存器搬运。
(1) 定义 struct context { uint32_t eip, eax, ebx, ecx, edx, esi, edi, ebp, esp, eflags, cs, ds, ss; } 与 struct task { int pid; struct context ctx; struct task *next; },按正文的 running_proc_head 建环形链表,四个任务,pid 0~3。
(2) 写 void switch_to(struct task **cur, struct task *next):把当前执行位置存进 (*cur)->ctx.eip,把 next->ctx 的十三个字段逐个装进全局的 struct context cpu,再把 *cur 改成 next。写 task_yield() 顺着 next 指针走一圈,打印每次切换前后的 pid 与 eip。
(3) 用 setjmp/longjmp 把「寄存器对象」变成真正的执行跳转:每个任务用 malloc 分配 64 KiB 栈并保存一个 setjmp 上下文,任务函数体只打印自己的 pid 就调用 task_yield()。
(4) 四个任务各让出 3 次后退出,打印完整执行序列。程序结束时所有任务都要跑到,且没有任务被漏掉或执行两次。
(5) 对照正文的 change_task_to:它只搬了 eip、ds、cs、eax、ss 五个字段,其余寄存器靠汇编里的 pusha/popa 与中断返回帧。说明为什么在真实的中断返回路径上这五个够了,而本题的主机模拟必须搬全部十三个。
(6) 正文的 switch_to_user_mode 在 iret 前把 esp 设成 USER_END,而不是当前内核栈。说明这个选择与 (5) 的寄存器搬运有没有冲突:用户栈上的数据是怎么过去、又是怎么回来的。
验收:第 (4) 问的序列恰好 12 次让出加 4 次退出;第 (5) 问能列出 pusha 保存的 8 个寄存器名,并指出中断帧补上的部分。
5.2 编程
实现一个带睡眠与退出的轮转调度器:
- 4 个任务,
pid0~3,时间片分别为 1、2、2、3 个 tick;pid 0是 IDLE,统计时单独标出; - 每个 tick 开始时先唤醒睡眠到期的任务,醒来时间片重新计满;
- 当前任务可运行且时间片未用完就继续,否则沿环按
pid递增找下一个可运行任务; - 睡眠与退出由脚本触发:tick 2 结束时
pid 1睡 4 个 tick,tick 4 结束时pid 2退出,tick 7 结束时pid 3睡 2 个 tick; - 整轮只剩 IDLE 时继续执行 IDLE,不空转。
要求:
(1) 先手算 15 个 tick 的执行序列,写进报告。
(2) 实现并运行,打印每个 tick 运行的 pid,最后打印每个任务累计的 tick 数与切换次数。
(3) 手算结果与程序输出必须一致。实测序列为 0 1 1 2 2 3 3 3 0 1 1 3 3 3 0,累计 3 4 2 6,切换 8 次。对不上时先查「唤醒发生在选择下一个任务之前还是之后」,这是最常见的分歧点。
(4) 把 pid 0 的时间片改成 3 重跑,说明序列与切换次数各变成什么(实测为 0 0 0 2 2 3 3 3 0 0 0 1 1 3 3、5 次切换)。为什么 IDLE 的时间片会影响其他任务的响应时间?
(5) 退出时如果只把任务标成 DEAD 而不从环上摘掉,next 指针会怎样?给出一个会在这个实现里触发的死循环路径,并说明 code/项目/mdrOS/task.c 的 task_kill 里 last->next = argv->next 那一行为什么不能省。
(6) code/项目/mdrOS/task.c 里的 schedule 在 io_cli() 之后才读 current,task_kill 也在关中断的状态下改链表。说明为什么两者的改动必须在同一个关中断区间里完成,多核下还需要什么。
验收:第 (3) 问的序列、累计数、切换次数三项全部吻合;第 (5) 问给出的死循环路径能用改坏的代码复现。
5.3 综合与挑战
正文第 4 节说「进程间页目录不同,同一线性地址指向不同物理地址」,线程共享页表。code/项目/mdrOS/exercises/9_8/addrspace_starter.c 已经搭好物理页框、页目录与页表的模拟环境,七个函数留空:
struct page_directory *pd_create(void);
int pd_map(struct page_directory *d, uint32_t va, uint32_t frame, uint32_t flags);
int pd_translate(struct page_directory *d, uint32_t va, uint32_t *pa, uint32_t *flags);
struct page_directory *pd_clone(const struct page_directory *src);
void pd_switch(struct page_directory *d);
int addr_read32(uint32_t va, uint32_t *out);
int addr_write32(uint32_t va, uint32_t value);2
3
4
5
6
7
要求:
(1) 补全前五个。pd_clone 按 clone_directory 的做法处理:内核区间的 PDE 直接共享,用户区间新建页表并逐项复制,PTE 的 present、rw、user 三位一起复制,只复制页框号不行。
(2) 补全 addr_read32 与 addr_write32,写操作检查 PTE 的 rw 位,写只读页返回 −1。
(3) 跑通 starter 的 main,贴出每一行输出。验收要点:A 与 B 写同一个 0x08000000 后各读各的,互不影响;A 的线程写进去的值 A 立刻能读到;0x09000000 与 0x08001000 在 B 里都是 FAULT。参考输出(页框号取决于分配顺序,只要相对关系对得上就算通过):
A va=0x08000000 mapped pa=0x00007000 frame=7 flags=0x7 value=0x00001111
A va=0xC0001000 mapped pa=0x00002000 frame=2 flags=0x3 value=0x00000000
B va=0x08000000 mapped pa=0x00008000 frame=8 flags=0x7 value=0x00002222
B va=0xC0001000 mapped pa=0x00002000 frame=2 flags=0x3 value=0x00000000
A va=0x08000000 mapped pa=0x00007000 frame=7 flags=0x7 value=0x00001111
A+thr va=0x08000000 mapped pa=0x00007000 frame=7 flags=0x7 value=0x00001111
A+thr va=0x08000000 mapped pa=0x00007000 frame=7 flags=0x7 value=0x00003333
B va=0x08000000 mapped pa=0x00008000 frame=8 flags=0x7 value=0x00002222
B va=0x09000000 FAULT read=FAULT
B va=0x08001000 FAULT read=FAULT
frames used: 1 2 3 4 5 6 7 8 9 102
3
4
5
6
7
8
9
10
11
(4) 加一个反例:让 A 与 B 共享同一个物理页框(pd_map 传同一个 frame),重跑,说明此时两个进程还能不能隔离。由此说明 clone_directory 在 fork 时必须复制页框内容,而不能只复制页表。
(5) 统计程序结束时的页框占用,解释 frames used 里每一类页框分别是谁分配的(页目录、页表、内核页、用户页)。
(6) 与线程对照:新建线程时不调用 pd_clone,让新线程的 pgd_dir 直接指向进程的页目录。在 starter 里加一条这样的路径(一个 struct task 数组,进程与线程各带 pgd_dir),验证「线程写、进程看见」,并说明线程切换为什么可以省掉 page_switch。
(7) 正文的 clone_table 里 alloc_frame(&table->pages[i], 0, 0) 之后还要逐位复制 present、rw、user、accessed、dirty。如果漏掉 user 位,用户态访问刚克隆出来的页会发生什么?写一句话并指出这对应哪种异常。
验收:starter 的 main 输出与预期一致;第 (4)、(6) 问的结论能用输出里的 pa 与 value 两项说明;gcc -std=c17 -Wall -Wextra 无警告。