一、本节任务
实验环境:
二、要点
如何防止程序破坏内核或其他进程空间?隔离地址空间,进程只能读写自己的内存空间。
在保证隔离的同时,如何将多个地址空间复用到一个物理内存上?虚拟内存/页表。操作系统通过页表来为每个进程提供自己的私有地址空间和内存。
2.1 分页硬件
页表为寻址提供了一个间接的层次,CPU 通过虚拟地址(VA)访存,MMU 将虚拟地址映射成实际的物理地址(PA),再通过实际的物理地址去访问 RAM,这样做的主要目的是为了隔离(isolation),每个进程都能有自己的地址空间。
内核通过页表(page table)来告诉 MMU 如何将虚拟地址映射成物理地址。
如果我们需要每个进程都有不同的地址空间,那么每个进程都需要有一个页表,内核通过写 MMU 的 satp 寄存器来切换页表。在切换进程时,内核也需要切换页表。
页表存在于内存中,satp 寄存器里面存放了页表在内存中的物理地址,MMU 通过 satp 寄存器从内存中加载页表项(page table entry, PTE)。
在 64 位地址的机器中,有 2^64 个不同的虚拟地址,假如页面大小为 4KB(12 位),则页表索引为高 52 位(64-12),低12 位则为页面偏移量。而页表的作用就是将高位的索引替换为实际物理地址的索引+低位的页内偏移去访问实际的物理内存。
页表项(PTE)有 64 位,其中 10 位保留,44位作为 PPN (physical page number),10 位作为标志位:
是否每个页表项(PTE)都直接映射到对应的物理地址?否,如果直接映射的话,页表会非常大,一般采取混合映射的方式,即直接映射加多级页表。
Xv6 运行在 Sv39 RISC-V 上,这意味着 xv6 只使用 64 位虚拟地址的低 39 位;未使用高 25 位。也就是说,RISC-V 页表逻辑上是由2^27个页表项(PTEs)组成的数组,每个PTE都包含一个44 位的物理页码(PPN)和一些标志。页硬件通过虚拟地址低 39 位的高 27 位(39 - 12 = 27)来找到对应的页表项,并且得到一个 56 位的物理地址,其顶部 44 位来自页表项中的 PPN,其底部 12 位从原始虚拟地址复制。
注:在上面的单极页表中,虚拟地址的 27 位只是索引,并不在页表项中占用空间,页表项中只包含物理页号和标志位。
在三级页表中,分页硬件使用 27 位中的前 9 位来在根页表页面中选择一个 PTE,中间的 9 位来在树的下一层的页表页面中选择一个 PTE,并使用最下面的 9 位来选择最终的 PTE。如果中间出现页表项不存在的情况,则引发缺页故障(page-fault exception)。
使用多级页表的好处就是可以节省页表所占用的空间,比如一个进程只用到了一个页面,那么除了一个根页表、一个二级页表、一个三级页表外,其他的空间都可以省略,等到需要用到的时候再分配。 缺点就是需要多次访存,如三级页表需要访存三次,取出对应的页表项。为了避免从内存中多次加载页表项,RISCV CPU 使用备用转换缓冲区(Translation Look-aside Buffer (TLB))来存放经常使用的页表项。
每个页表项都有对应的标志位:
- PTE_V 指示页表项是否存在,如果没有设置,对页面的引用将导致异常;
- PTE_R 控制是否允许将指令读取到该页面;
- PTE_W 控制是否允许将指令写入该页面;
- PTE_X 控制 CPU 是否可以将页面的内容解释为指令并执行它们;
- PTE_U 控制是否允许在用户模式下的指令访问该页面;
这些标志和所有其他与页面硬件相关的结构都在(kernel/riscv.h)中定义。
2.2 内核地址空间
Xv6 为每个进程维护一个页表,以描述每个进程的用户地址空间;还需要有一个用于描述内核地址空间的页表。内核配置其地址空间的布局,使自己能够在可预测的虚拟地址上访问物理内存和各种硬件资源。文件(kernel/memlayout.h)中声明了 xv6 内核布局中的一些常量,如 KERNBASE,PHYSTOP 等。
内核使用 “直接映射(direct mapping)” 获取 RAM 和内存映射的设备寄存器;即,虚拟地址等于物理地址。直接映射能简化内核读写内存,例如,当 fork 为子进程分配用户内存时,分配器返回该内存的物理地址;当它将父进程的用户内存复制到子进程时,fork 直接将该地址用作虚拟地址。
也有几个内核虚地址是没有直接映射的:
- The trampoline page:它被映射在虚拟地址空间的顶部;用户页表具有相同的映射。一个物理页面(holding the trampoline code)在内核的虚拟地址空间中映射两次:一次在虚拟地址空间的顶部,一次通过直接映射。
- The kernel stack pages:每个进程都有自己的内核栈,再每个进程内核栈的下面会有一个 Guard page,这个页面是无效的(PTE_V is not set),作用是如果内核栈溢出,会直接发生异常,避免栈溢出导致覆盖其他进程的内核栈。
虽然内核通过高内存映射使用其堆栈,但内核也可以通过一个直接映射的地址访问它们。但是这种安排中无法通过 Guard page 来提供保护。
2.3 创建地址空间的代码
大多数用于操作地址空间和页表的 xv6 代码都位于 vm.c(kernel/vm.c)中。
其中核心的数据结构为 pagetable_t,它作为一个指针指向根页表页面,可能是内核页表,也可能是用户进程的页表。
核心的函数为 walk 函数,该函数的作用是找到虚拟地址对应的页表项和为新的映射安装页表项。vm.c 中 kvm 开头的函数操作内核页表,uvm 开头的函数操作用户页表,其他函数则两者都可以。copyout 和 copyin 函数拷贝数据到用户虚拟地址和从用户虚拟地址拷贝数据。
在系统启动时,main 函数会调用 kvminit 函数,kvminit 函数又会调用 kvmmake 函数来创建内核的页表,此时 xv6 并未使能分页机制,所以直接使用物理地址。kvmmake 首先分配一页的物理内存来保存根页表,然后它会调用 kvmmap 来安装内核的指令和数据,以及实际上是设备的内存范围的转换。proc_mapstacks 为每个进程分配一个内核堆栈。它调用 kvmmap 来在 KSTACK 生成的虚拟地址上映射每个堆栈,这为 invalid stack-guard pages 留出了空间。
kvmmap 调用 mappages 来安装一系列虚拟地址到对应物理地址的映射(页表项)到页表中,它以页为间隔对范围内的每个虚拟地址分别执行此操作。
对于每个要映射的虚拟地址,mappages 调用 walk,以查找该地址的 PTE 的地址。然后,它会初始化PTE,以保存相关的物理页码。
walk 使用每个级别的 9 位虚拟地址来查找下一级页表或最后一个页面的 PTE。如果 PTE 无效,则表示尚未分配所需的页面(即未建立该虚拟地址到物理地址的映射);如果设置了 alloc 参数,walk 将分配一个新的页表页面,并将其物理地址放在 PTE 中。它返回树中最低图层中的 PTE 的地址。
上面的代码依赖于物理内存被直接映射到内核虚拟地址空间。例如,当 walk 降低页表的级别时,它从 PTE 中提取下一级页表的(物理)地址,然后使用该地址作为虚拟地址来获取下一级的 PTE。
main 函数调用 kvminithart 来装载内核页表,它会将根页表页面的物理地址写入 satp 寄存器,在这之后 cpu 会使用内核页表来转换地址。
2.4 物理内存分配
内核必须能在运行时分配和释放页表、用户内存、内核栈、和管道 buffer 的物理内存,xv6 使用内核尾部到 PHYSTOP 之间的内存作为运行时分配的内存。每次分配和释放一整个页面(4096B),并且使用链表结构来追踪空闲页面。
这部分代码位于 kernel/kalloc.c 中,结构体 struct run 用于指向可用的页面,kalloc 和 kfree 用于从 freelist 拿取或增添可用的页面,从而实现物理内存的分配与回收。freelist 由一个自旋锁所保护。
main 函数会调用 kinit 来初始化从内核尾部到 PHYSTOP 之间的所有物理页面。分配器有时将地址视为整数,以便对它们进行算术运算(例如,在 freerange 中遍历所有页面),有时使用地址作为读写内存的指针(例如,操作存储在每个页面中的 run 结构)。
2.5 进程地址空间
每个进程都拥有独立的页表,并且当 xv6 切换进程时,页表也要切换。
一个进程的用户内存开始于虚拟地址 0,并且能够增长到 MAXVA(kernel/riscv.h)。并且由程序的 text 段(xv6 使用 PTE_R、PTE_X 和 PTE_U 权限映射)、包含程序预先初始化的数据的页面、栈的页面、堆的页面,Xv6 使用权限 PTE_R、PTE_W 和 PTE_U 映射数据、栈和堆。
通过映射没有 PTE_X 的数据,用户程序不能随意地跳转到程序数据中的一个地址,并在该地址开始执行。
用户栈只有一个页面,并显示了由exec创建的初始内容,包含命令行参数的字符串以及指向它们的指针数组位于堆栈的最顶部。为了检测用户栈溢出,xv6 将堆栈的正下方放置一个不可访问的保护页面(guard page),并且清除 PTE_U 标志。
2.6 sbrk
sbrk 是为进程收缩或增加其内存的系统调用。该系统调用由 growproc 函数实现,growproc 再根据 n 是正数还是负数来使用 uvmalloc 或 uvmdealloc,并且使用 mappages 函数来添加页表项到用户的页表中。
2.7 exec
exec 为系统调用,它读取一个二进制或可执行文件并替换进程的用户地址空间,exec 首先使用 namei(kernel/exec.c:36)来打开 path 指向的二进制文件,然后读取文件的 ELF header(kernel/elf.h)。
ELF 二进制文件由一个 ELF header、struct elfhdr(kernel/elf.h),紧接着一系列 program section headers struct proghdr(kernel/elf.h)组成。每个 progvhdr 描述了必须加载到内存中的应用程序的一部分;xv6 程序有两个 program section headers:一个用于指令,一个用于数据。
一个 ELF 二进制文件以四字节的 “magic number” 0x7F、“E”、“L”、“F” 开头,即 7f 45 4c 46。exec 会先使用 readi 函数从该文件的 inode 中读出 elfhdr,然后查看 elfhdr 中的 magic 是否和 ELF_MAGIC 一致;然后使用 proc_pagetable 分配一个用户页表给当前进程,但并未进行用户内存空间映射,只对用户空间顶部的 trampoline code 进行了映射。然后读取后续的 proghdr,使用 uvmalloc 给每个段分配页面,最后使用 loadseg 来导入每个段到内存中。
exec 的后续代码会先分配两个页面,第一个页面作为 guard page,第二个页面作为用户栈,然后将 argv 中的参数先存储到栈中,再将参数的地址数组 ustack 存到栈中,再将 ustack 的地址存储到 p->trapframe->a1,return argc 表示将 argc 存放到 p->trapframe->a0(函数的返回值会存放到 a0 寄存器)。最后再保存进程的映像,如进程的新页表,进程的入口,栈指针等。
2.8 C 语言和汇编如何相互调用?
调用约定(Calling Convention)。
Base ISA: Program counter, 32 general-purpose registers (x0--x31)
- ra 寄存器需要调用者保存。
- sp 寄存器需要被调用者保存。
- t0-t6 临时寄存器需要调用者保存。
- s0-s11 保存寄存器需要被调用者保存。
- a0-a7 函数参数寄存器需要调用者保存。
在 rv32 和 rv64 中,int 类型都是 32 位,而 long 和 指针类型在 rv32 中是 32 位,在 rv64 则是 64 位。
三、Lab: page tables
3.1 Speed up system calls (easy)
一些系统调用将数据放到用户空间和内核空间之间的只读区域(即内核和用户都可以访问,但是用户只能读页面),从而绕过内核实现加速。而本实验就是要在 trapframe 下定义一个新的用户只能读的页放在 USYSCALL(USYSCALL 是一个虚拟地址,在 memlayout.h 中定义):
在该页的起始位置存放一个 struct usyscall 结构体(kernel/memlayout.h),里面存放当前进程的 pid, 因此可以提供一个 ugetpid() 函数给用户,该函数直接在用户态从该结构体中读出 pid,省去了 getpid() 系统调用切换到内核态的时间:
实现:
首先在 kernel/proc.h 中的 proc 结构体中定义结构体 usyscall 的指针 usyscall_page:
// Per-process state
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// wait_lock must be held when using this:
struct proc *parent; // Parent process
// these are private to the process, so p->lock need not be held.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // data page for trampoline.S
struct usyscall *usyscall_page;
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};
然后在 kernel/proc.c 的 allocproc 函数中为 usyscall_page 分配物理页面,并且为 usyscall 结构体的 pid 赋值:
// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc*
allocproc(void)
{
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == UNUSED) {
goto found;
} else {
release(&p->lock);
}
}
return 0;
found:
p->pid = allocpid();
p->state = USED;
// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// Allocate a usyscall page
if((p->usyscall_page = (struct usyscall *)kalloc()) == 0)
{
freeproc(p);
release(&p->lock);
return 0;
}
p->usyscall_page->pid = p->pid;
// An empty user page table.
p->pagetable = proc_pagetable(p);
if(p->pagetable == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// Set up new context to start executing at forkret,
// which returns to user space.
memset(&p->context, 0, sizeof(p->context));
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;
return p;
}
相应地,在 freeproc() 函数中需要释放该物理页面(不释放的话后面的 usertest 通过不了):
// free a proc structure and the data hanging from it,
// including user pages.
// p->lock must be held.
static void
freeproc(struct proc *p)
{
if(p->trapframe)
kfree((void*)p->trapframe);
if(p->usyscall_page)
kfree((void*)p->usyscall_page);
p->trapframe = 0;
if(p->pagetable)
proc_freepagetable(p->pagetable, p->sz);
p->pagetable = 0;
p->sz = 0;
p->pid = 0;
p->parent = 0;
p->name[0] = 0;
p->chan = 0;
p->killed = 0;
p->xstate = 0;
p->state = UNUSED;
}
在 kernel/proc.c 中的 proc_pagetable() 函数中使用 mappages() 函数创建虚拟地址 USYSCALL 到物理地址的映射,并将其作为页表项(PTE)存入当前进程的页表中,该页的权限位为 PTE_R | PTE_U,即只读,并且用户可以访问:
// Create a user page table for a given process, with no user memory,
// but with trampoline and trapframe pages.
pagetable_t
proc_pagetable(struct proc *p)
{
pagetable_t pagetable;
// An empty page table.
pagetable = uvmcreate();
if(pagetable == 0)
return 0;
// map the trampoline code (for system call return)
// at the highest user virtual address.
// only the supervisor uses it, on the way
// to/from user space, so not PTE_U.
if(mappages(pagetable, TRAMPOLINE, PGSIZE,
(uint64)trampoline, PTE_R | PTE_X) < 0){
uvmfree(pagetable, 0);
return 0;
}
// map the trapframe page just below the trampoline page, for
// trampoline.S.
if(mappages(pagetable, TRAPFRAME, PGSIZE,
(uint64)(p->trapframe), PTE_R | PTE_W) < 0){
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmfree(pagetable, 0);
return 0;
}
// 映射 USYSCALL 页面 below the trapframe page,read only
if(mappages(pagetable, USYSCALL, PGSIZE,
(uint64)(p->usyscall_page), PTE_R | PTE_U) < 0){
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmunmap(pagetable, TRAPFRAME, 1, 0);
uvmfree(pagetable, 0);
return 0;
}
return pagetable;
}
相应的,在 proc_freepagetable 也要解除该页的映射:
// Free a process's page table, and free the
// physical memory it refers to.
void
proc_freepagetable(pagetable_t pagetable, uint64 sz)
{
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmunmap(pagetable, TRAPFRAME, 1, 0);
uvmunmap(pagetable, USYSCALL, 1, 0);
uvmfree(pagetable, sz);
}
执行 make qemu,在 xv6 中运行 pgtbltest 会显示:
ugetpid_test starting
ugetpid_test: OK
3.2 Print a page table (easy)
此部分需要实现一个函数 vmprint,此函数能够递归打印涉及到的页表项,如下图所示:
这是打印 pid = 1 的进程的页表,首先打印页表地址,然后递归打印页表项和对应的物理地址。
代码实现如下,首先在 vm.c 中添加 vmprint() 函数,可以参考同文件下的 freewalk 函数,此函数作用为递归地释放非叶节点页表的空间:
static void
vmprintpte(pagetable_t pagetable, int depth)
{
for(int i = 0; i < 512; i++)
{
pte_t pte = *(pagetable + i);
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0)
{
for(int j = 0; j < depth; j++)
printf("..");
printf("%d: pte %p pa %p\n", i, pte, PTE2PA(pte));
uint64 child = PTE2PA(pte);
vmprintpte((pagetable_t)child, depth+1);
}
else if(pte & PTE_V)
{
// leaf page
for(int j = 0; j < depth; j++)
printf("..");
printf("%d: pte %p pa %p\n", i, pte, PTE2PA(pte));
}
}
}
// vmprint, Recursively print page-table.
void
vmprint(pagetable_t pagetable)
{
printf("page table %p\n", pagetable);
vmprintpte(pagetable, 1);
}
然后到 kernel/defs.h 中增加 vmprint() 函数的定义:
void vmprint(pagetable_t);
最后到 kernel/exec.c 的 exec 函数的 return argc 前加入如下代码,即如果进程为初始进程,则递归打印其页表:
// vmprint
if(p->pid == 1)
{
vmprint(p->pagetable);
}
使用 make qemu 启动 xv6 即可看到打印结果:
3.3 Detect which pages have been accessed (hard)
此部分需要我们实现 pgaccess() 函数,该函数用来确认传入的页面是否被访问过。这个函数需要三个参数,第一个参数为用户传入的页面的起始虚拟地址;第二个参数为需要检查的页数;第三个参数为一个地址,用来返回结果,将结果存储到位中(每页使用一位,其中第一页对应于最低有效位)。
首先我们需要定义 PTE_A,即访问位,查阅 RISC-V privileged architecture manual 手册可知,PTE_V 为第六位:
然后在 kernel/riscv.h 中定义 PTE_V:
#define PTE_V (1L << 0) // valid
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4) // user can access
#define PTE_A (1L << 6) // access bit
pgaccess() 函数是一个系统调用,其对应的内核函数为 sys_pgaccess() (实现在 kernel/sysproc.c 中),代码如下:
int
sys_pgaccess(void)
{
// lab pgtbl: your code here.
uint64 base;
int len;
uint64 mask;
unsigned int abits = 0;
pte_t *pte;
struct proc *p = myproc();
argaddr(0, &base);
argint(1, &len);
argaddr(2, &mask);
//vmprint(p->pagetable);
//printf("%p\n", PTE2PA(*walk(p->pagetable, base, 0)));
for (int i = 0; i < len && base < MAXVA; i++, base += PGSIZE)
{
if ((pte = walk(p->pagetable, base, 0)) != 0 && (*pte & PTE_A))
{
abits |= (1 << i);
*pte &= ~PTE_A;
}
}
if (copyout(p->pagetable, mask, (char *)&abits, sizeof(abits)) < 0)
{
return -1;
}
//printf("aa%d\n", abits);
return 0;
}
此时执行 pgtbltest 可以看到测试用例全部通过: