重读 xv6(III)

xv6 虚拟内存

#

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

物理内存 #

源码文件:kalloc.c

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

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

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

空间结构 #

源码文件:memlayout.h

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

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

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

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

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

地址转换 #

源码文件:vm.c

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

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

xv6 以 mappagewalk 作为该逻辑的核心

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

xv6 的页表设计:

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

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

应用层 #

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

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

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

  1. 申请页表
  2. 插入 trapkernel 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 为 trapframekstack 提供了额外的映射,便于共享内存

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

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

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

重新启动 #

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

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

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

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

第一发现人 #

源码文件:exec.c

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

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

exec 的流程:

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

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

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

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


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

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