Overview

复习

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

本次课回答的问题

  • Q: 为什么要这么做?

本次课主要内容

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

处理器的虚拟化

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

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

原理上

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

实际上

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

热身:协程库

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

positive_integers() 并不是 “调用” 它执行

  • 而是返回一个 generator
  • generator 可以调用,到 yield 后 “封存” 状态
  • 我们用这个特性实现了 model-checker.py

我们同样也可以在 C 里这么做

  • call yield 切换到另一个执行流
    • 所有执行流共享内存
    • 拥有独立的寄存器和堆栈

复习:程序的状态

寄存器

  • 32 个通用寄存器 + \$pc
    • \$x0 (\$zero), \$x1 (\$ra), ..., \$x31

内存

  • \$satp “配置” 出的地址空间
    • QEMU: info mem 查看
  • 再次调试 initcode

持有的操作系统对象 (不可见)

  • 程序只能看见 “文件描述符” (系统调用返回值, a0, syscall.c)
  • 回顾 minimal.S

虚拟化:状态机的管理

寄存器组 (\$x0...\$x31, \$pc) 只有一份,物理内存也只有一份

  • 寄存器的虚拟化:我们可以把寄存器保存到内存
  • 内存的虚拟化:\$satp 的数据结构

操作系统代码最重要的 invariant (假设单处理器)

  • 操作系统代码开始真正 “处理” 系统调用/中断时,所有进程的状态都被 “封存” 在操作系统中
    • 可以通过 struct proc 里的指针访问 (struct trapframe)
    • 中断/异常处理的一小段代码需要保证这一点
    • 中断返回时,把进程的状态机 “恢复” 到 CPU

状态的封存:Trivial 的操作系统实现

用最直观的 “封存” 方式

  • 直接都保存到内存
    • 假设操作系统代码直接 “看到” 所有物理内存 (L1)
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)

ecall 时额外的系统状态

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

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

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

End.