NJUOS-19-Xv6上下文切换

本文最后更新于:1 年前

去看看视频,这两节课都是昂!!!

  • xv6 的系统调用实现:大家听得一头雾水,但留了个印象
    • ecall 指令:跳转到 trampoline 代码
    • 保存所有寄存器到 trapframe
    • 使内核代码能够继续执行

本次课回答的问题

  • Q: 为什么要这么做?

本次课主要内容

  • 上下文切换的原理与实现

处理器的虚拟化

今天借助代码回答一个根本性的问题

为什么死循环不能使计算机被彻底卡死?

原理上

  • 硬件会发生中断 (类似于 “强行插入” 的 ecall)
  • 切换到操作系统代码执行
  • 操作系统代码可以切换到另一个进程执行

实际上

  • 到底是如何发生的?
    • 上一次课调试了代码,有了第一印象
    • 今天再补充一些细节

1
2
3
int main(){
while(1);
}

在我们的应用程序(用户程序),其实还好,不会对操作系统进行破坏。但是如果是操作系统里面出现这样的bug,由于操作系统本身具有对于系统的整体的控制权,导致很危险!!!

  • 程序的类别:

image-20230107192723000

  • 操作系统-管理程序状态机的集合:

image-20230107192934353

  • OS的分时复用:

image-20230107193205020

虚拟化:操作系统做的所有的事情,我的进程是看不到的。例如我在shell里面,编程模型里面,我是拥有计算机所有的资源的昂(寄存器和内存等),虚拟出了一个操作系统世界。

热身:协程库

1
2
3
4
def positive_integers():
i = 0
while i := i + 1:
yield i

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 指定一个 “内核栈”
    • 这块空间可以顺便用来保存寄存器
      • 参考 AbstractMachine (trap64.S; x86-qemu.h)

xv6 (不限于 RISC-V)

  • 把进程的 trap frame 分配到固定地址 (通过 $stap)
    • trap frame 保存在 $sscratch
  • 保存完毕后切换到内核线程执行 (包括堆栈切换)

再次调试系统调用

ecall 指令的行为

  • 关闭中断
  • 复制 $pc 到 $sepc
  • 设置 $sstatus 为 S-mode,$scause 为 trap 的原因 (ecall, 8)
  • 跳转到 $stvec (S-mode trap vector)

img

ecall 时额外的系统状态

  • $satp 控制了 “虚假” 的地址空间
    • 进程访问内存时仿佛戴了 VR
  • $sscratch 保存了进程的 trap frame 地址
    • 均由操作系统设置

image-20230107210816987

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
  • 操作系统可以把任何一个状态机 “加载” 回 CPU
    • 恢复寄存器和 $satp,然后 sret (保持 invariant, 包括 $scratch)

因为被封存,我们的处理器可以选择把任何一个状态机恢复

  • 机制:允许在中断/异常返回时把任何进程加载回 CPU
  • 策略:处理器调度 (下次课)

总结

本次课回答的问题

  • Q: 操作系统是如何完成进程之间的切换的?

Take-away messages

image-20230107212331945

进程切换需要OS介入,整个切换过程消耗较多的CPU时间,这个时间可以执行几百个普通指令

进程切换的性能,不用考虑,一面中cpu能执行10几个G条语句

  • “操作系统是中断处理程序
    • ecall 后执行 trampoline 代码 (操作系统控制)
    • 进入系统调用后,就完全是状态机 (取 mem[$pc] 指令执行)
  • “操作系统是状态机的管理者”
    • 操作系统持有所有物理页面 (通过 $stap 任意映射)
    • 用数据结构 (struct proc) 表示进程对象
      • 进程的页面 (包括 trapframe) 实现状态的封存
    • ecall → invariant (状态机被封存) → schedule → sret
    • “操作系统给CPU戴上VR眼睛,CPU完全被操作系统控制住了。操作系统能看到的东西,就是进程能看到的东西。虚拟化!!!”

虚拟化的结果 ->

对于进程来说,进程独占CPU执行。

对于CPU来说,CPU只能看到进程看到的东西,当前只执行一个进程,被进程绑定住了。

References

  1. vedio link: https://www.bilibili.com/video/BV1eA4y1Q76T/?spm_id_from=333.999.0.0&vd_source=ff957cd8fbaeb55d52afc75fbcc87dfd
  2. ppt link: https://jyywiki.cn/OS/2022/slides/19.slides#/
  3. Xv6官方手册:https://pdos.csail.mit.edu/6.S081/2020/xv6/book-riscv-rev1.pdf
  4. Trap: 陷入的意思

NJUOS-19-Xv6上下文切换
https://alexanderliu-creator.github.io/2023/01/07/njuos-19-xv6-shang-xia-wen-qie-huan/
作者
Alexander Liu
发布于
2023年1月7日
许可协议