读 xv6 必备的 RISC-V 基础 / RISC-V Basics For xv6
1. 为什么学 xv6 需要一点 RISC-V
xv6-riscv 不是抽象操作系统模型。
它是一份运行在 RISC-V 机器上的内核源码。
所以有些代码不是纯 C 语言能解释的。
你会遇到:
entry.Strampoline.Skernelvec.Sswtch.Sriscv.hsatpsepcsstatusecallsret
这些内容看起来像硬件细节。
但它们恰恰是操作系统和 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。
总路线如下:
RISC-V execution model
|
v
registers
|
v
privilege modes
|
v
CSR registers
|
v
trap and syscall
|
v
page table register satp
|
v
xv6 assembly files1.1 本文基础词小注释
RISC-V 这篇里会出现很多硬件词。
先用最白话的方式理解。
CPU
|
+-- 真正执行指令的硬件
+-- 内核和用户程序最终都要被 CPU 执行
指令
|
+-- instruction
+-- CPU 能直接执行的低层操作
+-- ecall、sret 都是 RISC-V 指令
寄存器
|
+-- CPU 内部的小存储格子
+-- a0/a7/sp/ra 都是寄存器名
特权级
|
+-- CPU 对代码权限的分级
+-- 用户程序权限低,内核权限高
CSR
|
+-- Control and Status Register
+-- CPU 内部的控制/状态寄存器
+-- 例如 sepc、scause、satp
ecall
|
+-- 用户程序请求进入内核的指令
+-- 系统调用会用到它2. 先建立 CPU 执行模型
CPU 做的事情可以先简化成三步。
fetch instruction
|
v
execute instruction
|
v
update program counter程序计数器保存下一条指令地址。
在 RISC-V 里,程序计数器常被称为 pc。
普通顺序执行时,pc 指向下一条指令。
函数调用、分支、中断、系统调用都会改变 pc。
normal flow
|
v
pc -> instruction A
pc -> instruction B
pc -> instruction C
branch / call / trap
|
v
pc jumps to another address操作系统关心的不只是 CPU 算什么。
它更关心:
- 当前 CPU 在执行用户代码还是内核代码。
- 当前使用哪张页表。
- 当前中断是否打开。
- 当前异常返回地址是什么。
- 当前寄存器内容要不要保存。
这些状态不全在普通内存中。
很多状态放在 CPU 寄存器里。
有些放在普通寄存器。
有些放在控制状态寄存器,也就是 CSR。
所以读 xv6 时要把“内存里的结构体”和“CPU 里的寄存器”一起看。
kernel memory objects
|
+-- struct proc
+-- struct trapframe
+-- struct context
+-- page tables
CPU state
|
+-- general registers
+-- pc
+-- CSR registers
+-- current privilege mode操作系统的很多代码,就是在两者之间搬运状态。
2.1 从 C 代码到 RISC-V 指令:一点编译原理背景
读 xv6 时,经常会出现一个困惑:
明明我看到的是 C 函数。
为什么最后又要看寄存器、汇编和机器指令?
原因是:C 代码不会被 CPU 直接执行。
CPU 最终执行的是机器指令。
C 代码需要经过编译、汇编、链接,最后变成能被 QEMU 里的 RISC-V CPU 执行的内核镜像或用户程序。
先看总图:
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这张图里有几个词要先解释。
编译 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 不是装饰品。
它告诉链接器怎样摆放内核:
object files
|
v
linker uses kernel.ld
|
+-- where text section starts
+-- where data section goes
+-- where entry symbol is
|
v
kernel image layout这里的 section 可以先理解成“程序文件里的区域”。
常见区域有:
.text
|
+-- machine instructions
+-- 函数代码通常在这里
.rodata
|
+-- read-only data
+-- 字符串常量等
.data
|
+-- initialized global data
+-- 有初始值的全局变量
.bss
|
+-- zero-initialized global data
+-- 初始为 0 的全局变量这和操作系统有什么关系?
关系很直接。
比如 kernel/entry.S 要作为很早的入口代码存在。
比如全局数组 proc[NPROC] 会被放在内核数据区域。
比如 end 这个符号常用来表示内核镜像结束位置,kalloc.c 可以从它后面开始管理空闲物理内存。
可以这样连起来:
C global variable
|
v
compiler emits data symbol
|
v
linker assigns address
|
v
kernel C code uses that address at runtime再看一个更贴近源码的例子。
C 里写一个普通函数调用:
yield();编译之后,它不会保持成“调用 yield 这几个字符”。
它会变成类似这样的底层动作:
put return address somewhere
jump to yield
yield runs
return to caller by return address在 RISC-V 里,返回地址通常和 ra 寄存器有关。
所以后面看 ra、sp、s0-s11 时,不要觉得它们和 C 没关系。
它们正是 C 函数调用在机器层面的影子。
再看系统调用。
用户程序里写:
write(1, "hi\n", 3);你在 C 层看到的是函数调用。
但真正进入内核之前,会经过用户态系统调用桩代码。
大致会变成:
put syscall number into a7
put arguments into a0, a1, a2
execute ecall
CPU traps into kernel这就是为什么学习系统调用必须懂一点调用约定和寄存器。
编译原理在这里不是抽象课程。
它帮助你把三层东西接起来:
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初学时最重要的不是背完整编译流程。
而是建立几个判断:
- C 函数调用最后会落到寄存器、栈和跳转。
- C 指针最后会落到地址和内存读写。
- C 全局变量最后会落到链接器安排的内存位置。
- C 结构体字段最后会落到“基地址 + 偏移量”的访问。
- 系统调用看起来像 C 函数,但底层会变成
ecall。
这几个判断足够支撑你读 xv6 的 RISC-V 相关代码。
3. 通用寄存器:函数参数、返回值和上下文
RISC-V 有一组通用寄存器。
它们可以保存整数、地址、临时值。
读 xv6 时不需要一次背完所有寄存器。
先记常见角色。
+----------+-----------------------------+-----------------------------+
| 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 |
+----------+-----------------------------+-----------------------------+最重要的是 a0-a7。
C 函数调用时,前几个参数通常通过 a0-a7 传递。
返回值通常放在 a0。
系统调用也会利用这个约定。
user code wants syscall
|
+-- a7 = syscall number
+-- a0-a5 = syscall arguments
|
v
ecall
|
v
kernel reads trapframe registers
|
v
kernel writes return value to a0sp 是栈指针。
每个执行流都需要栈。
用户程序有用户栈。
内核执行时用内核栈。
进程进入内核后,不能继续随便使用用户栈。
原因很简单:
用户栈属于用户地址空间。
用户程序可以破坏它。
内核需要可信的内核栈。
User mode
|
+-- user stack
|
v trap
Kernel mode
|
+-- kernel stack for current processra 是返回地址。
普通函数调用会保存返回位置。
swtch.S 里保存和恢复 ra 很关键。
因为切换上下文后,函数返回路径也会变。
4. 调用约定:哪些寄存器由谁保存
调用约定是函数之间的协议。
它规定参数怎么传、返回值怎么传、哪些寄存器由调用者保存、哪些由被调用者保存。
读 swtch.S 时,这个概念特别重要。
RISC-V 里有一类寄存器叫 callee-saved registers。
可以理解为:被调用函数如果要改它们,必须负责恢复。
常见的是 s0-s11。
swtch.S 保存这些寄存器。
因为 xv6 的上下文切换发生在内核函数调用边界附近。
struct context 也主要保存这组寄存器。
简化模型:
process kernel context
|
+-- ra
+-- sp
+-- s0
+-- s1
+-- ...
+-- s11为什么不保存所有寄存器?
因为不是所有寄存器都需要由 swtch 保存。
有些临时寄存器按调用约定本来就不保证跨函数调用保持。
而用户态寄存器则由 trap 路径保存到 trapframe。
所以有两个保存对象:
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初学者常把这两个混起来。
可以用一句话区分:
trapframe 服务于用户态到内核态再返回用户态。
context 服务于内核调度器和进程内核上下文之间切换。
5. 特权级:User、Supervisor、Machine
RISC-V 有多个特权级。
xv6 学习中最常见的是:
Machine mode
|
v
Supervisor mode
|
v
User modeMachine mode 权限最高。
Supervisor mode 是 xv6 内核运行的主要模式。
User mode 是用户程序运行的模式。
+---------------------+
| Machine mode |
| earliest setup |
+---------------------+
|
v
+---------------------+
| Supervisor mode |
| xv6 kernel |
+---------------------+
^
|
trap / sret
|
v
+---------------------+
| User mode |
| user programs |
+---------------------+为什么要分特权级?
为了隔离。
用户程序不能随便改页表。
用户程序不能随便开关中断。
用户程序不能随便访问设备寄存器。
否则一个普通程序就能破坏整个系统。
系统调用就是一种受控入口。
用户程序不能直接执行内核函数。
它只能通过 ecall 请求内核服务。
user program
|
| cannot directly call kernel internal function
v
ecall
|
v
kernel validates and dispatches syscall这就是操作系统的边界。
不是 C 语言函数边界。
而是硬件特权级边界。
6. CSR:CPU 内部的控制状态寄存器
CSR 是 Control and Status Register。
可以翻译为控制和状态寄存器。
它们是 CPU 内部的特殊寄存器。
普通 C 变量在内存里。
CSR 在 CPU 内部。
操作 CSR 通常需要特殊指令。
xv6 在 kernel/riscv.h 里封装了很多 CSR 读写。
常见 CSR:
+----------+------------------------------------------------+
| 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 |
+----------+------------------------------------------------+sepc 很重要。
发生 trap 时,硬件会把异常发生位置附近的 pc 保存到 sepc。
内核处理完后,sret 会根据 sepc 返回。
系统调用时,xv6 还要让 sepc 前进。
否则返回用户态后会再次执行同一条 ecall,陷入循环。
user pc points to ecall
|
v
ecall traps to kernel
|
v
sepc = address of ecall
|
v
kernel advances sepc
|
v
sret returns to next instructionscause 表示 trap 原因。
它能告诉内核这次 trap 是系统调用、页错误,还是设备中断。
stvec 保存 trap 入口地址。
发生 trap 后,CPU 知道跳到哪里。
satp 保存页表根。
虚拟内存章节会反复遇到它。
7. ecall:用户程序进入内核
ecall 是环境调用指令。
用户态执行 ecall 时,会触发 trap。
内核会接管控制流。
这就是系统调用的硬件入口。
系统调用路径可以画成:
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 handlerxv6 的用户态系统调用 wrapper 由 user/usys.pl 生成。
它们最终会执行 ecall。
内核侧由 trampoline.S 和 trap.c 接住。
初学者要特别注意:
ecall 不是跳转到某个 C 函数。
它是触发 CPU 的异常处理机制。
内核随后根据 trap 原因和系统调用号分发。
function call
|
+-- same privilege level
+-- follows normal calling convention
syscall ecall
|
+-- crosses privilege boundary
+-- enters trap handler
+-- validates syscall number and arguments8. sret:从内核返回用户态
进入内核只是上半程。
内核处理完系统调用或中断后,还要返回用户态。
RISC-V 使用 sret 从 supervisor trap 返回。
返回前,内核必须准备好:
- 用户页表。
- 用户寄存器。
sepc。sstatus中的返回模式信息。- trampoline 返回代码。
返回路径大致是:
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为什么需要 trampoline?
因为切换页表很尴尬。
代码正在执行。
如果切换页表后,当前指令地址在新页表里不存在,CPU 就没法继续执行。
所以 xv6 把 trampoline 映射到用户页表和内核页表的同一个虚拟地址。
kernel page table user page table
| |
v v
same virtual address: TRAMPOLINE
| |
+----------+--------------+
|
v
trampoline code这是一种很漂亮的设计。
它让 CPU 在切换页表的过程中还能继续执行那段桥接代码。
9. 页表和 satp
RISC-V 使用页表进行地址翻译。
satp 是一个关键 CSR。
它告诉 CPU 当前使用哪张页表。
简化模型:
virtual address
|
v
satp points to root page table
|
v
page table walk
|
v
physical addressxv6 中有内核页表。
每个用户进程还有自己的用户页表。
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为什么每个进程需要自己的页表?
因为每个进程应该有独立地址空间。
进程 A 的虚拟地址 0x1000 和进程 B 的虚拟地址 0x1000 可以映射到不同物理页。
Process A virtual 0x1000 -> physical page A
Process B virtual 0x1000 -> physical page B这就是虚拟内存隔离。
读 vm.c 时,会看到很多地址变量。
要分清:
va
|
+-- virtual address
pa
|
+-- physical address
pagetable
|
+-- root page table pointer
pte
|
+-- page table entry不要把 uint64 va 当成普通 C 指针。
它可能只是一个地址数值,需要通过页表解释。
10. trapframe:保存用户态现场
trapframe 是 xv6 里非常关键的结构。
它在 kernel/proc.h 中定义。
作用是保存用户态寄存器。
当用户程序发生系统调用、中断或异常时,CPU 进入内核。
内核必须保存用户程序的寄存器。
否则返回时用户程序就乱了。
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 resumestrapframe 可以理解成用户态现场快照。
它保存:
- 用户寄存器。
- 用户 pc。
- 内核页表信息。
- 内核栈。
- 当前 hart 信息。
usertrap地址。
不是所有字段都是纯用户寄存器。
有些字段是 trampoline 返回内核时需要用的辅助信息。
关系图:
struct proc
|
+-- trapframe ----+
|
v
+------------------+
| saved user regs |
| kernel_satp |
| kernel_sp |
| kernel_trap |
| epc |
+------------------+读系统调用路径时,要一直盯着 trapframe。
系统调用参数来自用户寄存器。
返回值写回 a0。
用户 pc 保存在 epc。
这些都在 trapframe 里体现。
11. context:保存内核上下文
context 和 trapframe 是不同东西。
context 用于调度器切换。
它保存的是内核执行流恢复所需的一组寄存器。
定义通常也在 proc.h。
简化图:
struct context
|
+-- ra
+-- sp
+-- s0
+-- s1
+-- ...
+-- s11调度切换:
process p kernel context
|
v
swtch(&p->context, &cpu->context)
|
v
cpu scheduler context再切回来:
cpu scheduler context
|
v
swtch(&cpu->context, &p->context)
|
v
process p kernel context为什么这个过程和用户态 trapframe 不一样?
因为调度发生在内核态。
此时不是从用户态 trap 进来,而是在内核执行路径中主动切换。
所以只需要保存内核调用约定要求的那部分寄存器。
一句话:
trapframe answers:
how do we return to user mode?
context answers:
how do we resume this kernel execution path later?12. xv6 中的汇编文件地图
xv6-riscv 中几个汇编文件各有职责。
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不要试图第一次逐指令吃透所有汇编。
先按职责读。
读汇编时问:
which execution boundary is this file handling?
|
+-- boot boundary?
+-- user/kernel trap boundary?
+-- kernel trap boundary?
+-- scheduler/process switch boundary?
+-- user syscall wrapper boundary?这样就不会把所有 .S 文件混成一团。
13. entry.S:内核最早入口
entry.S 的核心任务是让内核有栈可以用。
在 C 函数能稳定运行前,必须先准备栈。
函数调用需要栈保存返回地址、局部变量、调用帧等。
每个 hart 都需要自己的栈。
hart 0 ---> stack 0
hart 1 ---> stack 1
hart 2 ---> stack 2
...启动关系:
QEMU loads kernel
|
v
entry.S
|
+-- compute stack for current hart
+-- set sp
+-- call start
v
start.c这里要注意:
sp 是栈指针寄存器。
设置 sp 不是给一个普通变量赋值。
它是在配置 CPU 后续函数调用使用的栈。
读 entry.S 时先关注:
- 它如何得到当前 hart id。
- 它如何选择栈。
- 它跳到哪个 C 函数。
不要一开始纠结每条汇编指令的所有细节。
14. trampoline.S:最难但最关键的桥
trampoline.S 处理用户态和内核态之间的切换。
它难,是因为它同时涉及:
- 用户页表。
- 内核页表。
- trapframe。
- CSR。
- 寄存器保存。
sret。
先看用户 trap 进入内核:
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再看返回用户:
trap.c:usertrapret
|
v
trampoline userret
|
v
switch to user page table
|
v
restore user registers
|
v
sret
|
v
user program resumes为什么叫 trampoline?
可以理解成“跳板”。
它放在一个特殊地址。
用户页表和内核页表都能看到它。
before switch page table
|
v
execute trampoline at virtual address X
|
v
switch satp
|
v
still execute trampoline at same virtual address X这个设计解决了切换页表时“脚下代码不能消失”的问题。
15. kernelvec.S:内核态 trap
trap 不只发生在用户态。
内核自己执行时也可能发生中断或异常。
例如设备中断可能在内核态到来。
这时走的是 kernel trap 路径。
kernelvec.S 负责保存内核寄存器,然后调用 kerneltrap()。
简化图:
kernel code running
|
v
interrupt / exception
|
v
kernelvec.S
|
+-- save registers
+-- call kerneltrap()
+-- restore registers
v
resume kernel code它和 trampoline.S 的区别:
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这个区别以后读 trap.c 很重要。
usertrap() 和 kerneltrap() 不是随便分开的。
它们对应不同来源。
16. swtch.S:上下文切换
swtch.S 是调度章节的核心。
它接收两个参数:
old context pointer
new context pointer它做两件事:
save current registers into old context
|
v
load registers from new context看起来很简单。
但效果很神奇。
因为加载新的 ra 和 sp 后,函数返回时就回到了另一个执行上下文。
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所以 swtch 不是“调用另一个函数”。
它是“让当前 CPU 接上另一个保存好的执行现场”。
调度器和进程之间的切换图:
Process A
|
v
sched()
|
v
swtch(A.context, CPU.context)
|
v
Scheduler
|
v
swtch(CPU.context, B.context)
|
v
Process B记住:
swtch.S 不决定选哪个进程。
选进程是 scheduler() 的工作。
swtch.S 只负责低层寄存器切换。
17. riscv.h 应该怎么读
riscv.h 是硬件封装头文件。
里面有很多内联汇编和宏。
第一次读不要从头到尾硬啃。
按用途分块。
riscv.h
|
+-- CSR read/write helpers
|
+-- interrupt enable/disable helpers
|
+-- page table flags and macros
|
+-- address translation helpers
|
+-- register helpers例如 CSR helper:
r_sstatus()
w_sstatus()
r_sepc()
w_sepc()
r_scause()
r_stval()
r_satp()
w_satp()它们通常通过内联汇编读写特殊寄存器。
这类函数不要当普通业务逻辑看。
它们是在和 CPU 控制状态对话。
页表相关宏:
PTE_V
PTE_R
PTE_W
PTE_X
PTE_U
PA2PTE
PTE2PA
PX这些宏会在 vm.c 里反复出现。
第一次读 vm.c 时,如果不理解这些宏,页表操作会很抽象。
建议先给它们做一张卡片。
PTE_V: valid
PTE_R: readable
PTE_W: writable
PTE_X: executable
PTE_U: user accessible这样读 mappages() 时就不会完全迷失。
18. 系统调用中的寄存器流动
系统调用最适合练习寄存器理解。
以 getpid() 为例。
用户程序调用:
getpid();用户 wrapper 大致做:
a7 = SYS_getpid
ecall
return a0进入内核后:
trapframe
|
+-- a7 contains syscall number
+-- a0 may contain first argument
+-- a1 may contain second argument
+-- ...syscall.c 读取系统调用号。
找到 sys_getpid()。
sys_getpid() 返回 pid。
返回值放回 trapframe->a0。
图:
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如果系统调用有参数,比如 write(fd, buf, n):
a0 = fd
a1 = buf user virtual address
a2 = n
a7 = SYS_write内核不能直接把 a1 当内核指针。
它要通过参数获取和 copyin/copyout 路径处理用户地址。
这把 RISC-V 寄存器和 C 指针边界连接起来了。
19. 定时器中断和调度
定时器中断让内核有机会抢回 CPU。
如果没有定时器中断,一个用户程序可以一直运行。
内核就很难实现分时调度。
简化路径:
timer interrupt
|
v
trap into kernel
|
v
trap.c handles timer
|
v
yield()
|
v
scheduler may choose another process定时器中断体现了硬件和调度的关系。
调度器不是凭空运行。
它需要触发点。
触发点可能是:
- 当前进程主动
yield()。 - 当前进程
sleep()。 - 当前进程退出。
- 定时器中断要求让出 CPU。
running process
|
+-- syscall sleep/wait/read
+-- timer interrupt
+-- exit
|
v
kernel gets control
|
v
scheduler decision所以学调度前,先理解 trap 和中断很有必要。
否则 scheduler() 看起来像自己突然出现。
20. 页错误和非法访问
trap 不只是系统调用。
用户程序访问非法地址也会 trap。
这类 trap 可能是 page fault。
简化路径:
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现代 Linux 会有更复杂处理。
例如按需分配页面、缺页加载、写时复制。
xv6 的处理更简单。
这正适合学习基础。
你要先理解:
- CPU 发现地址不合法。
- trap 进入内核。
- 内核读取
scause和stval。 - 内核决定如何处理。
这条路径以后理解虚拟内存非常重要。
21. 汇编阅读方法
读 xv6 汇编时,不要被每条指令吓住。
先按四步读。
第一步,看文件位置。
entry.S -> boot entry
trampoline.S -> user/kernel boundary
kernelvec.S -> kernel trap boundary
swtch.S -> context switch第二步,看输入输出。
这个汇编片段进入时,CPU 处于什么状态?
离开时,CPU 应该处于什么状态?
第三步,看它保存什么。
保存寄存器通常意味着后面要恢复。
第四步,看它跳到哪里。
跳到 C 函数,还是执行 sret,还是返回另一个上下文?
assembly file
|
v
entry state
|
v
saved state
|
v
state change
|
v
exit target比如 trampoline.S。
入口状态是用户态 trap 后。
它保存用户寄存器。
切换页表。
跳到 usertrap()。
返回路径则恢复用户寄存器。
执行 sret。
这样读比逐条死抠更稳。
22. 和 C 代码如何连接
xv6 的汇编不是孤立的。
它们和 C 代码通过符号连接。
例如:
entry.S
|
v
start.c:start()
trampoline.S
|
v
trap.c:usertrap()
kernelvec.S
|
v
trap.c:kerneltrap()
swtch.S
|
v
proc.c:scheduler/schedC 代码里可能声明外部符号。
链接器把 C 和汇编编译结果组合在一起。
这就是为什么 kernel.ld 和符号地址也很重要。
C source
|
v
object file
|
v
link with assembly object
|
v
kernel image读源码时可以在 C 和汇编之间来回跳。
不要只读一种。
例如读系统调用:
user C call
|
v
user generated assembly
|
v
kernel trampoline assembly
|
v
kernel C trap handler这条链路就是操作系统的真实边界。
23. 本篇涉及的 xv6 源码文件
本篇主要服务这些文件:
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后续读这些文件时,遇到 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,可以在这些位置设置断点:
user getpid wrapper
kernel/trampoline.S uservec
kernel/trap.c usertrap
kernel/syscall.c syscall
kernel/sysproc.c sys_getpid观察:
a7: syscall number
a0: return value
sepc: user pc
scause: trap cause如果暂时不用 GDB,也可以先做轻量打印。
在 syscall() 中打印系统调用号。
在 sys_getpid() 中打印当前进程 pid。
注意不要在高频路径打印太多。
系统调用和中断频繁发生。
打印过多会严重干扰系统。
实验记录模板:
syscall name:
syscall number:
arguments:
where a7 is read:
where return value is written:
which function implements it:
how it returns to user:完成这个实验后,你应该能解释:
user function
|
v
RISC-V ecall
|
v
xv6 trap
|
v
syscall dispatch
|
v
return through trapframe26. 本篇总结
读 xv6 不需要先成为 RISC-V 专家。
但必须掌握 RISC-V 和内核交界处的几个概念。
核心是:
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有了这张地图,后面读启动、系统调用、进程调度和页表都会轻松很多。
下一篇应该进入:
04-how-to-read-xv6-source.md那篇会讲怎么把 Makefile、kernel、user、mkfs、GDB、QEMU 和源码阅读顺序组织起来。
换句话说,本篇解释 CPU 和内核怎么接触。
下一篇解释学习者怎么和 xv6 源码接触。