mmap 系统调用

1 mmap 简介

mmap 称为内存映射,准确地说应该叫做虚拟内存映射,是指将文件映射到进程的虚拟内存地址空间。

mmap 的声明为:
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);

其中,len 为映射区的长度,表示最多能映射的字节数,并非文件所要映射部分的长度。

需要知道的是,mmap 绝对不仅仅是 为了避免进程中的数据在用户空间和内核空间不必要的拷贝,以及实现共享内存。

事实上,mmap 系统调用是一个非常基础的系统调用。它在很多地方都被使用到,比如程序装载器 loader 在装载所要执行的程序文件,程序运行时动态库的链接,epoll 中为了减少进程用户空间与内核空间之间 fd 的拷贝次数,实现共享内存用于多个进程间通信,实现零拷贝加速文件传输等。

1.1 read/write系统调用

read/write系统调用需要拷贝两次,才能将文件中的数据拷贝至用户程序某个数组中。

  • 第一次拷贝发生在将文件中的内容拷贝至内核中的页高速缓存中(数据实质上是保存在物理内存中的,所对应的数据结构是一个 page 结构体);
  • 第二次拷贝发生在实际I/O阶段,也即将数据从内核中的页高速缓存拷贝至用户空间(数据实质上仍是保存在物理内存中的,但是是另一块物理内存,所对应的数据结构是一个数组,即 read/write 函数中的第二个参数)。

如图所示:

1.png-53.7kB

有以下几点需要注意:

  • 进程的虚拟地址空间并不存储实际的数据。
  • 程序的执行事实上都是根据指令或数据对应的虚拟地址,通过搜索页表(每个用户进程都有自己的页表),然后再实际访问它们对应的物理地址。
  • 之所以要将数据从内核空间拷贝至用户空间中,是因为用户进程无法直接访问内核中的数据。

1.2 使用 mmap 系统调用来拷贝文件

首先通过 mmap 系统调用在进程用户空间申请一段地址,然后在用户页表中增加一个用户虚拟地址空间到文件物理地址的映射。当进程运行时访问这段虚拟地址空间发生缺页异常时,才将普通文件拷贝至这段用户空间所映射的内存中(mmap 只负责前面两个步骤,这个步骤是由内存管理相关模块负责)。

如图所示:

2.png-63.6kB

要注意这里两个映射。一个是文件所在磁盘或者其他设备的物理地址到进程虚拟地址的映射,以及物理内存到进程虚拟地址的映射。

具体过程如下:

  • mmap 在进程用户空间中的堆栈之间根据 mmap 参数申请一段地址空间。
  • 建立映射并不是说将这个文件的内容直接装载到内存。
    一般来说,虚拟内存管理都是按需进行装载。也就是说,当需要访问某个虚拟地址对应的页面而其又不存在于内存之中时(通过查看页表),才根据 mmap 建立好的映射关系,获取文件中该页在设备上的物理地址,然后将该页装入物理内存之中。
  • 之后建立该页的逻辑地址到物理地址的映射(在页表中添加相应的页表项)。

1.3 使用 mmap 与 read/write 对比

从上可以看到,当想读取文件中的内容到进程用户空间时,mmap 只需要进行一次拷贝。

而 read/write 由于需要先将文件中的数据拷贝至页高速缓存,而其又处于内核之后总,因此到最终拷贝至进程用户空间中时,需要进行两次拷贝。

2 mmap 的实现

主要分为两步。

  • 第一步是在进程的用户空间中申请一段地址空间;
  • 第二步是建立进程刚申请好的地址空间到文件的物理地址空间的映射。

2.1 mm_struct 结构体

这个结构体叫做内存描述符,用以表示进程的地址空间。

其中有三个最重要的元素,分别是 struct vm_area_struct *mmapstruct rb_root *mm_rb 以及 pgd_t *pgd

  • mmap 指针用来以 单链表 的形式组织并管理进程的所有虚拟内存区域 (Virtual Memory Areas, VMAs);
  • mm_rb 指针用来以 红黑树 的形式组织并管理进程的所有虚拟内存区域;

    两者保存的内容都是一样的,只是形式不一样而已。

  • pgd 指针用来组织并管理进程的页全局目录(Linux 之前采用三级页表结构,但自内核 2.6.11 开始采用四级页表结构,其中最上层的页表就是这个页全局目录)。

如下图所示:

3.png-223.9kB

进程中的所有 vm_area_struct 对象用两种形式进行组织。一种是单链表;另外一种是红黑树。由于两者的特点各自能够适用于不同的场景,因此它们在进程中同时存在。

这些数据结构由内核管理,因为内核才是资源的最终管理者,用户进程只是申请资源。

2.2 vm_area_struct 结构体

该结构体中有一个 struct file *vm_file,这个指针指向的是一个 file 结构体。除此之外,还有文件偏移量等元素。

内核在执行 mmap 系统调用的时候,首先会分配一段地址空间,然后建立一个 vm_area_struct 对象,并插入相应的单链表和红黑树之中。同时根据传入的 fd 及其偏移量,设置好该结构体中相应元素的值。

2.2 文件物理地址到进程虚拟地址的映射

调用 remap_pfn_range 或者 nopage 等函数建立映射。实质上就是添加一个相应的页表项。

具体地,这些函数通过 fd 对应的 file 对象找到这个文件对应的 inode 对象。然后根据给定的文件偏移量以及要映射的文件的长度,通过 inode 对象中的逻辑块到物理块的映射,得到所要映射部分的物理地址。然后再添加到该进程的页表项中。

3 mmap 的其他注意细节

3.1 关于 fd 的问题

用 mmap 建立好进程用户空间逻辑地址到文件物理地址的映射后,可以关闭 fd。

因为,fd 只是被 mmap 用来找到所要映射部分的物理地址,从而建立映射。所以,映射完成后,完全可以关闭 fd;

3.2 mmap 中的参数 len

这个 len 参数指的是映射区的大小,也就是说最多映射 len 个字节到映射区中。

具体地说,有两种情况:

  • 当 len 等于文件要映射的大小时,此时在虚拟地址空间中开辟的空间大小也为 len;
  • 当 len 大于文件要映射的大小时,也就是说 len 大于 文件尾部与文件偏移量的差值时,只会将文件中偏移量到文件尾部之间的部分映射到映射区中。

3.3 关于对齐的问题

  • 文件偏移量必须是页长的整数倍。
  • 对于映射区首地址,可以为其指定一个值。即使指定的这个值不是页长的整数倍,操作系统也会自动对其进行适当扩展至页长的整数倍(当然,具体的实现与所要映射区的大小以及虚拟内存地址空间的空闲部分相关)。
    通常情况下,传递给 mmap 的首地址指定为 NULL。
  • 映射区的长度没有这个限制。
  • 不管映射区空间大小大于或者是等于文件所要映射部分大小,如果文件所要映射部分的大小不是页长的整数倍时,那么末尾部分所占用的页中,剩余空间会被 0 填充。

对于被 0 填充的问题有以下几点需要注意:

  • 当映射区空间大小等于文件所要映射部分大小时,即使所要映射的末位部分所占用的页跨过了映射区范围,这页中跨过映射区范围内的部分仍然会被 0 填充。如图所示(假设是将整个文件映射到进程虚拟地址空间):

    4.png-47kB

  • 当映射区空间大小大于文件所要映射部分大小时,映射区中除了文件所要映射的末尾部分所在页的剩余空间会被 0 填充外,其余没有映射文件的所有区域都不会被 0 填充。如图所示(假设是将整个文件映射到进程虚拟地址空间):

    5.png-58.1kB

  • 另外,这里所说的映射区中没有映射文件的区域填充 0,并不是在文件末尾追加填充 0,更不是在虚拟内存中填充 0,而是当将映射区装入内存时,这块区域在内存中会被填充为 0。
  • 对于填充为 0 的部分,进程是完全可以正常访问的。不过,虽然可以向其中写入数据,但不会反映到文件中。这是因为,它们并不属于文件所映射的部分,只是为了解决对齐问题而填充的。

3.4 SIGSEGV 和 SIGBUS 信号

  • 如果进程试图访问映射区以外且不是被 0 填充的部分,或者进程试图写入数据到一个只读的区域时,内核会发出 SIGSEGV 信号;
  • 如果进程试图访问映射区中没有映射任何文件且不是被 0 填充的区域,则内核会发出 SIGBUS 信号。

例如:

一个文件的大小是 5000 字节,且 mmap 映射区的大小是 5000 字节。 mmap 函数从文件的起始位置开始,映射 5000 字节到进程的虚拟地址空间中。如图所示:

6.png-88.5kB

此时,

  • 进程读写映射区中前 5000 个字节(0 ~ 4999),并且写入的数据可以反映到原文件中。
  • 对于映射区中 5000 ~ 8191 字节的数据,进程可以正常读写。但是进程读取这段空间时结果全为 0,写这段空间时,所写的内容不会反映到原文件中 。
  • 对于映射区中 8192 字节之后的空间,进程不能对其进行正常读写。如果进程试图对其进行读写,则内核会发送 SIGSECV 信号。

又例如:

一个文件的大小是 5000 字节,且 mmap 映射区的大小为 15000 字节。mmap 函数从一个文件的起始位置开始,映射 5000 字节到虚拟地址空间中。如图所示:

7.png-123.4kB

此时,

  • 进程可以正常读写映射区中的前 5000 字节(0 ~ 4999),并且写入的数据可以反映到原文件中。
  • 对于映射区中 5000 ~ 8191 字节的数据,进程可以正常读写。但是进程读取这段空间时结果全为 0,写这段空间时,所写的内容不会反映到原文件中 。
  • 对于映射区中 8192 ~ 14999 字节,进程不能对其进行正常读写。如果进程试图对其进行读写,则内核会发送 SIGBUS 信号。
  • 对于映射区中 15000 字节之后的空间,进程不能对其进行正常读写。如果进程试图对其进行读写,则内核会发送 SIGSEGV 信号。

3.5 不是所有的文件都可以进行虚拟内存映射

比如,不能将控制终端或套接字映射虚拟地址空间中,它们只能通过 read/write 系统调用等函数来访问。


Reference