xv6内核源码分析 006
从今晚开始我们就来看看xv6的虚拟内存系统的xv6的进程的实现。
直接开始看源码,我们先看vm.c
,看看虚拟内存机制的实现。
我们先看看文件开头的全局变量
/*
* the kernel's page table.
*/
pagetable_t kernel_pagetable;
extern char etext[]; // kernel.ld sets this to end of kernel code.
extern char trampoline[]; // trampoline.S
kernel_pagetabale
:内核页表,xv6默认是全部进程共享一个内核页表etext[]
:一个汇编向量,指向一段汇编代码trampoline[]
:也是一个汇编向量,指向一段汇编代码,这段代码是trap机制实现的关键。
我们先看到核心的函数walk()
和mappages()
pte_t walk(pagetable_t pagetable, uint64 va, int alloc)
我们首先需要介绍几个概念
PTE
:PTE指的是pagetable entry
,页表项,进程的进程地址空间在xv6中是由一个一个固定的4096个字节的虚拟页面(virtual page)来组成的,但是每个虚拟页的实际物理页其实不同的,所以说,一个进程的虚拟地址空间是连续的,但是这个进程的物理地址空间不一定是连续的。
多级映射:在xv6中,虚拟内存机制的实现基于页表的三级映射,也就是说,一个虚拟地址需要在页表中进行三次寻址才能够找到对应的物理地址。看下图。
MMU
:MMU(memory management unit),内存管理单元,它负责将虚拟地址转换成物理地址,由硬件来完成,操作系统和MMU是这样配合的:操作系统在初始化或分配、释放内存时会执行一些指令在物理内存中填写页表,然后用指令设置MMU,告诉MMU页表在物理内存中的什么位置。关于MMU更详细的可以看看这篇文章[Linux内核 MMU的工作原理 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/402141219#:~:text=操作系统和 MMU,是这样配合的:操作系统在初始化或分配、释放内存时会执行一些指令在物理内存中填写页表,然后用指令设置MMU,告诉MMU 页表在物理内存中的什么位置。)。
page
:xv6中虚拟内存系统调度的单位,内核以一个页作为单位来管理进程的地址空间。
下面看到源码
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
// 检查用户访问的虚拟地址是否在指定的范围内
if(va >= MAXVA)
panic("walk");
// 迭代寻址,当前处在第一级,所以只需要迭代两次
for(int level = 2; level > 0; level--) {
// 根据va中对应的位索引当前页表中的页表项
pte_t *pte = &pagetable[PX(level, va)];
// 如果PTE_V没有被设置就说明这个页表项是无效的
// 还未被分配
if(*pte & PTE_V) {
// 由对应的pte寻址到下一级的pagetable
pagetable = (pagetable_t)PTE2PA(*pte);
} else {
// 如果需要分配一个页,就alloc一个
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
return 0;
// 重置
memset(pagetable, 0, PGSIZE);
// 设置对应的位 <------这个会一级一级的调用
*pte = PA2PTE(pagetable) | PTE_V;
}
}
// 将第三级的pagetable的pte中的地址
// 也就是物理地址返回
return &pagetable[PX(0, va)];
}
先介绍一下这个函数中的宏常量
PX(level, va)
,level代表当前是在哪一级的页表进行寻址,va是用户访问的虚拟地址MAXVA
,代表着虚拟空间的最大值PTE_V
:表示访问的pte的权限,这个常量代表着当前pte是否有效PTE2PA
:表示将解析出一个pte所指向的page,这个page可能是一个pagetable page(页表页),也可能是一个physical page(物理页)。PGSIZE
:表示一个page 的大小,在xv6中固定为4096
其实这个walk()
就是MMU的功能。
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
我们先看看注释
// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned. Returns 0 on success, -1 if walk() couldn't
// allocate a needed page-table page.
可以看到mappages()
的作用是将一个va映射到一个pa。其实我们上面看到,walk
函数也是会在一个pagetable中创建一个pte,所以mappages()
在内部也是调用了walk()
来创建一个pte。
// pagetable 被映射的页表
// va 起始虚拟地址
// size 映射的页面的大小
// pa 物理地址
// perm 设置pte的权限
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;
// xv6确保映射的起始地址都是4096的倍数
// 找到va对应的block的起始地址
a = PGROUNDDOWN(va);
// 找到需要映射的空间的最后一个块的起始地址
last = PGROUNDDOWN(va + size - 1);
for(;;){
// 用walk来创建一个pte
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
// pte存在
if(*pte & PTE_V)
panic("remap");
*pte = PA2PTE(pa) | perm | PTE_V;
if(a == last)
break;
// 迭代, 往后移动
// a是进程地址空间的虚拟地址
a += PGSIZE;
// pa不一定是物理地址,
// 有可能是一个pagetable的地址
pa += PGSIZE;
}
return 0;
}
最主要的两个函数都看啦。我们现在看看其他的函数。
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
既然有映射,那肯定有取消映射。很好理解我们简单来看看吧。
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
uint64 a;
pte_t *pte;
// 先检查va是否对齐
if((va % PGSIZE) != 0)
panic("uvmunmap: not aligned");
// 迭代清除映射
for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
// 判断映射是否有效
if((pte = walk(pagetable, a, 0)) == 0)
panic("uvmunmap: walk");
if((*pte & PTE_V) == 0)
panic("uvmunmap: not mapped");
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
if(do_free){
// 是否需要将这个页释放
// 这个页可能是一个页表页,
// 也可能是一个数据页,但是本质都是一个物理页
uint64 pa = PTE2PA(*pte);
kfree((void*)pa); // kfree我们下一次再讲
}
// 清除映射
*pte = 0;
}
}
kvminit()
这函数是在内核启动时用来加载硬件的,也就是说,这个函数会将硬件的物理地址映射到对应的物理内存中,使cpu能够通过物理内存和硬件进行交互。而这些硬件是通过这直接映射的方式进行映射的,即pa == va
。这个在xv6的小书中都有将,我们在做页表实验的时候也会涉及到这一个函数。
void
kvminit()
{
// 内核页表
kernel_pagetable = (pagetable_t) kalloc();
memset(kernel_pagetable, 0, PGSIZE);
// uart registers
kvmmap(UART0, UART0, PGSIZE, PTE_R | PTE_W);
// virtio mmio disk interface
kvmmap(VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// CLINT
kvmmap(CLINT, CLINT, 0x10000, PTE_R | PTE_W);
// PLIC
kvmmap(PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// map kernel text executable and read-only.
kvmmap(KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
// map kernel data and the physical RAM we'll make use of.
kvmmap((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(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}
函数将这些硬件的物理地址都记录在kernel pagetable中,在xv6中,这个内核页表是所有进程共享的,所以页表实验的目的就是在每一个进程的内核栈中维护一个内核页表。
这样做有哪些好处呢?
-
能够减少MMU的工作,因为在只有一个内核页表的情况下,这个内核页表是和用户进程的页表分离的,所以在内核页表中并不包含用户进程的地址空间的映射,这就意味着,在用户态有效的地址(va),在内核页表上是无效的,所以内核需要将这些地址转换成物理地址,才能够在内核页表上使用。
我后面又查了一下资料想起一些之前看到过的细节,现在讲清楚一点:在xv6的页表实验中,我们在每个进程中维护了一个内核页表,而不是使用共享页表。在实验中,内核页表和用户页表都是共用一个进程地址空间的,内核页表映射在进程地址空间的高地址,而用户页表映射的虚拟地址是从0x000000开始的。
- 减少tlb的刷新,tlb是一个缓存,用来缓存常用的映射pte,但是当页表切换之后,之前的tlb中的内容都作废了,所以,需要刷新。
kvminithart()
// Switch h/w page table register to the kernel's page table,
// and enable paging.
void
kvminithart()
{
// 改变satp寄存器的指向,将其指向内核页表
w_satp(MAKE_SATP(kernel_pagetable));
// 刷新tlb,因为旧页表的tlb缓存对于新的页表是无效的
sfence_vma();
}
walkaddr(pagetable_t pagetable, uint64 va)
将一个虚拟地址转换成物理地址,本质上就是调用walk()
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
pte_t *pte;
uint64 pa;
if(va >= MAXVA)
return 0;
pte = walk(pagetable, va, 0);
if(pte == 0)
return 0;
if((*pte & PTE_V) == 0)
return 0;
if((*pte & PTE_U) == 0)
return 0;
pa = PTE2PA(*pte);
return pa;
}
后面很多的函数都是大同小异的,我就挑一些有代表性的来说
我们看一个fork相关的
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
从名字来看就知道这个函数是拷贝一个页表的
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;
uint64 pa, i;
uint flags;
char *mem;
// 遍历old pagetable的每一个pte
for(i = 0; i < sz; i += PGSIZE){
if((pte = walk(old, i, 0)) == 0)
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;
}
copyout(pagetaable_t pagetable, uint64 dstva, char *src, uint64 len)
这个函数是将一个位于内核空间中的数据拷贝到用户空间的指定位置,这个跟我们平时的网络编程中的解决粘包的过程有点像。
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
// 数据拷贝完了就终止循环
while(len > 0){
// 找到目的地址对应的物理地址
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
// 本次拷贝的数据量
n = PGSIZE - (dstva - va0);
if(n > len)
n = len;
// pa0 + 地址偏移量
memmove((void *)(pa0 + (dstva - va0)), src, n);
// 准备下一次迭代
len -= n;
src += n;
dstva = va0 + PGSIZE;
}
return 0;
}
另外copyin
和copystr
也是相同的道理。
vm.c的分析到此结束!!!
return -1;
// 本次拷贝的数据量
n = PGSIZE - (dstva - va0);
if(n > len)
n = len;
// pa0 + 地址偏移量
memmove((void *)(pa0 + (dstva - va0)), src, n);
// 准备下一次迭代
len -= n;
src += n;
dstva = va0 + PGSIZE;
}
return 0;
}
另外`copyin`和`copystr`也是相同的道理。
vm.c的分析到此结束!!!