内存映射

储存映射

除了标准文件I/O,内核还提供了一个接口,支持应用程序将文件映射到内存中,即内存地址和文件数据一一对应。这样开发人员就可以直接通过内存来访问文件,就像操作内存中的数据块一样,甚至可以写入内存数据区,然后通过透明的映射机制将文件写入磁盘

mmap()

mmap()调用请求内核将文件描述符fd所指向的对象的len个字节数据映射到内存中,起始位置从offset开始。如果指定addr,表示优先使用addr作为内存中的起始地址。参数prot指定了访存权限,flags指定了其他操作行为

1
2
3
#include <sys/mman.h>

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

addr参数告诉内核映射内存文件的最佳地址,但仅仅是作为提示信息,而不是强制性的,大部分用户对该参数传递0.调用返回内存映射区域的真实开始地址

prot参数描述了对内存区域所请求的访问权限。如果是PROT_NONE,表示无法访问映射区域的页(基本不用),也可以是以下标志位的比特位或运算值:

PROT_READ 页可读

PROT_WRITE 页可写

PROT_EXEC 页可执行

prot参数所设置的访问权限不能和打开文件的访问模式冲突。即如果文件只读模式打开,prot不能设置PROT_WRITE

flag参数描述了内存映射的类型及其一些行为。其值为以下值按位或运算的结果:

MAP_FIXED : 表示mmap()强制接收参数addr,而不是作为提示信息。如果内核无法映射文件到指定地址,调用会失败。如果地址和长度指定的内存和已有映射有重叠区域,重叠区域的原有内容被丢弃,通过新的内容填充。该选项需要深入了解进程的地址空间,不可移植,因此不鼓励使用

MAP_PRIVATE : 表示映射区不共享。文件映射采用了写时复制,进程对内存的任何改变不影响真正的文件或其他进程的映射

MAP_SHARED : 表示和其他映射该文件的进程共享映射内存。该映射区域会受到其他进程写操作的影响

MAP_SHARED和MAP_PRIVATE必须指定其中的一个,但是不能同时指定

当映射文件描述符时,文件的引用计数会加1.因此,如果映射文件后关闭文件,进程依然可以访问该文件。当取消映射或进程终止时,对应的文件引用计数会减1

1
2
3
4
5
6
void *p
p = mmap (0, len, PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED)
{
perror("mmap");
}

页大小

页是内存管理单元(MMU)的粒度单位。因此,它是内存中允许具有不同权限和行为的最小单元。页是内存映射的基本块,因而也是进程地址空间的基本块

mmap()系统调用的操作单元是页。参数addr和offset都必须按页大小对齐。也就是说,它们必须是页大小的整数倍

所以,映射区域是页大小的整数倍。如果调用方提供的len参数没有按页对其,映射区域会一直占满最后一个页,多出来的内存,即最后一个有效字节到映射区域边界这一部分区域,会用0填充。该区域所有读写操作都将返回0.写操作都不会影响文件的最后部分,即使使用MAP_SHARED进行映射,只有最前面len个字节会写到文件中。

标准POSIX规定,获得页大小的方法是通过sysconf()函数,它将返回一系列系统特定的信息:

1
2
#include <unistd.h>
long sysconf (int name);

sysconf()调用会返回配置项name值,如果name无效,返回-1.出错时,errno被设置为EINVAL。因为-1对某些项来说是有效值,所以明智的做法是在调用前清空errno,并在调用后检查

POSIX定义_SC_PAGESIZE(_SC_PAGE_SIZE与其同义)表示页大小,因此获取页大小很简单:

1
long page_size = sysconf (_SC_PAGESIZE);

Linux也提供了getpagesize()函数来获得页大小:

1
int page_size = getpagesize();

并不是所有的UNIX系统都支持此函数,POSIX 1003.1-2001弃用了该函数

页大小是由<asm/pages.h>中的宏PAGE_SIZE定义的。因此第三种获取页大小的方法是:

1
int page_size = PAGE_SIZE;

为了可移植性和代码的兼容性,sysconf()是最好的选择

返回值和错误码

成功时,mmap()返回映射区域的地址。失败时,返回MAP_FAILED,并相应设置errno值。mmap()调用,永远不会返回0

errno可能为:

EACESS : 指定的文件描述符不是普通文件,或者打开模式和参数prot或flags冲突

EAGAIN : 文件已通过文件锁锁定

EBADF : 指定文件描述符非法

EINVAL : 参数addr、len、off中的一个或多个非法

ENFILE : 打开文件数达到系统上限

ENODEV : 文件所在文件系统不支持存储映射

ENOMEM : 内存不足

EOVERFLOW : 参数addr+len的结果超过了地址空间大小

EPERM : 设定了参数PORT_EXEC,但文件系统以不可执行方式挂载

相关信号

和映射区域相关的两个信号如下:

SIGBUS 当进程试图访问一块已经失效的映射区域时,会生成该信号

SIGSEGV 当进程试图写一块只读的映射区域时,会生成该信号

munmap()

Linux提供了munmap()系统调用,来取消mmap()所创建的映射

1
2
#include <sys/mman.h>
int munmap (void *addr, size_t len);

munmap()会消除进程地址空间从addr开始,len字节长的内存中的所有页面映射。一旦映射被消除,之前关联的内存区域就不再有效,如果试图再次访问会生成SIGSEGV信号

一般来说,传递给munmap()的参数是上次mmap()调用的返回值及其参数len

成功时,munmap()返回0;失败时返回-1,并设置相应errno值。唯一标准的errno值是EINVAL,它表示一个或多个参数无效

存储映射实例

下面是将文件输出到标准输出的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>

int main (int argc, char *argv[])
{
struct stat sb;
off_t len;
char *p;
int fd;

if (argc < 2)
{
fprintf (stderr, "usage: %s <file>\n", argv[0]);
return 1;
}

fd = open (argv[1], O_RDONLY);
if (fd == -1)
{
perror ("open");
return 1;
}

if (fstat (fd, &sb) == -1)
{
perror ("fstat");
return 1;
}

if (!S_ISREG (sb.st_mode))
{
fprintf (stderr, "%s is not a file\n", argv[1]);
return 1;
}

p = mmap (0, sb.st_size, PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED)
{
perror ("mmap");
return 1;
}

if (close (fd) == -1)
{
perror ("close");
return 1;
}

for (len = 0; len < sb.st_size; len++)
{
putchar (p[len]);
}

if (munmap (p, sb.st_size) == -1)
{
perror ("munmap");
return 1;
}

return 0;
}

mmap()的优点

相对于read()和write()而言,使用mmap()处理文件有很多优点:

  • 使用read()或write(),需要从用户缓冲区进行数据读写,而使用映射文件进行操作,可以避免多余的数据拷贝操作。
  • 除了可能潜在页错误,读写映射文件不会带来系统调用和上下文切换的开销,它就像直接操作内存一样简单
  • 当多个进程把同一个对象映射到内存中时,数据会在所有进程间共享。只读和写共享的映射在全体中都是共享的;私有可写的映射对尚未进行写时拷贝的页是共享的
  • 映射对象中搜索只需要很简单的指针操作,不需要使用系统调用lseek()

基于以上理由,mmap()是很多应用的明智选择

mmap()的不足

使用mmap()需要注意以下几点:

  • 由于映射区域的大小总是页的整数倍,因此,文件大小与页大小的整数倍之间有空间浪费。对于小文件,空间浪费比较严重。例如,如果页大小是4KB,一个7字节的映射就会浪费4089字节
  • 存储映射区域必须在进程地址空间内。对于32位的地址空间,大量的大小不同的映射会导致生成大量的碎片,使得很难找到连续的大片空内存。当然,这个问题在64位地址空间就不是很明显
  • 创建和维护映射以及相关的内核数据结构有一定的开销。不过,由于mmap()消除了读写时不必要的拷贝,这种开销几乎可以忽略,对于大文件和频繁访问的文件更是如此

基于以上理由,处理大文件(浪费空间很小),或者在文件大小恰好被怕个大小整除(没有空间浪费)时,mmap()的优势就会非常显著

给出映射提示

Linux提供了系统调用madvise(),进程对自己期望如何访问映射区域给内核一些提示信息。内核会根据此优化自己的行为,尽量更好的利用映射区域。

调用madvise()会告诉内核该如何对于起始地址为addr,长度为len的内存映射区域进行操作

1
2
#include <sys/mman.h>
int madvise (void *addr, sizet len, int advice);

如果len为0,内核将把提示信息应用于所有起始地址为addr的映射。参数advice表示提示信息,可以是下列值之一:

MADV_NORMAL 对指定的内存区域,应用没有特殊提示,按正常方式操作

MADV_RANDOM 应用将以随机访问方式,访问指定范围的页

MADV_SEQUENTIAL 应用期望从低地址到高地址顺序访问指定范围的页

MADV_WILLNEED 应用期望会很快访问指定范围的页

MADV_DONTNEED 应用在短期内不会访问指定范围内的页


内存映射
https://carl-5535.github.io/2021/03/21/Linux系统编程/内存映射/
作者
Carl Chen
发布于
2021年3月21日
许可协议