mmap是linux操作系统提供给用户空间调用的内存映射函数,很多人仅仅只是知道可以通过mmap完成进程间的内存共享和减少用户态到内核态的数据拷贝次数,但是并没有深入理解mmap在操作系统内部是如何实现的,原理是什么。
本文想要和大家一起来聊聊mmap的原理,本文整体脉络如下:
这里的段页式内存管理主要基于linux 0.11进行讲解(作者本人并非主攻linux,所以只是对linux 0.11略有研究)
无论是现代操作系统还是最早的linux 0.11操作系统,在对于物理内存的管理,都是将物理内存按页划分,如下图所示:
按页划分的好处是可以避免内存碎片的产生。
物理内存按页划分是方便了操作系统管理内存,但是对于程序员来说,我们更希望看到的内存视图是类似于一个完整数组般:
并且由于一个完整的程序是分为了代码段,栈段和数据段的,当我们运行起这个程序时,该运行中的程序就被称为一个进程,我们更希望该进程下管理的程序数据在内存上是如下分布状态:
现在的问题就是站在程序员的视角,希望分段来管理内存,而操作系统更希望分页管理,现在就需要进行一波折中,也就像程序屏蔽底层对物理内存的分页管理,对外展示的内存外貌为一整块内存,这怎么办到呢?
这就需要采用段页式内存管理了,由于实际物理内存的管理还是需要分页管理,所以程序员视角看到的内存其实是一块虚拟内存,虚拟内存上的地址通过某种方式会映射到物理内存上的某一页的某块偏移地址上,而具体的映射方式采用的是多级页表的方式:
本文重点不在linux内存管理上,因此这部分内容不会细讲,如果想完整了解,可以看此篇文章:操作系统段页结合的实际内存管理–13
如何通过多级页表完成虚拟地址到物理页映射的,这里就不多展开了,想要完整了解的,看上面那篇文章。
对于linux 0.11而言,是把虚拟内存设置为了0-4G大小,而这块虚拟内存是被多个进程共享的,如下图所示:
因为每个进程的段空间不重叠,意味着各个进程的虚拟空间中的虚拟地址不会重叠,那么对应各个进程的虚拟地址解析得到的虚拟页号不会重叠,因此在linux 0.11中多个进程可以共享一套页表。
但是,对于现代32操作系统而言,每个进程都会单独占有4G虚拟内存,各个进程对应的页表是会产生重叠的,因此每个进程需要有自己的段表和页表。
mmap的实现不是基于linux 0.11的虚拟内存管理方式,而是更复杂的方式,这里大家需要记住上面标红的那段话。
mmap是一种内存映射的方法,这一功能可以用在文件的处理上,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系
。
在编程时可以使某个磁盘文件的内容看起来像是内存中的一个数组。如果文件由记录组成,而这些记录又能够用结构体来描述的话,可以通过访问结构数组来更新文件的内容。
实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如图所示:
进程的虚拟地址空间,由多个虚拟内存区域构成。
虚拟内存区域是进程的虚拟地址空间中的一个同质区间,即具有同样特性的连续地址范围。上图中所示的text数据段(代码段)、初始数据段、BSS数据段、堆、栈和内存映射,都是一个独立的虚拟内存区域。
而为内存映射服务的地址空间处在堆栈之间的空余部分。
这里说的地址都是当前进程享有的一块完整的虚拟内存中的地址
内核为系统中的每个进程维护一个单独的任务结构(task_struct)。任务结构中的元素包含或者指向内核运行该进程所需的所有信息(PID、指向用户栈的指针、可执行目标文件的名字、程序计数器等)。Linux内核使用vm_area_struct结构来表示一个独立的虚拟内存区域,由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。各个vm_area_struct结构使用链表或者树形结构链接,方便进程快速访问,如下图所示:
这里可以简单将vm_area_struct结构体看做是描述当前进程内某个段信息的载体,例如: 当前段位于当前进程虚拟内存中哪段虚拟地址范围,访问标志啥的…
vm_area_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm_area_struct中获得。mmap函数就是要创建一个新的vm_area_struct结构,并将其与文件的物理磁盘地址相连。
mm_struct:描述了虚拟内存的当前状态。pgd指向一级页表的基址(当内核运行这个进程时,pgd会被存放在CR3控制寄存器,也就是页表基址寄存器中),mmap指向一个vm_area_structs的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域。vm_starts指向这个区域的起始处。vm_end指向这个区域的结束处。vm_prot描述这个区域内包含的所有页的读写许可权限。vm_flags描述这个区域内的页面是与其他进程共享的,还是这个进程私有的以及一些其他信息
。vm_next指向链表的下一个区域结构。
mmap内存映射的实现过程,总的来说可以分为三个阶段:
(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
第8步就是将在虚拟地址空间申请的那片虚拟地址和实际物理页建立映射关系
(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
这其实也算是一种懒加载思想的体现
注意: 修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。
mmap (内存映射)函数的作用是建立一段可以被两个或更多个程序读写的内存。一个程序对它所做出的修改可以被其他程序看见。这要通过使用带有特殊权限集的虚拟内存段来实现。对这类虚拟内存段的读写会使操作系统去读写磁盘文件中与之对应的部分。 mmap 函数创建一个指向一段内存区域的指针,该内存区域与可以通过一个打开的文件描述符访问的文件的内容相关联。mmap 函数原型如下:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
msync 函数的作用是:把在该内存段的某个部分或整段中的修改写回到被映射的文件中(或者从被映射文件里读出)。
#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);
内存段需要修改的部分由作为参数传递过来的起始地址 addr 和长度 len 确定。flags 参数控制着执行修改的具体方式,可以使用的选项如下:
munmap 函数的作用是释放内存段:
#include <sys/mman.h>
int munmap(void *addr, size_t length);
mmap 的全称是 memory map,中文意思是 内存映射。其用途是将文件映射到内存中,然后可以通过对映射区的内存进行读写操作,其效果等同于对文件进行读写操作。
下面我们通过一幅图来对 mmap 的原理进行阐述:
从上图可以看出,mmap 的原理就是将虚拟内存空间映射到文件的页缓存,我们可以知道:对文件进行读写时需要经过页缓存进行中转的。所以当虚拟内存地址映射到文件的页缓存后,就可以直接通过读写映射区内存来对文件进行读写操作。
mmap 实现:
当我们使用 mmap() 系统调用对文件进行映射时,将会触发调用 do_mmap_pgoff() 内核函数来完成工作,我们来看看 do_mmap_pgoff() 函数的实现(经过精简后):
unsigned long
do_mmap_pgoff(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, unsigned long pgoff)
{
...
// 1. 获取一个未被使用的虚拟内存区
addr = get_unmapped_area(file, addr, len, pgoff, flags);
if (addr & ~PAGE_MASK)
return addr;
...
// 2. 调用 mmap_region() 函数继续进行映射操作
return mmap_region(file, addr, len, flags, vm_flags, pgoff, accountable);
}
经过精简后的 do_mmap_pgoff()
函数主要完成 2 个工作:
get_unmapped_area()
函数来获取进程没被使用的虚拟内存区,并且返回此内存区的首地址。mmap_region()
函数继续进行映射操作。在 32 位的操作系统中,每个进程都有 4GB 的虚拟内存空间,应用程序在使用内存前,需要先向操作系统发起申请内存的操作。操作系统会从进程的虚拟内存空间中查找未被使用的内存地址,并且返回给应用程序。 操作系统会记录进程正在使用中的虚拟内存地址,如果内存地址没被登记,说明此内存地址是空闲的(未被使用)。
我们继续来看看 mmap_region()
函数的实现,代码如下(经过精简后):
unsigned long
mmap_region(struct file *file, unsigned long addr,
unsigned long len, unsigned long flags,
unsigned int vm_flags, unsigned long pgoff,
int accountable)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma, *prev;
int correct_wcount = 0;
int error;
...
// 1. 申请一个虚拟内存区管理结构(vma)
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
...
// 2. 设置vma结构各个字段的值
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = protection_map[vm_flags & (VM_READ|VM_WRITE|VM_EXEC|VM_SHARED)];
vma->vm_pgoff = pgoff;
if (file) {
...
vma->vm_file = file;
/* 3. 此处是内存映射的关键点,调用文件对象的 mmap() 回调函数来设置vma结构的 fault() 回调函数。
* vma对象的 fault() 回调函数的作用是:
* - 当访问的虚拟内存没有映射到物理内存时,
* - 将会调用 fault() 回调函数对虚拟内存地址映射到物理内存地址。
*/
error = file->f_op->mmap(file, vma);
...
}
...
// 4. 把 vma 结构连接到进程虚拟内存区的链表和红黑树中。
vma_link(mm, vma, prev, rb_link, rb_parent);
...
return addr;
}
mmap_region()
函数主要完成以下 4 件事情:
vm_area_struct
结构(vma),内核使用 vma 来管理进程的虚拟内存地址mmap()
回调函数来设置vma结构的 fault()
回调函数,一般文件对象的 mmap()
回调函数为:generic_file_mmap()
。内核使用 vm_area_struct
结构来管理进程的虚拟内存地址。当进程需要使用内存时,首先要向操作系统进行申请,操作系统会使用 vm_area_struct
结构来记录被分配出去的内存区的大小、起始地址和权限等。
我们来看看 vm_area_struct
结构的定义:
struct vm_area_struct {
struct mm_struct *vm_mm;
unsigned long vm_start; // 内存区的开始地址
unsigned long vm_end; // 内存区的结束地址
struct vm_area_struct *vm_next; // 把进程所有已分配的内存区链接起来
pgprot_t vm_page_prot; // 内存区的权限
...
struct rb_node vm_rb; // 为了加快查找内存区而建立的红黑树
...
struct vm_operations_struct *vm_ops; // 内存区的操作回调函数集
unsigned long vm_pgoff;
struct file *vm_file; // 如果映射到文件,将指向映射的文件对象
...
};
struct vm_operations_struct {
// 当虚拟内存区没有映射到物理内存地址时,将会触发缺页异常,
// 而在缺页异常处理函数中,将会调用此回调函数来对虚拟内存映射到物理内存。
int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf);
...
};
当把文件映射到虚拟内存空间时,需要把 vma
结构的 vm_file
字段设置为要映射的文件对象,然后调用文件对象的 mmap()
回调函数来设置 vma
结构的 fault()
回调函数。
vma
结构的fault()
回调函数的作用是:当虚拟内存区没有映射到物理内存地址时,将会触发缺页异常。而在缺页异常处理中,将会调用此回调函数来对虚拟内存映射到物理内存。
我们来看看 generic_file_mmap()
函数是怎么设置 vma
结构的 fault()
回调函数的:
struct vm_operations_struct generic_file_vm_ops = {
.fault = filemap_fault, // 将 fault() 回调函数设置为:filemap_fault()
};
int generic_file_mmap(struct file *file, struct vm_area_struct *vma)
{
...
vma->vm_ops = &generic_file_vm_ops;
...
return 0;
}
至此,文件映射的过程已经分析完毕。我们来看看其调用链:
sys_mmap()
└→ do_mmap_pgoff()
└→ mmap_region()
└→ generic_file_mmap()
前面介绍了 mmap()
系统调用的处理过程,可以发现 mmap()
只是将 vma
的 vm_file
字段设置为被映射的文件对象,并且将 vma
的 fault()
回调函数设置为 filemap_fault()
。也就是说,mmap()
系统调用并没有对虚拟内存进行任何的映射操作。
虚拟内存必须映射到物理内存才能使用。如果访问没有映射到物理内存的虚拟内存地址,CPU 将会触发缺页异常。也就是说,虚拟内存并不能直接映射到磁盘中的文件。
那么 mmap() 是怎么将文件映射到虚拟内存中呢?
读写文件时并不是直接对磁盘上的文件进行操作的,而是通过 页缓存
作为中转的,而页缓存就是物理内存中的内存页。所以,mmap()
可以通过将文件的页缓存映射到虚拟内存空间来实现对文件的映射。
但我们在 mmap()
系统调用的实现中,也没看到将文件页缓存映射到虚拟内存空间。那么映射过程是在什么时候发生的呢?
答案就是:缺页异常。
由于 mmap()
系统调用并没有直接将文件的页缓存映射到虚拟内存中,所以当访问到没有映射的虚拟内存地址时,将会触发 缺页异常
。当 CPU 触发缺页异常时,将会调用 do_page_fault()
函数来修复触发异常的虚拟内存地址。
我们主要来看看 do_page_fault()
函数对文件映射的实现部分,其调用链如下:
do_page_fault()
└→ handle_mm_fault()
└→ handle_pte_fault()
└→ do_linear_fault()
└→ __do_fault()
所以我们直接来看看 __do_fault()
函数的实现:
static int
__do_fault(struct mm_struct *mm, struct vm_area_struct *vma,
unsigned long address, pmd_t *pmd, pgoff_t pgoff,
unsigned int flags, pte_t orig_pte)
{
...
vmf.virtual_address = address & PAGE_MASK; // 要映射的虚拟内存地址
vmf.pgoff = pgoff; // 映射到文件的偏移量
vmf.flags = flags; // 标志位
vmf.page = NULL; // 映射到虚拟内存中的物理内存页
// 1. 如果虚拟内存管理区提供了 falut() 回调函数,那么将调用此函数来获取要映射的物理内存页,
// 我们在 mmap() 系统调用的实现中看到,已经将其设置为 filemap_fault() 函数了。
if (likely(vma->vm_ops->fault)) {
ret = vma->vm_ops->fault(vma, &vmf);
...
}
...
if (likely(pte_same(*page_table, orig_pte))) {
...
// 2. 通过物理内存页生成一个页表项值(可以参考内存映射一文)
entry = mk_pte(page, vma->vm_page_prot);
if (flags & FAULT_FLAG_WRITE)
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
// 3. 将虚拟内存地址映射到物理内存(也就是将进程的页表项设置为刚生成的页表项的值)
set_pte_at(mm, address, page_table, entry);
...
}
...
return ret;
}
__do_fault()
函数对处理文件映射部分主要分为 3 个步骤:
fault()
回调函数(也就是 filemap_fault()
函数)来获取到文件的页缓存。对于 filemap_fault()
函数是怎样读取文件页缓存的,本文不作解释,有兴趣的可以自行阅读源码。
最后,我们以一幅图来描述一下虚拟内存是如何与文件进行映射的:
从上图可以看出,mmap()
是通过将虚拟内存地址映射到文件的页缓存来实现的。当对映射后的虚拟内存进行读写操作时,其效果等价于直接对文件的页缓存进行读写操作。对文件的页缓存进行读写操作,也等价于对文件进行读写操作。
常规文件调用过程:
像 read()/write() 这些系统调用,首先需要进入内核空间,然后把文件内容读入到缓存中,然后再对缓存进行读写操作,最后由内核定时同步到文件中。
总结来说,常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的buffer在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。
而使用mmap操作文件中,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。
总而言之,常规文件操作需要从磁盘到页缓存再到用户主存的两次数据拷贝。而mmap操控文件,只需要从磁盘到页Buffer的一次数据拷贝过程。说白了,mmap的关键点是实现了用户空间虚拟地址直接映射到页Buffer物理页上,从而实现用户空间无需进入内核即可访问页Buffer。因此mmap效率更高。
调用 mmap() 系统调用对文件进行映射后,用户对映射后的内存进行读写实际上是对文件缓存的读写,所以减少了一次系统调用,从而加速了对文件读写的效率。 如果当前进程拥有的虚拟地址空间中,存在某部分区域的虚拟地址是直接解析到内核空间中页buffer拥有的物理地址上的,也就是当前进程内存在一部分虚拟地址空间和内核空间中页buffer对应的虚拟地址空间映射到了同一块物理地址上,如下图:
那么后续通过DMA从磁盘将文件数据加载到页Buffer Pool后,进程一不就直接可以通过自身内部那段虚拟地址空间直接获取到文件数据了吗?
对于常规的文件读写方式来说,由于对应的进程在用户空间中没有那段直接映射到物理地址中页buffer存储位置的虚拟空间,所以就无法在用户空间内直接访问到内核空间中的页buffer,就必须使用系统调用进行访问了。
由上文讨论可知,mmap优点共有一下几点:
同时,如果进程A和进程B都映射了区域C,当A第一次读取C时通过缺页从磁盘复制文件页到内存中;但当B再读C的相同页面时,虽然也会产生缺页异常,但是不再需要从磁盘中复制文件过来,而可直接使用已经保存在内存中的文件数据。
其特点为:
其特点为:
总结: