MIT6.s081_Lab10 mmap: Mmap

MIT6.s081 Lab10:mmap

看网上说这是第二难的Lab了,综合了文件,虚拟内存,还有页面计数的思想,花了大概一天的时间,刚开始想的很复杂,越深入越复杂,后来根据测试改已经写出来的代码的一些bug,简化一些就通过测试了,最后考虑的内存释放的事情。

代码,在写这个文章的时候,将一些没用的操作删了。所以这里有两次提交。

1. mmap

总体上来说,做这个lab你需要了解mmap的一些重要的知识,先看实验中的说明和hint,总体了解了再写,否则会出现不断修改的情况。起初我是想一边写,一边写文档的,但是后来改的太频繁了,就没有实时写,1-4部分记录一点起初的想法。

下面就是具体的实现了。

  1. 根据以往的经验,以及第一个提示,要先编译通过,然后完善mmapmunmap,就是系统调用那老一套,下面是修改的文件。

    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 23
    

    syscall.c

    extern uint64 sys_mmap(void);
    extern uint64 sys_munmap(void);
    
    [SYS_mmap]    sys_mmap,
    [SYS_munmap]  sys_munmap,
    

    sysfile.c加入系统调用函数,这里为了编译通过先返回 -1

    uint64 
    sys_mmap(void)
    {
      return -1;
    }
    
    uint64
    sys_munmap(void)
    {
      return -1;
    }
    

    经过上面的修改,make qemu就可以编译通过了。

  2. 添加vma结构体,存放mmap的一些信息,并放到进程中。

    (这里最初实现的时候忘记增加fd对应的file增加引用计数了。要对原本的fd做一定的修改了)

    在操作系统和内存管理领域,VMAVirtual 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.h

    extern struct spinlock vma_lock;
    

    修改proc.c,增加vmavma_lock定义。

    struct vma vma[VMASIZE];
    struct spinlock vma_lock;
    

    新增一个虚拟地址,方便mmap映射没有找到对应的数据进行trap操作,分配真正的内存。

    #define MMAPBASE 0x60000000
    
  3. 修改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;
    }
    
  4. 实现缺页时分配物理页面。从这开始就发现改动的过于频繁,于是

    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);
    }
    

    实现addparef

    int
    addparef(uint64 pa)
    {
      int paref;
      acquire(&ref_lock);
      ref_counts[pa/PGSIZE] += 1;
      paref = ref_counts[pa/PGSIZE];
      release(&ref_lock);
      return paref;
    }
    

    如实验上说的现在应该可以到达第一个munmap

  5. 实现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就写入文件。

  6. 修改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;
      }
    
  7. 修改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 的关键。

  1. 进程文件描述符表 (Per-process File Descriptor Table)
    • 位置: struct proc 结构体中的 struct file *ofile[NOFILE]; 数组。
    • 作用: 这是每个进程私有的。数组的索引就是文件描述符 fd。数组的每个元素是一个指向“系统级打开文件表”中某一项的指针。
    • 特点: 进程间不共享。fork() 会复制一份父进程的文件描述符表,但父子进程的表是独立的(尽管它们可能指向同一个打开文件条目)。
  2. 系统级打开文件表 (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个字节开始。
  3. 内存中的 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 条目。
  • 参数: 无。
  • 核心逻辑:
    1. 获取 ftable 的锁。
    2. 遍历 ftable.file 数组,寻找一个 ref 引用计数为0的条目(表示空闲)。
    3. 找到后,将其 ref 设为1,并初始化其他字段。
    4. 释放锁,返回这个 struct file 的指针。
  • 引用: open, pipe 等创建新文件句柄的系统调用会使用它。

2. fileclose(struct file *f)

  • 作用: 关闭一个打开的文件,即减少对 struct file 条目的引用。
  • 参数:
    • struct file *f: 指向要被关闭的 struct file 条目。
  • 核心逻辑:
    1. 获取 ftable 的锁。
    2. f->ref 减一。
    3. 关键检查: 如果 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 条目。
  • 核心逻辑:
    1. 获取 ftable 的锁。
    2. 检查 f->ref 是否小于1,如果是则说明有问题(不应该复制一个已关闭的文件)。
    3. f->ref 加一。
    4. 释放锁,返回 f
  • 引用: dup 系统调用和 fork 中复制文件描述符表时会使用。

4. filestat(struct file *f, uint64 addr)

  • 作用: 获取一个打开文件的状态信息。
  • 参数:
    • struct file *f: 指向目标 struct file
    • uint64 addr: 一个用户空间的地址,用于存放 struct stat 结果。
  • 核心逻辑:
    1. 检查文件类型是否为 inode 或设备。
    2. 调用 stati(f->ip, &st) 从 inode 层获取元数据。
    3. 调用 copyout() 将内核中的 stat 结构安全地拷贝到用户空间。
  • 引用: fstat 系统调用。

5. fileread(struct file *f, uint64 addr, int n)

  • 作用: 从一个打开的文件中读取数据。
  • 参数:
    • struct file *f: 指向要读取的 struct file
    • uint64 addr: 存放读取数据的用户空间地址。
    • int n: 要读取的最大字节数。
  • 核心逻辑:
    1. 检查文件是否可读 (f->readable)。
    2. 获取 inode 的锁 (ilock(f->ip))。
    3. 调用 readi(f->ip, 1, addr, f->off, n),将读写偏移量 f->off 传递给 inode 层。
    4. 更新 f->off 为新的偏移量。
    5. 释放 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 请求和文件系统底层复杂实现的关键枢纽

posted @ 2025-08-03 16:11  BuerH  阅读(9)  评论(0)    收藏  举报