NJUOS-19-Xv6上下文切换
本文最后更新于:2 年前
去看看视频,这两节课都是昂!!!
- xv6 的系统调用实现:大家听得一头雾水,但留了个印象
- ecall 指令:跳转到 trampoline 代码
- 保存所有寄存器到 trapframe
- 使内核代码能够继续执行
本次课回答的问题
- Q: 为什么要这么做?
本次课主要内容
- 上下文切换的原理与实现
处理器的虚拟化
今天借助代码回答一个根本性的问题
为什么死循环不能使计算机被彻底卡死?
原理上
- 硬件会发生中断 (类似于 “强行插入” 的 ecall)
- 切换到操作系统代码执行
- 操作系统代码可以切换到另一个进程执行
实际上
- 到底是如何发生的?
- 上一次课调试了代码,有了第一印象
- 今天再补充一些细节
1 |
|
在我们的应用程序(用户程序),其实还好,不会对操作系统进行破坏。但是如果是操作系统里面出现这样的bug,由于操作系统本身具有对于系统的整体的控制权,导致很危险!!!
- 程序的类别:
- 操作系统-管理程序状态机的集合:
- OS的分时复用:
虚拟化:操作系统做的所有的事情,我的进程是看不到的。例如我在shell里面,编程模型里面,我是拥有计算机所有的资源的昂(寄存器和内存等),虚拟出了一个操作系统世界。
热身:协程库
1 |
|
positive_integers()
并不是 “调用” 它执行
- 而是返回一个 generator
- generator 可以调用,到 yield 后 “封存” 状态
- 我们用这个特性实现了 model-checker.py
操作系统层面,这个yield,就是操作系统自动帮助我们插入的。分时嘛,隔一段时间,就出发一次,yield一下,本质就是这样昂!!!
我们同样也可以在 C 里这么做
call yield
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
切换到另一个执行流
- 所有执行流共享内存
- 拥有独立的寄存器和堆栈
> Yield: snapshot and switch
>
> 把寄存器瞬间的现场状态保存下来,然后切换到另外一个寄存器现场。
## 复习:程序的状态
- 进程是什么?
> pmap可以看到进程的一些内容(内存映射)
>
> 进程如何切换?xv6可以看到昂,运行的进程和对应的代码昂!!! -> 看视频,34min的位置左右。
>
> 进程是用到的内存+寄存器组,假设内存无限大,只要保持寄存器组到内存,从内存恢复别的进程的寄存器组
![img](https://cdn.jsdelivr.net/gh/alexanderliu-creator/blog_img/img/202301071921879.png)
- 进程切换:
![image-20230107195752744](https://cdn.jsdelivr.net/gh/alexanderliu-creator/blog_img/img/202301071957783.png)
- xv6程序:
> 无非就是内存+寄存器!!!
寄存器
- 32 个通用寄存器 + \$pc
- \$x0 (\$zero), \$x1 (\$ra), ..., \$x31
------
内存
- \$satp “配置” 出的地址空间
- QEMU: info mem 查看
- 再次调试 initcode
------
持有的操作系统对象 (不可见)
- 程序只能看见 “文件描述符” (系统调用返回值, a0, syscall.c)
- 回顾 [minimal.S](https://jyywiki.cn/pages/OS/2022/demos/minimal.S)
---
- 程序的状态:
![image-20230107200301075](https://cdn.jsdelivr.net/gh/alexanderliu-creator/blog_img/img/202301072003105.png)
![image-20230107200335263](https://cdn.jsdelivr.net/gh/alexanderliu-creator/blog_img/img/202301072003307.png)
> CPU无非就是取出mem[$PC]执行!!!
## 虚拟化:状态机的管理
寄存器组 (\$x0...\$x31, \$pc) 只有一份,物理内存也只有一份
- 寄存器的虚拟化:我们可以把寄存器保存到内存
- 内存的虚拟化:\$satp 的数据结构
------
操作系统代码最重要的 invariant (假设单处理器)
- **操作系统代码开始真正 “处理” 系统调用/中断时,所有进程的状态都被 “封存” 在操作系统中**
- 可以通过 `struct proc` 里的指针访问 (`struct trapframe`)
- 中断/异常处理的一小段代码需要保证这一点
- 中断返回时,把进程的状态机 “恢复” 到 CPU
> 保存到 trapframe 里面然后根据 a7 寄存器执行系统调用顺便执行调度,下面这个就是封存!!!封存好了之后,跳转到操作系统代码,操作系统schedule,也就是从下面“封存”的状态机中,选一个出来!!!恢复状态到CPU上 -> 这就是我们所说的上下文切换!!!
>
> 上下文切换:操作系统是不应该“入侵”状态机的,状态机都应该封存下来,操作系统选择执行其他的状态机而已。
>
> Xv6具体的代码切换,视频58min左右,建议康康昂!
![image-20230107201332691](https://cdn.jsdelivr.net/gh/alexanderliu-creator/blog_img/img/202301072013734.png)
## 状态的封存:Trivial 的操作系统实现
- Trapframe:
![image-20230107204236214](https://cdn.jsdelivr.net/gh/alexanderliu-creator/blog_img/img/202301072042248.png)
> 可以看到,封存到对应的p的数据结构里面啦!!!“封存”这样子,才算理解透彻了!!!
用最直观的 “封存” 方式。
- **直接都保存到内存**
- 假设操作系统代码直接 “看到” 所有物理内存 (L1)
```c
struct page { int prot; void *va, *pa; }
struct proc {
uint64_t x1, x2, ... x31;
struct page pages[MAXPAGES];
};保存:把 x1, …, x31 保存到当前的 proc 即可
- 就满足了 “状态机封存” 的 invariant
恢复:把 pages 送到 $satp 对应的数据结构里
- 通常我们是把这个数据结构准备好,只要一个赋值就行
状态的封存:体系结构相关的处理
x86-64
- 中断/异常会伴随堆栈切换
- 通过 TSS 指定一个 “内核栈”
- 中断前的寄存器保存在堆栈上 (典型的 CISC 行为)
- 感受一下有多复杂
- 这块空间可以顺便用来保存寄存器
- 参考 AbstractMachine (trap64.S; x86-qemu.h)
- 通过 TSS 指定一个 “内核栈”
xv6 (不限于 RISC-V)
- 把进程的 trap frame 分配到固定地址 (通过 $stap)
- trap frame 保存在 $sscratch
- 保存完毕后切换到内核线程执行 (包括堆栈切换)
再次调试系统调用
ecall 指令的行为
- 关闭中断
- 复制 $pc 到 $sepc
- 设置 $sstatus 为 S-mode,$scause 为 trap 的原因 (ecall, 8)
- 跳转到 $stvec (S-mode trap vector)
ecall 时额外的系统状态
- $satp 控制了 “虚假” 的地址空间
- 进程访问内存时仿佛戴了 VR
- $sscratch 保存了进程的 trap frame 地址
- 均由操作系统设置
OS没有戴VR,它看到的就是真实的世界昂,真实的物理地址空间!!!可以访问所有的物理地址空间昂!!!
进程戴了一个VR,SATP指向了一个数据结构,也在物理内存里面。SATP指向的物理内存里面的数据结构,维护了一个映射,也就是右边标黄的那一部分(SATP就像是那个VR昂!!!)
进程不能访问SATP,Stvec之类的寄存器,这些都是系统寄存器。OS会把这些寄存器都配置好,给进程戴上VR,让进程以为,自己在独占CPU在执行。借助硬件的分页等机制啊,实现的(虚拟页表),trick!!!
Trampoline 代码完成的工作
把寄存器保存到 trap frame
- 全靠 (struct trapframe *)$sscratch 寄存器
切换到内核线程
- 堆栈切换: $sp ←
tf->kernel_sp
- 设置当前处理: $tp ←
tf->kernel_hartid
- 设置页表: $satp ←
tf->kernel_trap
- xv6: 与物理内存一一映射
- 通过 info mem 查看内核线程的地址空间映射
- 低位的内存是 PLIC (0xc000000) 和 UART (0x10000000)
- 物理内存一一映射 (A = Access, D = Dirty, xv6 中不使用)
- 跳转到处理程序
tf->kernel_trap
执行
调用 usertrap() 后的系统状态
所有进程都被 “封存”
- 通过
struct proc
就可以找到寄存器、内存、操作系统对象、…… - 进程对应的 “内核线程” 开始执行
- L2 - Kernel Multithreading
- 从另一个角度,“进程” 就是拥有了地址空间的线程
操作系统代码可以为所欲为
- 修改任何一个状态机
- 例如,执行系统调用
- 执行系统调用时可能发生 I/O 中断
- 将任何另一个状态机调度到处理器上 (userret)
小结:状态机的封存
在执行完 “寄存器现场保存” 之后
- 操作系统处于 “invariant 成立” 的状态
- 每个进程的状态机都被 “封存”
- 能被操作系统内核代码访问
- xv6:
struct proc
- xv6:
- 操作系统可以把任何一个状态机 “加载” 回 CPU
- 恢复寄存器和 $satp,然后 sret (保持 invariant, 包括 $scratch)
因为被封存,我们的处理器可以选择把任何一个状态机恢复
- 机制:允许在中断/异常返回时把任何进程加载回 CPU
- 策略:处理器调度 (下次课)
总结
本次课回答的问题
- Q: 操作系统是如何完成进程之间的切换的?
Take-away messages
进程切换需要OS介入,整个切换过程消耗较多的CPU时间,这个时间可以执行几百个普通指令
进程切换的性能,不用考虑,一面中cpu能执行10几个G条语句
- “操作系统是中断处理程序”
- ecall 后执行 trampoline 代码 (操作系统控制)
- 进入系统调用后,就完全是状态机 (取 mem[$pc] 指令执行)
- “操作系统是状态机的管理者”
- 操作系统持有所有物理页面 (通过 $stap 任意映射)
- 用数据结构 (struct proc) 表示进程对象
- 进程的页面 (包括 trapframe) 实现状态的封存
- ecall → invariant (状态机被封存) → schedule → sret
- “操作系统给CPU戴上VR眼睛,CPU完全被操作系统控制住了。操作系统能看到的东西,就是进程能看到的东西。虚拟化!!!”
虚拟化的结果 ->
对于进程来说,进程独占CPU执行。
对于CPU来说,CPU只能看到进程看到的东西,当前只执行一个进程,被进程绑定住了。