文章结构
前言
继续上次的xv6实验,从做完第一个实验后,到现在已经过去很久了6.S081-Lab: Xv6 and Unix utilities 操作系统实验分享。中途只做完了系统调用,也没有写上笔记。
这次学习xv6
页表一章,从看书到看代码再到编写实验,花费了很多时间,遇到了很多坑,反反复复修改代码才得到正确的结果。
还是和以前一样,对操作系统有兴趣的千万不要错过这个实验。这次的实验会接触到很多内核知识,尤其是编写实验代码过程中,将页表理论知识化为实际的代码,别说有多舒服了。
分页可谓是操作系统中一个非常重要的思想,在操作系统中使用页表来和硬件一起实现内存虚拟化。对页表的理解当然不能仅仅只停留在理论上,
来看看xv6是如何实现它的。
下面的源码是依据 github 上的xv6源码进行解读的,导致我在读完源码后,再看6.S081的实验后有点迷茫。
另外,下面的实验是以 6.S081-2020 的课程进行的,不是 2021 的课程实验;这两年的实验不相同!!!
kernel/riscv.h 分页相关操作的定义
The flags and all other page hardware-related structures are defined in (kernel/riscv.h)
book-riscv-rev2.pdf-P33
在 riscv.h 文件中定义了页的一些标志和硬件相关的结构, 以下是该文件的部分源码
#define PGSIZE 4096
#define PGSHIFT 12
#define PGROUNDUP(sz) (((sz)+PGSIZE-1) & ~(PGSIZE-1))
#define PGROUNDDOWN(a) (((a)) & ~(PGSIZE-1))
#define PTE_V (1L << 0)
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4)
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)
#define PTE2PA(pte) (((pte) >> 10) << 12)
#define PTE_FLAGS(pte) ((pte) & 0x3FF)
#define PXMASK 0x1FF
#define PXSHIFT(level) (PGSHIFT+(9*(level)))
#define PX(level, va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))
typedef uint64 pte_t;
typedef uint64 *pagetable_t;
从源码中能理解大部分的 define 语句是做什么的,比如页的大小、最大虚拟地址等,这里挑几个出来讲讲
结合书中的图3.2分析:
- PGROUNDUP:获取地址sz对应的物理页号(PPN),向上取整
- PGROUNDDOWN:同上,获取地址sz对应的物理页号(PPN),向下取整
- PTE_FLAGS:获取页表项对应的标志
- PXMASK:虚拟地址的低39位被 xv6 使用, 这39bit中的高27bit被用来索引页表项,xv6的页表是三级页表,
,这27bit被分为三次使用,每次使用9bit,因此PXMASK
为0x1FF
,PXSHIFT
则是根据是第几级页表来计算移位数
xv6只使用了5个页标志,而不是图中RISC-V的8个标志。 至于 PA2PTE
、PTE2PA
,光看这个文件的话会难以理解,留到后续与其他文件一起解释。
类型 pagetable_t
将会在下面提到,只需记住,它是64位长的指针。
kernel/memlayout.h xv6内存布局
The file (kernel/memlayout.h) declares the constants for xv6’s kernel memory layout.
book-riscv-rev2.pdf-P34
这个文件定义了 xv6 的内存布局
#define UART0 0x10000000L
#define UART0_IRQ 10
#define VIRTIO0 0x10001000
#define VIRTIO0_IRQ 1
#define CLINT 0x2000000L
#define CLINT_MTIMECMP(hartid) (CLINT + 0x4000 + 8*(hartid))
#define CLINT_MTIME (CLINT + 0xBFF8)
#define PLIC 0x0c000000L
#define PLIC_PRIORITY (PLIC + 0x0)
#define PLIC_PENDING (PLIC + 0x1000)
#define PLIC_MENABLE(hart) (PLIC + 0x2000 + (hart)*0x100)
#define PLIC_SENABLE(hart) (PLIC + 0x2080 + (hart)*0x100)
#define PLIC_MPRIORITY(hart) (PLIC + 0x200000 + (hart)*0x2000)
#define PLIC_SPRIORITY(hart) (PLIC + 0x201000 + (hart)*0x2000)
#define PLIC_MCLAIM(hart) (PLIC + 0x200004 + (hart)*0x2000)
#define PLIC_SCLAIM(hart) (PLIC + 0x201004 + (hart)*0x2000)
#define KERNBASE 0x80000000L
#define PHYSTOP (KERNBASE + 128*1024*1024)
#define TRAMPOLINE (MAXVA - PGSIZE)
#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)
#define TRAPFRAME (TRAMPOLINE - PGSIZE)
上面的代码和 book-riscv-rev2.pdf-P34 的图3.3所示的一致,正是xv6内核的地址空间布局
,比如 KERNBASE 是 0x80000000 RAM 的起始地址,xv6内核使用的内存大小是 128M(KERNBASE ~ PHYSTOP 范围的地址空间);
另外,我们可以注意到,虚拟地址空间最高的那两页被分配给了 TRAMPOLINE 和 TRAPFRAME。
kernel/vm.c
Most of the xv6 code for manipulating address spaces and page tables resides in vm.c
book-riscv-rev2.pdf-P35
xv6绝大部分用于处理地址空间和页表的代码都在 kernel/vm.c
文件中,
由于源码过长,不容易快速理解,现在按照书P36的内容来分析这里面的代码。
在xv6启动的时候, kernel/main.c
在启用分页功能前,先调用了kvminit
函数创建内核页表。
以下是kernel/main.c
的main
函数代码:
void
main()
{
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit();
kvminit();
kvminithart();
procinit();
trapinit();
trapinithart();
plicinit();
plicinithart();
binit();
iinit();
fileinit();
virtio_disk_init();
userinit();
__sync_synchronize();
started = 1;
} else {
while(started == 0)
;
__sync_synchronize();
printf("hart %d starting\n", cpuid());
kvminithart();
trapinithart();
plicinithart();
}
scheduler();
}
现在我们只关注对vm.c
中的函数调用,即kvminit
和kvminithart
。
kvminit 初始化内核页表
void
kvminit(void)
{
kernel_pagetable = kvmmake();
}
这个函数只是调用kvmmake
获取返回值后赋值给 kernel_pagetable
,kernel_pagetable
声明如下:
pagetable_t kernel_pagetable;
pagetable_t 类型的变量,正是上述的riscv.h
中定义的64位长的指针,也就说明kvmmake
的返回了内核页表的指针。
现在看看kvmmake
函数是如何创建内核页表的。
kvmmake
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;
}
kvmmake
首先调用kalloc
分配一个物理页并用零值初始化。kvmmake
然后调用kvmmap
对该页表设置虚拟地址-物理地址映射,并设置该页的标志(valid、read、write等),使用到的常量大多是在memlayout.h
中定义的;
由于此时还没有开启分页,因此是直接映射(direct mapping),也就是虚拟地址等于物理地址。kvmmake
接着调用proc_mapstacks
为每个进程分配一个内核栈和一个守护页(guard page)的内存。- 最后,返回内核页表的指针
kpgtbl
。
现在将注意力集中在页表是如何创建的,内存分配在后面讨论,我们查看kvmmap
函数是如何做的:
kvmmap
void
kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
if(mappages(kpgtbl, va, sz, pa, perm) != 0)
panic("kvmmap");
}
kvmmap
调用了mappages
函数,继续看mappages
函数
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(;;){
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;
}
mappages
函数的逻辑如下:
- 根据虚拟地址的起始地址
va
和空间大小size
,使用riscv.h
中的PAGEDOWN
获取虚拟地址的页号范围[a,last]
- 在for循环中,调用
walk
获取页号a
对应的页目录项(PTE)的地址,walk
函数会在页目录项未分配时(即PTE_V是0的状况)
为页目录项分配一页,分配失败时,walk
返回 0,walk
的逻辑会在后面详细介绍 - 接着会判断该目录项
pte
是否重复映射,如果重复映射,则panic
- 接着会将标志填充至要映射的物理页地址
pa
,并将其赋给pte
,使目录项pte
指向该物理页 - 更新虚拟页地址
a
和物理页地址pa
并开始新一轮循环。
walk
函数是如何获取页表pagetable
中的虚拟地址a
对应的页表项地址pte
呢?
walk
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
if(va >= MAXVA)
panic("walk");
for(int level = 2; level > 0; level--) {
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);
*pte = PA2PTE(pagetable) | PTE_V;
}
}
return &pagetable[PX(0, va)];
}
walk
函数的逻辑如下:
- 如果虚拟地址
a
超过最大虚拟地址MAXVA
(上述riscv.h中定义的),则报错 - 进入
for
循环,循环会进行两次,因为xv6
使用的三级页表,要跨过前两级页表,返回第三级页表的页表项地址;在for
循环中,利用预处理操作PX
获取虚拟地址va
对应level
的9bit大小页表项索引(索引区间[0,511]共512个页表项,因为页表pagetable
的大小就是一页,
页表项大小为8个字节,即4096/8=512,而va
中的27bit被分成三个9bit使用,9bit刚好可以索引512个页表项),根据页表项是否valid,判断是否需要为该页表项分配内存- 如果
pte
所指向的页已分配,则将pagetable
指向页表项pte
指向的页 - 如果
pte
所指向的页未分配(没有设置valid),则会根据alloc
标志参数是否设置,调用kalloc
分配一页内存并初始化它
- 如果
- 最终,
pagetable
指向三级页表的地址,然后使用虚拟地址va
索引对应的页表项,返回页表项地址。 - 如果页表项地址未成功分配,则返回 0
kvmmap
经及其使用的函数我们已经知道了,现在来看kvmmake
中调用的最后一个函数proc_mapstacks
proc_mapstacks(proc.c)
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);
}
}
proc_mapstacks
调用kalloc
分配一个物理页va
用于存放内核栈,然后使用KSTACK
生成的虚拟地址va
,在内核页表上建立映射。
预处理操作KSTACK
定义如下:
#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)
可见内核栈都是在TRAMPOLINE
下面,相邻的内核栈相距2个物理页(1页用于内核栈,一页防止栈溢出覆盖数据),且分配顺序是自上而下(因为栈是向下增长的)。
结合proc_mapstacks
中的p-proc
不难看出,这是为每个进程划分的一块内核栈,互相独立。
对应于书中的xv6内核地址空间布局图。注意,所有进程的内核栈映射都在内核页表!!!
book-riscv-rev2.pdf#P34
- 由于是在初始化内核页表,因此索引到的页表项
pte
大多情况下都是需要kalloc
分配内存页 - 上述这些操作发生时,还没有开启分页功能,因此是虚拟地址-物理地址的直接映射(direct mapping),
walk
将页表项指向的页的物理地址当成虚拟地址使用
引用原文的一段描述:
A page table is stored in physical memory as a three-level tree. The root of the tree is a 4096-byte
page-table page that contains 512 PTEs, which contain the physical addresses for page-table pages
in the next level of the tree. Each of those pages contains 512 PTEs for the final level in the tree.
The paging hardware uses the top 9 bits of the 27 bits to select a PTE in the root page-table page,
the middle 9 bits to select a PTE in a page-table page in the next level of the tree, and the bottom
9 bits to select the final PTE.book-riscv-rev2.pdf#32
现在,kvmminit
初始化了内核页表,kernel_pagetable
指向了内核根页表(root page-table),main.c
接着接着调用
kvminithart
函数启用分页并刷新TLB。
kvminithart 启用分页
void
kvminithart()
{
w_satp(MAKE_SATP(kernel_pagetable));
sfence_vma();
}
w_astp
使用汇编指令,将内核页表的物理地址写入到寄存器satp
sfence_vma
使用RISC_V的汇编指令刷新CPU的TLB(由于读取页表映射需要进行读取内存,因此使用TLB缓存这些映射,利用局部性原理加快速度)
kvminithart
函数执行之后,后续指令使用到的虚拟地址都会被映射为正确的物理地址
kernel/kalloc.c
上述在初始化内核页表时,都是使用kalloc
函数分配内存,现在来看看xv6的内存管理机制:
xv6使用一个链表来记录哪些页是可用的, 俗称”空闲链表“,且每次分配或释放的空间都是大小为4KB的页。
内存管理使用到结构体run
来存储可分配空间的地址,结构体run
定义如下:
struct run {
struct run *next;
};
结构体run
形成一个空闲页的链表,链表freelist
和该链表的自旋锁则封装在另一个结构体kmem
中:
struct {
struct spinlock lock;
struct run *freelist;
} kmem;
只有在获取到自旋锁时,,才能分配或释放内存,以确保线程安全。
freelist
用于记录空闲内存页,那么存储这些记录的内存又从哪里来呢?
xv6将空闲链表节点存储在空闲内存页当中,空闲页page
中可以存储这样一个指针p
,指针p
的地址就是空闲页page
的地址,指针p
的内容就是下一个空闲页的地址。
记住这一点,将更容易理解下面xv6的源代码。
kinit 初始化可用内存
回到xv6的启动过程,在main.c
调用kvminit
函数创建页表之前,先调用了kinit
函数初始化可用内存。
kinit
void
kinit()
{
initlock(&kmem.lock, "kmem");
freerange(end, (void*)PHYSTOP);
}
在kinit
函数中,先初始化了空闲链表的自旋锁,在这里先不考虑自旋锁的创建细节;随后调用freerange
释放end~PHYSTOP
范围的物理内存。
freerange
是如何释放该范围内的物理地址呢?
freerange
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);
}
freerange
函数使用riscv.h
中预处理操作PGROUNDUP
获取起始物理页地址,之后对该范围内的物理页依次调用kfee
添加到空闲页。
现在看看kfee
函数对物理页做了什么操作:
kfree
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;
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}