XV6学习(3)Page tables
页表是操作系统中非常重要的一部分,用于将虚拟地址转化为物理地址。虚拟内存是操作系统实现进程隔离的关键技术。
在 XV6 中通过 RISC-V 的页表机构完成了虚拟地址向物理地址的转换。
分页硬件机构
XV6 运行于 Sv39 RISC-V 上,64 位地址中的低 39 位被使用。RISC-V 的页表逻辑上是 page table entries (PTEs) 的数组,长度为 2^27。PTE 包含 44 位物理地址号(PPN)。页的大小为 4KB,因此,分页硬件使用 39 位中的高 27 位查找 PTE,之后转化为 56 位的物理地址。
而实际上,RISC-V 使用的三级页表,1 级页表为 1 页(4KB),包含 512 个 PTE。27 位页号中的高 9 位为一级页表,中间 9 位为 2 级页表,末 9 位为三级页表。
在 PTE 中,低 8 位为标志位,其中 PTE_V 代表地址是否有效,当访问无效页面时会触发page fault;PTE_U代表地址能否在用户模式被访问,如果未设置则页面只能在 supervisor mode 中访问。
为了使分页机构能够正常运行,操作系统必须设置satp
寄存器为1级页表的物理地址。
内核地址空间
XV6 每个进程拥有一个独立页表,同时内核也拥有一个页表用于描述内核地址空间。内核会将自身地址空间直接映射到物理地址上,来方便访问物理内存和硬件资源。内核地址空间定义在memlayout.h
中,如下图所示:
在QEMU中,0~0x80000000用于映射设备接口,而0x80000000(KERNBASE) ~ 0x86400000(PHYSTOP)为RAM。
有一小部分内核地址空间不是直接映射的,Trampoline 页面在地址空间最高的位置,随后是每个进程对应的内核栈,每个栈之间都有一个 Guard page ,该页的 PTE_V 设置为 0,用于避免缓冲区溢出。
如果内核栈使用直接映射的方法,那么 Guard page 相对应的物理内存中将会产生很多空洞,导致内存管理变得困难。
Code: 创建地址空间
与地址空间有关的代码主要在vm.c
文件中。pagetable_t
代表一级页表,实际数据类型是一个指针,指向页表的物理地址。
walk
函数是最核心的函数,该函数通过页表pagetable
将虚拟地址va
转换为PTE,如果alloc
为1
就会分配一个新页面。
kvminit
分析
kvminit
函数用于初始化内核页表,该函数在内核启动开启分页机制前被调用,因此是直接对物理地址进行操作的。函数首先通过kalloc
申请了一个页面用于保存一级页表。kalloc
函数就简单地从kmem.freelist
中取出一个空闲页面。
而kmem
结构体的初始化在kinit
中进行,该函数在kvminit
之前被调用。该函数首先初始化锁,之后使用freerange
函数将内核之后的全部空闲 RAM 以 4KB 为一页加入该链表。
void kinit()
{
initlock(&kmem.lock, "kmem");
freerange(end, (void*)PHYSTOP);
}
回到kvminit
函数,在申请到页表后,通过调用kvmmap
函数,将物理地址中的UART0
CLINT
等映射到内核页表中,完成了内核页表的初始化。
在kvminit
函数完成后,main
函数紧接着就会调用kvminithart
函数。在该函数中,使用MAKE_SATP
产生SATP
的值,将该值写入satp
寄存器中,之后使用sfence_vma
刷新 TLB,完成了虚拟地址转换的开启,之后代码中的地址就全部会通过地址转换机构进行转换。
#define SATP_SV39 (8L << 60)
#define MAKE_SATP(pagetable) (SATP_SV39 | (((uint64)pagetable) >> 12))
而在MAKE_SATP
中使用SATP_SV39
设置 MODE 域为 8,即开启 SV39 地址转换,如下图所示。
在kvminithart
函数执行完成后,就会调用procinit
函数,初始化所有进程结构体,对每个进程结构体申请两个页面作为内核栈,之后将该页面映射到内核地址空间的高位上。最后再次调用kvminithart
函数,刷新 TLB,使硬件知道新 PTEs 的加入,防止使用旧的 TLB 项。
sfence.vma
sfence.vma rs1, rs2
指令是一条特权指令,用于通知处理器页表的修改。rs1
指示了页表哪个虚址对应的转换被修改了;rs2
给出了被修改页表的进程的地址空间标识符(ASID)。如果两者都是x0
,便会刷新整个 TLB。
sfence.vma 仅影响执行当前指令的 hart 的地址转换硬件。当 hart 更改了另一个 hart 正在使用的页表时,前一个 hart 必须用处理器间中断来通知后一个 hart,他应该执行 sfence.vma 指令。这个过程通常被称为 TLB 击落。
在 XV6 中,两个地方使用了sfence.vma
指令,一个是上文提到的kvminithart
函数,另一个就是trampoline.S
中,当陷入内核以及返回用户态时会调用。
进程地址空间
每个进程拥有独立的地址空间,当进程切换时同时会对页表进行切换。XV6 进程地址空间从 0 开始到 MAXVA,即 256GB。
当进程申请内存时,内核就会先调用kalloc
函数申请物理页面,之后构造PTE加入进程对应的页表项中。
在进程地址空间的最高位置为 trampoline,所有进程的该页面映射到同一个物理页面上。同样地,在用户栈的下方也设置了一个 guard page 来防止缓冲区溢出。
Code: sbrk
系统调用char* sbrk(int)
用于增加或减少物理内存,当参数为正数时增加,负数时减少。sbrk
实际通过growproc
进行,growproc
调用uvmalloc
或uvmdealloc
完成工作。
进程地址空间是从 0 开始连续向上增长的,因此通过
proc.sz
获取已分配字节数,就可以计算得到当前已分配空间的顶部地址,之后就可以得到对应的页面地址。
uvmalloc
函数先计算需要申请的页面数,之后在进程地址空间顶部再申请所需的连续的页面。函数通过kalloc
申请物理页面,之后使用mappages
函数映射到进程页表中。
uvmdealloc
函数先计算需要减少的页面数,之后通过uvmunmap
删除页面。在uvmunmap
函数内部通过walk
获取对应 PTE,将PTE_V设置为0,最后通过kfree
函数将该物理页面添加到空闲链表中。
Code: exec
系统调用exec
用于创建进程地址空间。函数首先使用namei
获取可执行文件,读取 ELF 头,检查 ELF 中的 magic。之后使用proc_pagetable
创建进程页表。
在proc_pagetable
函数中,先使用uvmcreate
函数申请一个页面,之后将 trampoline 和 trapframe 映射到高位地址空间中。
exec
之后使用uvmalloc
申请内存空间,再使用loadseg
函数将程序加载到对应页面中。在 Program Header 中描述了各段的 filesz,memsz等信息,当 filesz 小于 memsz 时,中间的空隙用 0 填充(如C语言中的全局变量)。
程序加载完成后,再申请两块页面,第一块为 guard page ,使用uvmclear
函数将该页面PTE_U设置为0,即不允许 user mode 访问。第二页设置为进程的栈。然后将argc
、argv
和返回地址压栈,完成栈的准备工作。
最后,exec
函数更新进程结构体,将旧页表释放。
在 Program Header 的vaddr
中,程序可以指定被加载到的虚拟地址,而这可能是危险的,因此在exec
中会检查if(ph.vaddr + ph.memsz < ph.vaddr)
,避免发生加法溢出。
实际操作系统
在 XV6 中,内核直接加载到 0x80000000 的位置上,而在实际操作系统中,一般会使用 kaslr 技术,即内核地址随机化,使攻击者不能直接通过反汇编获取内核变量和函数的地址。
在 RISC-V 中支持 super pages,即大小为4MB的页面,用于降低大内存机器上的页表开销。
XV6 也缺少类似于 malloc 的机制来减少使用sbrk
大量分配小对象的开销。