6.S081——虚拟内存部分——xv6源码完全解析系列(2)
0.Briefly Spaeking
点此回看本系列博客的上一篇
上一篇博客中,我们详细分析了xv6内核代码中有关虚拟内存的部分,主要剖析了vm.c这个文件中的三个全局变量和6个函数,这篇博客紧跟着上篇博客的步伐。接着剖析和阅读接下来的源码,同样首先列出所有要读的源码列表:
1.kernel/memorylayout.h
2.kernel/vm.c(434 rows) <-----------(这篇博客要阅读的代码)
3.kernel/kalloc.c
4.kernel/exec.c
5.kernel/riscv.h
我们上次已经读了vm.c源码的一小部分,这次接着来把它看完,为了方便记忆,这里的标号也接着上一篇来:
1.kenrel/vm.c
1.8 walkaddr函数
walkaddr函数是walk函数的一层封装,专门用来查找用户页表中特定虚拟地址va所对应的物理地址。所以对于本函数注意两条:
1.它只用来查找用户页表
2.返回的是物理地址,而非像walk函数那样只返回最终层的PTE
下面看看含注释的代码吧:)
// Look up a virtual address, return the physical address,
// or 0 if not mapped.
// Can only be used to look up user pages.
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
pte_t *pte;
uint64 pa;
// 如果虚拟地址大于最大虚拟地址,返回0
// 物理地址为0的地方是未被使用的地址空间
// Question: 为什么不像walk函数一样直接陷入panic?
if(va >= MAXVA)
return 0;
// 调用walk函数,直接在用户页表中找到最低一级的PTE
pte = walk(pagetable, va, 0);
// 如果此PTE不存在,或者无效,或者用户无权访问
// 都统统返回0(为什么不陷入panic?)
if(pte == 0)
return 0;
if((*pte & PTE_V) == 0)
return 0;
if((*pte & PTE_U) == 0)
return 0;
// 从PTE中截取下来物理地址页号字段,直接返回
pa = PTE2PA(*pte);
return pa;
}
最后,我试着去查看了一下到底是谁在调用这个函数,发现是这三个函数:
1.copyin
2.copyout
3.copyinstr
它们是vm.c中的重头戏,专门负责内核态和用户态之间数据拷贝的,我们到时候会好好研究一下这三个函数,以及它们如何使用walkaddr函数实现用户页表中的地址翻译的。
1.9 freewalk函数
又是一个walk函数…这个函数的作用就是专门用来回收页表页的内存的,因为页表是多级的结构,所以此函数的实现用到了递归,从源码上的英文注释所述,在调用这个函数时应该保证叶子级别页表的映射关系全部解除并释放(这将会由后面的uvmunmap函数负责),因为此函数专门用来回收页表页。
uvmunmap函数和freewalk函数结合,成功实现了页表页、物理页的全面释放。
以下是示意图:
下面是含注释的源码:
// Recursively free page-table pages.
// All leaf mappings must already have been removed.
// 译:递归地释放页表页,所有的叶级别页表映射关系必须已经被解除
void
freewalk(pagetable_t pagetable)
{
// there are 2^9 = 512 PTEs in a page table.
// 每一个页表都正好有512个页表项PTE,所以要遍历它们并尝试逐个释放
for(int i = 0; i < 512; i++){
// 取得对应的PTE
pte_t pte = pagetable[i];
// 注意,这里通过标志位的设置来判断是否到达了叶级页表
// 如果有效位为1,且读位、写位、可执行位都是0
// 说明这是一个高级别(非叶级)页表项,且此项未被释放,应该去递归地释放
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);
// 释放完毕之后,将原有的PTE全部清空,表示已经完全释放
pagetable[i] = 0;
// 如果有效位为1,且读位、写位、可执行位有一位为1
// 表示这是一个叶级PTE,且未经释放,这不符合本函数调用条件,会陷入一个panic
} else if(pte & PTE_V){
panic("freewalk: leaf");
}
// 这里隐藏了一个逻辑,即if(pte & PTE_V == 0)
// 这说明当前PTE已经被释放,不用再次释放了,直接遍历下一个PTE
}
// 最后释放页表本身占用的内存,回收,回到上一层递归
kfree((void*)pagetable);
}
画一条分割线,接下来我们要看看用来操作用户地址空间的函数了,这些函数都是以uvm开头的。从最开头一个个开始吧!
1.10 uvmcreate函数
这个函数的作用很简单,就是为用户进程分配一个页表页并返回指向此页的指针。
// create an empty user page table.
// returns 0 if out of memory.
// 译:创建一个空的用户页表,当内存耗尽时返回空指针
pagetable_t
uvmcreate()
{
pagetable_t pagetable;
// 分配一个内存页
pagetable = (pagetable_t) kalloc();
if(pagetable == 0)
return 0;
// 将此页表的每一个PTE完全清空
memset(pagetable, 0, PGSIZE);
return pagetable;
}
1.10 uvminit函数
这个函数的作用是将initcode加载到用户页表的0地址上,initcode是启动第一个进程时所需要的一些代码。这个函数的作用就是将initcode映射到用户地址空间中。首先给出xv6中用户地址空间的设计,可以看到从虚拟地址0开始存放的是进程的代码段,对于操作系统启动的第一个进程而言,这个位置放置的就是initcode代码。
// Load the user initcode into address 0 of pagetable,
// for the very first process.
// sz must be less than a page.
// 译:将用户的initcode加载到页表的0地址
// 仅为第一个进程而服务
// 代码的尺寸必须小于一个页(4096 bytes)
void
uvminit(pagetable_t pagetable, uchar *src, uint sz)
{
// mem虽然是一个指针,但是因为内核地址空间中虚拟地址和物理地址
// 在RAM上是直接映射的,所以它其实也就等于物理地址
char *mem;
// 如果要求分配的大小大于一个页面,则陷入panic
if(sz >= PGSIZE)
panic("inituvm: more than a page");
// 分配一页物理内存作为initcode的存放处,memset用来将当前页清空
mem = kalloc();
memset(mem, 0, PGSIZE);
// 在页表中加入一条虚拟地址0 <-> mem的映射,相当于将initcode成功映射到了虚拟地址0
mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
// 将initcode的代码一个字节一个字节地搬运到mem地址
memmove(mem, src, sz);
}
这段函数中调用了memmove函数,它被定义在kernel/string.c中,实现的功能是从src地址拷贝n个字节到dst地址,并返回指向目的地址的指针。仔细阅读之后发现这段代码真是绝妙,简洁优雅地实现了整个字符串拷贝的过程,下面简单跑个题欣赏一下这段代码。
void*
memmove(void *dst, const void *src, uint n)
{
// 声明两个活动的指针,用来实时更新拷贝的过程
const char *s;
char *d;
// 如果n等于0,说明不需要拷贝字符,直接返回dst
if(n == 0)
return dst;
s = src;
d = dst;
// case1:src与dst字符串部分重叠时,倒序对字节进行复制,这样可以避免覆盖问题
if(s < d && s + n > d){
// 调整指针到字符串尾部,准备开始倒序复制
s += n;
d += n;
while(n-- > 0)
*--d = *--s;
} else
// 否则直接正序复制即可
while(n-- > 0)
*d++ = *s++;
return dst;
}
最重要的就是分类讨论,就是为了分析潜在的Src和Dst的地址重叠问题,重叠时反向复制(从终点开始复制到起点),否则正向复制(从起点复制到终点),这就是memmove函数的实现,还是非常严谨巧妙的。
1.11 uvmunmap函数
这个函数的作用是取消用户进程页表中指定范围的映射关系,从虚拟地址va开始释放npages个页面,但是要注意va一定要是页对齐的。(这里引申思考一个问题留下,为什么在内存管理的代码中,有些代码要求传入的内存地址对齐,而有些不需要呢?)
事实上,uvmunmap函数和freewalk函数是组合使用的,前面我们在看freewalk函数时还记得它负责释放的是页表页,那么这里的uvmunmap负责的就是释放叶级页表中PTE记录的映射关系,特别地,如果设置标志位do_free,此函数还会一并将分配出去的物理页面也进行回收。
// Remove npages of mappings starting from va. va must be
// page-aligned. The mappings must exist.
// Optionally free the physical memory.
// 译:从虚拟地址va开始移除npages个页面的映射关系
// va必须是页对齐的,映射必须存在
// 释放物理内存是可选的
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
uint64 a;
pte_t *pte;
// va不是页对齐的,陷入panic
if((va % PGSIZE) != 0)
panic("uvmunmap: not aligned");
// 通过遍历释放npages * PGSIZE大小的内存
for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
// 如果虚拟地址在索引过程中对应的中间页表页不存在,陷入panic
// 回顾一下,walk函数返回0,只有一种情况,那就是某一级页表页在查询时发现不存在
if((pte = walk(pagetable, a, 0)) == 0)
panic("uvmunmap: walk");
// 查找成功,但发现此PTE不存在,陷入panic
if((*pte & PTE_V) == 0)
panic("uvmunmap: not mapped");
// 查找成功,但发现此PTE除了valid位有效外,其他位均为0
// 这暗示这个PTE原本不应该出现在叶级页表(奇怪的错误),陷入panic
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
// 否则这是一个合法的,应该被释放的PTE
// 如果do_free被置位,那么还要释放掉PTE对应的物理内存
if(do_free){
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);
}
// 最后将PTE本身全部清空,成功解除了映射关系
*pte = 0;
}
}
在这段代码中使用了一个宏PTE_FLAGS,这个宏是直接用来提取一个PTE的所有标志位的,定义如下:
// 提取出PTE的所有标记位
// 0x3ff相当于保留低10位
#define PTE_FLAGS(pte) ((pte) & 0x3FF)
1.12 uvmdealloc函数
这个函数用来回收用户页表中的页面,将用户进程中已经分配的空间大小从oldsz修改到newsz,并返回新地址空间的大小,值得注意的是oldsz不一定大于newsz,也就是说这个函数不是一定会导致用户地址空间缩小的。
事实上,这个函数和sbrk系统调用有直接关系,它是用来回收多余的进程堆内存空间的,详见growproc函数(kernel/proc.c)。
// Deallocate user pages to bring the process size from oldsz to
// newsz. oldsz and newsz need not be page-aligned, nor does newsz
// need to be less than oldsz. oldsz can be larger than the actual
// process size. Returns the new process size.
// 译:回收用户页,使得进程的内存大小从oldsz变为newsz。oldsz和newsz不一定要是
// 页对齐的,newsz也不一定要大于oldsz。oldsz可以比当前实际所占用的内存大小更大。
// 函数返回进程新占用的内存大小
uint64
uvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
// 如果新的内存大小比原先内存还要大,那么什么也不用做,直接返回oldsz即可
if(newsz >= oldsz)
return oldsz;
// 如果newsz经过圆整后占据的页面数小于oldsz
// PGROUNDUP宏定义的讲解见上一篇博客
if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
// 计算出来要释放的页面数量
int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
// 调用uvmunmap,清空叶级页表的PTE并释放物理内存
// 因为我们使用了PGROUNDUP来取整页面数量,所以这里可以保证va是页对齐的
// 因为用户地址空间是从地址0开始紧密排布的, 所以PGROUNDUP(newsz)对应着新内存大小的结束位置
// 注意do_free置为1,表示一并回收物理内存
uvmunmap(pagetable, PGROUNDUP(newsz), npages, 1);
}
return newsz;
}
1.13 uvmalloc函数
有uvmdealloc函数来回收进程的内存,对应的也就会有一个函数来为用户进程向内核申请更多的内存,这就是uvmalloc函数的作用,它和uvmdealloc函数是对应的姊妹函数。下面直接看看它的源码实现:
// Allocate PTEs and physical memory to grow process from oldsz to
// newsz, which need not be page aligned. Returns new size or 0 on error.
// 译:分配PTE和物理内存来将分配给用户的内存大小从oldsz提升到newsz
// oldsz和newsz不必是页对齐的
// 成功时返回新的内存大小,出错时返回0
uint64
uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
char *mem;
uint64 a;
// 如果新的内存大小更小,不用分配,直接返回旧内存大小
if(newsz < oldsz)
return oldsz;
// 计算原先内存大小需要至少多少页,因为进程地址空间紧密排列
// 所以这里oldsz指向的其实是原先已经使用内存的下一页,崭新的一页
oldsz = PGROUNDUP(oldsz);
// 开始进行新内存的分配
for(a = oldsz; a < newsz; a += PGSIZE){
// 获取一页新的内存
mem = kalloc();
// 如果mem为空指针,表示内存耗尽
// 释放之前分配的所有内存,返回0表示出错
if(mem == 0){
uvmdealloc(pagetable, a, oldsz);
return 0;
}
// 如果分配成功,则将新分配的页面全部清空
memset(mem, 0, PGSIZE);
// 并在当前页表项中建立起来到新分配页表的映射
// mappages函数的讲解见完全解析系列博客(1)
if(mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0){
// 如果mappages函数调用返回值不为0,表明在调用walk函数时索引到的PTE无效
// 释放之前分配的所有内存,返回0表示出错
kfree(mem);
uvmdealloc(pagetable, a, oldsz);
return 0;
}
}
// 如果成功跳出循环,表示执行成功,返回新的内存空间大小
return newsz;
}
1.14 uvmcopy函数
uvmcopy函数是为fork系统调用服务的,它会将父进程的整个地址空间全部复制到子进程中,这包括页表本身和页表指向的物理内存中的数据。
以下是完整的源代码:
// Given a parent process's page table, copy
// its memory into a child's page table.
// Copies both the page table and the
// physical memory.
// returns 0 on success, -1 on failure.
// frees any allocated pages on failure.
// 译:给定一个父进程页表,将其内存拷贝到子进程页表中
// 同时拷贝页表和对应的物理内存
// 返回0表示成功,-1表示失败
// 失败时会释放所有已经分配的内存
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;
uint64 pa, i;
uint flags;
char *mem;
// sz指要复制的地址空间大小,被调用时传入p->sz,表示整个地址空间
// 对整个地址空间逐页复制
for(i = 0; i < sz; i += PGSIZE){
// 如果寻找过程中发现有PTE不存在,陷入panic
if((pte = walk(old, i, 0)) == 0)
panic("uvmcopy: pte should exist");
// PTE存在但是对应页未被使用,陷入panic
// 再次强调,用户空间的内存使用是严格紧密的,中间不会有未使用的页存在
// 自下而上:text、data、guard page、stack、heap
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;
}
}
// 成功时返回0
return 0;
// 错误处理程序,解除所有已经分配的映射关系,并释放对应的物理内存,返回-1
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
1.15 uvmclear函数
uvmclear函数专门用来清除一个PTE的用户使用权限,用于exec函数来设置守护页。实现非常简单,如下所示:
// mark a PTE invalid for user access.
// used by exec for the user stack guard page.
// 译:将一个PTE标记为用户不可访问的
// 用在exec函数中来进行用户栈守护页的设置
void
uvmclear(pagetable_t pagetable, uint64 va)
{
pte_t *pte;
// 使用walk函数找到对应的PTE
pte = walk(pagetable, va, 0);
// 如果找不到va对应的PTE就陷入panic
if(pte == 0)
panic("uvmclear");
// 否则将对应PTE的User位清空
*pte &= ~PTE_U;
}
1.16 uvmfree函数
还记得我们之前所说的uvmunmap用于取消叶级页表的映射关系,freewalk用于释放页表页,两者结合可以完全释放内存空间吗。uvmfree函数就是两者的一个简单结合和封装,用于完全释放用户的地址空间。
// Free user memory pages,
// then free page-table pages.
// 译:释放用户内存页
// 然后释放页表页
void
uvmfree(pagetable_t pagetable, uint64 sz)
{
// 如果用户内存空间大小大于0,首先调用uvmunmap完全释放所有的叶级页表映射关系和物理页
if(sz > 0)
uvmunmap(pagetable, 0, PGROUNDUP(sz)/PGSIZE, 1);
// 然后再释放页表页
freewalk(pagetable);
}
到这里位置,我们就完全将用于操纵用户态的函数(uvm*)全部研究透了,可以稍微松一口气。接下来就剩下三个函数copyin、copyout和copyinstr了,这三个函数专门用于在内核态和用户态之间进行数据传递。
这篇文章也不短了,还是写在下一篇吧…
点击此处跳转到下一篇博客