xv6 trap、中断与系统调用 / xv6 Traps Interrupts And System Calls
1. 这篇文章解决什么问题
系统调用是理解 xv6 的最佳入口之一。
因为它把用户程序、CPU、汇编、trapframe、内核 C 函数、进程结构全部连起来。
你在用户程序里写:
getpid();看起来像普通函数调用。
但真实路径不是:
user getpid -> kernel sys_getpid真实路径更像:
user getpid wrapper
|
v
ecall instruction
|
v
hardware trap
|
v
trampoline.S
|
v
trap.c:usertrap()
|
v
syscall.c:syscall()
|
v
sysproc.c:sys_getpid()
|
v
trapframe->a0
|
v
usertrapret()
|
v
trampoline.S:userret
|
v
sret back to user这篇文章要解决:
- trap 是什么。
- 系统调用、中断、异常有什么区别。
- 用户态如何进入内核。
- 内核如何保存用户寄存器。
trapframe为什么存在。syscall.c如何分发系统调用。sysproc.c和sysfile.c分别做什么。- 返回值如何回到用户程序。
- 为什么 trampoline 必须存在。
这篇是后面学习 proc、调度、文件系统的关键前置。
1.1 本文基础词小注释
这一篇绝不能默认你知道 trap。
先把最基础词讲清。
trap
|
+-- CPU 转去内核处理特殊事件
+-- 事件可能是系统调用、中断、异常
+-- 可以理解成“执行流被带到内核处理”
syscall
|
+-- 系统调用
+-- 用户程序主动请求内核服务
+-- 例如 fork、read、write
interrupt
|
+-- 中断
+-- 外部设备或定时器打断当前执行
+-- 例如键盘输入、磁盘完成、时钟到点
exception
|
+-- 异常
+-- 当前指令出问题导致进入内核
+-- 例如访问非法地址
trapframe
|
+-- 保存用户寄存器的一块内存
+-- 内核处理完 trap 后靠它恢复用户程序2. trap、interrupt、exception、syscall 的区别
先把几个词分清。
trap 是总称。
它表示 CPU 从当前执行流转入内核处理特殊事件。
特殊事件包括:
- 系统调用。
- 中断。
- 异常。
trap
|
+-- syscall
| |
| +-- user program intentionally asks kernel for service
|
+-- interrupt
| |
| +-- external or timer event interrupts current flow
|
+-- exception
|
+-- current instruction causes fault or illegal condition系统调用是主动的。
用户程序执行 ecall,请求内核服务。
中断通常是异步的。
例如定时器到了,磁盘完成了,UART 收到输入了。
异常通常是当前指令引起的。
例如访问非法地址、执行非法指令。
这三种事件都会让 CPU 进入 trap 处理路径。
但内核要根据原因做不同处理。
这就是 scause 的作用。
3. 用户态和内核态边界
用户程序运行在 user mode。
内核运行在 supervisor mode。
用户程序不能直接执行内核内部函数。
它必须通过受控入口进入内核。
User mode
|
| ecall / interrupt / exception
v
Supervisor mode
|
| sret
v
User mode这个边界不是 C 语言边界。
而是硬件特权级边界。
普通函数调用:
caller
|
v
callee
|
v
return系统调用:
user code
|
v
ecall
|
v
trap entry
|
v
kernel dispatch
|
v
sret
|
v
user code这就是为什么系统调用要处理:
- 寄存器保存。
- 地址空间切换。
- 权限检查。
- 用户参数读取。
- 返回值写回。
普通函数调用不需要这些。
4. 本篇涉及的源码文件
系统调用和 trap 路径涉及这些文件:
user/user.h
|
+-- user-visible syscall declarations
user/usys.pl
|
+-- generates user/usys.S syscall wrappers
kernel/syscall.h
|
+-- syscall number definitions
kernel/syscall.c
|
+-- syscall dispatch table
+-- argument fetching helpers
+-- syscall()
kernel/sysproc.c
|
+-- process-related syscalls
+-- fork, exit, wait, getpid, sbrk, sleep, uptime
kernel/sysfile.c
|
+-- file-related syscalls
+-- open, read, write, close, exec, chdir, link, unlink
kernel/trap.c
|
+-- usertrap()
+-- usertrapret()
+-- kerneltrap()
+-- devintr()
kernel/trampoline.S
|
+-- uservec
+-- userret
+-- save/restore user registers
+-- switch page tables
kernel/kernelvec.S
|
+-- kernel-mode trap vector
kernel/proc.h
|
+-- struct trapframe
+-- struct proc
kernel/proc.c
|
+-- myproc()
+-- yield()
+-- sleep/wakeup interactions注意:
系统调用不是只看 syscall.c。
syscall.c 只是分发中心。
完整路径跨越用户态 wrapper、trap 汇编、trap C 处理、具体系统调用实现和返回路径。
5. 一次 getpid() 的完整路径
getpid() 是最适合入门的系统调用。
因为它没有复杂参数。
它只返回当前进程 pid。
完整路径:
user program
|
| getpid()
v
user syscall wrapper
|
| a7 = SYS_getpid
| ecall
v
hardware trap
|
| sepc = user pc
| scause = syscall cause
| jump to stvec
v
trampoline.S:uservec
|
| save user registers into trapframe
| switch to kernel page table
v
trap.c:usertrap()
|
| detects syscall
| advances epc
v
syscall.c:syscall()
|
| num = trapframe->a7
| call syscalls[num]
v
sysproc.c:sys_getpid()
|
| return myproc()->pid
v
syscall()
|
| trapframe->a0 = return value
v
usertrapret()
|
| prepare return to user
v
trampoline.S:userret
|
| switch to user page table
| restore user registers
| sret
v
user program receives pid in a0这条路径非常重要。
后面 fork()、read()、write() 都是在这条骨架上增加复杂度。
6. 用户态 syscall wrapper
用户程序里调用的 getpid(),不是内核里的 sys_getpid()。
用户态有一层 wrapper。
它负责:
- 把系统调用号放进寄存器。
- 执行
ecall。 - 返回结果。
抽象图:
user C call
|
v
assembly wrapper
|
+-- load syscall number into a7
+-- ecall
+-- return to calleruser/usys.pl 会生成 user/usys.S。
这说明有些源码是生成的。
不要只找手写 .S 文件。
你要知道生成关系。
user/usys.pl
|
v
user/usys.S
|
v
user program links syscall wrappers系统调用号来自 kernel/syscall.h。
用户态 wrapper 和内核分发表必须对同一个编号达成一致。
否则用户请求的编号和内核解释会错位。
7. ecall 发生后硬件做什么
ecall 是 RISC-V 指令。
它会触发环境调用异常。
硬件会进入 trap 机制。
简化来说:
execute ecall in user mode
|
v
hardware records trap info
|
+-- sepc records user pc
+-- scause records cause
|
v
jump to trap vectortrap vector 地址由 stvec 决定。
用户态 trap 时,xv6 会让它进入 trampoline。
为什么不能直接跳到普通 C 函数?
因为这时状态很特殊:
- 当前仍处于用户页表相关环境。
- 用户寄存器还没保存。
- 内核栈需要切换。
- 后续要进入内核页表。
所以先进入汇编桥接代码。
hardware trap
|
v
trampoline assembly
|
v
C trap handler这就是 trampoline.S 的价值。
8. trampoline.S:uservec:保存用户现场
用户态 trap 进入后,uservec 负责保存现场并切入内核。
它的核心任务:
uservec
|
+-- save user registers to trapframe
+-- load kernel stack
+-- load kernel page table
+-- jump to usertrap()为什么要保存用户寄存器?
因为内核运行也要用寄存器。
如果不保存,用户程序的寄存器值会被内核覆盖。
返回用户态时就无法继续。
before trap
|
+-- user registers contain user computation state
during kernel
|
+-- kernel needs registers
after trap
|
+-- user registers must be restoredtrapframe 就是保存位置。
struct proc
|
+-- trapframe
|
v
saved user registers这里一定要记住:
trapframe 是当前进程的一部分。
不同进程有不同 trapframe。
9. struct trapframe
struct trapframe 定义在 proc.h。
它保存用户态寄存器和内核返回所需信息。
简化结构:
struct trapframe
|
+-- kernel_satp
+-- kernel_sp
+-- kernel_trap
+-- epc
+-- kernel_hartid
|
+-- ra
+-- sp
+-- gp
+-- tp
+-- t0-t6
+-- s0-s11
+-- a0-a7分两组看。
第一组:返回内核时需要的辅助字段。
kernel_satp
kernel_sp
kernel_trap
kernel_hartid第二组:用户寄存器保存区。
ra sp gp tp
t0-t6
s0-s11
a0-a7
epcepc 保存用户程序返回位置。
a7 保存系统调用号。
a0 保存参数,也保存返回值。
系统调用返回值写入:
trapframe->a0这就是用户程序最终拿到返回值的原因。
10. usertrap():进入内核后的 C 层处理
usertrap() 在 trap.c 中。
它处理来自用户态的 trap。
它要先判断 trap 原因。
usertrap()
|
v
read scause
|
+-- syscall?
|
+-- device interrupt?
|
+-- unexpected exception?如果是系统调用:
if killed
|
+-- exit
advance epc
|
+-- skip ecall instruction
enable interrupts
|
+-- allow devices while syscall runs
syscall()
|
+-- dispatch为什么要 advance epc?
因为 sepc/epc 指向触发 trap 的那条 ecall。
如果不前进,返回用户态后会再次执行 ecall。
于是无限系统调用。
pc points to ecall
|
v
trap
|
v
epc must become next instruction这是一处非常经典的源码点。
11. syscall.c:系统调用分发器
syscall.c 做两类事。
第一类,取参数。
第二类,按系统调用号分发。
分发表模型:
syscall number
|
v
syscalls[num]
|
v
sys_* function例如:
SYS_fork -> sys_fork
SYS_exit -> sys_exit
SYS_wait -> sys_wait
SYS_getpid -> sys_getpid
SYS_read -> sys_read
SYS_write -> sys_write系统调用号来自哪里?
用户 wrapper 放进 a7。
trap 保存到 trapframe。
内核读取:
myproc()->trapframe->a7返回值写到:
myproc()->trapframe->a0图:
trapframe
|
+-- a7: syscall number
|
+-- a0: return value这就是寄存器约定和 C 结构体的结合点。
12. 参数获取:为什么不能直接读用户指针
系统调用可能带参数。
例如:
write(fd, buf, n);用户态寄存器大致:
a0 = fd
a1 = buf
a2 = n
a7 = SYS_writefd 和 n 是普通整数。
buf 是用户虚拟地址。
内核不能直接把它当 char * 解引用。
所以参数获取分两层。
第一层,从 trapframe 取寄存器值。
argraw(n)
|
v
trapframe->a0/a1/a2/...第二层,如果参数代表用户地址或字符串,再通过 copyin/copyout/copyinstr。
user pointer value
|
v
copyin/copyout/copyinstr
|
v
safe kernel buffer or user write这就是系统调用安全的基础。
用户传什么地址,内核都要验证。
13. sysproc.c:进程相关系统调用
sysproc.c 处理进程类系统调用。
常见:
sys_fork
sys_exit
sys_wait
sys_pipe
sys_kill
sys_getpid
sys_sbrk
sys_sleep
sys_uptime这些函数通常比较薄。
它们会:
- 取参数。
- 调用内核核心函数。
- 返回结果。
例如:
sys_getpid
|
v
myproc()
|
v
p->pidsys_fork:
sys_fork
|
v
fork()sys_exit:
sys_exit
|
v
argint status
|
v
exit(status)不要把 sys_* 当成全部实现。
它们经常是系统调用层 wrapper。
核心逻辑在 proc.c、vm.c、file.c 等文件中。
14. sysfile.c:文件相关系统调用
sysfile.c 处理文件和路径相关系统调用。
常见:
sys_read
sys_write
sys_open
sys_close
sys_fstat
sys_link
sys_unlink
sys_mkdir
sys_mknod
sys_chdir
sys_exec它比 sysproc.c 更复杂。
因为文件系统参数涉及:
- fd。
- 用户 buffer。
- 路径字符串。
- inode。
- file。
- pipe。
典型 read() 路径:
sys_read
|
+-- fetch fd
+-- fetch user buffer address
+-- fetch size
v
fileread
|
+-- pipe read
+-- inode read
+-- device read典型 open() 路径:
sys_open
|
+-- copy path string from user
+-- begin filesystem op
+-- name lookup or create
+-- allocate file object
+-- allocate fd
+-- return fd这里你会看到用户地址边界。
路径字符串来自用户空间。
需要安全复制到内核。
15. 设备中断路径
系统调用是主动 trap。
设备中断是异步 trap。
例如 UART 收到字符。
或者 VirtIO 磁盘完成请求。
路径大致是:
device event
|
v
PLIC
|
v
CPU trap
|
v
trap.c:devintr()
|
+-- identify device
+-- call driver handler
+-- complete interruptUART 输入可能走:
UART interrupt
|
v
devintr
|
v
uartintr
|
v
consoleintr
|
v
wakeup readers磁盘中断可能走:
VirtIO disk interrupt
|
v
devintr
|
v
virtio_disk_intr
|
v
wakeup waiting process这连接到 sleep/wakeup。
进程等待设备时可能睡眠。
设备完成后中断唤醒它。
16. 定时器中断和调度
定时器中断让内核获得周期性控制权。
如果当前进程一直运行,定时器中断可以让内核进入 trap。
然后决定是否 yield()。
timer interrupt
|
v
trap
|
v
usertrap or kerneltrap
|
v
yield
|
v
scheduler这就是分时调度的基础。
用户程序不需要主动配合。
硬件定时器会制造进入内核的机会。
running user process
|
| timer interrupt
v
kernel gets control
|
v
may switch process所以 trap 和调度关系很近。
学调度前必须理解定时器中断。
17. kerneltrap():内核态 trap
并不是所有 trap 都来自用户态。
内核执行时也可能发生 trap。
这时走 kerneltrap()。
路径:
kernel code running
|
v
interrupt / exception
|
v
kernelvec.S
|
v
trap.c:kerneltrap()
|
v
handle or panic和 usertrap() 的区别:
usertrap
|
+-- came from user mode
+-- can kill current process
+-- returns through usertrapret
kerneltrap
|
+-- came from kernel mode
+-- unexpected exceptions are serious
+-- returns to kernel code如果用户程序非法访问,内核可以杀掉用户进程。
如果内核自己非法访问,通常说明内核 bug。
这就是二者处理差异的根本原因。
18. usertrapret():准备返回用户态
系统调用处理完后,不能直接 C return 回用户程序。
因为之前跨越了特权级和页表边界。
必须准备返回环境。
usertrapret() 做这些事情:
usertrapret()
|
+-- disable interrupts while preparing
+-- set stvec to user trap vector
+-- fill trapframe kernel fields
+-- set sepc to user pc
+-- set sstatus for returning to user
+-- jump to trampoline userret然后 userret:
userret
|
+-- switch to user page table
+-- restore user registers
+-- execute sret返回图:
kernel C
|
v
usertrapret
|
v
trampoline userret
|
v
sret
|
v
user code这条路径体现了:
普通函数返回不够。
必须用硬件定义的 trap return 机制。
19. 系统调用返回值怎么回到用户程序
系统调用返回值通常通过寄存器 a0。
进入 trap 时,用户寄存器被保存到 trapframe。
内核处理后,把返回值写到 trapframe->a0。
返回用户态时,trampoline 恢复寄存器。
于是用户态 a0 就是返回值。
sys_* returns value
|
v
syscall()
|
v
p->trapframe->a0 = value
|
v
userret restores a0
|
v
user function returns value这解释了为什么 trapframe 是系统调用路径的关键对象。
它不是抽象概念。
它是用户寄存器和内核 C 代码之间的桥。
20. 系统调用、proc 和 trapframe 的关系
当前进程通过 myproc() 找到。
当前进程里有 trapframe。
系统调用从 trapframe 读参数、写返回值。
current CPU
|
v
struct cpu
|
v
current proc pointer
|
v
struct proc
|
v
trapframe
|
+-- syscall number in a7
+-- args in a0-a5
+-- return value in a0所以读 syscall.c 时,必须知道 proc。
读 proc 时,也要知道 trapframe。
它们互相依赖。
这就是为什么本专题把 trap 放在 proc 前面。
否则 p->trapframe 会很抽象。
21. 系统调用和文件系统的连接
以 write() 为例。
路径:
user write(fd, buf, n)
|
v
ecall
|
v
syscall
|
v
sys_write
|
+-- fdarg finds struct file
+-- fetch user buffer address
v
filewrite
|
+-- console/device
+-- pipe
+-- inode file这里有三个层次:
syscall layer
|
+-- convert user request to kernel objects
file layer
|
+-- operate on struct file
filesystem/device layer
|
+-- inode, pipe, console, disk系统调用层的任务不是自己完成所有工作。
它把用户态请求安全地转成内核对象操作。
22. 系统调用和虚拟内存的连接
系统调用参数经常包含用户地址。
例如:
read(fd, user_buf, n)
write(fd, user_buf, n)
exec(user_path, user_argv)
stat(user_path, user_stat_addr)这些都需要跨用户/内核地址边界。
trapframe contains user virtual address
|
v
syscall implementation
|
v
copyin/copyout/copyinstr
|
v
vm.c walks process page table这就是 trap 和 vm.c 的连接。
系统调用不是只和 syscall.c 有关。
它经常调用虚拟内存辅助函数。
如果用户地址非法,系统调用应该失败,而不是让内核崩溃。
23. 系统调用和调度的连接
有些系统调用会导致当前进程不再继续运行。
例如:
sleep
wait
read from empty pipe
exit这些系统调用会改变进程状态。
RUNNING
|
+-- sleep syscall
v
SLEEPING
RUNNING
|
+-- exit syscall
v
ZOMBIE然后调度器会选择其他进程。
所以系统调用也可能是调度入口。
user syscall
|
v
kernel changes proc state
|
v
sched / scheduler这就是为什么不能把系统调用理解成普通函数。
普通函数调用不会让当前执行流变成睡眠进程。
系统调用可以。
24. 初学者常见误解
误解一:trap 等于中断。
不准确。
trap 是总称,中断只是其中一种。
误解二:系统调用是普通函数调用。
不是。
它跨越用户态和内核态。
误解三:sys_getpid() 是用户程序直接调用的。
不是。
用户程序通过 wrapper 和 ecall 进入内核,再由分发表调用 sys_getpid()。
误解四:系统调用参数都安全。
不是。
用户传入的地址必须验证和复制。
误解五:trapframe 只是调试用结构。
不是。
它保存用户寄存器,是返回用户态的关键。
误解六:返回用户态就是 C 函数 return。
不是。
需要 usertrapret、trampoline 和 sret。
误解七:syscall.c 包含所有系统调用逻辑。
不是。
它主要分发,具体逻辑在 sysproc.c、sysfile.c 和更底层模块。
误解八:中断只在用户态发生。
不是。
内核态也可能发生 trap,对应 kerneltrap()。
25. 小实验:追踪 getpid()
实验目标:
观察一次最简单系统调用。
可以在这些位置加打印或断点:
syscall()
sys_getpid()
usertrap()
usertrapret()建议先打印:
pid
syscall number
return value
trapframe address示意:
usertrap: pid=3
syscall: pid=3 num=SYS_getpid
sys_getpid: pid=3
syscall return: a0=3观察问题:
- 系统调用号从哪里来?
- 返回值写到哪里?
- 当前进程怎么找到?
- 用户程序为什么能拿到返回值?
不要在所有系统调用里长期打印。
输出会很多。
实验结束后撤掉打印。
26. 小实验:新增一个简单系统调用
实验目标:
通过新增系统调用理解完整链路。
可以新增一个返回固定值的系统调用。
路径会涉及:
kernel/syscall.h
|
+-- add syscall number
kernel/syscall.c
|
+-- add extern declaration
+-- add dispatch table entry
kernel/sysproc.c
|
+-- implement sys_hello or similar
user/user.h
|
+-- add user declaration
user/usys.pl
|
+-- add wrapper entry
user test program
|
+-- call new syscall这个实验很适合巩固:
- 用户态声明。
- wrapper 生成。
- syscall number。
- 内核分发表。
sys_*实现。- 返回值路径。
完成后,你会真正理解系统调用不是一个文件里的东西。
它是一条跨越用户态和内核态的协议。
27. 本篇总结
trap 是 xv6 中用户态和内核态的桥。
系统调用是用户程序主动进入内核的方式。
中断是设备或定时器让内核获得控制权的方式。
异常是当前指令出问题时进入内核的方式。
核心路径:
user wrapper
|
v
ecall
|
v
trampoline uservec
|
v
trapframe
|
v
usertrap
|
v
syscall
|
v
sysproc/sysfile
|
v
trapframe->a0
|
v
usertrapret
|
v
trampoline userret
|
v
sret读懂这条路径后,后面的 proc 就不再只是一个结构体。
你会知道:
proc 保存 trapframe。
trapframe 保存用户寄存器。
系统调用通过当前 proc 找到 trapframe。
返回值通过 trapframe 回到用户程序。
下一篇进入:
08-process-and-proc-structure.md这是整个 xv6 专题的核心之一。
我们会把 struct proc、proc[NPROC]、进程状态、父子关系、trapframe、context、pagetable、ofile、cwd 全部拆开。