简 述: 本篇讲解另外一种进程间通信方式,内存映射区 mmap(),以及对应的释放内存映射区 munmap() ,。前面两篇讲解了进程间通信,使用有名管道匿名管道的方式进行 IPC,也是经常用到的,可以去接触一下。

  • 对于有血缘关系的进程间通信:
    • 有名内存映射区
    • 匿名内存映射区(推荐)
  • 对于无血缘关系的进程间通信:
    • (只能用)有名内存映射区

[TOC]


本文初发于 “偕臧的小站“,同步转载于此。


编程环境:

  💻: MacOS 10.14 📎 gcc/g++ 9.2 📎 gdb8.3

  💻: uos20 📎 gcc/g++ 8.3 📎 gdb8.0


mmap内存映射原理:

(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

1、进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

2、在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址

3、为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化

4、将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中

(二)调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系

5、为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。

6、通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。

7、内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。

8、通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。

(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

9、进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。

10、缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。

11、调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。

12、之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。


创建内存映射区 mmap():

作用: 将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件。

void* mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
  • 参数:
    • addr:
      • 映射区的首地址,传 NULL ;系统会自动在虚拟地址空间的动态加载区,开辟一块大小为 len 的内存区域空间。
    • len: //映射区的大小
      • 必须是 4K 的整数倍,且不能够为 0。
      • 一般文件有多大,len 就有多大
    • prot: //映射区的权限
      • PROT_READ – 映射区必须要有读权限
      • PROT_WRITE – 写权限
    • **flags: //标志位参数 **
      • MAP_SHARED 共享区域,开启此权限,则内存中映射区域的内容,是和磁盘文件的内容保持一致
      • MAP_PRIVATR 内存区域的映射内容,是和磁盘文件的内容,不是时刻同步的。
    • fd: //文件描述符
      • 磁盘文件(想要映射到内存中的共享区)的那个文件的文件描述符
    • offset: //偏移文件的偏移量
      • 当想要从文件的中间某处到结束区域,映射到内存中,就可以只用这个偏移
  • 返回值:
    • *void 开辟的那个区域的首地址,用指针传出来。
      • 映射区的首地址 – 调用成功
      • 调用失败, 返回 MAP_FAILED

释放内存映射区 munmap():

就像 malloc - free; new - delete; mmap - munmap 一样,有开辟空间,就有释放该内存区域

int munmap(void *addr, size_t len);

两个参数,就是 mmap() 的第一个和第二个参数。


写一个例子,验证内存内容和磁盘文件会同步:

对于一个已有的文本文件 it.txt 进行映射,创建一个内存映射区,然后在内存映射区里面修改文件的聂荣,再重新打开磁盘的文本文件查看内容是否同时发生了改变。显示修改之前文件和使用内存映射区的内容后的文件内容后,发现磁盘里面的内容的确是和同步的改变了。最后要记得使用 munmap() 关闭你使用 mmap() 创建的内存映射区的空间哦。

  • 代码示例:

    #include <stdio.h>
    #include <unistd.h> 
    #include <sys/wait.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdlib.h>
    #include <string.h>
    #include <sys/mman.h>
    
    int main(int argc, char *argv[])
    {
        int fd = open("it.txt", O_RDWR);
    
        if (fd == -1) {
            perror("[open file] ");
            _exit(1);
        }
    
        int len = lseek(fd, 0, SEEK_END);
        void* ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);  //创建内存映射区
        if (ptr == MAP_FAILED) {
            perror("[mmap fail] ");
            exit(1);
        }
    
        ((char *)ptr)[0] = 'a';
        ((char *)ptr)[1] = 'b';
        ((char *)ptr)[2] = 'c';
        
        printf("%s\n", (char*)ptr);  //释放内存映射区
        munmap(ptr, len);
        close(fd);
    
        return 0;
    }
  • 运行效果:

    和预期的效果一直,在内存中改动内容,磁盘的文件的内容也随之改变。


对于 mmap() 的一些思考:

  • 如果 mmap() 的返回值 (ptr)做++操作(ptr++),munmap是否能够成功?
    • 不能,如果要做指针偏移的的话,可以 char* pt = ptr;
  • 如果 open() 时候 O_RDONLY,mmap 时 prot 参数指定 PROR_READ | PROT_WRITE 会怎样?
    • mmap 会调用失败
    • open() 文件指定权限应该大于等于 mmap() 的第三个参数 prot 指定的权限
  • 如果文件的偏移量为 1000 会怎么样?
    • 会失败,其必须是 4096 的整数倍
  • 如果不检查 mmap() 的返回值会怎样?
    • 也不会怎么样
  • mmap() 什么时候会调用失败?
    • 第二个参数 len = 0
    • 第三个必须要有 PROT_READ 权限;且 open()打开的权限要大于 mmap() 的 port 参数权限
  • 可以open()的时候,O_CREAT 一个新文件来创建映射区吗?
    • 可以,但是需要做文件扩展
    • lseek()
    • truncate(path,length)
  • mmap 后关闭文件描述符,对 mmap 映射有没有影响?
    • 文件被打开之后,就没有影响了。
  • 对 ptr 越界操作会怎么样?
    • 这个取决于 ptr 越界后面的内存写的是什么。但是大概率的会遇到段错误

mmap 实现内存映射?

  • 必须要有一个文件
  • 文件数据什么时候有用?
    • 单纯的实现文件映射
    • 进行进程间通信,磁盘的文件数据时没有用的。(在内存操作会更有效率,但是属于非阻塞)

父子进程间永远共享的东西?

  • 文件描述符
  • 内存映射区

例子实现父子进程间的通信:

通过改写上面的例子,创建 anonMmap.cpp 文件,创建子进程,父进程对内存映射区进行修改内容,在首段使用 strcpy() 添加一段中文语句,然后在子进程里面对复制进来的尾部’\0’进行覆盖,再次修改一段内容,然后在子进程里面间该短内容输出到终端显示。

  • 代码实现:

    #include <stdio.h>
    #include <unistd.h> 
    #include <sys/wait.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdlib.h>
    #include <string.h>
    #include <sys/mman.h>
    
    int main(int argc, char *argv[])
    {
        int fd = open("it.txt", O_RDWR);
    
        if (fd == -1) {
            perror("[open file] ");
            _exit(1);
        }
    
        int len = lseek(fd, 0, SEEK_END);
        void* ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);  //创建内存映射区
        if (ptr == MAP_FAILED) {
            perror("[mmap fail] ");
            exit(1);
        }
    
        pid_t pid = fork();
    
        if (pid > 0) {  //父进程
             strcpy((char *)ptr, "(我是父进程写入数据到内存映射区内容)");  //下标0-53,一共 54 个,其中ptr[53]为'\0'
             wait(NULL);         //回收子进程
        } else if (pid == 0) {  //子进程
            // sleep(2);
            ((char *)ptr)[53] = 'a';  //故意覆盖掉'\0',方便打印出来后面文章
            ((char *)ptr)[54] = 'b';
            ((char *)ptr)[57] = 'c';
            printf("%s", (char *)ptr);
        }
    
        munmap(ptr, len);
        close(fd);
    
        return 0;
    }
  • 运行效果:


创建匿名内存映射区:

上面写的例子,都是对于有血缘关系的父子进程之间的通信例子,通过磁盘文件使用 mmap() 创建的是(有名)内存映射区; 但是改一下 mmap() 创建的倒数第二个参数,且不需要 open() 磁盘文件,创建出来的就是(匿名)内存映射区

但是匿名内存映射区只能够适用于有血缘关系之间的进程通信。而有名内存映射区,可以使用与在有有血缘的进程和无血缘的进程之间的通信,都可以。

匿名内存映射区(有血缘关系进程通信):

  • (匿名)内存映射区 代码例子:

    int main(int argc, char *argv[])
    {
        int len = 4096;
        void* ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANON, -1, 0);  //创建匿名内存映射区,只需要修改倒数 2、3 两个阐述即可
        if (ptr == MAP_FAILED) {
            perror("[mmap fail] ");
            exit(1);
        }
    
        pid_t pid = fork();
    
        if (pid > 0) {  //父进程
             strcpy((char *)ptr, "this is parent process");
             wait(NULL);         //回收子进程
        } else if (pid == 0) {  //子进程
            // sleep(2);
            ((char *)ptr)[0] = 'a';  //故意覆盖掉'\0',打印出来后面文章
            ((char *)ptr)[1] = 'b';
            ((char *)ptr)[2] = 'c';
            printf("%s", (char *)ptr);
        }
    
        munmap(ptr, len);
    
        return 0;
    }
    
  • 运行效果:


有名内存映射区(无血缘关系进程通信):

而对于无血缘关系的进程间通信,只需要都打开同一个磁盘文件,各自的进程会按照这个顺序, 磁盘文件名 --> (各自的进程虚拟地址空间的)内存映射区 --> (共用一份的)物理内存的区域 , 然后都可以修改和读取这一段内存区域,从而实现进程间通信。

创建 aProcess.cpp 生成 a 进程,创建 bProcess.cpp 生成 b 进程;a 进程先对 c.txt 文件改写添加 “abc”,然后 b 进程再对 c.txt 文件改写添加 “ABC”,然后输出到终端显示。

  • 代码显示:

    实现伪代码如下,详细的源码见下面下载链接

    //aProcess.cpp
    int main(int argc, char *argv[])
    {
        int fd = open("c.txt", O_RDWR);
     		...
        int len = lseek(fd, 0, SEEK_END);
        void* ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
        ((char *)ptr)[0] = 'a';
        ((char *)ptr)[1] = 'b';
        ((char *)ptr)[2] = 'c';
        
        munmap(ptr, len);
    		...
    }
    
    
    //bProcess.cpp
    int main(int argc, char *argv[])
    {
        int fd = open("c.txt", O_RDWR);
     		...
        int len = lseek(fd, 0, SEEK_END);
        void* ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
        ((char *)ptr)[3] = 'A';
        ((char *)ptr)[4] = 'B';
        ((char *)ptr)[5] = 'C';
        
        munmap(ptr, len);
    		...
    }
  • 运行效果:


借鉴博客与总结:

发现一篇讲解的很棒的博客,更多的是理论和概念上面的分析 mmap() 的原理:认真分析mmap:是什么 为什么 怎么用, 其中文章开头的一段拿来再描述一下,其余则是本篇的侧重点是用代码来写两个例子,以及需要注意的一些坑,验证和学习这个内存映射区。


下载地址:

13_mmap

欢迎 star 和 fork 这个系列 的 linux 学习,附学习由浅入深的目录。