本章将介绍如何使用 mmap()系统调用来创建内存映射。内存映射可用于 IPC 以及其他很多方面。下面在深入介绍 mmap()之前首先概述一些基础概念。
49.1 概述
mmap()系统调用在调用进程的虚拟地址空间中创建一个新内存映射。映射分为两种。
1.文件映射:文件映射将一个文件的一部分直接映射到调用进程的虚拟内存中。一旦一个文件被映射之后就可以通过在相应的内存区域中操作字节来访问文件内容了。映射的分页会在需要的时候从文件中(自动)加载。这种映射也被称为基于文件的映射或内存映射文件。
2.匿名映射:一个匿名映射没有对应的文件。相反,这种映射的分页会被初始化为 0。
一个进程的映射中的内存可以与其他进程中的映射共享(即各个进程的页表条目指向RAM 中相同分页)。这种行为会在两种情况下发生。
1.当两个进程映射了一个文件的同一个区域时它们会共享物理内存的相同分页。
2.通过 fork()创建的子进程会继承其父进程的映射的副本,并且这些映射所引用的物理内存分页与父进程中相应映射所引用的分页相同。
当两个或更多个进程共享相同分页时,每个进程都有可能会看到其他进程对分页内容做出的变更,当然这要取决于映射是私有的还是共享的。
1.私有映射( MAP_PRIVATE):
在映射内容上发生的变更对其他进程不可见,对于文件映射来讲,变更将不会在底层文件上进行。尽管一个私有映射的分页在上面介绍的情况中初始时是共享的,但对映射内容所做出的变更对各个进程来讲则是私有的。内核使用了写时复制( copy-on-write)技术完成了这个任务。这意味着每当一个进程试图修改一个分页的内容时,内核首先会为该进程创建一个新分页并将需修改的分页中的内容复制到新分页中(以及调整进程的页表)。正因为这个原因,MAP_PRIVATE 映射有时候会被称为私有、写时复制映射。
2.共享映射( MAP_SHARED):
在映射内容上发生的变更对所有共享同一个映射的其他进程都可见,对于文件映射来讲,变更将会发生在底层的文件上。
四种不同的内存映射的创建和使用方式
1.私有文件映射:
映射的内容被初始化为一个文件区域中的内容。多个映射同一个文件的进程初始时会共享同样的内存物理分页,但系统使用写时复制技术使得一个进程对映射所做出的变更对其他进程不可见。这种映射的主要用途是使用一个文件的内容来初始化一块内存区域。一些常见的例子包括根据二进制可执行文件或共享库文件的相应部分来初始化一个进程的文本和数据段。
2.私有匿名映射:
每次调用 mmap()创建一个私有匿名映射时都会产生一个新映射,该映射与同一(或不同)进程创建的其他匿名映射是不同的(即不会共享物理分页)。 尽管子进程会继承其父进程的映射,但写时复制语义确保在 fork()之后父进程和子进程不会看到其他进程对映射所做出的变更。私有匿名映射的主要用途是为一个进程分配新(用零填充)内存(如在分配大块内存时 malloc()会为此而使用 mmap())。
3.共享文件映射:
所有映射一个文件的同一区域的进程会共享同样的内存物理分页,这些分页的内容将被初始化为该文件区域。对映射内容的修改将直接在文件中进行。这种映射主要用于两个用途。
第一,它允许内存映射 I/O,这表示一个文件会被加载到进程的虚拟内存中的一个区域中并且对该块内容的变更会自动被写入到这个文件中。因此, 内存映射 I/O 为使用 read()和 write()来执行文件 I/O 这种做法提供了一种替代方案。
第二种用途是允许无关进程共享一块内容以便以一种类似于 System V共享内存段(第 48 章)的方式来执行 IPC。
4.共享匿名映射:
与私有匿名映射一样,每次调用 mmap()创建一个共享匿名映射时都会产生一个新的、与任何其他映射不共享分页的截然不同的映射。这里的差别在于映射的分页不会被写时复制。这意味着当一个子进程在 fork()之后继承映射时,父进程和子进程共享同样的 RAM 分页,并且一个进程对映射内容所做出的变更会对其他进程可见。共享匿名映射允许以一种类似于 System V 共享内存段的方式来进行 IPC,但只有相关进程之间才能这么做。
一个进程在执行 exec()时映射会丢失,但通过 fork()创建的子进程会继承映射,映射类型( MAP_PRIVATE 或 MAP_SHARED)也会被继承。
通过 Linux 特有的/proc/PID/maps 文件能够查看在 48.5 节中介绍过的与一个进程的映射有关的所有信息。
49.2 创建一个映射: mmap()
mmap()系统调用在调用进程的虚拟地址空间中创建一个新映射。
#include<mman.h>
void *mmap(void *addr,size_t length,int prot,int flags,int fd,off_t offset)
/* 文件映射需要传fd,offset,匿名映射不需要*/
addr 参数指定了映射被放置的虚拟地址。如果将 addr 指定为 NULL,那么内核会为映射选择一个合适的地址。这是创建映射的首选做法。或者在 addr 中指定一个非 NULL 值时,内核会在选择将映射放置在何处时将这个参数值作为一个提示信息来处理。
在实践中,内核至少会将指定的地址舍入到最近的一个分页边界处。不管采用何种方式,内核会选择一个不与任何既有映射冲突的地址。(如果在 flags 包含了 MAP_FIXED,那么 addr 必须是分页对齐的。在 49.10 节中将会对这个标记进行介绍。 )
成功时 mmap()会返回新映射的起始地址。发生错误时 mmap()会返回 MAP_FAILED。
length 参数指定了映射的字节数。尽管 length 无需是一个系统分页大小( sysconf(_SC_PAGESIZE)返回值)的倍数,但内核会以分页大小为单位来创建映射,因此实际上 length 会被向上提升为分页大小的下一个倍数。 prot 参 数 是 一 个 位 掩 码 , 它 指 定 了 施 加 于 映 射 之 上 的 保 护 信 息 , 其 取 值 要 么 是PROT_NONE,要么是表 49-2 中列出的其他三个标记的组合(取 OR)。
表 49-2:prot参数内存保护值
值 | 描 述 |
---|---|
PROT_NONE | 区域无法访问 |
PROT_READ PROT_WRITE PROT_EXEC | 区域内容可读取 区域内容可修改 区域内容可执行 |
flags 参数是一个控制映射操作各个方面的选项的位掩码。这个掩码必须只包含下列值中一个。
MAP_PRIVATE 创建一个私有映射。区域中内容上所发生的变更对使用同一映射的其他进程是不可见的,对于文件映射来讲,所发生的变更将不会反应在底层文件上。 MAP_SHARED 创建一个共享映射。区域中内容上所发生的变更对使用 MAP_SHARED 特性映射同一区域的进程是可见的,对于文件映射来讲,所发生的变更将直接反应在底层文件上。对文件的更新将无法确保立即生效,具体可参加 49.5 节中对 msync()系统调用的介绍。
除了 MAP_PRIVATE 和 MAP_SHARED 之外,在 flags 中还可以有选择地对其他标记取OR。在 49.6 和 49.10 节中将会对这些标记进行介绍。
剩余的参数 fd 和 offset 是用于文件映射的(匿名映射将忽略它们)。 fd 参数是一个标识被映射的文件的文件描述符。 offset 参数指定了映射在文件中的起点,它必须是系统分页大小的倍数。要映射整个文件就需要将 offset 指定为 0 并且将 length 指定为文件大小。
前 面 提 过 mmap() prot 参 数 指 定 了 新 内 存 映 射 上 的 保 护 信 息 。 这 个 参 数 可 以 取PROT_NONE 或者 PROT_READ、 PROT_WRITE、以及 PROT_EXEC 中一个或多个标记的掩码。如果一个进程在访问一个内存区域时违反了该区域上的保护位,那么内核会向该进程发送一个 SIGSEGV 信号。
标记为 PROT_NONE 的分页内存的一个用途是作为一个进程分配的内存区域的起始位置或结束位置的守护分页。如果进程意外地访问了其中一个被标记为PROT_NONE 的分页,那么内核会通过生成一个 SIGSEGV 信号来通知该进程这样一个事实。
内存保护信息驻留在进程私有的虚拟内存表中。因此,不同的进程可能会使用不同的保护位来映射同一个内存区域。
使用 mprotect()系统调用( 50.1 节)能够修改内存保护位。
在一些 UNIX 实现上,实际施加于一个映射分页上的保护位于在 prot 中指定的信息可能不完全一致。特别地,底层硬件在保护粒度上的限制(如老式的 x86-32 架构)意味着在很多UNIX 实现上 PROT_READ 会隐含 PROT_EXEC,反之亦然,并且在一些实现上指定PROT_WRITE 会隐含 PROT_READ。但应用程序不应该依赖于这种行为; prot 指定的信息应该总是与所需的内存保护信息一致
标准中规定的对 offset 和 addr 的对齐约束
SUSv3 规定 mmap()的 offset 参数必须要与分页对齐,而 addr 参数在指定了 MAP_FIXED 的情况下也必须要与分页对齐。 Linux 遵循了这些要求,但后面又发现 SUSv3 的要求与之前的标准提出的要求是不同的,之前的标准对这些参数的要求要低一些。 SUSv3 中的措辞会(不必要地)导致一些之前符合标准的实现变得不符合标准了。 SUSv4 则放宽了这方面的要求:
1.一个实现可能会要求 offset 为系统分页大小的倍数。
2.如果指定了 MAP_FIXED,那么一个实现可能会要求 addr 是分页对齐的。
3.如果指定了 MAP_FIXED 并且 addr 为非零值,那么 addr 和 offset 除以系统分页大小所得的余数应该相等
49.3 解除映射区域: munmap()
munmap()系统调用执行与 mmap()相反的操作,即从调用进程的虚拟地址空间中删除一个映射。
#include<mman.h>
void *munmap(void *addr,size_t length)
addr 参数是待解除映射的地址范围的起始地址,它必须与一个分页边界对齐。( SUSv3 规定 addr 必须是分页对齐的。 SUSv4 表示一个实现可以要求这个参数是分页对齐的。 ) length 参数是一个非负整数,它指定了待解除映射区域的大小(字节数)。范围为系统分页大小的下一个倍数的地址空间将会被解除映射。
一般来讲通常会解除整个映射。因此可以将 addr 指定为上一个 mmap()调用返回的地址,并且 length 的值与 mmap()调用中使用的 length 的值一样。下面是一个例子。
或者也可以解除一个映射中的部分映射,这样原来的映射要么会收缩,要么会被分成两个,这取决于在何处开始解除映射。还可以指定一个跨越多个映射的地址范围,这样的话所有在范围内的映射都会被解除。
如果在由 addr 和 length 指定的地址范围中不存在映射, 那么 munmap()将不起任何作用并返回 0(表示成功)。 在解除映射期间,内核会删除进程持有的在指定地址范围内的所有内存锁。(内存锁是通过mlock()或 mlockall()来建立的, 50.2 节将会对此予以介绍。 ) 当一个进程终止或执行了一个 exec()之后进程中所有的映射会自动被解除。 为确保一个共享文件映射的内容会被写入到底层文件中,在使用 munmap()解除一个映射之前需要调用 msync()
49.4 文件映射
要创建一个文件映射需要执行下面的步骤。
1. 获取文件的一个描述符,通常通过调用 open()来完成。
2. 将文件描述符作为 fd 参数传入 mmap()调用。
执行上述步骤之后 mmap()会将打开的文件的内容映射到调用进程的地址空间中。一旦mmap()被调用之后就能够关闭文件描述符了,而不会对映射产生任何影响。但在一些情况下,将这个文件描述符保持在打开状态可能是有用的 除了普通的磁盘文件,使用 mmap()还能够映射各种真实和虚拟设备的内容,如硬盘、光盘以及/dev/mem。 在打开描述符 fd 引用的文件时必须要具备与 prot 和 flags 参数值匹配的权限。特别地,文件必须总是被打开以允许读取,并且如果在 flags 中指定了 PROT_WRITE 和 MAP_SHARED,那么文件必须总是被打开以允许读取和写入。 offset 参数指定了从文件区域中的哪个字节开始映射,它必须是系统分页大小的倍数。将offset 指定为 0 会导致从文件的起始位置开始映射。 length 参数指定了映射的字节数。 offset和length 参数一起确定了文件的哪个区域会被映射进内存,如图 49-1 所示。
49.4.1 私有文件映射
私有文件映射最常见的两个用途如下所述。
1.允许多个执行同一个程序或使用同一个共享库的进程共享同样的(只读的)文本段,它是从底层可执行文件或库文件的相应部分映射而来的。
2.映射一个可执行文件或共享库的初始化数据段。这种映射会被处理成私有使得对映射数据段内容的变更不会发生在底层文件上。
mmap()的这两种用法通常对程序是不可见的,因为这些映射是由程序加载器和动态链接器创建的。读者可以在 48.5 节中给出的/proc/PID/maps 的输出中发现这两种映射。
私有文件映射的另一个不太常见的用途是简化程序的文件输入逻辑。这与使用共享文件映射来完成内存映射I/O类似,但它只允许文件输入。
49.4.2 共享文件映射
当多个进程创建了同一个文件区域的共享映射时,它们会共享同样的内存物理分页。此外,对映射内容的变更将会反应到文件上。实际上,这个文件被当成了该块内存区域的分页存储,如图 49-2 所示。
共享文件映射存在两个用途:内存映射 I/O 和 IPC。下面将分别介绍这两种用途。
内存映射 I/O
由于共享文件映射中的内容是从文件初始化而来的,并且对映射内容所做出的变更都会自动反应到文件上,因此可以简单地通过访问内存中的字节来执行文件 I/O,而依靠内核来确保对内存的变更会被传递到映射文件中。(一般来讲,一个程序会定义一个结构化数据类型来与磁盘文件中的内容对应起来,然后使用该数据类型来转换映射的内容。 )这项技术被称为内存映射 I/O,它是使用 read()和 write()来访问文件内容这种方法的替代方案。
内存映射 I/O 具备两个潜在的优势。
1.使用内存访问来取代 read()和 write()系统调用能够简化一些应用程序的逻辑。
2.在一些情况下,它能够比使用传统的 I/O 系统调用执行文件 I/O 这种做法提供更好的性能。
内存映射 I/O 之所以能够带来性能优势的原因如下。
1.正常的 read()或 write()需要两次传输:一次是在文件和内核高速缓冲区之间,另一次是在内核高速缓冲区和用户空间缓冲区之间。使用 mmap()就无需第二次传输了。对于输入来讲,一旦内核将相应的文件块映射进内存之后用户进程就能够使用这些数据了。 对于输出来讲,用户进程仅仅需要修改内存中的内容,然后可以依靠内核内存管理器来自动更新底层的文件。
2.除了节省了内核空间和用户空间之间的一次传输之外, mmap()还能够通过减少所需使用的内存来提升性能。当使用 read()或 write()时,数据将被保存在两个缓冲区中:一个位于用户空间,另一个位于内核空间。当使用 mmap()时,内核空间和用户空间会共享同一个缓冲区。此外,如果多个进程正在在同一个文件上执行 I/O,那么它们通过使用 mmap()就能够共享同一个内核缓冲区,从而又能够节省内存的消耗。
内存映射 I/O 所带来的性能优势在在大型文件中执行重复随机访问时最有可能体现出来。如果顺序地访问一个文件, 并假设执行 I/O 时使用的缓冲区大小足够大以至于能够避免执行大量的 I/O 系统调用,那么与 read()和 write()相比, mmap()带来的性能上的提升就非常有限或者说根本就没有带来性能上的提升。性能提升的幅度之所以非常有限的原因是不管使用何种技术,整个文件的内容在磁盘和内存之间只传输一次,效率的提高主要得益于减少了用户空间和内核空间之间的一次数据传输,并且与磁盘 I/O 所需的时间相比,内存使用量的降低通常是可以忽略的。
使用共享文件映射的 IPC
由于所有使用同样文件区域的共享映射的进程共享同样的内存物理分页,因此共享文件映射的第二个用途是作为一种 IPC 方法。
这种共享内存区域与 System V 共享内存对象之间的区别在于区域中内容上的变更会反应到底层的映射文件上。这种特性对那些需要共享内存内容在应用程序或系统重启时能够持久化的应用程序来讲是非常有用的。
49.4.3 边界情况
在很多情况下,一个映射的大小是系统分页大小的整数倍,并且映射会完全落入映射文件的范围之内。但这种要求不是必需的,下面来看一下当这些条件不满足时会发生什么事情。
图 49-3 描绘了映射完全落入映射文件的范围之内但区域的大小并不是系统分页大小的一个整数倍的情况(在这个讨论中假设分页大小为 4096 字节)。
由于映射的大小不是系统分页大小的整数倍,因此它会被向上舍入到系统分页大小的下一个整数倍。 由于文件的大小要大于这个被向上舍入的大小, 因此文件中对应字节会像图 49-3中那样被映射。
试图访问映射结尾之外的字节将会导致 SIGSEGV 信号的产生(假设在该位置处不存在其他映射)。这个信号的默认动作是终止进程并打印出一个 core dump。 当映射扩充过了底层文件的结尾处时(参见图 49-4)情况就变得更加复杂了。与之前一样,由于映射的大小不是系统分页大小的整数倍,因此它会被向上舍入。但在这种情况下,虽然在向上舍入区域(即图中 2200 字节和 4095 字节)中的字节是可访问的,但它们不会被映射到底层文件上(由于在文件中不存在对应的字节),并且它们会被初始化为 0( SUSv3 对此进行了规定)。当然,这些字节也不会与映射同一个文件的其他进程共享,即使它们指定了足够大的 length 参数。对这些字节做出的变更不会被写入到文件中
如果映射中包含了超出向上舍入区域中(即图 49-4 中 4096 以及之后的字节)的分页,那么试图访问这些分页中的地址将会导致 SIGBUS 信号量的产生, 即警告进程文件中没有区域与这些地址对应。与之前一样,试图访问超过映射结尾处的地址将会导致 SIGSEGV 信号的产生。 从上面的描述中可以看出,创建一个大小超过底层文件大小的映射可能是无意义的。但通过扩展文件的大小(如使用 ftruncate()或 write()),可以使得这种映射中之前不可访问的部分变得可用。
49.4.4 内存保护和文件访问模式交互
到目前为止还没有详细解释的一点是通过 mmap() prot 参数指定的内存保护与映射文件被打开的模式之间的交互。
从一般原则来讲, PROT_READ 和 PROT_EXEC 保护要求被映射的文件使用 O_RDONLY 或 O_RDWR 打开,而 PROT_WRITE 保护要求被映射的文件使用O_WRONLY 或 O_RDWR 打开。 然而,由于一些硬件架构提供的内存保护粒度有限,因此情况会变得复杂起来(参见 49.2节)。对于这种架构,下列结论是适用的。
1.所有内存保护组合与使用 O_RDWR 标记打开文件是兼容的。
2.没有内存保护组合——哪怕仅仅是 PROT_WRITE——与使用 O_WRONLY 标记打开的文件是兼容的(导致 EACCES 错误的发生)。这与一些硬件架构不允许对一个分页的只写访问这样一个事实是一致的。在 49.2 节中指出过在那些架构上 PROT_WRITE隐含了 PROT_READ,这意味着如果分页可写入,那么它也能被读取。而读取操作与O_WRONLY 是不兼容的,该操作是不能暴露文件的初始内容的。
3.使用 O_RDONLY 标记打开一个文件的结果依赖于在调用 mmap()时是否指定了MAP_PRIVATE 或MAP_SHARED。对于一个 MAP_PRIVATE 映射来讲,在 mmap()中可以指定任意的内存保护组合——因为对 MAP_PRIVATE 分页内容做出的变更不会被写入到文件中,因此无法写入文件不会成为问题。对于一个MAP_SHARED 映射来讲 , 唯 一 与 O_RDONLY 兼 容 的 内 存 保 护 是 PROT_REA 和 (PROT_READ |PROT_EXEC)。这是符合逻辑的,因为一个 PROT_WRITE, MAP_SHARED 映射允许更新被映射的文件。
49.5 同步映射区域: msync()
内核会自动将发生在 MAP_SHARED 映射内容上的变更写入到底层文件中,但在默认情况下,内核不保证这种同步操作会在何时发生。 ( SUSv3 要求一个实现提供这种保证。 )
msync()系统调用让应用程序能够显式地控制何时完成共享映射与映射文件之间的同步。
#include<sys/mman.h>
int msync(void *addr,size_t length,int flags);
同步一个映射与底层文件在多种情况下都是非常有用的。如,为确保数据完整性,一个数据库应用程序可能会调用 msync()强制将数据写入到磁盘上。调用 msync()还允许一个应用程序确保在可写入映射上发生的更新会对在该文件上执行 read()的其他进程可见。
传给 msync()的 addr 和 length 参数指定了需同步的内存区域的起始地址和大小。在 addr中指定的地址必须是分页对齐的, length 会被向上舍入到系统分页大小的下一个整数倍。 ( SUSv3 规定 addr 必须要分页对齐。 SUSv4 表示一个实现可以要求这个参数是分页对齐的。 ) flags 参数的可取值为下列值中的一个。 MS_SYNC 执行一个同步的文件写入。这个调用会阻塞直到内存区域中所有被修改过的分页被写入到底盘为止。 MS_ASYNC 执行一个异步的文件写入。内存区域中被修改过的分页会在后面某个时刻被写入磁盘并立即对在相应文件区域中执行 read()的其他进程可见。 另一种区分这两个值的方式可以表述为在 MS_SYNC 操作之后,内存区域会与磁盘同步,而在 MS_ASYNC 操作之后,内存区域仅仅是与内核高速缓冲区同步
在 flags 参数中还可以加上下面这个值。 MS_INVALIDATE 使映射数据的缓存副本失效。当内存区域中所有被修改过的分页被同步到文件中之后,内存区域中所有与底层文件不一致的分页会被标记为无效。当下次引用这些分页时会从文件的相应位置处复制相应的分页内容,其结果是其他进程对文件做出的所有更新将会在内存区域中可见。
与很多其他现代 UNIX 实现一样, Linux 提供了一个所谓的同一虚拟内存系统。这表示内存映射和高速缓冲区块会尽可能地共享同样的物理内存分页。因此通过映射获取的文件视图与通过 I/O 系统调用( read()、 write()等)获得的文件视图总是一致的,而 msync()的唯一用途就是强制将一个映射区域中的内容写入到磁盘。
不管怎样, SUSv3 并没有要求实现统一虚拟内存系统,并且并不是所有的 UNIX 实现都提供了同一虚拟内存系统。在这类系统上需要调用 msync()来使得一个映射上发生的变更对其他 read()该文件的进程可见,并且在执行逆操作时需要使用 MS_INVALIDATE 标记来使得其他进程对文件所做出的写入对映射区域可见。使用 mmap()和 I/O 系统调用操作同一个文件的多进程应用程序如果希望可被移植到不具备统一虚拟内存系统的系统之上的话就需要恰当使用 msync()。