CH3 Page tables
os通过页表来给每个进程提供私有的地址空间和内存. 页表决定了什么是内存地址以及物理内存的什么部分可以被获取. 它允许将多个进程地址空间存放在同一个物理内存中. 同时也允许将相同内存映射到几个不同的地址空间(trampoline页).
Paging hardware
RISC-V指令(user和kernel)都操作虚拟地址, 而内存是由物理地址索引的. RISC-V页表在这两种地址之间建立关联(通过将虚拟地址映射到物理地址).
xv6只使用39 bits的虚拟地址, 还有25 bits未使用. 一个页表有2^27个页表条目(PTE, page table entry). 每个PTE都保存着44-bit的物理页号(PPN, physical page number)和一些标志位.
页表硬件将虚拟地址的前27(27/39)位用作索引页表, 后12位与通过前27位索引到的PTE中的PPN结合为56位的物理地址(一个页的大小为2^12=4096 bytes).
但是真正的翻译是分三步, 第一层页表的物理地址存放在satp
寄存器中, 第一层L2通过9 bits索引512个PTE, 每个PTE中的PPN保存着下一层页表的物理地址, 下一层也是一样, 知道最后一层PTE中存放的PPN是要翻译的最终物理地址.
如果查找序列中3个PTE任意一个PPN不存在, 那么会page-fault exception
. 由于通常大范围的PTE都为空, 三层查找可以大大节省物理内存(如果第一层只有一个PTE, 那么剩余的511个PTE中的PPN为空, 那么就不需要为其分配下一层页表, 这样就只需要2^16 * 2^16 + 2^6).
每个PTE有10位标志位, 用来告诉翻译硬件虚拟地址如何被允许使用.
- PTE_V: 表明是否PTE存在. 如果为0, 对该页的引用会造成异常.
- PTE_R: 指令是否允许读该页.
- PTE_W: 指令时候允许写该页.
- PTE_X: 是否cpu可以运行的指令.
- PTE_U: 是否在user模式下的指令可以获取页. 如果PTE_U未被设置, 那么PTE只能在supervisor模式下运行.
内核通过将根页表页的物理地址写入satp
寄存器来使用一个页表. 每个cpu都有自己的satp, 所以不同的cpu可以运行不同进程.
物理内存指的是DRAM中的存储单元, 每个字节的物理内存分配了一个地址. 指令只使用虚拟地址, 通过翻译硬件翻译为物理地址并发送到DRAM硬件来读写.
Kernel address space
每个进程都有一个页表来描述进程的用户地址空间, 以及只有一个页表来描述内核地址空间.
物理内存从0x80000000开始, 并至少到0x86400000结束(PHYSTOP
). 在0x80000000以下的物理地址映射着设备, qemu通过控制寄存器来控制这些设备. 内核可以通过读写这些特殊的物理地址来和设备交互, 这种读写是直接和设备硬件交流的而不是物理内存.
内核是直接映射的, 也就是说, 虚拟地址与物理地址是一样的(内核存储在虚拟地址和物理地址的KERNBASE
=0x80000000处). 直接映射简化了内核读写物理内存的代码.
但有些内核虚拟地址不是直接映射:
- trampoline页: 存放在虚拟地址空间的顶部(内核和用户有相同的结构). trampoline的物理页在内核虚拟地址空间映射两次, 一次直接映射, 一次在虚拟地址顶部.
- 内核的栈页: 每个进程有它自己的内核栈, 映射在内核虚拟地址的顶部, 所以内核栈之间可以通过未映射的guard page来隔离(PTE是无效的, 当越界时, 会异常).
内核的trampoline和text页都是PTE_R和PTE_X.
Code: creating an address space
大多数操作地址空间和页表的代码都在vm.c
(kernel/vm.c). pagetable_t(要么是内核页表, 要么是每个进程页表)是指向RISC-V根页表页的指针.
几个重要的函数:
-
walk
: 通过pagetable, 找到va对应的物理地址, 如果alloc为1, 那么为va映射对应的物理地址. -
mappages
: 在pagetable上映射va<->pa(新的PTE). 对于每个虚拟地址,mappages
一页页得翻译成物理地址. 先调用walk来找到可用的PTE, 再初始化PTE来保存相关的物理页号(PPN). -
copyin, copyout
: 从user虚拟地址复制数据/复制数据到user虚拟地址. -
kvminit
: 在boot阶段, main调用kvminit
来创建内核页表. 由于是在xv6可以翻译页表之前, 映射的页表是直接映射.kvminit
首先分配一个物理内存页来保存根页表页. 之后使用kvmmap
来翻译内核需要的物理内存(内核指令和数据). -
Kvminithart
: 装载内核页表. 通过将根页表页物理地址写入satp
寄存器, 之后 cpu将使用内核页表来翻译地址. -
procinit
: 通过main调用, 为每个进程分配一个内核栈. 通过KSTACK来产生虚拟地址, 将每个栈映射到该虚拟地址. 再通过kvmmap
将PTE映射到内核页表. 并调用kvminithart
来重新加载内核页表进satp
. -
sfence.vma
: cpu会缓冲页表条目进TLB(translation look-aside buffer), 所以当xv6改变页表时, 必须通过sfence.vma
来清除缓存.
物理内存分配
xv6在运行时, 会分配和释放从内核顶部到PHYSTOP(0x86400000)的物理内存. 一次分配和释放一页(4096bytes).
物理内存分配器
分配器在kalloc.c
中, 分配器是一个可分配物理内存页的空闲链表.
main调用kinit
函数来初始化分配器. kinit
通过初始化空闲链表来保存从内核顶部到PHYSTOP的内存(128MB).
kfree通过将要释放的内存中的每一个字节设置为1, 这会导致之后使用这段空闲内存的代码只能看到垃圾数据从而更快崩溃. 之后将pa
转化为run
指针, 并将其保存在空闲链表中.
进程地址空间
当一个进程需要内存时, xv6先使用kalloc来分配物理内存, 之后将指向新分配的物理内存的PTE加入到该进程的页表中, 并设置标志位.
几个页表特性:
- 不同进程的页表将user地址翻译到不同的物理内存.
- 每个进程都认为它的内存是连续的, 从0开始的.
- 内核将一个有trampoline代码的页映射到user地址空间的顶部, 所以trampoline这个页出现在所有的虚拟地址空间.
栈占一个页. 先是保存着args参数和指向args参数的指针, 接下来是允许程序从main开始运行的值. 为了检测栈是否溢出, 在栈下放置了一个guard页, 有效位设置为0.