mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

读此文前可以查看先行了解下:虚拟内存(Virtual Memory),特别是其中的3.3 内存映射

mmap共享存储映射又称为 存储I/O映射,是Unix 共享内存 概念中的一种。

在Unix进程间通信中,大致有:

管道                  pipe(),用于父子进程间通信(不考虑传递描述符)
FIFO(有名管道)       非父子进程也能使用,以文件打通
文件                  文件操作,效率可想而知
本地套接字             最稳定,也最复杂.套接字采用Unix域
共享内存               传递最快,消耗最小,传递数据过程不涉及系统调用
信号                  数据固定且短小

IPC相关解析见:linux基础——linux进程间通信(IPC)机制总结

其中,共享内存是IPC(进程间通信)中最快的,一旦共享内存映射到共享它的进程的地址空间中,这些进程的数据传递就不再涉及内核,因为它会以指针的方式读写内存,不涉及系统级调用。



(一)管道与共享存储映射对比


管道

请看上图,左图描述了fork()前通过pipe()开启管道的示意图,假设父进程从文件A中读取数据并通过管道传递给子进程,由子进程执行某些操作后写入文件B。
首先,进程(32bit操作系统)的数据区位于0-3G的虚拟地址空间中,3G-4G为内核区,注意,文件A和文件B并不是存储在内核区,这里只是示意。并且,本次父子进程完全按照最早期Unix的实现讲解,也就是说父子进程完全独立的空间,不涉及到后来的写时复制(copy on write,即改动前父子进程共享一份全局/非运行时数据)等技术。

  • (1)父进程通过系统调用read()从文件A读取数据的过程中,父进程的状态切换到内核态,读取数据并保存到父进程空间中的buf中,再切换回用户态。这里发生了第一次数据的拷贝
  • (2)父进程通过系统调用write()将读取的数据从buf中拷贝到管道的过程中,父进程状态切换到内核态,向管道写入数据,再切换回用户态。这里发生第二次数据拷贝
  • (3)子进程通过系统调用read()从管道读取数据的过程中,子进程状态切换到内核态,读取数据并保存到子进程空间中的buf中,再切换回用户态。这里发生第三次数据拷贝
  • (4)子进程通过系统调用write()将读取的数据从buf中拷贝到文件B的过程中,子进程状态切换到内核态,向文件B写入数据,再切换回用户态。这里发生第四次数据拷贝

可以看到,这里发生了四次数据拷贝都是再内核与某个进程间进行的,这种开销往往更大(比存粹在内核中或单个进程内复制数据的开销更大)

因此,通过管道进行数据传递在编程上简单,而实际开销是作为一个追求极致效率的程序员所不允许的。接着我们来看看共享存储映射的开销是怎样的呢?


共享存储映射(存储I/O映射)

请看上图,该图描述了父进程使用mmap()使用共享存储映射,fork()后,fork会对内存映射文件进行特殊处理,也就是父进程在调用fork()之前创建的内存映射关系由子进程共享。该方式只有两次系统系统调用。而之前有四次调用

因此,父子进程可以通过指针对该内存区域进行读写操作,以完成数据通信。

该方法的奇特之处在于,进程间通信的I/O操作在内核的掩盖下完成,对内存的直接存取操作不涉及系统调用,避免了进程状态的频繁切换与系统调用。

  • (1)使用mmap()建立共享存储映射区
  • (2)父进程fork(),子进程共享该区域
  • (3)父进程读取文件A中的数据的过程中,切换至内核态,根据mmap返回的指针ptr,将数据拷贝到共享区域,再切换回来。这里发生第一次数据拷贝
  • (4)子进程根据ptr指针从内存读取数据到文件B,切换到内核态,write数据到文件B,再切换回来。这里发生第二次数据拷贝

注意:这里是父进程直接copy文件A到共享区,子进程从共享区copy数据到文件B,即共享存储映射是将磁盘上的文件映射到进程的虚拟地址空间,其物理支撑是物理内存,而进程通信时就是通过物理内存来传递数据,而不是写入磁盘再读出来。



(二)mmap函数

mmap函数把一个文件或一个Posix共享内存区对象映射到调用进程的地址空间。
(1)使用普通文件以提供内存映射I/O
(2)使用特殊文件以提供匿名内存映射

#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
//若成功则返回被映射区的起始地址,若出错则返回MAP_FAILED 
  • addr:指定被映射到进程空间内的起始地址,通常设为 NULL,代表让系统自动选定地址,映射成功后返回该地址。
  • len:映射到调用进程地址空间中的字节数。
  • prot:内存映射区域的保护方式,也即是获取映射地址指针进程的读写执行权限设置。常用 PROT_READ | PROT_WRITE
    PROT_EXEC 映射区域可被执行;
    PROT_READ 映射区域可被读取;
    PROT_WRITE 映射区域可被写入;
    PROT_NONE 映射区域不能存取;
  • flagsMAP_SHAREDMAP_PRIVATE 必须指定一个,其他可选:
    MAP_SHARED 调用进程对被映射数据所作修改对于共享该对象的所有进程可见,并且改变其底层支撑(物理内存) 并不是改变内存数据就马上写回磁盘。这个取决于虚拟存储的实现。
    MAP_PRIVATE 调用进程对被映射数据所作的修改只对该进程可见,而不改变其底层支撑(物理内存) ;
    MAP_FIXED 用于准确解释addr参数,从移植性考虑不应指定它,如果没有指定,而addr不是空指针,那么addr如何处置取决于实现。不为空的addr值通常被当作有关该内存区应如何具体定位的线索。可移植的代码应把addr指定为空指针,并且不指定MAP_FIXED;
    MAP_ANON 匿名映射时用;
  • fd:要映射到内存中的文件描述符。如果使用匿名内存映射时,即flags中设置了MAP_ANON,fd设为-1。
  • offset:文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍(一般是4096的整数倍)

使用普通文件进行存储映射
int main(int argc, char **argv)
{  
   /*忽略命令行参数处理步骤*/
   int fd, zero = 0;
   fd = open(argv[1], O_RDWR | O_CREAT, 0644);
   write(fd, &zero, sizeof(int));
   ptr = mmap(NULL, sizeof(int), 
              PROT_READ | PROT_WRITE | MAP_SHARED,
              fd, 0);
   close(fd); //至此就可以摆脱文件描述符参与的工作啦
   /*
     这里父子进程同步(信号量)地使用ptr进行数据交换
     且退出exit(0)
    */
}

匿名内存映射

所谓匿名映射,表示不存在fd这么个真实的文件。匿名内存映射适用于具有亲属关系的进程之间,避免被其他程序共享;由于父子进程之间的这种特殊的父子关系,在父进程中先调用mmap(),然后调用fork(),那么,在调用fork() 之后,子进程继承了父进程的所有资源,当然也包括匿名映射后的地址空间和mmap()返回的地址,这样父子进程就可以通过映射区域进行通信了。

实现匿名映射的方式主要有以下两种:

  • BSD 提供匿名映射的办法是fd =-1,同时 flag 指定为MAP_SHARE|MAP_ANON
  • SVR4 提供匿名映射的办法是 open /dev/zero设备文件,把返回的文件描述符,作为mmap的fd参数。

匿名映射的实际实现应与匿名文件(请求二进制0的内存页,见虚拟内存(Virtual Memory)——3.3 内存映射)有关。

/* BSD  匿名 */
int main(int argc, char **argv)
{  
   /*忽略命令行参数处理步骤*/
   int fd;
   ptr = mmap(NULL, sizeof(int), 
              PROT_READ | PROT_WRITE | MAP_SHARED | MAP_ANON,
              -1, 0);
   /*
     这里父子进程同步(信号量)的使用ptr进行数据交换
     且退出exit(0)
    */
}

/* SVR4 /dev/zero  特殊文件 */
int main(int argc, char **argv)
{  
   /*忽略命令行参数处理步骤*/
   int fd;
   fd = open("/dev/zero", O_RDWR);
   ptr = mmap(NULL, sizeof(int), 
              PROT_READ | PROT_WRITE | MAP_SHARED,
              fd, 0);
   close(fd);
   /*
     这里父子进程同步(信号量)地使用ptr进行数据交换
     且退出exit(0)
    */
}
/*/dev/zero 是一个特殊的文件,当你读它的时候,
它会提供无限的空字符(NULL, ASCII NUL, 0x00)
该文件一个作用是用它作为源,产生一个特定大小的空白文件。*/

一般来说,子进程单独维护从父进程继承下来的一些变量,而mmap()返回的地址却是由父子进程共同维护的;对于具有亲属关系的进程之间实现共享内存的最好方式应该是采用匿名映射的方式。此时,不必指定具体的条件,只要设置相应的标志即可。

呼应上文可以这么理解:匿名映射所映射到的内存共享区域,由于其是为了父子进程间通信服务的,并不是确切的磁盘上的文件(file_backed虚拟内存区域),也即所映射到的内存区域并不是一个(普通的)磁盘文件,因此称为匿名映射



(三)mmap文件大小和映射空间大小

第二大点讨论的mmap函数的参数,offset参数作为文件偏移,为什么要强调要4096(分页大小)的整数倍呢?mmap和页大小有关系吗?

当一个进程调用mmap成功后,将一个文件映射到该进程的地址空间中,现在该进程可以用返回的指针ptr对内存进行数据操作。物理内存中数据的变化什么时候写入到磁盘取决于虚拟存储的实现,因此,并不是写入数据到内存就马上写回磁盘。当然也可以调用msync函数进行磁盘数据同步。


文件大小等于映射区大小的情况

当我们用普通文件作映射区时,如果文件大小时5000,并且我们也用5000的映射区时(不是页面的整数), 虽然映射区大小为5000,但仍能够在一定程度上越界访问。 这其实是因为内核的内存保护是以页面为单位的,5000大小分得的物理页面支撑实际上是2个页面(8192大小)。

在0-4999可以使用ptr进行正常的读写访问,而5000-8191这一段里,内核是允许我们读写的,但是不会写入。注意,是允许读写,但写不进去。就是说内核允许写操作,但内核又不执行这个写操作。当超过了物理页面支撑后的任何操作都是不合规矩的,引发SIGSEGV信号。


文件大小远小于映射区大小的情况

文件大小仍然是5000,而映射区大小我们改为15000。物理页面支撑2个页面大小(8192大小)。

在访问0-4999是没有问题的,5000-8191这段允许读写但不执行写入操作。当超过物理页面支撑以后的空间分为两种情况

  • (1)超过物理页面但是没有超过映射区大小 –> 引发SIGBUS信号
  • (2)超过物理页面且超过映射区大小 —> 引发SIGSEGV信号
    由此我们可以看出,mmap映射时物理页面上面并不是单纯的以我们填入的数据分配,内核仍然会对文件本身的大小进行检查。

可以总结如下:

  • 没超过物理页面,没超过映射区大小 —> 正常读写;
  • 没超过物理页面,超过映射区大小 —> 内核允许读写但不执行写入操作;
  • 超过物理页面,没有超过映射区大小 —> 引发SIGBUS信号;
  • 超过物理页面, 超过映射区大小 —> 引发SIGSEGV信号;


(四)父子进程存储内存映射的地址分布

不涉及现时父子进程实现的写时复制机制,即父进程fork之后,子进程是父进程的副本,而不是大部分非运行时数据共享。换句话说,父子进程在物理内存上是完全不同的两个进程,有各自占用的明确的物理地址空间

那么父进程在fork出子进程之前调用mmap,之后父子进程依靠该共享存储映射区进行进程间通信。那么,父子进程的用户空间、物理内存、磁盘会是什么情况呢?

父进程fork之前,mmap成功返回一个ptr指针指向共享存储映射区的首地址。而共享存储映射区是位于进程空间的虚拟地址空间里,内核根据其实现将对应到物理内存的某个区域上,而fork之后,fork会对mmap产生的这段共享存储映射进行特殊处理,因此,当子进程复制得到这部分的副本时,ptr指针仍然指向对应的物理内存的那个区域。

这样就会产生一个疑惑,是不是子进程复制得到的这些数据的物理地址和父进程的一样呢?

答案是不同的,虽然后来在写时复制技术上不算错,但这里我们谈论的并没引入该技术,也就是说,除了PCB和正文,其他部分基本上都被复制了,父子进程在物理内存上是存放在不同区域的,而共享存储映射的这部分物理区域是相同的。综上,我们编写一测试代码验证我们的说法:

#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>

int main(void) {
	int *ptr;
	int i = 1;
	
	ptr = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,
				MAP_SHARED | MAP_ANON, -1, 0); //在父进程中进行内存映射
	*ptr = 1;

	if(fork() == 0) { //若是子进程
		printf("\n C >>>>> ptrAddress= %p valuesAddress=%p values=%d \n", &ptr, ptr, *ptr);
		printf("\n C >>>>> i=%d i_address=%p \n", i, &i);
		*ptr = 2;
		i = 2;
		sleep(2);
		exit(0);
	}
	
	//继续父进程的逻辑
	sleep(1);
	printf("\n P >>>>> ptrAddress= %p valuesAddress=%p values=%d \n", &ptr, ptr, *ptr);
	printf("\n P >>>>> i=%d i_address=%p \n", i, &i);
	sleep(2);
	exit(0);
}

该代码的意思是:

  • 在父进程fork之前成功调用了mmap函数,我们将共享存储映射的大小设置为一个int大小的空间,将ptr指向的那块物理内存赋值为1,局部变量i的值为1;
  • 然后fork,程序先将父进程睡眠1s,尽可能的保证子进程先运行,因此子进程打印出的ptr指向的数据应该是1,i值也为1。然后将ptr指向的数据改为2,i值改为2。接着子进程睡眠;
  • 父进程开始执行,如果说共享映射区的物理区域真的是共享的,那么子进程修改的数据父进程就可以打印出2。而事实确实是我们预期的;
  • 父进程打印数据: *ptr为2,i值为1;
  • 可以看到,父子进程在物理内存上的地址空间是不同的,i并没有被共享,而mmap产生的共享存储映射区则确确实实是共享的;

问题又出现了,上图的地址中,父子进程对同一个变量的地址是相同的,ptr的地址,ptr指向的那个地方的地址,以及i的地址,父子进程打印出来一样,代码以睡眠的方式保证了四次打印时父子进程都是没有结束的。

那么,在父子数据地址相同,并且满足局部变量不共享,共享存储映射区共享的情况下,系统是怎么实现的呢?

请记住2个概念: 虚拟地址空间物理内存
父子进程所在的是用户空间,其地址可以说是逻辑地址,而逻辑地址与真实物理地址的对应关系由**MMU(Memroy Managment Unit,内存管理单元)**来完成,因此,父子进程的i变量的地址一样,是由于它们各自有自己的虚拟内存空间,在各自的虚拟内存空间上i变量的地址一样,但是映射到物理内存上就不同了。同理,共享存储映射区的物理地址是相同的。

实际的内存情况如下图所示:

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐