资讯详情

xv6启动流程深度解析:从硬件复位到第一个Shell进程

📅 2026/10/15 3:21:37 | 华诺云谱 👁 阅读
xv6启动流程深度解析:从硬件复位到第一个Shell进程
做操作系统实验的同学十有八九会对启动过程抱有一种“既好奇又懒得碰”的态度。xv6这个教学内核代码量不大但启动链条横跨硬件复位、特权级切换、页表建立、第一个进程诞生任何一个环节理解不到位后面做系统调用、调度、文件系统实验都会觉得地基是歪的。这篇文章就围绕xv6启动过程把从QEMU上电到shell跑起来之间的每一步拆开来看适合正在上操作系统实验课、或者自己啃xv6源码的读者。我也会把调试启动链时踩过的坑和验证方法一并写出来。1. 为什么要把启动过程单独拎出来看1.1 启动过程一次跨过三个特权级xv6运行在RISC-V上CPU有三个特权级机器态M-mode、监管态S-mode、用户态U-mode。平常写实验代码大部分时间待在S-mode的内核里和U-mode的用户程序里而启动过程最特殊的地方在于它把三个特权级全走了一遍。上电瞬间CPU在M态此时能访问全部内存和CSR寄存器一切物理资源裸奔在眼前但这层窗口稍纵即逝。内核做完必要的初始化后会主动把自己降级到S态把大部分中断和异常委托给S态处理然后才进入正常的内核生活。而用户进程的第一次执行又要从S态通过sret一路跳到U态。这个“降级”过程不是理所应当的它是启动代码里一笔一笔写出来的。理解启动过程本质上就是理解这几个特权级之间的交接协议。还有一个实际层面的原因启动代码几乎没法用常规调试手段。早期阶段printf没有就绪串口没有初始化你写一百个printk都不可能有输出。很多同学第一次断点打在main函数上结果发现程序根本没走到那里也不知道卡在哪儿。所以启动过程必须靠读代码、断点、寄存器观察来推进而不是靠打印。1.2 实验里最容易卡住的三个节点以我带实验时观察到的现象大部分人卡在三个节点上第一个是内核入口。QEMU加-kernel参数启动时CPU跳到0x80000000但很多人不知道这个地址是怎么来的也不知道为什么内核代码会正好出现在这里于是第一行汇编就看得云里雾里。第二个是M态到S态的切换。mret这条指令看着很简单实际背后涉及mepc、mstatus.MPP、中断委托等多个状态少设一个位程序就跑到奇怪的地方。第三个是第一个用户进程。userinit创建出来的进程实际上先执行一段嵌入在内核里的汇编字节码而不是什么高级语言程序。这段字节码再通过exec系统调用去加载真正的/init程序。这个“进程套进程”的启动逻辑初次看容易绕晕。这三个节点恰好对应本文接下来要讲的每一条启动链。先建立整体印象再逐个击破。2. 复位入口与内核第一条指令0x1000到0x800000002.1 QEMU复位后到底发生了什么在RISC-V架构的QEMU virt机器上CPU复位后的程序计数器并不是0而是0x1000。这算是一个约定0x1000处放一段引导代码它做的事情极其简单——跳转到0x80000000。很多教学文章喜欢把这段引导跳转一笔带过但这里值得停下来想一想为什么内核入口要放在0x80000000原因有两层。第一层是QEMU virt机器的内存布局。物理内存从0x80000000开始这是平台固件约定好的DRAM起始地址。如果把内核放在这个地址之前CPU复位后可能读到一个不存在的物理地址连第一步跳转都完成不了。第二层是链接脚本。xv6的kernel.ld把内核的第一个代码段.text显式安排到0x80000000这样QEMU把整个内核镜像加载到物理内存时镜像开头的指令正好落在重启后CPU要跳转的目标地址上。注意这里的用词是“正好”。这个巧合不是运气而是链接脚本和加载地址配合的结果。你可以做个实验把kernel.ld里的入口地址改成0x80200000观察QEMU启动后会发生什么。结果是CPU跳到0x80000000时那里要么是空的要么是别的数据内核根本不会跑起来。2.2 链接脚本决定了内核住在哪里看内核启动不能只看代码还要看内核是怎么被放在内存里的。xv6根目录下的kernel/kernel.ld里有几行关键内容OUTPUT_ARCH( riscv ) ENTRY( _entry ) SECTIONS { . 0x80000000; ... }ENTRY(_entry)声明了整个内核的入口符号是_entry. 0x80000000把链接后的虚拟地址起点定在0x80000000。链接器把entry.S里定义的_entry放到这个地址上后续的函数、数据依次往后排。这里有一个容易困惑的点xv6的内核页表启用后虚拟地址和物理地址会有什么区别。启动早期的start.c里其实有一段设置satp为0的操作意思是一开始不开启页表虚拟地址直接映射物理地址。也就是说在内核页表生效前0x80000000既是虚拟地址也是物理地址。而main函数执行到kvminithart之后页表才真正切换过来此时内核地址空间被映射到0x80000000到物理内存末尾的区间。理解这个先后关系对后面看kvminit代码很有帮助。2.3 一个影响全局的细节-bios nonexv6实验里QEMU的启动参数长期包含一个-bios none很多人不知道它是干什么的。RISC-V的QEMU如果不加这个参数会默认加载一份固件固件在M态做一些初始化后再从某个地址跳转到内核。而xv6实验把它关掉了目的是让CPU复位后直接跳到0x80000000的_entry并且保持M态进入内核。这个细节直接决定了启动代码后面为什么必须在M态完成一堆工作。如果你用的实验框架没有加-bios none而是依赖引导固件跳到S态那么start.c里关于mstatus、mepc、mret的逻辑就要相应调整。所以遇到“我的xv6跑不起来”这类问题先去看Makefile里QEMU启动参数再去看特权级假设往往比盯着一行汇编更有用。我在实验指导里专门强调过分析启动流程前先确定自己环境里的QEMU参数。2.4 在入口处下的第一个断点调试启动过程最有价值的第一个断点就是0x80000000本身。用gdb连接QEMU后(gdb) break *0x80000000断下来之后先看三样东西mhartid当前CPU编号、mstatus寄存器、pc是否等于0x80000000。正常情况下2号CPU或其他CPU复位时mhartid会不同但都会跳到同一个入口。看到多个CPU都停在同一条指令上是启动过程的正常现象不是bug。实际xv6是多核启动的每个核都会经历从_entry到start再到main的过程只不过main里用cpunum() 0做了分工。这个断点也适合用来验证编译链接是否正确。如果断点落不下来或者pc落在完全不同的地址优先检查kernel.ld和Makefile的链接地址而不是去源码里找问题。3. entry.S给每个CPU找一块能落脚的栈3.1 栈为什么是堆出来的而不是分配出来的_entry的第一件事不是清状态不是设页表而是设置栈指针sp。原因很朴素汇编代码马上要调用start()这个C函数而C函数一来就要用栈保存现场、传递参数没有栈函数一进去就崩。这是启动代码里一个非常反直觉的地方内核都还没初始化内存怎么就能用栈了答案是xv6在编译链接阶段就在内存里预留了一块静态区域叫stack0。它不靠运行时分配而是靠链接脚本把一块.bss空间预留出来再由_entry把sp指过去。操作系统内核早期阶段的栈本质是一块“写死的内存”直到内存管理模块初始化完成栈才纳入正式的管理范围。xv6在entry.S里的实现大概是la sp, stack0 li a0, 1024*4 csrr a1, mhartid addi a1, a1, 1 mul a0, a0, a1 add sp, sp, a0第一眼看上去有点绕为什么不用stack0的起始地址直接作为第一个核的栈顶因为栈是向下生长的而这块预留空间的布局方式决定了每个核要拿到属于自己的栈顶。这里用(mhartid 1) * 4096算出偏移意思是0号核从stack0 4096开始往下用1号核从stack0 8192开始往下用每个核的栈空间是4KB互不重叠。为什么加1因为如果直接用mhartid * 40960号核的sp会落在stack0本身而stack0的起始地址可能紧挨着其他符号栈向下增长时可能越界踩到隔壁数据。多预留一个页面等于给0号核的栈底加了保护垫。3.2 mhartid与每个核的栈隔离csrr a1, mhartid这一步读者容易当成“读一个编号”就略过。实际上这里牵涉到多核启动的一个核心问题所有核复位后执行的是同一份代码如果它们用同一个栈那么谁先压栈谁就把谁的数据覆盖掉整个启动过程会乱套。xv6的解法是给每个CPU分配独立的物理栈区域并通过mhartid来区分当前是哪个核在执行。mhartid是机器态的一个CSR硬件在复位后会给每个核填入不同的编号0号、1号、2号……这个编号在启动早期是唯一的“身份证明”。后面start.c把mhartid保存到tp寄存器再往后cpunum()函数就是通过读tp来判断当前是哪个核。一条从汇编到C的身份传递链从这里就埋下了。这也解释了为什么实验里如果把tp寄存器改了整个调度和cpunum()全乱。调试多核问题的时候优先检查tp没有被异常代码破坏。3.3 进入C语言前的一小段“硬译”从_entry到call start中间只有几条指令但它们每一行都有明确的意图选栈、算偏移、跳转。没有复杂的循环没有条件判断甚至连BSS段清理都没有。为什么不需要清BSS因为xv6在kernel目录的编译流程里对BSS段有独立处理而且启动早期对BSS内容并没有严格依赖——代码里直接用到的变量大多会被后续的C初始化代码显式赋值。如果你在改造xv6时新增了需要在start之前就有效的全局变量那就得自己补上BSS清理逻辑否则变量初值可能是随机的。call start这条指令实际会经过auipc加jalr两步完成因为它要支持位置无关跳转。内核被加载到固定地址时直接用绝对跳转也行但RISC-V的工具链习惯用这种相对寻址方式保证代码在链接地址和加载地址一致时行为正确。3.4 汇编阶段没有printf靠什么确认进展很多同学到这一步就难受了汇编阶段打不了日志怎么知道执行到哪了我的做法是用gdb看寄存器变化而不是指望打印。具体来说在0x80000000断下来后单步执行每一条指令重点观察sp的变化是否符合预期。第0个核执行完la sp, stack0和偏移计算后sp应该等于stack0 4096。如果你在gdb里看到sp落在stack0本身或者某个离谱地址说明偏移计算写错了。还有一种技巧是直接读内存。栈区域在.bss里刚启动时内容应该全是0QEMU加载镜像时会清零BSS区域如果某个核还没执行到栈设置它的sp还是复位后的初始值。通过对比多核的sp差异也能判断启动进度。4. start.c机器态的最后一场布置会4.1 mret之前必须说清楚的三件事进入C语言后start()在M态做一系列配置最后通过mret跳回S态执行main。mret这名字看起来像“从M态返回”但它不是简单地回到调用点而是按照mepc寄存器指定的地址跳转同时根据mstatus里的MPP位决定返回后进入哪个特权级。所以在执行mret之前必须把三件事说清楚第一mepc要写上将来要执行的地址xv6写入的是main函数的地址第二mstatus.MPP要设成S态这样mret之后CPU进入S态第三S态陷阱相关的CSR要至少配好基础项否则一进S态就被中断打懵。xv6的start.c里有这么一段unsigned long mstatus r_mstatus(); mstatus ~(1 11); // 清MPP mstatus | (1 11); // MPP 01 (S-mode) w_mstatus(mstatus); w_mepc((uint64)main);这里的位操作就是在改mstatus.MPP。如果这里写错了mret之后CPU可能留在M态或者进U态行为都会变得非常诡异。我自己遇到过的情况是为了“精简启动过程”把MPP设置删了结果内核一直在M态跑。表面上看好像没什么问题但一旦接触到S态相关的CSR比如satp、stvec就疯狂触发非法指令异常。4.2 陷阱委托为什么要把中断和异常交给S态操作系统的日常运行主要发生在S态如果所有中断异常都还在M态处理内核的工作就会被割裂而且每次S态程序想要响应中断都要先陷入M态再绕回来效率极低。xv6在start()里执行w_medeleg(0xffff); // 将异常代理给S态 w_mideleg(0xffff); // 将中断代理给S态medeleg和mideleg是机器态的委托寄存器每一位对应一种异常或中断类型写1表示允许M态把该类陷阱交给S态处理。这里直接写0xffff相当于把常见的异常和中断全部委托下去。这个设计说明了一个关键点操作系统内核并不打算长期待在M态“一手遮天”而是把绝大部分工作下沉到S态。只有M态特有的东西比如重新配置PMP、处理M态定时器才留在M态处理。对照着看kernel/trap.c里的S态异常处理逻辑会更容易理解为什么usertrap、kerneltrap都建立在S态陷阱已委托的基础上。4.3 timerinit的提前量时钟中断是操作系统进行时间片调度的基础而xv6的时钟来自硬件平台上的CLINT定时器运行在机器态。xv6在start.c里调用timerinit()来设置时钟中断w_stimecmp(CLINT_MTIME 1000000); w_mie(r_mie() | MIE_MTIE);它做的事情是设置S态的比较寄存器stimecmp这个值相当于“未来某时刻”然后使能M态定时器中断。执行完mret进S态后S态代码可以开启sie里对应的定时器中断位从而在将来触发时钟中断。这里有一个容易忽略的细节定时器中断虽然在M态的mie里使能了但真正进入S态处理还需要S态那边sie的配合以及mideleg已经把S态定时器中断委托下来。三层开关任何一层没开时钟都跑不起来。后文做调度实验时如果发现yield之后进程永远切不回来第一反应就应该去查这三层开关的状态。4.4 物理内存保护与页表开关的取舍S态代码要能访问内存必须通过PMP物理内存保护的检查。xv6在start.c里设置w_pmpaddr0(0x3fffffffffffffull); w_pmpcfg0(0xf);把整个物理地址空间开放给S态和U态访问。这一步在实验里很少被改到但它的存在很关键如果PMP配置不对哪怕你在S态把页表配好了每次访问内存还是会被硬件拦截。至于页表xv6早期并没有马上开启而是把satp设为0维持直映射直到main里的kvminithart才把内核页表正式写进satp。启动阶段不开页表的好处是简单减少一重翻译降低了早期调试的复杂度。代价是物理内存布局需要提前设计好这也是为什么内核镜像要放在固定的0x80000000。从M态到S态这一段表面上只是几条CSR操作实际上是把“硬件裸机”状态交给“内核软件”状态的一次交接。交接不干净后面到处都是雷。5. main.c初始化顺序就是一张依赖图5.1 第一个能看到的输出为什么那么晚你在实验终端上第一次看到文字通常是“xv6 kernel is booting”这句话出自main()里的一段代码。但它出现之前已经跑了无数条指令。printf能工作依赖两样东西一是控制台硬件UART已经初始化二是printf内部的锁和缓冲区已经就绪。所以main里顺序很重要consoleinit(); // 初始化UART printfinit(); // 初始化printf的锁和缓冲区 printf(xv6 kernel is booting\n);如果把printf挪到consoleinit之前你会看到字符丢失、乱码甚至完全没有输出但程序不一定会崩溃。这类问题最难排查因为它不报错只是“看不到现象”。调试这类初始化顺序问题的小技巧是不要在consoleinit之前追求任何输出而是用gdb在consoleinit返回后设断点确认执行流已经到达这里。要么检查UART寄存器要么看函数返回地址都比盲目加打印有效。5.2 内存子系统kinit、kvminit、kvminithartmain里内存相关的初始化有三步kinit、kvminit、kvminithart它们的顺序不可调换。kinit负责初始化物理内存分配器建立空闲页链表。没有它后续所有申请页面的操作都会失败。kvminit负责创建内核页表这是一份只存在于内存里的页表结构还没有生效。kvminithart才是把页表地址写进satp、开启分页的步骤。我特意提这个顺序是因为有同学在改进内核时想在kinit之前调用kalloc拿一个页面。这在逻辑上好像没问题实际上kinit都没跑空闲链表是空的kalloc要么返回0要么直接触发断言。做内核实验改代码前先画依赖图比对着源码硬抄重要得多。另外注意kvminithart之后指令执行流本身还是在虚拟地址空间里运行的。由于xv6内核页表把内核的虚拟地址段直接映射到等效物理地址段所以代码不会感觉到变化。这也是所谓“恒等映射”的直观体现。5.3 中断子系统trap与plic的两两配对trapinit和trapinithart是一对plicinit和plicinithart是一对。为什么要分两次trapinit是每个CPU都需要的全局数据结构初始化比如设置该CPU的stvectrapinithart则是把stvec写进当前CPU的CSR。同理plicinit在CPU0上完成平台级中断控制器的全局配置而plicinithart让每个CPU打开自己感兴趣的外部中断源。xv6的写法是把“全局配置”和“当前核生效”分开这样多核之间不会互相干扰。如果在实验里改了中断路由相关的代码记得同时检查这两步只配了一半往往表现为CPU0正常工作CPU1的外部中断永远不来。5.4 文件与磁盘藏在后面的启动依赖binit初始化块缓存、iinit初始化inode表、fileinit初始化文件描述符表、virtio_disk_init初始化磁盘驱动。这几个初始化在userinit之前完成因为第一个用户进程虽然只执行一段exec(/init)但exec要读文件系统文件系统要访问磁盘设备磁盘设备要依赖中断和DMA相关的初始化。如果只看启动过程的前半段很容易觉得文件系统初始化无关紧要。但userinit创建的进程一旦调用exec整个文件系统栈就会运转起来。所以main.c里这些初始化的排列实际上是按“从硬到软”的依赖顺序排的先物理设备再数据缓存再逻辑结构。5.5 非0号CPU为什么直接进schedulermain函数的开头有if (cpunum() 0)0号CPU做全套初始化其他CPU直接跳过走到最后的scheduler()。这不代表其他CPU什么也不用准备——它们在各自的start()阶段已经设置好M态相关配置并且在main之前都已经执行完mret进入S态只是不再重复做全局初始化。多核实验中如果你想让每个核都跑一遍某个初始化不要直接加在main的if外面要考虑竞争条件。更稳妥的做法是放在scheduler循环之前或者用一次性的标志位保护。xv6的朴素写法在实验扩展里很容易被踩到尤其是“每个核都要安装一个本地中断处理函数”这类需求。6. userinit第一个进程是怎么变成“活的”6.1 initcode一段被当进程执行的汇编userinit创建的进程它的“用户程序”并不来自文件系统而是一段已经编译好、以字节数组形式嵌进内核的汇编代码叫initcode。xv6在user/initcode.S里定义了这段汇编initcode: la a0, init // 参数路径字符串 la a1, argv // 参数参数数组 li a7, SYS_exec // 系统调用号 ecall这段代码做的事极其简单调用exec系统调用执行/init程序。userinit把这短代码拷贝到第一个进程的页表里然后让这个进程到用户态去执行它。为什么要搞这么一层“中间人”因为内核虽然能直接加载并执行用户程序但“第一个进程”本身需要通过正常的进程机制诞生。用一小段汇编把exec调用发出去正好把进程创建、地址空间分配、系统调用、程序加载整条链路都激活了。直接在内核里调用exec反而绕过了进程调度和系统调用的正常路径。6.2 trapframe填充与第一根栈userinit里能看到这样的代码p-trapframe-epc 0; p-trapframe-sp PGSIZE;epc被设为0意味着进程从虚拟地址0开始执行那里正好加载了initcodesp被设为PGSIZE栈顶在用户地址空间的第一个页面之后。这个页面的内容就是initcode本身而它只有几行汇编剩下的页面空间基本是空的正好留给栈用。这个细节经常被忽略但它定了用户进程启动时的寄存器状态模板。后面用exec加载真实程序时epc和sp会被重新设置成ELF入口点和程序头指定的栈位置。理解userinit里这两个字段的初值有助于理解系统调用exec在填充trapframe时到底在做什么。6.3 scheduler第一次交出CPUuserinit执行完进程状态设为RUNNABLE然后main调用scheduler。scheduler是一个死循环不断扫描进程表找一个RUNNABLE的进程找到之后把它的context切换进来。这里有个容易出误会的地方xv6的进程切换并不是传统意义上的“线程切换”它通过swtch函数保存当前CPU的寄存器到context再加载目标进程的context。第一次切换时目标进程的context是被allocproc初始化过的其中的ra指向forkret函数sp指向该进程的内核栈。所以第一次切换并不是直接跳到用户态而是先跳到forkret再经过trapret里的sret才真正进入用户态执行initcode。这条路径是“内核态上下文 - 内核态trap返回 - 用户态程序”的标准三部曲。很多同学在这里跟丢是因为以为scheduler直接就把CPU交给了用户进程。6.4 从initcode到shinitcode通过ecall进入内核sys_exec被调起加载/init程序。/init是内核里的第二个用户程序它做的事情是打开控制台设备、创建第一个真正用来交互的进程——sh。到这一步xv6启动过程才算真正完成。从硬件复位的0x1000到_entry、start、main、userinit、scheduler再到initcode和exec整条链路兜了一大圈最终把CPU交给了一个普通用户态的shell。回头再看启动整个过程它其实就是“硬件约定 - 内核接管 - 进程系统诞生 - 用户程序接管”这样一条线。每个环节都依赖前一个环境的正确建立任何一环出问题启动链就会断在某处。7. 启动链上的调试经验断点、打点与常见翻车7.1 一条可以复用的断点清单分析启动过程时我通常会在gdb里预先设置一串断点按顺序验证启动进度0x80000000确认CPU到达内核入口。start函数入口确认汇编阶段后进入C语言。main函数入口确认M态到S态切换成功。consoleinit调用前/后确认最早期初始化完成。userinit入口确认所有内核子系统初始化完成。scheduler入口确认第一个进程已经进入调度队列。/init程序的入口地址确认exec加载成功。这一串断点下来启动链在哪一步断掉一目了然。比我见过很多同学“从头单步几千条指令”要高效得多。这里补一个注意事项断点设在C函数入口时gdb可能因为编译器优化出现行号偏移最好用break *函数名加上反汇编确认目标地址而不是只靠源码行号。7.2 “printf没输出”的三种可能“内核跑起来但没有任何输出”是启动实验里最高频的问题。总结下来一般有三种可能第一种是根本没跑到consoleinit。这种情况要通过断点确认执行流位置而不是盲目调UART配置。第二种是UART初始化了但波特率或寄存器配置和QEMU的控制台模拟不一致导致字符发不出去。检查consoleinit多和各平台文档对照注意QEMU的-nographic参数是否生效。第三种是printf内部锁没初始化字符在缓冲区里堵死了。printfinit被漏掉或者顺序错了都会有类似表现。区分这三种情况最直接的办法是分阶段设断点先在consoleinit返回后打断点看字符是不是已经写进UART的发送寄存器。如果是说明问题在驱动向外设传输环节如果不是说明执行流都没到驱动层。7.3 改启动代码最容易踩的坑启动代码是内核里最“命薄”的部分因为它依赖的上下文极少。改这里的代码至少有四个高频坑第一乱改mepc和mstatus.MPP导致mret跳错位置。第二在页表生效前访问了超出恒等映射范围的地址直接触发访存异常。第三多核环境下用全局变量做一次性初始化但没有加锁或者没有按cpunum区分出现竞态。第四动entry.S里的栈偏移计算导致多个CPU栈重叠启动过程时好时坏。我对启动代码的基本态度是不要为了“优化性能”去大幅度精简它。启动代码只跑一次性能收益几乎为零但出错代价极大。稳妥的做法是每动一处就跑一遍全文的断点清单确认启动链没有被破坏。7.4 最后分享一个调试小习惯分析xv6启动过程时一旦涉及到寄存器我习惯在gdb里用info registers和info registers mstatus这种命令直接看机器态寄存器而不是靠猜想。比如在mret之前查看mstatus.MPP、mepc的值确认它们分别指向S态和main的地址在sret之前查看sepc和sstatus.SPP确认返回用户态时的目标地址和特权级。启动过程里每一步都是一个“先看状态、再操作、再看状态”的流程只要养成这个习惯很多问题在发生之前就能被拦截。这个项目的后续实验比如加系统调用、改调度策略、扩展文件系统都可以继续沿用这套“分阶段断点加特权级验证”的思路。启动过程是地基地基扎实了上面盖多少层都不慌。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑