Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← 系统与高性能 / Systems & Performance

xv6 源码学习 / xv6 Source Learning

1. xv6 源码学习专题 / xv6 Source Learning

2. xv6 学习路线:从 C 指针到操作系统源码 / xv6 Learning Roadmap

3. 读 xv6 必备的 C 语言和指针基础 / C And Pointers For xv6

4. 读 xv6 必备的 RISC-V 基础 / RISC-V Basics For xv6

5. 如何阅读 xv6 源码 / How To Read xv6 Source

6. xv6 启动与内核入口 / xv6 Boot And Kernel Entry

7. xv6 内存布局与页表 / xv6 Memory Layout And Page Tables

8. xv6 trap、中断与系统调用 / xv6 Traps Interrupts And System Calls

9. xv6 进程与 struct proc / xv6 Process And struct proc

10. xv6 上下文切换与调度器 / xv6 Context Switch And Scheduler

11. xv6 sleep/wakeup、wait 与 exit / xv6 Sleep Wakeup Wait And Exit

12. xv6 锁与并发 / xv6 Locks And Concurrency

13. xv6 文件描述符与 inode / xv6 File Descriptor And Inode

14. xv6 文件系统与日志 / xv6 File System And Log

15. xv6 设备驱动、console、UART 与磁盘 / xv6 Device Driver Console UART And Disk

16. xv6 用户程序与 shell / xv6 User Programs And Shell

17. 用 QEMU/GDB 调试 xv6 / Debugging xv6 With QEMU And GDB

18. 从 xv6 过渡到 Linux 0.11 / Bridge From xv6 To Linux 0.11

19. xv6 专题总结与实践项目 / xv6 Summary And Practice Projects

本页目录

xv6 启动与内核入口 / xv6 Boot And Kernel Entry ​

1. 这篇文章解决什么问题 ​

普通 C 程序通常从 main() 开始。

但内核不是普通 C 程序。

xv6 启动时,CPU 并不会自动帮你准备好完整的 C 运行环境。

内核需要自己处理早期入口、栈、特权级、页表、中断和第一个进程。

所以 xv6 的启动不能只看 main.c。

必须沿着这条路径看:

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这篇文章的目标是让你理解:

  • xv6 为什么不是直接从普通 main() 开始。
  • entry.S 为什么要先设置栈。
  • start.c 为什么处理特权级和中断委托。
  • main.c 为什么按固定顺序初始化子系统。
  • 第一个用户进程是怎么创建的。
  • 调度器如何成为后续进程运行的入口。
  • 启动阶段涉及哪些内核文件。

这篇不会把每条汇编指令都拆到极限。

重点是建立启动主线。

后续读 proc、trap、页表和调度时,都要回到这条主线。

1.1 本文基础词小注释 ​

启动篇里会出现一些容易被默认理解的词。

先补一下。

text
启动
  |
  +-- boot
  +-- 从机器开始执行到操作系统准备好运行用户程序的过程

入口
  |
  +-- entry
  +-- CPU 最早开始执行的一段内核代码位置

栈
  |
  +-- stack
  +-- 函数调用和局部变量常用的一片内存
  +-- 内核早期必须先准备栈,C 函数才好运行

hart
  |
  +-- RISC-V 的 hardware thread
  +-- 可以先理解成一个能独立执行指令的 CPU 执行单元

初始化
  |
  +-- init
  +-- 把某个子系统从“还不能用”准备到“可以用”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

2. 先建立最简单的启动模型 ​

内核启动可以先理解成三个阶段。

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

对应 xv6 文件:

text
entry.S
  |
  v
start.c
  |
  v
main.c
1
2
3
4
5
6
7

再往后:

text
main.c
  |
  v
userinit()
  |
  v
scheduler()
  |
  v
init process
1
2
3
4
5
6
7
8
9
10

一句话总结:

entry.S 让 C 代码能开始跑。

start.c 让内核进入合适的 RISC-V 特权环境。

main.c 初始化内核世界。

userinit() 创造第一个用户进程。

scheduler() 开始选择进程运行。

3. 本篇涉及的源码文件 ​

本篇主要涉及这些文件:

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

不要一次把所有文件展开。

先跟主线走。

每个文件只先理解它在启动链条中的职责。

4. QEMU 在这里扮演什么角色 ​

xv6-riscv 通常运行在 QEMU 模拟的 RISC-V virt 机器上。

QEMU 可以理解成一台虚拟硬件机器。

它提供:

  • RISC-V CPU。
  • 内存。
  • UART。
  • VirtIO 磁盘。
  • PLIC 中断控制器。
  • 启动加载环境。

关系图:

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13

这意味着:

你在 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 等待初始化完成,再做各自的本地初始化。

text
hart 0
  |
  +-- global init
  +-- create first process
  +-- scheduler

hart 1
  |
  +-- wait
  +-- per-hart init
  +-- scheduler

hart 2
  |
  +-- wait
  +-- per-hart init
  +-- scheduler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

为什么要区分全局初始化和 per-hart 初始化?

有些资源整个系统只需要初始化一次。

例如物理内存分配器、进程表、文件表。

有些状态每个 CPU/hart 都要设置。

例如当前 hart 的 trap vector、页表寄存器、中断控制。

text
global state
  |
  +-- initialized once
  +-- shared by all harts

per-hart state
  |
  +-- initialized on each hart
  +-- belongs to one execution unit
1
2
3
4
5
6
7
8
9

这个概念对后面理解 struct cpu 很重要。

6. entry.S:为什么先要有栈 ​

entry.S 是早期汇编入口。

它最重要的任务之一是设置栈。

为什么栈这么重要?

C 函数调用需要栈。

局部变量、返回地址、调用帧都依赖栈。

如果没有设置 sp,直接进入复杂 C 代码是不可靠的。

text
CPU starts executing
  |
  v
no trusted C stack yet
  |
  v
entry.S sets sp
  |
  v
C functions can run
1
2
3
4
5
6
7
8
9
10

每个 hart 需要自己的栈。

如果多个 hart 共用同一个栈,会互相覆盖。

text
hart 0 stack
+------------------+
|                  |
+------------------+

hart 1 stack
+------------------+
|                  |
+------------------+

hart 2 stack
+------------------+
|                  |
+------------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14

entry.S 的核心逻辑可以抽象成:

text
read current hart id
  |
  v
choose stack for this hart
  |
  v
set sp register
  |
  v
jump/call start()
1
2
3
4
5
6
7
8
9
10

这里的 sp 是 RISC-V 栈指针寄存器。

设置 sp 不是设置一个 C 变量。

而是在告诉 CPU:后续函数调用使用这片内存作为栈。

7. start.c:从 machine mode 准备进入 supervisor mode ​

RISC-V 启动时会涉及 machine mode。

xv6 内核主要运行在 supervisor mode。

start.c 负责做低层特权级设置。

简化模型:

text
machine mode
  |
  | configure delegation and status
  v
supervisor mode
  |
  v
main()
1
2
3
4
5
6
7
8

为什么不一直运行在 machine mode?

因为 xv6 是一个教学操作系统内核。

它主要展示 supervisor mode 下的操作系统机制。

machine mode 更像更底层的固件或监控层。

start.c 可能会处理:

  • 设置 mstatus。
  • 设置 mepc。
  • 配置异常和中断委托。
  • 配置 timer 相关机制。
  • 设置每个 hart 的 scratch 区域。
  • 最后执行返回到 supervisor mode 的路径。

你不必第一次读懂每个 CSR 位。

先知道:

start.c 是把 CPU 从早期机器模式带到 xv6 内核主要运行环境的过渡层。

图:

text
entry.S
  |
  v
start()
  |
  +-- prepare privilege configuration
  +-- prepare interrupt delegation
  +-- prepare timer
  +-- set target to main
  |
  v
main()
1
2
3
4
5
6
7
8
9
10
11
12

如果说 entry.S 解决“C 怎么开始跑”,那 start.c 解决“内核在哪个特权环境跑”。

8. main.c:内核初始化总导演 ​

main.c 是启动阅读的核心。

它像一个初始化剧本。

每一行调用都对应一个子系统。

简化顺序:

text
main()
  |
  +-- consoleinit()
  +-- printfinit()
  +-- kinit()
  +-- kvminit()
  +-- kvminithart()
  +-- procinit()
  +-- trapinit()
  +-- trapinithart()
  +-- plicinit()
  +-- plicinithart()
  +-- binit()
  +-- iinit()
  +-- fileinit()
  +-- virtio_disk_init()
  +-- userinit()
  +-- scheduler()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这个顺序很有意义。

不是随便排列。

你可以按依赖关系理解。

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

为什么先初始化 console 和 printf?

因为后续初始化如果出错,需要打印信息。

为什么先 kinit() 再很多其他模块?

因为很多模块需要分配物理页。

为什么要 kvminit() 和 kvminithart()?

因为内核要建立并启用自己的页表。

为什么要 procinit()?

因为后面要创建进程。

为什么要 trapinit() 和 trapinithart()?

因为系统调用、中断、异常都需要 trap 机制。

为什么最后 userinit()?

因为第一个用户进程依赖前面很多基础设施。

为什么进入 scheduler()?

因为创建了可运行进程后,CPU 需要调度器选择它运行。

9. 第一颗 hart 和其他 hart 的区别 ​

xv6 多核启动时,不是所有 hart 都做完整初始化。

通常一个主 hart 做全局初始化。

其他 hart 等待。

初始化完成后,其他 hart 做本地初始化并进入调度器。

抽象图:

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这里出现了两类函数。

全局初始化函数:

text
consoleinit
printfinit
kinit
kvminit
procinit
trapinit
plicinit
binit
iinit
fileinit
virtio_disk_init
userinit
1
2
3
4
5
6
7
8
9
10
11
12

每个 hart 都要执行的函数:

text
kvminithart
trapinithart
plicinithart
scheduler
1
2
3
4

这能帮助你理解为什么有些函数名带 hart。

它们不是初始化全局结构,而是初始化当前 hart 的 CPU 状态。

10. console 和 printf:为什么先初始化输出 ​

内核启动很早就需要输出。

如果没有输出,调试会非常痛苦。

consoleinit() 和 printfinit() 让内核可以打印信息。

text
consoleinit
  |
  +-- initialize console device interface

printfinit
  |
  +-- initialize printf lock or related state
1
2
3
4
5
6
7

打印路径大致是:

text
printf
  |
  v
console
  |
  v
uart
  |
  v
QEMU terminal
1
2
3
4
5
6
7
8
9
10

这就是为什么你能在宿主机终端看到 xv6 的输出。

对学习来说,打印是最简单调试方式。

但要注意:

启动阶段打印还算安全。

调度、中断、锁路径打印太多会造成干扰。

11. kinit():物理页分配器启动 ​

kinit() 初始化物理页分配器。

xv6 的物理内存按页管理。

一页通常是 4096 字节。

kalloc() 分配一页。

kfree() 释放一页。

启动阶段必须先让物理页分配器工作。

因为后面会需要分配页表页、进程内存、trapframe 等资源。

text
physical memory
  |
  v
kinit builds free list
  |
  v
kalloc can return pages
  |
  v
vm/proc/fs can allocate memory
1
2
3
4
5
6
7
8
9
10

物理页分配器可以想象成一条空闲页链表。

text
free list
  |
  v
+--------+    +--------+    +--------+
| page A | -> | page B | -> | page C |
+--------+    +--------+    +--------+
1
2
3
4
5
6

读 kalloc.c 时,要关注:

  • 空闲页从哪里来。
  • 每页大小是多少。
  • free list 用什么结构串起来。
  • 哪个锁保护 free list。

这里也会用到 C 指针。

空闲页本身被临时当作链表节点使用。

这是一种内核常见技巧。

12. kvminit() 和 kvminithart():建立并启用内核页表 ​

kvminit() 建立内核页表。

kvminithart() 让当前 hart 使用这张页表。

两者区别很重要。

text
kvminit()
  |
  +-- build kernel page table data structure

kvminithart()
  |
  +-- write satp
  +-- flush TLB
  +-- current hart starts using page table
1
2
3
4
5
6
7
8
9

建立页表和启用页表不是一回事。

建立页表是在内存中填写页表结构。

启用页表是写 CPU 的 satp 寄存器。

text
memory contains page table
  |
  v
satp points to page table
  |
  v
CPU address translation uses it
1
2
3
4
5
6
7

内核页表映射:

text
kernel virtual address
  |
  v
kernel page table
  |
  +-- kernel text
  +-- kernel data
  +-- physical memory
  +-- UART
  +-- VIRTIO
  +-- PLIC
  +-- trampoline
1
2
3
4
5
6
7
8
9
10
11
12

为什么设备也在页表里?

因为设备寄存器通过内存映射 I/O 访问。

CPU 访问某些地址,实际上是在访问设备。

这些地址定义在 memlayout.h。

13. procinit():准备进程表和内核栈 ​

进程系统需要初始化。

procinit() 会准备进程表。

每个进程槽位都需要锁。

每个进程还需要内核栈。

text
proc[NPROC]
  |
  +-- proc[0] lock + kstack
  +-- proc[1] lock + kstack
  +-- proc[2] lock + kstack
  +-- ...
1
2
3
4
5
6

内核栈不是用户栈。

当进程进入内核时,内核要用可信的内核栈运行。

每个进程有自己的内核栈。

text
process p
  |
  +-- user stack in user address space
  |
  +-- kernel stack in kernel address space
1
2
3
4
5

为什么启动阶段就要准备这些?

因为后面 userinit() 要创建第一个进程。

没有进程表和内核栈,就无法创建进程。

读 procinit() 时关注:

  • 遍历 proc[]。
  • 初始化每个 p->lock。
  • 设置或映射每个进程的内核栈。
  • p->state 初始是什么。

这会为后面的 allocproc() 打基础。

14. trap 初始化:让 CPU 知道异常去哪 ​

trap 包括系统调用、中断和异常。

内核必须告诉 CPU:发生 trap 时跳到哪里。

这涉及 stvec 等 CSR。

启动阶段相关函数:

text
trapinit()
  |
  +-- initialize global trap-related lock/state

trapinithart()
  |
  +-- set trap vector for current hart
1
2
3
4
5
6
7

抽象图:

text
CPU executes code
  |
  | trap occurs
  v
CPU reads trap vector
  |
  v
jump to kernel trap entry
1
2
3
4
5
6
7
8

如果没有正确设置 trap,系统调用和中断都无法正常进入内核。

系统调用路径依赖 trap。

定时器中断依赖 trap。

设备中断也依赖 trap。

所以 trap 初始化必须在用户进程真正运行前完成。

15. PLIC 和设备初始化 ​

PLIC 是 Platform-Level Interrupt Controller。

可以理解为平台级中断控制器。

外部设备中断需要经过它。

text
device
  |
  v
PLIC
  |
  v
hart
  |
  v
kernel trap handler
1
2
3
4
5
6
7
8
9
10

启动阶段会初始化:

text
plicinit()
  |
  +-- global interrupt controller setup

plicinithart()
  |
  +-- per-hart interrupt controller setup
1
2
3
4
5
6
7

设备相关初始化还包括:

text
virtio_disk_init()
  |
  +-- initialize virtual disk driver
1
2
3

console 和 uart 也在输出输入路径中。

这些设备后面会服务用户程序。

例如 shell 输入输出、文件系统读写磁盘。

16. buffer cache、inode 和 file 表初始化 ​

文件系统相关初始化包括:

text
binit()
  |
  +-- initialize buffer cache

iinit()
  |
  +-- initialize inode table/cache

fileinit()
  |
  +-- initialize global file table
1
2
3
4
5
6
7
8
9
10
11

关系图:

text
file table
  |
  v
inode layer
  |
  v
buffer cache
  |
  v
disk driver
1
2
3
4
5
6
7
8
9
10

为什么启动时就要初始化这些?

因为第一个用户进程 init 很快会打开 console,并启动 shell。

shell 后续要执行文件系统中的用户程序。

没有文件系统相关结构,用户态世界无法正常展开。

不要把这些初始化看成孤立动作。

它们是在为用户空间准备基本服务。

text
user shell
  |
  +-- needs console
  +-- needs file descriptors
  +-- needs filesystem
  +-- needs exec
1
2
3
4
5
6

17. userinit():创造第一个用户进程 ​

userinit() 是启动路径中的关键点。

它创建第一个用户进程。

这个进程后续会运行用户态 init 程序。

关系图:

text
main()
  |
  v
userinit()
  |
  +-- allocproc()
  +-- create initial user address space
  +-- set up trapframe
  +-- set process name
  +-- set cwd
  +-- mark RUNNABLE
  |
  v
scheduler()
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这里第一次真正用到 struct proc。

allocproc() 从 proc[NPROC] 中找空槽位。

然后设置该进程的内核资源。

简化生命周期:

text
UNUSED proc slot
  |
  v
allocproc
  |
  v
USED
  |
  v
userinit fills user state
  |
  v
RUNNABLE
1
2
3
4
5
6
7
8
9
10
11
12
13

RUNNABLE 表示这个进程已经可以被调度器选择运行。

注意:

userinit() 创建的是进程对象。

它不是直接让用户代码马上运行。

真正让它运行的是后面的调度器。

18. 第一个用户程序从哪里来 ​

xv6 的第一个用户程序不是从普通文件系统路径直接 exec 出来的。

它使用一段内嵌的初始用户代码。

这段代码会启动 /init。

然后 user/init.c 里的程序会运行。

简化关系:

text
kernel userinit
  |
  v
initial user code
  |
  v
exec("/init")
  |
  v
user/init.c
  |
  v
open console
  |
  v
fork shell
  |
  v
user/sh.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这里有一个非常重要的观念:

内核创建第一个用户进程后,用户空间才真正开始。

init 是用户态程序。

它不是内核函数。

但它是由内核创建的第一个用户进程路径带起来的。

init 后续会启动 shell。

shell 再执行其他用户程序。

text
kernel
  |
  v
first user process
  |
  v
init
  |
  v
shell
  |
  v
commands
1
2
3
4
5
6
7
8
9
10
11
12
13

19. scheduler():启动的终点,也是运行的起点 ​

main() 最后进入 scheduler()。

这意味着 CPU 进入调度循环。

调度器会寻找 RUNNABLE 进程。

找到后切换到该进程。

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

这里第一次出现真正的“操作系统运行状态”。

启动阶段是准备世界。

调度器开始后,世界运转起来。

text
boot initialization
  |
  v
process created
  |
  v
scheduler starts
  |
  v
normal OS execution
1
2
3
4
5
6
7
8
9
10

以后系统调用、中断、调度、文件读写都发生在这个运转中的世界里。

20. 启动路径中的关键结构体 ​

启动阶段涉及几个关键结构体。

第一,struct cpu。

它描述一个 hart/CPU 的内核调度状态。

text
struct cpu
  |
  +-- current process pointer
  +-- scheduler context
  +-- interrupt nesting state
1
2
3
4
5

第二,struct proc。

它描述进程。

text
struct proc
  |
  +-- state
  +-- pid
  +-- pagetable
  +-- trapframe
  +-- context
  +-- kstack
  +-- ofile[]
  +-- cwd
1
2
3
4
5
6
7
8
9
10

第三,struct context。

它保存调度切换需要的内核上下文。

text
struct context
  |
  +-- ra
  +-- sp
  +-- s0-s11
1
2
3
4
5

第四,struct trapframe。

它保存用户态寄存器和返回信息。

text
struct trapframe
  |
  +-- user registers
  +-- kernel_satp
  +-- kernel_sp
  +-- kernel_trap
  +-- epc
1
2
3
4
5
6
7

启动阶段的对象关系:

text
hart
  |
  v
struct cpu
  |
  v
scheduler context

first process
  |
  v
struct proc
  |
  +-- trapframe
  +-- pagetable
  +-- kernel stack
  +-- context
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这张图会在 proc 文章中反复出现。

21. 启动顺序为什么不能随便换 ​

内核初始化顺序有依赖关系。

比如:

没有物理页分配器,就很难创建页表。

没有页表,虚拟内存机制不能正常启用。

没有进程表,就不能创建第一个进程。

没有 trap,用户程序不能安全系统调用。

没有文件系统和设备,init 和 shell 很难工作。

依赖图:

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

如果乱改顺序,可能会出现:

  • 打印不可用。
  • 页分配失败。
  • 页表未启用。
  • trap 入口未设置。
  • 第一个进程无法创建。
  • shell 无法读写 console。
  • 文件系统无法访问磁盘。

所以读 main.c 要带着依赖关系看。

它不是平铺清单。

它是内核世界的搭建顺序。

22. 启动阶段和后续章节的关系 ​

启动不是孤立章节。

它引出后续所有机制。

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

所以这篇文章是后续文章的目录式前导。

每个初始化函数都对应一个深挖专题。

不要期待在启动篇一次学完所有机制。

启动篇要达到的目标是:

看到整个内核被搭起来。

知道后续每个机制从哪里接入。

23. 初学者常见误解 ​

误解一:xv6 从 main() 直接开始。

不准确。

main() 是 C 层的重要入口。

但在它之前还有 entry.S 和 start.c。

误解二:栈是 C 编译器自动准备的。

对普通用户程序可以这么粗略理解。

但内核早期启动时,需要自己设置栈。

误解三:所有 hart 都做同样初始化。

不是。

一个 hart 做全局初始化。

其他 hart 等待后做 per-hart 初始化。

误解四:userinit() 直接运行用户程序。

不准确。

它创建并设置第一个用户进程,使其变成 RUNNABLE。

真正运行要靠 scheduler。

误解五:init 是内核的一部分。

不是。

init 是用户态程序。

它是第一个重要用户进程。

误解六:初始化顺序只是代码风格。

不是。

初始化顺序体现子系统依赖。

误解七:QEMU 只是命令行工具。

不只是。

在这里 QEMU 扮演虚拟硬件机器。

24. 小实验:打印启动顺序 ​

实验目标:

观察 main() 初始化顺序。

可以在每个主要初始化函数前后加短打印。

示意输出:

text
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

注意:

多 hart 输出可能交错。

可以只在主 hart 打印全局初始化。

或者打印 hart id。

text
boot: hart=0 kinit
boot: hart=1 kvminithart
1
2

实验观察问题:

  • 哪些初始化只出现一次?
  • 哪些初始化每个 hart 都出现?
  • userinit 在哪些初始化之后?
  • scheduler 是否每个 hart 都进入?

这个实验很适合建立启动直觉。

25. 小实验:观察第一个进程 ​

实验目标:

观察 userinit() 创建第一个进程。

可以在 userinit() 中打印:

text
userinit: pid=1 state=RUNNABLE name=initcode
1

也可以在 allocproc() 中打印:

text
allocproc: p=... pid=1 state=USED
1

观察路径:

text
main
  |
  v
userinit
  |
  v
allocproc
  |
  v
first process RUNNABLE
  |
  v
scheduler
1
2
3
4
5
6
7
8
9
10
11
12
13

你要验证:

userinit() 创建的是第一个 struct proc。

它不是直接执行 shell。

shell 是后面用户态 init 启动的。

text
kernel creates first process
  |
  v
first user code execs /init
  |
  v
init opens console and forks shell
1
2
3
4
5
6
7

这能帮助你分清内核启动和用户空间启动。

26. 本篇总结 ​

xv6 启动可以压缩成一条主线:

text
QEMU
  |
  v
entry.S
  |
  v
start.c
  |
  v
main.c
  |
  v
subsystem initialization
  |
  v
userinit
  |
  v
scheduler
  |
  v
first user process
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

每个节点解决一个问题。

entry.S 解决早期栈和汇编入口。

start.c 解决 RISC-V 特权级过渡。

main.c 解决内核子系统初始化。

userinit() 解决第一个进程从哪里来。

scheduler() 解决 CPU 如何开始运行进程。

理解启动后,再读 proc 会容易很多。

因为你已经知道第一个 struct proc 是什么时候出现的。

也知道调度器什么时候开始接管。

下一篇应该进入:

text
06-memory-layout-and-page-table.md
1

因为启动之后,最核心的基础设施之一就是内存布局和页表。

页表理解清楚后,再回头看 trapframe、copyin/copyout、fork 和 exec,会顺得多。

最后更新于:

Pager
上一篇5. 如何阅读 xv6 源码 / How To Read xv6 Source
下一篇7. xv6 内存布局与页表 / xv6 Memory Layout And Page Tables

持续记录,持续成长

Copyright © Tidenflow