通过分析xv6-riscv代码理解页表机制

本文最后更新于:2023年5月5日 下午

通过分析xv6-riscv代码理解页表机制

不得不说在之前我一直对于页表这一虚拟内存机制抱有不小的疑惑,今天仔细分析完xv6-riscv对于页表的实现代码(又是一段苦痛之路),终于明朗了起来。

最早接触到页表应该是在上操作系统课程的时候,然而那个时候只讲原理没有实践,我顶多一知半解,甚至有时候连页表到底是操作系统对内存机制还是存储机制都傻傻分不清。

简单来说,页表是一种操作系统提供的虚拟内存机制,在没有页表的时候,操作系统中的所有进程(不管是内核进程还是用户进程)所用到的内存都是直接在物理内存(也就是DRAM)上操作的。有了页表之后,页表负责将每个进程的地址空间映射到特定物理内存中,这样每个进程的地址空间就能够隔离开来,每个进程不再操控实际的物理地址而是自己独一份的虚拟地址空间。每种操作系统或者cpu架构不同,页表的实现也不尽相同。

那么接下来就根据xv6-riscv的页表实现来谈谈这一机制的奇妙之处吧。

首先xv6-riscv是运行于Sv39 RISC-V上的,这意味着系统进程理论上的虚拟内存最大为39byte,也就是512GiB。这颗芯片最大可承载的地址线是56根,这意味着物理内存最大为56byte,反正大到现如今的科技基本上不可能实现就是了。

页表原理呢,实际上也是很简单的,虚拟地址往物理地址映射的基本单位是一页,一页就是4KiB(当然也有其他大小的方案),页表上存放上PTE(Page Table Entry),每个占用64bit,一个PTE就可以根据它中间的44bit来确定一个物理页在哪(56-44=4K)。每个进程都有自己独有的页表,页表地址存储在satp(supervisor address translation protected)寄存器中,进程切换上下文的时候它也跟着换页表地址。这样就可以实现每个进程的地址空间隔离。

假设我们每个进程都有只有一个页表,页表存储了所有映射的物理地址,那么这个页表就有2^27(39-12=27)个PTE,而一个PTE是8B,那么这个页表占用的空间就是1GiB!如果每个进程都这么存的话,光页表就把内存撑爆了。

于是xv6-riscv设计出了动态的三级页表结构,将虚拟地址分别分配为9bit(pt2)+9bit(pt1)+9bit(pt0)+12bit(offset),这样如果进程最多只用到0xfff的话(也就是前面三个页表的索引没有变化),就只需要三个页表就行了,总占用为3*512*8=12KiB,也就是3个页就可以存储。

xv6-riscv具体是什么样的一个页表结构我这里就不多赘述了,详细可以去看官方手册

你可能已经注意到了,这里所有的内存存储计算的基本粒度都是一页!这样我们就能非常方便地统一用一种方式处理物理内存(其实就是kalloc)。

当xv6-riscv系统运行起来的时候,系统内核会调用main函数,然后执行kinit初始化物理内存并分配所有空闲的内存给到kmem.freelist这个链表(每个节点至少相距4K并都以4K对齐);在main中继续会执行kvmmake建立最初的内核页表,映射所有的IO设备到前0x80000000中,然后调用proc_mapstacks为每个进程分配内核栈空间。

kalloc和walk在页表机制中扮演着关键的角色,前者会根据kmem.freelist来找到空闲的物理地址,然后按要求分配页表或者是物理地址(因为页表的大小也是4K);而后者会根据虚拟地址来找到最后一级的PTE,如果中间页表不存在也会分配出需要的中间页表(调用kalloc)。

另一个比较重要的mappages函数,它主要作用就是将对应的虚拟地址映射到物理地址。通过上述三个函数,我们基本上就可以完成任何页表或者内存操作。