ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入解析xv6进程管理:从proc结构到上下文切换

深入解析xv6进程管理:从proc结构到上下文切换 我读xv6源码有一段时间了。坦白说最先让我对整个系统豁然开朗的不是页面表那套魔鬼细节也不是文件系统的inode链而是proc机制。原因很直接xv6里几乎每个核心机制最后都会落到进程表上——进程在跑、在睡、被kill、变成僵尸、被wait回收这些状态流转把中断、调度、系统调用全部串了起来。这篇笔记就是围绕XV6的proc机制写的进程怎么被创建上下文切换到底干了什么sleep为什么能恰好被wakeup叫醒exit之后僵尸进程为什么非要等wait。如果你正在做xv6 lab或者做完了实验却觉得代码“都认识但连不起来”这篇应该能帮你把那层窗户纸捅破。1. 为什么是proc一张能把xv6串起来的“网”xv6的源码量对初学者很友好全部加起来不到一万行但真要硬啃还是会卡住。我刚上手的时候按常规思路从boot开始读什么entry.S、start.c、main.c一路看下来到了虚拟内存、中断那几章就开始犯迷糊每个文件里的函数都能说出大概作用但它们在体系里怎么互相调用、一个系统调用从用户态进来之后到底走了多少层脑子里是一团浆糊。后来做Lab的时候需要改调度器、加系统调用被逼着把proc.c从头到尾读了几遍一下就想通了xv6里几乎所有事情都以进程为“宿主”。你看这几个场景系统调用从用户态陷入内核之后核心工作都是围绕myproc()拿到的当前进程来做的要么改它的内存要么动它的文件表。中断处理时钟中断引发进程调度本质上就是在进程表里挑一个进程换上去。内存管理用户态页表是挂在进程上的换进程就是换页表。文件系统打开的文件描述符、当前目录全都记录在进程结构体里。所以proc机制像一张网把中断、调度、内存、文件四块全兜住了。把proc读透再回头看其他模块每个函数都有“服务对象”了就不会再觉得它们飘在空中。我建议的读法是先不抠细节把proc.h里的结构体和状态机抄一遍然后在proc.c里找每个状态是在哪里被修改的画一张“状态转移图”。这张图画完你对xv6的调度骨架就有数了。本文后面几章的内容基本就是按这张图展开的创建UNUSED→USED→RUNNABLE、运行RUNNABLE→RUNNING、睡眠RUNNING→SLEEPING、退出RUNNING→ZOMBIE→UNUSED。xv6涉及proc机制的核心文件不多集中在下面几张文件职责proc.hproc结构体、状态枚举、cpu结构体定义proc.c进程创建、调度、睡眠、退出、回收的全套实现swtch.S上下文切换的汇编只有十几条指令sysproc.cfork/exit/wait等系统调用的用户态入口映射trap.c中断进内核后的分发里面会调用yield和kill检查读的时候盯住这五个文件就够了其余地方碰到再回头看。2. 环境搭建与调试手段先把代码跑起来再谈理解纯读代码很容易“脑内跑飞”尤其是进程切换这种并发逻辑靠想象不靠谱。我强烈建议先把xv6跑起来配合gdb单步看寄存器变化。2.1 在Linux或WSL2上搭环境xv6官方仓库在MIT的git上最新版是xv6-riscv。下面这套命令在Ubuntu 22.04/24.04上实测没问题sudo apt update sudo apt install git build-essential gdb-multiarch qemu-system-misc \ gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu git clone git://g.csail.mit.edu/xv6-labs-2023 cd xv6-labs-2023 make qemu看到QEMU窗口弹出来里面有$提示符能执行ls说明环境通了。用Ctrl-A再按x可以退出QEMU。Windows用户建议直接用WSL2别在原生Windows上折腾工具链RISC-V交叉编译那套在WSL里最省心。macOS用户注意需要确认qemu-system-riscv64装上brew装包的时候留意Apple Silicon下的版本兼容性m1/m2跑xv6没问题但有时需要手动指定qemu-system-riscv64路径。一个常见坑Ubuntu仓库里的qemu-system-misc版本如果和xv6的makefile预期不一致启动时可能报fatal: no bootable device。解决方式是卸载后从QEMU官网下载静态编译版本或者直接用MTI官方推荐的qemu包。大部分情况下apt install的版本够用真遇到问题再去换。2.2 gdb调试看寄存器比猜代码靠谱xv6提供了很方便的调试目标# 终端1 make qemu-gdb # 终端2 gdb-multiarch kernel/kernel连上之后可以用标准gdb命令断点调试我最常用的几招b allocproc # 断在进程分配函数 b scheduler # 断在调度主循环 b swtch # 断在上下文切换汇编 c # 继续执行 info registers # 看寄存器观察sp/ra的变化 bt # 打印内核栈回溯读进程切换时b swtch然后一次次si单步汇编你能肉眼看清楚sp和ra是怎么从一个进程的内核栈切到另一个进程内核栈的。这段动态过程一旦在脑子里立住了后面理解scheduler就非常轻松。调试中另一个建议充分用p命令打印结构体。断在allocproc里可以直接p *p看整个proc结构体初始化情况比在代码里加一堆printf强。2.3 调试验证状态机的技巧xv6源码里有很多panic本身就是很好的断言。比如sched()里会检查当前是否持有进程锁、中断是否已关闭。这些panic不是摆设它们帮你确认“调度器在做切换之前环境是否符合预期”。我读代码时遇到panic分支会顺手去搜一下触发条件往往能倒推出xv6的设计约束——这是读源码最高效的手段之一。3. struct proc逐字段拆解每个设计决策都有原因proc.h是整个proc机制的核心这个结构体字段不多但每个字段背后都是一个设计决策。我按自己读代码的顺序逐个拆一遍。enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE }; struct proc { struct spinlock lock; enum procstate state; struct proc *parent; void *chan; int killed; int xstate; int pid; uint64 kstack; uint64 sz; pagetable_t pagetable; struct trapframe *trapframe; struct context context; struct file *ofile[NOFILE]; struct inode *cwd; char name[16]; };先看状态机。UNUSED是空位USED表示分配了但还没完全初始化RUNNABLE排队等CPURUNNING正在CPU上跑SLEEPING在等某个事件ZOMBIE已经退出但残留尸体等父进程收走。这个状态设计把进程生命周期焊死了状态转换只发生在少数几个函数里状态迁移发生位置UNUSED → USEDallocprocUSED → RUNNABLEuserinit / fork 末尾RUNNABLE → RUNNINGscheduler 选中进程RUNNING → RUNNABLEyield 主动让出RUNNING → SLEEPINGsleepRUNNING → ZOMBIEexitZOMBIE → UNUSEDwait 中 freeproc再逐字段看含义。pid是进程身份证由全局计数器nextpid分配。parent指向父进程这是进程树形成的核心wait和exit都靠它找对象。chan是睡眠通道地址sleep/wakeup用同一个chan值完成配对本质是一个条件变量指针。killed是协作式终止标记xv6不强行抢占杀进程而是置位标志让进程在安全时机自杀。xstate存退出状态码wait要把它抄给用户。kstack是每个进程独立的内核栈物理地址陷入内核后栈就在这里多进程靠它隔离内核态栈空间。sz记录用户内存大小pagetable是用户态页表地址xv6换进程时会同时切页表。trapframe保存用户态寄存器现场context保存内核态线程上下文这两个字段极易混淆必须分开说。trapframe和context的区别是我觉得初学者最容易栽跟头的地方。trapframe管的是用户态↔内核态的切换用户进程触发中断/系统调用时CPU自动把用户态寄存器值存进trapframe等从内核返回用户态时再恢复。而context管的是内核态线程之间的调度切换调度器把一个进程换下CPU时要保存它内核执行流的返回地址、栈指针和callee-saved寄存器下次换回来才能从原位置继续跑。一个跨特权级一个跨执行流两者互为补充缺一不可。新版xv6里每个proc自带一把spinlock这也是一个值得品的设计。旧版xv6只有一把全局ptable.lock保护整个进程表一个CPU核持有锁时其他核要操作进程表只能排队等多核扩展性很差。后来改成每进程一把锁锁粒度细了两个CPU核可以同时操作不同的进程只有遍历进程表做分配、回收时才需要全局锁。读仓库代码时你会发现锁特别多别被吓到——先问“这把锁保护的是什么数据”再问“要操作这个数据需要先拿哪把锁”思路就顺了。主要的分工是全局的进程表锁保护proc数组的分配与遍历每进程的lock保护这个进程内部的state、parent、chan等字段。另外注意struct cpu cpus[NCPU]这个结构每个CPU核有自己独立的context、proc指针和中断开关计数。xv6的调度器是每CPU一份的理解多核调度时想象“每个核都跑一个scheduler循环在自己核上挑进程”就对了。4. 进程从无到有allocproc、fork、exec的完整链路读proc机制最好入口就是allocproc它是进程诞生的第一站。4.1 allocproc找一个空位子把基础设施搭好static struct proc* allocproc(void) { struct proc *p; for(p proc; p proc[NPROC]; p) { acquire(p-lock); if(p-state UNUSED) { goto found; } else { release(p-lock); } } return 0; found: p-pid allocpid(); p-state USED; if((p-trapframe (struct trapframe *)kalloc()) 0){ freeproc(p); release(p-lock); return 0; } p-pagetable proc_pagetable(p); if(p-pagetable 0){ freeproc(p); release(p-lock); return 0; } memset(p-context, 0, sizeof(p-context)); p-context.ra (uint64)forkret; p-context.sp (uint64)p-kstack PGSIZE; return p; }NPROC默认是64所以xv6最多同时存在64个进程。这也是教学系统典型的“静态数组”思路进程表是固定大小的遍历查找简单省去动态链表管理把复杂度留给后面的调度逻辑。注意allocproc做了三件关键的事分配trapframe页、建立用户页表、把context初始化成“从forkret开始执行”。尤其第三个动作很重要它是子进程第一次被调度时的“入口点”。一个全新进程的context里ra不是某个函数的返回地址而是forkret函数地址sp指向内核栈顶。这样调度器第一次swtch到新进程时CPU就像“调用forkret函数”一样开始执行随后再由forkret跳到用户态。4.2 userinit启动第一个用户进程系统启动时内核态main()做完初始化调用userinit()创建第一个用户进程。这个进程运行一段手写的initcode机器码最终exec执行/init。userinit的流程就是allocproc拿一个进程然后uvminit(p-pagetable, initcode, sizeof(initcode)); p-sz PGSIZE; p-trapframe-epc 0; // 用户程序计数器从0开始 p-trapframe-sp PGSIZE; // 用户栈顶从页顶向下生长这段代码揭示了xv6的模型进程的用户态地址空间从0开始栈从PGSIZE往下长第一个进程也不例外。设置完trapframe后把它标成RUNNABLE放进调度队列。4.3 fork复制一切然后“返回两次”fork是proc机制里最魔幻的系统调用因为同一个函数调用在父子进程里返回了不同的值。核心代码不长np-sz p-sz; *(np-trapframe) *(p-trapframe); np-trapframe-a0 0; // 子进程fork返回值设为0 np-context.ra (uint64)forkret; // 子进程从forkret开始 np-context.sp (uint64)np-kstack PGSIZE;关键在于子进程完全复制了一份父进程的用户态寄存器现场trapframe然后把a0寄存器改为0。在RISC-V调用约定里a0就是C函数的返回值所在寄存器。等子进程第一次被调度时它从forkret进入内核返回路径最终usertrapret把trapframe恢复到用户态用户态的fork()调用就看到a0等于0所以子进程“返回0”。而父进程的fork还在继续执行最后返回np-pid。这个设计极其精巧fork只复制状态不复制执行过程。子进程看起来像是“自己从fork返回了”其实是把父进程的现场复制了一份再做了个小手脚。fork还要复制用户页表xv6用uvmcopy逐页分配物理页并拷贝内容同时设置相同的页表权限。这是最花时间的地方也是fork性能开销的主要来源。理解了这个后面读Linux的写时复制COWfork就容易看出它是在“复制页表”这个环节做了延迟优化。4.4 exec换身体不换灵魂如果说fork是“复制自己”exec就是“换一个新程序进来”。exec从文件系统读ELF格式的可执行文件解析出代码段、数据段入口地址然后把进程的用户页表整个换掉重新设置trapframe的epc为ELF入口点sp为用户栈顶。在proc机制的视角里exec没有改变进程的pid、父进程、打开的文件描述符等“身份信息”只是把地址空间和用户态执行现场重置了。所以xv6里“运行一个新程序”的标准姿势是先fork出一个子进程再在子进程里exec。fork负责“复制身份”exec负责“替换内容”两者配合构成了Unix进程模型的基石。5. 调度器与swtch上下文切换的本质是“换寄存器”进程切换是proc机制里最劝退、也最值得啃的一块。啃完之后你会发现整个切换过程其实就是三件事保存当前执行的现场、选一个备选进程、恢复那个进程的现场。5.1 scheduler每个CPU的无限循环xv6的调度器不是某个定期触发的函数而是一个无限循环每个CPU核都跑一个for(;;){ intr_on(); for(p proc; p proc[NPROC]; p) { acquire(p-lock); if(p-state RUNNABLE) { p-state RUNNING; c-proc p; swtch(c-context, p-context); c-proc 0; } release(p-lock); } }scheduler从头到尾扫一遍proc数组挑出RUNNABLE进程把它的状态改成RUNNING然后swtch切过去。注意swtch这句是个“陷阱”它执行之后当前CPU核并不会马上回到这个循环而是要等被切换走的进程再次让出CPUswtch才会返回到这里继续扫描下一个进程。更准确地说swtch在这段代码里的行为是保存scheduler自己的现场到c-context然后恢复目标进程的context。当新进程第一次被调度时它context里的ra指向forkret所以CPU会跳到forkret去当老进程让出CPU时它绕了一大圈最终swtch回到scheduler的contextscheduler才从halt的地方继续跑寻找下一个RUNNABLE。5.2 swtch汇编为什么只保存这几组寄存器swtch.S只有十几条指令却是整个操作系统的“魔术时刻”.globl swtch swtch: sd ra, 0(a0) sd sp, 8(a0) sd s0, 16(a0) sd s1, 24(a0) sd s2, 32(a0) sd s3, 40(a0) sd s4, 48(a0) sd s5, 56(a0) sd s6, 64(a0) sd s7, 72(a0) sd s8, 80(a0) sd s9, 88(a0) sd s10, 96(a0) sd s11, 104(a0) ld ra, 0(a1) ld sp, 8(a1) ld s0, 16(a1) ld s1, 24(a1) ld s2, 32(a1) ld s3, 40(a1) ld s4, 48(a1) ld s5, 56(a1) ld s6, 64(a1) ld s7, 72(a1) ld s8, 80(a1) ld s9, 88(a1) ld s10, 96(a1) ld s11, 104(a1) ret参数a0是旧context地址a1是新context地址。为什么只保存s0-s11和ra、sp因为这些是RISC-V调用约定里的callee-saved寄存器——如果一个函数要使用这些寄存器必须自己保存并恢复。C语言编译器在函数调用边界已经自动保证了这个约定。swtch本身作为一个被调用的函数它只需要保存callee-saved寄存器就能让未来恢复现场时被切换的代码“感觉”自己只是经历了一次普通函数调用。sp也保存了因为切换进程本质上是切换内核栈。保存ra是因为要恢复执行位置。至于a0-a7、t0-t6这些caller-saved寄存器函数调用方自己会保存swtch替它们保存反而多此一举。这套设计让swtch异常精简也保证了和C编译器的无缝协作。5.3 一次完整的切换流程以时钟中断为例把整个过程串一遍进程P1在用户态跑着CPU收到定时器中断。硬件自动跳到trampoline.S把P1的用户态寄存器保存到p-trapframe。进入usertrap中断分发发现是定时器中断调用yield()。yield把P1状态改成RUNNABLE调用sched()。sched()做一堆安全检查持有锁、中断关闭、状态合法然后swtch(p-context, c-context)这里把P1的内核执行现场保存恢复scheduler的现场。scheduler从swtch返回继续循环遍历进程表找到P2。swtch(c-context, p-context)切到P2的现场CPU跳转到P2上次让出CPU的位置或者forkret。P2继续执行最终通过usertrapret恢复P2的trapframe返回用户态。第5步到第7步是整个机制最抽象的地方scheduler先“睡”在swtch里等P1再次让出CPU才醒来P2则是从自己过去的现场里“醒”来继续跑自己的内核栈。把这个循环在脑子里跑通调度机制就通了。还有一个值得注意的细节sched()里要求中断关闭也就是切换过程不能被外部中断打断。如果在保存现场的过程中又来了时钟中断两个执行流同时写context现场就乱了。所以xv6在push_off里维护了嵌套计数保证切换前的临界区完整。5.4 新进程第一次被调度forkret的作用新fork出来的子进程context里的ra被设成了forkret函数地址而不是某个从swtch返回的位置。所以它第一次被调度时CPU跳进forkret执行void forkret(void) { release(ptable.lock); usertrapret(); }forkret先释放进程表锁再调用usertrapret返回用户态。为什么要释放锁因为调度器切到新进程时还没走完scheduler循环里的release(p-lock)锁还在新进程手上。forkret不释放的话这把锁会一直握着其他进程全卡死。这个细节也解释了为什么forkret必须存在——它填补了“新进程不是从swtch返回来而是从头开始执行”这个特例。6. sleep/wakeup/exit/wait一场协作式的状态流转进程不会永远在RUNNING状态它经常要等某件事——等磁盘IO、等管道数据、等子进程退出。xv6用sleep/wakeup实现这种等待用exit/wait处理生命周期收尾。6.1 sleep的锁顺序为什么先拿进程锁再放调用者锁sleep的标准用法是这样void sleep(void *chan, struct spinlock *lk) { struct proc *p myproc(); acquire(p-lock); release(lk); p-chan chan; p-state SLEEPING; sched(); p-chan 0; release(p-lock); acquire(lk); }这个顺序是有讲究的。进程要进入睡眠必须保证“wakeup不会在我睡着之前就错过我”。sleep的做法是先获取自己进程的锁再释放调用者传进来的锁然后设置chan和SLEEPING状态。wakeup在唤醒时遍历进程表对每个进程acquire(p-lock)后检查state SLEEPING chan chan。因为sleep在设置SLEEPING之前一直持有p-lockwakeup在那之前无法获得这把锁也就无法“看到”一个还没完全睡下去的进程。这样就把“检查睡眠条件”和“进入睡眠”变成了原子操作不会丢唤醒。这里的锁顺序也倒逼了调用者的习惯你带着一把锁进sleepsleep会先释放它等醒来再帮你重新持有。所以sleep调用者的代码通常长这样while(pipeempty(p)) { sleep(p-nread, p-lock); }等唤醒后再循环检查条件防止“假唤醒”或者条件被其他进程改掉。这是教科书里条件变量的标准使用姿势。6.2 wakeup扫描进程表把睡对了地方的人叫醒wakeup更简单就是全表扫描void wakeup(void *chan) { struct proc *p; for(p proc; p proc[NPROC]; p) { acquire(p-lock); if(p-state SLEEPING p-chan chan) { p-state RUNNABLE; } release(p-lock); } }把状态改成RUNNABLE剩下的交给调度器。这样的设计不区分“谁在等这个chan”所有睡在同一个chan上的进程全部唤醒然后它们各自重新检查条件不行再睡回去。这就是“惊群效应”的雏形不过xv6为了简单完全没做优化Linux里后来演化出了等待队列和独占唤醒。6.3 exit与ZOMBIE死亡没有那么容易一个进程调用exit后并不会直接“消失”而是变成ZOMBIE等待父进程收尸。exit的核心逻辑关闭所有打开的文件释放用户内存和页表。把当前进程的子进程过继给init进程防止它们变成“孤儿”。把状态改成ZOMBIE保存退出码到xstate。唤醒父进程告诉它“你儿子没了可以来收尸了”。调用sched让出CPU从此这个进程的内核线程不再被调度。问题来了既然进程已经释放了大部分资源为什么还要保留proc结构和内核栈因为wait要读取退出码、pid还要检查子进程的存在性。如果exit立刻释放proc表项wait就没法拿到这些信息了。所以xv6选择保留尸体直到父进程来收。6.4 wait收尸人与“找不到孩子就睡觉”wait的流程是遍历所有进程找parent等于自己的子进程。如果找到了ZOMBIE状态的就把xstate复制给用户调用freeproc彻底释放proc表项返回子进程pid。如果找到了子进程但都还没退出就调sleep(p, ptable.lock)睡下去等某个子进程exit时通过wakeup把自己叫醒。这里有个互相配合的细节exit在变成ZOMBIE时会wakeup(p-parent)而wait在sleep时传入的chan就是自己父进程的struct proc地址。一个用父进程地址作为chan一个用自己地址作为chan配对成立。这也是读sleep/wakeup代码时判断“谁和谁配对”的关键技巧看chan传的是什么地址就知道谁在等谁。6.5 kill温和到极致的“杀人”kill系统调用的实现让人意外——它只是设置p-killed 1不做任何强制清理p-killed 1;那被kill的进程什么时候死xv6在几个“安全检查点”检查killed标志从内核返回用户态之前发现killed就调用exit进程睡眠被唤醒后发现killed就调用exit陷入内核处理系统调用/中断时发现killed也调用exit。这个设计叫协作式终止内核不强行中断进程的执行流而是设个标志让进程在最安全的地方自己退出。为什么不能直接强杀想象一个进程正在内核态修改文件系统关键数据结构你把它强制终止锁都没释放整个文件系统就挂了。所以xv6宁可让“死人”再跑一会儿也要保证它在安全边界上退出。理解了这一点再看Linux里的SIGKILL为什么也要“尽快”而不是“立即”处理思路是一样的。6.6 锁的分工与常见死锁陷阱前面反复提到全局进程表锁和per-proc锁两把锁。实际使用中要特别注意锁的获取顺序一般是先拿ptable.lock遍历再拿具体进程的p-lock操作状态。一旦顺序反了一个核持有p-lock想拿ptable.lock另一个核持有ptable.lock想拿p-lock就死锁了。xv6代码里lock ordering的注释很明确读源码时留意这些注释能避免很多困惑。7. 回到Linux把xv6的proc理解迁移到更大工程xv6的proc机制是简化版的教学模型但它的核心思想在真实操作系统里完整保留着。读懂xv6之后再看Linux的进程管理会有一种在石膏模型上学过解剖之后第一次看真实人体标本的感觉结构更复杂但器官你全认得了。7.1 结构对比task_struct比struct proc大得多Linux的进程描述符是task_struct一个结构体几千行。但剥开看核心字段和xv6的proc如出一辙功能xv6Linux进程IDpidpid / tgid状态state枚举state字段 exit_state父进程parentparent / real_parent内核栈kstackthread_union / kernel_stack用户态现场trapframethread_struct pt_regs内核切换现场contextthread_struct打开的fdofile[NOFILE]files_struct当前目录cwdfs_structLinux里同样区分pt_regs用户/内核态切换现场和thread_struct内核线程切换现场这个分层和xv6的trapframe/context完全同构。7.2 状态对比同名不同义要看注释Linux的进程状态用一堆TASK_*宏表示和xv6六个状态的直观枚举不同但概念能对上TASK_RUNNING约等于xv6的RUNNABLE RUNNINGLinux不区分“排队中”和“正在跑”因为运行队列本身就是状态。TASK_INTERRUPTIBLE类似SLEEPING但可以被信号唤醒xv6的SLEEPING通过检查killed来模拟这种“可被打断”。TASK_UNINTERRUPTIBLE是xv6没有的对应那些不能被打断的睡眠比如等待磁盘IO完成。EXIT_ZOMBIE和xv6的ZOMBIE完全同义。读到Linux的TASK_UNINTERRUPTIBLE时就能理解xv6为什么没做类似状态因为在单核教学系统里所有睡眠都被当成“等事件”而事件总会在中断里完成Linux要在真实硬件上等IO有些场景还不能中断睡眠。7.3 调度对比从线性扫描到CFSxv6的调度是O(N)遍历N最多64每次都从头扫到尾挑第一个RUNNABLE。Linux早几年也用O(1)调度器维护多个优先级队列现在的CFS完全公平调度基于红黑树按虚拟运行时间挑选最“饿”的进程复杂度O(logN)。但调度框架的骨架没变每个CPU有一个运行队列选一个进程用__switch_to切换上下文等它让出CPU再选下一个。xv6里scheduler循环和swtch聚合到了Linux就是pick_next_task和__switch_to核心思想一脉相承。7.4 学习路径与实验扩展建议如果读完这篇笔记觉得有了手感可以做几个小实验巩固理解改调度策略把scheduler的线性扫描改成简单优先级调度或者实现时间片轮转的多级队列。这个Lab能逼你把scheduler循环吃透。增加waitpid系统调用支持传入pid参数只等待指定子进程。这要求你理解wait的遍历和chan配对逻辑。实现写时复制fork在xv6里把uvmcopy改成只复制页表、共享物理页写时才复制。做完这个你会对fork的性能问题和进程地址空间理解上一个台阶。加入进程睡眠优先级让高优先级进程更早被wakeup观察对调度产生的连锁影响。这些实验做完再回头读Linux的kernel/fork.c、kernel/sched/core.c你会发现很多命名和结构都眼熟。我个人的体会是xv6最大的价值不是“复刻一个小Linux”而是提供了一个能完全读懂的参照系。有了这个参照系真实系统的复杂度就不会把你淹没你能从几千行的结构体里认出那些老朋友状态、上下文、运行队列、睡眠等待。最后再分享一个小习惯读这类源码时每遇到一个状态字段就在代码里搜一遍它的所有赋值点然后把赋值点和对应函数名写下来。这张表就是你对这个机制最扎实的笔记比任何博客都有用。
返回列表