Appearance
mmap
开启新实验
git fetch
git checkout mmap
make clean
mmap
任务描述
实现一个 UNIX 操作系统中常见系统调用 mmap()
和 munmap()
的子集。此系统调用会把文件映射到用户空间的内存,这样用户可以直接通过内存来修改和访问文件。
可以在 Linux 系统中通过 man mmap
查看该命令的详细描述。
mmap()
的定义如下:
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
意思是映射描述符为 fd
的文件从 offset
开始的 length
个字节到 addr
的位置。
如果 addr
参数为 0,系统会自动分配一个空闲的内存区域来映射,并返回这个地址。
在实验中我们只需要支持 addr
和 offset
都为 0 的情况,也就是完全不用考虑用户指定内存和文件偏移量。
prot
和 flags
都是一些标志位(定义于 kernel/fcntl.h
)。
具体来说,prot
有以下的选项:
规定了能对映射后文件做的操作。
flags
则决定,如果在内存映射文件中做了修改,是否要在取消映射时,把这些修改更新到文件中。有 MAP_SHARED
和 MAP_PRIVATE
两个选项。
munmap(addr, length)
应该删除指定地址范围内的 mmap 映射。如果进程修改了内存并且 flags
标志位设为 MAP_SHARED
,则应首先将修改写入文件。munmap()
调用只覆盖 mmap 编辑区域的一部分,可以假设它将在开头或结尾或整个区域取消映射(但不会在区域中间打孔)。
实现过程
向 UPROGS 添加
_mmaptest
重复一下添加系统调用的一系列操作
在
user/user.h
中添加声明char* mmap(void*, int, int, int, int, int); int munmap(void*, int);
在
user/usys.pl
添加entry("mmap"); entry("munmap");
在
kernel/syscall.h
中添加定义#define SYS_mmap 22 #define SYS_munmap 23
修改
kernel/syscall.c
,用 extern 全局声明新的内核调用函数,并且在 syscalls 映射表中,加入从前面定义的编号到系统调用函数指针的映射extern uint64 sys_mmap(void); extern uint64 sys_munmap(void); static uint64 (*syscalls[])(void) = { ... [SYS_mmap] sys_mmap, [SYS_munmap] sys_munmap, }
在
kernel/proc.h
定义 VMA(虚拟内存区域)的结构,记录 mmap 创建的虚拟内存范围的地址、长度、权限、文件等。由于 xv6 内核中没有内存分配器,因此可以声明一个固定大小的 VMA 数组,并根据需要从该数组中分配。大小 16 应该就足够了。#define NVMA 16 struct vma { uint64 addr; uint64 len; int perm; int flags; struct file *file; }; struct proc { ... struct vma vma[NVMA]; };
VMA 是进程的私有字段,因此对于 xv6 的进程单用户线程的系统,访问 VMA 是无需加锁的。
在
kernel/sysfile.c
中实现系统调用sys_mmap()
(1):获取参数并进行简单的参数检查。uint64 sys_mmap(void){ uint64 addr, len, offset; int prot, flags, fd, perm = PTE_U | PTE_V; struct file *file; // void *mmap(void *addr, int length, int prot, int flags, int fd, int offset); if(argaddr(0, &addr) < 0) return -1; if(argaddr(1, &len) < 0) return -1; if(argint(2, &prot) < 0) return -1; if(argint(3, &flags) < 0) return -1; if(argfd(4, &fd, &file) < 0) return -1; if(argaddr(5, &offset) < 0) return -1; if(addr || offset) return -1; // 本实验 addr 和 offset 都为0 if(prot & PROT_WRITE){ // 如果文件不可写但是要求写 if(!file->writable && (flags & MAP_SHARED)) return -1; perm |= PTE_W; } if(prot & PROT_READ){ // 如果文件不可读但是要求读 if(!file->readable) return -1; perm |= PTE_R; } ... }
在
kernel/sysfile.c
中实现系统调用sys_mmap()
(2):从当前进程的 VMA 数组中为该映射分配一个 VMA 结构,遍历 VMA 数组,若 addr 为 0 则表示该 VMA 未被使用即可用于本次映射。uint64 sys_mmap(void){ ... struct proc* p = myproc(); struct vma *vma = 0; for(int i = 0; i < NVMA; i++){ if(!p->vma[i].addr){ vma = &p->vma[i]; break; } } if(!vma) return -1; ... }
在
kernel/sysfile.c
中实现系统调用sys_mmap()
(3):将参数记录到分配的 VMA 中,需要将prot
转化为 PTE。难点在于addr
参数都为 0,即需要内核自行选择映射的地址。还应该使用filedup()
增加文件的引用计数,这样当文件关闭时结构就不会被释放。最后返回找到的addr
。映射地址的选择
参考[MIT 6.s081] Xv6 Lab11 Mmap 实验记录 | tzyt的博客 (ttzytt.com)
TRAPFRAME 以下的堆区可以作为映射空间,遍历 VMA 数组,找到已被映射内存的最低地址,然后对该地址向下取页面对齐,进而确定映射地址。
#include "memlayout.h" uint64 sys_mmap(void){ ... addr = TRAPFRAME; for (int i = 0; i < NVMA; i++){ if(p->vma[i].addr){ addr = (addr < p->vma[i].addr ? addr : p->vma[i].addr); } } addr = PGROUNDDOWN(addr-len) vma->addr = addr; vma->len = len; vma->perm = perm; vma->flags = flags; vma->file = file; filedup(file); return addr; }
根据实验要求,映射内存采用的是 Lazy allocation,因此此处只需在 VMA 中记录映射的地址,而后续通过在
usertrap()
中对 page fault 处理进行实际的内存页面分配。修改
kernel/trap.c
中usertrap()
的代码(1):添加对 page fault 情况的检查。然后根据发生 page fault 的地址去当前进程的 VMA 数组中找对应的 VMA 结构体,并进行读错误与写错误的判断处理。注意:文件映射时参数
len
实际上可以超过文件大小,对应超过文件实际大小的部分,内容都会是 0,可以访问修改,但最后都不会写回文件中。scause
scause
寄存器中保存了进入trap的原因,下图是risc-v中该寄存器中值所代表的原因的表格,可以看到12、13、15都是和 page fault 相关的:由于映射的内存未分配,而且该内存读写执行都是有可能的,因此可能发生 13 读错误和 15 写错误。
void usertrap(void) { ... if(r_scause() == 8){ ... } else if(r_scause() == 13 || r_scause() == 15){ struct vma *vma = 0; uint64 va = r_stval(); for(int i = 0; i < NVMA; i++){ if(p->vma[i].addr && va >= p->vma[i].addr && va < p->vma[i].addr + p->vma[i].len){ vma = &p->vma[i]; break; } } if(!vma) goto err; if(r_scause() == 13 && (vma->perm & PTE_R)){ goto err; } if(r_scause() == 15 && (vma->perm & PTE_W)){ goto err; } ... } else if((which_dev = devintr()) != 0){ // ok } else { err: 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; } ... }
修改
kernel/trap.c
中usertrap()
的代码(2):使用kalloc()
先分配一个物理页, 并使用memset()
进行清空。接着使用readi()
根据发生 page fault 的地址从文件的相应部分读取内容到分配的物理页(在这个过程前后需要对文件的 inode 进行加锁)。最后即可使用mappages()
将物理页映射到用户进程的页面值。void usertrap(void) { ... if(r_scause() == 8){ ... } else if(r_scause() == 13 || r_scause() == 15){ ... va = PGROUNDDOWN(addr); char* pa = kalloc(); if(!pa) goto err; memset(pa, 0, PGSIZE); ilock(vma->file->ip); if(readi(vma->file->ip, 0, (uint64)pa, va - vma->addr, PGSIZE) == 0){ iunlock(vma->file->ip); goto err; } iunlock(vma->file->ip); if(mappages(p->pagetable, va, PGSIZE, (uint64)pa, vma->perm) != 0){ kfree(pa); goto err; } } else if((which_dev = devintr()) != 0){ // ok } else { err: 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; } ... }
在
kernel/sysfile.c
中实现系统调用sys_munmap()
。找到地址范围的 VMA,并取消映射指定的页面(提示:使用uvmunmap
)。如果 munmap 删除了前一个 mmap 的所有页面,它应该减少相应文件的引用计数。uint64 munmap(uint64 addr, uint64 len) { struct proc *p = myproc(); struct vma *vma = 0; for(int i = 0; i < NVMA; i++){ if(p->vma[i].addr && addr >= p->vma[i].addr && addr < p->vma[i].addr + p->vma[i].len){ vma = &p->vma[i]; break; } } if(!vma) return -1; if(addr > vma->addr && addr + len < vma->addr + vma->len){ return -1; } writeback(p->pagetable, addr, len, vma); uvmunmap(p->pagetable, addr, len / PGSIZE, 1); if(addr == vma->addr){ vma->addr += len; } vma->len -= len; if(vma->len <= 0){ fileclose(vma->file); vma->addr = 0; } return 0; } uint64 sys_munmap(void) { uint64 addr, len; if(argaddr(0, &addr) < 0 || argaddr(1, &len) < 0) return -1; return munmap(addr, len); }
分开另外写函数的原因是还需要在
exit()
里调用munmap()
,有些进程在退出后还没有取消它的文件映射,那我们就需要帮它强制清理掉这些映射,要不然会造成内存泄露。此外,此处修改了
uvmunmap()
函数的 PTE_V 标志位的检查部分,取消映射的页面可能并未实际分配,此时跳过即可。void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free) { // ... for(a = va; a < va + npages*PGSIZE; a += PGSIZE){ if((pte = walk(pagetable, a, 0)) == 0) panic("uvmunmap: walk"); if((*pte & PTE_V) == 0) { continue; // lab10 // panic("uvmunmap: not mapped"); // lab10 } if(PTE_FLAGS(*pte) == PTE_V) { continue; // lab10 // panic("uvmunmap: not a leaf"); // lab10 } // ... } }
写回函数先判断是否需要写回,当需要写回时才将数据写到对应的文件当中去。其中,我们利用了 PTE 的标志位 PTE_D 来判断文件映射的某个页帧是否被修改过。
// kernel/riscv.h #define PTE_D (1L << 7)
// kernel/vm.c void writeback(pagetable_t pt, uint64 addr, uint64 len, struct vma* vma) { // printf("starting writeback: %p %d\n", addr, len); uint64 a; pte_t *pte; for(a = PGROUNDDOWN(addr); a < PGROUNDDOWN(addr + len); a += PGSIZE){ if((pte = walk(pt, a, 0)) == 0){ panic("mmap_writeback: walk"); } if(PTE_FLAGS(*pte) == PTE_V) panic("mmap_writeback: not leaf"); if(!(*pte & PTE_V)) continue; // 懒分配 if((*pte & PTE_D) && (vma->flags & MAP_SHARED)){ // 写回 begin_op(); ilock(vma->file->ip); uint64 copied_len = a - addr; writei(vma->file->ip, 1, a, copied_len, PGSIZE); iunlock(vma->file->ip); end_op(); } kfree((void*)PTE2PA(*pte)); *pte = 0; } }
修改
exit()
以取消映射进程的映射区域(调用munmap()
)。// kernel/proc.c void exit(int status) { struct proc *p = myproc(); if(p == initproc) panic("init exiting"); // munmap all mmap vma for(int i = 0; i < NVMA; i++){ if(p->vma[i].addr){ if(munmap(p->vma[i].addr, p->vma[i].len) != 0) panic("exit: munmap"); } } // Close all open files. for(int fd = 0; fd < NOFILE; fd++){ if(p->ofile[fd]){ struct file *f = p->ofile[fd]; fileclose(f); p->ofile[fd] = 0; } } ... }
修改
fork()
以确保子级具有与父级相同的映射区域,并增加 VMA 的 struct 文件的引用计数。int fork(void) { ... for(int i = 0; i < NVMA; i++){ if(p->vma[i].addr){ np->vma[i] = p->vma[i]; filedup(p->vma[i].file); } } release(&np->lock); return pid; }
The End
终于结束了,最后这个实验做了好久。