Lab: mmap (hard)
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
返回该地址,如果失败,则返回0xffffffffffffffff
。 length
是要映射的字节数; 它可能与文件的长度不同。prot
指示内存是否应映射为可读、可写和/或可执行; 您可以假设prot
是PROT_READ
或PROT_WRITE
或两者兼而有之。flags
要么是MAP_SHARED
,这意味着对映射内存的修改应该写回文件,要么是MAP_PRIVATE
,这意味着它们不应该写回文件。你不必在flags
中实现任何其他位。fd
是要映射的文件的打开文件描述符。- 您可以假设偏移量为零(文件中要映射的起点)。
对于映射同一MAP_SHARED
文件的不同进程,禁止共享物理页。
munmap(addr,length)
应该删除指定地址范围内的mmap映射。如果进程修改了内存且为MAP_SHARED
映射,则应首先将修改写入文件。munmap
调用可能只覆盖已被映射的区域的一部分,但您可以假设它会在开始时、结尾或整个区域取消映射(但不会在区域中间打一个洞)。
您应该实现足够的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
$
提示
- 首先向
UPROGS
添加_mmaptest
,以及mmap
和munmap
系统调用,以便使user/mmaptest.c
能够编译。现在,只需从mmap
和munmap
返回错误。我们在kernel/fcntl.h
中为您定义了PROT_READ
等。运行mmaptest
,这将在第一次mmap
调用时失败。 - 惰性地填写页表,以响应页面错误。也就是说,
mmap
不应分配物理内存或读取文件。相反,请在usertrap
中(或由usertrap
调用)的页面错误处理代码中执行此操作,就像在惰性页面分配实验中一样。惰性的原因是确保大文件的mmap
是快速的,并且大于物理内存的文件的mmap
是可能的。 - 跟踪
mmap
为每个进程映射的内容。定义与第 15 讲中描述的 VMA(虚拟内存区域)对应的结构,记录mmap
创建的虚拟内存范围的地址、长度、权限、文件等。由于 xv6 内核中没有内存分配器,因此可以声明一个固定大小的 VMA 数组,并根据需要从该数组进行分配。大小为 16 就足够了。 - 实现
mmap
:在进程的地址空间中查找要在其中映射文件的未使用区域,并将 VMA 添加到进程的映射区域表中。VMA 应包含指向要映射的文件的struct file
的指针;mmap
应增加文件的引用计数,以便在关闭文件时结构不会消失(提示:请参阅filedup
)。运行mmaptest
:第一个mmap
应该成功,但第一次访问已映射的内存会导致页面错误并 killmmaptest
。 - 添加代码,以在访问已映射区域中导致的页面错误时,分配一页物理内存,将相关文件的 4096 字节读取到该页面中,并将其映射到用户地址空间。使用
readi
读取文件,它需要一个偏移参数来读取文件(但您必须锁定/解锁传递给readi
的 inode)。不要忘记在页面上正确设置权限。运行mmaptest
;它应该到达第一个munmap。 - 实现
munmap
:找到地址范围的 VMA 并取消映射指定的页面(提示:使用uvmunmap
)。如果munmap
删除了前一个mmap
的所有页面,它应该减少相应struct file
的引用计数。如果已修改未映射的页面并且文件已映射MAP_SHARED
,请将该页面写回该文件。查看filewrite
以获得灵感。 - 理想情况下,您的实现只会写回真正被程序修改的
MAP_SHARED
页面。RISC-V PTE 中的脏位 (D) 指示是否已写入页面。但是,mmaptest
不会检查非脏页面是否没有写回; 因此,您可以在不查看 D 位的情况下重新编写页面。 - 修改
exit
以取消映射进程的映射区域,就像调用munmap
一样。运行mmaptest
;mmap_test
应该通过,但可能不会通过fork_test
。 - 修改
fork
以确保子级与父级具有相同的映射区域。不要忘记递增 VMAstruct file
的引用计数。在子级的页面错误处理程序中,可以分配新的物理页面,而不是与父级共享页面。后者会更酷,但需要更多的工作。运行mmaptest
; 它应该通过mmap_test
和fork_test
。
实现
- 添加
mmap
和munmap
系统调用:添加系统调用通用步骤(在用户空间声明系统调用、添加条目,在内核空间增加系统调用命令序号以及对应的sys_mmap()
和sys_munmap()
函数) 在
proc.h
中struct proc
中添加映射区域:首先定义映射区域结构体struct MapArea
及一个进程映射区域的数量:#define MAP_AREA_LENGTH 16 struct MapArea { uint64 address; // 映射开始地址 int length; // 映射区域长度 int prot; int flag; int offset; struct file* fp; };
然后在
struct proc
中添加表示映射区域的数组字段:struct proc { ... struct MapArea mapareas[MAP_AREA_LENGTH]; };
实现
sys_mmap()
的功能:uint64 sys_mmap(void) { uint64 addr; int len, prot, flag, fd, offset, i; struct file* fp; struct proc* p = myproc(); argaddr(0, &addr); argint(1, &len); argint(2, &prot); argint(3, &flag); if (argfd(4, &fd, &fp) < 0) return -1; argint(5, &offset); // 判断权限是否冲突 if (!fp->writable && (prot & PROT_WRITE) && (flag & MAP_SHARED)) { printf("file is not writable!\n"); return -1; } // 找空闲map area for (i = 0; i < MAP_AREA_LENGTH; ++i) { if (p->mapareas[i].address == 0) break; } if (i == MAP_AREA_LENGTH) { printf("no more map area!\n"); return -1; } // 若未指定地址则需要分配地址 if (!addr) { addr = PGROUNDUP(p->sz); p->sz += PGROUNDUP(len); } // 复制字段 p->mapareas[i].address = addr; p->mapareas[i].length = len; p->mapareas[i].prot = prot; p->mapareas[i].flag = flag; p->mapareas[i].offset = offset; p->mapareas[i].fp = fp; // 文件引用计数+1 filedup(fp); return addr; }
在
usertrap()
中识别页面错误并将文件内容写入对应地址空间:首先需要在usertrap()
原本的选择分支框架中添加页面错误的分支,在该分支中分配内存并将文件内容写入到指定位置。这里通过map_fill()
函数完成此功能。void usertrap(void) { ... if(r_scause() == 8){ // system call ... } else if (r_scause() == 13 || r_scause() == 15) { // 页面错误,需要分配内存并写入文件内容 if (map_fill(r_stval()) == 0) { goto error; } } else if((which_dev = devintr()) != 0){ // ok } else { error: printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid); printf(" sepc=%p stval=%p\n", r_sepc(), r_stval()); setkilled(p); } ... }
map_fill()
函数完成分配内存并写入文件内容的功能:uint64 map_fill(uint64 va) { int idx, perm, prot, offset; struct inode* ip; void* new_mem; struct proc* p = myproc(); // 找到哪个map area for (idx = 0; idx < MAP_AREA_LENGTH; ++idx) { if (va >= p->mapareas[idx].address && va < p->mapareas[idx].address + p->mapareas[idx].length) break; } if (idx == MAP_AREA_LENGTH) return 0; // 分配内存 if ((new_mem = kalloc()) == 0) { return 0; } // 物理虚拟地址建立映射 prot = p->mapareas[idx].prot; perm = PTE_U; if (prot & PROT_READ) perm |= PTE_R; if (prot & PROT_WRITE) perm |= PTE_W; if (prot & PROT_EXEC) perm |= PTE_X; if (mappages(p->pagetable, va, PGSIZE, (uint64)new_mem, perm) == -1) { kfree(new_mem); return 0; } // 拷贝文件内容 ip = p->mapareas[idx].fp->ip; if (ip == 0) { printf("ip == 0\n"); return 0; } offset = p->mapareas[idx].offset; ilock(ip); if (p->mapareas[idx].length - offset > PGSIZE) { readi(ip, 1, va, offset, PGSIZE); } else { readi(ip, 1, va, offset, p->mapareas[idx].length - offset); } p->mapareas[idx].offset += PGSIZE; iunlock(ip); return (uint64)new_mem; }
实现
sys_munmap()
:uint64 sys_munmap(void) { uint64 addr; int length, idx; struct proc* p = myproc(); argaddr(0, &addr); argint(1, &length); // 找到addr对应的map area for (idx = 0; idx < MAP_AREA_LENGTH; ++idx) { if (addr >= p->mapareas[idx].address && addr < p->mapareas[idx].address + p->mapareas[idx].length) break; } if (idx == MAP_AREA_LENGTH) return -1; // MAP_SHARED写回文件 if (p->mapareas[idx].flag & MAP_SHARED) { filewrite(p->mapareas[idx].fp, addr, length); } // 解除映射 uvmunmap(p->pagetable, addr, PGROUNDUP(length) / PGSIZE, 1); if (PGROUNDUP(length) >= p->mapareas[idx].length) { // 若解除了所有映射,则文件引用计数-1,并将地址置0以标记该区域空闲 fileclose(p->mapareas[idx].fp); p->mapareas[idx].address = 0; } else { // 若未解除所有映射,则调整映射区域的范围(地址及长度) p->mapareas[idx].length -= PGROUNDUP(length); p->mapareas[idx].address += PGROUNDUP(length); } return 0; }
在
exit()
中解除映射区域的映射:void exit(int status) { struct proc *p = myproc(); if(p == initproc) panic("init exiting"); // 解除map area的mmap for (int i = 0; i < MAP_AREA_LENGTH; ++i) { if (p->mapareas[i].address) { // MAP_SHARED写回文件 if (p->mapareas[i].flag & MAP_SHARED) { filewrite(p->mapareas[i].fp, p->mapareas[i].address, p->mapareas[i].length); } // 解除映射 uvmunmap(p->pagetable, p->mapareas[i].address, PGROUNDUP(p->mapareas[i].length) / PGSIZE, 1); fileclose(p->mapareas[i].fp); p->mapareas[i].address = 0; } } ... }
在
fork()
中复制map areaint fork(void) { ... pid = np->pid; // 拷贝映射区 for (i = 0; i < MAP_AREA_LENGTH; ++i) { if (p->mapareas[i].address) { np->mapareas[i] = p->mapareas[i]; filedup(np->mapareas[i].fp); // 文件引用计数+1 } } ... }
问题
- panic: uvmunmap: not mapped
原因:munmap()
时,实际上可能存在部分文件内容没有访问、因而没有触发页面错误、没有建立映射的情况,这时候是对着没有映射过的内存解除映射,uvmunmap()
对这种情况会报错。
我的处理方法是将uvmunmap()
中(*pte & PTE_V) == 0
的情况由原来的panic(...)
改为continue
。碰到没有映射过的内存时,不触发panic而是忽略掉继续运行。
在fork()
中uvmcopy()
时碰过到类似情况也做类似处理。
结果
收获
mmap()
的作用是将(部分)文件内容与一段内存建立映射关系,以加速程序对文件的访问。mmap()
有两种形式,其中MAP_SHARED
模式不仅可以读文件,还可以将程序对映射内存的改动写回到文件中(如果文件可写)