1. 从PCB到进程切换的全景解析记得第一次接触进程管理时看着课本上PCB是进程存在的唯一标志这句话总觉得抽象难懂。直到在Lab3中亲手实现了从PCB初始化到进程调度的完整链路才真正理解这句话的含义。这次实验就像搭积木需要把内存管理、中断机制、ELF加载等模块有机组合起来最终让两个用户进程在时钟中断驱动下实现时间片轮转调度。进程控制块(PCB)在MOS中对应struct Env结构体它就像进程的身份证档案袋。不仅包含进程ID、状态等元信息还保存了页目录地址、寄存器状态等关键数据。我特别喜欢指导书中的比喻PCB就像游戏中的存档点当进程被切换出去时所有状态都被冻结保存等再次被调度时又能从上次中断的地方继续执行。实验中最让我惊讶的是PCB的env_tf字段Trapframe结构体。它用regs[32]数组保存了所有通用寄存器还有cp0_epc这样的特殊寄存器。这意味着通过精心设计这个结构体我们就能像时间暂停一样完整保存进程的CPU状态。后来在实现进程切换时这个设计果然发挥了关键作用。2. 进程诞生的三重奏2.1 内存空间的魔术env_setup_vm创建进程首先要解决内存隔离问题。在env_setup_vm函数中我们需要为新进程构建专属的地址空间。这里有个精妙的设计——模板页目录// 复制内核空间映射 memcpy(e-env_pgdir PDX(UTOP), base_pgdir PDX(UTOP), sizeof(Pde) * (PDX(UVPT) - PDX(UTOP)));这段代码将内核空间的页表项复制到新进程的页目录中。UTOP以上是内核区域所有进程共享UTOP以下是用户私有区域。这种设计既保证了内核安全又实现了进程隔离。最让我费解的是页目录自映射e-env_pgdir[PDX(UVPT)] PADDR(e-env_pgdir) | PTE_V;这行代码让进程可以通过UVPT地址访问自己的页目录。就像在迷宫里放了一面镜子通过它能看到整个迷宫的结构。这种设计极大简化了用户态访问页表的操作。2.2 进程身份证env_alloc的奥秘env_alloc函数就像新生儿登记处负责分配和初始化PCB。其中有两个ID生成机制特别值得注意env_id生成器u_int mkenvid(struct Env *e) { static u_int i 0; return ((i) (1 LOG2NENV)) | (e - envs); }这个函数用静态变量保证ID唯一性低位存储PCB数组索引。这种设计既避免了ID冲突又能快速定位PCB位置。ASID分配器static int asid_alloc(u_int *asid) { for (u_int i 0; i NASID; i) { if (!(asid_bitmap[i/32] (1(i%32)))) { asid_bitmap[i/32] | 1(i%32); *asid i; return 0; } } return -E_NO_FREE_ENV; }ASID(Address Space ID)就像TLB的名片夹帮助硬件区分不同进程的地址空间。这个位图分配算法虽然简单但效率极高。2.3 ELF加载的艺术load_icode详解加载用户程序就像给新生儿注入灵魂。load_icode函数通过ELF解析将二进制程序映射到进程空间ELF_FOREACH_PHDR_OFF (ph_off, ehdr) { Elf32_Phdr *ph (Elf32_Phdr *)(binary ph_off); if (ph-p_type PT_LOAD) { panic_on(elf_load_seg(ph, binary ph-p_offset, load_icode_mapper, e)); } }这里有个精妙的回调设计load_icode_mapper。它负责具体的内存映射工作这种分层设计使得ELF加载器可以复用static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm, const void *src, size_t len) { struct Env *env (struct Env *)data; // 分配物理页 // 拷贝数据 // 建立页表映射 }特别要注意.bss段的处理——文件大小可能小于内存大小这时要用0填充剩余空间。这解释了为什么全局变量默认初始化为0。3. 时钟中断与调度器交响曲3.1 中断机制的幕后英雄在kclock_init中设置200Hz的中断频率LEAF(kclock_init) li t0, 200 // 200Hz频率 sw t0, (KSEG1|DEV_RTC_ADDRESS|DEV_RTC_HZ) jr ra END(kclock_init)而enable_irq则像打开水龙头LEAF(enable_irq) li t0, (STATUS_CU0 | STATUS_IM4 | STATUS_IEc) mtc0 t0, CP0_STATUS jr ra END(enable_irq)这里STATUS_IM4表示启用4号中断(时钟中断)STATUS_IEc表示在内核态启用中断。这两个设置是进程调度的先决条件。3.2 异常分发的精妙设计当CPU遇到异常时会跳转到0x80000080执行exc_gen_entryexc_gen_entry: SAVE_ALL mfc0 t0, CP0_CAUSE andi t0, 0x7c // 提取异常码 lw t0, exception_handlers(t0) jr t0exception_handlers就像电话总机根据异常类型转接处理函数void (*exception_handlers[32])(void) { [0] handle_int, // 中断 [2...3] handle_tlb, // TLB异常 [8] handle_sys // 系统调用 };这种设计将硬件异常与软件处理解耦扩展性极强。我在调试时曾不小心触发保留异常看到do_reserved打印的寄存器状态对理解异常现场帮助很大。3.3 调度算法的核心逻辑schedule函数是操作系统的心脏它的时间片轮转算法朴实但有效if (yield || count 0 || e NULL || e-env_status ! ENV_RUNNABLE) { // 从调度队列取新进程 e TAILQ_FIRST(env_sched_list); count e-env_pri; // 优先级即时间片 } count--; env_run(e);这里有个易错点env_sched_list包含所有可运行进程包括当前运行的。这样才能保证TAILQ_REMOVE操作安全。我最初漏掉了这个设计导致调度时出现诡异的内存错误。4. 进程切换的终极魔法env_run完成了从内核到用户态的华丽转身void env_run(struct Env *e) { if (curenv) { curenv-env_tf *((struct Trapframe *)KSTACKTOP - 1); } curenv e; cur_pgdir e-env_pgdir; env_pop_tf(e-env_tf, e-env_asid); }最后的env_pop_tf汇编函数是点睛之笔LEAF(env_pop_tf) sll a1, a1, 6 // ASID左移6位 mtc0 a1, CP0_ENTRYHI move sp, a0 j ret_from_exception END(env_pop_tf)它完成了三件大事设置ASID标记新进程切换栈指针到新进程的trapframe通过ret_from_exception恢复所有寄存器当执行最后的eret指令时CPU会从cp0_epc指向的地址开始执行——这就是我们在load_icode中设置的ELF入口地址。至此一个用户进程终于获得了CPU的控制权。5. 实验中的踩坑实录在实现进程创建时我曾遇到一个诡异的问题用户程序总是崩溃。经过单步调试发现原来忘记设置env_tf.regs[29](SP寄存器)的初始值。用户栈需要预留argc/argv的空间e-env_tf.regs[29] USTACKTOP - sizeof(int) - sizeof(char **);另一个坑点是map_segment的实现。最初我直接循环调用page_insert但忘记处理页面对齐的情况。正确的做法应该像这样for (int i 0; i size; i BY2PG) { page_insert(pgdir, asid, pa2page(pa i), va i, perm); }通过这次实验我深刻理解了操作系统如何通过PCB管理进程生命周期。从内存分配到寄存器恢复每个细节都影响着系统的稳定性和效率。当看到两个测试进程在时钟中断下交替运行时那种成就感至今难忘。