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,如果alloc1就会分配一个新页面。

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 地址转换,如下图所示。
satp

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调用uvmallocuvmdealloc完成工作。

进程地址空间是从 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 访问。第二页设置为进程的栈。然后将argcargv和返回地址压栈,完成栈的准备工作。

最后,exec函数更新进程结构体,将旧页表释放。

在 Program Header 的vaddr中,程序可以指定被加载到的虚拟地址,而这可能是危险的,因此在exec中会检查if(ph.vaddr + ph.memsz < ph.vaddr),避免发生加法溢出。

实际操作系统

在 XV6 中,内核直接加载到 0x80000000 的位置上,而在实际操作系统中,一般会使用 kaslr 技术,即内核地址随机化,使攻击者不能直接通过反汇编获取内核变量和函数的地址。

在 RISC-V 中支持 super pages,即大小为4MB的页面,用于降低大内存机器上的页表开销。

XV6 也缺少类似于 malloc 的机制来减少使用sbrk大量分配小对象的开销。

posted @ 2020-12-21 10:32  星見遥  阅读(3950)  评论(0编辑  收藏  举报