Chapter3

Page tables

页表


Paging hardware

分页硬件

首先需要明确的是:RISC-V指令(包括kernel和user)操作的都是虚拟地址,机器的RAM或者物理内存则通过物理地址进行索引。RISC-V的page table hardware通过将每个虚拟地址映射到物理地址来连接它们。

xv6运行在Sv39 RISC-V上,这表明它只用64-bit虚拟地址的底部39位,剩下的25位不用。在这种配置中,RISC-V的page table在逻辑上是一个包含$2^{27}$个页表项(page table entries, PTEs)的数组。每个PTE包含一个44位的物理页号(physical page number,PPN)和一些标志位。分页硬件通过使用39位中的顶部27位来索引页表以找到一个PTE,并生成一个56位的物理地址,其顶部44位来自PTE中的PPN,底部12位则从原始的虚拟地址中复制而来。

页表使操作系统能够以4096字节对齐的块为单位控制虚拟地址到物理地址的转换,这样的块称为page

如上图所示,实际的转换过程分为三个步骤。页表以三级树的形式存储在物理内存中。root是一个4096字节的页表,包含512个PTE(这受限于后面的高9位),这些PTE包含树中下一级页表页的物理地址,每个页表页又包含512个PTE,就这样构成三层的树状结构。分页硬件使用27位中的高9位选择根页表的页中的PTE,中间9位用于选择树中下一级页表的页中的PTE,低9位选择最终的PTE。

如果这三个PTE中任何一个不存在,分页硬件都会引发一个页面错误异常,将由内核来处理这个异常。

每个PTE包含标志位,这些标志位告诉分页硬件如何使用相关的虚拟地址。PTE_V指示PTE是否存在,如果未设置,引用该页面会导致异常;PTE_R控制是否允许指令读取该页面;PTE_W控制是否允许指令写入该页面;PTE_X控制CPU是否可以将页面内容解释为指令并执行;PTE_U控制是否允许用户模式下的指令访问该页面,如果未设置PTE_U,则PTE只能在监管模式下使用。

内核会通过把根页表的页的物理地址写入satp寄存器来告诉硬件使用页表。每个CPU都有自己的satp,CPU使用它自己的satp指向的页表来转换后续指令生成的所有地址。

一些解释:物理内存指DRAM中的存储单元,一个字节的物理内存有一个地址,称为物理地址。指令只使用虚拟地址,分页硬件将虚拟地址转换为物理地址,然后发送到DRAM硬件进行读取或写入。而虚拟内存是内核提供的用于管理物理内存和虚拟地址的抽象和机制的集合。


Kernel address space

内核地址空间

xv6为每个进程维护一个页表,描述它的用户地址空间,再加上一个描述内核地址空间的单页表。内核负责其地址空间的布局,以便在虚拟地址上访问物理内存和各种硬件资源。

QEMU模拟的计算机中,包含从物理地址0x80000000至少到0x86400000的RAM,xv6称为PHYSTOP;模拟还包括磁盘接口等I/O设备,QEMU将设备接口暴露给软件,当作物理地址空间0x80000000以下的内存映射设备的寄存器,内核可以通过读写这些特殊的物理地址与设备交互,这些操作是与设备硬件通信。

内核通过直接映射访问RAM和内存映射设备的寄存器。内核本身位于虚拟地址空间与物理内存中的KERNBASE,直接映射简化了读取或写入物理内存的内核代码。例如:fork为子进程分配用户内存时,分配器返回该内存的物理地址;fork在将父进程的用户内存复制到子进程时,直接将该地址用作虚拟地址。

但也有几个内核虚拟地址不是直接映射的:

  • The trampoline page:它被映射到虚拟地址空间的顶部,用户页表也有相同的映射;

  • The kernel stack pages:每个进程都有自己的内核栈,它被映射到高位,以便xv6可以在其下方留下一个未映射的防护页。防护页的PTE是无效的(PTE_V未设置),因此如果内核溢出内核栈,可能会导致异常并使内核崩溃。如果没有防护页,溢出的栈就会覆盖其他内核内存导致操作错误。在操作系统设计中,崩溃比可能的错误操作更好。

内核通过权限PTE_R和PTE_X映射trampoline pagekernel text,内核从这些页面读取并执行指令;通过权限PTE_R和PTE_W映射其他页面,以便能读取这些页面中的内存。但guard pages的映射是无效的。


Code: creating an address space

代码:创建一个地址空间

大部分用于操作系统空间和页表的xv6代码位于kernel/vm.c中。

核心数据是pagetable_t,它实际上是指向RISC-V根页表页的指针。pagetable_t可以是内核页表,也可以是每个进程的页表之一。

核心函数是walk,它负责查找虚拟地址的PTE,以及前两级PTE映射的页表页。

kvm开头的函数操作内核页表,以uvm开头的函数操作用户页表,其他函数二者均可。copyoutcopyin是用于实现用户态与内核态数据交换的核心通道。它们都需要显示转换地址来找到相应的物理内存。

copyout用于内核到用户空间的拷贝:

其中,PGROUNDDOWN是一个重要的地址对齐宏,定义在kernel/riscv.h中:

xv64KB页,故地址对齐要求地址必须是4096的整数倍,即要求对齐地址的二进制低12位全部为0。同时有PGSIZE=4096=0x1000,实际计算过程如下:

掩码即用于将低12位全部清零。

copyin用于用户到内核空间的拷贝:

在启动序列的早期,main首先调用kvminit创建内核的页表。

这个调用发生在xv6在RISC-V上启用分页之前,因此地址直接引用物理内存。kvminit首先分配一页物理内存来保存根页表页,然后它调用kvmmap来安装内核所需的地址转换。这些转换包括内核的指令和数据、物理内存直到PHYSTOP,以及属于设备的内存范围。

构造的内存布局如下:

kvmmap调用mappages,将虚拟地址到物理地址的映射加入页表中。

mappages对范围内的每个虚拟地址分别进行映射,以页为单位。对于每个要映射的虚拟地址,mappages调用walk来找到该地址的PTE地址,然后它初始化PTE以保存相关的物理页号以及相关的权限参数,并将PTE_V标记为有效。

walk函数模仿RISC-V的分页硬件寻找虚拟地址的PTE,它逐级下降三级页表,每次使用9位虚拟地址,使用每一级的9位虚拟地址来查找下一级页表或最终页面的PTE。如果PTE无效,则所需的页面尚未分配,如果alloc参数被设置,则walk会分配一个新的页表页并将其物理地址放入PTE中。walk返回树中最底层的PTE地址。

上述代码依赖于物理内存直接映射到内核虚拟地址空间。在它逐层下降页表时,它从PTE中获取下一级页表的物理地址,然后使用该地址作为虚拟地址获取下一级的PTE。

main调用kvminithart来设置内核页表,这个函数是xv6启用虚拟内存的核心操作。它将根页表页的物理地址写入寄存器satp。此后,CPU将用内核页表来转换地址。

main中随后调用procinit函数,这个函数负责为每个进程分配一个内核栈,它将每个栈映射到由KSTACK生成的虚拟地址,并留出保护页。kvmmap将映射的PTE添加到内核页表中,调用kvminithart将内核页表重新加载到satp中,以便硬件知道新的PTE。

内存布局示例(NPROC=3):

RISC-V的CPU在Translation Look-aside Buffer(TLB)中缓存页表项,当xv6更改页表时,它需要告诉CPU使得相应的缓存TLB失效。RISC-V有sfence.vma指令刷新当前CPU的TLB。xv6在重新加载寄存器后在kvminithart中执行sfence.vma,并在切换到用户页表后返回到用户空间前在trampoline代码中执行。


Physical memory allocation

物理内存分配

内核必须在运行时为页表、用户内存、内核栈和管道缓冲区分配和释放物理内存。xv6使用从内核结束到PHYSTOP之间的物理内存进行运行时分配,它一次分配和释放整个4096字节的页面。内核通过追踪维护一个链表来获取页面是否空闲的情况。allocation从链表中移除一个页面,freeing将释放的页面添加到链表中。


Code:Physical memory allocator

代码:物理内存分配器

分配器相关的代码位于kernel/kalloc.c。其数据结构是一个可用于分配的物理内存页面的列表,每个空闲页面的列表元素是run,分配器将每个空闲页面的run结构存储在空闲页面本身中。

main函数调用kinit函数来初始化分配器。kinit将空闲列表初始化为包含kernel endPHYSTOP之间的每一页。

xv6假设机器有128兆字节的RAM,kinit调用freerange来通过每页调用kfree将内存添加到空闲列表中。一个PTE只能引用对齐在4096字节边界上的物理地址,因此freerange使用PGROUNDUP来确保它只释放对齐的物理地址。

分配器开始时没有内存,对kfree的调用为它提供了一些内存来管理。分配器对地址的操作主要为两种:将其视为整数进行算术运算或者将其用作指针来读写内存。

kfree首先将被释放的内存中的每个字节都设置为1,这会导致在释放内存后使用内存的代码会读取到垃圾数据而非旧的合法内容,这是一种保护性与安全性的考虑,希望垃圾数据可以使得代码尽早崩溃。

然后kfree将页面添加到空闲列表的前面,它将pa强制转换为指向run的指针,将空闲列表的旧起始位置记录在r->next中,并将空闲列表设置成r

kalloc移除并返回空闲列表中的第一个元素。


Process address space

进程地址空间

每个进程都有独立的页表,当xv6在进程间切换时,页表也会更改。进程的用户内存从虚拟地址0开始,可以增长到MAXVA(可见Figure 2.3),允许进程原则上寻址256G的内存。

当一个进程向xv6请求更多的用户内存时,xv6首先使用kalloc分配物理页。然后将指向新物理页的PTE添加到进程的页表中,xv6会在这些PTE中设置相关的参数标志。

这样设计主要有三个优点:

  • 不同进程的页表将用户地址转换为了物理内存的不同页,每个进程都有私有的用户内存。

  • 每个进程都将其内存视为从零开始的连续虚拟地址,进程的物理内存可以是非连续的。

  • 一个物理内存页面出现在所有地址空间中

trampoline页用于在处理系统调用或中断时,从用户态切换到内核态。而所有进程在切换到内核时都需要执行相同的trampoline代码,因此xv6将这个页映射到每个用户地址空间的相同虚拟地址。这样无论哪个页触发系统调用,都能跳转到同一个物理页中的代码。


Code:sbrk

Sbrk是进程用于缩小或增长其内存的系统调用,实现在kernel/proc.c/growproc中。growproc根据n的正负来调用uvmallocuvmdealloc

uvmalloc使用kalloc分配物理内存,并使用mappages将PTE添加到用户页表中。

uvmdealloc调用uvmunmap

uvmunmap调用walk查找PTE并使用kfree释放它们所引用的物理内存。

页表在xv6中不仅仅用于地址转换,还作为跟踪物理内存分配的数据结构。这意味着在释放内存时,系统必须遍历页表来找到所有相关的物理页并释放,这也体现在uvmunmap函数中。


Code:exec

Exec是创建用户部分的地址空间的系统调用。它从文件系统存储的文件中初始化地址空间的用户部分。Exec使用namei打开指定的二进制路径,然后读取ELF头。

xv6程序以ELF格式描述,定义在kernel/elf.h中,一个ELF二进制文件由一个ELF头struct elfhdr和一系列程序段头struct proghdr组成。每个proghdr描述了必须加载到内存中的应用程序部分

它首先检查文件是否包含一个ELF二进制文件,该文件以4字节的magic number0x7FELFELF_MAGIC开头。如果开头正确,则exec认为该文件格式良好。

然后它调用proc_pagetable分配一个没有用户映射的新页表,使用uvmalloc为每个ELF段分配内存,并使用loadseg将每个段加载到内存中。

loadseg使用walkaddr找到分配的物理地址,写入ELF段的每一页,并使用readi从文件中读取数据。

exec创建的第一个用户程序/init的程序段头如下:

LOAD段:uvmalloc会分配2136字节的物理内存,但仅从ELF文件中读取2112字节,剩余24字节将会被填零。

字段
说明

偏移

0x00000000000000b0

该段在ELF文件中的起始位置(相对于文件开头的字节偏移)。

虚拟地址

0x0000000000000000

该段应加载到进程虚拟地址空间的位置(用户空间通常从0开始)。

物理地址

0x0000000000000000

物理地址(通常与虚拟地址相同,除非启用特殊内存映射,xv6未使用此字段)。

对齐

2^3(即8字节对齐)

段在内存和文件中的对齐要求。

文件大小

0x0000000000000840(2112字节)

段在ELF文件中占用的实际数据大小。

内存大小

0x0000000000000858(2136字节)

段在内存中占用的总大小(包括未初始化的数据,如.bss段)。

标志

rwx

内存权限:可读(r)、可写(w)、可执行(x)。

接下来,exec分配并初始化用户栈,它仅分配一个栈页。exec将参数字符串逐个复制到栈顶,并在ustack中记录它们的指针。它在main函数接受的argv列表末尾放置一个空指针,ustack的前三个条目是虚拟的返回程序计数器,argcargv指针。

Exec会在栈页下面放置一个不可访问的页面,这样尝试使用超过一个页面大小的程序将会出错。这个不可访问的页面还允许exec处理过大的参数,这种情况下exec用于将参数复制到栈的copyout函数将注意到目标页面不可访问并返回-1

STACK段:仅用于指示栈的权限和对齐要求,实际栈空间由操作系统在运行时分配。exec会为用户进程分配一个物理页作为栈,并映射到用户虚拟地址空间的高端地址,栈下方还会放一个不可访问的页用于检测栈溢出。

字段
说明

偏移

0x0000000000000000

该段在ELF文件中无数据(栈不由文件内容初始化)。

虚拟地址

0x0000000000000000

栈的虚拟地址起始位置(通常由操作系统动态分配,此处为占位符)。

物理地址

0x0000000000000000

未使用(xv6中用户栈由内核分配)。

对齐

2^4(即16字节对齐)

栈的对齐要求。

文件大小

0x0000000000000000

栈空间不由文件内容初始化,因此大小为0。

内存大小

0x0000000000000000

栈大小由操作系统动态决定(xv6默认分配一个页,如4096字节)。

标志

rw

栈的权限:可读、可写(不可执行)。

在准备新内存映像时,如果exec检测到错误,它将会跳转到bad,释放新映像并返回-1Exec必须等待直到确定系统调用成功后才能释放旧映像,如果旧映像已被释放则系统调用无法向其返回-1

另外,exec根据ELF文件指定的地址将字节从ELF文件加载到内存中,用户或进程可以在ELF文件中放置任意地址。因此,exec的风险在于ELF文件中的地址可能指向内核导致崩溃或是内核隔离机制破坏。xv6进行多项检查来规避风险:

  1. 地址溢出检查:

    • 代码:if (ph.vaddr + ph.memsz < ph.vaddr)

    • 目的:防止用户构造的ELF段通过整数溢出指向内核空间。

    • 旧版本漏洞:旧xv6用户地址空间包含内核(但无权限),恶意程序可能通过溢出将数据写入内核地址。

  2. 内核与用户页表隔离:

    • RISC-V版xv6中,内核使用独立页表,用户页表仅映射用户空间。

    • loadseg 写入的是进程页表,无法直接修改内核内存。

  3. 栈保护页:

    • 在用户栈下方放置不可访问页,触发栈溢出时立即报错,而非覆盖其他内存。

  4. 参数边界检查:

    • copyout 在复制参数到用户栈时,检查目标地址是否有效。

Last updated