3.1 Paging hardware#
- RISC-V 的指令, 不论是用户空间还是内核空间, 所操纵的均为虚拟地址.
- xv6 运行在 Sv39 RISC-V 架构上, 意味着 64 位的地址空间中只有低 39 位被使用. 这 39 位中低 12 位用于页内偏移 (一个物理页面的大小为 $2^12 = 4096$ 字节), 高 27 位用作三级页表的索引, 每一级分配 9 位, 因此一个页表页面中包含 $2^{9} = 512$ 个页表项 (page table entry, PTE), 每个页表项长 $2^{12 - 9} = 8$ 字节, 即 64 位, 其中 44 位用作物理页面号 (physical page number, PPN), 另有若干个比特位则被用作特殊的标志位, 用于指示该 PTE 的状态.
- 由于采用三级页表结构, 在最为倒霉的情况下单次访存需要依次加载二级页表页面, 三级页表页面, 以及最终的物理页面, 然后才是实际的 load/store 操作. 为了尽可能避免这种情况, RISC-V CPU 使用快表 (Translation Look-aside Buffer, TLB) 来缓存最近使用到的 PTE.
- 每个 PTE 中包含了若干个特殊的标志位:
PTE_V
: "V" 即 "valid", 表示该 PTE 是否有效 (是否指向一个物理页面); 如果并不有效, 那么访问该 PTE 将触发一次缺页异常 (exception).PTE_R
: 指示物理页面是否可读.PTE_W
: 指示物理页面是否可写.PTE_X
: 指示物理页面是否可执行.PTE_U
: 指示物理页面是否可在用户模式下被读, 写, 以及执行. 如果此标志位被置 0, 那么该物理页面可以在内核模式 (supervisor mode) 下被读, 写, 以及执行, 或者说该页面对内核来说是安全的. 如果此标志位被置 1, 那么该页面能够在用户模式下被读, 写, 以及执行, 同时也能在内核模式下被读和写, 但不能在内核模式下被执行 (为了保证安全性).
- 为了让 CPU 能够访问到页表, 内核必须将根页表的物理地址写入一个名叫 "satp" 的特殊寄存器. 每个 CPU 拥有自己独立的 satp 寄存器, 因而具有自己独立的地址空间.
- 内核一般会在虚拟地址空间和物理地址空间之间建立一个恒等映射 (当然这么做可能会导致物理地址空间中剩余一部分空间无法被使用, 毕竟虚拟地址空间的大小一般小于物理地址空间的大小), 这样内核就可以直接使用一般的 load/store 指令, 利用虚拟地址来读写 PTE 的内容.
3.2 Kernel address space#
- QEMU 一般假设计算机的主存 (RAM) 在物理地址空间中从地址
0x80000000
开始 (此即 xv6 中的 KERNBASE
常量的值), 并至少具有 128 MB 的大小 (即至少延伸至地址 0x88000000
(此即 xv6 中的 PHYSTOP
常量的值), 容易验证两者之差为 128 MB), 这一段地址空间中包含了内核自身的代码, 数据, 以及将来可分配给自身以及所有用户进程的所有物理页面. 在地址 0x80000000
的下方 (低地址方向) 是 I/O 设备所用的地址空间, 称作 "memory-mapped control registers". - 内核虚拟地址空间的倒数第一个页面被称为 "蹦床页面 (trampoline page)", 其中存储的是用户/内核模式的切换例程. 从该页面开始往低地址方向向下每隔一个页面分配一个 "内核栈页面 (kernel stack page)", 此即每个进程的内核栈页面. 相邻两个内核栈页面之间还额外留有一个 "哨兵页面 (guard page)", 所有这些哨兵页面的 PTE 都是非法的 (即根本就没有为这些哨兵页面分配物理页面), 这样当栈溢出时便将触发一个缺页异常并使得操作系统能够捕捉到该次栈溢出.
- 有趣的一点是由于所有物理页面 (
0x80000000
~0x88000000
) 已经被恒等映射至虚拟地址空间中, 而蹦床页面以及所有内核栈页面均从这些物理页面中进行分配, 因此我们可以看到同一个物理页面实际上能够被多次映射到虚拟地址空间中. - 由于哨兵页面需要在虚拟地址空间中进行指定, 因此内核一般将哨兵页面以及相关的内核栈页面放置在虚拟地址空间中的高地址处, 避免与物理地址空间建立了恒等的那一部分虚拟地址空间发生重叠. 如果不采用这种设计, 那么可以将哨兵页面放置在与物理地址空间建立了恒等的那一部分虚拟地址空间中, 但这样做就会导致该哨兵页面对应的物理页面变得无法使用, 即使我们根本无需为哨兵页面分配任何物理页面. 将哨兵页面放置在不与物理地址空间发生重叠的高位虚拟地址段便能够避免这一点, 提高内存利用效率.
3.3 Code: creating an address space#
- 由于每个 RISC-V CPU 使用快表对 PTE 进行缓存, 一旦切换了页表, 我们就需要告诉 CPU 清空 (flush) 快表中的所有条目. 如果不进行清空, 那么当前进程就有可能访问到前一个进程的物理页面, 这显然是错误的. RISC-V 中专门使用指令
sfence.vma
对 TLB 进行清空, 这一指令可以在 xv6 的与页表切换有关的代码中见到, 例如在 kvminithart
函数或是蹦床 (trampoline) 代码中.
3.4 Physical memory allocation#
- 内核使用一个单链表将所有空闲物理页面组织在一起, 其中第一个空闲物理页面从内核代码与数据区域开始 (注意并不是从
0x80000000
开始), 而最后一个物理页面位于 PHYSTOP
之前 (即最后一个物理页面的尾后地址就是 PHYSTOP
). 分配一个页面就是从链表中摘一个页面下来, 而回收一个页面就是将页面重新挂到链表中, 方便快捷.
3.6 Process address space#
- 由于进程不需要处理物理内存相关的细节, 每个进程的虚拟地址空间可以从地址
0x0
开始, 并最多延伸至 MAXVA
(其定义位于文件 kernel/riscv.h
中), 即最大 $2^{9 + 9 + 9 + 12 - 1} = 256$ GB 的虚拟地址空间. - 由于使用了虚拟地址将其与物理地址进行解耦, 这就带来了若干好处:
- 每个进程可以拥有其自己独立且私有的地址空间.
- 每个进程看到自己的虚拟地址空间都是连续的, 而实际映射到的物理页面完全可以是乱序且离散的.
- 同一个物理页面能够被重复映射至同一个虚拟地址空间的多个不同的页面中, 也能够被映射至多个虚拟地址空间的同一个页面中. 蹦床代码所在的物理页面就是一个例子——内核和所有进程均将蹦床代码所在的物理页面映射至各自虚拟地址空间的最后一个虚拟页面.
- xv6 中一个进程的内存映像分别由代码 (text), 数据 (data), 哨兵页面 (guard page), 一个大小仅为一个物理页面的用户栈 (stack), 堆 (heap), 陷入栈帧 (trapframe), 以及蹦床页面 (trampoline page) 构成. 其中用户栈使用一个哨兵页面进行保护, 如果用户栈发生下溢, 那么硬件将会触发一次缺页异常.
- 需要指出的是现实中的操作系统一般会在用户栈下溢的时候自动分配空间, 而不是使用一个哨兵页面进行限制.
3.8 Code: exec#
- 在加载可执行文件的过程中, 内核一般需要对可执行文件的合法性进行详细而周全的检查, 并且必须做到滴水不漏. 在 xv6 中这样的检查显然是无法做到的, 因此恶意程序完全可以利用 xv6 的系统漏洞来绕过 xv6 的隔离机制.
3.9 Real world#
- xv6 中假设 RAM 总是从
0x80000000
开始, 这一设计不可能在现实中的操作系统中出现, 现实中的操作系统一般会使用各种技术来避免固定的映射. - RISC-V 还支持所谓的 "超级页面 (super page)", 即大小为 4MB 的大页面. xv6 中并不使用这一特性.
- xv6 中没有实现类似
malloc
的动态分配内存的功能, 这使得 xv6 无法使用一些要求动态内存分配机制的复杂的数据结构.