6.s081 Lab9 mmap 实现
作为 xv6 的最后一个核心大实验,Lab 9 (mmap) 标着硬核的 “Hard” 难度。但从本质上讲,它其实是 Lab 3(缺页中断)和 Lab 8(文件系统)的融合。
mmap 和 munmap 系统调用允许 UNIX 程序对自己的地址空间进行极其细致的控制。通过将磁盘文件直接映射到内存中,程序可以像读写普通数组一样操作文件,不仅免去了频繁调用 read 和 write 的开销,
xv6-book 虚拟内存与 mmap 核心原理
一、传统 I/O vs 内存映射 (mmap)
在传统的 UNIX 编程中,读写文件需要经过繁琐的系统调用路径:
-
调用 read() 进入内核态。
-
内核将磁盘块读入 Buffer Cache。
-
内核将数据从内核空间拷贝到用户空间的 Buffer 中(内存拷贝开销)。
-
如果修改了数据,还要调用 write() 再原路写回。
而 mmap 提供了一种**零拷贝(Zero-copy)**的优雅思路:内核直接把一段虚拟内存地址与磁盘文件的某个部分“绑定”。当用户程序读写这段虚拟内存时,CPU 的内存管理单元(MMU)和内核的缺页异常处理机制会在后台默默完成物理内存的分配和磁盘数据的加载。
二、VMA (Virtual Memory Area) 与懒加载
在实现 mmap 时,操作系统使用一种叫做 VMA (虚拟内存区域) 的数据结构来记录映射关系。
xv6 原本的内存管理非常粗放(仅用 p->sz 记录堆栈大小)。引入 mmap 后,进程地址空间里会散落着多个文件映射区。内核必须为每个进程维护一个 VMA 数组,记录每个映射的起始地址、长度、权限(读/写/执行)、对应的文件描述符以及文件偏移量。
6.s081 Lab9: mmap 实现 (hard)
实现 mmap 是一项极其考验系统全局观的工程,需要打通进程控制块、页表、中断处理和文件系统等多个子模块。
1. 结构定义与 sys_mmap(登记挂号)
首先在 kernel/proc.h 中定义 VMA 结构,并在 struct proc 中加入容量为 16 的 VMA 数组。
struct vma {
int used;
uint64 addr;
int length;
int prot;
int flags;
struct file *vfile;
int offset;
};
接着实现 sys_mmap。核心逻辑是从高地址(Trapframe 下方)寻找一块空闲的虚拟地址空间,记录 VMA,并增加文件的引用计数以防止文件在映射期间被意外关闭。
踩坑记录一:隐蔽的 64 位指针截断
在获取系统调用参数时,如果不小心写成了 argint(0, (int*)&addr),由于 RISC-V 下的虚拟地址是 64 位的,而 argint 只能拿 32 位数据,会导致 addr 的高 32 位全是垃圾值。必须严格区分数据类型,使用 argaddr(0, &addr)。
uint64
sys_mmap(void)
{
uint64 addr;
int length, prot, flags, fd, offset;
uint64 map_addr = MAXVA - 2 * PGSIZE;
struct proc *p = myproc();
struct file *f;
struct vma *v = 0;
if(argaddr(0, &addr) < 0 || argint(1, &length) < 0 || argint(2, &prot) < 0 ||
argint(3, &flags) < 0 || argfd(4, &fd, &f) < 0 || argint(5, &offset) < 0)
return -1;
if((flags & MAP_SHARED) && (prot & PROT_WRITE) && (f->writable == 0))
return -1;
for(int i = 0; i < NVMA; i++){
if (p->vmas[i].used == 0) {
v = &p->vmas[i];
break;
}
}
if(v == 0)
return -1;
for(int i = 0; i < NVMA; i++){
if(p->vmas[i].used){
if(p->vmas[i].addr < map_addr)
map_addr = p->vmas[i].addr;
}
}
map_addr -= PGROUNDUP(length);
v->used = 1;
v->addr = map_addr;
v->length = length;
v->prot = prot;
v->flags = flags;
v->vfile = f;
v->offset = offset;
filedup(v->vfile);
return v->addr;
}
2. usertrap 中的缺页中断
当程序第一次访问 mmap 返回的地址时,会触发中断进入 usertrap。在这里我们要完成真正的物理内存分配和文件加载。
值得细品的是这里的坐标转换数学逻辑:
-
va_begin = PGROUNDDOWN(fault_va):找准当前错误地址所属页面的页首。 -
page_offset = va_begin - v->addr:计算当前页在整个 VMA 内部的偏移。 -
readi时的绝对偏移:v->offset + page_offset,精准读取该页对应的文件片段。
...
} else if(r_scause() == 13 || r_scause() == 15){
uint64 fault_va = r_stval();
struct proc *p = myproc();
struct vma *v = 0;
for(int i = 0; i < NVMA; i++){
if(p->vmas[i].used && fault_va >= p->vmas[i].addr && fault_va < p->vmas[i].addr + p->vmas[i].length){
v = &p->vmas[i];
break;
}
}
if(v != 0){
void *pa;
if((pa = kalloc()) == 0){
p->killed = 1;
} else {
memset(pa, 0, PGSIZE);
uint64 va_begin = PGROUNDDOWN(fault_va);
int page_offset = va_begin - v->addr;
ilock(v->vfile->ip);
readi(v->vfile->ip, 0, (uint64)pa, v->offset + page_offset, PGSIZE);
iunlock(v->vfile->ip);
int pte_flags = PTE_U;
if(v->prot & PROT_READ)
pte_flags |= PTE_R;
if(v->prot & PROT_WRITE)
pte_flags |= PTE_W;
if(v->prot & PROT_EXEC)
pte_flags |= PTE_X;
if(mappages(p->pagetable, va_begin, PGSIZE, (uint64)pa, pte_flags) != 0){
kfree(pa);
p->killed = 1;
}
}
} else {
p->killed = 1;
}
...
3. sys_munmap 与脏数据写回
解除映射不仅是释放页表,如果 VMA 被标记为 MAP_SHARED 且内容被修改,还必须将数据写回磁盘(利用 writei 包裹在日志事务中)。
踩坑记录二:解绑页数算错导致内存泄露
在解除页表映射时,计算要释放的页数不能向下取整。如果用户释放 5000 字节,跨越了 2 个页面,使用 PGROUNDDOWN(5000) / PGSIZE 会得出 1 页,导致最后一页变成幽灵页永远留存在页表里。必须使用 PGROUNDUP。
此外,为了配合懒加载机制,必须去 kernel/vm.c 修改 uvmunmap 函数,如果在遍历页表时发现 (*pte & PTE_V) == 0,不能再直接 panic,而是要用 continue 跳过,因为懒加载下有些页表根本还没分配。
uint64
sys_munmap(void)
{
uint64 addr;
int length;
struct proc *p = myproc();
if(argaddr(0, &addr) < 0 || argint(1, &length) < 0)
return -1;
struct vma *v = 0;
for(int i = 0; i < NVMA; i++){
if(p->vmas[i].used && addr >= p->vmas[i].addr && addr < p->vmas[i].addr + p->vmas[i].length){
v = &p->vmas[i];
break;
}
}
if(v == 0)
return -1;
if(v->flags & MAP_SHARED){
int file_offset = v->offset + (addr - v->addr);
begin_op();
ilock(v->vfile->ip);
writei(v->vfile->ip, 1, addr, file_offset, length);
iunlock(v->vfile->ip);
end_op();
}
int npages = PGROUNDUP(length) / PGSIZE;
uvmunmap(p->pagetable, addr, npages, 1);
if(addr == v->addr && length == v->length){
v->used = 0;
fileclose(v->vfile);
} else if(addr == v->addr){
v->addr += length;
v->offset += length;
v->length -= length;
} else {
v->length -= length;
}
return 0;
}
4. exit 崩溃解决 (panic: freewalk: leaf)
在跑测试时,我长期卡在 panic: freewalk: leaf。这个内核崩溃意味着当进程退出时,内核在销毁其页表时发现了尚未解除的叶子映射。
原因在于:如果程序退出时忘记调用 munmap 或者意外崩溃,原本的映射依然挂在页表上。
因此,必须在 kernel/proc.c 的 exit() 函数中,为该进程强行执行一遍 munmap 的擦屁股逻辑,确保 VMA 被清空、脏数据写回、文件引用释放。同时,还要在 fork() 中继承父进程的 VMA 结构。
void
exit(int status)
{
...
// 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;
}
}
for(int i = 0; i < NVMA; i++){
struct vma *v = &p->vmas[i];
if(v->used){
if(v->flags & MAP_SHARED){
begin_op();
ilock(v->vfile->ip);
writei(v->vfile->ip, 1, v->addr, v->offset, v->length);
iunlock(v->vfile->ip);
end_op();
}
int npages = PGROUNDUP(v->length) / PGSIZE;
uvmunmap(p->pagetable, v->addr, npages, 1);
fileclose(v->vfile);
v->used = 0;
}
}
...
}
int
fork(void)
{
...
// increment reference counts on open file descriptors.
for(i = 0; i < NOFILE; i++)
if(p->ofile[i])
np->ofile[i] = filedup(p->ofile[i]);
np->cwd = idup(p->cwd);
for(int i = 0; i < NVMA; i++){
if(p->vmas[i].used){
np->vmas[i] = p->vmas[i];
filedup(p->vmas[i].vfile);
}
}
...
}
总结
至此,xv6 最硬核的 Lab 9 终于通关! 在实现 mmap 的过程中,我深刻体会到了操作系统抽象的威力。缺页异常(Trap机制)就像一个管家,在用户态程序浑然不觉的情况下,将底层的块设备文件无缝地编织进了连续的线性内存空间里。这也解释了为什么在现代操作系统中,处理大文件、加载动态链接库(.so)或实现高效跨进程通信时,mmap 永远是最高效、最优雅的选择。