PA4.1多道程序,听起来只是整个实验序列里一个普通的小阶段,但真正动手做的时候才会发现,这一步是整个系统从“玩具”走向“操作系统”的分界线。前面你写的内核虽然能跑用户程序,但本质上还是一条路走到黑:加载一个程序,让它运行,它退出,再加载下一个。到了PA4.1,用户程序不再只有一个,你的内核需要同时管理多个并发执行的进程,这意味着你要开始处理进程控制块、上下文切换、时钟中断和调度策略这一整套机制。这篇文章就围绕PA4.1多道程序展开,我会把设计思路、核心机制、实操步骤和踩坑记录都整理出来,适合正在做类似实验的开发者,也适合想搞清楚多道程序底层原理的读者。
1. 为什么“多道程序”是一个分水岭
1.1 从单道到多道的认知切换
我在做之前一直觉得,多道程序不就是把好多个程序加载进内存,然后轮流执行吗?听起来很直观,但实际动手才发现,真正困难的地方在于“轮到谁”和“怎么轮”这两个问题。单道程序时代,CPU一旦开始执行用户程序,整个内核就暂停了,你的操作系统代码相当于让出了控制权。用户程序跑完之前,你没有任何机会再插进去做决策。而多道程序要求你的内核在用户程序运行过程中也能重新获得控制权,这就必须引入硬件中断机制。
当你从单道切换到多道,最需要转变的观念是“当前进程”不再是恒定的。你的内核代码里不能再写死一个全局指针然后永远指向它,你得维护一张进程表,让CPU在某个进程用满时间片或者主动让出CPU时,能够快速找到下一个该运行的进程,并把它的执行现场完整恢复出来。
1.2 多道程序对硬件的依赖
多道程序能成立,依赖一个最容易忽略的硬件机制——定时器产生的时钟中断。没有时钟中断,你的内核永远没有机会在用户程序主动放弃CPU之前打断它,那所谓的时间片轮转就是空中楼阁。我当时做的时候,先把时钟中断的初始化写好了,但偷了个懒,没有在中断处理里调用调度器,结果整个系统看起来跟单道程序没有任何区别。后来把调度器接进中断处理函数里,才第一次看到两个程序交替执行的效果。
除了时钟中断,还需要处理特权级切换。用户程序运行在低特权级,内核运行在高特权级,当中断发生,CPU必须切换特权级并切换栈,这时候如果硬件不支持独立的栈机制,多道程序几乎不可能实现。说到底,多道程序不是单纯的软件技巧,它是软件与硬件协同工作的结果。
1.3 PA4.1 到底要完成什么
PA4.1在这次实验里的目标很明确:让内核能够同时驻留多个用户程序,并通过时钟中断和上下文切换使它们交替在CPU上执行。表面上你只是加了几个结构体和一个调度器,实际上你完成的是一个操作系统的核心骨架。之后的分页、虚拟内存、进程通信,全都要建立在这套多道机制之上。
我当时给自己列了一个检查清单:第一,内存里能同时存在多个用户程序的代码和数据;第二,每个进程都有自己的内核栈和用户栈;第三,每次时钟中断都能保存当前进程的完整现场,并切换到另一个进程;第四,进程可以在运行、就绪、阻塞等状态之间正常迁移。把这四条做完,PA4.1就算真正拿下了。
2. 核心机制与设计思路拆解
2.1 进程控制块与状态模型
多道程序的第一步,是建立一个描述进程身份的数据结构。我习惯叫它PCB,进程控制块。PCB里至少要有这几样东西:进程号、程序入口地址、保存的寄存器现场、当前状态、内核栈指针,以及用户程序在内存中的位置信息。
#define MAX_PROC_NUM 4 #define STACK_SIZE 4096 typedef enum { PROC_UNUSED, PROC_READY, PROC_RUNNING, PROC_BLOCKED, PROC_DEAD } proc_status_t; typedef struct { uint32_t pid; proc_status_t status; volatile uint32_t saved_esp; uint32_t entry; uint32_t kernel_stack_top; uint32_t user_stack_top; uint32_t code_start; uint32_t code_size; } PCB;状态模型也不需要太复杂,刚开始有就绪、运行、阻塞、退出这几个足够。我当时没有一开始就做阻塞态,因为PA4.1阶段还不需要等磁盘或者等键盘,进程要么运行要么就绪。但PCB结构最好提前把状态字段留好,不然后面加需求时重构很痛苦。每个进程的内核栈是独立分配的,初始化时把栈顶指针写入PCB,切换进程时把现场保存到对应的栈里。
2.2 两套栈:用户栈与内核栈的分工
多道程序里最容易搞混的就是栈。每个进程必须同时拥有两个栈,一个用户栈,一个内核栈。用户程序执行时,它的栈指针是用户栈的栈顶;一旦发生中断或系统调用,CPU会切换到内核栈来执行内核代码。为什么需要两套栈?因为用户栈和内核栈的隔离是保护系统安全的关键,用户程序可以通过疯狂压栈把自己的用户栈写满,但不能因此破坏内核栈上的数据。
在内核态时,你执行的代码是所有进程共享的,但执行现场是每个进程独立的。如果不给每个进程配一个独立的内核栈,进程A在内核中被中断后,保存现场的数据只能放在同一个内核栈上,进程B一旦也进入内核,现场就被覆盖了。我记得我第一版只用了单一内核栈,结果上下文切换时保存的寄存器全乱了,程序一跑就死,后来给每个进程分配独立内核栈才解决。
2.3 上下文切换:从 save 到 restore 的完整过程
上下文切换是PA4.1里最核心的操作。很多资料一上来就给你看汇编代码,其实理解它没有那么玄。所谓上下文,就是当前进程在CPU上留下的一堆寄存器状态。切换过程可以分成两步:把当前寄存器保存到当前进程的内核栈,再从下一个进程的内核栈恢复寄存器。
x86下典型的保存和恢复代码大概是这样的:
# void switch_to(PCB *next); # 进入这个函数时,当前进程已经因为中断或系统调用而位于内核态 switch_to: movl 4(%esp), %eax # eax = next PCB 指针 pushl %ebx pushl %esi pushl %edi pushl %ebp movl %esp, current_pcb->saved_esp movl %eax, current_pcb movl next->saved_esp, %esp popl %ebp popl %edi popl %esi popl %ebx ret这里的关键点在于,保存现场和恢复现场使用的是每个进程自己独立的内核栈。当前进程被停住时,它的内核栈栈顶记录着它下次被调度时所需要的所有寄存器值;下一次切换回来,只要把esp重新换回到这个位置,再按顺序弹出所有寄存器,进程就能无缝继续执行。这个机制有点像你在读一本小说时夹了个书签,下次拿起书时翻到书签页接着读。
2.4 时钟中断如何推动任务切换
光有上下文切换函数还不够,你得有东西来不停触发它。时钟中断会周期性地把CPU从用户态拉回内核态,中断处理程序的第一步需要保存用户现场,然后判断是否应该切换进程。通常的做法是维护一个全局计数器,每次时钟中断把计数器加一,加到某个阈值就表示一个时间片用完了,于是执行调度。
调度策略在这个阶段可以朴素一些,最简单的就是轮转法:把就绪进程排成环,每次从当前进程的下一个开始找,找到第一个状态为就绪的进程,就切换过去。这里有一个细节:如果当前进程的时间片还没用完,但它在等待某个事件,它会被标记为阻塞,调度器也要能跳过阻塞进程。
我当时把时钟中断处理逻辑写成了这样:
void clock_handler(void) { current_ticks++; save_current_context(); if (current_ticks % TIME_SLICE == 0) { schedule(); } restore_current_context(); }这个写法虽然能工作,但它把所有工作都堆在中断处理函数里。后来我发现更好的做法是把上下文切换单独抽象出来,让时钟中断只负责修改时间和触发调度,不直接参与具体实现。这样代码清晰很多,排查问题的时候也不会把中断逻辑和调度逻辑混在一起。
3. 实操过程:从单道程序扩展到多道程序
3.1 让加载器支持同时装入多个用户程序
PA4.1的前一个阶段,加载器只需要把单个用户程序从镜像里拷到内存固定位置并跳转执行。扩展成多道程序之后,加载逻辑不能再假设用户程序都放在同一个地址。我采用的做法是在编译阶段把多个用户程序打包成固定地址的二进制镜像,加载时按顺序把每个程序拷贝到各自的内存区域,并记录下代码起始地址和大小。
static void load_programs(void) { int i; for (i = 0; i < PROGRAM_COUNT; i++) { pcb[i].pid = i; pcb[i].code_start = USER_BASE_ADDR + i * USER_PROGRAM_SIZE; pcb[i].code_size = user_program[i].size; memcpy((void *)pcb[i].code_start, user_program[i].data, user_program[i].size); } }这一步看起来简单,但有一个坑:如果两个用户程序的入口地址或者栈地址设置成同样的值,第二个程序启动时会把第一个程序的内存踩坏。所以每个进程的用户栈也要分配独立空间,我当时是让每个进程的用户栈从它自己的代码段上方开始向下增长,相互之间预留足够的隔离区间。
3.2 初始化 PCB 和初始上下文
用户程序被加载到内存之后,进程还处于“未启动”状态。要让调度器第一次选择它时能顺利跳进用户程序,你必须手工构造一个初始上下文,相当于给进程伪造一个“从假想的中断返回现场”。这个假现场必须包含用户程序的入口地址,以及一个合理的用户栈指针。
static void init_pcb(int i, uint32_t entry, uint32_t user_stack_top) { pcb[i].pid = i; pcb[i].status = PROC_READY; pcb[i].entry = entry; pcb[i].user_stack_top = user_stack_top; pcb[i].kernel_stack_top = alloc_kernel_stack(); pcb[i].saved_esp = pcb[i].kernel_stack_top; }在x86上,真正的切换过程通常会通过iret指令回到用户态,所以初始上下文里要提前压入用户态栈指针、标志寄存器、代码段、指令指针。我当时写了一个专门的函数构造这个初始栈帧,确保第一次调度这个进程时,内核代码执行完iret之后能直接从用户程序的入口地址开始跑。这里如果搞错顺序,程序几乎必挂。
3.3 实现调度器与上下文切换函数
调度器本身不负责具体的寄存器保存和恢复,它只负责回答一个问题:下一个该运行哪个进程。这个函数应该遍历进程表,选择状态为就绪的进程,然后调用上下文切换函数。
static void schedule(void) { int next = -1; int i; for (i = 0; i < PROC_MAX; i++) { int candidate = (current_pid + i + 1) % PROC_MAX; if (pcb[candidate].status == PROC_READY) { next = candidate; break; } } if (next == -1) { next = current_pid; } if (next != current_pid) { current_pid = next; switch_to(&pcb[next]); } }我需要提醒你的是,switch_to这个函数一旦被调用,它返回时的执行流可能完全属于另一个进程了。所以它的行为跟普通函数不一样,你不应该指望在switch_to之后还有当前进程的代码立刻执行。当前进程的后续代码,要等它的时间片再次轮到时才会继续。有些人刚开始会觉得这很诡异,其实理解之后就很自然。
3.4 接入时钟中断并实现时间片轮转
时钟中断的初始化通常已经在更早的阶段做完了,PA4.1要做的只是把调度器挂到中断处理链上。这里最重要的是“中断处理函数里不能随便使用普通的内核变量而不加保护”。因为在中断上下文中,你的代码可能随时被打断,如果这时还去修改一个当前进程正在使用的内核数据结构,就会造成不可预期的后果。
我个人建议一开始就采用最简单的方案:在时钟中断里保存现场,检查时间片,决定是否调度,如果调度就切换,否则恢复现场。成功跑通之后,再把可重入和锁保护的细节加进来。PA4.1不需要完全实现复杂的同步机制,但有机会提前思考这些问题是很有价值的。
3.5 让第一个用户程序“跑起来”的必要条件
很多人的PA4.1卡在最开始:系统启动后,第一个用户程序无法运行。这是非常典型的问题。要让第一个进程跑起来,你至少要做三件事:分配PCB并初始化;把它标记为就绪;在系统初始化完成后的某处调用schedule(),或者直接switch_to它。如果初始化时没有设置好初始上下文,调度器选择它之后进入switch_to,弹出的寄存器全是垃圾值,程序直接飞掉。
我建议把“第一个进程初始化”和“后续进程初始化”分开,单独验证。先确保只有进程0能完整运行一遍,再加进程1。一次性初始化多个进程再调试,出问题往往根本不清楚是哪个环节挂了。
4. 调试记录:经典问题与排查方法
4.1 常见问题速查表
这里是我整理的一份常见问题速查表,基本覆盖了PA4.1阶段九成会碰到的故障。
| 现象 | 常见原因 | 排查方法 |
|---|---|---|
| 启动后系统完全没有输出或反复重启 | 初始上下文构造错误,iret时栈帧不合法 | 检查初始栈中SS、ESP、EFLAGS、CS、EIP的压栈顺序 |
| 进程跑到一半跳飞 | 上下文切换保存不完整,漏掉某个寄存器 | 对比switch_to中压栈和弹出的寄存器列表 |
| 时间片到了但不切换 | 时钟中断没有真正触发调度器 | 在时钟中断处理函数里加打印,确认中断次数在增长 |
| 两个进程输出交错但其中一个卡死 | 调度器总是选中同一个进程 | 检查就绪状态是否正确维护,是否在切换后被错误清掉 |
| 用户程序能跑但系统崩溃 | 用户栈地址和内核栈地址重叠 | 打印每个进程的栈顶和栈底,确认无重叠 |
| 切换后内核变量丢失 | 保存现场时没有保存“当前进程指针” | 修改current_pid时要保证与switch_to的顺序一致 |
| 时钟中断处理函数执行过慢 | 每次中断都做完整任务切换 | 限制切换频率,让时间片阈值大于中断的最小间隔 |
4.2 问题一:程序启动后直接跳飞
这是我最开始遇到的第一个大问题。当时我把初始上下文里的EIP设置为用户程序的入口,但忘记设置用户栈的SS:ESP,只是在PCB里存了一个变量,没有压到初始栈帧中。结果调度器第一次切换到进程0时,iret弹出的是一个无效的栈段和栈指针,CPU立即触发保护异常,然后异常处理又没有写好,系统直接重启。
解决这个方法很简单,我拿一张纸把x86的iret从低地址到高地址应该压栈的元素列出来:先是SS,再是ESP,然后是EFLAGS,再是CS,最后是EIP。然后对照初始栈帧构造代码,逐项核对。这种问题如果靠猜,可能一晚上都找不出来,但把硬件文档翻出来一对照,五分钟就定位了。
4.3 问题二:时钟中断导致系统死循环
另一个印象深刻的问题是,接入时钟中断之后,系统启动没几秒就陷入死循环,屏幕上的输出频率越来越快,最后卡死。原因是我的时钟中断处理函数在每次中断时都会调用一次调度器,而两个进程都有自己的打印循环,一旦进程切换,当前环境还没有完全恢复就调用打印函数,导致打印锁重入,整个系统的执行流被搅乱。
这个问题的修复方式并不复杂,我把“是否需要切换”的判断前移:只在计数器到达时间片阈值时才做任务切换,其他时钟中断只是简单递增计数并立即返回。这样虽然中断频率没变,但实际任务切换频率大幅降低,系统稳定了很多。做完这个修复后我还意识到,中断处理函数应该尽量短小,任何可能破坏当前进程状态的操作都要谨慎。
4.4 问题三:打印输出顺序诡异
有时候两个进程都在跑,输出的字符却出现了“穿插但明显不符合顺序”的情况。比如进程0先输出一串字母,进程1应该后输出,结果却是进程1的前半段出现在进程0的前半段中间。这种情况通常意味着用户程序自己使用的缓冲区是共享的,或者你把两个进程的用户栈放置得太近,导致打印函数的局部变量互相踩踏。
我调这个问题的办法是,给每个用户程序传入不同的栈顶地址,并把它们编译成独立的代码段,确保数据段也完全隔离。后来我又在PCB中加了一个简单的“当前输出行号”字段,用来辅助判断某个进程到底运行到了哪里。这种日志辅助定位的方法虽然土,但在没有复杂调试器的环境下很管用。
4.5 调试工具与技巧
PA4.1阶段的调试,我强烈建议开启GDB的远程调试。你可以在内核初始化完成之后、第一个用户程序运行之前,下一个断点,然后手动查看每个PCB的保存现场是否合法。很多复杂的调度问题,其实在切换现场之前就能看出来。
还有一个技巧是给每个进程分配不同的输出颜色或者不同的前缀。比如进程0输出前加“A:”,进程1输出前加“B:”,这样一旦时序错乱,你从日志里立刻就能分辨是谁先谁后。把输出和上下文切换做成一个可读的事件流,是所有并发调试里最实用的方法。
5. 动手做 PA4.1 的几条实战建议
5.1 不要一上来就写完整调度器
我见很多同学一上来就想把抢占式调度做得非常完善,使用了各种复杂的数据结构。但PA4.1的定位是让你先理解多道程序的基础,并不需要在并发性能上做文章。我个人的经验是,先写一个最简单的轮转调度,不考虑优先级,不考虑阻塞唤醒,只要能让两个进程稳定交替运行,就算基本完成。
5.2 先验证中断栈切换,再验证任务切换
我建议严格按照这个顺序做:先确保一个用户程序在运行的时候,时钟中断发生后能正确进入内核,并能正常返回到同一个用户程序;然后才开始让调度器切换到另一个进程。如果你连“中断后回到原进程”都做不到,那么“中断后切到另一个进程”就更不可能正确了。这一条验证顺序帮我省掉了大量无效排查。
5.3 为日志输出加上进程编号和切换标记
开发过程中,我在内核里增加了一个全局的“调度事件日志”缓冲区。每次发生上下文切换,就把当前的时间戳、旧进程号和新进程号写进缓冲区。这样的话,即使系统崩溃,我也可以在崩溃点附近查看最后几次切换事件,快速定位是哪一个进程切换时出了问题。这种方法看着笨,但在操作系统实验里非常可靠。
PA4.1真正做完的时候,我的感受是:多道程序这个名字听起来平淡,但它背后是一整套进程管理的雏形。你以为你只是写了几个结构体和函数,其实你已经亲手摸到了操作系统最核心的机制——并发。后面再学信号量、锁、进程间通信,你会发现它们共同的基础还是这里:如何安全地保存现场,如何可靠地切换进程,如何让多个任务在同一个CPU上像多个人在同时工作。这一步的代码量不算多,但边界条件和细节极其繁杂,顺利做下来,整个操作系统的图景会清晰很多。