c++内存管理系列:mmap原理

内存映射

内存映射,简而言之就是将用户空间的一段内存区域映射到内核空间,映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间。那么对于内核空间<—->用户空间两者之间需要大量数据传输等操作的话效率是非常高的。

传统的读写文件

一般来说,修改一个文件的内容需要如下3个步骤:

  • 把文件内容读入到内存中。
  • 修改内存中的内容。
  • 把内存的数据写入到文件中。


从图中可以看出,页缓存(page cache) 是读写文件时的中间层,内核使用页缓存与文件的数据块关联起来。所以应用程序读写文件时,实际操作的是页缓存。

mmap读写文件

mmap并不分配空间, 只是将文件映射到调用进程的地址空间里(但是会占掉你的 virutal memory), 然后你就可以用memcpy等操作写文件, 而不用write()了.写完后,内存中的内容并不会立即更新到文件中,而是有一段时间的延迟,你可以调用msync()来显式同步一下, 这样你所写的内容就能立即保存到文件里了.这点应该和驱动相关。 不过通过mmap来写文件这种方式没办法增加文件的长度, 因为要映射的长度在调用mmap()的时候就决定了.如果想取消内存映射,可以调用munmap()来取消内存映射。

函数调用的格式为:

1
2
3
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
//返回:若成功则为被映射区的起始地址,若出错则为MAP_FAILED

其中addr可以指定描述符fd应该被映射到的进程内空间的起始地址。它通常被指定为一个空指针,这样告诉内核自己去选择起始地址。无论哪种情况下,该函数的返回值都是描述符fd所映射到内存的起始地址。

len是映射到调用进程地址空间中的字节数,它从被映射文件开头起第offset个字节处开始算。offset通常设置为0。

内存映射区的保护由prot参数指定,该参数的常见值是代表读写访问的PROT_READ|PROT_WRITE

1
2
3
4
5
prot          说明
PROT_READ 数据可读
PROT_WRITE 数据可写
PROT_EXEC 数据可执行
PROT_NONE 数据不可访问

从传统读写文件的过程中,我们可以发现有个地方可以优化:如果可以直接在用户空间读写 页缓存,那么就可以免去将 页缓存 的数据复制到用户空间缓冲区的过程。
那么,有没有这样的技术能实现上面所说的方式呢?答案是肯定的,就是 mmap。
使用 mmap 系统调用可以将用户空间的虚拟内存地址与文件进行映射(绑定),对映射后的虚拟内存地址进行读写操作就如同对文件进行读写操作一样。原理如图所示:

前面我们介绍过,读写文件都需要经过 页缓存,所以 mmap 映射的正是文件的 页缓存,而非磁盘中的文件本身。由于 mmap 映射的是文件的 页缓存,所以就涉及到同步的问题,即 页缓存 会在什么时候把数据同步到磁盘。
Linux 内核并不会主动把 mmap 映射的 页缓存 同步到磁盘,而是需要用户主动触发。同步 mmap 映射的内存到磁盘有 4 个时机:

  • 调用 msync 函数主动进行数据同步(主动)。
  • 调用 munmap 函数对文件进行解除映射关系时(主动)。
  • 进程退出时(被动)。
  • 系统关机时(被动)。

总结

mmap内存映射的实现过程,总的来说可以分为三个阶段:

  • 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

    • 进程在用户空间调用库函数mmap;

      1
      void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
    • 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址;

    • 为此虚拟区分配一个vmareastruct结构,接着对这个结构的各个域进行了初始化;

    • 将新建的虚拟区结构(vmareastruct)插入进程的虚拟地址区域链表或树中

  • 调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系

    • 为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
    • 通过该文件的文件结构体,链接到 file_operations 模块,调用内核函数mmap :
      1
      int mmap(struct file *filp, struct vm_area_struct *vma)
      不同于用户空间库函数。
    • 内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
    • 通过remappfnrange函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。
  • 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

    • 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
    • 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
    • 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
    • 之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
  • 注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。