王道C++班级参考资料
——
Linux部分卷3文件系统编程
节4文件映射

最新版本V3.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

所谓文件映射(Memory Mapped Files, mmap)是一种内存映射文件的技术,允许将文件的内容在内存中进行映射,使得程序可以像访问普通内存一样来访问文件的内容。

一旦文件被映射到内存中,应用程序就可以通过指针操作映射区域,此时就不再需要传统的read、write等API来操作文件了:

  1. 对这段内存的读操作就是读文件内容

  2. 对这段内存的写操作就是写文件的内容,会自动同步修改到文件中。

  3. 同样,文件内容的更新也可以立即反映在内存映射中。

根据以上内容,我们大体上,可以认为文件映射是下面的模型:

文件映射-图

当然这个图是比较粗糙的,但对于使用文件映射而言,是足够的。

文件映射

Gn!

文件映射的实现,首先要依赖于mmap系统调用函数。该函数会返回一个指向用户态空间映射文件内容的指针,利用该指针操作映射的内存区域,就可以实现操作文件内容。

该函数的声明如下:

参数列表解释如下:

  1. void *addr:该参数表示告诉mmap函数从用户内存空间的哪里开始映射文件。建议这个参数设为 NULL,表示由操作系统决定映射的起始地址。

  2. size_t length: 想要映射的区域的大小,以字节为单位。这个参数要和最后一个参数一起使用,用于控制将文件的哪一部分映射到内存区域中。注意length必须是大于0的整数值,不能等于0,更不能是负数。

  3. off_t offset:被映射文件,映射位置的偏移量,表示从文件的哪个位置开始映射。如果写0,表示从文件开头进行映射。

  4. 结合上面两个参数,如果想要一次性映射整个文件,length可以直接写文件的大小,offset则是0。

  5. int prot: 映射的内存区域的访问权限标志。比较常用的是:

    1. PROT_READ:区域可被读取。

    2. PROT_WRITE:区域可被写入。

    3. 类似open函数的读写模式,该参数写PROT_READ|PROT_WRITE就表示映射区域可读可写。

  6. int flags: 决定内存映射区域的回写特性。常用的标志有:

    1. MAP_SHARED:对映射区域的修改会反映到文件上,并且对所有映射该文件的其他进程可见。

    2. MAP_PRIVATE:建立一个写时复制的私有映射,对该区域的修改不会影响原文件,也不会影响到其他进程。

    3. 建议目前这个阶段,该参数就写MAP_SHARED

  7. int fd: 要映射到进程空间的文件的文件描述符,由open函数获取。

该函数的形式参数列表,乍一看很丰富,内容很多,但实际上在一般情况下,大多数参数都是固定的:

  1. void *addr:NULL

  2. size_t length: 想要映射的文件内容的字节大小

  3. off_t offset:一般就从文件头开始映射,写0

  4. int prot: 写PROT_READ|PROT_WRITE

  5. int flags: 写MAP_SHARED

  6. int fd: 要映射到进程空间的文件的文件描述符,由open函数获取。

返回值:

  1. 成功时,mmap 返回用户空间文件映射内存区域的指针。

  2. 失败时,返回 MAP_FAILED(即 (void *)-1,指向-1这个内存区域的指针,不是空指针),并设置 errno 以指示错误。

munmap函数

Gn!

既然mmap会返回用户空间文件映射区域的指针,分配了内存空间,那么自然就需要在使用完毕后释放内存资源。

munmap函数用于解除一个映射区域,释放内存资源。munmap是词组"memory unmap map"的缩写,表示解除内存映射区域。

当你通过 mmap 创建了一个文件或内存的映射之后,完成操作并不再需要使用这个映射时,应该使用 munmap 来释放这部分资源。

此函数的声明如下:

参数列表:

  1. void *addr:映射区域的起始地址,即 mmap 函数的返回值。

  2. size_t length: 这是映射区域的长度,应与mmap创建映射时使用的长度相同。

返回值:

  1. 成功时,munmap 返回 0。

  2. 失败时,返回 -1 并设置 errno 以指示错误原因。

注意事项

Gn!

使用mmap映射文件到内存中,需要填入一个希望映射的字节大小。如果这个数值超过了文件的大小,映射操作就可能失败或者超出映射的部分就有可能访问出错。(未定义行为,有可能会产生一个bus error,总线错误)

为了避免这种情况的发生,在必要的时候,在mmap映射文件之前,可以先用 ftruncate 来调整文件的大小为希望映射区域的大小。

但这个操作不是必须的,尤其是希望映射区域小于文件大小时, ftruncate 会截断文件,这显然不合理。

总之,是否使用 ftruncate 取决于具体需求,不要盲目使用。否则容易导致文件截断,数据丢失。

除此之外:

open打开文件的模式应该和文件映射区域的权限标志保持一致,如果文件映射区域的权限标志是PROT_READ|PROT_WRITE,那么open打开文件的模式要写:O_RDWR,即可读可写模式。

总线错误(Bus Error)

总线错误通常发生在CPU访问一个物理内存地址时出现问题,而在 mmap 的情况下,如果访问超出了文件大小的内存区域,可能就会引发这样的错误。这是因为这部分内存没有有效的数据可以访问。

对于mmap来说,虽然映射区域大于文件大小是一个未定义行为,但更普遍的规律是:

  1. 当文件是一个空文件,但要映射非零个字节的内存空间时,此时有可能出现两种情况:

    1. 直接映射失败, mmap 调用失败并返回 MAP_FAILED,设置errno指示错误

    2. mmap函数没有报错,但是访问这块映射区域(读或写),导致总线错误。

  2. 当文件不是一个空文件,但要映射的字节数量大于文件大小时,对于超出文件实际大小的部分,访问这部分内存的行为是未定义的:

    1. 有可能访问到零值

    2. 有可能访问到随机值

    3. 还有可能出现总线错误

总之,在使用mmap映射时需要关注文件的大小和映射长度的关系,要小心谨慎一些。

补充:关于mmap函数使用时open函数的打开标记

调用mmap函数时,需要传入一个open函数打开文件返回的文件描述符,open函数需要传入一个flags参数用于确定打开文件的读写模式。

而mmap函数也需要传入一个用于确定映射区域读写特性的prot参数,同样都是代表读写模式的参数,它们之间有关联吗?

如果你仅仅想要创建一个只读的映射区域,从而实现读文件数据,那么你可以使用只读模式open文件,open函数的标志只需要加一个O_RDONLY

而如果你想要创建一个可写的映射区域,即希望能够修改文件数据,此时open函数可不可以用O_WRONLY标志打开呢?

当然是不行的。

mmap在创建映射区域时,总是需要将文件的初始内容加载到内存中,也就是说总需要能够读这个文件,若此时open的打开模式是只写,将会导致无法读文件,从而创建映射区域失败!

所以:

  1. 如果你想要创建一个只读的映射PROT_READ,你可以以只读方式O_RDONLYopen打开文件。

  2. 如果你想创建一个可写的映射PROT_READ|PROT_WRITE,你必须以读写方式O_RDWRopen打开文件。

简单的示例

Gn!

这个简单的示例,只用来演示映射5个字节的文件内容到用户空间中,并演示了一下随机访问以及随机修改。

文件映射会带来一个非常大的优势:

由于你可以像操作内存一样操作文件内容,而内存是支持随机访问的,于是你也可以对文件的内容进行随机访问。

相比较而言read和write是顺序读写的,它们做不到随机访问。

文件映射的原理

Gn!

我们先来对比一下 read/write读写文件,这两个系统调用函数会让数据在内核态的文件对象和用户态内存之间进行来回拷贝。

内核区域文件对象里的数据如何交互到磁盘呢?这个过程还需要借助内核区域的页缓存来实现。

页缓存(Page Cache),也称为文件系统缓存,是操作系统用来缓存磁盘中文件内容的一部分内存。你可以认为文件对象和页缓存之间具有关联的关系,而页缓存会直接和硬盘数据交互。

一般来说,操作系统会选择一个合适策略并使用专门的硬件(比如DMA设备)来同步磁盘和页缓存当中的内容,这样 read/write 操作最终就会影响到磁盘。

而 mmap 的处理就更加简单粗暴,它直接把页缓存中的文件数据的一部分映射到用户态内存,这样用户在用户态当中的操作就直接对应页缓存的操作,从而更直接的操作文件数据。

文件映射的原理-图

这样看上去的话, mmap 的效率总是会比 read/write 更加高,因为它避免了一次数据在用户态和内核态之间的拷贝

但是考虑到 read/write 的特殊性质——它们总是顺序地而不是随机地访问磁盘文件的内容,所以操作系统可以根据这个特点进行优化,比如文件内容的预读等等。

总之最终经过测试,我们的结论是:

  1. read/write 在顺序读写文件时的性能更好。

  2. mmap 在随机访问文件的时候性能更好。

当然,日常使用更多的还是read/write,因为顺序读写的场景会更多一些。

将文件内容中的小写字母转换成大写保存

Gn!

将文件内容中的小写字母转换成大写并保存,可以通过 read/writemmap 方法实现。我们首先来看一下使用文件映射来实现。

mmap实现

Gn!

mmap实现,只需要打开文件并建立映射,然后使用循环遍历,并判断文件内容是否是小写字母,然后将小写字母转换成大写。

参考代码如下:

注意:toupper函数用于将小写字符转换成大写,使用它要包含头文件<ctype.h>

可以看到这个代码是比较简洁的。

read/write实现

Gn!

read/write我们已经相对比较熟练了,整个过程我们的想法就是:

从头开始读文件,一边读到字符就将它转换成大写,然后直接写回去。

这里能够实现需求嘛?

显然是不可以的,为什么呢?

因为在read/write的过程中,始终存在一个内核态缓冲区指针来指示文件读写的位置,每read一下文件指针就要后移1位(移动读写元素大小个字节),每write一下指针同样后移1位。

如果原文件数据是:how are you,那么最终的结果是:

hHwWaAeEyYuU

那怎么办呢?

要想真正实现需求,显然在read之后,就需要将文件指针向文件的开头移动1位。

于是我们就需要一个能够移动文件指针位置的函数,这个函数就是lseek函数。

lseek函数

Gn!

lseek函数基本上和fseek函数是一致的,只不过lseek函数用来移动内核态缓冲区的文件指针。

其函数声明如下:

形式参数:

  1. fd: 文件描述符,它是通过open系统调用打开文件时返回的。

  2. offset: 根据参数whence的设置,这个值会指示文件内的新位置。

    1. 如果是0表示不偏移

    2. 如果是负数表示向文件开头偏移

    3. 如果是正数表示向文件末尾偏移

  3. whence: 文件指针偏移的基准点,它可以取以下几个值:

    1. SEEK_SET:offset是相对于文件开头的偏移值

    2. SEEK_CUR:offset是相对于文件指针当前位置的偏移值

    3. SEEK_END:offset是相对于文件末尾的偏移值

返回值:

  1. 成功:返回从文件开始到当前文件指针位置的字节数。利用这个特点,我们可以来获取文件的大小。

  2. 失败:返回-1,并设置errno以指示错误类型。

基于这个函数,我们把上述代码改为:

这样我们就可以正常的将一个文件的内容,从小写转换成大写了。

利用mmap实现大文件的复制

Gn!

基于以上文件映射的原理,我们知道mmap对比read/write性能主要的提升点在于:mmap减少一次数据在内核区域和用户空间的拷贝。

那么换句话说:

如果内核区域频繁需要和用户空间进行数据交互拷贝,那么mmap肯能就会具有比read/write更好的性能了。

那么什么场景下,数据需要频繁大量的进行拷贝呢?

答:文件很大,尤其是特别大时。比如几G,及时十G,甚至上T的文件复制,这个过程如果使用read/write那么性能就会被大量的数据拷贝所拖累。

所以在处理大型文件时,mmap非常具有优势!也就是说除了随机访问修改文件内容外,大型文件的处理,也是mmap一个非常重要的应用场景。

当然,要想完成一个大文件的复制,就不能再像上面的做法那样,把整个文件全部映射到内存中了:

因为几十G上T的文件很正常,但内存可没有这么大。

所以:我们需要将src文件分段映射到内存中,然后再将src分段映射的内存数据拷贝到dest文件的内存映射中。

这个过程需要以下前置知识点:

  1. 文件分段,分几段?所以首先要知道文件的大小

  2. 将src映射内存中的数据拷贝到dest映射的内存中,这里需要知道一种内存数据复制的手段

下面来讲解一下这两个问题。

获取文件大小

Gn!

获取文件大小,你已经学过一种办法了:利用lseek函数跳到文件末尾,返回值就是文件的字节大小。

除此之外,你还可以利用函数:

  1. stat函数,获取stat结构体,该结构体中就存有文件的大小信息。但是要注意文件名和文件路径名的区别。

  2. fstat函数,此函数的声明如下:

    该函数是stat函数的变种,直接利用文件描述符来获取对应stat结构体对象。

你可以利用man 2 fstat来学习一下fstat函数,当然除了参数从文件路径名变为了文件描述符,它的用法完全类似stat函数。

内存复制函数

Gn!

如果你希望将一块内存的数据完全复制到另一块,那么memcpy 就能够实现这个功能。

memcpy 是一个标准C语言库函数(ISO-C),它就是词组"memory copy"的缩写。用于从源内存地址拷贝 n 个字节到目标内存地址,是进行内存操作十分常用的一个函数。

此函数的声明如下:

形参列表:

  1. dest:内存复制的目的地内存块指针

  2. src:内存复制的数据源内存块指针

  3. n:复制的字节数量。

返回值:memcpy 函数返回一个指向目标内存区域 dest 的指针。

思路分析

Gn!

利用mmap实现大文件复制的思路如下:

  1. 分别open源文件和目标文件,并计算源文件大小

  2. ftruncate设置目标文件大小,避免文件大小不足,映射失败也能够提前预留空间,避免因空间不足而复制失败。

  3. 记录文件复制时dest目标文件的偏移量offset,也就是已复制字节的数量。利用该变量进行循环复制源文件到目标文件。

代码实现

Gn!

参考的代码实现如下:

写完这段代码后,你可以和之前写过的"read/write文件复制"、文件流复制进行对比,比较它们在不同场景当中的性能差异。

The End