MIT6.s081_Lab10 mmap: Mmap
MIT6.s081 Lab10:mmap
看网上说这是第二难的Lab了,综合了文件,虚拟内存,还有页面计数的思想,花了大概一天的时间,刚开始想的很复杂,越深入越复杂,后来根据测试改已经写出来的代码的一些bug,简化一些就通过测试了,最后考虑的内存释放的事情。
代码,在写这个文章的时候,将一些没用的操作删了。所以这里有两次提交。
1. mmap
总体上来说,做这个lab你需要了解mmap的一些重要的知识,先看实验中的说明和hint,总体了解了再写,否则会出现不断修改的情况。起初我是想一边写,一边写文档的,但是后来改的太频繁了,就没有实时写,1-4部分记录一点起初的想法。
下面就是具体的实现了。
-
根据以往的经验,以及第一个提示,要先编译通过,然后完善
mmap和munmap,就是系统调用那老一套,下面是修改的文件。Makefile加入用户空间的调用$U/_mmaptest\usys.pl加入系统调用入口entry("mmap"); entry("munmap");user.h加入系统调用函数,这里根据mmaptest调用的返回情况确定返回类型。char* mmap(void *, uint, int, int, int, int); int munmap(void *, int);syscall.h加入系统调用号#define SYS_mmap 22 #define SYS_munmap 23syscall.cextern uint64 sys_mmap(void); extern uint64 sys_munmap(void); [SYS_mmap] sys_mmap, [SYS_munmap] sys_munmap,sysfile.c加入系统调用函数,这里为了编译通过先返回 -1uint64 sys_mmap(void) { return -1; } uint64 sys_munmap(void) { return -1; }经过上面的修改,
make qemu就可以编译通过了。 -
添加
vma结构体,存放mmap的一些信息,并放到进程中。(这里最初实现的时候忘记增加
fd对应的file增加引用计数了。要对原本的fd做一定的修改了)在操作系统和内存管理领域,VMA 是 Virtual Memory Area(虚拟内存区域)的缩写。
一个 VMA 是进程虚拟地址空间中一个连续的、具有相同属性(如权限、类型、映射文件等)的区域。操作系统内核使用 VMA 来管理进程的虚拟地址空间。
根据提示
vma数组大小设为16,在param.h添加宏定义。#define VMASIZE 16修改
proc,在proc结构体中添加vma结构体。struct proc { …… char name[16]; // Process name (debugging) struct vma vma[VMASIZE]; };新增
vma结构体,proc.h,里面有些数据可以使用枚举。// VMA data. struct vma { uint64 addr; // map memory address int length; // map file length int prot; // map area readable or writeable or either int flags; // mapshared need to writeback disk, mapprivate dont need struct file *filepointer; // file pointer int offset; // map file start point int used; // vma has been used? used:1-unused:0 };新增一个虚拟地址,方便
mmap映射没有找到对应的数据进行trap操作,分配真正的内存。#define MMAPBASE 0x400000最后这里放上最初的废案,但其实思想上没太大的变化,主要是为了能够打开多个文件进行
mmap。修改
proc,在proc结构体中添加vma结构体。struct proc { …… char name[16]; // Process name (debugging) struct vma *vma; };新增
vma结构体,proc.h,里面有些数据可以使用枚举。// VMA data. struct vma { uint64 addr; // map memory address int length; // map file length int prot; // map area readable or writeable or either int flags; // mapshared need to writeback disk, mapprivate dont need struct file *filepointer; // file pointer int offset; // map file start point int used; // vma has been used? used:1-unused:0 }; //使用外部定义 extern struct vma vma[VMASIZE];增加一个vma的锁,防止并发访问。
proc.hextern struct spinlock vma_lock;修改
proc.c,增加vma、vma_lock定义。struct vma vma[VMASIZE]; struct spinlock vma_lock;新增一个虚拟地址,方便
mmap映射没有找到对应的数据进行trap操作,分配真正的内存。#define MMAPBASE 0x60000000 -
修改
sys_mmap(),下面的锁有优化空间,不用那么晚释放,这里就不优化了(太懒)。刚开始是用
fd来记录文件的,后来发现不好操作,就发现了可以使用argfd来直接获取文件。mmap权限这块要注意的,刚开始没注意,是后面测试的时候才发现。总的来说这边改动也不算太大。uint64 sys_mmap(void) { int length, prot, flags, offset; struct file *f; if(argint(1, &length) < 0 || argint(2, &prot) < 0 || argint(3, &flags) < 0 || argfd(4, 0, &f) < 0 || argint(5, &offset) < 0){ return 0xffffffffffffffff; } // 传入的addr是0,这里就不接收了 int i; struct proc *p = myproc(); // 下面的两个判断是为了保证打开的权限和映射的权限一致,或者小于。 if(PROT_READ & prot) { if(!f->readable){ printf("readable failed\n"); return 0xffffffffffffffff; } } if(PROT_WRITE & prot) { if(!f->writable && flags != MAP_PRIVATE){ printf("writeable failed\n"); return 0xffffffffffffffff; } } //初始化 acquire(&p->lock); for(i = 0; i < VMASIZE; i++) { if(!p->vma[i].used){ p->vma[i].used = 1; //这边很重要,每一次mmap要给不同的地址,后面错误也需要根据地址找到index,不能为0,所以要加1 p->vma[i].addr = MMAPBASE*(i+1); p->vma[i].prot = prot * 2; p->vma[i].flags = flags; p->vma[i].filepointer = f; p->vma[i].offset = offset; p->vma[i].length = length; break; } } release(&p->lock); // 如果没有空的就返回失败 if(i == VMASIZE) { return 0xffffffffffffffff; } //增加文件引用计数 filedup(f); return p->vma[i].addr; }最初的代码如下:
uint64 sys_mmap(void) { int length, prot, flags, offset; struct file *f; if(argint(1, &length) < 0 || argint(2, &prot) < 0 || argint(3, &flags) < 0 || argfd(4, 0, &f) < 0 || argint(5, &offset) < 0){ return 0xffffffffffffffff; } int i; acquire(&vma_lock); for(i = 0; i < VMASIZE; i++) { if(!vma[i].used){ vma[i].used = 1; vma[i].addr = 0; vma[i].prot = prot; vma[i].flags = flags; vma[i].filepointer = f; vma[i].offset = offset; myproc()->vma = &vma[i]; break; } } release(&vma_lock); if(i == VMASIZE) { return 0xffffffffffffffff; } filedup(f); return myproc()->vma->addr; } -
实现缺页时分配物理页面。从这开始就发现改动的过于频繁,于是
trap.c中添加必要的判断,} else if(r_scause() == 13 || r_scause() == 15){ uint64 va = r_stval();//stval存放出错的进程的虚拟地址。 if(allocmappage(va) == -1) { p->killed = 1; }下面是最初的实现,当时没有传触发
trap的地址,为什么后面要呢,因为映射多个文件的时候,需要判断是哪个vma,有了地址,就可以计算出来了。} else if(r_scause() == 13 || r_scause() == 15){ if(allocmappage() == -1) { p->killed = 1; }实现分配页面函数。
int allocmappage(uint64 va) { struct proc *p = myproc(); //获取触发trap的是哪一个vma int index = (va - MMAPBASE) / MMAPBASE; if(va > p->vma[index].addr + p->vma[index].length){ return -1; } uint64 *pgaddr = kalloc(); //有一个测试需要后面的值为0,这里初始化一下,不然kalloc初始化的是5 memset(pgaddr, 0, PGSIZE); if(mappages(p->pagetable, va, PGSIZE, (uint64)pgaddr, p->vma[index].prot | PTE_U) == -1) { return -1; } //给分配的地址增加一个引用计数,当引用计数为0就可以直接释放这个内存了 addparef((uint64)pgaddr); mmapfileread(&p->vma[index], va); return 0; }刚开始不成熟的实现,只考虑一个文件映射,也没有做错误判断,也没有考虑到最后的内存释放。
int allocmappage(uint64 va) { uint64 pgaddr = (uint64)kalloc(); struct proc *p = myproc(); mappages(p->pagetable, va, pgaddr, PGSIZE, p->vma->prot); mmapfileread(p->vma); return -1; }实现读文件到页面的函数。这个区别主要就是加入了
va来进行写入,还加入了了判断读文件读出的数据是否正常。为-1的话就是读失败了。这里当时因为要求为0,但是文件大小只有4096+2048,导致一直测试不通过,后来是在初始化时就改了。int mmapfileread(struct vma *vma, uint64 va) { int r = 0; struct file *f = vma->filepointer; if(f->readable == 0) return -1; if(f->type != FD_INODE){ return -1; } ilock(f->ip); r = readi(f->ip, 1, PGROUNDDOWN(va), vma->offset + PGROUNDDOWN(va) - vma->addr, PGSIZE); if(r == -1) { iunlock(f->ip); return r; } iunlock(f->ip); return r; }def.h添加声明int mmapfileread(struct vma*, uint64); struct vma; int addparef(uint64);实现物理内存使用计数。这一块和
cow实验类似,先声明,加入一个锁。int ref_counts[PHYSTOP / PGSIZE]; struct spinlock ref_lock;初始化锁
void kinit() { initlock(&ref_lock, "memref"); initlock(&kmem.lock, "kmem"); freerange(end, (void*)PHYSTOP); }实现
addparefint addparef(uint64 pa) { int paref; acquire(&ref_lock); ref_counts[pa/PGSIZE] += 1; paref = ref_counts[pa/PGSIZE]; release(&ref_lock); return paref; }如实验上说的现在应该可以到达第一个
munmap。 -
实现
munmap,这个就完全是是最后博客的时候写的。前面的内容刚开始想的没有那么全面,都是后面补充的,当然前面的也比较容易。在实现
munmap前先整理一下思路,前面已经实现了mmap,调用mmap时只是分配一个vma同时增加文件的引用计数,当真正访问文件的时候才真正分配页面(补一句,最初我以为只能用一个物理页面,可痛苦了,后来发现没必要),分配页面后调入访问的文件的内容,然后给物理地址增加计数。下面
munmap要做的就是如果文件有修改就写回文件,如果内存地址引用计数为0或者页面不是共享的可以直接free这个物理内存,同时取消进程的内存映射,如果已经取消了文件的所有映射,减少文件的引用计数。思路说完了,下面看代码。
uint64 sys_munmap(void) { int addr; int length; if(argint(0, &addr) < 0 || argint(1, &length) < 0){ return -1; } struct proc *p = myproc(); int index = (addr - MMAPBASE) / MMAPBASE;//获取地址对应的vma struct vma *vma = &p->vma[index]; //限制映射的区域 if(addr < vma->addr || length <= 0 || addr + length > vma->addr + vma->length){ return -1; } int i; int npages = length/PGSIZE;//判断需要解除映射多少页 uint64 pa; //最初下面这部分写的很烂,后来修改了,本来写入不需要传入i的,我增加了实际写入到页面的长度这个参数到vma中,发现是错误的,于是考虑采用walkaddr这个来判断是否映射了这个界面,pa不为0的时候在做判断。 for(i = 0; i < npages; i++) { if(vma->flags == MAP_SHARED){ mmapfilewrite(vma, i); } if((pa = walkaddr(p->pagetable, addr + PGSIZE*i)) != 0){ if(subparef(pa) == 0 || vma->flags == MAP_PRIVATE){ //如果页面私有,或者引用降为0了,就取消映射并释放界面。 uvmunmap(p->pagetable, PGROUNDDOWN(addr + PGSIZE*i), 1, 1); }else{ uvmunmap(p->pagetable, PGROUNDDOWN(addr + PGSIZE*i), 1, 0); } } } if(PGROUNDDOWN(addr) == vma->addr){ //释放最初始的页面,就增加虚拟地址的起始位置,为了维护 //addr + length > vma->addr + vma->length 这个判断 vma->addr += PGSIZE*npages; vma->offset += PGSIZE*npages; } vma->length -= length; if(vma->length == 0) { fileundup(vma->filepointer); vma->used = 0; } return 0; }def.h添加声明struct file* fileundup(struct file*); int mmapfilewrite(struct vma*, int); int subparef(uint64);fileundup实现,减少文件的引用。struct file* fileundup(struct file *f) { acquire(&ftable.lock); if(f->ref < 1) panic("fileundup"); f->ref--; release(&ftable.lock); return f; }subparef实现,减少mmap物理地址的引用。int subparef(uint64 pa) { int paref; acquire(&ref_lock); ref_counts[pa/PGSIZE] -= 1; paref = ref_counts[pa/PGSIZE]; release(&ref_lock); return paref; }mmapfilewrite实现,将shared页面写入文件。int mmapfilewrite(struct vma *vma, int i) { int r = 0; struct file *f = vma->filepointer; if(f->readable == 0) return -1; if(f->type != FD_INODE){ return -1; } begin_op(); ilock(f->ip); r = writei(f->ip, 1, vma->addr + PGSIZE*i, vma->offset + PGSIZE*i, PGSIZE); iunlock(f->ip); end_op(); return r; }下面就跟着提示一步一步做就好,后面还有个提示,关于页面是否写入,判断后再写入文件。我这里没有判断脏位,直接
shared就写入文件。 -
修改
exit函数,在函数释放前做和munmap一样的操作,来确保物理内存被释放。在
exit函数中加入下面的代码。总体来说和上面的unmap几乎一样的操作,这里可以抽取为一个函数,更加的符合工程思想。这里宏定义无法使用,所以有的地方我直接写了0x1这种。实现后可以通过mmaptest所有测试。int i; int npages; uint64 pa; for(int k = 0; k < VMASIZE; k++) { if(!p->vma[k].used) continue; npages = p->vma[k].length/PGSIZE; for(i = 0; i < npages; i++) { if(p->vma[k].flags == 0x01){ mmapfilewrite(&p->vma[k], i); } if((pa = walkaddr(p->pagetable, p->vma[k].addr + PGSIZE*i)) != 0){ if(subparef(pa) == 0 || p->vma[k].flags == 0x02){ uvmunmap(p->pagetable, PGROUNDDOWN(p->vma[k].addr + PGSIZE*i), 1, 1); } else { uvmunmap(p->pagetable, PGROUNDDOWN(p->vma[k].addr + PGSIZE*i), 1, 0); } } } if(p->vma[k].length) { fileundup(p->vma[k].filepointer); } p->vma[k].used = 0; } -
修改
fork,适应mmap。这里有思考过真正的mmap可以不同的进程共享页面,但是那个也更复杂了,后面有空可以研究研究。代码如下,主要就是增加两个计数,以及映射那个界面,这里因为没考虑到前面的映射是根据进程大小来的,导致错误,这里需要单独映射
mmap的界面。int fork(void) { …… np->state = RUNNABLE; uint64 pa; for(int i = 0; i < VMASIZE; i++) { np->vma[i] = p->vma[i]; if(p->vma[i].used){ np->vma[i].filepointer = filedup(p->vma[i].filepointer);//增加一次文件引用计数 int npages = np->vma[i].length/PGSIZE; for(int j = 0; j < npages; j++){ if((pa = walkaddr(p->pagetable, p->vma[i].addr + j*PGSIZE)) != 0) { if(mappages(np->pagetable, np->vma[i].addr + j*PGSIZE, PGSIZE, pa, p->vma[j].prot | PTE_U) == -1) { return -1; } addparef(pa);//增加物理页面计数 } } } } release(&np->lock); return pid; }
实现上面所有的代码就可以通过测试了。起初想的太复杂,搞的头疼,好就好在实验比较人性化。
2. 附录:file.c主要函数
文件系统之文件描述符到inode,下面是一个大概的映射,AI写的方便复习。
xv6 文件描述符层及 file.c 详解
核心思想:抽象与间接
文件描述符的核心思想是抽象。当一个进程打开一个文件时,内核不会把复杂的 inode 结构直接返回给用户。相反,它返回一个简单的、非负的小整数,这就是文件描述符 (File Descriptor, fd)。
你可以把它想象成你去图书馆借书时,图书管理员给你的借书卡号,或者你去餐厅吃饭时服务员给你的桌号。你后续的所有操作(点菜、加水、结账)都只需要报出你的桌号即可,而不需要描述你桌子的具体位置、材质和大小。
这个简单的整数句柄,将进程与底层的文件细节(inode、磁盘位置等)完全解耦,带来了巨大的灵活性,比如 I/O 重定向和管道。
核心数据结构:三张表的联动
文件描述符层的实现依赖于三个核心数据结构的精妙协作。理解这三张表以及它们之间的指针关系,是理解 file.c 的关键。
- 进程文件描述符表 (Per-process File Descriptor Table)
- 位置:
struct proc结构体中的struct file *ofile[NOFILE];数组。 - 作用: 这是每个进程私有的。数组的索引就是文件描述符
fd。数组的每个元素是一个指向“系统级打开文件表”中某一项的指针。 - 特点: 进程间不共享。
fork()会复制一份父进程的文件描述符表,但父子进程的表是独立的(尽管它们可能指向同一个打开文件条目)。
- 位置:
- 系统级打开文件表 (System-wide Open File Table)
- 位置: 全局唯一的
struct ftable结构体,其内部有一个struct file file[NFILE];数组。 - 作用: 记录了当前系统中所有被打开的文件。这张表是所有进程共享的。
struct file的关键成员:type: 条目的类型(是文件、管道,还是设备)。ref: 引用计数,记录有多少个进程文件描述符正指向这个struct file条目。readable,writable: 文件的打开权限。ip: 指向该文件在内存中的 inode (struct inode)。off: 读写偏移量 (offset)。这是实现文件共享的关键!当父子进程共享同一个打开文件时,它们共享同一个struct file,因此也共享同一个off。当父进程读取了100字节后,子进程再读取时会从第101个字节开始。
- 位置: 全局唯一的
- 内存中的 inode 表 (In-memory Inode Table)
- 位置: 全局唯一的
struct icache,内部有一个struct inode inode[NINODE];数组。 - 作用: 缓存了最近从磁盘读取的 inode (
dinode),并为其添加了运行时的管理信息(如锁lock和引用计数ref)。 - 关系: 系统级打开文件表中的
ip指针就指向这张表中的某一项。
- 位置: 全局唯一的
三者关系图:
[进程 p] [全局] [全局]
+-----------------+ +---------------------+ +---------------------+
| ... | | 打开文件表 (ftable) | | inode 缓存 (icache) |
| ofile[fd] ------>------------------> | file[] | | inode[] |
| ... | | .type = FD_INODE | | .inum, .type, ... |
+-----------------+ | .ref | | .lock, .ref |
| .off | | |
| .ip --------------->----------->| |
+---------------------+ +---------------------+
file.c 中的主要函数分析
file.c 的函数就是围绕这三张表进行操作的“管理员”。
1. filealloc(void)
- 作用: 在系统级打开文件表 (
ftable) 中分配一个空闲的struct file条目。 - 参数: 无。
- 核心逻辑:
- 获取
ftable的锁。 - 遍历
ftable.file数组,寻找一个ref引用计数为0的条目(表示空闲)。 - 找到后,将其
ref设为1,并初始化其他字段。 - 释放锁,返回这个
struct file的指针。
- 获取
- 引用:
open,pipe等创建新文件句柄的系统调用会使用它。
2. fileclose(struct file *f)
- 作用: 关闭一个打开的文件,即减少对
struct file条目的引用。 - 参数:
struct file *f: 指向要被关闭的struct file条目。
- 核心逻辑:
- 获取
ftable的锁。 - 将
f->ref减一。 - 关键检查: 如果
f->ref减到0,说明这是最后一个引用该文件的描述符。此时,必须释放其底层的资源:- 将
f->type设为FD_NONE。 - 释放锁。
- 调用
iput(f->ip)来减少对内存 inode 的引用计数(如果f->ip指向的 inode 的引用也降为0,iput会将其写回磁盘并从缓存中移除)。
- 将
- 获取
- 引用:
close系统调用和进程退出 (exit) 时会间接调用它。
3. filedup(struct file *f)
- 作用: 复制一个文件描述符,即增加对一个
struct file条目的引用计数。 - 参数:
struct file *f: 指向要被复制的struct file条目。
- 核心逻辑:
- 获取
ftable的锁。 - 检查
f->ref是否小于1,如果是则说明有问题(不应该复制一个已关闭的文件)。 - 将
f->ref加一。 - 释放锁,返回
f。
- 获取
- 引用:
dup系统调用和fork中复制文件描述符表时会使用。
4. filestat(struct file *f, uint64 addr)
- 作用: 获取一个打开文件的状态信息。
- 参数:
struct file *f: 指向目标struct file。uint64 addr: 一个用户空间的地址,用于存放struct stat结果。
- 核心逻辑:
- 检查文件类型是否为 inode 或设备。
- 调用
stati(f->ip, &st)从 inode 层获取元数据。 - 调用
copyout()将内核中的stat结构安全地拷贝到用户空间。
- 引用:
fstat系统调用。
5. fileread(struct file *f, uint64 addr, int n)
- 作用: 从一个打开的文件中读取数据。
- 参数:
struct file *f: 指向要读取的struct file。uint64 addr: 存放读取数据的用户空间地址。int n: 要读取的最大字节数。
- 核心逻辑:
- 检查文件是否可读 (
f->readable)。 - 获取 inode 的锁 (
ilock(f->ip))。 - 调用
readi(f->ip, 1, addr, f->off, n),将读写偏移量f->off传递给 inode 层。 - 更新
f->off为新的偏移量。 - 释放 inode 锁 (
iunlock(f->ip))。
- 检查文件是否可读 (
- 引用:
read系统调用。
6. filewrite(struct file *f, uint64 addr, int n)
- 作用: 向一个打开的文件写入数据。
- 参数:
struct file *f: 指向要写入的struct file。uint64 addr: 包含待写入数据的用户空间地址。int n: 要写入的字节数。
- 核心逻辑: 与
fileread类似,但它检查f->writable并调用writei()。 - 引用:
write系统调用。
总结
文件描述符层和 file.c 是 xv6 中一个完美的抽象和间接层的范例。它通过三张表的巧妙联动,实现了:
- 统一接口: 无论是普通文件、设备还是管道,在用户看来都是一个简单的整数
fd。 - 资源共享: 允许多个进程(或同一个进程的多个fd)共享同一个文件的读写状态(offset)。
- 生命周期管理: 通过引用计数,确保了底层资源(inode)在不再被任何文件描述符使用时,才会被安全地释放。
这一层是连接用户进程的简单 I/O 请求和文件系统底层复杂实现的关键枢纽。

浙公网安备 33010602011771号