您的位置:首页 >聚焦 >

今日热文:Linux黑科技|mmap实现详解

2022-07-26 18:45:12    来源:程序员客栈

故事的开始是这样的,某天在脉脉上看到有人发了下面的帖子:

想不到mmap都成了黑科技了,为了让大家都能了解这个黑科技,所以还是写篇文章来详细介绍一下mmap的实现吧。


(相关资料图)

其实,源码分析是比较难写的,主要有两个原因:

一方面是源码实现一般会涉及多个知识点,所以在分析源码时需要穿插多个知识点,从而增加分析的难度。另一方面是源码实现会处理很多细节问题,这些细节问题虽然不是设计的主要框架,但忽略了有时会让人摸不着头脑。

所以,为了降低分析的难度和让读者能够更容易看懂,在分析源码时更注重知识点的实现,而在不影响理解的情况下,我会忽略一些细节问题。而对于穿插其他知识点的时候,会先跳过其实现,并且在后续的文章对其进行分析。

mmap 原理

在之前的文章中,我们也介绍过mmap的原理,比如这篇:《原来 mmap 这么简单》。当然这篇文章只是简单介绍了mmap的原理,但是mmap的实现远不止那么简单,这是因为mmap涉及多个子系统,如:内存管理、文件系统、中断处理等。

好消息是,这几个子系统我们都有对应的文章介绍过:

内存管理:《Linux虚拟内存空间管理》文件系统:《什么是页缓存》中断处理:《Linux中断处理》

在阅读本文前,最好复习一下上面的文章。

虽然在《原来 mmap 这么简单》一文中,我们简单介绍过mmap的原理。但为了方便分析源码,下面还是简单回顾一下mmap的原理吧。

mmap的全称是memory map,中文意思是内存映射。其用途是将文件映射到内存中,然后可以通过对映射区的内存进行读写操作,其效果等同于对文件进行读写操作。

下面我们通过一幅图来对mmap的原理进行阐述:

从上图可以看出,mmap 的原理就是将虚拟内存空间映射到文件的页缓存,在《什么是页缓存》一文中可知,对文件进行读写时需要经过页缓存进行中转的。所以当虚拟内存地址映射到文件的页缓存后,就可以直接通过读写映射区内存来对文件进行读写操作。

mmap 实现

在分析mmap的实现前,最好先了解其使用方式,mmap的使用可以参考《原来 mmap 这么简单》这篇文章。

1. 文件映射

当我们使用mmap()系统调用对文件进行映射时,将会触发调用do_mmap_pgoff()内核函数来完成工作,我们来看看do_mmap_pgoff()函数的实现(经过精简后):

unsignedlongdo_mmap_pgoff(structfile*file,unsignedlongaddr,unsignedlonglen,unsignedlongprot,unsignedlongflags,unsignedlongpgoff){...//1.获取一个未被使用的虚拟内存区addr=get_unmapped_area(file,addr,len,pgoff,flags);if(addr&~PAGE_MASK)returnaddr;...//2.调用mmap_region()函数继续进行映射操作returnmmap_region(file,addr,len,flags,vm_flags,pgoff,accountable);}

经过精简后的do_mmap_pgoff()函数主要完成 2 个工作:

首先,调用get_unmapped_area()函数来获取进程没被使用的虚拟内存区,并且返回此内存区的首地址。然后,调用mmap_region()函数继续进行映射操作。

在 32 位的操作系统中,每个进程都有 4GB 的虚拟内存空间,应用程序在使用内存前,需要先向操作系统发起申请内存的操作。操作系统会从进程的虚拟内存空间中查找未被使用的内存地址,并且返回给应用程序。

操作系统会记录进程正在使用中的虚拟内存地址,如果内存地址没被登记,说明此内存地址是空闲的(未被使用)。

我们继续来看看mmap_region()函数的实现,代码如下(经过精简后):

unsignedlongmmap_region(structfile*file,unsignedlongaddr,unsignedlonglen,unsignedlongflags,unsignedintvm_flags,unsignedlongpgoff,intaccountable){structmm_struct*mm=current->mm;structvm_area_struct*vma,*prev;intcorrect_wcount=0;interror;...//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);...returnaddr;}

mmap_region()函数主要完成以下 4 件事情:

申请一个vm_area_struct结构(vma),内核使用 vma 来管理进程的虚拟内存地址,关于 vma 的详细介绍可以参考:《Linux虚拟内存空间管理》。设置 vma 结构各个字段的值。通过调用文件对象的mmap()回调函数来设置vma结构的fault()回调函数,一般文件对象的mmap()回调函数为:generic_file_mmap()。把新创建的 vma 结构连接到进程的虚拟内存区链表和红黑树中。

内核使用vm_area_struct结构来管理进程的虚拟内存地址。当进程需要使用内存时,首先要向操作系统进行申请,操作系统会使用vm_area_struct结构来记录被分配出去的内存区的大小、起始地址和权限等。

我们来看看vm_area_struct结构的定义:

structvm_area_struct{structmm_struct*vm_mm;unsignedlongvm_start;//内存区的开始地址unsignedlongvm_end;//内存区的结束地址structvm_area_struct*vm_next;//把进程所有已分配的内存区链接起来pgprot_tvm_page_prot;//内存区的权限...structrb_nodevm_rb;//为了加快查找内存区而建立的红黑树...structvm_operations_struct*vm_ops;//内存区的操作回调函数集unsignedlongvm_pgoff;structfile*vm_file;//如果映射到文件,将指向映射的文件对象...};structvm_operations_struct{//当虚拟内存区没有映射到物理内存地址时,将会触发缺页异常,//而在缺页异常处理函数中,将会调用此回调函数来对虚拟内存映射到物理内存。int(*fault)(structvm_area_struct*vma,structvm_fault*vmf);...};

当把文件映射到虚拟内存空间时,需要把vma结构的vm_file字段设置为要映射的文件对象,然后调用文件对象的mmap()回调函数来设置vma结构的fault()回调函数。

vma结构的fault()回调函数的作用是:当虚拟内存区没有映射到物理内存地址时,将会触发缺页异常。而在缺页异常处理中,将会调用此回调函数来对虚拟内存映射到物理内存。

我们来看看generic_file_mmap()函数是怎么设置vma结构的fault()回调函数的:

structvm_operations_structgeneric_file_vm_ops={.fault=filemap_fault,//将 fault()回调函数设置为:filemap_fault()};intgeneric_file_mmap(structfile*file,structvm_area_struct*vma){...vma->vm_ops=&generic_file_vm_ops;...return0;}

至此,文件映射的过程已经分析完毕。我们来看看其调用链:

sys_mmap()└→ do_mmap_pgoff()   └→ mmap_region()      └→ generic_file_mmap()

2. 缺页异常

前面介绍了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()函数的实现:

staticint__do_fault(structmm_struct*mm,structvm_area_struct*vma,unsignedlongaddress,pmd_t*pmd,pgoff_tpgoff,unsignedintflags,pte_torig_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);...}...returnret;}

__do_fault()函数对处理文件映射部分主要分为 3 个步骤:

调用虚拟内存管理区结构(vma)的fault()回调函数(也就是filemap_fault()函数)来获取到文件的页缓存。通过页缓存的物理内存页来生成一个页表项值,可以参考《漫画解说 “内存映射”》一文。将虚拟内存地址映射到页缓存的物理内存页(也就是将进程的页表项设置为上面生成的页表项的值)。

对于filemap_fault()函数是怎样读取文件页缓存的,本文不作解释,有兴趣的可以自行阅读源码。

最后,我们以一幅图来描述一下虚拟内存是如何与文件进行映射的:

从上图可以看出,mmap()是通过将虚拟内存地址映射到文件的页缓存来实现的。当对映射后的虚拟内存进行读写操作时,其效果等价于直接对文件的页缓存进行读写操作。对文件的页缓存进行读写操作,也等价于对文件进行读写操作。

关键词: 虚拟内存 回调函数 物理内存

相关阅读