Xv6 剖析
个人对 xv6 这个简易操作系统内核比较感兴趣,就想写一个剖析,看看 xv6 都是怎么实现这些功能的,坚持一周更一个话题
预备知识
可以直接读下面的话题,这里是便于读此文章时快速了解
关于 xv6
xv6 是一个基于 RISC-V 64 位架构 CPU 的类 Unix 简易操作系统
寄存器
通用寄存器
Register | ABI Name | Description | Saver |
---|---|---|---|
x0 | zero | Hard-wired zero | - |
x1 | ra | Return address | Caller |
x2 | sp | Stack pointer | Callee |
x3 | gp | Global pointer | - |
x4 | tp | Thread pointer | - |
x5-7 | t0-2 | Temporaries | Caller |
x8 | s0/fp | Saved register / frame pointer | Callee |
x9 | s1 | Saved register | Callee |
x10-x11 | a0-1 | Function arguments / return values | Caller |
x12-x17 | a2-7 | Function arguments | Caller |
x18-27 | s2-11 | Saved registers | Callee |
x28-31 | t3-6 | Temporaries | Caller |
在汇编中一般使用寄存器的 ABI 名字
还有 f 开头的寄存器,用于浮点数,没有列举出来
ra 保存当前函数的返回地址
s0/fp、sp 用于保存栈底和栈顶,注意 s0 和 fp 是同一个寄存器
tp 保存当前 CPU 核 id
a0 还用于保存函数返回值
- Saver
- 函数之间除了 a0 寄存器传递返回值外,应该不能互相影响,因此其他寄存器需要被保存下来,Saver 指定当前函数的寄存器是由调用函数还是被调用函数保存
- Caller:调用函数
- Caller 在函数开始就可以选择 Caller 类寄存器保存下来,一般只保存 ra,具体由编译器选择
- Callee:被调用函数
- Callee 只保存它会用到的 Callee 类寄存器,在返回到 Caller 前恢复
- 为什么要分开保存呢?
- Callee 类的寄存器不能确定下层函数会不会用到,而且随时会变化,每次调用函数前后都存取一遍,性能会降低很多,为了提高性能,由被调用者来保存更加合适
控制状态寄存器
CSR(Control Status Register)
- pc:指向下一条将要指向指令的地址
- Program Counter
- satp:保存一级页表的物理地址
- Supervisor Address Translation and Protection
- stvec:保存发生 trap 时跳转的地址
- Supervisor Trap Vector Base Address Register
- 用户模式下会指向 kernel/trapmpoline.S 的
uservec
- 管理者模式下会指向 kernel/kernelvec.S 的
kernelvec
- sepc:保存发生 trap 时 pc 的值,便于返回到用户进程
- Supervisor Exception Program Counter
- 内核可控制 sepc 让 sret 返回到适当的位置
- scause:记录发生 trap 的原因,内核根据这个做进一步处理
- Supervisor Cause Register
- 8 表示系统调用
- 其他表示错误或者中断
- sstatus:以 bitmap 形式保存一些控制信息
- Supervisor Status Register
- SPP:表示 trap 来自用户模式还是管理者模式,并用来告诉 sret 返回到哪个模式
- SIE:表示在管理者模式下是否允许中断,0 表示禁止,RISC-V 会推迟硬件中断
- sscatch:在内核态与用户态时起辅助作用
- 一般用来保存 a0
在 xv6 的 2020 版本用来保存 trapframe 地址
汇编指令
- csrr、csrw
- 用于读写 CSR
- sfence.vma
- 清空 TLB 缓存
- ecall(Environment Call)
- 将模式从用户模式更改为管理者模式(sstatus 的 SPP 位)
- 将 pc 寄存器保存到 sepc 寄存器
- 将 pc 寄存器改为 stvec 寄存器值
- 关闭硬件中断(将 sstatus 的 SIE 位设为 0)
- sret(Supervisor Return)
- 将模式从管理者模式更改为指定的模式(sstatus 的 SPP 位)
- 将 pc 寄存器改为 sepc 寄存器值
- 启用硬件中断(将 sstatus 的 SIE 位设为 1)
地址空间
- trampline:在内核页表和用户页表中都有映射,作为用户进程切换到内核的跳板,放在虚拟地址空间的顶部(0x3ffffff000),大小为一页
- trapframe:在用户页表中有映射,用于切换到内核时保存用户进程的上下文,放在 trampoline 下面(0x3fffffe000),大小为一页
其他
1 | struct cpu { |
- proc
- 保存当前 CPU 核运行的进程
- context
- 保存内核用于调度功能的线程的上下文
怎么在内核和用户进程间切换
在 RISC-V 中,有三种 trap
- 硬件中断
- 系统调用
- 异常
后两个也称为软件中断
在用户进程中发生 trap 时,需要陷入到内核中进行处理,处理完后内核会根据情况回到用户进程或者杀死用户进程,这其中就涉及到内核和用户进程间的切换
切换过程
发生 trap 时,硬件会执行以下操作
- 将 sstatus 中的 SIE 位清零,禁用硬件中断以防止干扰,如果这个 trap 是硬件中断,不会做以下操作
- 将模式从用户模式更改为管理者模式
- 将 pc 寄存器的值复制到 sepc 寄存器中
- 将当前模式(用户或者管理者)保存到 sstatus 寄存器的 SPP 位
- 设置 scause 寄存器的值反映 trap 原因
- 将 stvec 寄存器的值复制到 pc 寄存器中
此时 pc 指向 trampoline,开始执行,注意,此时页表寄存器并没有便,也就是说还使用着用户进程的页表
1 | uservec |
总结:
- 将用户进程的寄存器保存到 trapframe 中
- 切换内核栈,内核线程 id,内核页表
- 跳转到 kernel/trap.c 的
usertrap
函数
uservec
主要就是做好切换到内核的准备
1 | void |
总结:
- 把 stvec 改成 kernelvec 以处理发生在内核的 trap
- 保存用户进程的 pc 到 trapframe
- 根据 scause、devintr 处理 trap
- 检查是否计时器中断,若是则进行进程调度
- 检查进程是否被杀死,若是则退出
- 跳转到
usertrapret
函数
usertrap
主要就是根据 trap 类型处理 trap
1 | void |
总结:
- 保存内核页表、内核栈、CPU 核的 id
- 配置 sstatus 寄存器
- 调用
userret
并传递用户页表
usertrapret
主要是处理 trap 后为返回到用户进程做准备
1 | globl userret |
userret
主要是恢复用户进程的上下文,回到发生 trap 的位置(或者发生 trap 位置后面一个指令,比如 ecall
后面的指令)
省流小结
真省流吗?
用户进程 -> uservec
-> usertrap
-> usertrapret
-> userret
-> 用户进程
- 发生 trap 时,程序会跳转到 stvec 寄存器指向的 kernel/trampoline.S 中的
uservec
保存用户进程的上下文,并设置内核栈、内核线程 id、内核页表 - 然后跳转到 kernel/trap.c 中的
usertrap
对 trap 进行处理 - 如果要恢复到用户进程,会跳转到 kernel/trap.c 的
usertrapret
为返回到用户进程做准备 - 最后跳转到 kernel/trampoline.S 中的
userret
恢复用户进程上下文,回到发生 trap 的位置(或者发生 trap 位置后面一个指令,比如ecall
后面的指令)
怎么实现系统调用
在 user/user.h 我们可以看到系统调用函数的声明
1 | int fork(void); |
那么,就有一个问题,系统调用明明是要用 ecall
来使用,xv6 是怎么把系统调用做成一个函数,使得用户程序像调用函数那样调用系统调用?
怎么制作用户系统调用函数
我们可以在 user/usys.S 看到系统调用函数在汇编中的定义
1 | #include "kernel/syscall.h" |
可以观察到,每一个系统调用函数都只是将系统调用号传递给 a7,然后使用 ecall
调用系统调用,最后返回到上一层函数
而参数传递是用户程序在调用系统调用函数之前,会根据函数声明将参数传递给对应的寄存器,然后跳转到系统调用函数的地址执行
这个 user/usys.S 文件其实是由一个 perl 脚本 user/usys.pl 生成的,便于添加系统调用
好的,上面解释了系统调用函数的实现,下面就看看 ecall
到底做了什么来请求内核完成系统调用
怎么请求内核完成系统调用
ecall
是由用户进程主动陷入 trap 请求内核完成系统调用的汇编指令
它会使 scause 寄存器值设为 8,在 RISC-V 中它代表着系统调用
在切换到内核态后,跳转到 kernel/trap.c 的 usertrap
我们仔细看看 kernel/trap.c 的 usertrap
怎么处理系统调用的
1 | void |
usertrap
检查到是系统调用时,将 epc 的值加 4,以便返回时执行 ecall
后面的指令,然后跳转到处理系统调用的函数 syscall
1 | // 处理系统调用的函数的原型 |
syscall
会根据用户进程指定的系统调用号执行指定的系统调用处理函数
最后会经过 usertrapret
、userret
返回到用户进程
省流小结
ecall -> uservec
-> usertrap
-> syscall
-> usertrapret
-> userret
-> ecall + 4
关键在于 syscall
根据系统调用号调用对应的系统调用处理函数
怎么获取系统调用所需的用户参数
#todo
怎么实现可变参数 printf 并输出
这里介绍内核的 printf
(kernel/printf.c)
可变参数
其实可变参数是由 C 语言库和编译器来实现的
C 语言库中给出了 va_start
、va_arg
、va_end
接口,这里只简单介绍一下,想详细了解可参考末尾的链接,不过是 x86_64 架构,原理类似
va_list
:一个变量类型,variable arguments list 可变参数列表va_start(v, l)
:初始化可变参数列表,l 为函数的第一个参数名va_arg(v, type)
:从可变参数列表中取出一个参数,须指定参数类型va_end(v)
:结束时释放 va_list 内存
我们知道 RISC-V 前 8 个参数是放在 a0-7 寄存器中,它是怎么又从寄存器中取值又从栈中取值的
答案是编译器会将除 a0 以外的寄存器全部压到栈中,这样所有参数都是连续存储在栈中,便于取值,但是不要自己去取地址然后输出(笑),要用 C 语言库的接口
输出到硬件
每次输出时将一个字符传给硬件,硬件回显给控制台
内核中有 consputc
就是将字符传给硬件的函数,在 kernel/console.c 中
1 | void |
传递给硬件寄存器具体在 kernel/uart.c 的 uartputc_sync
函数中
1 | void |
printf
接下来看看 printf
代码实现
1 | void |
其实逻辑挺简单的
参考
揭秘X86架构C可变参数函数实现原理:其实这篇文章所说的 x86 指的是 x86_64
怎么成功创建一个进程
#todo
怎么实现页表的创建与更新
#todo
怎么实现虚拟地址映射
#todo