page tables
这一章可以说是xv6中重点中的重点了。页表是操作系统设计的核心部分,通过页表的作用能够让操作系统的很多功能得以实现。每一个进程都有自己的页表。页表决定了进程的虚拟地址到实际物理地址的映射。有了页表的存在,每一个进程实际的物理地址就不可能冲突,此外页表还可以让多个虚拟地址映射到相同的物理地址上来节约内存(例如每一个进程中断都要进去的trampoline代码段),以及页表还能以一种简单的方法来保护内核或者用户栈。总之页表的作用非常强大,弄懂页表是后面很多章节的基础。
Paging hardware
这节主要是介绍 xv6中页表项的组成。以及具体的虚拟地址如何映射到物理地址的过程,这个还是很有趣的,我们仔细来看一下。首先上图:
xv6 是 64位的操作系统所以虚拟地址是64位,但是应该是为了简单起见,64位中的前25位是没用作用了,只靠后39位来映射物理地址。其中39位还没分成 27 + 12前面的27位是用来在页表中寻找页表项(PTE)的。 后面12位是寻找到最后的物理页后具体的偏移量(2^12 = 4096,一页大小就是4096)。xv6中是有3级页表的,所以刚好需要27位来对应,也就是9 + 9 + 9。为什么要这样分呢? 首先一个PTE是8字节,但是类似于虚拟地址,页表项的前面10位也是没用的,只有后面的54位有用。其中前44位表示对应的物理地址页的起始地址,后面的10位是标志位(后面会细讲标志位的作用)。 所以综上具体的寻址过程为: 首先根据页表寄存器找到三级页表储存的物理地址,然后根据虚拟地址中间27位中的前9位来定位这个三级页表的中的某一PTE(2^9刚好是512 刚好匹配),找到这个PTE后,根据这个pte的前44位(也就是PPN)来寻找这个PET对应的二级页表的物理地址的开头(前44位由PPN确定,后面12位全是0),然后再根据虚拟地址的中间27位的第二个9位来确定二级页表的PTE项,然后找到三级页表,然后根据最后一个9位,来找到最后的PTE项,将这个项的PPN与虚拟地址的后12位拼接在一起,最后形成56位的物理地址,映射成功(最后为啥是56位的物理地址我也不知道,哈哈哈)。
这里小小的推理一下如果 是linux的4级页表应该会怎么样。4级页表的单个应该还是只有512项,所以中间就需要36位来表示,然后最后肯定也是要12位偏移的,这一加起来就是48位。所以前面的16位也是没用吗,哈哈哈。当然这都是猜测,后面肯定要具体学linux的页表的。
如果在寻址的过程中,任何一个PTE不存在,硬件就会抛出一个经典的page-fault ,缺页中断,然后内核就会进行相应的处理。这个操作非常重要,后面实现很多功能都是基于这个操作。图中的PPN后面10个标志位可以简单的看看,包括有 PTE是否有效? 该数据是否可读 可写,可执行? 用户态执行还是内核态执行?等等。
每一个cpu都有自己的页表寄存器 satp,而每一个进程都有自己的页表地址(在切换时保存)。
Kernel address space
这一节也是有趣,介绍了内核虚拟空间是如何与物理地址相互映射的。
每一个进程都有自己的用户页表,但是所有进程都是共享内核的,所以每一个进程都需要一个用户页表,但是所有进程的内核只需要一个内核页表。下图就是内核的虚拟地址到物理地址的映射:
可以看到内核虚拟地址与用户的虚拟地址映射有较大的区别。内核的虚拟地址与物理地址之间的映射绝大部分是直接映射(direct mapping),也就是虚拟地址与物理地址一样,这种特殊的映射在后面内核的源码中有很大的作用。此外在0x80000000地址一下的区域代表的是硬件,操作这些地址会被当成是直接操控硬件。当然上图中虚拟地址的顶部还有一部分的 特殊,也就是不是直接映射,也就是会有两个虚拟地址被映射到同一个物理地址上,这样做都是用意义的。
- 首先是trampoline 区域的代码,除了在顶部有映射外,在kernerltext区域也有映射,这样的做的目的在第四章有介绍
- 然后是内核栈。 内核栈为什么要这么做呢? 答案是为了节约物理内存。 我们看到每一个内核栈的下面都有一个 保护栈这个保护栈在页表中的 PTE_V是置否的,也就是说当栈溢出的时候就会触发错误。当然这个页实际不储存任何地址,所以,如果在直接映射的地方加入机会显得浪费,所以索性在顶部设置这些页,既起到保护栈的作用,又没有占用空间。
Code : creating an adress space
这一节就是代码的展现了,这一节我将详细介绍vm.c这个文件中的所有代码。
总的来说这些函数中,开头带有kvm的是操作内核页表的,开头带有uvm的是操作用户页表的。
这些函数中首先是执行初始化 kvminit()
也就是调用 kvmake()
创建一个内核页表,所以详细看一下后者:
// Make a direct-map page table for the kernel.
pagetable_t
kvmmake(void)
{
pagetable_t kpgtbl;
kpgtbl = (pagetable_t) kalloc(); //分配一个页内存,然内核页表指向他
memset(kpgtbl, 0, PGSIZE);//将数据清零
// uart registers
kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
// virtio mmio disk interface
kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// PLIC
kvmmap(kpgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// map kernel text executable and read-only.
kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
// map kernel data and the physical RAM we'll make use of.
kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
// map kernel stacks
proc_mapstacks(kpgtbl);
return kpgtbl;
}
这里面主要是有调用了三个函数 kalloc()
kvmmap
proc_mapstacks()
我们先介绍仔细介绍后面两个,第三个暂时略过不影响。
讲kalloc()必须要来到kalloc.c文件中,这里涉及到内存的分配问题,看一下kalloc.c文件
extern char end[]; // first address after kernel.
// defined by kernel.ld.
//这里的end只的是kernel初始化完成后的第一个地址
struct run {//run可以看成指向页面的指针,同时也是一个单向链表的节点,
struct run *next;
};
struct {
struct spinlock lock;
struct run *freelist;
} kmem; //所有内核内存的集合
void
kinit()
{
initlock(&kmem.lock, "kmem");//初始化内存锁
freerange(end, (void*)PHYSTOP);//将从end到内存顶点的数全部free,其实也是建立链表的过程
}
void
freerange(void *pa_start, void *pa_end)
{//将指定范围内的内存全部kfree。
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
kfree(p);
}
// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc(). (The exception is when
// initializing the allocator; see kinit above.)
void
kfree(void *pa)
{//传入参数是物理地址,也就是要释放这个物理地址。
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");//只能是某一个页面的开头地址
// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);//装入无效的数据
r = (struct run*)pa; //这里直接将r指向这个物理内存,感觉有骚操作,读者可以细细品味一下。如果是我的话我的run 结构体内还会除了下一个指针外(next),还会防止一个指针指向内存地址(val)。
acquire(&kmem.lock);
r->next = kmem.freelist;//相当于直接访问那一块物理内存的前4个字节,让其指向当前的链表的第一个值这样就串起来了。
kmem.freelist = r; //然后将链表头设置为当前的r,这样相当于在链表中头插加入了一个值
release(&kmem.lock);
}
// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
void *
kalloc(void)
{//返回一个页表的物理地址,返回的是头部的页表,
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r)
kmem.freelist = r->next;
release(&kmem.lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
好了再来看kvmmap()
在这个函数会调用mappages()
而后者又会调用walk()
这个walk()
才是最终的底层最重要的函数 (讲课的老师说这是他最喜欢的函数之一)让我们来看看walk()
//传入参数一个页表,一个虚拟地址,一个alloc标志
//传出参数一个PTE指针(也就是页表项指针)
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
if(va >= MAXVA)//判断是否合法
panic("walk");
for(int level = 2; level > 0; level--) {
/*首先要弄懂这几个宏
PXMASK 0xFF 9位全是1
PXSHIFT(level) (PGSHITF + (9*level)) PGSHIFT为12
PX(level,va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)
结合我们上面将的虚拟地址如何在页表中寻找页表项,这也就不能理解了,总的来说就是根据中间的9位来找到页表项的索引。注意 pagetable是 unit64*所以也可以看成一个数组的开头
*/
pte_t *pte = &pagetable[PX(level, va)]; //找到第一个页表的相应页表项
if(*pte & PTE_V) {//如果该页表项存在
pagetable = (pagetable_t)PTE2PA(*pte); //找到他指向的下一个页表地址(中间的宏比较简单,读者应该能自己看懂,如果看不懂,还是要看之前将的地址都分别代表什么)
} else {//如果不存在
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
return 0;
memset(pagetable, 0, PGSIZE);//如果alloc标志位1,分配一个新的页表
*pte = PA2PTE(pagetable) | PTE_V;//这个在第一个页表的页表项中填入这个新创建的页表
}
//继续循环 后面就是level= 2 也就是第二个页表的故事了
}
//循环结束后, 此时的pagetable就是指向最后一个页表(第三个)
return &pagetable[PX(0, va)];//返回的是最后一个页表中对应的页表项的地址。
}
ok这个函数看完我们再来看一下mappages()
//传入参数,一个页表指针,虚拟地址,大小,物理地址,和标志位
//本函数就是在传入参数的页表中建立 va to pa 的映射
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;
a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;){
if((pte = walk(pagetable, a, 1)) == 0)//建立映射其实也就是修改最后一个页表的对应页表项
return -1;
if(*pte & PTE_V)//此时的页表项应该是不valid的。否则就是以及map过的
panic("remap");
*pte = PA2PTE(pa) | perm | PTE_V;//根据标志位填入该pte的值,
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;//size可能大于一个页表所以要循环。
}
return 0;
}
再来看kvmmap()就简单了
void
kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
if(mappages(kpgtbl, va, sz, pa, perm) != 0)
panic("kvmmap");
}
就只是 调用了maopages()
只不过传入页表参数是内核页表而已。这样一来kvmmake()
要做的事就很清晰了,就是建立了一系列的内存映射,主要是硬件的以及TRAMPOLINE。再来简单的看一下栈映射函数
void
proc_mapstacks(pagetable_t kpgtbl) {
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
char *pa = kalloc(); //为每一个进程都分配一个内核栈
if(pa == 0)
panic("kalloc");
uint64 va = KSTACK((int) (p - proc));//这个宏有点意思,可以自己仔细看看
kvmmap(kpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);//映射到内核页表中
}
}
void
kvminithart()
{//将内核页表写入到satp寄存器中,更新快表TLB
w_satp(MAKE_SATP(kernel_pagetable));
sfence_vma();
}
3.4 3.5 中的代码在上面已经讲过了,就不赘述。
Process adress space
来看一下用户的虚拟内存的使用情况:
从最底下开始,是经典的text 和 data, 然后就是stack 区域上面是heap,最上面是trapframe和trampoline,这两个也是很有很有意思,后面第四章仔细讲。第一个stack是main函数的stack 开头储存了 main函数的一些参数
课本上就暂停一下,我们继续看vm.c文件中的其他内容
//这个函数的作用跟前面的kvmmap是相反的。这个函数是删除页表中相应的PTE以及释放对应的物理地址的页内存
//传入参数是 用户页表,虚拟地址 页数,以及是否要执行物理地址的释放
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
uint64 a;
pte_t *pte;
if((va % PGSIZE) != 0)
panic("uvmunmap: not aligned");
for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
if((pte = walk(pagetable, a, 0)) == 0)//通过walk找到最后的一个PTE
panic("uvmunmap: walk");
if((*pte & PTE_V) == 0) //如果这个PTE没有效 报错
panic("uvmunmap: not mapped");
if(PTE_FLAGS(*pte) == PTE_V)//如果这个PTE没有其他的标志表示不是最后一个PTE
panic("uvmunmap: not a leaf");
if(do_free){
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);//释放该内存页
}
*pte = 0;
}
}
//创建用户页表
pagetable_t
uvmcreate()
{
pagetable_t pagetable;
pagetable = (pagetable_t) kalloc();
if(pagetable == 0)
return 0;
memset(pagetable, 0, PGSIZE);
return pagetable;
}
//创建一个用户页表后,将开始initcode 映射到从0开始的虚拟地址中
void
uvminit(pagetable_t pagetable, uchar *src, uint sz)
{
char *mem;
if(sz >= PGSIZE)
panic("inituvm: more than a page");
mem = kalloc();
memset(mem, 0, PGSIZE);
mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
memmove(mem, src, sz);
}
//在uvmunmap函数删除映射的基础上添加了指定的页面数而已
uint64
uvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
if(newsz >= oldsz)
return oldsz;
if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
uvmunmap(pagetable, PGROUNDUP(newsz), npages, 1);
}
return newsz;
}
//创建映射和页面 没什么好说的。
uint64
uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
char *mem;
uint64 a;
if(newsz < oldsz)
return oldsz;
oldsz = PGROUNDUP(oldsz);
for(a = oldsz; a < newsz; a += PGSIZE){
mem = kalloc();
if(mem == 0){
uvmdealloc(pagetable, a, oldsz);
return 0;
}
memset(mem, 0, PGSIZE);
if(mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0){
kfree(mem);
uvmdealloc(pagetable, a, oldsz);
return 0;
}
}
return newsz;
}
//递归的删除所有的页表空间
void
freewalk(pagetable_t pagetable)
{
// there are 2^9 = 512 PTEs in a page table.
for(int i = 0; i < 512; i++){
pte_t pte = pagetable[i];
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
// this PTE points to a lower-level page table.
uint64 child = PTE2PA(pte);
freewalk((pagetable_t)child);
pagetable[i] = 0;
} else if(pte & PTE_V){
panic("freewalk: leaf");
}
}
kfree((void*)pagetable);
}
//将old 页表中的所有内容(sz大小),包括映射的物理内容 全部赋值到new中
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;
uint64 pa, i;
uint flags;
char *mem;
for(i = 0; i < sz; i += PGSIZE){
if((pte = walk(old, i, 0)) == 0)//获得old的pte
panic("uvmcopy: pte should exist");
if((*pte & PTE_V) == 0)
panic("uvmcopy: page not present");
pa = PTE2PA(*pte);
flags = PTE_FLAGS(*pte);
if((mem = kalloc()) == 0)
goto err;
memmove(mem, (char*)pa, PGSIZE); //赋值物理内存的内容
if(mappages(new, i, PGSIZE, (uint64)mem, flags) != 0){
kfree(mem);
goto err;
}
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
剩下的copyin等 在第四章系统调用的时候有具体介绍