Skip to content

mmap

Giovanna

About 2494 wordsAbout 8 min

2024-08-23

Lab: mmap (mit.edu)


开启新实验

git fetch
git checkout mmap
make clean

mmap

任务描述

实现一个 UNIX 操作系统中常见系统调用 mmap() 和 munmap() 的子集。此系统调用会把文件映射到用户空间的内存,这样用户可以直接通过内存来修改和访问文件。

可以在 Linux 系统中通过 man mmap 查看该命令的详细描述。

mmap() 的定义如下:

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

意思是映射描述符为 fd 的文件从 offset 开始的 length 个字节到 addr 的位置。

如果 addr 参数为 0,系统会自动分配一个空闲的内存区域来映射,并返回这个地址

在实验中我们只需要支持 addr 和 offset 都为 0 的情况,也就是完全不用考虑用户指定内存和文件偏移量。

prot 和 flags 都是一些标志位(定义于 kernel/fcntl.h )。

具体来说,prot 有以下的选项:

tmpB03A.png

规定了能对映射后文件做的操作。

flags 则决定,如果在内存映射文件中做了修改,是否要在取消映射时,把这些修改更新到文件中。有 MAP_SHAREDMAP_PRIVATE 两个选项。

tmpEB60.png

munmap(addr, length) 应该删除指定地址范围内的 mmap 映射。如果进程修改了内存并且 flags 标志位设为 MAP_SHARED,则应首先将修改写入文件。munmap() 调用只覆盖 mmap 编辑区域的一部分,可以假设它将在开头或结尾或整个区域取消映射(但不会在区域中间打孔)。

实现过程

  1. 向 UPROGS 添加 _mmaptest

  2. 重复一下添加系统调用的一系列操作

    user/user.h 中添加声明

    char* mmap(void*, int, int, int, int, int);
    int munmap(void*, int);

    user/usys.pl 添加

    entry("mmap");
    entry("munmap");

    kernel/syscall.h 中添加定义

    #define SYS_mmap 22
    #define SYS_munmap 23

    修改 kernel/syscall.c,用 extern 全局声明新的内核调用函数,并且在 syscalls 映射表中,加入从前面定义的编号到系统调用函数指针的映射

    extern uint64 sys_mmap(void);
    extern uint64 sys_munmap(void);
    
    static uint64 (*syscalls[])(void) = {
    ...
    [SYS_mmap]   sys_mmap,
    [SYS_munmap] sys_munmap,
    }
  3. kernel/proc.h 定义 VMA(虚拟内存区域)的结构,记录 mmap 创建的虚拟内存范围的地址、长度、权限、文件等。由于 xv6 内核中没有内存分配器,因此可以声明一个固定大小的 VMA 数组,并根据需要从该数组中分配。大小 16 应该就足够了。

    #define NVMA 16
    
    struct vma {
      uint64 addr;
      uint64 len;
      int perm;
      int flags;
      struct file *file;
    };
    
    struct proc {
      ...
      struct vma vma[NVMA];
    };

    VMA 是进程的私有字段,因此对于 xv6 的进程单用户线程的系统,访问 VMA 是无需加锁的。

  4. 在 kernel/sysfile.c 中实现系统调用 sys_mmap() (1):获取参数并进行简单的参数检查。

    uint64 sys_mmap(void){
      uint64 addr, len, offset;
      int prot, flags, fd, perm = PTE_U | PTE_V;
      struct file *file;
    
      // void *mmap(void *addr, int length, int prot, int flags, int fd, int offset);
      if(argaddr(0, &addr) < 0) return -1;
      if(argaddr(1, &len) < 0) return -1;
      if(argint(2, &prot) < 0) return -1;
      if(argint(3, &flags) < 0) return -1;
      if(argfd(4, &fd, &file) < 0) return -1;
      if(argaddr(5, &offset) < 0) return -1;
      
      if(addr || offset) return -1;  // 本实验 addr 和 offset 都为0
      if(prot & PROT_WRITE){         // 如果文件不可写但是要求写
        if(!file->writable && (flags & MAP_SHARED))
          return -1;
        perm |= PTE_W;
      }
      if(prot & PROT_READ){          // 如果文件不可读但是要求读
        if(!file->readable) return -1;
        perm |= PTE_R;
      }
      ...
    }
  5. 在 kernel/sysfile.c 中实现系统调用 sys_mmap() (2):从当前进程的 VMA 数组中为该映射分配一个 VMA 结构,遍历 VMA 数组,若 addr 为 0 则表示该 VMA 未被使用即可用于本次映射。

    uint64 sys_mmap(void){
      ...
      
      struct proc* p = myproc();
    
      struct vma *vma = 0;
      for(int i = 0; i < NVMA; i++){
        if(!p->vma[i].addr){
          vma = &p->vma[i];
          break;
        }
      }
      if(!vma) return -1;
    
      ...
    }
  6. 在 kernel/sysfile.c 中实现系统调用 sys_mmap() (3):将参数记录到分配的 VMA 中,需要将 prot 转化为 PTE。难点在于 addr 参数都为 0,即需要内核自行选择映射的地址。还应该使用 filedup() 增加文件的引用计数,这样当文件关闭时结构就不会被释放。最后返回找到的 addr

    映射地址的选择

    参考[MIT 6.s081] Xv6 Lab11 Mmap 实验记录 | tzyt的博客 (ttzytt.com)

    TRAPFRAME 以下的堆区可以作为映射空间,遍历 VMA 数组,找到已被映射内存的最低地址,然后对该地址向下取页面对齐,进而确定映射地址。

    #include "memlayout.h"
    
    uint64 sys_mmap(void){
      ...
      
      addr = TRAPFRAME;
      for (int i = 0; i < NVMA; i++){
        if(p->vma[i].addr){
          addr = (addr < p->vma[i].addr ? addr : p->vma[i].addr);
        }
      }
    
      addr = PGROUNDDOWN(addr-len)
    
      vma->addr = addr;
      vma->len = len;
      vma->perm = perm;
      vma->flags = flags;
      vma->file = file;
      filedup(file);
    
      return addr;
    }

    根据实验要求,映射内存采用的是 Lazy allocation,因此此处只需在 VMA 中记录映射的地址,而后续通过在 usertrap() 中对 page fault 处理进行实际的内存页面分配。

  7. 修改 kernel/trap.c 中 usertrap() 的代码(1):添加对 page fault 情况的检查。然后根据发生 page fault 的地址去当前进程的 VMA 数组中找对应的 VMA 结构体,并进行读错误与写错误的判断处理。

    注意:文件映射时参数 len 实际上可以超过文件大小,对应超过文件实际大小的部分,内容都会是 0,可以访问修改,但最后都不会写回文件中

    scause

    scause 寄存器中保存了进入trap的原因,下图是risc-v中该寄存器中值所代表的原因的表格,可以看到12、13、15都是和 page fault 相关的: image.png

    由于映射的内存未分配,而且该内存读写执行都是有可能的,因此可能发生 13 读错误和 15 写错误。

    void usertrap(void)
    {
      ...
      
      if(r_scause() == 8){
        ...
      } else if(r_scause() == 13 || r_scause() == 15){
      
        struct vma *vma = 0;
        uint64 va = r_stval();
        for(int i = 0; i < NVMA; i++){
    	  if(p->vma[i].addr && va >= p->vma[i].addr && va < p->vma[i].addr + p->vma[i].len){
            vma = &p->vma[i];
            break;
          }
        }
        if(!vma) goto err;
    
    	if(r_scause() == 13 && (vma->perm & PTE_R)){
          goto err;
        }
        if(r_scause() == 15 && (vma->perm & PTE_W)){
          goto err;
        }
    
    	...
    
      } else if((which_dev = devintr()) != 0){
        // ok
      } else {
    err:
    	printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
        printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
        p->killed = 1;
      }
      
      ...
    }
  8. 修改 kernel/trap.c 中 usertrap() 的代码(2):使用 kalloc() 先分配一个物理页, 并使用 memset() 进行清空。接着使用 readi() 根据发生 page fault 的地址从文件的相应部分读取内容到分配的物理页(在这个过程前后需要对文件的 inode 进行加锁)。最后即可使用 mappages() 将物理页映射到用户进程的页面值。

    void usertrap(void)
    {
      ...
      
      if(r_scause() == 8){
        ...
      } else if(r_scause() == 13 || r_scause() == 15){
    
    	...
    	
    	va = PGROUNDDOWN(addr);
        char* pa = kalloc();
        if(!pa) goto err;
        memset(pa, 0, PGSIZE);
    
        ilock(vma->file->ip);
        if(readi(vma->file->ip, 0, (uint64)pa, va - vma->addr, PGSIZE) == 0){
          iunlock(vma->file->ip);
          goto err;
        }
        iunlock(vma->file->ip);
        
        if(mappages(p->pagetable, va, PGSIZE, (uint64)pa, vma->perm) != 0){
          kfree(pa);
          goto err;
        }
    
      } else if((which_dev = devintr()) != 0){
        // ok
      } else {
    err:
    	printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
        printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
        p->killed = 1;
      }
      
      ...
    }
  9. 在 kernel/sysfile.c 中实现系统调用 sys_munmap()。找到地址范围的 VMA,并取消映射指定的页面(提示:使用 uvmunmap)。如果 munmap 删除了前一个 mmap 的所有页面,它应该减少相应文件的引用计数。

    uint64 munmap(uint64 addr, uint64 len)
    {
      struct proc *p = myproc();
      struct vma *vma = 0;
      for(int i = 0; i <  NVMA; i++){
    	if(p->vma[i].addr && addr >= p->vma[i].addr && addr < p->vma[i].addr + p->vma[i].len){
          vma = &p->vma[i];
          break;
        }
      }
      if(!vma) return -1;
    
      if(addr > vma->addr && addr + len < vma->addr + vma->len){
        return -1;
      }
    
      writeback(p->pagetable, addr, len, vma);
    
      uvmunmap(p->pagetable, addr, len / PGSIZE, 1);
    
      if(addr == vma->addr){
        vma->addr += len;
      }
      vma->len -= len;
    
      if(vma->len <= 0){
        fileclose(vma->file);
        vma->addr = 0;
      }
    
      return 0;
    }
    
    
    uint64 sys_munmap(void)
    {
      uint64 addr, len;
      if(argaddr(0, &addr) < 0 || argaddr(1, &len) < 0)
        return -1;
      return munmap(addr, len);
    }

    分开另外写函数的原因是还需要在 exit() 里调用 munmap(),有些进程在退出后还没有取消它的文件映射,那我们就需要帮它强制清理掉这些映射,要不然会造成内存泄露。

  10. 此外,此处修改了 uvmunmap() 函数的 PTE_V 标志位的检查部分,取消映射的页面可能并未实际分配,此时跳过即可。

    void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
    {
      // ...
      for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
        if((pte = walk(pagetable, a, 0)) == 0)
          panic("uvmunmap: walk");
        if((*pte & PTE_V) == 0) {
          continue;                         // lab10
    //      panic("uvmunmap: not mapped");  // lab10
        }
        if(PTE_FLAGS(*pte) == PTE_V) {
          continue;                         // lab10
    //      panic("uvmunmap: not a leaf");  // lab10
        }
        // ...
      }
    }
  11. 写回函数先判断是否需要写回,当需要写回时才将数据写到对应的文件当中去。其中,我们利用了 PTE 的标志位 PTE_D 来判断文件映射的某个页帧是否被修改过。

    // kernel/riscv.h
    #define PTE_D (1L << 7)
    // kernel/vm.c
    void writeback(pagetable_t pt, uint64 addr, uint64 len, struct vma* vma)
    {
      // printf("starting writeback: %p %d\n", addr, len);
    
      uint64 a;
      pte_t *pte;
      for(a = PGROUNDDOWN(addr); a < PGROUNDDOWN(addr + len); a += PGSIZE){
        if((pte = walk(pt, a, 0)) == 0){
          panic("mmap_writeback: walk");
        }
        if(PTE_FLAGS(*pte) == PTE_V)
          panic("mmap_writeback: not leaf");
        if(!(*pte & PTE_V)) continue; // 懒分配
    
        if((*pte & PTE_D) && (vma->flags & MAP_SHARED)){
          // 写回
          begin_op();
          ilock(vma->file->ip);
          uint64 copied_len = a - addr;
          writei(vma->file->ip, 1, a, copied_len, PGSIZE);
          iunlock(vma->file->ip);
          end_op();
        }
        kfree((void*)PTE2PA(*pte));
        *pte = 0;
      }
    }
  12. 修改 exit() 以取消映射进程的映射区域(调用 munmap() )。

    // kernel/proc.c
    void exit(int status)
    {
      struct proc *p = myproc();
    
      if(p == initproc)
        panic("init exiting");
    
      // munmap all mmap vma
      for(int i = 0; i < NVMA; i++){
        if(p->vma[i].addr){
          if(munmap(p->vma[i].addr, p->vma[i].len) != 0)
            panic("exit: munmap");
        }
      }
    
      // 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;
        }
      }
      
      ...
    }
  13. 修改 fork() 以确保子级具有与父级相同的映射区域,并增加 VMA 的 struct 文件的引用计数。

    int fork(void)
    {
      ...
    
      for(int i = 0; i < NVMA; i++){
        if(p->vma[i].addr){
          np->vma[i] = p->vma[i];
          filedup(p->vma[i].file);
        }
      }
    
      release(&np->lock);
    
      return pid;
    }

The End

tmpE88E.png

终于结束了,最后这个实验做了好久。