6.s081 Lab9 Mmap


最后一个,也是最有挑战性的一项 coding 任务,是所有前置 lab 的知识综合。

Preparation

切换到对应分支

$ git fetch
$ git checkout mmap
$ make clean

我们需要实现的 mmap()munmap() 这两个是系统调用,那就要修改若干文件,这在之前的 lab 中已经操作过很多次了,跳过不聊。

从 mmap 开始

void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

其作用是将某一文件 fd 从偏移量 offset 开始 length 个字节的长度映射到内存中。操作系统不仅要为其分配物理内存空间 pa,还需要在进程的虚拟地址空间为其分配一块虚拟内存 va,这样才能通过页表机制进行内存的访问。其中 addr 就是用户指定的虚拟地址。如果 addr 为 0,则由操作系统进行目标虚拟地址的挑选。

prot 指定了映射内存区域 pa 的访问权限,flags 指定了映射内存是否应当在 unmap 时将所有修改写回文件。这两者对应的标志位都定义在 kernel/fcntl.h 中。

本 lab 中,入参 addr 可以假设恒为 0。

如何映射?

假设,对于文件 f,我们选择从 offset 处开始 2 个 PGSIZE 大小的内容进行映射,操作系统会为我们分配 2 个 PGSIZE 大小的虚拟空间,起始地址为 va,长度 length = 2*PGSIZE,并记录下对应文件的偏移量 offset。操作系统还会在合适的时间分配内存,但要注意的是,虽然虚拟地址空间是连续的,但是分配的物理内存并不一定连续,这两页可能会分别映射到两个离散的地址 pa1pa2。其中 pa1 中的内容为 foffset 开始的一个 PGSIZE 的数据,而 pa2 中的内容为 foffset+PGSIZE 开始的一个 PGSIZE 的数据。

文件 虚拟地址 物理地址
offset va pa1
offset+PGSIZE va+PGSIZE pa2

实现 VMA

根据手册,第一次调用 mmap() 时,我们不必为其分配物理内存,而是仅仅在虚拟空间划出一块区域并返回相应的 va,等到通过 va 访问内存时,发现页表中没有相应 pte,这个时候再去分配物理内存。

这就是和 lab COW 一样利用了 page fault,防止映射大文件时一次 alloc 所有空间导致物理内存不够用的情况。

那么第一步我们就要解决「在虚拟空间划出一块区域」这个问题了。然而,内核中并没有虚拟地址空间的分配器,但是对我们而言,虚拟地址之所以「虚拟」,是因为我们只需要记住一定的数据,最后 usertrap() 里根据这些数据,在页表中建立页表项并且映射到物理地址即可。所以,进程结构体中可以增加这样一个字段,用于分配并追踪某个虚拟地址的使用情况。

这个字段除了标明某一对映射的虚拟地址、长度,还要有偏移量以及标志位等信息,以及对应的文件信息,那么就有:

struct vma {
  int valid;      // 该字段是否有效

  uint64 va;      // 虚拟起始地址
  uint64 length;  // 长度,可以不是 PGSIZE 的整数倍
  int prot;       // 权限标志位
  int flags;      // 脏数据处理标志位
  int fd;         // 文件描述符
  struct file* f; // 文件
  int offset;     // 相对于文件的偏移量
};

如果 valid 为 1,那么就表明从 va 开始 length 个 bytes 的这段虚拟空间已经被使用了,下次要分配时也应当避开这段空间。一个进程很可能调用多次 mmap() 进行多个文件的映射,那么就不能只存一条记录,而是实现为数组的形式。手册中提示我们 16 是个合适的大小。

现在还有一个问题,该怎样为 va 赋值?回想之前写 lab Page Table 的时候,我们实现了一个功能,是通过固定的虚拟地址加快 getpid()。当时分配的虚拟地址是位于虚拟空间最末端,TRAMPOLINE 和 TRAPFRAME 之前,这是因为虚拟空间非常大,完全可以把后面那点用不到的空间利用起来。

根据这一经验,我们完全可以在进程中建立一个字段 max_VMA,它会在新建进程时初始化为 MAXVA-2*PGSIZE,每次分配时往低地址增长:当调用 mmap() 时,max_VMA 会减去待映射的长度 length,然后找到一个 valid=0 的 vma,进行 vma.va = max_VMA 的赋值。

kernel/proc.h
struct proc { ... #define NVMA 16 struct vma vma[NVMA]; // mmap record uint64 max_VMA; // trampoline and trapframe };

到此,虚拟内存的分配记录就完成了。

sys_mmap

读取参数,找到可用 vma 并设置数据即可。

kernel/sysfile.c
uint64 sys_mmap(void) { uint64 addr; int length; int prot; int flags; int fd; struct file *f; int offset; 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 (!f->readable && (prot & PROT_READ)) { return -1; } if (!f->writable && (prot & PROT_WRITE) && (flags & MAP_PRIVATE) == 0) { // 如果文件不可写,但是允许对 MAP_PRIVATE 模式映射的内存进行写操作,反之不行 return -1; } uint64 va = -1; struct proc *p = myproc(); struct vma *vma; for (int i = 0; i < NVMA; i++) { if (!p->vma[i].valid) { vma = &p->vma[i]; break; } } vma->valid = 1; if (addr == 0) { p->max_VMA -= PGROUNDUP(length); vma->va = va = p->max_VMA; } // else { // 现有测试不会跳到这一个分支 // vma->va = va = addr; // } vma->length = length; vma->prot = prot; vma->flags = flags; vma->fd = fd; vma->f = filedup(f); // 映射会增加文件引用计数 vma->offset = offset; return va; }

page fault

当出现读/写的 page fault 时,就需要检查是否是在 mmap 内存上引起的。如果是,那就进行物理内存的分配,页表项的建立,以及文件数据的拷贝。注意需要逐页进行物理内存分配。

kernel/trap.c
void usertrap(void) { ... if (r_scause() == 13 || r_scause() == 15) { // page fault occured by reading or writing a mmap virtual address // that hasn't been allocated any physical page if(p->killed) exit(-1); uint64 va = PGROUNDDOWN(r_stval()); if (va >= MAXVA) exit(-1); // 找到相应的字段 struct vma *vma = 0; for (int i = 0; i < NVMA; i++) { if (p->vma[i].valid && p->vma[i].va <= va && p->vma[i].va + PGROUNDUP(p->vma[i].length) > va) { vma = &p->vma[i]; break; } } // 如果没有找到,说明出错的虚拟地址没有进行映射,直接退出 if (!vma) { exit(-1); } // 完善页表项的标志位 int flags = PTE_U; if (vma->prot & PROT_READ) { flags |= PTE_R; } if (vma->prot & PROT_WRITE) { flags |= PTE_W; } if (vma->prot & PROT_EXEC) { flags |= PTE_X; } uint64 pa; uint64 following = vma->va - va + PGROUNDUP(vma->length); // 逐页进行映射+拷贝操作 ilock(vma->f->ip); for (uint64 off = 0; off < following; off += PGSIZE) { // 如果当前地址已有映射,跳过 if ((pa = walkaddr(p->pagetable, va+off)) != 0) { continue; } // 无可用内存,报错 if ((pa = (uint64)kalloc()) == 0) { panic("no free memory"); } // 清空数据,并建立映射,再进行拷贝 memset((void*)pa, 0, PGSIZE); if (mappages(p->pagetable, va+off, PGSIZE, pa, flags) != 0) { panic("cannot map"); } if (readi(vma->f->ip, 0, pa, vma->offset+off, PGSIZE) == -1) { panic("read file failed"); } } iunlock(vma->f->ip); } ... }

进行 munmap

int munmap(void *addr, int length)

munmap() 是对指定地址 addr 上长度为 length 的地址解除映射,即释放对应内存,删除页表项,并且还要修改相应的 vma 字段。

比如对之前表格中进行 munmap(va+PGSIZE, PGSIZE),就会导致文件映射长度减少,那么 vmalength 字段就需要改为 PGSIZE(原来是 2*PGSIZE)。

而如果进行 munmap(va, PGSIZE),那么还会额外导致 vmava 字段改为 va+PGSIZE,并且偏移量 offset 也会增加 PGSIZE

当然,调用 munmap(va, 2*PGSIZE) 会使 vmalengthoffset 直接归零,此时意味着该文件的映射区域被完全解除,应降低该文件的引用计数,并且重置相应 vma 的所有字段(即归零)。

这很合理,毕竟解除了某一区域的映射,那么 vma 中文件的映射长度与偏移量肯定也会发生变化,不然就会出现不一致的问题。下次对已解除区域重新 mmap() 时,经查 vma 数组发现已经有映射了,这显然与现实矛盾。

sys_munmap

kernel/sysfile.c
uint64 sys_munmap(void) { uint64 addr; int length; if (argaddr(0, &addr) < 0 || argint(1, &length) < 0) { return -1; } struct proc *p = myproc(); struct vma *vma = 0; for (int i = 0; i < NVMA; i++) { if (p->vma[i].valid && p->vma[i].va <= addr && p->vma[i].va + PGROUNDUP(p->vma[i].length) > addr) { vma = &p->vma[i]; break; } } if (vma) { vmaunmap(p, vma, addr, length); return 0; } return -1; }

其中 vmaunmap() 就是做了所需要的全部工作——修改 page table、释放内存、修改 vma。

还不够

进程结束的时候,可能用户会忘记调用 munmap(),而进程只会释放 TRAMPOLINE 和 TRAPFRAME 以及低虚拟地址处的内存,对于文件映射内存区域,还需要我们额外加入代码进行处理。手册提示我们加在 exit() 函数里,我想是因为所有进程正常退出时都会调用该函数,并且我尝试在 freeproc() 中进行内存释放和数据写回,发现会在 bcache 上产生死锁的问题,但并未深究。

以及还要在 allocproc()freeproc() 里增加对 vma[]max_VMA 的初始化与重置。

哦对,调用 fork() 创建子进程时,只需要拷贝 vma[]max_VMA 即可,而不需要拷贝物理内存,这也是利用了 page fault 的 lazy allocation 策略——用到再分配。

当然,还要增加 vma[] 中记录的文件的引用计数。

kernel/proc.c
static struct proc* allocproc(void) { ... found: ... memset(&p->vma, 0, sizeof(p->vma)); p->max_VMA = PGROUNDUP(MAXVA) - PGSIZE*2; ... } static void freeproc(struct proc *p) { ... memset(&p->vma, 0, sizeof(p->vma)); p->max_VMA = 0; } void exit(int status) { ... for (int i = 0; i < NVMA; i++) { struct vma* vma = &p->vma[i]; if (vma->valid) { vmaunmap(p, vma, vma->va, vma->length); } } p->max_VMA = 0; ... } int fork(void) { ... for (int i = 0; i < NVMA; i++) { np->vma[i] = p->vma[i]; if (np->vma[i].f) { np->vma[i].f->ref++; } np->max_VMA = p->max_VMA; } ... }

测试结果

$ make grade
...
== Test running mmaptest ==
$ make qemu-gdb
(3.9s)
== Test   mmaptest: mmap f ==
  mmaptest: mmap f: OK
== Test   mmaptest: mmap private ==
  mmaptest: mmap private: OK
== Test   mmaptest: mmap read-only ==
  mmaptest: mmap read-only: OK
== Test   mmaptest: mmap read/write ==
  mmaptest: mmap read/write: OK
== Test   mmaptest: mmap dirty ==
  mmaptest: mmap dirty: OK
== Test   mmaptest: not-mapped unmap ==
  mmaptest: not-mapped unmap: OK
== Test   mmaptest: two files ==
  mmaptest: two files: OK
== Test   mmaptest: fork_test ==
  mmaptest: fork_test: OK
== Test usertests ==
$ make qemu-gdb
usertests: OK (129.9s)
    (Old xv6.out.usertests failure log removed)
== Test time ==
time: OK
Score: 140/140

最后的工作

  1. git commit -am "" 将所有修改提交到本地;
  2. 执行 make handin。由于 lab0 保存了 APIKey,故直接成功提交;

可选的挑战再说吧,没有什么想做的欲望。


  目录