详解linux中mmap的底层原理及应用
Linux开发架构之路 2025-01-09

一、mmap在linux系统哪里?

二、什么是mmap?

通过上图我们可以知道mmap是操作硬件设备的一种方法,所谓操作设备,比如IO端口(点亮一个LED)、LCD控制器、磁盘控制器,实际上就是往设备的物理地址上读写数据。

但是,由于应用程序不能直接操作设备硬件地址,所以操作系统提供了这样一种机制——内存映射,把设备地址映射到进程虚拟地址,mmap就是实现内存映射的接口。

操作设备还有很多方法,如ioctl、ioremap等。

mmap的好处就是,mmap把设备内存映射到虚拟内存,则用户操作虚拟内存相当于在直接操作设备,省去了用户空间到内核空间的复制过程,相对IO操作来说,增加了数据的吞吐量。

三、什么是内存映射?

既然mmap是实现内存映射的接口,那么内存映射是什么呢?如下图所示:

每个进程都有独立的进程地址空间,通过页表和MMU,可以将虚拟地址转换为物理地址,每个进程都有独立的页表数据,这就可以解释为什么两个不同进程相同的虚拟地址却对应着不同的物理地址。

四、mmap在文件内存映射上的应用

在unix/linux平台上读写文件,一般有两种方式。分别为open系统调用函数和mmap内存映射。

1.open系统调用

首先open文件,接着使用read系统调用函数来读取文件。于是内核将文件的内容从磁盘上读取到内核页高速缓存中(主存RAM中),再从内核高速缓存读取到用户进程的地址空间。这么做需要在内核和用户空间之间做多次数据拷贝。而且当多个进程同时读取一个文件时,那么每个进程在自己的页表中都有这个文件的副本并且都对应到物理内存中各自的副本,这样便造成了物理内存的浪费。如下图所示:

2.mmap内存映射

mmap是一种内存映射的方法,就是将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。如下图所示:

五、linux内存描述符

了解了内存映射的概念后,那么内核是怎么管理这些地址空间的呢?任何复杂的理论最终也是通过各种数据结构体现出来的,而这里使用的数据结构就是进程描述符。从内核角度看,进程是分配系统资源(CPU、内存)的载体,为了管理进程,内核必须对每个进程所做的事情进行清楚的描述,这就是进程描述符。内核用task_struct结构体来表示进程,并且维护一个该结构体链表来管理所有进程。该结构体包含一些进程状态、调度信息等上千个成员,我们这里主要关注进程描述符里面的内存描述符(struct mm_struct mm)。内存管理如下图所示:

现在已经知道了内存映射是把设备地址映射到进程空间地址(注意:并不是所有内存映射都是映射到进程地址空间的,ioremap是映射到内核虚拟空间的,mmap是映射到进程虚拟地址上的),实质上是分配了一个vm_area_struct结构体加入到进程的地址空间,也就是说,把设备地址映射到这个结构体,具体到映射的过程就是驱动程序要做的事情了。vm_area_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用到的信息,都可以从vm_area_struct中获得。并且如上图所示vm_area_struct结构通常是用链表的形式保存以方便进程快速的访问。

六、mmap内存映射原理

1.内存映射的步骤

  • 用open系统调用打开文件,并返回文件描述符fd;

  • 用mmap建立内存映射,并返回映射首地址指针start;

  • 对映射文件进行各种操作,可以用指针偏移的方式进行读写;

  • 用munmap关闭内存映射;

  • 用close系统调用关闭文件描述符fd。

2.mmap内存映射的实现过程,总的来说可以分为三个阶段

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

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

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

  • 为此虚拟区域分配一个vm_area_struct结构体,接着对这个结构体的各个字段进行初始化;

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

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

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

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

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

  • 通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。(本质上,用户进程的虚拟地址映射到磁盘文件中间还是需要经过物理内存的,也就是说进程操作虚拟地址会对应到物理地址上,物理内存再与磁盘交互数据)

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

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

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

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

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

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

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

3.mmap和常规文件读写的区别

简单介绍一下常规文件系统操作(调用read/fread等函数),函数的调用过程是:

  1. 进程发起读写文件请求;

  2. 内核通过查找进程文件符表,定位到内核已打开文件集上的文件信息,从而找到此文件的inode;

  3. inode在address_space上查找要请求的文件页是否已经缓存在内核页缓存中。如果存在,则直接返回这片文件页的内容;

  4. 如果不存在,则通过inode定位到文件磁盘地址,将数据从磁盘复制到内核页缓存。之后再次发起读页面的过程,进而将内核页缓存中的数据发给用户进程。

总结来说,常规文件操作为了提高读写效率和保护磁盘,使用了内核页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到内核页缓存中,由于内核页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将内核页缓存中的数据页再次拷贝到用户进程对应的物理内存中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的buffer在内核空间是不能直接访问的,必须要先拷贝至内核空间对应的主存,再写回磁盘中(系统回延迟写脏页面),也是需要两次数据拷贝。

而使用mmap操作文件时,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入用户进程的内存中,供进程使用。

总而言之,常规文件操作需要从磁盘到内核页缓存再到用户进程物理内存的两次数据拷贝。而mmap操作文件,只需要从磁盘到用户进程物理内存的一次数据拷贝过程。说白了,mmap的关键点是实现了用户空间到内核空间的数据直接交互而省去了空间不同数据不通的繁琐过程,因此mmap效率更高。

七、mmap函数说明

头文件


#include

创建内存映射mmap


void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);  参数说明: addr: 入参,如果这个地址为null那么内核将自己为你指定一个地址,如果不为null,将使用这个地址作为映射区的起始地址 length:映射区的大小(<=文件的大小) prot: 访问属性,一般用PROT_READ、PROT_WRITE、PROT_READ|PROT_WRITE flags:这个参数是确定映射的更新是否对映射相同区域的其他进程可见,以及是否对基础文件进行更新 MAP_SHARED:共享此映射,映射的更新对映射相同区域的其他进程可见 MAP_PRIVATE: 创建写时专用拷贝映射,映射的更新对映射的其他进程不可见,相同的文件,并且不会传递到基                      础文件。 我们一般用MAP_SHARED,这两个权限是限制内存的,而不限制文件 fd:被映射的文件句柄 offset:默认为0,表示映射文件全部。偏移未知,需要时4K的整数倍。   返回值:成功:被映射的首地址  失败:MAP_FAILED (void *)-1

释放内存映射


int munmap(void *addr, size_t length); 参数说明: addr: 被映射的首地址 length: 映射的长度 返回值:0:成功 -1:失败

八、mmap的基础使用


#include #include #include #include #include #include  int main(int argc, const char *argv[]){ char *p = NULL; int fd = -1; // 打开文件 fd = open("temp", O_RDWR|O_CREAT|O_TRUNC, 0644); if (-1 == fd) { printf("文件打开失败...\n"); return -1; }  // 因为我们文件不能是一个0大小的文件,所以我们需要修改文件的大小 // 有两种方式:leek,write,或者ftruncate都可以  /* // 改变一个文件的读写指针 lseek(fd, 1023, SEEK_END); // 写入一个结束符\0 write(fd, "\0", 1); */ // 我们还是用这种,比较方便,直接就修改了,和上面效果一样 ftruncate(fd, 1024);  // 创建一个内存映射,让内和指定一个映射地址,大小为1024,可读可写,共享,映射到这个fd上 p = mmap(NULL, 1024, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { printf("mmap failed\n"); close(fd); return -1; }   // 拿到地址之后我们就可以像操作普通地址一样写数据,读数据了,例如memcpy,strcpy等等  memcpy(p, "hello world", sizeof("hello world")); // 读数据  printf("p = %s\n",p);   // 最后释放这个映射 if (munmap(p, 1024) == -1) { printf("munmap failed\n"); close(fd); return -1; }  close(fd); return 0;}


gcc mmap.c 进行编译得到可执行文件a.out./a.out 可以得到执行结果p = hello world

然后看当前文件夹下会出现一个temp的文件


我们直接用cat命令进行输出:

我们会发现其实是和程序输出的一样的,到这里,基本使用就结束了。

九、mmap的使用注意事项

1.能使用创建出来的新文件进行映射吗?

答案:能,但是需要修改文件的大小,如果不修改则会出现总线错误,程序如下:


#include #include #include #include #include #include  int main(int argc, const char *argv[]){ char *p = NULL; int fd = -1; // 打开文件 fd = open("temp", O_RDWR|O_CREAT|O_TRUNC, 0644); if (-1 == fd) { printf("文件打开失败...\n"); return -1; }  // 因为我们文件不能是一个0大小的文件,所以我们需要修改文件的大小 // 有两种方式:leek,write,或者ftruncate都可以  /* // 改变一个文件的读写指针 lseek(fd, 1023, SEEK_END); // 写入一个结束符\0 write(fd, "\0", 1); */ // 我们还是用这种,比较方便,直接就修改了,和上面效果一样 // TODO ftruncate(fd, 1024); // 主要修改了这行,我们不进行文件大小调整,那么文件大小就是0  // 创建一个内存映射,让内和指定一个映射地址,大小为1024,可读可写,共享,映射到这个fd上 p = mmap(NULL, 1024, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { printf("mmap failed\n"); close(fd); return -1; }   // 拿到地址之后我们就可以像操作普通地址一样写数据,读数据了,例如memcpy,strcpy等等  memcpy(p, "hello world", sizeof("hello world")); // 读数据  printf("p = %s\n",p);   // 最后释放这个映射 if (munmap(p, 1024) == -1) { printf("munmap failed\n"); close(fd); return -1; }  close(fd); return 0;}

和基础使用例子一样,只是注释了修改文件大小的逻辑ftruncate(fd, 1024),这样新创建的文件大小就是0,

我们编译运行,如下图:Bus error

所以结论就是:创建映射区的文件大小为0,而指定的大小非零的时候会出现总线错误

2.创建映射区的文件大小为0,实际指定映射区的大小为0

得到的结果:无效的参数

3.如果打开文件时flag为O_RDONLY,mmap时PROT参数为PROT_READ|PROT_WRITE会怎样?

得到的结果:无效的参数

4.如果打开文件时flag为O_RDONLY(新文件不行,需要一个有文件大小的文件),mmap时PROT参数为PROT_READ会怎样?

得到的结果:在写数据的时候段错误

5.如果打开文件时flag为O_WRONLY(新文件不行,需要一个有文件大小的文件),mmap时PROT参数为PROT_WRITE会怎样?

得到的结果:没有权限,mmap在创建的时候需要读权限,mmap的读写权限应该小于等于文件的打开权限,文件至少必须要有读权限。(前提是MAP_SHARED 模式下)

6.文件描述符fd,在mmap创建映射区完成即可关闭,后续访问文件,用地址访问。

7.如果offset是1000会怎么样?

得到的结果:无效的参数,必须是4K的整数倍(这个跟MMU有关,MMU映射的最小单位就是4K)

8.对mmap越界操作会怎样?

得到的结果:段错误,mmap映射以页为单位,就是说得到的空间的大小是4096的倍数,举个例子就是你申请了10个字节,但系统会给你申请4096,因为不够一页(4k),如果你申请4097,那么会给你申请两个页,所以才会发现你申请10个空间却能写如20个或者4096以下的字节数也不会崩溃的原因。

9.对mmap++是否还能munmap成功

得到的结果:不能,无效的参数,首地址变了,munmap必须释放申请的地址

十、mmap父子进程间通信


#include #include #include #include #include #include #include #include #include   // 全局变量 var int var = 100;int main(int argc, const char *argv[]){ int *p; pid_t pid; int ret = 0;  int fd; // 打开一个文件 fd = open("temp", O_RDWR|O_TRUNC, 0644); if (fd < 0) { perror("open error"); exit(1); } // truncate文件大小 ftruncate(fd, 4); // 创建映射区 p = (int *)mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { perror("mmap error"); exit(1); }  // 关闭fd,mmap创建成功后就可以关闭了,因为直接使用地址了,不需要fd了 close(fd);  // fork一个进程 pid = fork(); if (pid == 0) // 子进程 { *p = 2000; var = 1000; printf("child *p = %d, var = %d\n", *p, var); }else{ // 父进程  sleep(1); // 休眠一秒,让子进程先执行 printf("parent *p = %d, var = %d\n", *p, var); wait(NULL); // 回收子进程 // 释放共享内存 if (munmap(p, 4) == -1) { perror("munmap error"); exit(1); }  } return 0;}

结果:


结果发现p指向的地址的内容改掉了,而var没有被改掉(对于父子进程共享的东西是读共享,写复制)

十一、mmap无血缘关系的进程间通信

写进程,循环写这个结构体大小的数据到共享内存


 #include  #include  #include  #include  #include  #include  #include  #include  #include   struct student{ int id; char name[256]; int age; };  int main(int argc, const char *argv[]){ int fd; struct student stu = {0, "zhangsan", 18}; struct student *p;  fd = open("temp", O_RDWR|O_CREAT|O_TRUNC, 0644); if (fd < 0) { perror("open error"); exit(1); }  ftruncate(fd, sizeof(stu)); p = mmap(NULL, sizeof(stu), PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { perror("mmap error"); exit(1); }  close(fd);  while (1) { // 循环写 memcpy(p, &stu, sizeof(stu)); stu.id++; sleep(3); }  if (-1 == munmap(p, sizeof(stu)))  { perror("munmap error"); exit(1); }     return 0; }

读进程,循环从共享内存中读


 #include  #include  #include  #include  #include  #include  #include  #include  #include   struct student{ int id; char name[256]; int age; };   int main(int argc, const char *argv[]){ int fd; struct student stu = {0, "zhangsan", 18}; struct student *p;  fd = open("temp", O_RDONLY, 0644); if (fd < 0) { perror("open error"); exit(1); }  p = mmap(NULL, sizeof(stu), PROT_READ, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { perror("mmap error"); exit(1); }  close(fd);  while (1) { // 循环读 printf("stu.id = %d, stu.name = %s, stu.age = %d\n", p->id, p->name, p->age);  sleep(3); }  if (-1 == munmap(p, sizeof(stu))) { perror("munmap error"); exit(1); }  return 0; }

一个读端一个写端执行结果如下:

一个写端多个读端执行结果如下:


多个写端一个读端:

十二、匿名映射

前面我们每次使用共享内存时,都会创建一个文件,这样会造成垃圾文件,接下来我们使用unlink把创建的文件删除掉,创建完就删除这个文件:unlink(文件名)


#include #include #include #include #include #include #include #include #include   // 全局变量 varint var = 100;int main(int argc, const char *argv[]){ int *p; pid_t pid; int ret = 0;  int fd; // 打开一个文件 fd = open("temp", O_RDWR|O_TRUNC, 0644); if (fd < 0) { perror("open error"); exit(1); }  // TODO 添加了这句删除文件   ret = unlink("temp"); if (ret == -1) { perror("unlink error"); exit(1); } // truncate文件大小 ftruncate(fd, 4); // 创建映射区 p = (int *)mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); if (p == MAP_FAILED) { perror("mmap error"); exit(1); }  // 关闭fd,mmap创建成功后就可以关闭了,因为直接使用地址了,不需要fd了 close(fd);   // fork一个进程 pid = fork(); if (pid == 0) // 子进程 { *p = 2000; var = 1000; printf("child *p = %d, var = %d\n", *p, var); }else{ // 父进程  sleep(1); // 休眠一秒,让子进程先执行 printf("parent *p = %d, var = %d\n", *p, var); wait(NULL); // 回收子进程 // 释放共享内存 if (munmap(p, 4) == -1) { perror("munmap error"); exit(1); }  } return 0;}

这样执行完成之后,那个临时文件就没了

又要open,又要unlink的好麻烦,有没有更方便的方法。答案是有的。可以直接使用匿名映射来代替,其实linux系统给我们提供了创建匿名映射区的方法,无需依赖一个文件即可创建映射区,同样需要借助标志位flags来指定。

使用MAP_ANONYMOUS(或MAP_ANON),如:


int *p = mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);

需要注意的是,MAP_ANONYMOUS和MAP_ANON这两个宏是linux操作系统中特有的,类UNIX系统中无该宏定义,可以使用如下两步来完成匿名映射区的建立


fd = open("/dev/zero", O_RDWR);p = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, fd, 0);

linux匿名映射的例子如下:只能用于有血缘关系的进程间通信


 #include  #include  #include  #include  #include  #include  #include  #include  #include    // 全局变量 var int var = 100; int main(int argc, const char *argv[]){ int *p; pid_t pid; int ret = 0;  // 创建映射区-----TODO 匿名映射,大小随便指定,权限随便指定,fd用-1 p = (int *)mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) { perror("mmap error"); exit(1); }  // fork一个进程 pid = fork(); if (pid == 0) // 子进程 { *p = 2000; var = 1000; printf("child *p = %d, var = %d\n", *p, var); }else{ // 父进程  sleep(1); // 休眠一秒,让子进程先执行 printf("parent *p = %d, var = %d\n", *p, var); wait(NULL); // 回收子进程 // 释放共享内存 if (munmap(p, 4) == -1) {  perror("munmap error"); exit(1); }  } return 0; } 

类unix的例子


#include #include #include #include #include #include #include #include #include   // 全局变量 varint var = 100;int main(int argc, const char *argv[]){ int *p; pid_t pid; int ret = 0;  int fd; // 打开一个文件  TODO /dev/zero fd = open("/dev/zero", O_RDWR|O_TRUNC, 0644); if (fd < 0) { perror("open error"); exit(1); }  if (ret == -1) { perror("unlink error"); exit(1); } // 创建映射区 flags 加 MAP_ANONYMOUS p = (int *)mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, fd, 0); if (p == MAP_FAILED) { perror("mmap error"); exit(1); }  // 关闭fd,mmap创建成功后就可以关闭了,因为直接使用地址了,不需要fd了 close(fd);   // fork一个进程 pid = fork(); if (pid == 0) // 子进程 { *p = 2000; var = 1000; printf("child *p = %d, var = %d\n", *p, var); }else{ // 父进程  sleep(1); // 休眠一秒,让子进程先执行 printf("parent *p = %d, var = %d\n", *p, var); wait(NULL); // 回收子进程 // 释放共享内存 if (munmap(p, 4) == -1) { perror("munmap error"); exit(1); }  } return 0;}


声明: 本文转载自其它媒体或授权刊载,目的在于信息传递,并不代表本站赞同其观点和对其真实性负责,如有新闻稿件和图片作品的内容、版权以及其它问题的,请联系我们及时删除。(联系我们,邮箱:evan.li@aspencore.com )
0
评论
  • 相关技术文库
  • RF
  • 射频
  • 通信
  • 无线
下载排行榜
更多
评测报告
更多
EE直播间
更多
广告