xv6内核页表挂载&pgtpl lab
今天分析一下xv6中的内核页表挂载流程,可能会做下pagetable lab。
启动
// start()会在每一个CPU上,以supervisor mode跳转到这里
void
main()
{
// 确保只有一个CPU执行一次
if(cpuid() == 0){
kinit(); // 物理页分配器
kvminit(); // 构建内核页表
kvminithart();
// ...
}
}
挂载物理内存到空闲页表 kinit
kinit
函数主要完成的功能是将物理内存页挂载到由内核维护的空闲链表中。
操作系统会暴露给应用进程用于分配内存的接口,比如alloc
啥的,还会暴露用于归还内存的接口,比如free
。在系统层面,内存的分配是以页为最小粒度的,所以,操作系统会记录哪些物理页可以用于分配,在xv6中,完成这个功能的结构是kernel/kalloc.c
文件中kmem
结构体中的freelist
链表:
// kernel/kalloc.c
// run是一个链表结构
struct run {
struct run *next;
};
struct {
struct spinlock lock;
// freelist是一个空闲页的链表
struct run *freelist;
} kmem;
下面看看kinit
如何进行物理页的挂载:
void
kinit()
{
// 对内存操作加锁,这把锁能够串行化内存分配
initlock(&kmem.lock, "kmem");
// 将end到PHYSTOP的内存按页面加到freelist中
freerange(end, (void*)PHYSTOP);
}
kinit
以end
作为起始,PHYSTOP
作为结束调用freerange
,freerange
的作用是把一个范围以页的形式挂载到freelist中。
这里面有很多陌生的知识,end
从哪来?PHYSTOP
是什么?
上面的图片是内核的虚拟地址空间到物理地址空间的映射,xv6的内核最大内存是写死的128MB,在risc-v主板上,0x80000000
是物理内存的起始地址,xv6会将内核的虚拟地址空间中的0x80000000
到0x86400000
共128MB映射到了物理地址空间的相同位置。KERNBASE
就是内核物理内存的起始地址0x80000000
,PHYSTOP
就是内核物理内存的结束地址0x86400000
。
但实际上,内核代码中肯定也有文本段和数据段,它们的加载也要耗费一定的空间,从kernel/kernel.ld
这个链接描述脚本中可以看到。
从下图中可以看到,内核代码的文本段被链接到了地址0x80000000
,后面是rodata
、data
、bss
段的定义:
在最后,该文件向C提供了一个end
变量,通过.
指向当前位置,所以,end
就是加载完内核的text和data后的可用内存起始位置
文本段是按照4096对齐的(0x1000),但由于rodata
、data
和bss
段都是以16字节(我也不清楚这里是字节还是位)对齐,所以,如果我们想以页为粒度分配空间,可能还要做一些努力。
freerange
中的代码以及所引用的PGROUNDUP
如下:
#define PGROUNDUP(sz) (((sz)+PGSIZE-1) & ~(PGSIZE-1))
void
freerange(void *pa_start, void *pa_end)
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
kfree(p);
}
实际上,PGROUNDUP
这个宏就是在C层面进行4K对齐,下面是一个假设,我们假设kernel data分配完所处的位置是8256,经过运算,p
会指向下面第一个4K对齐的位置——12288。
假设kernel.ld中的end位置是8256
0000 1111 1111 1111 PGSIZE - 1
1111 0000 0000 0000 ~(PGSIZE - 1)
0011 0000 0011 1111 sz + PGSIZE - 1
(sz + PGSIZE - 1) & ~(PGSIZE - 1)
0011 0000 0000 0000 12288 = 4096 * 3
实际上通过debug也可以看到,pa_start
是未对齐的数字,而p
则是比它稍大的一个已经对齐的数字。对齐会造成一点点的空间浪费,但是能极大的提高读写效率。
后面的for
循环就很好理解了,从可用的第一个页p
开始,一直到pa_end
,每次加一个页面大小,然后调用kfree
去释放页面,实际上就是加到freelist中。
void
kfree(void *pa)
{
struct run *r;
// 安全校验,地址是否按页面大小对齐,地址是否小于开始位置,大于等于最大内存位置
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// 填充垃圾数据
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
// 加锁,向freelist链表头添加数据,更换freelist指向新的头,释放锁
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
可以看到执行几次循环后,freelist
变成了一个链表,每个链表项目是一个可以被分配使用的页面。
从代码中还可以看出,实际上空闲物理页的
run
结构存储就存储在该页的起始位置,因为空闲页本身就是不存数据的,所以并没有额外的用户内存消耗。
挂载内核页表 kvminit
刚刚我们所做的一切操作都是直接在物理内存做的,并没有做虚拟地址转换。在kvminit
函数中挂载了内核页表,并做了一些基本的映射。
我们先宏观上大体上看一下这些代码,后面再逐个函数进行解释。下面的代码主要做的就是调用kalloc
分配一个页,作为内核页表,并将硬件设备、kernel text、kernel data做一个虚拟地址与实际物理地址相等的直接映射。然后,还将trampoline代码映射到了内核的最顶端,这是进行UMode到SMode转换时的一个蹦床代码,上一篇文章中讲到过。实际上,从刚刚的kernel.ld
可以看出,trampoline代码实际上在kernel text中,所以实际上,它在页表中被映射了两次,这也是页表的强大之处,你可以随意进行任意模式的映射(但实际物理空间只占用一次),xv6中大量的使用了这种技巧。
最后,kvminit
函数对内核栈进行了映射。
void
kvminit(void)
{
kernel_pagetable = kvmmake();
}
// 为内核创建一个直接映射页表
pagetable_t
kvmmake(void)
{
pagetable_t kpgtbl;
kpgtbl = (pagetable_t) kalloc();
memset(kpgtbl, 0, PGSIZE);
kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
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
很简单,没啥可说的
// 分配物理内存中的4098Bytes大小的页面
// 返回一个内核可用的指针
// 若无法分配内存,返回0
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
// 向内核页表中添加一个映射
// 只在启动时被使用
// 该函数不会刷新TLB或(通知硬件)打开分页
void
kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
if(mappages(kpgtbl, va, sz, pa, perm) != 0)
panic("kvmmap");
}
看看mappages
:
#define PGROUNDDOWN(a) (((a)) & ~(PGSIZE-1))
// 为在va处开始的虚拟地址创建指向pa处的物理地址的PTEs
// va以及size不必须是页对齐的。
// 返回0成功,如果`walk()`函数无法分配一个需要的页表页则返回-1
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;
if(size == 0)
panic("mappages: size");
a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;){
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
if(*pte & PTE_V)
panic("mappages: remap");
*pte = PA2PTE(pa) | perm | PTE_V;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
PGROUNDDOWN
的含义就不像PGROUNDUP
一样很细致的分析了,可以自己算一下,它就是取下面的第一个页对齐位置。(假如给定n=8244
,则PGROUNDDOWN(n)=8192
)。
那么,[a, last]
,就是要为用户分配的一段连续虚拟地址空间范围,首部和尾部都进行了向下页对齐。
walk分析
在开始分析walk
之前,先分析几个宏:
#define PGSHIFT 12 // 一个页面中的偏移量位数
// 下面三个宏用于从虚拟地址中提取三个9位的索引
#define PXMASK 0x1FF // 9位
#define PXSHIFT(level) (PGSHIFT+(9*(level)))
#define PX(level, va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)
#define PTE2PA(pte) (((pte) >> 10) << 12)
- PX(level, va):从虚拟地址va中提取出第level级页表索引
- PTE2PA(pte):PTE转换成物理地址,实际上是左移10位去掉flag,右移补上12位0(页表都是这样对齐的)
- PA2PTE(pa):物理地址转换成PTE,实际上是右移12位去掉offset,然后左移10位预留flag位
这里建议跟着risc-v的三级页表结构来分析:
下面是walk
的代码:
// 返回在页表pagetable中,虚拟地址va对应的PTE地址。如果`alloc != 0`,则自动创建必要的页表页
//
// 在risc-v Sv39模式中,有三级页表页。
// 一个页表页包含512个64位的PTE。
// 一个64位的虚拟地址被分割成了五个域:
// 39..63 -- 必须为0
// 30..38 -- 9位二级索引
// 21..29 -- 9位一级索引
// 12..20 -- 9位零级索引
// 0..11 -- 12位的页中字节偏移量
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
if(va >= MAXVA)
panic("walk");
// 从2级开始
for(int level = 2; level > 0; level--) {
// 获取每个级别的页表索引
pte_t *pte = &pagetable[PX(level, va)];
// 如果存在pte并且有效,合法,从PTE中取出下一级页表物理地址并替换pagetable
if(*pte & PTE_V) {
pagetable = (pagetable_t)PTE2PA(*pte);
} else {
// 走到这里,说明不存在pte,或者该pte已经是无效pte
// 如果不允许分配,或者尝试为该pte分配新的下一级页表失败,直接返回0
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
return 0;
// 将刚刚创建的新页表填充0
memset(pagetable, 0, PGSIZE);
// 设置正确的pte
*pte = PA2PTE(pagetable) | PTE_V;
}
}
// 返回最后一级页表中的PTE
return &pagetable[PX(0, va)];
}
总结一下,walk
会返回虚拟地址对应的最后一级页表项,若alloc!=0
,并且va没有对应的页表项,则会为它创建出一个页表项。
注意,循环中每次创建新页表是创建该pte指向的下一级页表,举个例子,当
level==2
时,实际上创建的是一级页表,二级页表是在外面已经创建好的,只有一个,必然存在。
所以,我们再次回到mappages
函数中:
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;
if(size == 0)
panic("mappages: size");
a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;){
// 如果索引pte失败,可能是内存不足了,直接返回-1
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
// 如果pte存在并且pte合法,说明我们要映射的虚拟地址上面已经有另一个映射了
// 直接panic
if(*pte & PTE_V)
panic("mappages: remap");
// 将物理地址转换成PTE形式(去掉offset部分,预留flag部分)
// 并加上权限,以及有效flag
*pte = PA2PTE(pa) | perm | PTE_V;
// 如果a和last相等,说明已经无需再分配了。
if(a == last)
break;
// a和pa一起递增,等待下一次循环
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
所以,实际上,mappages
在你输入的va
和va + size-1
已经对齐到页的时候(我们不考虑别的情况),实际上做的就是帮你在va
到pa
之间建立几个页面的映射。
实际上,在TLB中已经有了
walk
这一功能,分页、创建页表项、三级页表的维护实际上都是应该由硬件完成的。而软件中还要有walk
的原因是,首先它要创建最初的三级页表,其次就是有时内核执行用户态内核态拷贝时会使用用户页表进行读取数据,并拷贝到内核中,此时可能需要手动模拟walk
,因为使用硬件,内核只能使用自己的页表进行地址转换。
proc_mapstakcs
看了这么久代码了,回顾一下,前面kvminit
函数中调用kvmmap
将硬件设备、kernel text、kernel data做一个虚拟地址与实际物理地址相等的直接映射,还将trampoline映射到内核空间的顶部,最后调用proc_mapstacks
进行内核栈的映射。
从这个图中看,内核栈被映射到内核地址空间中稍微偏上的部分,好像还映射了两个:
看到代码中的注释就明白了,每一个进程当然都需要一个内核栈,所以这里的图示中分配了两个。
// 在trampoline下面映射内核栈
// 每一个内核栈被一个GuardPage包裹
#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)
// 为每个处理器的内核栈分配一个页面
// 映射到内存的高处,跟一个非valid的guardpage
void
proc_mapstacks(pagetable_t kpgtbl) {
struct proc *p;
// 循环获取每个进程,xv6中最大允许64个进程
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);
}
}
由于栈是向上增长的,为了防止增长超过内核栈的总大小,在其上面映射了一个Guard Page,这个虚拟页并不映射到实际的物理内存,所以它也不占实际的内存,只占用一个PTE大小,它的vaild
标记是0,一旦栈尝试向上扩展时超过了栈的大小,就会访问到GuardPage,这时,就会出现错误。这个映射,也是页表带来的强大灵活性的一个体现。
我们目前还不知道用户程序栈是如何建立并映射的,但至少,我们可以看出xv6中的内核栈最大只有4096字节,并且,GuardPage并没有实际的映射过程,因为本来那里就应该是无效的,只要你不主动映射它。
启动虚拟地址转换 kvminithart
// 切换硬件的页表寄存器到内核页表,并开启虚拟地址转换
void
kvminithart()
{
w_satp(MAKE_SATP(kernel_pagetable));
sfence_vma();
}
没啥好说的,就是些satp寄存器,执行内存屏障以刷新TLB。
至此,xv6启动时的内核页表挂载过程执行完成。
pagetable lab
Print a page table (easy)
为了帮你学习RISC-V的页表,并且...也许是为了帮助你未来的debug,你的第一个任务是编写一个打印页表内容的函数。
定义一个叫
vmprint()
的函数,它接收一个pagetable_t
参数,以下面介绍的格式打印它。在exec.c
的return argc
语句之前添加if (p->pid == 1) vmprint(p->pagetable)
以打印第一个进程的页表。
现在,当你启动xv6,它应该像如下一样输出,在完成exec()
init
后打印第一个进程的页表:
page table 0x0000000087f6e000
..0: pte 0x0000000021fda801 pa 0x0000000087f6a000
.. ..0: pte 0x0000000021fda401 pa 0x0000000087f69000
.. .. ..0: pte 0x0000000021fdac1f pa 0x0000000087f6b000
.. .. ..1: pte 0x0000000021fda00f pa 0x0000000087f68000
.. .. ..2: pte 0x0000000021fd9c1f pa 0x0000000087f67000
..255: pte 0x0000000021fdb401 pa 0x0000000087f6d000
.. ..511: pte 0x0000000021fdb001 pa 0x0000000087f6c000
.. .. ..510: pte 0x0000000021fdd807 pa 0x0000000087f76000
.. .. ..511: pte 0x0000000020001c0b pa 0x0000000080007000
第一行显示了vmprint
的参数。在那之后就是每一个PTE显示一行,包括指向了树种更深层次的页表页的PTE。每一个PTE行都以一系列" .."进行缩进,这表明了它在树中的深度。每一个PTE行显示在它的页表页中的PTE索引,pte位以及PTE中解出的物理地址。不要打印非vaild的PTE。在上面的例子中,顶级页表页中有0和255这两个项目,项目0的下一级只有一个0索引被映射了,最低级的索引0具有0、1、2三个映射。
你的代码中的物理地址可能和上面的不同,但项目数和虚拟地址将是相同的。
一些提示:
- 你可以在
kernel/vm.c
中添加vmprint()
- 使用
kernel/riscv.h
底部定义的宏 freewalk
函数可能会给你启发- 在
kernel/defs.h
中定义vmprint
的原型,以让你可以从exec.c
中调用它 - 在你的
printf
调用中使用%p
以输出完整的64位十六进制PTE以及地址
void _vmp_level(pagetable_t pagetable, int level) {
for (int i = 0; i < 512; i++)
{
pte_t pte = pagetable[i];
if (pte & PTE_V)
{
for (int j=0; j<=level; j++) {
printf(" ..");
}
uint64 nextpgtbl = PTE2PA(pte);
printf("%d: pte %p pa %p\n", i, pte, nextpgtbl);
if ((pte & (PTE_R | PTE_W | PTE_X)) == 0)
_vmp_level((pagetable_t)nextpgtbl, level + 1);
}
}
}
void
vmprint(pagetable_t pagetable) {
printf("page table %p\n", pagetable);
_vmp_level(pagetable, 0);
}