理解内存映射mmap,一篇就够了

Mmap基础概念

mmap是一种文件映射方法,可以将一个文件或者其它的操作系统支持映射的对象映射到内存中,并且连接到进程的地址空间,使得进程能在用户态直接操作这段内存地址。

这段进程中映射的内存与文件的磁盘地址通过操作系统做了一一对应。

进程可以直接通过指针的方式操作读写这段内存,而操作系统会自动把有改动的脏页面写回到对应的文件磁盘中,这样对文件的操作不用调用read,write等系统调用函数,减少了数据copy,减少了内核态与用户态切换开销,提升了性能。

当然,内核对这段内存区域的操作也直接反应到了用户空间,从而实现了不同进程空间的文件共享。

图1: mmap函数申请内存的逻辑.png

如图所示,进程的地址空间是由一个一个的虚拟内存区域构成的,操作系统内核把这些虚拟内存区域连接起来组成了一个逻辑上的连续地址的虚拟内存。

其中共享内存只是其中的一段,也是通过操作系统映射出来加入到进程所见的这段连续内存。


mmap与常规文件操作的对比

系统调用write方式写文件的过程

image

  1. 步骤 1: 进程(用户态)调用write系统调用,并且告诉内核需要写入的数据内存起始地址和长度(告诉内核需要写入的数据在那里)。
  2. 步骤 2:内核收到write调用,校验用户态的数据,并把这些数据copykernel buffer 中,也就是Page cache.
  3. 步骤 3: 操作系统调度,将这些数据异步写入磁盘。

mmap 写入文件的过程

image

  1. 调用mmap映射文件,内核会为该文件在内核态空间创建一个虚拟page cache(未分配物理空间),并把这段内存空间的地址给到进程(用户态)。
  2. 进程(用户态)将要写入的数据直接copy到对应的mmap地址(内核态),这里只进行了一次内存copy
  3. 若要写入的mmap地址未对应物理内存,则产生缺页异常,由内核处理,生成真正的物理地址。若已经分配了物理地址,则直接copy到物理内存。
  4. 操作系统执行系统调用异步将脏页写回磁盘。

从理论上分析,这两种方式性能对比,

  1. 若是每次写入的数据大小接近于Page size(默认是4096),那么write调用于mmap的写性能应该比较接近,因为系统调用的此处相近。
  2. 若每次写入的数据非常小,那么mmap的性能应该是比write调用的方式要快很多很多,因为每次缺页异常是一次性产生一个page cache。那么总的系统调用要比write少很多。

read 读数据过程

image

  1. 进程(用户态) 调用read系统调用,告诉内核要读取的数据。
  2. 内核执行系统调用,把要读取的数据写入内核缓存(第一次copy)。
  3. 内核再次把数据copy给进程(用户态)的buffer(第二次copy)。

mmap 读数据过程

image

  1. 调用mmap映射文件,内核会为该文件在内核态空间创建一个虚拟page cache(未分配物理空间),并把这段内存空间的地址给到进程(用户态)。
  2. 进程(用户态) 直接操作这段内存,如果物理地址不存在,产生业异常,copy数据到内核page cache(一次copy)。
  3. 进程对这端内存直接操作。

从上面的读过程可以看出,mmap读比read读要少一次内存copy,理论上来说性能要快的多的多。


mmap的优缺点

优点:

  1. 实现了内核空间与用户空间的高效交互。两个空间各自的修改直接反应在映射区域,从而被对方及时捕捉。
  2. 操作文件就像操作内存一样,对文件的读取操作跨过了页缓存,减少了数据的copy次数。用内存读写取代传统的I/O读写,大大提升了文件的读写效率。
  3. 提供了进程间共享内存和相互通讯的方式,亲缘进程或无亲缘进程,都可以通过把自身用户空间的映射到同一片区域,达到进程间共享或进程间通讯的目的。
  4. 可以实现高效的大规模数据传输,使用磁盘代替内存空间。使用mmap映射,解决内存空间不足。

缺点:

  1. 因为mmap的映射跟内核的page cache密切相关,当申请的空间很小,内核也会生成一个等于page size大小的页,造成空间浪费。
  2. 申请的内存无法扩展,如果要扩展,只能重新申请一个更大的共享内存映射,再把老的数据copy过去,所以对变长文件不适合使用mmap

Mmap相关函数详解

mmap函数

mmap函数把一个文件或者一个Posix共享内存区对象映射到调用内存的地址空间。 使用该函数有三个目的:

  1. 使用普通文件以提供内存映射I/O。
  2. 使用特殊文件以提供匿名内存映射。
  3. 使用shm_open以提供无亲缘关系进程间的Posix共享内存区.

mmap 函数原型:

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

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

参数:

  • addr: 映射区开始地址,一般传NULL,

  • len: 映射区的长度

  • prot: 内存映射区的权限,可选值见下表。

    prot 说明
    PROT_READ 数据可读
    PROT_WRITE 数据可写
    PROT_EXEC 数据可执行
    PROT_NONE 数据不可访问
  • flags: 指定映射对象的类型,可以是一个或多个值得组合体 如:MAP_SHARED | MAP_FIXED,

    含义
    MAP_FIXED 如果addr参数所指的地址无法建立映射,则放弃映射,不对地址做修改,不建议使用此标志。
    MAP_SHARED 与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
    MAP_PRIVATE 建立一个写时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和MAP_SHARED标志是互斥的,只能选其一。
    MAP_DENYWRITE 这个标志被忽略。
    MAP_EXECUTABLE 这个标志被忽略。
    MAP_NORESERVE 不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。
    MAP_LOCKED 锁定映射区的页面,从而防止页面被交换出内存。
    MAP_GROWSDOWN 用于堆栈,告诉内核VM系统,映射区可以向下扩展。
    MAP_ANONYMOUS 匿名映射,映射区不与任何文件关联。
    MAP_ANON MAP_ANONYMOUS的别称,不再被使用。
    MAP_FILE 兼容标志,被忽略。
    MAP_32BIT 将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。
    MAP_POPULATE 为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。
    MAP_NONBLOCK 仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。
  • fd: 有效的文件描述符,一般是open()函数返回的描述符。

  • offset: 文件映射的偏移量,通常设置为0,代表从文件的最前方开始,这个参数必须设置为分页大小的整数倍。

返回说明:

  • 成功: mmap返回被映射区域的指针
  • 失败: 返回 MAP_FALIED,错误原因在errno中。

错误码:

  • EBADF 参数fd不是有效的文件描述符
  • EACCES 存取权限有误,如果flags设置了MAP_PRIVATE,则文件必须有可读权限,flags设置了MAP_SHARED,则port参数必须设置PROT_WRITE,且文件必须可写。
  • EINVAL 参数addr、length、offset三个参数中有不合法参数。
  • EAGAIN 文件被锁,或者太多内存被锁.
  • ENOMEM 内存不足。

通过图1,很容易就明白了mmap函数各参数的含义。

munmap函数

从某个进程的地址空间删除一个映射关系

munmap 函数原型:

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

int munmap(void *addr, size_t len);

//返回: 若成功则为0,若出错则为-1

参数:

  • addr: 由mmap函数返回的地址。
  • len: 映射区的大小。

如果调用了这个函数删除了进程空间的映射关系,那么进程中再次访问这些地址,将导致进程产生一个SIGSEGV信号。

如果映射区使用了MAP_PRIVATE标识,那么进程对该映射区做的所有变动都会被丢弃。

msync函数

同步内存映射区的数据到文件中

msync 函数原型:

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

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

//返回: 若成功则为0,若出错则为-1

参数:

  • addr: 一般是指整个映射区,也就是mmap函数返回的值,不过也可以指定该内存区的一个子集。
  • len: 需要同步的数据大小,不能超过mmap中申请的len。
  • flags: 标志位
    常量 说明
    MS_ASYNC 执行异步写
    MS_SYNC 执行同步写
    MS_INVALIDATE 使高速缓存的数据失效

mprotect 函数

修改一段指定内存区域的保护属性

mprotect 函数原型:

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

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

//返回: 若成功则为0,若出错则为-1

参数:

  • addr: 一般是指整个映射区,也就是mmap函数返回的值.如果申请了多个内存页,也可以是某一个内存页的起始地址。必须是内存页的起始地址。
  • len: 长度,必须是页大小的整数倍。
  • prot: 标志位,同mmap()的prot

出错原因可以查看errno。主要有以下几个:

  • EACCES 内存不能设置为相应的权限,比如,你在使用mmap()映射一个文件的时候设置为只读,接着使用mprotect()设置为PROT_WRITE.
  • EINVAL addr不是一个有效的指针,指向的不是某个内存的开头。
  • ENOMEM 内核内部的结构体无法分配
  • ENOMEN 进程的地址空间在区间(addr,addr+len)范围内无效,或者一个或多个内存页没有映射.

如果调用进程内存访问行为侵犯了这些设置的保护属性,内核会给该进程发送SIGSEGV信号,并终止该进程。


操作实例

1. 通过内存映射的方式修改文件

1
2
3
4
//cat data.txt
aaaaaaaaaa
bbbbbbbbbb
cccccccccc
  1. 使用MAP_SHARED的方式修改文件
 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
//mmap_shared.c

#include <sys/mman.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <error.h>
#include <unistd.h>
#include <fcntl.h>


int main(int argc,char *argv[])
{

	int fd,nread;
	struct stat st;
	char *start;

	if((fd = open(argv[1],O_RDWR)) < 0){
		perror("please usage: mmapread <filepath>");
		return -1;
	}
	if(fstat(fd,&st) == -1){
		perror("fstat");
		return -1;
	}
	
	if((start = mmap(NULL,st.st_size,PROT_READ | PROT_WRITE,MAP_SHARED,fd,0)) == (void *)-1  ){
		perror("mmap");
		return -1;
	}
	start[0] = '1';
	start[11] = '2';
	if((msync((void *)start,st.st_size,MS_SYNC)) == -1){
		perror("msync");
		return -1;
	}
	
	if((munmap((void *)start,st.st_size)) == -1){
		perror("munmap");
		return -1;
	}
	close(fd);
	return 0;	
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 编译
gcc mmap_shared.c -o mmap_shared

// 执行
./mmap_shared /home/lzm/learnSwoole/table/data.txt

//cat data.txt
1aaaaaaaaa
2bbbbbbbbb
cccccccccc
  1. 私有映射无法修改文件
1
2
3
4
5
6
7
8
9
//mmap_private.c
...
...
if((start = mmap(NULL,st.st_size,PROT_READ | PROT_WRITE,MAP_PRIVATE,fd,0)) == (void *)-1){
		perror("mmap");
		return -1;
	}
...
...

2. 使用共享内存进行两个进程间的通讯

两个进程分别是A进程,B进程。两个进程都映射同一个文件,A进程先映射该文件,然后死循环间隔2秒输出文件内容, 再打开B进程同时映射该文件,并且修改该文件内容,观察A进程的输出是否有变化。

A进程代码:

 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
//mmap_communication_a.c
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <error.h>
#include <unistd.h>
#include <fcntl.h>


int main(int argc,char *argv[])
{

	int fd,nread;
	struct stat st;
	char *start;

	if((fd = open(argv[1],O_RDWR)) < 0){
		perror("please usage: mmapread <filepath>");
		return -1;
	}
	if(fstat(fd,&st) == -1){
		perror("fstat");
		return -1;
	}
	
	if((start = mmap(NULL,st.st_size,PROT_READ | PROT_WRITE,MAP_SHARED,fd,0)) == (void *)-1  ){
		perror("mmap");
		return -1;
	}
	close(fd);

	while(1){
		printf("%s\n",start);
		sleep(2);
	}	

	return 0;	
}

B进程代码:

 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
//mmap_communication_b.c
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <error.h>
#include <unistd.h>
#include <fcntl.h>


int main(int argc,char *argv[])
{

	int fd,nread;
	struct stat st;
	char *start;

	if((fd = open(argv[1],O_RDWR)) < 0){
		perror("please usage: mmapread <filepath>");
		return -1;
	}
	if(fstat(fd,&st) == -1){
		perror("fstat");
		return -1;
	}
	
	//注意这里,如果使用MAP_PRIVATE模式打开,是无法修改文件内容的
	if((start = mmap(NULL,st.st_size,PROT_READ | PROT_WRITE,MAP_SHEARD,fd,0)) == (void *)-1  ){
	//if((start = mmap(NULL,st.st_size,PROT_READ | PROT_WRITE,MAP_PRIVATE,fd,0)) == (void *)-1  ){
		perror("mmap");
		return -1;
	}
	close(fd);
	start[11] = '2';
	return 0;	
}

3. 匿名映射实现父子进程间通讯

 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
//mmap_annoymous.c
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUF_SIZE 100

int main(int argc,char * argv[])
{
	char *p_map;


	p_map = (char *)mmap(NULL,BUF_SIZE,PROT_READ | PROT_WRITE,MAP_SHARED | MAP_ANONYMOUS,-1,0);
	
	if(fork() == 0){
		sleep(1);
		printf("child got a message: %s\n",p_map);
		sprintf(p_map,"%s","hi,dad, this is son");
		munmap(p_map,BUF_SIZE);
		exit(0);
	}
	sprintf(p_map,"%s","hi,this is father");
	sleep(2);
	printf("parent got a message: %s\n",p_map);
	
	return 0;
}
1
2
3
4
5
6
7
//编译
    gcc mmap_annoymous.c -o mmap_annoymous
//运行
    ./mmap_annoymous
//输出
child got a message: hi,this is father
parent got a message: hi,dad, this is son

4. 对mmap()返回地址的访问

image

linux采用的是页式管理机制,对于用mmap()映射出来的普通文件,进程会在自己的地址空间新增一块空间,空间的大小由mmap的len参数决定,但是,进程不一定对全部新增空间都能进行有效访问,进程能访问的有效地址大小取决于文件被映射的部分的大小。如果访问了超过这个大小的地址,内核会产生错误,如上图所示。

代码:

 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
//mmap_memory_out.c

#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc,char *argv[])
{
	int fd,i;
	int pagesize,offset;
	char *p_map;
	struct stat st;

	pagesize = sysconf(_SC_PAGESIZE);
	printf("pagesize is %d\n",pagesize);
	
	fd = open(argv[1],O_RDWR,00777);
	
	fstat(fd, &st);
	
	printf("file size is %zd\n",(size_t)st.st_size);
	
	offset = 0;
	p_map = (char *) mmap(NULL,pagesize*2,PROT_READ | PROT_WRITE,MAP_SHARED,fd,offset);
	close(fd);

//	p_map[st.st_size] = '9'; //导致总线错误
	p_map[pagesize] = '9'; //导致段错误

	munmap(p_map,pagesize * 2);
	
	return 0;

}

mmap()内存映射就讲到这里,有兴趣的朋友可以继续深入研究,这批文章是我参考了很多网上的资料加上自己的理解写成。 大家可以随便转载,转载请务必标明出处,谢谢!文章的末尾是我参考的一部分内容。

参考

0%