MIT 6.S081 Mmap
mmap/munmap
mmap和munmap系统调用允许UNIX程序对其地址空间进行详细控制。它们可以用于在进程之间共享内存,将文件映射到进程地址空间,并作为用户级页面错误方案的一部分,如讲座中讨论的垃圾收集算法。在这个实验中,您将向xv6添加mmap和munmap,重点关注内存映射文件。
手册页面(运行 man 2 mmap)显示了mmap的声明:
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
mmap可以通过多种方式调用,但这个实验只需要其与内存映射文件相关的特性的子集。您可以假设addr始终为零,这意味着内核应该决定映射文件的虚拟地址。mmap返回该地址,如果失败则返回0xffffffffff。length是要映射的字节数;它可能与文件的长度不同。prot指示存储器是否应该被映射为可读的、可写的和/或可执行的;您可以假设prot是PROT_READ或PROT_WRITE,或者两者都是。标志将是MAP_SHARED,意味着对映射内存的修改应写回文件,或者是MAP_PRIVATE,意味着它们不应写回。您不必在标志中实现任何其他位。fd是要映射的文件的打开文件描述符。您可以假设偏移为零(它是文件中映射的起点)。
如果映射同一个MAP_SHARED文件的进程不共享物理页面,也可以。
munmap(addr,length)应该删除指示地址范围内的mmap映射。如果进程已修改内存并将其映射到MAP_SHARED,则应首先将修改写入文件。munmap调用可能只覆盖mmap-ed区域的一部分,但您可以假设它将在开始、结束或整个区域取消映射(但不能在区域中间打孔)。
实验任务
实验任务:您应该实现足够的mmap和munmap功能,以使mmaptest测试程序正常工作。如果mmaptest不使用mmap功能,则不需要实现该功能。
完成后,您应该会看到以下输出:
$ mmaptest
mmap_test starting
test mmap f
test mmap f: OK
test mmap private
test mmap private: OK
test mmap read-only
test mmap read-only: OK
test mmap read/write
test mmap read/write: OK
test mmap dirty
test mmap dirty: OK
test not-mapped unmap
test not-mapped unmap: OK
test mmap two files
test mmap two files: OK
mmap_test: ALL OK
fork_test starting
fork_test OK
mmaptest: all tests succeeded
$ usertests -q
usertests starting
...
ALL TESTS PASSED
Hints
- 首先,将_mmaptest添加到UPROGS,以及mmap和munmap系统调用,以便让user/maptest.c进行编译。现在,只返回mmap和munmap中的错误。我们在kernel/fcntl.h中为您定义了PROT_READ等。运行mmaptest,它将在第一次mmap调用时失败。
- Lazy分配页面,以应对页面错误。也就是说,mmap不应该分配物理内存或读取文件。相反,在usertrap中(或由usertrap调用)的页面错误处理代码中这样做,就像在lazy page allocation lab中一样。lazy的原因是确保大文件的mmap很快,并且文件的mmap大于物理内存是可能的。
- 跟踪mmap为每个进程映射了什么。定义与第15讲中描述的VMA(虚拟内存区域)相对应的结构,记录mmap创建的虚拟内存范围的地址、长度、权限、文件等。由于xv6内核中没有内存分配器,因此可以声明一个固定大小的VMA数组,并根据需要从该数组中进行分配。16号应该足够了。
- 实现mmap:在进程的地址空间中找到一个未使用的区域来映射文件,并将VMA添加到进程的映射区域表中。VMA应包含指向要映射的文件的结构文件的指针;mmap应该增加文件的引用计数,这样当文件关闭时结构就不会消失(提示:请参阅filedup)。运行mmaptest:第一个mmap应该成功,但第一次访问mmap-ed内存将导致页面错误并终止mmaptest。
- 在mmap-ed区域中添加导致页面错误的代码,以分配一页物理内存,将相关文件的4096字节读取到该页面中,并将其映射到用户地址空间中。使用readi读取文件,它采用一个偏移量参数来读取文件(但您必须锁定/解锁传递给readi的inode)。别忘了在页面上正确设置权限。运行mmaptest;它应该到达第一个munmap。
- 实现munmap:找到地址范围的VMA并取消映射指定的页面(提示:使用uvmunmap)。如果munmap删除了前一个mmap的所有页面,那么它应该减少相应结构文件的引用计数。如果未映射的页面已被修改,并且文件已映射为MAP_SHARED,请将页面写回该文件。从filewrite中寻找灵感。
- 理想情况下,您的实现只会写回程序实际修改的MAP_SHARED页面。RISC-V PTE中的脏比特(D)指示页面是否已经被写入。但是,mmaptest不会检查是否未回写非脏页;因此,您可以在不看D位的情况下写回页面。
- 修改exit以取消映射进程的映射区域,就好像调用了munmap一样。运行mmaptest;mmap_test应该通过,但可能不会通过fork_test。
- 修改fork以确保子对象具有与父对象相同的映射区域。不要忘记增加VMA结构文件的引用计数。在子级的页面错误处理程序中,可以分配一个新的物理页面,而不是与父级共享页面。后者会更酷,但需要更多的实施工作。运行mmaptest;它应该同时通过mmaptest和forktest。
运行usertests -q以确保一切正常。
解决方案
我们可以根据Hints来构建解决方案。
准备
在这个实验以前肯定已经完成很多的实验了,这里主要是添加函数原型,以便能调用mmap/munmap
VMA
struct vma {
struct spinlock lock;
uint64 start;
uint64 end;
int length;
int off;
int perm;
int flags;
struct file *file;
struct vma *next;
};
struct vma VMA[NVMA];
注意将VMA中每个length变为-1,说明此结构没有被占用,内核可以分配给任意进程。
struct vma *
vma_alloc(void) {
for(int i = 0; i < NVMA; i++){
if (VMA[i].length == -1) {
acquire(&VMA[i].lock);
if(VMA[i].length == -1) {
VMA[i].length = 0;
release(&VMA[i].lock);
return &VMA[i];
}
release(&VMA[i].lock);
}
}
panic("no enough vma");
}
先看一下是否有可能有要释放的结构,如果有,去夺取锁,然后再次检测,看是否已经被分配,没有被分配就可以分配给当前任务。
mmap
uint64
sys_mmap(void) {
uint64 addr;
int length;
int prot;
int flags;
int fd;
int offset;
argaddr(0, &addr);
argint(1, &length);
argint(2, &prot);
argint(3, &flags);
argint(4, &fd);
argint(5, &offset);
if (addr != 0 || offset != 0) {
return -1;
}
struct proc *p = myproc();
struct file* f = p->ofile[fd];
int pte_flag = PTE_U;
if (prot & PROT_WRITE) { // 想要写,那么文件必须是可写, 或者标志是私有
if(!f->writable && !(flags & MAP_PRIVATE)) {
return -1;
}
pte_flag |= PTE_W;
}
if (prot & PROT_READ) {
if(!f->readable) {
return -1;
}
pte_flag |= PTE_R;
}
struct vma* v = vma_alloc();
v->length = length;
v->off = offset;
v->perm = pte_flag;
v->flags = flags;
filedup(f);
v->file = f;
v->next = (struct vma*)0;
struct vma *pv = p->vma;
if (pv) {
while (pv->next) {
pv = pv->next;
}
v->start = PGROUNDUP(pv->end); // 使得开始的位置为一页的整数
v->end = v->start + length;
pv->next = v;
} else {
v->start = VMA_START;
v->end = v->start + length;
p->vma = v;
}
addr = v->start;
printf("mmap: [%p, %p)\n", addr, v->end); // for debugging
return addr;
}
Page fault 处理
在trap.c中,如果是因为缺页导致的错误,进入缺页处理程序,此时检测这个地址是否可以被翻译。
因为这个实验中,我们只对mmap做了lazy分配,所以地址要在vma的地址之间,否则就是因为其他的原因导致的缺页,不归当前的实验处理,返回-1,同时要注意在trap.c中的这个分支,看到了返回-1,需要杀死这个进程。因为这个进程可能访问、写、执行了他没有权限的地址,这是一个致命的错误。
int
mmap_handler(uint64 scause, uint64 va) {
struct proc *p = myproc();
struct vma *v = p->vma;
while (v) {
if (va >= v->start && va < v->end) {
break;
}
v = v->next;
}
if (v == 0 || scause == 12 || (scause == 13 && !(v->perm & PTE_R)) || (scause == 15 && !(v->perm & PTE_W))) {
// 12 执行指令,在这里不可能映射动态链接库用于执行指令,所以不行
// 13 load 引起,但是如果不可读,那么会错误
// 15 store 引起,不可写也会错误
return -1;
}
va = PGROUNDDOWN(va); // 找打页面
char *mem = kalloc();
if (mem == 0) {
// panic("mmap_handler: kalloc error");
return -1;
}
memset(mem, 0, PGSIZE);
uint doff = va + v->off - v->start; // 在文件中的实际开始位置
ilock(v->file->ip);
readi(v->file->ip, 0, (uint64)mem, doff, PGSIZE);
iunlock(v->file->ip);
// 此时已经读入了文件的内容
if (mappages(p->pagetable, va, PGSIZE, (uint64)mem, v->perm) < 0) {
kfree(mem);
// panic("mmap_handler: mappages error");
return -1;
}
return 0;
}
munmap
这个是比较难处理的,看了好些解答,但是没有一个解答是让我满意的,基本看了头几行,就发现他们代码不行,主要是他们的处理太过理想了。
很多的代码有如下的假设:
- 每次释放的地址addr都是PGSIZE的整数倍
- 每次释放的长度length都是PGSIZE的整数倍
- 在释放的时间点,需要释放的页面,在之前肯定都已经访问过了,所以在页面中肯定有其相应的PTE,而且是有效的,所以直接使用
void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
。
这上面的假设都是不正确的,特别是,在释放的时间点,很可能没有访问过这个页面,那么此时释放会导致释放一个未映射的页面,导致panic。
在mmaptest的代码中,上面头两个假设还是满足了,但是第三个假设在mmaptest中都没有满足。
uint64
sys_munmap(void) {
uint64 addr;
int length;
argaddr(0, &addr);
argint(1, &length);
if (addr == 0 || length == 0) {
return 0; // 不用取消映射
}
struct proc *p = myproc();
struct vma *v = p->vma;
struct vma *pre = 0;
while(v != 0){
if(addr >= v->start && addr < v->end) break; // found
pre = v;
v = v->next;
}
if(v == 0)
return -1;
printf("munmap: %p %d\n", addr, length);
if(addr != v->start && addr + length != v->end)
panic("munmap middle of vma");
if (length > v->length) {
length = v->length;
}
if (addr == v->start) {
writeback(v, addr, length);
// 由于可能在同一个映射空间上调用两次munmap,所以这里的addr不一定是PGSIZE的整数倍
// 实际要释放的页为 从 PGROUNDDOWN(addr)开始的 (len + (addr - PGROUNDDOWN(addr))) / PGSIZE 数量的页。又因为可能当前还没有实际分配,所以不能使用uvmunmap
uint64 end = (length + (addr - PGROUNDDOWN(addr))) / PGSIZE * PGSIZE + PGROUNDDOWN(addr);
pte_t *pte;
for (uint64 va = PGROUNDDOWN(addr); va < end; va += PGSIZE) {
if((pte = walk(p->pagetable, va, 0)) == 0) {
continue;
}
if (*pte & PTE_V) {
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);
*pte = 0;
} // else 无效,不要释放
}
if(length == v->length){
// 将会全部释放,那么对于最后一页需要一些特别的操作
uint64 va = PGROUNDDOWN(v->end - 1);
if((pte = walk(p->pagetable, va, 0)) != 0) {
if (*pte & PTE_V) {
uint64 pa = PTE2PA(*pte);
kfree((void*)pa); // 释放最后一页
*pte = 0;
}
}
fileclose(v->file);
if(pre == 0){
p->vma = v->next; // head
}else{
pre->next = v->next;
}
v->next = 0;
acquire(&v->lock);
v->length = -1;
release(&v->lock);
} else {
v->start += length; // 开始位置应该加length
v->off += length;
v->length -= length;
}
} else {
// 从addr释放到end 那么要释放页,从UP(addr) ~ up(end)
pte_t *pte;
for(uint64 va = PGROUNDUP(addr); va < PGROUNDUP(v->end); va += PGSIZE){
if((pte = walk(p->pagetable, va, 0)) == 0) {
continue;
}
if (*pte & PTE_V) {
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);
*pte = 0;
}
}
v->length -= length;
v->end -= length;
}
return 0;
}
在我的实现中,考虑到了,这个条目根本没有在页表中的情况,这个代码更鲁棒。
共享文件取消映射后写回
这个同样有上面的问题,当没有访问过,也就不存在页表中,那就不要放回了,否则,会每次写入文件中的字节数为0,导致死循环。
void
writeback(struct vma* v, uint64 addr, int n)
{
if(!(v->perm & PTE_W) || (v->flags & MAP_PRIVATE)) // 不可写,或者这是一个私有的,那么不应该写回
return;
struct proc *p = myproc();
struct file* f = v->file;
int max = ((MAXOPBLOCKS-1-1-2) / 2) * BSIZE;
int i = 0;
pte_t *pte;
while(i < n){
if ((pte = walk(p->pagetable, PGROUNDDOWN(addr), 0)) == 0 || !(*pte & PTE_V)) {
// pte不存在或者无效,说明这部分没有更改
i += (PGROUNDDOWN(addr) + PGSIZE - addr);
addr += PGROUNDUP(addr + 1);
continue;
}
// 找到了物理位置
uint64 pa = PTE2PA(*pte);
uint64 start = addr - PGROUNDDOWN(addr); // 开始位置
int n1 = n - i;
if (n1 > PGSIZE - start) {
// 不超过这个页面
n1 = PGSIZE - start;
}
if (n1 > max) {
n1 = max;
}
begin_op();
ilock(f->ip);
int r = writei(f->ip, 0, pa + start, addr + v->off - v->start, n1);
iunlock(f->ip);
end_op();
i += r;
addr += r; // 下次从r位置开始写
}
}
exit 取消所有映射的文件
同样的问题,只释放实际加载的页面。
// Exit the current process. Does not return.
// An exited process remains in the zombie state
// until its parent calls wait().
void
exit(int status)
{
struct proc *p = myproc();
if(p == initproc)
panic("init exiting");
// munmap all mmap vma
struct vma* v = p->vma;
struct vma* pv;
while(v){
writeback(v, v->start, v->length);
uint64 start = PGROUNDDOWN(v->start);
uint64 end = PGROUNDUP(v->end);
pte_t *pte;
for (uint64 va = start; va < end; va += PGSIZE) {
if ((pte = walk(p->pagetable, va, 0)) == 0 || !(*pte & PTE_V)) {
continue; // 没有加载
}
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);
*pte = 0;
}
fileclose(v->file);
pv = v->next;
acquire(&v->lock);
v->next = 0;
v->length = -1;
release(&v->lock);
v = pv;
}
p->vma = 0;
// Close all open files.
...
}
fork 复制父进程的VMA
由于fork的mapcopy已经为子进程copy了所有父进程的页面,所以此时父进程中映射页面的分布是什么样的,子进程中就会是什么样的,直接复制他的vma结构就可以了。
当然,即使是lazy allocation,这样直接复制也没问题,因此此时子进程vma对应的所有页都是无效的,在访问时,会触发page fault,会使得其有效。
注意,不能直接使得子进程的vma指向父进程的vma,这样,父子进程不能写时复制,破坏了其能指向的页面,可能会造成严重的内存泄漏。
// Create a new process, copying the parent.
// Sets up child kernel stack to return as if from fork() system call.
int
fork(void)
{
int i, pid;
struct proc *np;
struct proc *p = myproc();
...
acquire(&np->lock);
np->state = RUNNABLE;
np->vma = 0;
struct vma *pv = p->vma;
struct vma *pre = 0;
while(pv){
struct vma *vma = vma_alloc();
vma->start = pv->start;
vma->end = pv->end;
vma->off = pv->off;
vma->length = pv->length;
vma->perm = pv->perm;
vma->flags = pv->flags;
vma->file = pv->file;
filedup(vma->file);
vma->next = 0;
if(pre == 0){
np->vma = vma;
} else {
pre->next = vma;
}
pre = vma;
pv = pv->next;
}
release(&np->lock);
return pid;
}