☰
xv6 Lab4 进程调度与上下文切换:从寄存器到调度器实现
2026/10/17 1:29:44 网站建设 项目流程

1. 实验概览与核心需求拆解

xv6 的 lab4 通常是整个操作系统课程实验里承上启下的关键一环。如果说前面的 lab 是在教你"进程长什么样",那 lab4 就是在逼你亲手回答"进程到底是怎么被调度、怎么切换、怎么让出的"。

我当年做这个实验的时候,第一反应是"不就是改几个指针吗",结果被页表、上下文切换、锁竞争轮番教育。这个实验的核心价值不在于你把那几个测试用例跑通,而在于你真正理解了操作系统里最微妙的一个操作——从一个进程切换到另一个进程时,CPU 的状态到底经历了什么。

先说清楚 lab4 到底在做什么。不同学校的 xv6 实验安排略有差异,但绝大多数情况下,lab4 包含以下三个核心任务:

  1. 实现或完善进程调度器:在 xv6 原始的 round-robin 调度基础上,增加优先级调度、随机调度或 stride 调度,涉及 proc 结构体的扩充、调度算法的选择逻辑、scheduler 函数的改写。
  2. 实现上下文切换的完整链路:理解 swtch 函数的汇编实现、context 结构体的保存与恢复,以及 trap 返回路径上用户态与内核态的切换。
  3. 处理调度相关的并发与锁问题:多个 CPU 同时遍历 proc 表时如何保证安全,如何避免死锁和锁竞争。

在这三个任务里,最劝退新手的往往是第二个。很多同学把 scheduler 函数背得滚瓜烂熟,但问他"swtch 的参数为什么是两个指针的指针",立刻就卡住了。这个实验恰恰就是把这种"背不出来的理解"挖出来放在阳光下。

从适用范围来说,这个实验适合两类人:一类是正在修操作系统课程、被 lab4 折磨的在校学生;另一类是工作后想补操作系统底层功底的开发者——尤其是做中间件、数据库、高性能网络服务的,调度器的设计思路会直接影响你对并发模型的理解。

2. 环境准备与工具链配置

2.1 构建环境与依赖安装

在动手改代码之前,先把环境收拾利索。xv6 的构建工具链并不复杂,但有几个坑值得提前避掉。

我使用的是 Ubuntu 20.04/22.04 的常规环境,需要安装的基础包如下:

sudo apt update sudo apt install build-essential gdb-multiarch qemu-system-misc \ git python3 expect

注意几个细节:

  • gdb-multiarch是调试 xv6 的关键工具。普通的 gdb 不支持 RISC-V 架构,如果你直接用gdb命令去调试 xv6,会直接报"architecture not recognized"。用gdb-multiarch可以同时支持 x86 和 RISC-V,调试体验完全不一样。
  • expect是跑实验自带的评分脚本用的。有些学校给的测试脚本是交互式的,需要 expect 来自动应答,不装的话脚本会在某个地方卡死,看起来像是代码写错了,实际上是工具链缺失。
  • QEMU 的版本建议 5.0 以上,老版本在模拟多核 RISC-V 时会有一些时序问题,导致调度相关的并发 bug 复现不稳定。

装完依赖后先跑一次原始代码,确认 baseline 是干净可用的:

make clean make qemu

如果能看到 xv6 的 shell 提示符$,说明环境正常。这一步非常重要——如果连原始代码都起不来,后面所有改动都无从谈起,别一上来就埋头改代码。

2.2 调试工具链的搭建

调试 xv6 常见的方式有两种:QEMU 内置的 monitor 和 gdb-multiarch 的远程调试。

QEMU monitor 可以随时按Ctrl+A然后按C进入,输入info registers查看寄存器,输入info cpus查看每个 vCPU 的当前状态。这个方式适合快速确认"当前在哪个核上跑、跑到了哪条指令",但不适合做源码级断点调试。

真正的源码级调试需要开两个终端。第一个终端启动 QEMU 并等待调试器连接:

make qemu-gdb

第二个终端启动 gdb-multiarch:

gdb-multiarch kernel/kernel

然后在 gdb 里连接 QEMU:

target remote localhost:26000

这时你就可以对内核代码下断点了,比如:

break scheduler continue

我在做 lab4 时,最常用的断点是swtch函数入口和scheduler函数入口。这两个断点能让你清晰地看到每一次进程切换发生的时间和位置。

提示:gdb 调试 RISC-V 内核时,layout split看源码视图可能不太稳定,建议直接用list命令和stepi单步汇编指令混用。上下文切换本身就是汇编级操作,单步指令比源码级单步更能看清寄存器流转。

3. 核心机制拆解:上下文切换的完整链路

3.1 从进程 A 到进程 B,CPU 经历了什么

理解 lab4 的前提,是彻底搞清楚"上下文切换"到底切换了什么。

用生活化的方式类比:假设你正在看书(进程 A),电话铃响了(时钟中断),你需要在电话本上记下"我读到了第 38 页第 3 行"(保存上下文),然后去接电话(切换到进程 B)。接完电话后,你再回到沙发,根据电话本上的记录,翻到第 38 页第 3 行继续读(恢复上下文)。

操作系统做的事情一模一样,只不过电话本换成了struct context,页码行号换成了寄存器值。

在 xv6 中,struct context定义在kernel/proc.h里:

struct context { uint64 ra; uint64 sp; uint64 s0; uint64 s1; uint64 s2; uint64 s3; uint64 s4; uint64 s5; uint64 s6; uint64 s7; uint64 s8; uint64 s9; uint64 s10; uint64 s11; };

注意这里保存的寄存器非常有讲究。RISC-V 的寄存器分为 caller-saved 和 callee-saved:caller-saved 寄存器由调用方负责保存,callee-saved 寄存器由被调用方负责保存。上下文切换时,我们只需要保存 callee-saved 寄存器(s0-s11)+ ra + sp,因为 caller-saved 寄存器在函数调用边界上已经被压栈了。

这就是为什么 context 结构体里没有 a0-a7,也没有 t0-t6——这些寄存器不需要跨切换保留,它们的值在切换点之后会被重新加载。

3.2 swtch 汇编函数的逐行解读

真正执行保存和恢复操作的,是kernel/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 结构体里把每个字段加载回寄存器,最后 ret。

但简单的背后藏着两个关键点:

第一,a0 是旧进程的 context 指针,a1 是新进程的 context 指针。这个传参顺序是在调用 swtch 之前就约定好的,调用方负责准备好这两个参数。

第二,ret 指令跳转到哪,取决于 ra 寄存器被加载成了什么。如果新进程是第一次被调度(比如 fork 出来的子进程第一次上 CPU),它的 ra 被设置成了 forkret 函数的地址;如果新进程之前被切换出去过,它的 ra 就是上次切换出去时保存的返回地址。

这就是整个上下文切换最精妙的地方——看起来只是保存和恢复寄存器,实际上 ra 寄存器决定了 CPU 接下来执行哪段代码。所有"进程从哪来、到哪去"的信息,都编码在这 14 个寄存器的值里。

3.3 调用链的完整时序

把 swtch 放进整个调用链里看,会更加清晰:

  1. 定时器中断触发,CPU 进入 trap 处理流程。
  2. trap 调用usertrap(用户态中断)或kerneltrap(内核态中断),最终调用yield。
  3. yield将当前进程的状态设为 RUNNABLE,然后调用sched。
  4. sched检查当前状态合法之后,调用swtch(&p->context, &cpuid()->scheduler)。
  5. swtch保存当前进程的 callee-saved 寄存器,加载 CPU 调度器的上下文。
  6. 调度器从自己的scheduler函数中恢复执行。注意,调度器的上下文里保存的 ra,指向的是 scheduler 函数内部 swtch 调用的下一条指令。
  7. 调度器继续循环,从 RUNNABLE 队列中挑一个进程,调用swtch(&cpuid()->scheduler, &p->context)。
  8. 新进程的上下文被加载,ret 跳到这个进程上次保存的 ra 位置继续执行。

这个来回切换的过程中,最容易被忽略的一点是:一个 swtch 调用不是对称的。swtch的两次调用,第一次是从进程视角进入调度器,第二次是从调度器视角进入另一个进程。它们不是简单的"一去一回",而是形成了一条链:进程 A → 调度器 → 进程 B → 调度器 → 进程 C。

理解这条链的关键,在于理解每个 CPU 都有自己的调度器上下文。在struct cpu里有一个scheduler字段:

struct cpu { struct context scheduler; struct proc *proc; int noff; int intena; };

这个 context 是每个 CPU 独立维护的,它代表"调度器在哪个位置"。当进程让出 CPU 时,它跳回调度器的这个位置;当调度器选中一个新进程时,它从这个位置带着新进程的 context 跳出去。

提示:调试这个阶段时,可以在 swtch 函数的第一条指令处打断点,然后用 gdb 打印 a0 和 a1 指向的 context 里的 ra 值。你会惊讶地发现,即使是第一次运行的新进程,它的 context 里也已经被正确地初始化了 ra 为 forkret。这就是初始化进程时allocproc里那句p->context.ra = (uint64)forkret的作用。

4. 调度算法实现与 proc 结构体扩充

4.1 调度需求分析与算法选型

lab4 的调度算法部分,通常是让在默认的 round-robin 基础上,实现至少一种新的调度策略。常见选项有三种:

调度算法核心思想实现难度考察重点
优先级调度每个进程有优先级,每次选优先级最高的低proc 字段设计、遍历逻辑
Lottery 调度每个进程持有若干彩票,随机抽取中随机数生成、累加逻辑
Stride 调度按 pass 值递增,选最小 pass 的进程中高大数防溢出、参数调整

我建议优先选stride 调度,原因是它最能体现调度器设计的核心矛盾——如何在公平性和效率之间平衡,而且代码量不大,逻辑清晰,对后续面试讲项目也更有说服力。

但如果你时间紧张,或者对指针操作还不太熟,先从优先级调度入手把流程跑通,再考虑升级到 stride,也是完全合理的路径。实验的目标不是比谁算法强,而是把调度器的骨架理解透。

4.2 proc 结构体的字段扩充

无论选哪种算法,第一步都是改struct proc,在kernel/proc.h中加字段:

struct proc { enum procstate state; int pid; int killed; int xstate; uint64 sz; uint64 trapframe_va; struct trapframe *trapframe; struct context context; // 新增字段 int priority; // 进程优先级,范围 1-100,数值越小优先级越高 int stride_pass; // stride 调度的 pass 值 int stride_stride; // stride 调度的步长,由 priority 换算 int tickets; // lottery 调度的彩票数 uint64 sched_count; // 被调度次数,用于调试和验证 uint64 sched_ticks; // 累计运行 tick 数 };

这里我多加了一个sched_count和sched_ticks,不是实验必须的,但对调试和验证调度算法正确性非常有帮助。你可以在调度器选择进程时累计这两个值,最后用一个小测试程序打印出来,直观地看到每个进程实际被调度了多少次。

4.3 stride 调度算法的实现细节

stride 调度的核心规则:每个进程有一个 pass 值和一个 stride 值。每次调度时,选择 pass 值最小的进程运行,运行一个时间片后,该进程的 pass 值增加它的 stride。

struct proc* stride_sched(void) { struct proc *p; struct proc *chosen = 0; int min_pass = 0; for (p = proc; p < &proc[NPROC]; p++) { if (p->state != RUNNABLE || p->pid <= 0) continue; if (chosen == 0 || p->stride_pass < min_pass) { chosen = p; min_pass = p->stride_pass; } } if (chosen) { chosen->stride_pass += chosen->stride_stride; chosen->sched_count++; return chosen; } return 0; }

stride 的计算方式是关键。一般设置一个全局常量STRIDE_MAX = 10000,然后:

chosen->stride_stride = STRIDE_MAX / chosen->priority;

这样优先级越高(数值越小),stride 越小,pass 增长越慢,被选中的频率就越高。

大数防溢出问题是 stride 算法最经典的坑。如果 pass 值一直累加下去,理论上会溢出。xv6 的使用场景下,32 位整数够用,但如果你接入了长时间运行的测试程序,建议用 64 位整数,并且在累加前检查:

if (chosen->stride_pass > (uint64)-1 - chosen->stride_stride) { // 接近溢出,把所有 RUNNABLE 进程的 pass 都减去一个基准值 for (p = proc; p < &proc[NPROC]; p++) { if (p->state == RUNNABLE) p->stride_pass -= chosen->stride_pass; } }

或者更简洁的做法:初始化pass为 0,当任意一个进程的 pass 超过STRIDE_MAX * 2时,把所有进程的 pass 减去一个固定的偏移量。这样既保证相对大小关系不变,又避免了溢出。

4.4 调度器主循环的改写

找到kernel/proc.c里的scheduler函数,原始版本是:

void scheduler(void) { struct proc *p; struct cpu *c = mycpu(); c->proc = 0; for(;;){ intr_on(); for(p = proc; p < &proc[NPROC]; p++) { if(p->state == RUNNABLE){ p->state = RUNNING; c->proc = p; swtch(&c->scheduler, &p->context); c->proc = 0; } } } }

改造后,把内部的for循环换成调度算法的选择逻辑:

void scheduler(void) { struct proc *p; struct cpu *c = mycpu(); c->proc = 0; for(;;){ intr_on(); p = stride_sched(); if(p != 0){ p->state = RUNNING; c->proc = p; swtch(&c->scheduler, &p->context); c->proc = 0; } } }

注意,intr_on()必须在循环里反复调用。因为swtch切走再切回来后,中断可能已经被关闭了(在sched中调用push_off关过中断),如果不开中断,时钟中断就无法触发,调度器会卡死。

4.5 fork 时初始化调度字段

新进程诞生时,需要在allocproc和fork里给调度字段赋初值。

在allocproc中:

p->priority = 1; // 默认最高优先级,或设一个中间值 p->stride_pass = 0; p->stride_stride = STRIDE_MAX / p->priority; p->tickets = 10; p->sched_count = 0; p->sched_ticks = 0;

在实际测试中,我建议给所有新进程统一默认优先级(比如 1),然后测试程序里用系统调用set_priority修改,这样可以避免"父进程优先级影响子进程"的困惑。

5. 系统调用实现与用户态测试

5.1 添加 set_priority 系统调用

调度算法的价值需要用户态程序来验证,所以 lab4 通常会要求新增一个或多个系统调用。以set_priority为例,完整流程如下:

第一步,在kernel/syscall.h中声明系统调用号:

#define SYS_set_priority 22

第二步,在kernel/syscall.c中注册:

extern uint64 sys_set_priority(void); static uint64 (*syscalls[])(void) = { [SYS_set_priority] sys_set_priority, };

第三步,在kernel/sysproc.c中实现:

uint64 sys_set_priority(void) { int priority; if(argint(0, &priority) < 0) return -1; if(priority < 1 || priority > 100) return -1; struct proc *p = myproc(); int old = p->priority; p->priority = priority; p->stride_stride = STRIDE_MAX / priority; return old; }

第四步,在用户态头文件和库函数中封装:

在user/user.h中加声明:

int set_priority(int);

在user/usys.pl中加一条:

entry("set_priority");

然后重新运行make,会自动生成usys.S。

这套流程对于不熟悉 xv6 系统调用机制的同学来说,容易漏掉usys.pl这一步。一定记住:xv6 的系统调用用户态入口是脚本生成汇编,不是手写函数。

5.2 编写测试程序验证调度公平性

调度算法的验证,核心思路是:创建多个进程,设置不同优先级,每个进程累加一个计数器运行一段时间,最后比较计数器的值比例是否与优先级成反比。

我写的测试程序user/test_sched.c大致如下:

#include "kernel/types.h" #include "user/user.h" int main(int argc, char *argv[]) { int n = 3; int pids[3], i; int priorities[] = {1, 2, 4}; int64 count[3] = {0}; for(i = 0; i < n; i++){ pids[i] = fork(); if(pids[i] == 0){ set_priority(priorities[i]); for(volatile int64 j = 0; j < 10000000; j++) count[i]++; printf("child %d (prio %d) count=%d\n", i, priorities[i], count[i]); exit(0); } } for(i = 0; i < n; i++) wait(0); printf("test done\n"); exit(0); }

测试时,注意观察三个关键指标:

  • 比例关系:优先级 1:2:4 的进程,理想情况下计数比例接近 4:2:1。
  • 波动范围:由于时间片和调度时序,实际比例会有 10% 左右的波动,这是正常的。如果偏差超过 30%,说明你的 pass 计算或选择逻辑有 bug。
  • 是否有进程饿死:在 stride 调度下,高优先级进程理论上不会完全饿死低优先级进程,因为低优先级进程的 pass 会一直增长,最终会变成最小值。

提示:测试程序里volatile int64 j的volatile不能省。如果编译器把循环优化掉了,计数结果毫无意义。

5.3 用启动参数配置调度器

如果你实现了多个调度算法,可以通过内核启动参数切换。在 xv6 中,内核启动参数在kernel/main.c的main()函数里传入:

void main(int argc, char *argv[]) { ... if(argc > 1 && strcmp(argv[1], "stride") == 0) scheduler_type = SCHED_STRIDE; ... }

然后在 Makefile 的 QEMU 启动参数里加:

QEMUOPTS = -kernel kernel/kernel -m 128M -smp 3 $(QEMUEXTRA)

启动时手动传参:

make qemu QEMUEXTRA="-append 'stride'"

这种方式方便你在不同算法之间快速切换,不用反复改代码重新编译。

6. 并发与锁:多核场景下的调度安全

6.1 多核调度的并发危险性

xv6 默认支持多核(-smp 3 表示 3 个 CPU),这就带来一个 lab4 避不开的问题:多个 CPU 同时执行 scheduler,同时遍历 proc 数组,怎么办。

如果没有任何保护,两个 CPU 可能同时选中同一个 RUNNABLE 进程,或者一个 CPU 正在切换进程 A 到 B 时,另一个 CPU 把进程 A 的状态改了,都会造成灾难性后果。

xv6 的处理方式是:调度器运行期间持有锁。回到sched函数:

void sched(void) { struct proc *p = myproc(); if(!holding(&p->lock)) panic("sched lock"); ... swtch(&p->context, &mycpu()->scheduler); }

这里的前提是调用sched之前,进程的锁已经被持有。具体来说是yield函数:

void yield(void) { struct proc *p = myproc(); acquire(&p->lock); p->state = RUNNABLE; sched(); release(&p->lock); }

锁的作用原理:当进程 A 在 CPU 0 上调用 yield 时,先acquire(&p->lock),然后通过 swtch 切到调度器。这时 CPU 1 如果也想调度进程 A,它必须在 scheduler 里先尝试acquire(&p->lock),但锁已经被 CPU 0 持有,CPU 1 只能自旋等待。

等到 CPU 0 的调度器选中了另一个进程 B,并通过 swtch 切走,CPU 0 才在sched返回后执行release(&p->lock)。此时 CPU 1 才能拿到锁,但这时进程 A 已经不在 RUNNABLE 状态(可能已经变成 RUNNING 被其他核调度,或者变成 SLEEPING),所以 CPU 1 会跳过它。

这套设计的关键点在于:进程的 state、锁、context 三者的生命周期必须保持一致。锁保护的不仅仅是 state 字段本身,还保护了"state 被修改到 context 切换完成"这个完整的临界区。

6.2 避免死锁的工程实践

多核场景下还有两个经典死锁问题:

第一个是锁顺序问题。scheduler 获取进程锁的顺序是遍历 proc 数组的顺序,进程自身获取锁的顺序是从acquire(&p->lock)开始的。如果调度器在持有进程 A 的锁时,试图去获取进程 B 的锁,而进程 B 同时持有自己的锁试图获取进程 A 的锁,就会死锁。

xv6 的调度器代码刻意避免了这种情况——它在遍历 proc 时,每次只持有一个进程的锁,拿到锁后立刻设置 state 并调用 swtch,不会同时持有两把进程锁。

第二个是中断和锁的交互。拿到自旋锁时,中断是被关闭的。如果持锁时间过长,会严重影响时钟中断的响应。在调度器里,intr_on()必须在持有锁之前调用,顺序不能反——如果你关着中断去拿锁,一旦锁被其他 CPU 持有,你就在自旋等待期间把整个 CPU 的中断都屏蔽了,时钟中断进不来,整个调度器就僵住了。

6.3 多核性能调优观察

做完调度器改完后,用-smp 3跑压力测试,你可能会观察到一些有趣的现象:某些 CPU 的负载明显不均,或者所有进程都挤在一个核上跑。

原因通常是全局进程表遍历 + 一把全局调度锁的结构限制了并行度。xv6 作为教学系统,这种设计完全可以接受。如果你有精力尝试优化,可以考虑引入 per-CPU 的 run queue,但那是更深入的课题了——至少在我的实验里,能正确跑完测试就已经达到了 lab4 的核心目标。

7. 常见问题与排查技巧实录

7.1 问题速查表

症状可能原因排查方法
系统启动后卡死,光标不动scheduler 里没调 intr_on()在 scheduler 循环入口加打印,确认是否进入死循环
进程创建后立即崩溃context 初始化不完整检查 allocproc 里 ra 是否设置为 forkret,sp 是否正确
修改优先级不生效系统调用参数获取错误用 argint 打印参数值,检查用户态封装是否正确
测试程序计数严重偏离预期stride 算法没更新 pass检查选进程后是否正确pass += stride
多核运行随机崩溃proc 遍历时锁保护缺失检查 sched 前的 acquire 是否成对
gdb 无法连接QEMU 和 gdb 端口不一致确认用的是make qemu-gdb,端口是 26000

7.2 我最想分享的三个避坑经验

第一,不要迷信测试脚本的通过率。很多学校的 lab4 评分脚本只检查"测试程序能跑完且输出包含特定字符串",但它不会检查你的调度算法是否公平、是否会饿死进程。我见过同学的实现里,直接返回 proc 数组里第一个 RUNNABLE 进程,所有测试都能过,但三个进程的计数比例完全失衡——这在本质上还是 round-robin,甚至比 round-robin 更差。写测试逻辑时多问自己一句:这个结果能体现调度算法的语义吗?

第二,调试上下文切换故障,优先看 ra 寄存器。如果你遇到"切到新进程后第一条指令不对"的问题,不要急着改 C 代码,先打印目标进程 context 里的 ra 值。如果 ra 指向了一个奇怪的地址,多半是 context 初始化时被 memset 清零了,或者 ra 在保存时被 swtch 之外的代码污染了。这种问题用源码级调试反而绕远,寄存器级检查一步到位。

第三,改完 proc 结构体后,务必 make clean 再编译。xv6 的 Makefile 在某些场景下不会自动检测头文件变更,导致你改了proc.h里的结构体,但proc.c还在用旧的内存布局编译,运行时 struct 字段错位,造成各种怪异问题。这个坑我踩过不只一次,浪费时间且毫无技术含量。

7.3 调试过程中的一个小技巧

当你怀疑调度器进入死循环时,最快的诊断方式是加一个计数器变量,在 scheduler 循环里每次迭代加一,然后用 gdb 打断点查看计数器的变化:

volatile uint64 sched_loop_count = 0; void scheduler(void) { for(;;){ sched_loop_count++; ... } }

如果sched_loop_count不断增加,说明调度器在正常循环;如果卡在某个值不动,说明死锁或者 swtch 根本没回来。这种简单的插桩比猜测要高效得多。

8. 实验总结与拓展思考

这个实验做完之后,我对操作系统的理解有一个很明显的提升:以前看"进程调度"是抽象的概念,现在能具体到"寄存器里的 ra 指向哪条指令"。

如果非要给后来者一个实操建议,我会说:不要在理解上下文切换之前就动手改调度算法。先把 swtch、sched、scheduler 这条调用链彻底走通,哪怕花掉一半的实验时间都值得。调度算法本身反而是次要的——那只是几行选择逻辑,真正难的是理解"为什么这就要求持有这把锁、为什么这个函数必须关中断"。

另外,做完 lab4 之后再回头看你平时用的线程池、协程库、Go runtime 的 GMP 模型,会有一种豁然开朗的感觉。计算机系统的很多设计,其实都是在解决同一个问题:从哪里来、到哪里去、中间怎么保存现场,而 xv6 的 lab4 是用最精简的方式把这个问题讲明白了。

最后再分享一个我在答辩时被问到最多的问题,也建议你提前想好答案:stride 调度和 CFS(Linux 的完全公平调度器)在思路上有什么异同。你可以从"按虚拟运行时间排序"这个角度切入:stride 选 pass 最小的,CFS 选 vruntime 最小的,核心思想都是从"谁欠得最多选谁"转换为"谁跑得最少先跑谁",区别在于实现的数据结构(xv6 用数组遍历,CFS 用红黑树)和调度粒度(xv6 按 tick,CFS 按 nanosecond)。能把这个问题讲清楚,说明你是真的理解了 lab4。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询