何为内存映射

Linux 通过将一个虚拟内存区域与一个文件对象关联,以初始化这个虚拟内存区域的内容,这个过程就被称为内存映射。

那么什么是文件对象呢?众所周知,Linux 继承了 Unix 中“一切皆文件”的思想,文件对象就包括 Linux 系统中的普通文件以及匿名文件 - 一块全部包含二进制零的物理内存。

我们假设读者已经了解了内存布局的相关概念。虚拟内存的内存映射区域就是用于内存映射的。

我们先来看看如何内存映射一个普通文件

走近内存映射 - 普通文件映射

程序如果想读写磁盘中的一个文件,我们可以在这个程序的虚拟地址空间中分配一段区域,使用这一段区域去映射磁盘中的文件。那么什么是映射呢?就是程序的每一个虚拟地址都对应文件中每一个字节的数据。

此时我们假设这个文件大小是 3 个页,即 12KB,那么对应的映射区域也需要有 3 个页。

如果程序要访问这个文件,一般来说是将这个文件从磁盘加载到内存中,CPU 去内存中访问数据。当程序访问映射区地址时,发现这个虚拟页在页表中对应的物理页不存在,会触发缺页异常,通过缺页异常处理程序在物理内存中分配物理页帧,将磁盘文件加载到物理内存中,并修改页表上对应虚拟页号的物理页号。这样一来,映射到物理内存中的每一个页对应的就是磁盘文件中的数据。达到了读取物理内存的数据相当于读取磁盘文件中的数据的目的。如果对物理内存数据进行修改,那么磁盘文件的数据也会被修改。程序可以直接通过虚拟地址,访问到磁盘中的文件,不过其中需要将磁盘文件加载到物理内存中,与虚拟内存做一层映射转换。

大部分静态对象都是使用该方法进行映射,比如代码段,已初始化的数据等

img

在应用程序运行期间,需要将文件和数据加载到内存当中,这个加载的方式就是内存映射,即将虚拟内存地址磁盘文件建立相互映射的过程

mmap() 系统调用使得进程之间可以通过映射一个普通的文件实现共享内存。普通文件映射到进程地址空间后,进程可以像访问内存的方式对文件进行访问,不需要其他陷入到内核态的系统调用 (read,write) 去操作

具体而言,实际上内存映射并没有将真实的文件加载到内存中,只是建立了对应关系,逻辑上放入了内存中,即由mmap()系统调用实现,初始化数据结构-vm_area_struct

工作流程

img

内存映射只是将【虚拟内存地址】与【磁盘文件】建立映射,但是数据并没有真正地从磁盘加载到物理内存中去,如果 CPU 此时需要访问该虚拟内存地址,就会触发缺页异常

  1. 当应用程序需要运行时,首先会进行【虚拟内存地址】和【磁盘文件】之间的相互映射,之后将【映射区首地址】返回给 CPU。

    • 映射区首地址即 ptr 指针,指向进程虚拟地址空间中的一个地址
    • 有了 ptr,进程无需通过 read write 读写文件,使用 ptr 就行了
    • 由于 ptr 是虚拟地址,因此需要 MMU 转换
  2. CPU 得到对应的【虚拟内存地址】,想要执行该【虚拟内存地址】对应的【物理内存地址】中的指令。

    • 此时会通过 MMU 查询页表,但是发现页表中并没有该虚拟内存地址对应的物理内存地址
    • 发生【缺页异常
  3. 此时 CPU 会将【虚拟内存地址】映射的【磁盘文件】加载到【物理内存】中去,并且更新页表

  4. 之后,CPU 再去执行刚才的虚拟内存地址上的指令即可。

其它的映射类型

匿名文件

匿名文件其实就是物理内存,只是这块内存中都填充的是零,其实这个时候与磁盘文件没有关系,说白了就是动态申请物理内存,如堆中的实例,在需要的时候才会进行申请。因此,匿名文件的映射不会占用磁盘空间,也不会影响磁盘上的任何数据,即匿名文件的映射不需要在磁盘上创建或打开任何真实的文件,也不需要将内存中的数据写回到磁盘上。

  • 匿名文件的映射只是在内核中创建了一个虚拟的文件对象,这个对象只存在于内存中,没有任何磁盘上的对应物。

  • 当进程对匿名文件的映射进行读写操作时,只会修改内存中的数据,而不会影响磁盘上的任何数据。

img

共享文件

内存映射除了申请物理内存外,还可以用于共享对象。首先说说过程:

  1. 程序 A 映射文件到物理内存中,程序 B 也需要这个文件,也得映射这个文件
  2. 由于这个文件已经在物理内存中了,因此直接用就行了,不需要再次加载文件到物理内存中

映射的这个文件被称为共享文件对象,在物理内存中只有一份。应用程序对这个文件的修改会进行同步。而映射这个共享文件的区域被称为共享内存映射区域。

img

典型的应用是实现动态共享库

编译了一个文件为 prog1.o,它依赖很多标准库的程序 printf.o, string.o, math.o 等,程序 prog2 也依赖标准库的程序,如果要变成可执行文件,那么需要链接。静态链接实际上是将所有.o 文件中的程序代码指令都复制到同一个可执行文件里,其中要做一些符号解析、重定位的工作。

如此一来就生成了 prog1 和 prog2 两个可执行目标文件。当要执行这两个文件时,就需要将他们加载到物理内存中,此时标准静态库中的指令会重复出现在物理内存中,浪费内存。你可能不以为然,但是当共享若干标准库的程序越多,那么占用的物理内存是非常可观的。

img

我们可以考虑将这些标准库打包成共享库文件libc.so
在静态链接的时候不打包这些文件到 prog1 中,而是将 libc.so 映射到虚拟内存区域中,当程序运行时需要用到的时候就会将整个文件的代码指令加载到物理内存中,进行动态链接即可,这样就使得物理内存中只有一份共享库代码,其中每一个程序可能都有个共享库内存映射区用于映射这个程序依赖的共享库。

img

私有文件映射

和共享对象基本是一个意思,在物理内存中只保存私有对象的一个副本,在没有更改对象的情况下与共享文件对象几乎没有差别。

不同的地方在于:共享文件映射中,一个程序对共享文件进行修改,那么另一个共享这个文件的对象是可以感知其被修改的,但是针对私有文件对象不行。每个程序对私有文件对象的修改都是其余程序无法感知的

只要有一个程序试图写私有区域内的某个页,写操作就会触发一个保护故障。故障处理程序注意到保护异常是程序试图写私有的写时赋值区域的一个页面引起的,就会在物理内存中创建一个这个页面的新副本,然后更新页表项指向这个新副本,然后恢复这个页面的可写程序。这个也被称为写时复制COW(Copy On Write)技术,主要是为了提高程序的性能。

img

实现

先来看看 mmap()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <unistd.h>
#include <sys/mmam.h>
// 返回:如果成功则返回执行映射区域的指针,否则返回 MAP_FAILED(-1)
// start:虚拟内存映射区域的首地址,如果不指定,则由内核指定
// length:需要映射区域的大小,字节为单位
// prot:包含描述新映射的虚拟内存区域的访问权限位,其实就是权限位。这些参数选项会传递到页表项中
// - PROT_EXEC:这个区域的页面由可以被 CPU 执行的指令组成
// - PROT_READ:这个区域的页面可读
// - PROT_WRITE:这个区域的页面可写
// - PROT_NONE:这个区域的页面不能被访问
// flags:描述被映射对象的类型
// - MAP_ANON:被映射的对象是一个匿名对象,相应的虚拟页是请求二进制零
// - MAP_PRIVATE:表示被映射的对象是一个私有的,写时复制的对象
// - MAP_SHARED:表示被映射的对象是一个共享对象
// offset:从磁盘文件的 offset 偏移开始,映射 length 个字节
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

// 表示让内核创建一个包含 size 字节的只读的私有的请求二进制零的虚拟内存区域
// 如果成功,bufp 则是这个新区域的起始地址
bufp = mmap(NULL, size, PROT_READ, MAP_PRIVATE | MAP_ANON, 0, 0)

内存映射实际上除了映射一整个文件,还可以只映射一个文件的一部分。

删除一个内存映射区域使用 munmap(),对一个已删除的区域的引用会导致段错误

1
2
3
4
#include <unistd.h>
#include <sys.mmam.h>
// 如果成功返回 0,否则返回 -1
int munmap(void *start, size_t length);

img

对于每一个程序都有对应的 ELF 可执行目标文件,其中对应虚拟地址都是一段一段的,加载 ELF 时每一段都是内存映射的过程。比如

  • 栈也是私有的请求二进制零的匿名文件映射
  • 共享库内存映射区就是共享文件映射
  • 运行时堆也是私有的,请求二进制零的匿名文件映射
  • 未初始化的数据.bss 是私有的,请求二进制零的匿名文件映射
  • 已初始化的数据.data 与代码.text,是私有的映射文件

img

管理内存映射区

应用程序通过内存映射将数据一段一段加载到内存中,同时操作系统为了更好管理应用程序的内存,操作系统将这一段一段的数据分成了一个个的内存区域,如代码段,已初始化数据段等。

管理内存

Linux 中将每一个内存区域都抽象为一个vm_area_struct结构体

1
2
3
4
5
6
7
vm_area_struct 包括部分属性:
vm_prev 指向它的上一个内存映射区实例
vm_start
vm_end
vm_prot
vm_flag
vm_next 指向它的下一个内存映射区实例

由于内存区域会频繁地创建和删除,所以使用双向链表来管理,因为链表的随机删除和插入都是 O(1) 时间复杂度。因此需要在链表节点中添加 prev 和 next 来维护链表。

链表的结构自然有链表的缺点,随机查找一个节点的时间复杂度是 O(N),如何根据一个虚拟地址快速找到这个虚拟地址处于哪个映射区域呢?
mmap() 是一个很常见的操作,但是链表可能会很长,直接遍历的效率很低。我们可以将节点都组成一颗红黑树,每一个节点都是vm_area_struct的实例,每一个节点区域的vm_start作为节点的 key,而vm_start又是有顺序的。根据虚拟地址通过比较 vm_start 与 vm_end就能很快找到虚拟地址所在节点,从而找到虚拟地址所在区域,红黑树的查找时间复杂度是 O(logN),在大基数情况下效率远超过顺序遍历链表的 O(N)。

最终采取的是链表加红黑树结合来管理内存区域。红黑树的相邻节点之间使用双向链表相连。链表用于维护相邻区域的关系,有利于相邻区域的合并,而红黑树用于高效查询指定区域

img

内核态中会存储每个应用程序所有的 vm_area_struct 以便管理

调用 mmap() 过程

实际上 mmap 是一个系统调用,mmap() 是 glibc 的一个函数
先陷入内核态,找到_sys_mmap 实现方法

  1. 判断内存映射是映射文件还是映射匿名文件。如果是普通文件,通过 fget 拿到文件对象 file(内核实例的指针,否则直接往前走
  2. 在红黑树找到一个未映射的内存映射区域。可能会指定起始地址啥的,需要看看是不是空闲的
  3. 判断是否可以与前一个区域合并(与前一个地址重叠或相等),合并完直接返回
  4. 否则创建一个新的 vm_area_struct
  5. 将 vm_area_struct 挂到红黑树上
  6. 判断是否是映射文件
  7. 如果是映射文件,还得建立文件 file 到内存映射区域的联系

虚拟地址空间

对于每一个用户态的虚拟地址空间用 mm_struct 结构体表示

1
2
3
4
5
6
7
struct mm_struct {
struct rb_root mm_rb, // 红黑树的根节点
struct vm_area_struct *mmap, // vm_area_struct 双向链表的表头
pgd, // 指向页表的起始地址
start_code, end_code, end_data, mmap_base // 代码段起始地址、结束地址,数据段结束地址,先创建的内存映射区的起始地址等
}

img

值得一提的是,vm_area_struct也有一个指向管理它的mm_struct的指针

优点

对文件的读取操作跨过了页缓存,减少了数据的拷贝次数,用内存读写取代 I/O 读写,提高了文件读取效率。避免了频繁的系统调用。

  • 普通读取文件如 read 需要先将文件数据从磁盘拷贝至内核态缓冲区中再从内核态拷贝至用户空间
  • 而 mmap 建立映射后,可以直接通过内存映射区域(mmap 内存映射区)访问磁盘文件(通过缺页中断实现。映射的内存区域实际上是内核的 page cache,这意味着不需要在用户空间创建副本),免去了一次数据拷贝的花费,效率要更高。
  • 一个比较好的实现是boltdb,它就是基于 mmap,省去了实现 page cacher,基于零拷贝的实现提高了性能,且项目更为简洁。

参考