MIT 6.S081 2021: Lab mmap
mmap
mmap就是把指定的文件fd映射到进程内存空间的某一个部分,映射建立之后,进程读写这块内存就像是在读写文件一样。按照提示来做实验:
Implement
mmap
: find an unused region in the process's address space in which to map the file, and add a VMA to the process's table of mapped regions. The VMA should contain a pointer to astruct file
for the file being mapped;mmap
should increase the file's reference count so that the structure doesn't disappear when the file is closed (hint: seefiledup
). Runmmaptest
: the firstmmap
should succeed, but the first access to the mmap-ed memory will cause a page fault and killmmaptest
.
这就是mmap的设计思路了。首先是在进程的地址空间里“找到一个未使用的区域”。我们能想到的,肯定可以合法访问的”未使用的区域“只有堆的顶部p->sz之上的地址了。lab lazy里的sbrk()函数就是通过对p->sz作修改来对进程的堆空间做伸缩操作。因此mmap的区域一定是基于p->sz进行操作的。
然后要求给进程加入一个VMA表。VMA表里面存储了每次调用mmap()得到的映射空间的信息。每个进程都有一个VMA表,理所当然的我们应该把它放在struct proc中。VMA的结构如下:
typedef struct vma{
struct file* mmapfile; //mmap()函数打开的文件的指针
struct inode* ip; //指向上述文件的inode
uint64 mmapaddr; //mmap()分配的映射起始地址
uint64 mmapend; //映射结束的位置
uint64 mmlength; //映射的剩余长度。注意这个值是会变化的
int mmprot; //mmap()参数里指定的prot
int mmflag; //mmap()参数里指定的flag
int valid; //该项是否空闲。若不空闲则置为1,反之置为0
}vma;
把下面这个数组加到struct proc里面:
vma map_region[16];
然后开始设计sys_mmap系统调用。按照提示,mmap()不能分配页面,不能读入文件,这些操作必须依靠page fault执行。也就是这些操作应该写在usertrap()里面。mmap()的主要工作就是分配地址,思路如下:
-
通过p->trapframe传入mmap()的参数。注意这个lab中addr和offset就是0,不用传参。
-
在map_region里面找一个空位置,先把已经确定的参数写入这个位置。这里使用一个简单的mapalloc()函数,如果找不到空位置就返回-1:
static int mapalloc() { int i; struct proc *p = myproc(); for(i = 0; i < NOFILE; i++){ if(p->map_region[i].valid == 0){ p->map_region[i].valid=1; return i; } } return -1; }
-
使用filedup()增加映射文件的引用数。
-
为映射区域找一个起始地址addr,写入mmapaddr和mmapend,直接返回addr。
sys_mmap()代码如下:
uint64 sys_mmap(void)
{
struct proc *p = myproc();
//传入参数
uint64 fail=(uint64)((char*)-1);
uint64 addr;
uint64 length=p->trapframe->a1;
int prot=p->trapframe->a2;
int flags=p->trapframe->a3;
int fd=p->trapframe->a4;
//检查打开的文件。如果是read-only文件开启了MAP_SHARED,则必须返回错误
if((p->ofile[fd]->writable)==0 && (flags&MAP_SHARED)&&(prot&PROT_WRITE)){
return fail;
}
//在map_region里面找到一个空位
int idx=mapalloc();
//printf("%d idx\n",idx);
//初始化
p->map_region[idx].mmlength=length;
p->map_region[idx].mmprot=prot;
p->map_region[idx].mmflag=flags;
p->map_region[idx].mmapfile=p->ofile[fd];
p->map_region[idx].ip=p->ofile[fd]->ip;
//file ref++
filedup(p->ofile[fd]);
//寻找一个地址
addr=PGROUNDUP(p->sz);
p->sz+=PGROUNDUP(length);
//p确定mmap的范围
p->map_region[idx].mmapaddr=addr;
p->map_region[idx].mmapend=addr+PGROUNDUP(length);
//printf("mmap range %p---%p\n",p->map_region[idx].mmapaddr,p->map_region[idx].mmapend);
return addr;
}
这里说明一下:
- mmaptest会检查对只读文件的映射这种情况。如果一个文件以O_RDONLY打开,那么如果同时开启MAP_SHARED和PROT_WRITE就意味着:映射区域可写,修改过的映射区域需要写回文件,这和O_RDONLY是冲突的,必须返回错误值0xffffffffffffffff。
- 如何寻找映射地址:这里使用PGROUNDUP(p->sz)作为映射的起始地址addr。为映射分配PGROUNDUP(length)个字节的空间,这样的话映射空间的地址就可以对齐页表。然后立刻把p->sz加上PGROUNDUP(length),不能拖到usertrap()里面再加。否则,当进程连续多次调用mmap()时,mmap()每次分配的会是相同的起始地址,所有映射空间会互相覆盖。
现在设计page fault的机制。这里仿照lab lazy的解决方案即可:
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if((which_dev = devintr()) != 0){
// ok
}
else if(r_scause()==13||r_scause()==15)
{
uint64 stval=r_stval();
//找到stval在哪个map区域里面
int idx=findmap(stval);
if(idx>=0)
{
int PTEword=PTE_U;
int prot=(p->map_region[idx]).mmprot;
uint64 length=(p->map_region[idx]).mmlength;
//设置关键字
if(prot&PROT_READ)
PTEword|=PTE_R;
if(prot&PROT_WRITE)
PTEword|=PTE_W;
if(prot&PROT_EXEC)
PTEword|=PTE_X;
uint64 sz=(p->map_region[idx]).mmapaddr;
uint64 newsz=(p->map_region[idx]).mmapend;
if((newsz=mmapalloc(p->pagetable, sz, newsz,PTEword))==0){
printf("allocate error");
}
struct inode* ip=p->map_region[idx].ip;
ilock(ip);
readi(ip,1,(p->map_region[idx]).mmapaddr,0,length);
iunlock(ip);
}
else{
p->killed = 1; //没找到vma务必记得kill进程
}
}
else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
-
首先是检测page fault。r_scause()等于13或15的时候说明发生了page fault,触发page fault的虚拟地址可以通过r_stval()获取。出现之后先使用findmap()在vma表里搜索该地址对应的映射。findmap()的原理很简单:传入一个地址addr,遍历vma表,看addr在哪个项的映射区间里即可,如果没找到返回-1:
int findmap(uint64 addr) { struct proc *p = myproc(); int i; for(i=0;i<16;i++) { uint64 a=p->map_region[i].mmapaddr; uint64 b=p->map_region[i].mmapend; if(addr>=a && addr<b){ return i; } } return -1; }
这里务必要注意一个细节:在本lab中,只有当进程访问了mmap()的未调入文件的映射空间时,page fault才是正常的。其他任何导致page fault的情况都是异常的。因此,如果findmap返回-1,说明进程访问了页表里不存在的、不该访问的地址空间,应该将其终止。(usertests里面的kernmem会检查这种情况,我费了好大力气才找到原因)
-
然后分配内存即可,这里如果产生了page fault,就直接把整个文件调入内存。稍微修改一下uvmalloc()得到mmapalloc(),它可以指定page的PTE项。mmapalloc()还有一个好处就是可以确保分配连续的虚拟内存空间。
uint64 mmapalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz, int prot) { char *mem; uint64 a; if(newsz < oldsz) return oldsz; //oldsz = PGROUNDUP(oldsz); for(a = oldsz; a < newsz; a += PGSIZE){ //printf("maphere\n"); mem = kalloc(); if(mem == 0){ uvmdealloc(pagetable, a, oldsz); return 0; } memset(mem, 0, PGSIZE); if(mappages(pagetable, a, PGSIZE, (uint64)mem, prot) != 0){ kfree(mem); uvmdealloc(pagetable, a, oldsz); return 0; } } return newsz; }
-
使用readi读入inode的数据到映射起始地址即可。注意为inode加上读写锁。
munmap
munmap()需要解除mmap()的映射。继续按照提示实现:
Implement
munmap
: find the VMA for the address range and unmap the specified pages (hint: useuvmunmap
). Ifmunmap
removes all pages of a previousmmap
, it should decrement the reference count of the correspondingstruct file
. If an unmapped page has been modified and the file is mappedMAP_SHARED
, write the page back to the file. Look atfilewrite
for inspiration.
这里需要注意,munmap不一定会释放整个映射。不过munmap()会按照从低到高的顺序,从剩余映射的起始释放:
An
munmap
call might cover only a portion of an mmap-ed region, but you can assume that it will either unmap at the start, or at the end, or the whole region (but not punch a hole in the middle of a region).
munmap的思路还是很容易的:先使用findmap()找到对应的vma项。如果mmap()指定了MAP_SHARED,需要用filewrite()把映射里的修改写回文件。使用PGROUNDUP(length)/PGSIZE计算从addr开始需要释放的页数。
使用uvmunmap()释放相应的页,并将p->map_region[idx].mmlength减去length项。如果mmlength为0,说明映射已经彻底释放了,使用fileclose()关闭对应文件,并使用memset()把vma项清零。
uint64 sys_munmap(void)
{
struct proc *p = myproc();
uint64 addr=p->trapframe->a0;
uint64 length=p->trapframe->a1;
//printf("unmap %p:addr %p:length\n",addr,length);
int idx=findmap(addr);
if(idx<0)
{
return -1;
}
int npages=PGROUNDUP(length)/PGSIZE;
//如果设置了MAP_SHARED
if(p->map_region[idx].mmflag & MAP_SHARED)
{
//printf("reach here1\n");
filewrite(p->map_region[idx].mmapfile, addr, length);
}
//printf("reach here2\n");
uvmunmap(p->pagetable,addr,npages,1);
p->map_region[idx].mmlength-=length;
if(p->map_region[idx].mmlength==0)
{
fileclose(p->map_region[idx].mmapfile);
//清除表项
memset((void*)&p->map_region[idx],0,sizeof(vma));
}
return 0;
}
fork
修改fork()和exit(),让子进程拥有父进程的映射空间,实现效果是这样的:
不过本实验允许子进程不和父进程共享物理页面,只需要让两个进程的映射空间映射到同一个文件就可以了。
所以很简单,直接在fork()的时候把vmap表复制到子进程就可以了。注意:Don't forget to increment the reference count for a VMA's struct file
. 把如下代码插入fork():
memmove(&np->map_region, &p->map_region,sizeof(vma)*16);
for(int idx=0;idx<16;idx++)
{
if(p->map_region[idx].valid!=0)//如果存在映射
{
filedup(p->map_region[idx].mmapfile);
}
}
还有一个问题:fork()复制页表的时候会调用uvmcopy()来复制父进程的页。如果只用上面的代码,会出现panic:uvmcopy: page not present。这是因为:调用mmap之后我们已经扩大了sz,但如果没有访问映射地址的话,映射地址内是没有合法的页的,这时sz里会包含PTE_V==0的页。uvmcopy会复制0到sz的所有页表,因此会访问这些尚未通过page fault载入的页面,从而panic。
解决方法很简单:uvmcopy检查PTE_V的时候直接跳过,不执行复制即可。稍微修改一下uvmcopy()得到mmapcopy(),把fork()里面的uvmcopy换掉即可:
for(i = 0; i < sz; i += PGSIZE){
if((pte = walk(old, i, 0)) == 0)
panic("uvmcopy: pte should exist");
if((*pte & PTE_V) == 0)
{
continue;
}
//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;
}
}
exit()需要释放所有的映射。
遍历vma表。因为映射长度不一定是初始时的长度,所以要计算出剩余映射空间的起始位置offset.因为本实验中所有munmap的区域都是从低到高紧密连接的,所以offset到mmapend的距离一定是剩余的length。
for(int idx=0;idx<16;idx++)
{
if(p->map_region[idx].valid!=0)//如果存在vma
{
uint64 len=p->map_region[idx].mmlength;
uint64 offset=p->map_region[idx].mmapend-len;
fileclose(p->map_region[idx].mmapfile);
uvmunmap(p->pagetable,offset,PGROUNDUP(len)/PGSIZE,1);
memset((void*)&p->map_region[idx],0,sizeof(vma));
}
}
这里uvmunmap也会出现panic,修改一下uvmunmap跳过PTE_V的检测:(其实这里也应该像上面一样新定义一个函数)
for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
if((pte = walk(pagetable, a, 0)) == 0)
panic("uvmunmap: walk");
if((*pte & PTE_V) == 0)
{
return;
}
//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);
}
*pte = 0;
}