Unix/Linux编程:内存映射------mmap()系列函数

概述

mmap()系统调用在调用进程的虚拟地址空间中创建一个新内存映射。映射分为两种:

  • 文件映射:
    • 文件映射将一个文件的一部分直接映射到调用进程的虚拟内存中。
    • 一旦一个文件被映射之后就可以通过在相应的内存区域中操作字节来访问文件内容了(即对文件映射内容的访问则转换为对相应内存区域的字节操作)。映射的分页会在需要的时候自动从文件中加载。这种映射也被称为基于文件的映射或者内存映射文件。
  • 匿名映射(与文件无关):
    • 这种映射没有对应的文件,其映射页面的内容会被初始化为0
    • 可以把匿名映射看成是一个内容总是被初始化为 0 的虚拟文件的映射

一个进程所映射的内存可以与其他进程中的映射共享(即各个进程的页表指向RAM中相同分页)。这种行为会在两种情况下发送:

  • 两个进程都针对某一文件的相同部分加以映射
  • 通过fork()创建的子进程会继承其父进程的映射的副本,并且这些映射所引用的物理内存分页中与父进程中相应的映射所引用的分页相同。

当两个或更多个进程共享相同分页时,每个进程都有可能会看到其他进程对分页内容做出的变更,当然这要取决于映射是私有的还是共享的。

  • 私有映射(MAP_PRIVATE):
    • 在映射内容上发生的变更对其他进程不可见,对于文件映射来讲,变更将不会在底层文件上进行。
    • 尽管一个私有映射的分页在上面介绍的情况中初始时是共享的,但对映射内容所做出的变更对各个进程来讲则是私有的。
    • 内核使用了写时复制(copy-on-write)技术完成了这个任务,这意味着每一个进程试图修改一个分页的内容时,内核首先会为该进程创建一个新分页并将需修改的分页中内容复制到新分页中(以及调整进程的页表)。
    • MAP_PRIVATE 映射有时候会被称为私有、写时复制映射
  • 共享映射(MAP_SHARED):在映射内容上发生的变更对所有共享同一个映射的其他进程都可见,对于文件映射来讲,变更将会发生在底层的文件上。

总结:

变更的可见性文件映射匿名映射
私有根据文件内容初始化内存内存分配
共享内存映射 I/O;进程间共享内存(IPC)进程间共享内存(IPC)
  • 私有文件映射:
    • 映射的内容被初始化为一个文件区域中的内容。
    • 多个映射同一文件的进程初始化是会共享同样的内存物理分页,但系统使用写时复制技术使得一个进程对映射所做出的变更对其他进程不可见
    • 这种映射的主要用途是使用一个文件的内容来初始化一个内存区域。一些常见的例子包括根据二进制可执行文件或共享库文件的相应部分来初始化一个进程的文本和数据段
  • 私有匿名映射:
    • 每次调用mmap()创建一个私有匿名映射时都会产生一个新映射,该映射与同一(或不同)进程创建的其他匿名映射是不同的(即不会共享物理分页)。
    • 尽管子进程会继承其父进程的映射,但写时复制语义确保在fork()之后父进程和子进程不会看到其他进程对映射做出的变更
    • 私有匿名映射的主要用途是为一个进程分配新(用零填充)内存(如在分配大块内存时 malloc()会为此而使用 mmap())。
  • 共享文件映射:
    • 所有映射一个文件的同一区域的进程会共享同一的内存物理分页,这些分页的内容将被初始化为该文件区域。 对映射内容的修改将直接在文件中进行
    • 这种映射主要用于两个用途。第一,它允许内存映射IO,这表示一个文件会被加载到进程的虚拟内存中的一个区域中并且对该块内容的变更会自动被写入到这个文件中。因此,内存映射 I/O 为使用 read()和 write()来执行文件 I/O 这种做法提供了一种替代方案。这种映射的第二种用途是允许无关进程共享一块内容以便以一种类似于 System V共享内存段的方式来执行IPC。
  • 共享匿名映射:
    • 每次mmap()创建一个共享匿名映射时都会产生一个新的、与任何其他映射不共享分页时截然不同的映射。这里的差别在于映射的分页不会被写时复制。这意味着当一个子进程在 fork()之后继承映射时,父进程和子进程共享同样的 RAM 分页,并且一个进程对映射内容所做出的变更会对其他进程可见。
    • 共享匿名映射允许以一种类似于 System V 共享内存段的方式来进行 IPC,但只有相关进程之间才能这么做。

一个进程在执行 exec()时映射会丢失,但通过 fork()创建的子进程会继承映射,映射类型(MAP_PRIVATE 或 MAP_SHARED)也会被继承

通过 Linux 特有的/proc/PID/maps 文件能够查看与一个进程的映射有关的所有信息

mmap()的另一个用途是与 POSIX 共享内存对象一起使用,它允许无关进程在不创建关联磁盘文件(共享文件映射需要这样的文件)的情况下共享一块内存区域。

创建一个映射:mmap()

mmap()系统调用在调用进程的虚拟地址空间中创建一个新映射

NAME
       mmap, munmap - map files or devices into memory

SYNOPSIS
       #include <sys/mman.h>

       void *mmap(void *addr, size_t length, int prot, int flags,  int fd, off_t offset);

addr:指定映射被放置的虚拟地址。

  • 将 addr 指定为 NULL,那么内核会为映射选择一个合适的地址。这是创建映射的首选做法。
  • 在 addr 中指定一个非 NULL 值时,内核会在选择将映射放置在何处时将这个参数值作为一个提示信息来处理。在实践中,内核至少会将指定的地址舍入到最近的一个分页边界处
  • 不管采用何种方式,内核会选择一个不与任何既有映射冲突的地址。(如果在 flags 包含了 MAP_FIXED,那么 addr 必须是分页对齐的)。

成功时 mmap()会返回新映射的起始地址。发生错误时 mmap()会返回 MAP_FAILED。

length:指定映射的字节数

  • 虽然length无需是一个系统分页大小(sysconf(SC PAGESIZE)返回值)的倍数,但内核会以分页大小为单位来创建映射,因此实际上 length 会被向上提升为分页大小的下一个倍数。

prot 参数是一个位掩码,它指定了施加于映射之上的保护信息,其取值要么是PROT_NONE,要么是下表 列出的其他三个标记的组合(取 OR)。

描述
PROT_NONE区域无法访问
PROT_READ区域内容可读取
PROT_WRITE区域内容可修改
PROT_EXEC区域内容可执行
  • 如果一个进程在访问一个内存区域时违反了该区域上的保护位,那么内核会向该进程发送一个 SIGSEGV 信号(有一些实现使用的是SIGBUS)
  • 标记为PROT_NONE的分页内存的一个用途是作为一个进程分配的内存区域的起始位置或者结束位置的守护分页。如果进程意外的访问了其中一个被标记为PROT_NONE的分页,那么内核会通过生成一个SIGSEGV信号来通知该进程这样一个事实。
  • 内存保护信息驻留在进程私有的虚拟内存表中。因此,不同的进程可能会使用不同的保护位来映射同一内存区域。
  • 使用 mprotect()系统调用能够修改内存保护位
  • 在一些 UNIX 实现上,实际施加于一个映射分页上的保护位于在 prot 中指定的信息可能不完全一致。但应用程序不应该依赖于这种行为;prot 指定的信息应该总是与所需的内存保护信息一致。

flags 参数是一个控制映射操作各个方面的选项的位掩码。这个掩码必须只包含下列值中一个。

  • MAP_PRIVATE :创建一个私有映射。区域中内容上所发生的变更对使用同一映射的其他进程是不可见的,对于文件映射来讲,所发生的变更将不会反应在底层文件上。
  • MAP_SHARED :创建一个共享映射。区域中内容上所发生的变更对使用 MAP_SHARED 特性映射同一区域的进程是可见的,对于文件映射来讲,所发生的变更将直接反应在底层文件上。对文件的更新将无法确保立即生效,除非msync()

除了 MAP_PRIVATE 和 MAP_SHARED 之外,在 flags 中还可以有选择地对其他标记取OR

  • MAP_ANONYMOUS :创建一个匿名映射,即没有底层文件对应的映射
  • MAP_FIXED:
    • 原样解释 addr 参数
  • MAP_HUGETLB(自 Linux 2.6.32 起):
    • 创建一个使用巨页的映射
    • 这个标记在 mmap()所起的作用与 SHM_HUGETLB 标记在 System V 共享内存段中所起的作用一样
  • MAP_LOCKED(自 Linux 2.6 起):按照 mlock()的方式预加载映射分页并将映射分页锁进内存
  • MAP_NORESERVE :
    • 用来控制是否提前为映射的交换空间执行预留操作
  • MAP_POPULATE(自 Linux 2.6 起)
    • 填充一个映射的分页。
    • 对于文件映射来讲,这将会在文件上执行一个超前读取。这意味着后续对映射内容的访问不会因分页故障而发生阻塞(假设此时不会因内存压力而导致分页被交换出去)
  • MAP_UNINITIALIZED(自 Linux 2.6.33 起)
    • 会防止一个匿名映射被清零。
    • 它能够带来性能上的提升,但同时也带来了安全风险,因为已分配的分页中可能会包含上一个进程留下来的敏感信息。因此这个标记一般只供嵌入式系统使用,因为在这种系统中性能是一个至关重要的因素,并且整个系统都处于嵌入式应用程序的控制之下。
    • 这个标记只有在使用 CONFIG_MMAP_ALLOW_ UNINITIALIZED 选项配置内核时才会生效

剩余的参数 fd 和 offset 是用于文件映射的(匿名映射将忽略它们)。

  • fd 参数是一个标识被映射的文件的文件描述符。
  • offset 参数指定了映射在文件中的起点,它必须是系统分页大小的倍数。要映射整个文件就需要将 offset 指定为 0 并且将 length 指定为文件大小

MAP_NORESERVE 和过度利用交换空间

一些应用程序会创建大(通常是私有匿名)映射,但只会使用映射区域中的一小部分(比如稀疏数组)。

如果内核总是为此类映射分配(或者预留)足够的交换空间,那么很多交换空间可能会被浪费。因此,内核使用了懒交换预留技术,只在需要用到映射分页的时候(即当应用程序访问分页时)为它们预留交换空间。

优点:

  • 应用程序总是使用的虚拟内存量能够超过RAM和加上交换空间的总量。
  • 允许交换空间被过度利用。
    • 这种方式能够很好地工作,只要所有进程都不试图访问整个映射。
    • 但如果所有应用程序都试图访问整个映射,那么 RAM 和交换空间就被耗尽。这时内核会通过杀死系统中的一个或者多个进程来降低内存压力。
    • 理想情况下,内核会尝试选择引起内存问题的进程,但这是无法保证的。
    • 因此,有时候可能会选择访问懒交换预留,而是强制系统在映射被创建时分配所有所需的交换空间

内核如何处理交换空间的预留是由调用mmap()时是否使用了MAP_NORESERVE 标记以及影响系统层面的交换空间过度利用操作的/proc接口来控制的。下表对这些因素进行了总结:
在这里插入图片描述
Linux 特有的/proc/sys/vm/overcommit_memory 文件包含了一个整数值,它控制着内核对交换空间过度利用的处理。在 2.6 之前的 Linux 上这个文件中的整数只能取两个值:

  • 0 表示拒绝明显的过度利用(遵从 MAP_NORESERVE 标记的使用)
  • 大于 0 表示在所有情况下都允许过度利用。

MAP_FIXED 标记

在 mmap() flags 参数中指定 MAP_FIXED 标记会强制内核原样地解释 addr 中的地址,而不是只将其作为一种提示信息。如果指定了MAP_FIXED,那么 addr 就必须是分页对齐的。

一般来讲,一个可移植的应用程序不应该使用 MAP_FIXED,并且需要将 addr 指定为NULL,这样就允许系统选择将映射放置在哪个地址处了

然而,还是存在一种可移植应用程序需要使用 MAP_FIXED 的情况。如果在调用 mmap()时指定了 MAP_FIXED,并且内存区域的起始位置为 addr,覆盖的 length 字节与之前的映射的分页重叠了,那么重叠的分页会被新映射替代。使用这个特性可以可移植地将一个文件(或多个文件)的多个部分映射进一块连续的内存区域:

  • 使用 mmap()创建一个匿名映射。在 mmap()调用中将 addr 指定为 NULL 并且不指定 MAP_FIXED 标记。这样就允许内核为映射选择一个地址了。
  • 使用一系列指定了 MAP_FIXED 标记的 mmap()调用来将文件区域映射(即重叠)进在上一步中创建的映射的不同部分中。

从 Linux 2.6 开始,使用 remap_file_pages()系统调用也能够取得同样的效果,但使用 MAP_FIXED 的可移植性更强,因为 remap_file_pages()是 Linux 特有的。

示例

下面演示了使用mmap()创建一个私有文件映射。这个程序是一个简单版本的 cat(1),它将映射通过命令行参数指定的(整个)文件,然后将映射中的内容写入到标准输出中

#define _GNU_SOURCE
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <zconf.h>
#include <errno.h>
#include <sys/sem.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(int argc, char *argv[])
{
    char *addr;
    int fd;
    struct stat sb;

    if (argc != 2 || strcmp(argv[1], "--help") == 0){
        printf("%s file\n", argv[0]);
        exit(EXIT_FAILURE);
    }


    fd = open(argv[1], O_RDONLY);
    if (fd == -1){
        printf("open");
        exit(EXIT_FAILURE);
    }


    /* Obtain the size of the file and use it to specify the size of
       the mapping and the size of the buffer to be written */

    if (fstat(fd, &sb) == -1){
        printf("fstat");
        exit(EXIT_FAILURE);
    }


    /* Handle zero-length file specially, since specifying a size of
       zero to mmap() will fail with the error EINVAL */

    if (sb.st_size == 0)
        exit(EXIT_SUCCESS);

    addr = (char *)mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (addr == MAP_FAILED){
        printf("mmap");
        exit(EXIT_FAILURE);
    }


    if (write(STDOUT_FILENO, addr, sb.st_size) != sb.st_size){
        printf("partial/failed write");
        exit(EXIT_FAILURE);
    }

    exit(EXIT_SUCCESS);
}

解除映射区域:munmap()

munmap()系统调用执行与 mmap()相反的操作,即从调用进程的虚拟地址空间中删除一个映射

NAME
       munmap - unmap files or devices into memory

SYNOPSIS
       #include <sys/mman.h>

       int munmap(void *addr, size_t length);

addr 参数是待解除映射的地址范围的起始地址

langth 参数是一个非负整数,它指定了待解除映射区域的大小(字节数)。范围为系统分页大小的下一个倍数的地址空间将会被解除映射。

一般来讲会解除这个映射。因此可以将addr指定为为上一个 mmap()调用返回的地址,并且 length 的值与 mmap()调用中使用的 length 的值一样。

addr = (char *)mmap(NULL, length, PROT_READ, MAP_PRIVATE, fd, 0);

// ... code ...

if(munmap(addr, length) == -1){
	exit(1);
}

也可以接触一个映射中的部分映射,这样原来的映射要么会收缩,要么会被分成两个,这取决于在何处开始解除映射。还可以指定一个跨越多个映射的地址范围,这样的话所有在范围内的映射都会被解除。

如果在由 addr 和 length 指定的地址范围中不存在映射,那么 munmap()将不起任何作用并返回 0(表示成功)。

在解除映射期间,内核会删除进程持有的在指定地址范围内的所有内存锁。(内存锁是通过 mlock()或 mlockall()来建立的)

当一个进程终止或执行了一个 exec()之后进程中所有的映射会自动被解除。

为确保一个共享文件映射的内容会被写入到底层文件中,在使用 munmap()解除一个映射之前需要调用 msync()

同步映射区域:msync()

内核会自动将发生在MAP_SHARED映射内容上的变更写入到底层文件中,但在默认情况下,内核不保证这种同步操作会在何时发生。

msync()系统调用让应用程序能够显式地控制何时完成共享映射与映射文件之间的同步

NAME
       msync - synchronize a file with a memory map

SYNOPSIS
       #include <sys/mman.h>

       int msync(void *addr, size_t length, int flags);

传给 msync()的 addr 和 length 参数指定了需同步的内存区域的起始地址和大小。

flags 参数的可取值为下列值中的一个。

  • MS_SYNC:
    • 执行一个同步的文件写入。
    • 这个调用会阻塞直到内存区域中所有被修改过的分页被写入到底层文件为止
  • MS_ASYNC:
    • 执行一个异步的文件写入
    • 内存区域中被修改过的分页会在后面的某个时刻被写入磁盘并立即对在相应文件区域中执行read()的其他进程可见
    • 在MS_SYNC之后,内存区域会与磁盘同步;在MS_ASYNC之后,内存仅仅是于内核高速缓存区同步
  • 如果在MS_ASYNC操作之后不采取进一步的动作,那么内存区域中被修改过的分页最终会作为pdflush内核线程程(在 Linux 2.4 以及之前的版本上是 kupdated)执行的自动缓冲区刷新的一部分被写入到磁盘。
  • 在Linux上存在两种更快的发动输出的(非标准)方法。在 msync()调用之后可以在映射对应的文件描述符上调用一个 fsync()(或 fdatasync())。这个调用会阻塞直到快速缓冲区与磁盘同步为止。或者可以使用 posix_fadvise() POSIX_FADV_DONTNEED 操作启动一个异步的分页写入
  • MS_INVALIDATE
    • 使映射数据的缓存副本失效。
    • 当内存区域中所有被修改过的分页被同步到文件中之后, 内存区域中所有与底层文件不一致的分页会被标记为无效。
    • 当下次引用这些分页时会从文件的相应位置处复制相应的分页内容,其结果是其他进程对文件做出的所有更新会将内存区可见。

重新映射一个映射区域:mremap()

在大多数 UNIX 实现上一旦映射被创建,其位置和大小就无法改变了。但 Linux 提供了(不可移植的)mremap()系统调用来执行此类变更。

NAME
       mremap - remap a virtual memory address

SYNOPSIS
       #define _GNU_SOURCE         /* See feature_test_macros(7) */
       #include <sys/mman.h>

       void *mremap(void *old_address, size_t old_size,
                    size_t new_size, int flags, ... /* void *new_address */);

old_address 和 old_size 参数指定了需扩展或收缩的既有映射的位置和大小。在 old_address中指定的地址必须是分页对齐的,并且通常是一个由之前的 mmap()调用返回的值。映射预期的新大小会通过 new_size 参数指定。在 old_size 和 new_size 中指定的值都会被向上舍入到系统分页大小的下一个整数倍

在执行重映射的过程中内核可能会为映射在进程的虚拟地址空间中重新指定一个位置,而是否允许这种行为则是由 flags 参数来控制的。它是一个位掩码,其值要么是 0,要么包含下列几个值

  • MREMAP_MAYMOVE
    • 如果指定了这个标记,那么根据空间要求的指令,内核可能会为映射在进程的虚拟地址空间中重新指定一个位置。
    • 如果没有指定这个标记,并且在当前位置处没有足够的空间来扩展这个映射,那么就返回 ENOMEM 错误。
  • MREMAP_FIXED(自 Linux 2.4 起)
    • 这个标记只能与 MREMAP_MAYMOVE 一起使用。它在 mremap()中所起的作用与MAP_FIXED 在 mmap()中所起的作用类似
    • 如果指定了这个标记,那么 mremap()
      会接收一个额外的参数 void *new_address,该参数指定了一个分页对齐的地址,并且映射将会被迁移至该地址处。所有之前在由 new_address 和 new_size 确定的地址范围之内的映射将会被解除映射

mremap()在成功时会返回映射的起始地址。由于(如果指定了MREMAP_MAYMOVE 标记)这个地址可能与之前的起始地址不同,从而导致指向这个区域中的指针可能会变得无效,因此使用 mremap()的应用程序在引用映射区域中的地址时应该只使用偏移量(不是绝对指针)

在Linux上,realloc()函数使用mremap()来高效的为malloc()之前使用mmap()MAP_ANONYMOUS 分配的大内存块重新指定位置。使用 mremap()来完成这种任务使得在重新分配空间的过程中避免复制字节成为可能

非线性映射:remap_file_pages()

使用mmap()创建的文件映射是连续的:映射文件的分页与内存区域的分页存在一个顺序的,一对一的关系。对于大多数应用程序来讲,线性映射已经够用了。然而一些应用程序需要创建大量的非线性映射------文件分页的顺序与它们在连续内存中出现的顺序不同的映射,如下图:
在这里插入图片描述
可以使用多个带 MAP_FIXED 标记的 mmap()调用创建非线性映射。然而这种方法的伸缩性不够好,其问题在于齐总每个mmap()调用都会创建一个独立的内核虚拟内存区域(VMA)数据结构。每个VMA的配置需要花费时间并且消耗一些不可交换的内核内存。此外,大量的VMA会降低虚拟内存管理器的性能。

从内核 2.6 开始,Linux 提供了 remap_file_pages()系统调用来在无需创建多个 VMA 的情况下创建非线性映射,具体如下。

  • 使用mmap()创建一个映射
  • 使用一个或者多个rempa_file_pages()调用来调整内存分页和文件分页之间的对应关系(remap_file_pages()所做的工作是操作进程的页表。)
NAME
       remap_file_pages - create a nonlinear file mapping

SYNOPSIS
       #define _GNU_SOURCE         /* See feature_test_macros(7) */
       #include <sys/mman.h>

       int remap_file_pages(void *addr, size_t size, int prot,
                            ssize_t pgoff, int flags);


flags 参数当前未被使用。

prot 参数会被忽略,其值必须是 0。在将来可能能够使用这个参数来修改受
remap_file_pages()影响的内存区域的保护信息。在当前实现中,保护信息保持与整个VMA 上的保护信息一致。

pgoff 和 size 参数标识了一个在内存中的位置待改变的文件区域:

  • pgoff 参数指定了文件区域的起始位置,其单位是系统分页代销(sysconf(_SC_PAGESIZE)的返回值)。
  • size 参数指定了文件区域的长度,其单位为字节。

addr 参数起两个作用。

  • 它标识了分页需调整的既有映射。换句话说,addr 必须是一个位于之前通过 mmap()映射的区域中的地址。
  • 它指定了通过 pgoff 和 size 标识出的文件分页所处的内存地址。

addr 和 size 都应该是系统分页大小的整数倍。如果不是,那么它们会被向下舍入到最近的分页大小的整数倍。

假设使用了下面的mmap()调用来映射通过fd引用的打开的文件的三个分页,并且将调用返回地址址 0x4001a000 赋给了 addr

ps = sysconf(_SC_PAGESIZE);  // obtain system page size
addr = mmap(0, 3* ps, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

下面的调用会创建一个非线性映射,如上图所示:

remap_file_pages(addr, ps, 0, 2, 0); //maps page 0 of file into page 2 of region
remap_file_pages(addr + 2 * ps, ps, 0, 0, 0); //maps page 2 of file into page 0 of region

在当前的实现上,remap_file_pages()仅适用于共享(MAP_SHARED)映射。

文件映射

要创建一个文件映射需要执行下面的步骤

  • 获取文件的一个描述符,通常通过调用 open()来完成。
  • 将文件描述符作为 fd 参数传入 mmap()调用

执行上述步骤之后 mmap()会将打开的文件的内容映射到调用进程的地址空间中。一旦mmap()被调用之后就能够关闭文件描述符了,而不会对映射产生任何影响。但在一些情况下,将这个文件描述符保持在打开状态可能是有用的

除了普通的磁盘文件,使用 mmap()还能够映射各种真实和虚拟设备的内容,如硬盘、光盘以及/dev/mem。

在打开描述符 fd 引用的文件时必须要具备与 prot 和 flags 参数值匹配的权限。特别地,文件必须总是被打开以允许读取,并且如果在 flags 中指定了PROT_WRITE 和 MAP_SHARED,那么文件必须总是被打开以允许读取和写入。

offset 参数指定了从文件区域中的哪个字节开始映射,它必须是系统分页大小的倍数。将offset 指定为 0 会导致从文件的起始位置开始映射。length 参数指定了映射的字节数。offset和 length 参数一起确定了文件的哪个区域会被映射进内存
在这里插入图片描述

私有文件映射

私有文件映射最常见的两个用途如下所述。

  • 允许多个执行同一个程序或使用同一个共享库的进程共享同样的(只读的)文本段,它是从底层可执行文件或库文件的相应部分映射而来的。

尽管可执行文件的文本段通常是被保护成只允许读取和执行访问(PROT_READ | PROT_EXEC),但在被映射时仍然使用了 MAP_PRIVATE 而不是 MAP_SHARED,这是因为调试器或自修改的程序能够修改程序文本(在修改了内存上的保护信息之后),而这样的变更是不应该发生在底层文件上或影响到其他进程的

  • 映射一个可执行文件或者共享库的初始化数据段。这种映射会被处理成私有使得对映射数据段内容的变更不会发生在底层文件上。

mmap()的这两种用法通常对程序是不可见的,因为这些映射是由程序加载器和动态链接器创建的。

私有文件映射的另一个不太常见的用途是简化程序的文件输入逻辑。这与使用共享文件映射来完成内存映射 I/O类似,但它只允许文件输入。

共享文件映射

当多个进程创建了同一个文件区域的共享映射时,它们会共享同样的内存物理分页。此外,对映射内容的变更将会反应到文件上。实际上,这个文件被当成了该块内存区域的分页存储。
在这里插入图片描述
共享文件映射存在两个用途:内存映射 I/O 和 IPC

内存映射IO

由于共享文件映射中的内容是从文件初始化而来的,并且对映射内容所做出的变更都会自动反映到文件上,因此可以简单的通过访问内存中的字节来执行文件IO,而依靠内核来确保对内存的变更会被传递到映射文件中。(一般来讲,一个程序会定义一个结构化数据类型来与磁盘文件中的内容对应起来,然后使用该数据类型来转换映射的内容。)这项技术被称为内存映射 I/O,它是使用 read()和 write()来访问文件内容这种方法的替代方案。

内存映射IO的优势:

  • 使用内存访问来取代 read()和 write()系统调用能够简化一些应用程序的逻辑。
  • 它能够比使用传统的 I/O 系统调用执行文件 I/O 这种做法提供更好的性能

内存映射IO之所以能够带来性能优势的原因如下:

  • 正常的read()或者write()需要两次传输:一次是在文件和内核高速缓冲区之间,另一次是在高速缓冲区和用户空间缓冲区之间。使用mmap()就无需第二次传输了。对于输入来讲,一旦内核将相应的文件块映射进内存之后用户进程就能够使用这些数据了。对于输出来讲,用户进程仅仅需要修改内存中的内容,然后就可以依靠内核内存管理器来自动更新底层的软件
  • 除了节省了内核空间和用户空间之间的一次传输之外,mmap()还能够通过减少所需使用的内存来提升性能。当使用read()或write()时,数据将被保存在两个缓冲区中:一个位于用户空间,另一个位于内核空间。当使用mmap()时,内核空间和用户空间会共享同一个缓冲区。此外,如果多个进程正在在同一个文件上执行 I/O,那么它们通过使用 mmap()就能够共享同一个内核缓冲区,从而又能够节省内存的消耗。

内存映射 I/O 也有一些缺点。对于小数据量 I/O 来讲,内存映射 I/O 的开销(即映射、分页故障、解除映射以及更新硬件内存管理单元的超前转换缓冲器)实际上要比简单的read()或 write()大。此外,有些时候内核难以高效地处理可写入映射的回写(在这种情况下,使用 msync()或 sync_file_range()有助于提高效率)

IPC

由于所有使用同样文件区域的共享映射的进程共享同样的内存物理分页,因此共享文件映射的第二个用途是作为一种(快速的)IPC 方法。这种共享内存区域与 System V 共享内存对象之间的区别在于内容上的变更会反应到底层的映射文件上。这种特性对那些需要共享内存在应用程序会在系统重启时能够持久化的应用程序来讲是很有用的。

下面程序来演示如何使用 mmap()创建一个共享文件映射。这个程序首先映射一个名称通过第一个命令行参数指定的文件,然后打印出映射区域起始位置的字符串值。最后,如果提供了第二个命令行参数,那么该字符串会被复制进共享内存区域中。

#define _GNU_SOURCE
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <zconf.h>
#include <errno.h>
#include <sys/sem.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>

#define MEM_SIZE 10
int main(int argc, char *argv[])
{
    char *addr;
    int fd;

    if (argc < 2 || strcmp(argv[1], "--help") == 0){
        printf("%s file [new-value]\n", argv[0]);
        exit(EXIT_FAILURE);
    }
        

    fd = open(argv[1], O_RDWR);
    if (fd == -1){
        printf("open");
        exit(EXIT_FAILURE);
    }
        

    addr = (char *)mmap(NULL, MEM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED){
        printf("mmap");
        exit(EXIT_FAILURE);
    }
        

    if (close(fd) == -1)                /* No longer need 'fd' */
    {
        printf("close");
        exit(EXIT_FAILURE);
    }

    printf("Current string=%.*s\n", MEM_SIZE, addr);
    /* Secure practice: output at most MEM_SIZE bytes */

    if (argc > 2) {                     /* Update contents of region */
        if (strlen(argv[2]) >= MEM_SIZE){
            printf("'new-value' too large\n");
            exit(EXIT_FAILURE);
        }
            

        memset(addr, 0, MEM_SIZE);      /* Zero out region */
        strncpy(addr, argv[2], MEM_SIZE - 1);
        if (msync(addr, MEM_SIZE, MS_SYNC) == -1){
            printf("msync");
            exit(EXIT_FAILURE);
        }
            

        printf("Copied \"%s\" to shared memory\n", argv[2]);
    }

    exit(EXIT_SUCCESS);
}

边界情况

在很多情况下,一个映射的大小是系统分页大小的整数倍,并且映射会完全落入映射文件的范围之内。但这种要求不是必需的,下面来看一下当这些条件不满足时会发生什么事情。

下图描绘了映射完全落入映射文件的范围之内但区域的大小并不是系统分页大小的一个整数倍的情况(在这个讨论中假设分页大小为 4096 字节)。

在这里插入图片描述
由于映射的大小不是系统分页大小的整数倍,因此它会被向上入到系统分页大小的下一个整数倍。由于文件的大小要大于这个被向上舍入的大小,因此文件中对应的字节会像上图一样被映射。

试图访问映射结尾之外的字节将导致SIGSEGV信号的产生(假设在该位置处不存在其他映射)。这个信号的默认动作是终止进程并打印出一个 core dump。

当映射扩充过了底层文件的结尾处时情况就变得更加复杂了。如下:
在这里插入图片描述

与之前一样,由于映射的大小不是系统分页大小的整数倍,因此它会被向上舍入。但在这种情况下,虽然在向上舍入区域(即图中 2200 字节和 4095 字节)中的字节是可访问的,但它们不会被映射到底层文件上(由于在文件中不存在对应的字节),并且它们会被初始化为 0(SUSv3 对此进行了规定)。当然,这些字节也不会与映射同一个文件的其他进程共享,即使它们指定了足够大的 length 参数。对这些字节做出的变更不会被写入到文件中。

如果映射中包含了超出向上舍入区域中(即上图 4096 以及之后的字节)的分页,那么试图访问这些分页中的地址将会导致 SIGBUS 信号量的产生,即警告进程文件中没有区域与这些地址对应。与之前一样,试图访问超过映射结尾处的地址将会导致 SIGSEGV 信号的产生。

从上面可以看出,创建一个大小超过底层文件大小的映射可能是无意义的。但通过扩展文件的大小(如使用 ftruncate()或 write())可以使得这种映射中之前不可访问的部分变得可用。

内存保护和文件访问模式交互

通过 mmap() prot 参数指定内存保护与映射文件被打开的模式之间的交互

  • 从一般原则来讲,PROT_READ 和 PROT_EXEC 保护要求被映射的文件使用 O_RDONLY 或 O_RDWR 打开
  • 而 PROT_WRITE 保护要求被映射的文件使用O_WRONLY 或 O_RDWR 打开

然而,由于一些硬件架构提供的内存保护粒度有限,因此情况会变得很复杂

匿名映射

匿名映射是没有对应文件的一种映射

MAP_ANONYMOUS 和/dev/zero

在 Linux 上,使用 mmap()创建匿名映射存在两种不同但等价的方法。

  • 在 flags 中指定 MAP_ANONYMOUS 并将 fd 指定为−1。(在 Linux 上,当指定了MAP_ANONYMOUS 之后会忽略 fd 的值。但一些 UNIX 实现要求在使用 MAP_ ANONYMOUS 时将 fd 指定为−1,因此可移植的应用程序应该确保它们这样做了。)
  • 打开/dev/zero 设备文件并将得到的文件描述符传递给 mmap()
  • 要从<sys/mman.h>中获得 MAP_ANONYMOUS 的定义必须要定义_BSD_SOURCE 特性测试宏或_SVID_SOURCE特性测试宏。Linux提供了常量MAP_ANON作为MAP_ANONYMOUS的一个同义词,其目的是为了与其他一些采用这种命名方式的 UNIX 实现保持兼容
  • /dev/zero是一个虚拟设备,当从中读取数据时它总是会返回0,而写入到这个设备中的数据总是会被丢弃。/dev/zero的一个常见用途是使用0来组装一个文件件(如使用 dd(1)命令)
  • MAP_ANONYMOUS 和/dev/zero 技术并没有在 SUSv3 进行规定,尽管大多数 UNIX 实现都支持其中一种或两种。之所以存在两种不同的技术实现同样的语义的原因是其中一种(MAP_ANONYMOUS)源自 BSD,而另一种(/dev/zero)则源自 System V。

不管是使用 MAP_ANONYMOUS 还是使用/dev/zero 技术,得到的映射中的字节会被初始化为 0。在两种技术中,offset 参数都会被忽略(因为没有底层文件,所以也无从指定偏移量)。

MAP_PRIVATE 匿名映射

MAP_PRIVATE 匿名映射用来分配进程私有的内存块并将其中的内容初始化为0。

下面的代码使用/dev/zero 技术创建了一个 MAP_PRIVATE 匿名映射

fd = open("/dev/zero", O_RDWR);
if(fd == -1){
	perror("open");
	exit(EXIT_FAILURE);
}

addr = mmap(NULL, length, PROT | PROT_WRITE, MAP_PRIVATE, fd, 0);
if(addr  == MAP_FAILED){
	perror("mmap");
	exit(EXIT_FAILURE);
}

glibc中的malloc()实现使用MAP_PRIVATE匿名映射来分配大小大于MMAP_ THRESHOLD 字节的内存块。这样在后面将这些内存块传递给 free()之后就能高效地释放这些块(通过 munmap())。(它还降低了重复分配和释放大内存块而导致内存分片的可能性。)MMAP_THRESHOLD 在默认情况下是 128 kB,但可以通过 mallopt()库函数来调整这个参数

MAP_SHARED 匿名映射

MAP_SHARED 匿名映射运行相关进程(比如子进程和父进程)共享同一块内存区域而无需一个对应的映射文件。

下面的代码使用 MAP_ANONYMOUS 技术创建了一个MAP_SHARED 匿名映射。

addr = mmap(NULL, length, PROT | PROT_WRITE, MAP_PRIVATE, -1, 0);
if(addr  == MAP_FAILED){
	perror("mmap");
	exit(EXIT_FAILURE);
}

如果在上面的代码之后加上一个对 fork()的调用,那么由于通过 fork()创建的子进程会继承映射,两个进程就会共享内存区域。

示例

下面演示了如何使用 MAP_ANONYMOUS 或/dev/zero 技术来在父进程和子进程之间共享一个映射区域。父进程在调用 fork()之前将共享区域中的一个整数初始化为 1。然后子进程递增这个共享整数并退出,而父进程则等待子进程退出,然后打印出该整数的值

#define _GNU_SOURCE
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <zconf.h>
#include <errno.h>
#include <sys/sem.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
    int *addr;                  /* Pointer to shared memory region */

    /* Parent creates mapped region prior to calling fork() */

#ifdef USE_MAP_ANON             /* Use MAP_ANONYMOUS */
    addr = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,
                MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (addr == MAP_FAILED){
        perror("mmap");
        exit(EXIT_FAILURE);
    }

#else                           /* Map /dev/zero */
    int fd;

    fd = open("/dev/zero", O_RDWR);
    if (fd == -1){
        perror("open");
        exit(EXIT_FAILURE);
    }


    addr = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED){
        perror("mmap");
        exit(EXIT_FAILURE);
    }

    if (close(fd) == -1) {
        perror("close");
        exit(EXIT_FAILURE);
    }
#endif

    *addr = 1;                  /* Initialize integer in mapped region */

    switch (fork()) {           /* Parent and child share mapping */
        case -1:
            perror("fork");
            exit(EXIT_FAILURE);

        case 0:                     /* Child: increment shared integer and exit */
            printf("Child started, value = %d\n", *addr);
            (*addr)++;
            if (munmap(addr, sizeof(int)) == -1){
                perror("munmap");
                exit(EXIT_FAILURE);
            }
            exit(EXIT_SUCCESS);

        default:                    /* Parent: wait for child to terminate */
            if (wait(NULL) == -1){
                perror("wait");
                exit(EXIT_FAILURE);
            }
            printf("In parent, value = %d\n", *addr);
            if (munmap(addr, sizeof(int)) == -1){
                perror("munmap");
                exit(EXIT_FAILURE);
            }
            exit(EXIT_SUCCESS);
    }
}

在这里插入图片描述

总结

mmap()系统调用在创建进程的虚拟地址空间中创建一个新内存映射。munmap()系统调用进行逆操作,即从进程的地址空间中删除一个映射。

映射分为两种:

  • 文件映射:将一个文件区域中的内容映射到进程的虚拟地址空间中
  • 匿名映射:(通过使用 MAP_ANONYMOUS 标记或映射/dev/zero来创建)并没有对应的文件区域,该映射中的字节会被初始化为 0。

映射既可以是私有的(MAP_PRIVATE),也可以是共享的(MAP_SHARED)。这种差别确定了在共享内存上发生的变更的可见性,对于文件映射来讲,这种差别还确定了内核是否会将映射内容上发生的变更传递到底层文件上。当一个进程使用 MAP_PRIVATE 映射了一个文件之后,在映射内容上发生的变更对其他进程是不可见的,并且也不会反应到映射文件上。
MAP_SHARED 文件映射的做法则相反——在映射上发生的变更对其他进程可见并且会反应到映射文件上

尽管内核会自动将发生在一个 MAP_SHARED 映射内容上的变更反应到底层文件上,但它不保证何时会完成这个操作。应用程序可以使用 msync()系统调用来显式地控制一个映射的内容何时与映射文件进行同步。

内存映射有很多用途,包括:

  • 分配进程私有的内存(私有匿名映射);
  • 对一个进程的文本段和初始化数据段中的内容进行初始化(私有文件映射);
  • 在通过 fork()关联起来的进程之间共享内存(共享匿名映射);
  • 执行内存映射 I/O,还可以将其与无关进程之间的内存共享结合起来(共享文件映射)。

在访问一个映射的内容时可能会遇到两个信号。如果在访问映射时违反了映射之上的保护规则(或访问一个当前未被映射的地址),那么就会产生一个 SIGSEGV 信号。对于基于文件的映射来讲,如果访问的映射部分在文件中没有相关区域与之对应(即映射大于底层文件),那么就会产生一个 SIGBUS 信号。

交换空间过度利用允许系统给进程分配比实际可用的 RAM 与交换空间之和更多的内存。过度利用之所以可能是因为所有进程都不会全部用完为其分配的内存。使用MAP_NORESERVE 标记可以控制每个 mmap()调用的过度利用情况,而使用/proc 文件则可以控制整个系统的过度利用情况。

mremap()系统调用允许调整一个既有映射的大小。remap_file_pages()系统调用允许创建非线性文件映射。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值