xv6 启动与内核入口 / xv6 Boot And Kernel Entry
1. 这篇文章解决什么问题
普通 C 程序通常从 main() 开始。
但内核不是普通 C 程序。
xv6 启动时,CPU 并不会自动帮你准备好完整的 C 运行环境。
内核需要自己处理早期入口、栈、特权级、页表、中断和第一个进程。
所以 xv6 的启动不能只看 main.c。
必须沿着这条路径看:
QEMU RISC-V machine
|
v
kernel/entry.S
|
v
kernel/start.c
|
v
kernel/main.c
|
v
kernel/proc.c:userinit()
|
v
kernel/proc.c:scheduler()
|
v
user/init.c这篇文章的目标是让你理解:
- xv6 为什么不是直接从普通
main()开始。 entry.S为什么要先设置栈。start.c为什么处理特权级和中断委托。main.c为什么按固定顺序初始化子系统。- 第一个用户进程是怎么创建的。
- 调度器如何成为后续进程运行的入口。
- 启动阶段涉及哪些内核文件。
这篇不会把每条汇编指令都拆到极限。
重点是建立启动主线。
后续读 proc、trap、页表和调度时,都要回到这条主线。
1.1 本文基础词小注释
启动篇里会出现一些容易被默认理解的词。
先补一下。
启动
|
+-- boot
+-- 从机器开始执行到操作系统准备好运行用户程序的过程
入口
|
+-- entry
+-- CPU 最早开始执行的一段内核代码位置
栈
|
+-- stack
+-- 函数调用和局部变量常用的一片内存
+-- 内核早期必须先准备栈,C 函数才好运行
hart
|
+-- RISC-V 的 hardware thread
+-- 可以先理解成一个能独立执行指令的 CPU 执行单元
初始化
|
+-- init
+-- 把某个子系统从“还不能用”准备到“可以用”2. 先建立最简单的启动模型
内核启动可以先理解成三个阶段。
Stage 1: early assembly entry
|
+-- CPU starts executing kernel entry code
+-- stack must be prepared
|
v
Stage 2: low-level machine setup
|
+-- configure privilege transition
+-- prepare supervisor mode
|
v
Stage 3: kernel subsystem initialization
|
+-- memory
+-- page table
+-- process table
+-- trap
+-- devices
+-- filesystem
+-- first user process对应 xv6 文件:
entry.S
|
v
start.c
|
v
main.c再往后:
main.c
|
v
userinit()
|
v
scheduler()
|
v
init process一句话总结:
entry.S 让 C 代码能开始跑。
start.c 让内核进入合适的 RISC-V 特权环境。
main.c 初始化内核世界。
userinit() 创造第一个用户进程。
scheduler() 开始选择进程运行。
3. 本篇涉及的源码文件
本篇主要涉及这些文件:
kernel/entry.S
|
+-- earliest assembly entry
+-- sets stack for each hart
+-- jumps to start
kernel/start.c
|
+-- machine-mode setup
+-- prepares supervisor-mode execution
+-- configures trap delegation and timer behavior
kernel/main.c
|
+-- subsystem initialization sequence
+-- first hart performs global initialization
+-- other harts perform per-hart initialization
+-- calls userinit
+-- enters scheduler
kernel/proc.c
|
+-- procinit
+-- userinit
+-- scheduler
+-- allocproc
kernel/proc.h
|
+-- struct cpu
+-- struct proc
+-- struct context
+-- struct trapframe
kernel/memlayout.h
|
+-- kernel memory layout constants
+-- trampoline/trapframe addresses
+-- device addresses
kernel/riscv.h
|
+-- CSR helpers
+-- interrupt helpers
+-- page table helpers
kernel/vm.c
|
+-- kernel page table initialization
+-- per-hart page table activation
kernel/kalloc.c
|
+-- physical page allocator initialization
kernel/trap.c
|
+-- trap initialization
+-- per-hart trap setup
user/init.c
|
+-- first user process program
+-- opens console
+-- starts shell不要一次把所有文件展开。
先跟主线走。
每个文件只先理解它在启动链条中的职责。
4. QEMU 在这里扮演什么角色
xv6-riscv 通常运行在 QEMU 模拟的 RISC-V virt 机器上。
QEMU 可以理解成一台虚拟硬件机器。
它提供:
- RISC-V CPU。
- 内存。
- UART。
- VirtIO 磁盘。
- PLIC 中断控制器。
- 启动加载环境。
关系图:
host operating system
|
v
QEMU process
|
+-- emulated RISC-V CPU
+-- emulated RAM
+-- emulated UART
+-- emulated VirtIO disk
+-- emulated interrupt controller
|
v
xv6 kernel runs inside virtual machine这意味着:
你在 Windows 或 Linux 主机上执行 make qemu。
实际运行 xv6 的不是主机 CPU 直接进入 xv6 内核。
而是 QEMU 创建一个虚拟 RISC-V 机器,然后 xv6 在里面启动。
这有助于理解为什么 xv6 里有很多固定设备地址。
这些地址对应 QEMU virt 机器模拟出的硬件布局。
memlayout.h 就记录了这些地址。
5. hart:RISC-V 的硬件线程
读 xv6 启动代码会看到 hart。
hart 是 hardware thread 的缩写。
可以先理解成一个能独立执行指令的 CPU 执行单元。
多核启动时,每个 hart 都会进入内核。
但 xv6 会让其中一个 hart 做全局初始化。
其他 hart 等待初始化完成,再做各自的本地初始化。
hart 0
|
+-- global init
+-- create first process
+-- scheduler
hart 1
|
+-- wait
+-- per-hart init
+-- scheduler
hart 2
|
+-- wait
+-- per-hart init
+-- scheduler为什么要区分全局初始化和 per-hart 初始化?
有些资源整个系统只需要初始化一次。
例如物理内存分配器、进程表、文件表。
有些状态每个 CPU/hart 都要设置。
例如当前 hart 的 trap vector、页表寄存器、中断控制。
global state
|
+-- initialized once
+-- shared by all harts
per-hart state
|
+-- initialized on each hart
+-- belongs to one execution unit这个概念对后面理解 struct cpu 很重要。
6. entry.S:为什么先要有栈
entry.S 是早期汇编入口。
它最重要的任务之一是设置栈。
为什么栈这么重要?
C 函数调用需要栈。
局部变量、返回地址、调用帧都依赖栈。
如果没有设置 sp,直接进入复杂 C 代码是不可靠的。
CPU starts executing
|
v
no trusted C stack yet
|
v
entry.S sets sp
|
v
C functions can run每个 hart 需要自己的栈。
如果多个 hart 共用同一个栈,会互相覆盖。
hart 0 stack
+------------------+
| |
+------------------+
hart 1 stack
+------------------+
| |
+------------------+
hart 2 stack
+------------------+
| |
+------------------+entry.S 的核心逻辑可以抽象成:
read current hart id
|
v
choose stack for this hart
|
v
set sp register
|
v
jump/call start()这里的 sp 是 RISC-V 栈指针寄存器。
设置 sp 不是设置一个 C 变量。
而是在告诉 CPU:后续函数调用使用这片内存作为栈。
7. start.c:从 machine mode 准备进入 supervisor mode
RISC-V 启动时会涉及 machine mode。
xv6 内核主要运行在 supervisor mode。
start.c 负责做低层特权级设置。
简化模型:
machine mode
|
| configure delegation and status
v
supervisor mode
|
v
main()为什么不一直运行在 machine mode?
因为 xv6 是一个教学操作系统内核。
它主要展示 supervisor mode 下的操作系统机制。
machine mode 更像更底层的固件或监控层。
start.c 可能会处理:
- 设置
mstatus。 - 设置
mepc。 - 配置异常和中断委托。
- 配置 timer 相关机制。
- 设置每个 hart 的 scratch 区域。
- 最后执行返回到 supervisor mode 的路径。
你不必第一次读懂每个 CSR 位。
先知道:
start.c 是把 CPU 从早期机器模式带到 xv6 内核主要运行环境的过渡层。
图:
entry.S
|
v
start()
|
+-- prepare privilege configuration
+-- prepare interrupt delegation
+-- prepare timer
+-- set target to main
|
v
main()如果说 entry.S 解决“C 怎么开始跑”,那 start.c 解决“内核在哪个特权环境跑”。
8. main.c:内核初始化总导演
main.c 是启动阅读的核心。
它像一个初始化剧本。
每一行调用都对应一个子系统。
简化顺序:
main()
|
+-- consoleinit()
+-- printfinit()
+-- kinit()
+-- kvminit()
+-- kvminithart()
+-- procinit()
+-- trapinit()
+-- trapinithart()
+-- plicinit()
+-- plicinithart()
+-- binit()
+-- iinit()
+-- fileinit()
+-- virtio_disk_init()
+-- userinit()
+-- scheduler()这个顺序很有意义。
不是随便排列。
你可以按依赖关系理解。
basic output
|
v
memory allocator
|
v
page table
|
v
process table
|
v
trap and interrupts
|
v
device and filesystem
|
v
first user process
|
v
scheduler为什么先初始化 console 和 printf?
因为后续初始化如果出错,需要打印信息。
为什么先 kinit() 再很多其他模块?
因为很多模块需要分配物理页。
为什么要 kvminit() 和 kvminithart()?
因为内核要建立并启用自己的页表。
为什么要 procinit()?
因为后面要创建进程。
为什么要 trapinit() 和 trapinithart()?
因为系统调用、中断、异常都需要 trap 机制。
为什么最后 userinit()?
因为第一个用户进程依赖前面很多基础设施。
为什么进入 scheduler()?
因为创建了可运行进程后,CPU 需要调度器选择它运行。
9. 第一颗 hart 和其他 hart 的区别
xv6 多核启动时,不是所有 hart 都做完整初始化。
通常一个主 hart 做全局初始化。
其他 hart 等待。
初始化完成后,其他 hart 做本地初始化并进入调度器。
抽象图:
hart 0
|
+-- consoleinit
+-- kinit
+-- kvminit
+-- procinit
+-- trapinit
+-- plicinit
+-- fs/device init
+-- userinit
+-- signal started
+-- scheduler
hart 1..N
|
+-- wait until started
+-- kvminithart
+-- trapinithart
+-- plicinithart
+-- scheduler这里出现了两类函数。
全局初始化函数:
consoleinit
printfinit
kinit
kvminit
procinit
trapinit
plicinit
binit
iinit
fileinit
virtio_disk_init
userinit每个 hart 都要执行的函数:
kvminithart
trapinithart
plicinithart
scheduler这能帮助你理解为什么有些函数名带 hart。
它们不是初始化全局结构,而是初始化当前 hart 的 CPU 状态。
10. console 和 printf:为什么先初始化输出
内核启动很早就需要输出。
如果没有输出,调试会非常痛苦。
consoleinit() 和 printfinit() 让内核可以打印信息。
consoleinit
|
+-- initialize console device interface
printfinit
|
+-- initialize printf lock or related state打印路径大致是:
printf
|
v
console
|
v
uart
|
v
QEMU terminal这就是为什么你能在宿主机终端看到 xv6 的输出。
对学习来说,打印是最简单调试方式。
但要注意:
启动阶段打印还算安全。
调度、中断、锁路径打印太多会造成干扰。
11. kinit():物理页分配器启动
kinit() 初始化物理页分配器。
xv6 的物理内存按页管理。
一页通常是 4096 字节。
kalloc() 分配一页。
kfree() 释放一页。
启动阶段必须先让物理页分配器工作。
因为后面会需要分配页表页、进程内存、trapframe 等资源。
physical memory
|
v
kinit builds free list
|
v
kalloc can return pages
|
v
vm/proc/fs can allocate memory物理页分配器可以想象成一条空闲页链表。
free list
|
v
+--------+ +--------+ +--------+
| page A | -> | page B | -> | page C |
+--------+ +--------+ +--------+读 kalloc.c 时,要关注:
- 空闲页从哪里来。
- 每页大小是多少。
- free list 用什么结构串起来。
- 哪个锁保护 free list。
这里也会用到 C 指针。
空闲页本身被临时当作链表节点使用。
这是一种内核常见技巧。
12. kvminit() 和 kvminithart():建立并启用内核页表
kvminit() 建立内核页表。
kvminithart() 让当前 hart 使用这张页表。
两者区别很重要。
kvminit()
|
+-- build kernel page table data structure
kvminithart()
|
+-- write satp
+-- flush TLB
+-- current hart starts using page table建立页表和启用页表不是一回事。
建立页表是在内存中填写页表结构。
启用页表是写 CPU 的 satp 寄存器。
memory contains page table
|
v
satp points to page table
|
v
CPU address translation uses it内核页表映射:
kernel virtual address
|
v
kernel page table
|
+-- kernel text
+-- kernel data
+-- physical memory
+-- UART
+-- VIRTIO
+-- PLIC
+-- trampoline为什么设备也在页表里?
因为设备寄存器通过内存映射 I/O 访问。
CPU 访问某些地址,实际上是在访问设备。
这些地址定义在 memlayout.h。
13. procinit():准备进程表和内核栈
进程系统需要初始化。
procinit() 会准备进程表。
每个进程槽位都需要锁。
每个进程还需要内核栈。
proc[NPROC]
|
+-- proc[0] lock + kstack
+-- proc[1] lock + kstack
+-- proc[2] lock + kstack
+-- ...内核栈不是用户栈。
当进程进入内核时,内核要用可信的内核栈运行。
每个进程有自己的内核栈。
process p
|
+-- user stack in user address space
|
+-- kernel stack in kernel address space为什么启动阶段就要准备这些?
因为后面 userinit() 要创建第一个进程。
没有进程表和内核栈,就无法创建进程。
读 procinit() 时关注:
- 遍历
proc[]。 - 初始化每个
p->lock。 - 设置或映射每个进程的内核栈。
p->state初始是什么。
这会为后面的 allocproc() 打基础。
14. trap 初始化:让 CPU 知道异常去哪
trap 包括系统调用、中断和异常。
内核必须告诉 CPU:发生 trap 时跳到哪里。
这涉及 stvec 等 CSR。
启动阶段相关函数:
trapinit()
|
+-- initialize global trap-related lock/state
trapinithart()
|
+-- set trap vector for current hart抽象图:
CPU executes code
|
| trap occurs
v
CPU reads trap vector
|
v
jump to kernel trap entry如果没有正确设置 trap,系统调用和中断都无法正常进入内核。
系统调用路径依赖 trap。
定时器中断依赖 trap。
设备中断也依赖 trap。
所以 trap 初始化必须在用户进程真正运行前完成。
15. PLIC 和设备初始化
PLIC 是 Platform-Level Interrupt Controller。
可以理解为平台级中断控制器。
外部设备中断需要经过它。
device
|
v
PLIC
|
v
hart
|
v
kernel trap handler启动阶段会初始化:
plicinit()
|
+-- global interrupt controller setup
plicinithart()
|
+-- per-hart interrupt controller setup设备相关初始化还包括:
virtio_disk_init()
|
+-- initialize virtual disk driverconsole 和 uart 也在输出输入路径中。
这些设备后面会服务用户程序。
例如 shell 输入输出、文件系统读写磁盘。
16. buffer cache、inode 和 file 表初始化
文件系统相关初始化包括:
binit()
|
+-- initialize buffer cache
iinit()
|
+-- initialize inode table/cache
fileinit()
|
+-- initialize global file table关系图:
file table
|
v
inode layer
|
v
buffer cache
|
v
disk driver为什么启动时就要初始化这些?
因为第一个用户进程 init 很快会打开 console,并启动 shell。
shell 后续要执行文件系统中的用户程序。
没有文件系统相关结构,用户态世界无法正常展开。
不要把这些初始化看成孤立动作。
它们是在为用户空间准备基本服务。
user shell
|
+-- needs console
+-- needs file descriptors
+-- needs filesystem
+-- needs exec17. userinit():创造第一个用户进程
userinit() 是启动路径中的关键点。
它创建第一个用户进程。
这个进程后续会运行用户态 init 程序。
关系图:
main()
|
v
userinit()
|
+-- allocproc()
+-- create initial user address space
+-- set up trapframe
+-- set process name
+-- set cwd
+-- mark RUNNABLE
|
v
scheduler()这里第一次真正用到 struct proc。
allocproc() 从 proc[NPROC] 中找空槽位。
然后设置该进程的内核资源。
简化生命周期:
UNUSED proc slot
|
v
allocproc
|
v
USED
|
v
userinit fills user state
|
v
RUNNABLERUNNABLE 表示这个进程已经可以被调度器选择运行。
注意:
userinit() 创建的是进程对象。
它不是直接让用户代码马上运行。
真正让它运行的是后面的调度器。
18. 第一个用户程序从哪里来
xv6 的第一个用户程序不是从普通文件系统路径直接 exec 出来的。
它使用一段内嵌的初始用户代码。
这段代码会启动 /init。
然后 user/init.c 里的程序会运行。
简化关系:
kernel userinit
|
v
initial user code
|
v
exec("/init")
|
v
user/init.c
|
v
open console
|
v
fork shell
|
v
user/sh.c这里有一个非常重要的观念:
内核创建第一个用户进程后,用户空间才真正开始。
init 是用户态程序。
它不是内核函数。
但它是由内核创建的第一个用户进程路径带起来的。
init 后续会启动 shell。
shell 再执行其他用户程序。
kernel
|
v
first user process
|
v
init
|
v
shell
|
v
commands19. scheduler():启动的终点,也是运行的起点
main() 最后进入 scheduler()。
这意味着 CPU 进入调度循环。
调度器会寻找 RUNNABLE 进程。
找到后切换到该进程。
scheduler loop
|
v
scan proc table
|
v
find RUNNABLE process
|
v
mark RUNNING
|
v
swtch to process context
|
v
process runs
|
v
eventually yields/sleeps/exits
|
v
back to scheduler这里第一次出现真正的“操作系统运行状态”。
启动阶段是准备世界。
调度器开始后,世界运转起来。
boot initialization
|
v
process created
|
v
scheduler starts
|
v
normal OS execution以后系统调用、中断、调度、文件读写都发生在这个运转中的世界里。
20. 启动路径中的关键结构体
启动阶段涉及几个关键结构体。
第一,struct cpu。
它描述一个 hart/CPU 的内核调度状态。
struct cpu
|
+-- current process pointer
+-- scheduler context
+-- interrupt nesting state第二,struct proc。
它描述进程。
struct proc
|
+-- state
+-- pid
+-- pagetable
+-- trapframe
+-- context
+-- kstack
+-- ofile[]
+-- cwd第三,struct context。
它保存调度切换需要的内核上下文。
struct context
|
+-- ra
+-- sp
+-- s0-s11第四,struct trapframe。
它保存用户态寄存器和返回信息。
struct trapframe
|
+-- user registers
+-- kernel_satp
+-- kernel_sp
+-- kernel_trap
+-- epc启动阶段的对象关系:
hart
|
v
struct cpu
|
v
scheduler context
first process
|
v
struct proc
|
+-- trapframe
+-- pagetable
+-- kernel stack
+-- context这张图会在 proc 文章中反复出现。
21. 启动顺序为什么不能随便换
内核初始化顺序有依赖关系。
比如:
没有物理页分配器,就很难创建页表。
没有页表,虚拟内存机制不能正常启用。
没有进程表,就不能创建第一个进程。
没有 trap,用户程序不能安全系统调用。
没有文件系统和设备,init 和 shell 很难工作。
依赖图:
console/printf
|
v
kalloc
|
v
kernel page table
|
v
proc table
|
v
trap and interrupt
|
v
filesystem and devices
|
v
first user process
|
v
scheduler如果乱改顺序,可能会出现:
- 打印不可用。
- 页分配失败。
- 页表未启用。
- trap 入口未设置。
- 第一个进程无法创建。
- shell 无法读写 console。
- 文件系统无法访问磁盘。
所以读 main.c 要带着依赖关系看。
它不是平铺清单。
它是内核世界的搭建顺序。
22. 启动阶段和后续章节的关系
启动不是孤立章节。
它引出后续所有机制。
entry.S/start.c
|
+-- RISC-V and privilege modes
kinit
|
+-- physical memory allocator
kvminit/kvminithart
|
+-- virtual memory and page tables
procinit/userinit
|
+-- process structure and lifecycle
trapinit/trapinithart
|
+-- trap, syscall, interrupt
binit/iinit/fileinit/virtio_disk_init
|
+-- filesystem and devices
scheduler
|
+-- scheduling and context switch所以这篇文章是后续文章的目录式前导。
每个初始化函数都对应一个深挖专题。
不要期待在启动篇一次学完所有机制。
启动篇要达到的目标是:
看到整个内核被搭起来。
知道后续每个机制从哪里接入。
23. 初学者常见误解
误解一:xv6 从 main() 直接开始。
不准确。
main() 是 C 层的重要入口。
但在它之前还有 entry.S 和 start.c。
误解二:栈是 C 编译器自动准备的。
对普通用户程序可以这么粗略理解。
但内核早期启动时,需要自己设置栈。
误解三:所有 hart 都做同样初始化。
不是。
一个 hart 做全局初始化。
其他 hart 等待后做 per-hart 初始化。
误解四:userinit() 直接运行用户程序。
不准确。
它创建并设置第一个用户进程,使其变成 RUNNABLE。
真正运行要靠 scheduler。
误解五:init 是内核的一部分。
不是。
init 是用户态程序。
它是第一个重要用户进程。
误解六:初始化顺序只是代码风格。
不是。
初始化顺序体现子系统依赖。
误解七:QEMU 只是命令行工具。
不只是。
在这里 QEMU 扮演虚拟硬件机器。
24. 小实验:打印启动顺序
实验目标:
观察 main() 初始化顺序。
可以在每个主要初始化函数前后加短打印。
示意输出:
boot: consoleinit
boot: printfinit
boot: kinit
boot: kvminit
boot: kvminithart
boot: procinit
boot: trapinit
boot: trapinithart
boot: plicinit
boot: plicinithart
boot: binit
boot: iinit
boot: fileinit
boot: virtio_disk_init
boot: userinit
boot: scheduler注意:
多 hart 输出可能交错。
可以只在主 hart 打印全局初始化。
或者打印 hart id。
boot: hart=0 kinit
boot: hart=1 kvminithart实验观察问题:
- 哪些初始化只出现一次?
- 哪些初始化每个 hart 都出现?
userinit在哪些初始化之后?scheduler是否每个 hart 都进入?
这个实验很适合建立启动直觉。
25. 小实验:观察第一个进程
实验目标:
观察 userinit() 创建第一个进程。
可以在 userinit() 中打印:
userinit: pid=1 state=RUNNABLE name=initcode也可以在 allocproc() 中打印:
allocproc: p=... pid=1 state=USED观察路径:
main
|
v
userinit
|
v
allocproc
|
v
first process RUNNABLE
|
v
scheduler你要验证:
userinit() 创建的是第一个 struct proc。
它不是直接执行 shell。
shell 是后面用户态 init 启动的。
kernel creates first process
|
v
first user code execs /init
|
v
init opens console and forks shell这能帮助你分清内核启动和用户空间启动。
26. 本篇总结
xv6 启动可以压缩成一条主线:
QEMU
|
v
entry.S
|
v
start.c
|
v
main.c
|
v
subsystem initialization
|
v
userinit
|
v
scheduler
|
v
first user process每个节点解决一个问题。
entry.S 解决早期栈和汇编入口。
start.c 解决 RISC-V 特权级过渡。
main.c 解决内核子系统初始化。
userinit() 解决第一个进程从哪里来。
scheduler() 解决 CPU 如何开始运行进程。
理解启动后,再读 proc 会容易很多。
因为你已经知道第一个 struct proc 是什么时候出现的。
也知道调度器什么时候开始接管。
下一篇应该进入:
06-memory-layout-and-page-table.md因为启动之后,最核心的基础设施之一就是内存布局和页表。
页表理解清楚后,再回头看 trapframe、copyin/copyout、fork 和 exec,会顺得多。