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 必备的 RISC-V 基础 / RISC-V Basics For xv6 ​

1. 为什么学 xv6 需要一点 RISC-V ​

xv6-riscv 不是抽象操作系统模型。

它是一份运行在 RISC-V 机器上的内核源码。

所以有些代码不是纯 C 语言能解释的。

你会遇到:

  • entry.S
  • trampoline.S
  • kernelvec.S
  • swtch.S
  • riscv.h
  • satp
  • sepc
  • sstatus
  • ecall
  • sret

这些内容看起来像硬件细节。

但它们恰恰是操作系统和 CPU 接触的地方。

如果完全跳过 RISC-V,读 xv6 会出现几个断点。

第一个断点:内核从哪里开始执行。

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

但 xv6 内核不是普通 C 程序。

它先执行汇编入口,再进入 start() 和 main()。

第二个断点:系统调用如何进入内核。

用户程序调用 getpid() 不是直接调用 sys_getpid()。

它通过 ecall 触发 trap。

第三个断点:为什么需要 trapframe。

CPU 从用户态进入内核时,必须保存用户寄存器。

否则内核处理完事件后,用户程序无法继续运行。

第四个断点:为什么需要 satp。

satp 记录当前页表。

页表决定虚拟地址如何翻译。

第五个断点:为什么 swtch.S 能切换进程。

上下文切换不是普通函数返回。

它保存一组寄存器,再恢复另一组寄存器。

所以这篇文章只学读 xv6 必须掌握的 RISC-V。

不追求完整 ISA。

总路线如下:

text
RISC-V execution model
  |
  v
registers
  |
  v
privilege modes
  |
  v
CSR registers
  |
  v
trap and syscall
  |
  v
page table register satp
  |
  v
xv6 assembly files
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

1.1 本文基础词小注释 ​

RISC-V 这篇里会出现很多硬件词。

先用最白话的方式理解。

text
CPU
  |
  +-- 真正执行指令的硬件
  +-- 内核和用户程序最终都要被 CPU 执行

指令
  |
  +-- instruction
  +-- CPU 能直接执行的低层操作
  +-- ecall、sret 都是 RISC-V 指令

寄存器
  |
  +-- CPU 内部的小存储格子
  +-- a0/a7/sp/ra 都是寄存器名

特权级
  |
  +-- CPU 对代码权限的分级
  +-- 用户程序权限低,内核权限高

CSR
  |
  +-- Control and Status Register
  +-- CPU 内部的控制/状态寄存器
  +-- 例如 sepc、scause、satp

ecall
  |
  +-- 用户程序请求进入内核的指令
  +-- 系统调用会用到它
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

2. 先建立 CPU 执行模型 ​

CPU 做的事情可以先简化成三步。

text
fetch instruction
  |
  v
execute instruction
  |
  v
update program counter
1
2
3
4
5
6
7

程序计数器保存下一条指令地址。

在 RISC-V 里,程序计数器常被称为 pc。

普通顺序执行时,pc 指向下一条指令。

函数调用、分支、中断、系统调用都会改变 pc。

text
normal flow
  |
  v
pc -> instruction A
pc -> instruction B
pc -> instruction C

branch / call / trap
  |
  v
pc jumps to another address
1
2
3
4
5
6
7
8
9
10
11

操作系统关心的不只是 CPU 算什么。

它更关心:

  • 当前 CPU 在执行用户代码还是内核代码。
  • 当前使用哪张页表。
  • 当前中断是否打开。
  • 当前异常返回地址是什么。
  • 当前寄存器内容要不要保存。

这些状态不全在普通内存中。

很多状态放在 CPU 寄存器里。

有些放在普通寄存器。

有些放在控制状态寄存器,也就是 CSR。

所以读 xv6 时要把“内存里的结构体”和“CPU 里的寄存器”一起看。

text
kernel memory objects
  |
  +-- struct proc
  +-- struct trapframe
  +-- struct context
  +-- page tables

CPU state
  |
  +-- general registers
  +-- pc
  +-- CSR registers
  +-- current privilege mode
1
2
3
4
5
6
7
8
9
10
11
12
13

操作系统的很多代码,就是在两者之间搬运状态。

2.1 从 C 代码到 RISC-V 指令:一点编译原理背景 ​

读 xv6 时,经常会出现一个困惑:

明明我看到的是 C 函数。

为什么最后又要看寄存器、汇编和机器指令?

原因是:C 代码不会被 CPU 直接执行。

CPU 最终执行的是机器指令。

C 代码需要经过编译、汇编、链接,最后变成能被 QEMU 里的 RISC-V CPU 执行的内核镜像或用户程序。

先看总图:

text
C source files
  |
  |  compiler front-end
  v
abstract syntax / semantic analysis
  |
  |  optimizer + code generator
  v
RISC-V assembly
  |
  |  assembler
  v
object files
  |
  |  linker
  v
kernel image / user ELF program
  |
  v
QEMU executes machine instructions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这张图里有几个词要先解释。

编译 compiler:把高级语言翻译成更低层表示的工具。对 xv6 来说,常见路径是把 .c 文件翻译成 RISC-V 汇编或目标文件。

词法分析 lexical analysis:把字符流切成一个个 token。比如 int x = 1; 会被拆成 int、x、=、1、; 这类小单位。读 xv6 不需要手写词法分析器,但知道这一步有助于理解“编译器不是逐字执行 C 代码”。

语法分析 parsing:根据 C 语言语法把 token 组织成结构。比如函数定义、if 语句、表达式、函数调用都会形成树状结构。

语义分析 semantic analysis:检查名字、类型、作用域等是否合理。比如某个变量有没有声明,指针类型能不能这样赋值,函数参数数量是否匹配。

代码生成 code generation:把高级结构变成目标机器能执行的指令。比如 C 里的函数调用,会变成保存返回地址、传递参数、跳转到函数入口等一组底层动作。

汇编 assembler:把人能读的汇编指令变成机器码。比如 sd ra, 0(sp) 这样的文本指令,最终会变成二进制编码。

链接 linker:把多个目标文件合并起来,解决函数和全局变量的地址问题,按照链接脚本安排代码和数据的位置。

在 xv6 里,链接尤其重要。

普通用户程序可以让操作系统装载它。

但内核自己就是操作系统。

内核必须知道自己被放到什么地址,入口点在哪里,各段如何排列。

所以 kernel/kernel.ld 不是装饰品。

它告诉链接器怎样摆放内核:

text
object files
  |
  v
linker uses kernel.ld
  |
  +-- where text section starts
  +-- where data section goes
  +-- where entry symbol is
  |
  v
kernel image layout
1
2
3
4
5
6
7
8
9
10
11

这里的 section 可以先理解成“程序文件里的区域”。

常见区域有:

text
.text
  |
  +-- machine instructions
  +-- 函数代码通常在这里

.rodata
  |
  +-- read-only data
  +-- 字符串常量等

.data
  |
  +-- initialized global data
  +-- 有初始值的全局变量

.bss
  |
  +-- zero-initialized global data
  +-- 初始为 0 的全局变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这和操作系统有什么关系?

关系很直接。

比如 kernel/entry.S 要作为很早的入口代码存在。

比如全局数组 proc[NPROC] 会被放在内核数据区域。

比如 end 这个符号常用来表示内核镜像结束位置,kalloc.c 可以从它后面开始管理空闲物理内存。

可以这样连起来:

text
C global variable
  |
  v
compiler emits data symbol
  |
  v
linker assigns address
  |
  v
kernel C code uses that address at runtime
1
2
3
4
5
6
7
8
9
10

再看一个更贴近源码的例子。

C 里写一个普通函数调用:

c
yield();
1

编译之后,它不会保持成“调用 yield 这几个字符”。

它会变成类似这样的底层动作:

text
put return address somewhere
jump to yield
yield runs
return to caller by return address
1
2
3
4

在 RISC-V 里,返回地址通常和 ra 寄存器有关。

所以后面看 ra、sp、s0-s11 时,不要觉得它们和 C 没关系。

它们正是 C 函数调用在机器层面的影子。

再看系统调用。

用户程序里写:

c
write(1, "hi\n", 3);
1

你在 C 层看到的是函数调用。

但真正进入内核之前,会经过用户态系统调用桩代码。

大致会变成:

text
put syscall number into a7
put arguments into a0, a1, a2
execute ecall
CPU traps into kernel
1
2
3
4

这就是为什么学习系统调用必须懂一点调用约定和寄存器。

编译原理在这里不是抽象课程。

它帮助你把三层东西接起来:

text
C source level
  |
  +-- function, struct, pointer, global variable
  |
  v
assembly level
  |
  +-- register, stack, branch, call, load, store
  |
  v
machine / OS level
  |
  +-- address, page table, trap, context switch
1
2
3
4
5
6
7
8
9
10
11
12
13

初学时最重要的不是背完整编译流程。

而是建立几个判断:

  • C 函数调用最后会落到寄存器、栈和跳转。
  • C 指针最后会落到地址和内存读写。
  • C 全局变量最后会落到链接器安排的内存位置。
  • C 结构体字段最后会落到“基地址 + 偏移量”的访问。
  • 系统调用看起来像 C 函数,但底层会变成 ecall。

这几个判断足够支撑你读 xv6 的 RISC-V 相关代码。

3. 通用寄存器:函数参数、返回值和上下文 ​

RISC-V 有一组通用寄存器。

它们可以保存整数、地址、临时值。

读 xv6 时不需要一次背完所有寄存器。

先记常见角色。

text
+----------+-----------------------------+-----------------------------+
| Register | Common role                 | xv6 relevance               |
+----------+-----------------------------+-----------------------------+
| zero     | always zero                 | writes ignored              |
| ra       | return address              | function return, swtch      |
| sp       | stack pointer               | kernel/user stack           |
| gp       | global pointer              | less important initially    |
| tp       | thread pointer              | xv6 uses for hart id        |
| t0-t6    | temporary registers         | assembly scratch values     |
| s0-s11   | saved registers             | preserved across calls      |
| a0-a7    | arguments / return values   | syscall args and return     |
+----------+-----------------------------+-----------------------------+
1
2
3
4
5
6
7
8
9
10
11
12

最重要的是 a0-a7。

C 函数调用时,前几个参数通常通过 a0-a7 传递。

返回值通常放在 a0。

系统调用也会利用这个约定。

text
user code wants syscall
  |
  +-- a7 = syscall number
  +-- a0-a5 = syscall arguments
  |
  v
ecall
  |
  v
kernel reads trapframe registers
  |
  v
kernel writes return value to a0
1
2
3
4
5
6
7
8
9
10
11
12
13

sp 是栈指针。

每个执行流都需要栈。

用户程序有用户栈。

内核执行时用内核栈。

进程进入内核后,不能继续随便使用用户栈。

原因很简单:

用户栈属于用户地址空间。

用户程序可以破坏它。

内核需要可信的内核栈。

text
User mode
  |
  +-- user stack
  |
  v trap

Kernel mode
  |
  +-- kernel stack for current process
1
2
3
4
5
6
7
8
9

ra 是返回地址。

普通函数调用会保存返回位置。

swtch.S 里保存和恢复 ra 很关键。

因为切换上下文后,函数返回路径也会变。

4. 调用约定:哪些寄存器由谁保存 ​

调用约定是函数之间的协议。

它规定参数怎么传、返回值怎么传、哪些寄存器由调用者保存、哪些由被调用者保存。

读 swtch.S 时,这个概念特别重要。

RISC-V 里有一类寄存器叫 callee-saved registers。

可以理解为:被调用函数如果要改它们,必须负责恢复。

常见的是 s0-s11。

swtch.S 保存这些寄存器。

因为 xv6 的上下文切换发生在内核函数调用边界附近。

struct context 也主要保存这组寄存器。

简化模型:

text
process kernel context
  |
  +-- ra
  +-- sp
  +-- s0
  +-- s1
  +-- ...
  +-- s11
1
2
3
4
5
6
7
8

为什么不保存所有寄存器?

因为不是所有寄存器都需要由 swtch 保存。

有些临时寄存器按调用约定本来就不保证跨函数调用保持。

而用户态寄存器则由 trap 路径保存到 trapframe。

所以有两个保存对象:

text
trapframe
  |
  +-- saves user registers during trap
  +-- used to return to user mode

context
  |
  +-- saves kernel callee-saved registers during swtch
  +-- used to resume kernel execution
1
2
3
4
5
6
7
8
9

初学者常把这两个混起来。

可以用一句话区分:

trapframe 服务于用户态到内核态再返回用户态。

context 服务于内核调度器和进程内核上下文之间切换。

5. 特权级:User、Supervisor、Machine ​

RISC-V 有多个特权级。

xv6 学习中最常见的是:

text
Machine mode
  |
  v
Supervisor mode
  |
  v
User mode
1
2
3
4
5
6
7

Machine mode 权限最高。

Supervisor mode 是 xv6 内核运行的主要模式。

User mode 是用户程序运行的模式。

text
+---------------------+
| Machine mode        |
| earliest setup      |
+---------------------+
          |
          v
+---------------------+
| Supervisor mode     |
| xv6 kernel          |
+---------------------+
          ^
          |
       trap / sret
          |
          v
+---------------------+
| User mode           |
| user programs       |
+---------------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

为什么要分特权级?

为了隔离。

用户程序不能随便改页表。

用户程序不能随便开关中断。

用户程序不能随便访问设备寄存器。

否则一个普通程序就能破坏整个系统。

系统调用就是一种受控入口。

用户程序不能直接执行内核函数。

它只能通过 ecall 请求内核服务。

text
user program
  |
  | cannot directly call kernel internal function
  v
ecall
  |
  v
kernel validates and dispatches syscall
1
2
3
4
5
6
7
8

这就是操作系统的边界。

不是 C 语言函数边界。

而是硬件特权级边界。

6. CSR:CPU 内部的控制状态寄存器 ​

CSR 是 Control and Status Register。

可以翻译为控制和状态寄存器。

它们是 CPU 内部的特殊寄存器。

普通 C 变量在内存里。

CSR 在 CPU 内部。

操作 CSR 通常需要特殊指令。

xv6 在 kernel/riscv.h 里封装了很多 CSR 读写。

常见 CSR:

text
+----------+------------------------------------------------+
| CSR      | Meaning                                        |
+----------+------------------------------------------------+
| sstatus  | supervisor status, interrupt enable, mode info  |
| sepc     | exception program counter                       |
| scause   | trap cause                                      |
| stval    | trap value, often faulting address              |
| stvec    | trap vector address                             |
| satp     | page table root and address translation mode     |
| sie      | supervisor interrupt enable                      |
| sip      | supervisor interrupt pending                     |
+----------+------------------------------------------------+
1
2
3
4
5
6
7
8
9
10
11
12

sepc 很重要。

发生 trap 时,硬件会把异常发生位置附近的 pc 保存到 sepc。

内核处理完后,sret 会根据 sepc 返回。

系统调用时,xv6 还要让 sepc 前进。

否则返回用户态后会再次执行同一条 ecall,陷入循环。

text
user pc points to ecall
  |
  v
ecall traps to kernel
  |
  v
sepc = address of ecall
  |
  v
kernel advances sepc
  |
  v
sret returns to next instruction
1
2
3
4
5
6
7
8
9
10
11
12
13

scause 表示 trap 原因。

它能告诉内核这次 trap 是系统调用、页错误,还是设备中断。

stvec 保存 trap 入口地址。

发生 trap 后,CPU 知道跳到哪里。

satp 保存页表根。

虚拟内存章节会反复遇到它。

7. ecall:用户程序进入内核 ​

ecall 是环境调用指令。

用户态执行 ecall 时,会触发 trap。

内核会接管控制流。

这就是系统调用的硬件入口。

系统调用路径可以画成:

text
user C function
  |
  v
user assembly wrapper
  |
  |-- put syscall number in a7
  |-- put args in a0-a5
  |-- execute ecall
  v
hardware trap
  |
  |-- switch privilege handling
  |-- save pc to sepc
  |-- jump to stvec
  v
kernel trap handler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

xv6 的用户态系统调用 wrapper 由 user/usys.pl 生成。

它们最终会执行 ecall。

内核侧由 trampoline.S 和 trap.c 接住。

初学者要特别注意:

ecall 不是跳转到某个 C 函数。

它是触发 CPU 的异常处理机制。

内核随后根据 trap 原因和系统调用号分发。

text
function call
  |
  +-- same privilege level
  +-- follows normal calling convention

syscall ecall
  |
  +-- crosses privilege boundary
  +-- enters trap handler
  +-- validates syscall number and arguments
1
2
3
4
5
6
7
8
9
10

8. sret:从内核返回用户态 ​

进入内核只是上半程。

内核处理完系统调用或中断后,还要返回用户态。

RISC-V 使用 sret 从 supervisor trap 返回。

返回前,内核必须准备好:

  • 用户页表。
  • 用户寄存器。
  • sepc。
  • sstatus 中的返回模式信息。
  • trampoline 返回代码。

返回路径大致是:

text
kernel finishes trap handling
  |
  v
usertrapret()
  |
  |-- set stvec for next user trap
  |-- prepare trapframe kernel fields
  |-- set sepc to saved user pc
  |-- set sstatus for user return
  v
trampoline userret
  |
  |-- switch to user pagetable
  |-- restore user registers
  |-- execute sret
  v
user program resumes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

为什么需要 trampoline?

因为切换页表很尴尬。

代码正在执行。

如果切换页表后,当前指令地址在新页表里不存在,CPU 就没法继续执行。

所以 xv6 把 trampoline 映射到用户页表和内核页表的同一个虚拟地址。

text
kernel page table        user page table
      |                         |
      v                         v
same virtual address: TRAMPOLINE
      |                         |
      +----------+--------------+
                 |
                 v
          trampoline code
1
2
3
4
5
6
7
8
9

这是一种很漂亮的设计。

它让 CPU 在切换页表的过程中还能继续执行那段桥接代码。

9. 页表和 satp ​

RISC-V 使用页表进行地址翻译。

satp 是一个关键 CSR。

它告诉 CPU 当前使用哪张页表。

简化模型:

text
virtual address
  |
  v
satp points to root page table
  |
  v
page table walk
  |
  v
physical address
1
2
3
4
5
6
7
8
9
10

xv6 中有内核页表。

每个用户进程还有自己的用户页表。

text
kernel page table
  |
  +-- maps kernel text/data
  +-- maps devices
  +-- maps trampoline

process user page table
  |
  +-- maps user text/data/heap/stack
  +-- maps trapframe
  +-- maps trampoline
1
2
3
4
5
6
7
8
9
10
11

为什么每个进程需要自己的页表?

因为每个进程应该有独立地址空间。

进程 A 的虚拟地址 0x1000 和进程 B 的虚拟地址 0x1000 可以映射到不同物理页。

text
Process A virtual 0x1000 -> physical page A
Process B virtual 0x1000 -> physical page B
1
2

这就是虚拟内存隔离。

读 vm.c 时,会看到很多地址变量。

要分清:

text
va
  |
  +-- virtual address

pa
  |
  +-- physical address

pagetable
  |
  +-- root page table pointer

pte
  |
  +-- page table entry
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

不要把 uint64 va 当成普通 C 指针。

它可能只是一个地址数值,需要通过页表解释。

10. trapframe:保存用户态现场 ​

trapframe 是 xv6 里非常关键的结构。

它在 kernel/proc.h 中定义。

作用是保存用户态寄存器。

当用户程序发生系统调用、中断或异常时,CPU 进入内核。

内核必须保存用户程序的寄存器。

否则返回时用户程序就乱了。

text
user program running
  |
  | registers contain user values
  v
trap
  |
  v
save registers into trapframe
  |
  v
kernel handles event
  |
  v
restore registers from trapframe
  |
  v
user program resumes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

trapframe 可以理解成用户态现场快照。

它保存:

  • 用户寄存器。
  • 用户 pc。
  • 内核页表信息。
  • 内核栈。
  • 当前 hart 信息。
  • usertrap 地址。

不是所有字段都是纯用户寄存器。

有些字段是 trampoline 返回内核时需要用的辅助信息。

关系图:

text
struct proc
  |
  +-- trapframe ----+
                   |
                   v
           +------------------+
           | saved user regs  |
           | kernel_satp      |
           | kernel_sp        |
           | kernel_trap      |
           | epc              |
           +------------------+
1
2
3
4
5
6
7
8
9
10
11
12

读系统调用路径时,要一直盯着 trapframe。

系统调用参数来自用户寄存器。

返回值写回 a0。

用户 pc 保存在 epc。

这些都在 trapframe 里体现。

11. context:保存内核上下文 ​

context 和 trapframe 是不同东西。

context 用于调度器切换。

它保存的是内核执行流恢复所需的一组寄存器。

定义通常也在 proc.h。

简化图:

text
struct context
  |
  +-- ra
  +-- sp
  +-- s0
  +-- s1
  +-- ...
  +-- s11
1
2
3
4
5
6
7
8

调度切换:

text
process p kernel context
  |
  v
swtch(&p->context, &cpu->context)
  |
  v
cpu scheduler context
1
2
3
4
5
6
7

再切回来:

text
cpu scheduler context
  |
  v
swtch(&cpu->context, &p->context)
  |
  v
process p kernel context
1
2
3
4
5
6
7

为什么这个过程和用户态 trapframe 不一样?

因为调度发生在内核态。

此时不是从用户态 trap 进来,而是在内核执行路径中主动切换。

所以只需要保存内核调用约定要求的那部分寄存器。

一句话:

text
trapframe answers:
  how do we return to user mode?

context answers:
  how do we resume this kernel execution path later?
1
2
3
4
5

12. xv6 中的汇编文件地图 ​

xv6-riscv 中几个汇编文件各有职责。

text
kernel/entry.S
  |
  +-- earliest kernel entry
  +-- sets stack
  +-- jumps to start

kernel/kernelvec.S
  |
  +-- kernel trap vector
  +-- saves kernel registers during kernel trap
  +-- calls kerneltrap

kernel/trampoline.S
  |
  +-- user trap entry and return
  +-- mapped at special virtual address
  +-- switches between user and kernel page tables

kernel/swtch.S
  |
  +-- context switch
  +-- saves old context
  +-- restores new context

user/usys.S
  |
  +-- generated syscall wrappers
  +-- each wrapper loads syscall number and executes ecall
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

不要试图第一次逐指令吃透所有汇编。

先按职责读。

读汇编时问:

text
which execution boundary is this file handling?
  |
  +-- boot boundary?
  +-- user/kernel trap boundary?
  +-- kernel trap boundary?
  +-- scheduler/process switch boundary?
  +-- user syscall wrapper boundary?
1
2
3
4
5
6
7

这样就不会把所有 .S 文件混成一团。

13. entry.S:内核最早入口 ​

entry.S 的核心任务是让内核有栈可以用。

在 C 函数能稳定运行前,必须先准备栈。

函数调用需要栈保存返回地址、局部变量、调用帧等。

每个 hart 都需要自己的栈。

text
hart 0 ---> stack 0
hart 1 ---> stack 1
hart 2 ---> stack 2
...
1
2
3
4

启动关系:

text
QEMU loads kernel
  |
  v
entry.S
  |
  +-- compute stack for current hart
  +-- set sp
  +-- call start
  v
start.c
1
2
3
4
5
6
7
8
9
10

这里要注意:

sp 是栈指针寄存器。

设置 sp 不是给一个普通变量赋值。

它是在配置 CPU 后续函数调用使用的栈。

读 entry.S 时先关注:

  • 它如何得到当前 hart id。
  • 它如何选择栈。
  • 它跳到哪个 C 函数。

不要一开始纠结每条汇编指令的所有细节。

14. trampoline.S:最难但最关键的桥 ​

trampoline.S 处理用户态和内核态之间的切换。

它难,是因为它同时涉及:

  • 用户页表。
  • 内核页表。
  • trapframe。
  • CSR。
  • 寄存器保存。
  • sret。

先看用户 trap 进入内核:

text
user program
  |
  v
ecall / interrupt / exception
  |
  v
CPU jumps to trampoline uservec
  |
  v
save user registers to trapframe
  |
  v
switch to kernel page table
  |
  v
jump to trap.c:usertrap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

再看返回用户:

text
trap.c:usertrapret
  |
  v
trampoline userret
  |
  v
switch to user page table
  |
  v
restore user registers
  |
  v
sret
  |
  v
user program resumes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

为什么叫 trampoline?

可以理解成“跳板”。

它放在一个特殊地址。

用户页表和内核页表都能看到它。

text
before switch page table
  |
  v
execute trampoline at virtual address X
  |
  v
switch satp
  |
  v
still execute trampoline at same virtual address X
1
2
3
4
5
6
7
8
9
10

这个设计解决了切换页表时“脚下代码不能消失”的问题。

15. kernelvec.S:内核态 trap ​

trap 不只发生在用户态。

内核自己执行时也可能发生中断或异常。

例如设备中断可能在内核态到来。

这时走的是 kernel trap 路径。

kernelvec.S 负责保存内核寄存器,然后调用 kerneltrap()。

简化图:

text
kernel code running
  |
  v
interrupt / exception
  |
  v
kernelvec.S
  |
  +-- save registers
  +-- call kerneltrap()
  +-- restore registers
  v
resume kernel code
1
2
3
4
5
6
7
8
9
10
11
12
13

它和 trampoline.S 的区别:

text
trampoline.S
  |
  +-- user/kernel boundary
  +-- page table switching
  +-- trapframe

kernelvec.S
  |
  +-- kernel trap while already in kernel
  +-- save kernel registers on kernel stack
  +-- no return to user directly
1
2
3
4
5
6
7
8
9
10
11

这个区别以后读 trap.c 很重要。

usertrap() 和 kerneltrap() 不是随便分开的。

它们对应不同来源。

16. swtch.S:上下文切换 ​

swtch.S 是调度章节的核心。

它接收两个参数:

text
old context pointer
new context pointer
1
2

它做两件事:

text
save current registers into old context
  |
  v
load registers from new context
1
2
3
4

看起来很简单。

但效果很神奇。

因为加载新的 ra 和 sp 后,函数返回时就回到了另一个执行上下文。

text
before swtch
  |
  +-- running on process A kernel stack
  +-- ra belongs to process A path

after restore new context
  |
  +-- running on scheduler stack or process B stack
  +-- ra belongs to restored path
1
2
3
4
5
6
7
8
9

所以 swtch 不是“调用另一个函数”。

它是“让当前 CPU 接上另一个保存好的执行现场”。

调度器和进程之间的切换图:

text
Process A
  |
  v
sched()
  |
  v
swtch(A.context, CPU.context)
  |
  v
Scheduler
  |
  v
swtch(CPU.context, B.context)
  |
  v
Process B
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

记住:

swtch.S 不决定选哪个进程。

选进程是 scheduler() 的工作。

swtch.S 只负责低层寄存器切换。

17. riscv.h 应该怎么读 ​

riscv.h 是硬件封装头文件。

里面有很多内联汇编和宏。

第一次读不要从头到尾硬啃。

按用途分块。

text
riscv.h
  |
  +-- CSR read/write helpers
  |
  +-- interrupt enable/disable helpers
  |
  +-- page table flags and macros
  |
  +-- address translation helpers
  |
  +-- register helpers
1
2
3
4
5
6
7
8
9
10
11

例如 CSR helper:

text
r_sstatus()
w_sstatus()
r_sepc()
w_sepc()
r_scause()
r_stval()
r_satp()
w_satp()
1
2
3
4
5
6
7
8

它们通常通过内联汇编读写特殊寄存器。

这类函数不要当普通业务逻辑看。

它们是在和 CPU 控制状态对话。

页表相关宏:

text
PTE_V
PTE_R
PTE_W
PTE_X
PTE_U
PA2PTE
PTE2PA
PX
1
2
3
4
5
6
7
8

这些宏会在 vm.c 里反复出现。

第一次读 vm.c 时,如果不理解这些宏,页表操作会很抽象。

建议先给它们做一张卡片。

text
PTE_V: valid
PTE_R: readable
PTE_W: writable
PTE_X: executable
PTE_U: user accessible
1
2
3
4
5

这样读 mappages() 时就不会完全迷失。

18. 系统调用中的寄存器流动 ​

系统调用最适合练习寄存器理解。

以 getpid() 为例。

用户程序调用:

c
getpid();
1

用户 wrapper 大致做:

text
a7 = SYS_getpid
ecall
return a0
1
2
3

进入内核后:

text
trapframe
  |
  +-- a7 contains syscall number
  +-- a0 may contain first argument
  +-- a1 may contain second argument
  +-- ...
1
2
3
4
5
6

syscall.c 读取系统调用号。

找到 sys_getpid()。

sys_getpid() 返回 pid。

返回值放回 trapframe->a0。

图:

text
user a7 = SYS_getpid
  |
  v
trap saves a7 into trapframe
  |
  v
syscall reads trapframe->a7
  |
  v
sys_getpid returns pid
  |
  v
trapframe->a0 = pid
  |
  v
user a0 receives return value
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如果系统调用有参数,比如 write(fd, buf, n):

text
a0 = fd
a1 = buf user virtual address
a2 = n
a7 = SYS_write
1
2
3
4

内核不能直接把 a1 当内核指针。

它要通过参数获取和 copyin/copyout 路径处理用户地址。

这把 RISC-V 寄存器和 C 指针边界连接起来了。

19. 定时器中断和调度 ​

定时器中断让内核有机会抢回 CPU。

如果没有定时器中断,一个用户程序可以一直运行。

内核就很难实现分时调度。

简化路径:

text
timer interrupt
  |
  v
trap into kernel
  |
  v
trap.c handles timer
  |
  v
yield()
  |
  v
scheduler may choose another process
1
2
3
4
5
6
7
8
9
10
11
12
13

定时器中断体现了硬件和调度的关系。

调度器不是凭空运行。

它需要触发点。

触发点可能是:

  • 当前进程主动 yield()。
  • 当前进程 sleep()。
  • 当前进程退出。
  • 定时器中断要求让出 CPU。
text
running process
  |
  +-- syscall sleep/wait/read
  +-- timer interrupt
  +-- exit
  |
  v
kernel gets control
  |
  v
scheduler decision
1
2
3
4
5
6
7
8
9
10
11

所以学调度前,先理解 trap 和中断很有必要。

否则 scheduler() 看起来像自己突然出现。

20. 页错误和非法访问 ​

trap 不只是系统调用。

用户程序访问非法地址也会 trap。

这类 trap 可能是 page fault。

简化路径:

text
user program loads invalid address
  |
  v
hardware cannot translate or permission fails
  |
  v
trap into kernel
  |
  v
scause tells fault type
  |
  v
stval may contain faulting address
  |
  v
xv6 kills process
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

现代 Linux 会有更复杂处理。

例如按需分配页面、缺页加载、写时复制。

xv6 的处理更简单。

这正适合学习基础。

你要先理解:

  • CPU 发现地址不合法。
  • trap 进入内核。
  • 内核读取 scause 和 stval。
  • 内核决定如何处理。

这条路径以后理解虚拟内存非常重要。

21. 汇编阅读方法 ​

读 xv6 汇编时,不要被每条指令吓住。

先按四步读。

第一步,看文件位置。

text
entry.S       -> boot entry
trampoline.S  -> user/kernel boundary
kernelvec.S   -> kernel trap boundary
swtch.S       -> context switch
1
2
3
4

第二步,看输入输出。

这个汇编片段进入时,CPU 处于什么状态?

离开时,CPU 应该处于什么状态?

第三步,看它保存什么。

保存寄存器通常意味着后面要恢复。

第四步,看它跳到哪里。

跳到 C 函数,还是执行 sret,还是返回另一个上下文?

text
assembly file
  |
  v
entry state
  |
  v
saved state
  |
  v
state change
  |
  v
exit target
1
2
3
4
5
6
7
8
9
10
11
12
13

比如 trampoline.S。

入口状态是用户态 trap 后。

它保存用户寄存器。

切换页表。

跳到 usertrap()。

返回路径则恢复用户寄存器。

执行 sret。

这样读比逐条死抠更稳。

22. 和 C 代码如何连接 ​

xv6 的汇编不是孤立的。

它们和 C 代码通过符号连接。

例如:

text
entry.S
  |
  v
start.c:start()

trampoline.S
  |
  v
trap.c:usertrap()

kernelvec.S
  |
  v
trap.c:kerneltrap()

swtch.S
  |
  v
proc.c:scheduler/sched
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

C 代码里可能声明外部符号。

链接器把 C 和汇编编译结果组合在一起。

这就是为什么 kernel.ld 和符号地址也很重要。

text
C source
  |
  v
object file
  |
  v
link with assembly object
  |
  v
kernel image
1
2
3
4
5
6
7
8
9
10

读源码时可以在 C 和汇编之间来回跳。

不要只读一种。

例如读系统调用:

text
user C call
  |
  v
user generated assembly
  |
  v
kernel trampoline assembly
  |
  v
kernel C trap handler
1
2
3
4
5
6
7
8
9
10

这条链路就是操作系统的真实边界。

23. 本篇涉及的 xv6 源码文件 ​

本篇主要服务这些文件:

text
kernel/riscv.h
  |
  +-- CSR helpers
  +-- page table macros
  +-- interrupt helpers

kernel/entry.S
  |
  +-- first kernel assembly entry
  +-- stack setup

kernel/start.c
  |
  +-- machine mode setup
  +-- delegation to supervisor mode

kernel/trampoline.S
  |
  +-- uservec
  +-- userret
  +-- page table switch during trap return

kernel/trap.c
  |
  +-- usertrap
  +-- usertrapret
  +-- kerneltrap
  +-- device interrupt dispatch

kernel/kernelvec.S
  |
  +-- kernel trap register save/restore

kernel/swtch.S
  |
  +-- context switch register save/restore

kernel/proc.h
  |
  +-- struct trapframe
  +-- struct context

kernel/vm.c
  |
  +-- page table operations
  +-- copyin/copyout
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

后续读这些文件时,遇到 RISC-V 名词可以回到本篇。

24. 初学者常见误解 ​

误解一:系统调用是普通函数调用。

不是。

系统调用通过 ecall 触发 trap,跨越用户态和内核态。

误解二:trapframe 和 context 是同一个东西。

不是。

trapframe 保存用户态寄存器。

context 保存内核上下文切换需要的寄存器。

误解三:汇编文件都是一类东西。

不是。

entry.S 管启动。

trampoline.S 管用户态 trap。

kernelvec.S 管内核态 trap。

swtch.S 管上下文切换。

误解四:satp 只是一个普通变量。

不是。

它是 CPU 的页表根寄存器。

改变 satp 会改变地址翻译。

误解五:用户程序可以直接进入内核函数。

不可以。

用户程序只能通过受控入口进入内核。

误解六:sret 只是普通 return。

不是。

它从 supervisor trap 返回,恢复特权级相关状态。

误解七:读汇编必须先精通所有指令。

不需要。

先读边界、保存状态、跳转目标。

再逐步补指令细节。

25. 小实验:观察一次系统调用寄存器路径 ​

实验目标:

观察 getpid() 如何从用户态进入内核。

如果你能使用 GDB,可以在这些位置设置断点:

text
user getpid wrapper
kernel/trampoline.S uservec
kernel/trap.c usertrap
kernel/syscall.c syscall
kernel/sysproc.c sys_getpid
1
2
3
4
5

观察:

text
a7: syscall number
a0: return value
sepc: user pc
scause: trap cause
1
2
3
4

如果暂时不用 GDB,也可以先做轻量打印。

在 syscall() 中打印系统调用号。

在 sys_getpid() 中打印当前进程 pid。

注意不要在高频路径打印太多。

系统调用和中断频繁发生。

打印过多会严重干扰系统。

实验记录模板:

text
syscall name:
syscall number:
arguments:
where a7 is read:
where return value is written:
which function implements it:
how it returns to user:
1
2
3
4
5
6
7

完成这个实验后,你应该能解释:

text
user function
  |
  v
RISC-V ecall
  |
  v
xv6 trap
  |
  v
syscall dispatch
  |
  v
return through trapframe
1
2
3
4
5
6
7
8
9
10
11
12
13

26. 本篇总结 ​

读 xv6 不需要先成为 RISC-V 专家。

但必须掌握 RISC-V 和内核交界处的几个概念。

核心是:

text
registers
  |
  +-- function arguments
  +-- syscall arguments
  +-- return values

privilege modes
  |
  +-- user mode
  +-- supervisor mode
  +-- machine mode

CSR
  |
  +-- sepc
  +-- scause
  +-- stvec
  +-- sstatus
  +-- satp

trap
  |
  +-- syscall
  +-- interrupt
  +-- exception

xv6 assembly
  |
  +-- entry.S
  +-- trampoline.S
  +-- kernelvec.S
  +-- swtch.S
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

有了这张地图,后面读启动、系统调用、进程调度和页表都会轻松很多。

下一篇应该进入:

text
04-how-to-read-xv6-source.md
1

那篇会讲怎么把 Makefile、kernel、user、mkfs、GDB、QEMU 和源码阅读顺序组织起来。

换句话说,本篇解释 CPU 和内核怎么接触。

下一篇解释学习者怎么和 xv6 源码接触。

最后更新于:

Pager
上一篇3. 读 xv6 必备的 C 语言和指针基础 / C And Pointers For xv6
下一篇5. 如何阅读 xv6 源码 / How To Read xv6 Source

持续记录,持续成长

Copyright © Tidenflow