我第一次做 xv6 启动过程分析实验的时候,把make qemu-gdb跑起来,在 gdb 里随手打了个break _entry,然后continue。PC 停在一个看起来特别普通的地址上,旁边的汇编加起来不到十行。当时我盯着屏幕想:就这几行东西,后面那个控制台、内存分配器、进程调度,到底是怎么从无到有冒出来的?答案其实全在这几行汇编和紧随其后的几个 C 函数里。这篇文章就把整条 xv6 启动过程从头到尾拆一遍,从 QEMU 引导、M 模式入口,到 S 模式下 main() 的初始化,再到第一个用户进程被调度起来。适合正在做操作系统实验课程的学生,也适合所有想真正看懂内核是怎么“开机”的自学者。
1. 启动过程分析:先看清整条链路再动手
1.1 这个实验到底要解决什么问题
xv6 启动过程分析这个实验,核心不是让你背下某几行代码,而是让你回答三个问题:内核从哪个物理地址开始执行;启动阶段的特权级是怎么从 M 模式一路切到 S 模式的;main() 里那一长串初始化函数为什么必须按那个顺序执行。
搞清楚这三个问题,你对操作系统的理解会上一个台阶。因为你手里不只是一个“能跑起来的教学内核”,而是一条完整的证据链:从 CPU 复位,到引导代码加载镜像,再到内核自举、开启分页、创建第一个进程。每一步都能在源码里找到对应代码,也都能在 gdb 里看到寄存器变化。
我当时做完这个实验最大的感受是:xv6 的启动代码非常短,短到你可以把所有关键文件从头读到尾,但信息密度极高。每一个 CSR 操作、每一次函数调用,背后都对应一个硬件机制或设计约束。这跟读 Linux 那种动辄几千行启动汇编是完全不同的体验。
1.2 启动全流程时间线
我建议在动手之前,先把整条链路画出来。下面的表格基本就是这个实验的“地图”,我后面所有小节都会照着它展开。
| 阶段 | 执行位置 | 特权级 | 关键事件 |
|---|---|---|---|
| 固件引导 | QEMU bootloader / OpenSBI | M | 把 kernel 镜像加载到物理地址 0x80000000,PC 跳到_entry |
| 入口汇编 | kernel/entry.S | M | 设置栈指针sp,调用 C 函数start() |
| 权限切换 | kernel/start.c | M → S | 配置mstatus、mepc、satp,执行mret进入main() |
| 内核初始化 | kernel/main.c | S | 控制台、内存、页表、进程表、中断、文件系统等初始化 |
| 第一个进程 | userinit()+scheduler() | S → U | 创建 initcode 进程,调度器选出并运行它 |
这个表格看起来简单,但每一行都值得展开。尤其是“权限切换”那一行,是整个启动过程最容易出错、也最容易让人困惑的地方。
1.3 需要哪些前置知识
做这个实验不需要你已经是内核专家,但有几个基础点最好先混个脸熟:
- RISC-V 特权级模型:M(Machine)、S(Supervisor)、U(User)三种模式,能看懂即可。
- CSR 操作:
mstatus、mepc、satp这些寄存器的名字和基本用途,遇到不会的再查手册。 - 链接脚本的基本概念:知道
_entry、bootstacktop这类符号是从哪里来的。 - 页表的最小概念:PTE 的权限位、
satp寄存器的作用,不理解细节也不影响先读代码。
工具方面,你需要 riscv64 交叉编译工具链、QEMU、gdb。这三个东西在 xv6 实验课程里是标配,make qemu-gdb一条命令就能把 QEMU 和 gdb 连起来。
2. 先搞懂三件事:特权级、地址空间、固件交接
2.1 RISC-V 的三级权限模型和 mret 的换挡逻辑
RISC-V 的三种特权级可以类比成公司门禁卡:M 模式是老板,能进任何房间;S 模式是部门主管,能管自己部门的大多数事务;U 模式是普通员工,只能在工位区域活动。操作系统内核通常运行在 S 模式,用户程序运行在 U 模式,而 M 模式一般只留给固件或者可信启动代码。
xv6 启动过程的关键动作之一,就是“从 M 模式降级到 S 模式”。这个降级不是打个寄存器标记就完事,RISC-V 提供了一个专门指令mret。
mret的行为可以概括成两件事:把当前特权级切换成mstatus.MPP字段里记录的值;把 PC 跳转到mepc寄存器里记录的值。换句话说,mret是“换挡 + 跳转”二合一。
所以,如果你想在启动过程中从 M 模式安全进入 S 模式,就必须在mret之前做好两件事:
- 把
mstatus.MPP设置为 01(S mode)。 - 把
mepc设置为进入 S 模式后要执行的第一条指令地址,也就是main()。
很多同学第一次做实验时,会下意识以为call start之后,start()会像普通 C 函数一样正常返回entry.S。但事实不是这样,start()最后用mret直接飞走了,不会再回来。这也是entry.S在call start后面写一个死循环的原因之一。
2.2 QEMU virt 平台的内存地图
xv6 实验通常跑在 QEMU 的 virt 机器上。这个机器有一个很关键的硬件事实:物理内存从0x80000000开始,而不是从0x0开始。
低地址区域被各种外设占用。xv6 的memlayout.h里写得很清楚:
| 设备 | 物理地址 | 说明 |
|---|---|---|
| UART0 | 0x10000000 | 串口,xv6 的 printf 输出就靠它 |
| CLINT | 0x02000000 | 定时器与核间中断 |
| PLIC | 0x0c000000 | 平台级中断控制器,管理外设中断 |
| VIRTIO0 | 0x10001000 | virtio 磁盘设备,xv6 的文件系统依赖它 |
内存布局里,KERNBASE被定义为0x80000000L,PHYSTOP是KERNBASE + 128MB。也就是说,内核镜像、物理内存分配器管理的所有页面,都集中在0x80000000到0x88000000之间。
这也是 QEMU 命令行里-m 128M的由来。你如果调大内存,PHYSTOP这个宏也要跟着改,否则内存分配器不会使用多出来的部分。
为什么内核要加载在0x80000000而不是更低地址?因为低地址是设备 MMIO 区域,如果把内核放在那里,要么覆盖固件,要么覆盖 UART 寄存器,结果都是灾难。0x80000000是 virt 平台定义的 RAM 起始点,内核从那里开始,是最自然的选择。
2.3 固件把控制权交给 xv6 的那一刻
实验环境里,QEMU 启动命令一般是:
qemu-system-riscv64 -machine virt -bios none -kernel kernel -m 128M -smp 3 -nographic-bios none表示不使用完整的 OpenSBI 固件,QEMU 会用一个最小的内置 bootloader。它的工作很简单:把-kernel指定的镜像加载到物理地址0x80000000,然后把 PC 设置到镜像入口,最后跳过去。
这里有一个必须要知道的前提:xv6 内核期望自己被加载到0x80000000,并且期望自己被以 M 模式启动。也就是说,当 PC 跳到_entry时,CPU 还在 M 模式,页表没有开启,异常向量没有设置,栈指针也没有一个可靠的值。固件做完自己该做的事之后就撒手不管了,后面所有现场重建工作都交给 xv6 自己。
很多初次接触的人会误以为“内核是被固件调用进来的,所以固件至少给了个像样的栈”。实际不是。xv6 入口第一件事就是自己设置sp,原因就在这里。
3. 第一站源码走读:entry.S 入口汇编
3.1 三行代码拆开看
xv6-riscv 的entry.S精简到让人怀疑自己看错了:
.section .text .global _entry _entry: la sp, bootstacktop call start spin: j spin没有 MMU 设置,没有异常向量安装,没有花哨的页表切换,只有三件事:设栈、调用 C 函数、留一个死循环兜底。
第一句la sp, bootstacktop把bootstacktop这个链接期符号的地址加载到栈指针。bootstacktop是内核镜像里一块静态栈的栈顶,这块栈在链接脚本里就已经预留好了,不需要动态分配。此时分页还没开,la得到的就是物理地址,访问起来没有任何问题。
第二句call start是普通的过程调用,它会先把返回地址压栈,然后跳到start()。如果没有后面的mret设计,理论上start()结束后会回到这里继续执行。
第三句spin: j spin是一个死循环。它存在的意义是防止start()意外返回后执行到未定义区域。如果你在 gdb 里看到 PC 停在spin,那说明start()没有走正常的mret流程,而是像普通函数一样返回了,这本身就是一种调试信号。
3.2 为什么入口只有一个栈,没有任何页表
这个问题我一开始也没想明白:既然后面要开分页,为什么不在入口汇编里顺手把页表建好?
答案其实很朴素:页表的建立过程需要访问物理内存、需要计算 PTE 的物理地址、需要处理各种对齐要求,这些逻辑用 C 来写要清晰得多。而入口汇编的任务只是让 C 代码有一个可运行的环境——栈。
栈是 C 语言的命脉。没有栈,局部变量没法存,函数调用没法压返回地址。所以入口首先要解决栈的问题。页表则不同,它可以等main()里通过kinit()拿到物理页之后,再从容地构造。
另一个原因是,xv6 的启动阶段并不需要复杂页表。在进入 main() 之前,CPU 一直用物理地址访问内存,因为satp保持为零,MMU 处于关闭状态。与其在汇编里手搓页表,不如把这件事推迟到 C 环境就绪之后。
对比其他成熟内核,比如 Linux 的启动汇编,你会看到大量页表相关代码。那是以性能和多平台适配为目标的设计。xv6 作为教学内核,刻意把启动汇编压到最短,目的就是让你把精力放在“特权级切换”和“初始化顺序”这两个核心问题上。
3.3 如何用 GDB 验证自己确实停在入口
做实验的时候,不要只盯着源码看,一定要用 gdb 把现场拉出来看一眼。
QEMU 调试模式启动后,你复位后的第一条指令可能不是_entry,而是 QEMU 内置 bootloader 的地址(通常是0x1000)。这个 bootloader 会负责跳转到0x80000000。所以你在_entry打断点是合理的,它会在固件跳转之后立刻命中。
(gdb) break _entry (gdb) continue (gdb) info registers pc sp satp如果一切正常,你会看到pc停在_entry对应的地址(约0x80000000附近),sp还不是有效值,satp为 0。这个现场就完美验证了前面说的:固件只是把控制权交过来了,栈、页表、异常向量全都还没建。
我第一次在 gdb 里看到这个现场时,才真正意识到“从零启动”这四个字的分量。
4. 第二站源码走读:start.c 的权限切换
4.1 start() 的每一行都是什么
从entry.S进来之后,执行权交给了 C 函数start()。这个函数很短,但每一行都在为mret做铺垫。
void start() { unsigned long x = r_mstatus(); x &= ~MSTATUS_MPP_MASK; x |= MSTATUS_MPP_S; w_mstatus(x); w_mepc((uint64)main); w_satp(0); w_medeleg(0xffff); w_mideleg(0xffff); w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE); asm volatile("mret"); }第一段是修改mstatus。MSTATUS_MPP_MASK覆盖的是mstatus里描述“上一步特权级”的那两位,MSTATUS_MPP_S对应的值就是 01。清掉旧值再写入 S 模式,相当于告诉硬件:mret之后我要去 S 模式,不要回 M 模式。
第二段是把main()的地址写入mepc。mepc的全称是 Machine Exception Program Counter,异常或中断发生时,硬件会把当前 PC 保存到这里;mret执行时,又会把这个值写回 PC。所以w_mepc((uint64)main)就是在设定“换挡后的第一站”。
第三段w_satp(0)的作用是确保分页关闭。虽然绝大多数环境下固件跳转过来时satp本来就是 0,但显式清零更稳妥。这行代码也印证了一个事实:直到这里,xv6 都还在用物理地址直接运行。
第四段是中断委托配置,第五段是mret。整个函数没有任何复杂逻辑,但它的目标极其明确:把 CPU 从 M 模式安全送到 S 模式,并让 PC 停在main()的入口。
4.2 为什么要把中断和异常全量委托给 S 模式
w_medeleg(0xffff)和w_mideleg(0xffff)这两行,很多初学者会一扫而过,但它其实是整个启动过程中最重要的设计决策之一。
RISC-V 的 M 模式天然拥有最高权限,所有异常和中断默认都会先到 M 模式。如果 xv6 不接管 M 模式的中断处理,而是允许异常继续流向 M 模式,那 CPU 就会跳到一个未经初始化的 M 模式 trap 向量,几乎必然导致死循环或 crash。
xv6 的设计是:内核主体运行在 S 模式,异常和中断的处理逻辑也全部在 S 模式实现(比如trap.c里的代码)。因此,启动阶段必须把几乎所有的异常和中断“委派”给 S 模式。medeleg是 Machine Exception Delegation,mideleg是 Machine Interrupt Delegation,写入0xffff意味着二进制位对应的异常类型全部委托给 S 模式。
有人会问:委托之后,M 模式还需要处理什么吗?实验里基本不需要。timerinit()会配置定时器中断,但它的核心目的是给后续的调度器提供时钟源,与主线的“启动过程”关系不大。你只要理解:如果没有这几行委托代码,进入 S 模式后任何一个异常都会让系统直接挂掉。
4.3 mret 那一刻到底发生了什么
mret是启动过程的“换挡按钮”。它执行时,硬件会把mstatus.MPP字段写入当前特权级,把mepc的值写入 PC。最终效果就是:CPU 从 M 模式变成 S 模式,同时跳转到main()。
我在 gdb 里看过很多次这个瞬间。单步执行到mret,然后再stepi,你会看到pc一下子从start()的中段跳到main()的第一条指令。这不是普通的函数调用,因为栈上不会多出返回地址;这是特权级切换,是一条不往回走的单行线。
最容易踩的坑是MSTATUS_MPP_MASK和MSTATUS_MPP_S写错。这两个宏的值是由硬件决定的,写成别的数字,mret后 CPU 可能回到 U 模式甚至留在 M 模式。如果回到 U 模式,内核继续访问 S 模式特有的 CSR 或执行特权指令,会立刻触发异常。而且因为异常向量可能还没有完全配置好,你看到的往往不是一行清晰的报错,而是 PC 飞到某个奇怪地址或者直接卡死。
所以排查启动问题的时候,第一个要确认的点永远是:mret执行前,mstatus.MPP是否为 01,mepc是否指向main。
5. 第三站源码走读:main.c 初始化全流程
5.1 main 函数的分叉:0 号核与其他核
进入main()之后,启动过程正式进入 C 语言的主场。xv6 默认支持多核(实验环境通常是 3 个核),因此main()一进来就要区分“我是主核还是辅助核”。
volatile static int started = 0; void main() { if(cpu() == 0){ consoleinit(); printfinit(); printf("xv6 kernel is booting\n"); kinit(); kvminit(); kvminithart(); procinit(); trapinit(); trapinithart(); plicinit(); plicinithart(); binit(); iinit(); fileinit(); virtio_disk_init(); userinit(); __sync_synchronize(); started = 1; } else { while(started == 0) ; __sync_synchronize(); kvminithart(); trapinithart(); plicinithart(); } scheduler(); }cpu()函数会读取当前 hart 的编号,xv6 在入口阶段就把 hartid 保存到了tp寄存器。0 号 hart 承担几乎所有初始化任务,其他 hart 则在started这个栅栏面前自旋等待。
主核把所有子系统和设备初始化完成之后,才会把started置 1。辅助核看到started变为 1,才开始执行属于自己的那部分初始化。
注意,辅助核是不需要重复初始化物理内存分配器、文件系统、进程表的,因为它们已经是全局状态,主核做好了,其他核直接共享。辅助核只需要做和当前核强相关的操作:开启页表、安装 trap 向量、启用 PLIC 中断。
5.2 初始化依赖链,一个都不能乱
main()里的初始化顺序是无数实验踩坑之后沉淀下来的。每一个函数的位置都有明确原因,我列了一张依赖关系表:
| 函数 | 作用 | 为什么在这个位置 |
|---|---|---|
consoleinit() | 初始化串口 | printf 依赖它,必须先让输出可用 |
printfinit() | 初始化 printf 锁 | 后续所有日志输出都需要它 |
kinit() | 物理内存分配器 | 建页表、进程结构、trapframe 都要分配物理页 |
kvminit() | 构造内核页表 | 开启分页之前必须先把页表准备好 |
kvminithart() | 开启当前核分页 | 核心里程碑,之后的访问都走虚拟地址 |
procinit() | 初始化进程表与自旋锁 | 需要 kinit 分配内存 |
trapinit() | 初始化 trap 相关锁 | 中断向量注册的前置条件 |
trapinithart() | 安装内核 trap 向量 | 设备中断开启前必须有处理入口 |
plicinit() | 初始化中断控制器 | 设备中断的前置 |
plicinithart() | 开启当前核的外部中断 | 需要 trap 向量已装好 |
binit() | 缓冲区缓存 | 文件系统读写依赖 |
iinit() | inode 缓存 | 进程要 exec/init,需要文件系统支持 |
fileinit() | 文件表 | 打开文件描述符依赖 |
virtio_disk_init() | 初始化 virtio 磁盘 | 根文件系统在磁盘上 |
userinit() | 创建第一个用户进程 | 所有资源就绪后才能启动业务 |
这里最关键的依赖是kinit()必须早于所有需要分配页表的模块,kvminit()必须早于kvminithart(),kvminithart()又必须早于访问任何虚拟地址的代码。
你可能会注意到,procinit()在kvminithart()之后。这是因为进程表结构体中的数据需要通过页表访问,开启分页之后再初始化进程表更安全,也不会出现“物理地址直访”和“虚拟地址访问”混用的问题。
5.3 里程碑:kvminithart 开启分页的那一瞬间
kvminit()负责构造内核页表kernel_pagetable,这个页表覆盖了内核镜像、设备 MMIO、trampoline 页、每个进程的内核栈等区域。
kvminithart()才是真正“通电”的瞬间。它把kernel_pagetable的根页面物理地址写入satp,然后执行sfence.vma刷新 TLB。
看到这里你可能会问:开启分页之后,PC 还在kvminithart()附近,下一跳指令的虚拟地址是0x80000000附近的某个值,它怎么保证能翻译到正确的物理地址?
答案在内核页表的映射策略:xv6 对内核代码和数据区域做了“恒等映射”,也就是虚拟地址0x80000000映射到物理地址0x80000000。所以开启分页的那一刻,地址翻译结果和之前物理地址直访完全一致,取指不会断。
这个设计是启动过程能够“无缝切换”的关键。如果你在做自己的实验时修改过内核页表映射,一定要保证 PC 所在地址处的指令有对应映射,否则在kvminithart()后的第一条指令就可能触发 instruction page fault。
开启分页之后,UART0、PLIC、CLINT 等设备地址也必须能在页表里找到对应映射。xv6 的kvmmake()会把 UART0、VIRTIO0、PLIC、CLINT 等设备的物理地址映射到相同的虚拟地址,这样驱动代码访问设备寄存器时不需要任何额外换算。
5.4 从 userinit 到 scheduler:第一个进程的诞生
main()初始化链的最后一步是userinit(),它负责创建第一个用户进程。这个进程并不是直接执行某个 ELF 文件,而是执行一个叫initcode的极简程序。
initcode是一段内嵌在 kernel 镜像里的二进制代码,它的职责只有一个:调用exec("/init"),把真正的系统初始化程序/init加载进来。userinit()会分配一个进程结构,为它分配用户页表,把initcode拷贝到页表的第一页,设置 trapframe 里的入口地址和栈指针,最后把进程状态置为RUNNABLE。
完成这些之后,主核和所有辅助核一起进入scheduler()。调度器是一个无限循环,它会不断寻找RUNNABLE状态的进程,然后通过上下文切换交出 CPU。
第一个被调度器选中的进程就是 initcode 进程。它第一次获得 CPU 时,会从用户态执行exec("/init"),随后被init程序接管。从这一刻起,xv6 算是“活”了,启动过程正式抵达终点。
6. 亲手调试:GDB 追踪启动流程与高频问题排查
6.1 一套能复用的 GDB 命令序列
只看不调,启动过程的分析永远是纸上谈兵。我强烈建议你跟着下面的流程亲手走一遍。
终端 1 启动带调试的 QEMU:
make qemu-gdb终端 2 启动 gdb,连接远程调试端口:
riscv64-unknown-elf-gdb kernel/kernel (gdb) target remote localhost:26000接下来把关键断点全部打上:
(gdb) break _entry (gdb) break start (gdb) break main (gdb) break kvminithart (gdb) break userinit (gdb) continue第一个断点命中后,用这些命令观察现场:
(gdb) info registers pc sp satp (gdb) stepi (gdb) p/x $mstatus (gdb) p/x $mepc (gdb) p/x $satp重点观察两处。第一处是start()里的mret,在mret之前打p/x $mstatus,确认 MPP 是 01,再执行stepi,看pc是否跳到main()。第二处是kvminithart()里写入satp之后,看后续 PC 是否还能正常取指。如果取指卡住,问题基本都在页表映射。
gdb 对 CSR 的名字支持取决于工具链版本,如果p/x $satp报错,也可以用info registers查看所有寄存器,或者把 QEMU 的 monitor 接到 45454 端口去查。
6.2 启动日志里的关键锚点
xv6 自带的日志输出本身就提供了很好的调试锚点。启动后你最先看到的是:
xv6 kernel is booting这行日志出现在consoleinit()和printfinit()完成之后。如果你连这行都没看到,说明问题出在更早的阶段:要么没进入main(),要么控制台初始化失败。此时你应该回头查mret和_entry。
如果看到了这行日志,但后续没有任何输出,那问题大概率出现在kvminithart()之后。因为开启分页后,任何没有映射的地址访问都会触发 page fault,而 page fault 处理流程可能还没有完全就绪,导致系统静默挂起。
我建议你实验时在几个关键位置临时加日志,比如:
printf("before kvminithart\n"); kvminithart(); printf("after kvminithart\n");这样一旦系统卡死,你能立刻知道卡在哪个区间。加日志的位置包括:kinit()之后、kvminithart()前后、userinit()前后。这是最笨但最有效的定位手段。
6.3 高频问题速查
我把实验里最常见的几个问题整理成了表格,方便你对照排查。
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
mret后 PC 飞掉或卡死 | mepc不是main()、MPP字段错误 | gdb 断在start(),检查mstatus与mepc |
| 串口完全无输出 | consoleinit()未执行或串口地址映射缺失 | 检查是否停在_entry/start,确认 UART0 映射 |
kvminithart()之后 page fault | 内核页表缺映射、PTE 权限错、satp未对齐 | 查看kernel_pagetable的 PTE,逐个核对权限位 |
辅助核一直卡在while(started == 0) | 缺少内存屏障或主核未执行到started = 1 | 检查__sync_synchronize(),断点started赋值处 |
| 启动时有输出但随即死机 | trap 向量未安装就使能中断 | 确认trapinithart()在plicinithart()之前执行 |
这五个问题里,最经典的是“mret 后卡死”。我记得有同学把MSTATUS_MPP_S宏理解成“设置 MPP 为 S 模式”的位掩码,结果在 mstatus 里写入了错误的值,mret后直接退回 U 模式,紧接着第一次访问内核地址就触发异常。排查这种问题没有什么捷径,就是把 gdb 断在mret前,一行一行读 CSR。
7. 吃透启动过程后的一点个人体会
如果让我重做一次这个实验,我会在第一遍读源码时就把main()的依赖链画成一张图,然后在 gdb 里给每个关键函数前后都加一个临时日志。这个习惯帮我节省了大量排查时间,也让“初始化顺序”不再是死记硬背,而是变成了看得见摸得着的因果链。
我个人踩过最大的坑,是对mret的语义理解不到位。当时我以为start()是普通函数调用,mret只是某种“跳转”的变体,结果调试时一直盯着calling convention,完全没意识到问题出在特权级切换上。后来把 RISC-V 手册里mret的定义反复读了几遍,又用 gdb 单步了一场,才彻底搞明白。
所以最后想给你一个非常实在的建议:不要只在源码里看启动流程,一定要亲手在 gdb 里单步走过一次mret。等你看过mstatus.MPP那两位从 10 变成 01、pc一下子跳到main的瞬间,很多文字描述才真正变成你自己的知识。那之后你再回头看entry.S里那三行汇编,会发现它们一点也不简单,每一步都踩在硬件的关键机制上。