重读 xv6(III)

xv6 虚拟内存

四 #

我们希望对用户隐藏底层物理空间,并提供无限大的虚拟空间假象,这就是虚拟内存的想法

物理内存 #

源码文件:kalloc.c

xv6 采用链表组织内存块,初始化时,将可用内存按固定分块加入链表

运行时通过 kalloc、kfree 获取其物理地址,再绑定到 pagetable 映射,便可以被用户进程使用了

另起一个超大内存块的链表,进行分类讨论,就可以实现扩展的 superpage 特性

空间结构 #

源码文件:memlayout.h

解决物理地址的映射问题后,我们便只需要关注虚拟内存了,这个空间是如何组织的呢?

虚拟空间由以下部分组成:

  1. 用户程序与数据
  2. 用户栈,固定大小
  3. 堆,用于动态分配空间
  4. TRAMPOLINE、Trapframe,用于 Trap 到内核态

用户栈是由 exec 负责分配的,此处按下不表

用户视角下的空间
用户视角下的空间

地址转换 #

源码文件:vm.c

这种虚拟地址(virture address)的转换,必须有硬件做支持,事实上我们有一个专门用来记录页表地址的寄存器,这样用户引用 va 时,硬件可以自动完成地址翻译

因此我们只需要关心如何安排页表映射即可

xv6 以 mappage 与 walk 作为该逻辑的核心

  1. walk 按偏移量逐层查找多级页表
  2. mappage 将内存段逐页加入页表映射

xv6 的页表设计:

  1. 三层
  2. 存储 物理地址 与 读写权限位
  3. satp 寄存器记录页表地址

权限位的设计,提供了极大的扩展性,我们可以添加自定义的标记以支持新功能
例如可以增加 PTE_S 权限位,只遍历前两层,用于指代 superPage

应用层 #

以 kalloc 与 walk-mappage 逻辑为基础设施,我们便可以实现常用的辅助函数:

  1. uvmalloc/uvmdealloc 申请与释放堆空间
  2. uvmcopy 复制 fork 进程的内存空间
  3. uvmmap/uvmunmap 插入或移除 page 映射
  4. uvmcreate 申请用户页表

现在我们就理解,II 中创建进程的 allocproc 在做什么了

  1. 申请页表
  2. 插入 trap 与 kernel stack 映射

申请堆空间,kalloc 后更新映射与 size 计数即可

而 fork 就是复制页表后,逐页将其内容 copy 到新进程

fork 时,我们当然可以现在一股脑把物理地址的内容也复制过去,但也有更好的做法

由于 va 机制是基于映射的,可以在映射上面做手脚,让两个不同的 va 指向同一块物理地址,真正写入时再进行区分,这就是 copy-on-write 的思想

内核特权 #

虚拟内存是针对用户而言的,那么 kernel 视角下的内存是什么样的呢?

按理说 kernel 拥有更高权限,应该能直接操作物理地址,但只有 machine mode 才能做到这一点

kernel mode 仍旧使用 页表,只不过我们将物理地址进行直接映射,把 x 映射到了 x

因此 kernel 可以直接访问设备、内核程序等内容,无缝运行内核代码

内核页表的映射,会将 PTE_U 置为禁止用户访问,防止用户通过漏洞恶意访问内核地址

举个例子,一般我们会将页表存入 TLB 缓存,方便读取,如果切换回用户态时,忘记清理 TLB 缓存,那么用户就能通过过期的kernel 映射,直接访问到内核的真实物理地址

此外,kernel 为 trapframe 与 kstack 提供了额外的映射,便于共享内存

这两个额外的 va 都放在 MAXVA 顶部,其中 trap 映射是为了所有进程能够共享同一段只读程序,kstack映射则是为了在中间插入虚拟的guardPage,一旦访问到 guard 就会引发异常报错

这里的一个技巧是假映射,通过让用户访问无效映射,引发 page fault

内核视角下的空间
内核视角下的空间

重新启动 #

检查完整个虚拟内存的设计后,我们回头看 main.c/kvminit 等初始化函数到底做了什么

  • kinit 将所有空闲的物理内存块都塞入 kalloc 链表中,用于分配堆
  • kvminit 申请 kernel 页表,加入直接映射,分配内核栈映射,用于 kernel 运行
  • kvmhartinit 将页表寄存器 %satp 替换为 kernel pagetable,启动地址转换功能

由于 kernel 采用的是直接映射,所以启动地址转换后,程序计数器的内容没有改变,程序流并不受影响,完成无缝切换

事实上,kernel mode 比 machine mode 的内容更加丰富,增加的映射是针对系统的优化

第一发现人 #

源码文件:exec.c

我们已经谈论过如何请求新的进程资源、复制一个现成的进程,可还是不明白,用户程序是怎样被加载到进程中的?

这就是 exec 的工作,它是一切的开端,我们调用 exec init 才有了第一个进程的出生

exec 的流程:

  1. 读取程序文件
  2. 载入 elf header,检查文件格式
  3. 申请新页表,将 program 内容载入到内存中
  4. 申请用户栈,将 args[] 压入栈,设置 guard page 的 PTE_U 为不可读
  5. 修改栈指针 %sp 与返回地址 %epc,重定向程序流
  6. 覆盖、释放原进程的页表空间

ret 后,程序将从 elf 文件定义的起点开始执行,完成程序镜像的替换

exec 修改了程序流与内存空间,但是并不影响 pid 这些进程状态

至此我们理解了一个用户任务是如何载入 xv6 ,并参与 CPU 调度的


这一节我们回答了以下问题:

  1. 各个视角下的虚拟内存是怎么组织的?
  2. xv6 如何动态分配内存?
  3. xv6 如何管理页表资源?
  4. 地址转换的透明化是由硬件完成的
  5. 栈、堆、代码,这些资源分别是在何时映射到 xv6 进程中的?
  6. vm.c 中的接口如何被上层所使用?
  7. 一块物理内存,从申请到释放,它的生命周期是怎样的?