对操作系统中的各种缓存进行一下梳理:
(一)高速缓冲存储器cache
1、cache的工作原理
高速缓冲存储器利用程序访问的局部性原理,把程序中正在使用的部分存放在一个高速的、容量较小的cache中,使CPU的访存操作大多数针对cache进行,从而使程序的执行速度大大提高。
当CPU发出读请求时,如果访存地址在cache中命中,就将此地址转换成cache地址,直接对cache进行读操作,与主存无关;如果cache不命中,则仍需访问主存,将此字所在的块一次从主存调入cache中。若此时cache已满,则需根据某种替换算法(如最久未使用算法(LRU)、先进先出算法(FIFO)、最近最少使用算法(LFU)、非最近使用算法(NMRU)等),用这个块替换掉cache中原来的某块信息。值得注意的是,CPU与cache之间的数据交换以字为单位,而cache和主存之间的数据交换则是以cache块为单位的。
CPU对cache的写入更改了cache的内容,故需选择某种策略使得cache内容和主存内容保持一致,主要为:
全写法(cache-through):当CPU对cache写命中时,必须把数据同时写入cache和主存。当某一块需要替换时,不必把这一块写回主存,将新调入的块直接覆盖即可。这种方法实现简单,能随时保持主存数据的正确性,缺点是增加了访存次数,降低了cache的效率。
写回法(cache-back):当CPU对cache写命中时,只修改cache的内容,而不立即写入主存,只有当此块被换出时才写回主存。这种方法减少了访存次数,但存在不一致的隐患。采用这种策略时,每个cache行必须设置一个标志位(脏位),以反映此块是否被CPU修改过。
如果写不命中,还需考虑是否调块至cache的问题,非写分配法只写入内存,不进行调块,非写分配法通常与全写法合用。写分配法除了要写入主存外,还要将该块从主存调入至cache,通常与写回法合用。
下图转载自: https://blog.csdn.net/wangwei222/article/details/79748597
2、cache和主存的映射方式
(1)直接映射: 主存数据块只能装入cache中的唯一位置。若这个位置已有内容,则产生块冲突,原来的块被无条件地替换出去(无需使用替换算法)。直接映射实现简单,但不够灵活。直接映射的关系可定义为:
j= i mod c // j为cache块号,i为主存块号,c是cache中的总块数
下图转载自: https://www.cnblogs.com/yutingliuyl/p/6773684.html
(2)全相联映射:可以把主存数据块放入cache的任何位置,比较灵活,冲突概率低,但地址变换速度慢,实现成本高,查找时需全部遍历一遍。下图中左边为主存,右边为cache
(3)组相联映射:将cache分成大小相同的组,主存中的一个数据块可以装入到一个组内的任何一个位置,即组间采取直接映射,组内采用全相联,每组有多少块就称为几路组相联,可用下式表示:
j = i mod q // j为缓存块号, i为主存块号, q为cache组数,当q为1时为全相联映射, q为cache块数时为直接映射
图源: https://www.cnblogs.com/jasmine-Jobs/p/6959261.html
最后贴几个关于cache的层级:细说Cache-L1/L2/L3 为什么CPU缓存会分为一级缓存L1、L2、L3 CPU缓存刷新
从L1到L3,容量越来越大,速度越来越慢,L1更加注重速度, L2和L3则更加注重节能和容量。
关于缓存行:CPU cache和缓存行 CPU cache
(二)地址转换后援缓冲器TLB
下述内容参考:https://www.cnblogs.com/alantu2018/p/9000777.html
上述cache为在得到物理地址之后,对访问内存的一个加速,而系统虚拟地址需要通过页表转换为物理地址,页表一般都很大,并且存放在内存中,所以处理器引入MMU后,读取指令、数据需要访问两次内存:首先通过查询页表得到物理地址,然后访问该物理地址读取指令、数据。为了减少因为MMU导致的处理器性能下降,引入了TLB,TLB是Translation Lookaside Buffer的简称,可翻译为“地址转换后援缓冲器”,也可简称为“快表”。简单地说,TLB就是页表的Cache,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。只有在TLB无法完成地址翻译任务时,才会到内存中查询页表(还有的说是查找时快表和慢表,即放在主存中的页表同时进行,若快表中有此逻辑页号,则将其对应的物理页号送入主存地址寄存器,并使慢表的查找作废),这样就减少了页表查询导致的处理器性能下降。
TLB中的项由两部分组成:标识和数据。标识中存放的是虚地址的一部分,而数据部分中存放物理页号、存储保护信息以及其他一些辅助信息。虚地址与TLB中项的映射方式有三种:全关联方式、直接映射方式、分组关联方式。直接映射方式是指每一个虚拟地址只能映射到TLB中唯一的一个表项。假设内存页大小是8KB,TLB中有64项,采用直接映射方式时的TLB变换原理如下图所示:
因为页大小是8KB,所以虚拟地址的0-12bit作为页内地址偏移。TLB表有64项,所以虚拟地址的13-18bit作为TLB表项的索引。假如虚拟地址的13-18bit是1,那么就会查询TLB的第1项,从中取出标识,与虚拟地址的19-31位作比较,如果相等,表示TLB命中,反之,表示TLB失靶。TLB失靶时,可以由硬件将需要的页表项加载入TLB,也可由软件加载,具体取决于处理器设计。TLB命中时,此时翻译得到的物理地址就是TLB第1项中的标识(即物理地址13-31位)与虚拟地址0-12bit的结合。在地址翻译的过程中还会结合TLB项中的辅助信息判断是否发生违反安全策略的情况,比如:要修改某一页,但该页是禁止修改的,此时就违反了安全策略,会触发异常。
TLB表项更新可以有TLB硬件自动发起,也可以有软件主动更新
1. TLB miss发生后,CPU从RAM获取页表项,会自动更新TLB表项
2. TLB中的表项在某些情况下是无效的,比如进程切换,更改内核页表等,此时CPU硬件不知道哪些TLB表项是无效的,只能由软件在这些场景下,刷新TLB。
在linux kernel软件层,提供了丰富的TLB表项刷新方法,但是不同的体系结构提供的硬件接口不同。比如x86_32仅提供了两种硬件接口来刷新TLB表项:
1. 向cr3寄存器写入值时,会导致处理器自动刷新非全局页的TLB表项
2. 在Pentium Pro以后,invlpg汇编指令用来使指定线性地址的单个TLB表项无效。
TLB内部存放的基本单位是TLB表项,TLB容量越大,所能存放的TLB表项就越多,TLB命中率就越高,但是TLB的容量是有限的。目前 Linux内核默认采用4KB大小的小页面,如果一个程序使用512个小页面,即2MB大小,那么至少需要512个TLB表项才能保证不会出现 TLB Miss的情况。但是如果使用2MB大小的大页,那么只需要一个TLB表项就可以保证不会出现 TLB Miss的情况。对于消耗内存以GB为单位的大型应用程序,还可以使用以1GB为单位的大页,从而减少 TLB Miss的情况。
(三)页缓存和块缓存
上述缓存机制为所需数据已经在物理内存中,而页缓存和块缓存则是在进行磁盘io时使用的。
(1)页缓存:针对以页为单位的所有操作,页缓存实际上负责了块设备的大部分缓存工作。页缓存的任务在于获得一些物理内存页,以加速在块设备上按页为单位执行的操作。
(2)块缓存:以块为操作单位,在进行io操作时,存取的单位是设备的各个块而不是整个内存页,尽管也长度对所有文件系统是相同的,但是块长度取决于特定的文件系统或其设置,因而块缓存必须能够处理不同长度的块。
1、页缓存
此部分内容来自:https://blog.csdn.net/gdj0001/article/details/80136364 及《深入Linux内核架构》
页缓存是Linux内核一种重要的磁盘高速缓存,以页为大小进行数据缓存,它将磁盘中最常用和最重要的数据存放到部分物理内存中,使得系统访问块设备时可以直接从主存中获取块设备数据,而不需从磁盘中获取数据。在大多数情况下,内核在读写磁盘时都会使用页缓存。内核在读文件时,首先在已有的页缓存中查找所读取的数据是否已经存在。如果该页缓存不存在,则一个新的页将被添加到缓存中,然后用从磁盘读取的数据填充它。如果当前物理内存足够空闲,那么该页将长期保留在缓存中,使得其他进程再使用该页中的数据时不再访问磁盘。写操作与读操作时类似,直接在页缓存中修改数据,但是页缓存中修改的数据(该页此时被称为Dirty Page)并不是马上就被写入磁盘,而是延迟几秒钟,以防止进程对该页缓存中的数据再次修改。
(1)管理和查找缓存的页:
Linux使用基数树来管理页缓存中包含的页,基数树是不平衡的,在树的不同分支之间可能有任意数目的高度差。树本身由两种不同的数据结构组成,叶子是page结构的实例,源代码中叶子使用的是void指针,意味着基数树还可以用于其他目的。树的结点具备两种搜索标记,二者用于指定给定页当前是否是脏的,或该页是否正在向底层块设备回写。标记不仅对叶节点设置,还一直向上设置到根节点。如果某个层次n+1的结点设置了某个标记,其在层次n的父节点也会获得该标记。使内核可以判断,在某个范围内是否有一页或多页设置了某个标记位。
(2)回写修改的数据:
内核同时提供了如下几个同步方案:
- 几个专门的内核守护进程在后台运行,称为pdflush,它们将周期性激活而不考虑页缓存中当前的情况,这些守护进程扫描缓存中的页,将超出一定时间没有与底层块设备同步的页写回。
- pdflush的第二种运作模式是:如果缓存中修改的数据项数目在短期内显著增加,则由内核激活pdflush
- 提供相关的系统调用。可由用户或应用程序通知内核写回所有未同步的数据,如sync调用
通常,修改文件或其他按页缓存的对象时,只会修改页的一部分。为节省时间,内核在写操作期间,将缓存中的每一页划分为较小的单位,称为缓冲区。在同步数据时,内核可以将回写操作限制于那些实际发生了修改的较小单位上。
(3)地址空间address_space
为管理可以按整页处理和缓存的各种不同对象,内核使用了address_space地址空间来抽象,将内存中的页和特定的块设备(或任何其他系统单元或系统单元的一部分)关联起来。每个地址空间都有一个“宿主”作为其数据来源,大多数情况下宿主都是表示一个文件的iNode,因为所有现存的iNode都关联到其超级块,内核只需要扫描所有超级块的链表,并跟随相关的iNode即可获得被缓存页的列表。
地址空间实现了内存页面和后备存储器两个单元之间的一种转换机制:
- 内存中的页分配到每个地址空间。这些页的内容可以由用户进程或内核本身使用各式各样的方法操作,这些数据表示了缓存的内容。
- 后备存储器指定了填充地址空间页的数据的来源。地址空间关联到处理器的虚拟地址空间,是由处理器在虚拟内存中管理的一个区域到源设备上对应位置之间的一个映射,如果访问了虚拟内存中的某个位置,该位置没有关联到物理内存页,内核可根据地址空间结构来找到读取数据的来源。
内核中每个存放数据的物理页帧都对应一个管理结构体struct page,如下:
struct page { /*flags描述page当前的状态和其他信息,如当前的page是否是脏页PG_dirty;是否是最新的已经同步到后备存储的页PG_uptodate; 是否处于lru链表上等*/ unsigned long flags; /*_count:引用计数,标识内核中引用该page的次数,如果要操作该page,引用计数会+1,操作完成之后-1。当该值为0时,表示没有引用该page的位置,所以该page可以被解除映射,这在内存回收的时候是有用的*/ atomic_t _count; /*mapcount:页表被映射的次数,也就是说page同时被多少个进程所共享,初始值为-1,如果只被一个进程的页表映射了,该值为0。
注意区分_count和_mapcount,_mapcount表示的是被映射的次数,而_count表示的是被使用的次数;被映射了不一定被使用,但是被使用之前肯定要先被映射*/ atomic_t _mapcount; unsigned long private;//私有数据指针 /*_mapping有三种含义: a.如果mapping = 0,说明该page属于交换缓存(swap cache); 当需要地址空间时会指定交换分区的地址空间swapper_space; b.如果mapping != 0, bit[0] = 0, 说明该page属于页缓存或者文件映射,mapping指向文件的地址空间address_space; c.如果mapping != 0, bit[0] !=0 说明该page为匿名映射,mapping指向struct anon_vma对象;*/ struct address_space *mapping; pgoff_t index; //在页缓存中的索引 struct list_head lru;//当page被用户态使用或者是当做页缓存使用的时候,将该page连入zone中的lru链表,供内存回收使用 void* virtual; };
page结构体中index和mapping分别为在页缓存中的索引和指向页所述地址空间的指针,在系统需要判断一个页是否已经缓存时,使用函数find_get_page(), 该函数根据mapping和index进行查找。而页是属于文件的,文件中的位置是按字节偏移量指定的,而非页缓存中的偏移量,两者之间的转换通过如下实现:
index = ppos >> PAGE_CACHE_SHIFT //ppos是文件的字节偏移量,而index是页缓存中对应的偏移量
Linux使用基数树管理页缓存中的页,一个文件在内存中具有唯一的inode结构标识,inode结构中有该文件所属的设备及其标识符,因而,根据一个inode能够确定其对应的后备设备。address_space将文件在物理内存中的页缓存和文件及其后备设备关联起来,可以说address_space结构体是将页缓存和文件系统关联起来的桥梁,其组成如下:
struct address_space { struct inode* host;/*指向与该address_space相关联的inode节点,inode节点与address_space之间是一一对应关系*/ struct radix_tree_root page_tree;/*所有页形成的基数树根节点*/ spinlock_t tree_lock;/*保护page_tree的自旋锁*/ unsigned int i_map_writable;/*VM_SHARED的计数*/ struct prio_tree_root i_mmap; struct list_head i_map_nonlinear; spinlock_t i_map_lock;/*保护i_map的自旋锁*/ atomic_t truncate_count;/*截断计数*/ unsigned long nrpages;/*页总数*/ pgoff_t writeback_index;/*回写的起始位置*/ struct address_space_operation* a_ops;/*操作表*/ unsigned long flags;/*gfp_mask掩码与错误标识*/ struct backing_dev_info* backing_dev_info;/*预读信息*/ spinlock_t private_lock;/*私有address_space锁*/ struct list_head private_list;/*私有address_space链表*/ struct address_space* assoc_mapping;/*相关的缓冲*/ }
一个文件inode对应一个地址空间address_space,而一个address_space对应一个页缓存基数树。address_space中host成员指向其所有者的iNode结点,page_tree为该inode结点对应文件的所有页的基数树,i_mmap为与该地址空间相关联的所有进程的虚拟地址区间vm_area_struct所对应的整个进程地址空间mm_struct形成的优先查找树的根节点;vm_area_struct中如果有后备存储,则存在prio_tree_node结构体,通过该prio_tree_node和prio_tree_root结构体,构成了所有与该address_space相关联的进程的一棵优先查找树,便于查找所有与该address_space相关联的进程,页缓存、文件和进程的关系如下:
2、关于mmap
说到页缓存再说一下mmap的问题,一直看到说mmap文件映射可实现像操作普通内存一样操作文件,这就让我想mmap映射的文件到底有没有映射进内存,答案应该是有的,用户进程应该不会直接操作磁盘上的文件数据,还是需要将其拷贝至内存中。那么第二个问题就是,都说普通文件读写需要复制两次,而内存映射文件mmap只需复制一次,至于原因,看到的解释很多是这样的:普通的读写文件为:进程调用read或是write后会陷入内核,因为这两个函数都是系统调用,进入系统调用后,内核开始读写文件,假设内核在读取文件,内核首先把文件读入自己的内核空间,读完之后进程在内核回归用户态,内核把读入内核内存的数据再copy进入进程的用户态内存空间。实际上我们同一份文件内容相当于读了两次,先读入内核空间,再从内核空间读入用户空间。而mmap的作用是将进程的虚拟地址空间和文件在磁盘的位置做一一映射,做映射之后,读写文件虽然同样是调用read和write但是触发的机制已经不一样了,mmap会返回来一个指针,指向进程逻辑地址空间的一个位置。这个时候的过程是这样的,首先read会改写为读内存操作,读内存的时候,系统发现该地址对应的物理内存是空的,触发缺页机制,然后再通过mmap建立的映射关系,从硬盘上将文件读入物理内存。也就是说mmap把文件直接映射到了用户空间,没有经历内核空间。
对于上述解释,之前一直在想普通的文件读写为什么要把数据从内核空间拷贝至用户空间,而不直接拷贝到用户空间映射的物理页面,想说是系统调用在内核处理,内核很自然地就把数据拷贝到其对应的物理页面了,然后由于用户空间不能访问内核空间的数据(针对内核保护,所以用户空间的虚拟地址应该不能直接映射到内核地址所映射的物理页面),所以需要把数据拷贝至用户空间,那么mmap也在内核实现,为什么可以直接把数据拷贝到用户空间映射的物理页面而不经过内核空间呢。
然后这两天看到了页缓存,又看到另一个对mmap的解释:所有的文件内容的读取(无论一开始是命中页缓存还是没有命中页缓存)最终都是直接来源于页缓存。当将数据从磁盘复制到页缓存之后,还要将页缓存的数据通过CPU复制到read调用提供的缓冲区中,这就是普通文件IO需要的两次复制数据复制过程。其中第一次是通过DMA的方式将数据从磁盘复制到页缓存中,本次过程只需要CPU在一开始的时候让出总线、结束之后处理DMA中断即可,中间不需要CPU的直接干预,CPU可以去做别的事情;第二次是将数据从页缓存复制到进程自己的的地址空间对应的物理内存中,这个过程中需要CPU的全程干预,浪费CPU的时间和额外的物理内存空间。普通文件io后进程地址空间对应如下,图源水印:
而通过内存映射IO---mmap,进程不但可以直接操作文件对应的物理内存,减少从内核空间到用户空间的数据复制过程,同时可以和别的进程共享页缓存中的数据,达到节约内存的作用。当映射一个文件到内存中的时候,内核将虚拟地址直接映射到页缓存中。如果文件的内容不在物理内存中,操作系统不会将所映射的文件部分的全部内容直接拷贝到物理内存中,而是在使用虚拟地址访问物理内存的时候通过缺页异常将所需要的数据调入内存中。如果文件本身已经存在于页缓存中,则不再通过磁盘IO调入内存。如果采用共享映射的方式,那么数据在内存中的布局如下图所示:
按照上述解释,所有文件内容读取都是来源于页缓存,那么mmap也就不是像第一种解释所说的将文件内容映射至用户空间对应的物理内存(其实要是说用户空间映射的物理内存就是页缓存的话也可以这么说),而是将文件内容拷贝至页缓存,然后用户空间虚拟地址直接映射到缓存页对应的物理地址。那么问题就是:既然都可以直接映射到页缓存了,为什么普通的文件读写非要进行将数据从页缓存拷贝到用户空间映射的物理页面的过程呢,对此我认为是:由于页缓存的大小总是有限的,而其中缓存的一般而言都是最近读写的文件内容,那么如果所有文件io都映射到页缓存的话,缓存的换入换出肯定十分频繁,这样的话就违背他作为缓存的本来目的了,因此需要将其拷贝至其他内存,即用户空间对应的物理页面中存放,这样下一次有新的文件读入时可以有位置将其加入缓存中,而之前用户空间读入的文件内容也存放在内存中,减少页缓存的换入换出。同时考虑到复制消耗的CPU及内存,又通过 mmap的方式提供用户空间对页缓存的直接映射。上述仅为我个人的理解与想法,如果看到这篇博客的人有不同的想法,还请告知。
今天看了源码和使用之后,从使用的角度进行了思考:我们使用mmap的时候,系统会首先分配一段虚拟内存,并且在该过程中建立了vma和映射文件的关联,向上返回虚拟内存的首地址,此时还没有建立和物理内存的映射关系。等到第一次访问虚拟地址时,发生缺页异常,将对应的文件内容读取到页缓存,并建立虚拟内存和页缓存的映射关系,如此只有一次将文件数据读取页缓存的过程。然而在使用read系统调用时,我们一般会先分配一个缓冲区buffer用于存放读取的文件内容,然后调用read系统调用,这里涉及到另一个问题:我在程序里动态分配的这个buffer此时是不是已经分配了物理内存,即动态内存何时分配相应的物理内存(也是第一次访问的时候?),如果在执行系统调用时buffer已经建立了物理映射,read系统调用类似地读取文件内容到页缓存中,由于buffer已经建立了到物理内存的映射,此时无法也没必要将其再映射到页缓存中,那么就需要将数据从页缓存拷贝至buffer已经映射的物理内存中,这样就多了一次将数据从内核空间拷贝至用户空间的过程。
为了了解mmap的方式,去看了下mmap部分的源码实现, 部分内容参考:https://blog.csdn.net/SweeNeil/article/details/83685812 mmap系列及 https://www.cnblogs.com/jikexianfeng/articles/5647994.html
源码基于3.10.1
系统调用mmap在内核中的实现为:
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len, unsigned long, prot, unsigned long, flags, unsigned long, fd, off_t, offset) { if (offset & ((1 << PAGE_SHIFT) - 1)) //判断offset是否对齐到页大小 return -EINVAL; return sys_mmap_pgoff(addr, len, prot, flags, fd, offset >> PAGE_SHIFT); }
可以看到mmap又调用了mmap_pgoff:
SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len, unsigned long, prot, unsigned long, flags, unsigned long, fd, unsigned long, pgoff) { struct file *file = NULL; unsigned long retval = -EBADF; if (!(flags & MAP_ANONYMOUS)) {//文件映射 audit_mmap_fd(fd, flags); if (unlikely(flags & MAP_HUGETLB)) return -EINVAL; file = fget(fd); //获取file结构体 if (!file) goto out; if (is_file_hugepages(file)) len = ALIGN(len, huge_page_size(hstate_file(file))); //调整len } else if (flags & MAP_HUGETLB) {//匿名映射大页内存 struct user_struct *user = NULL; struct hstate *hs = hstate_sizelog((flags >> MAP_HUGE_SHIFT) & SHM_HUGE_MASK); if (!hs) return -EINVAL; len = ALIGN(len, huge_page_size(hs)); /* * VM_NORESERVE is used because the reservations will be * taken when vm_ops->mmap() is called * A dummy user value is used because we are not locking * memory so no accounting is necessary */ file = hugetlb_file_setup(HUGETLB_ANON_FILE, len, VM_NORESERVE, &user, HUGETLB_ANONHUGE_INODE, (flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK); if (IS_ERR(file)) return PTR_ERR(file); } flags &= ~(MAP_EXECUTABLE | MAP_DENYWRITE); retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);//最终调用该函数完成映射 if (file) fput(file); out: return retval; }
mmap_pgoff完成部分准备工作,然后调用函数vm_mmap_pgoff,该函数对进程的虚拟地址空间进行映射:
unsigned long vm_mmap_pgoff(struct file *file, unsigned long addr, unsigned long len, unsigned long prot, unsigned long flag, unsigned long pgoff) { unsigned long ret; struct mm_struct *mm = current->mm; unsigned long populate; ret = security_mmap_file(file, prot, flag);//selinux相关 if (!ret) { down_write(&mm->mmap_sem); ret = do_mmap_pgoff(file, addr, len, prot, flag, pgoff, &populate);//主要函数 up_write(&mm->mmap_sem); if (populate) mm_populate(ret, populate); } return ret; }
而vm_mmap_pgoff主要调用的是do_mmap_pgoff,这个函数的代码比较长就不贴出来了,主要就是使用函数get_unmapped_area获取未映射地址,然后调用mmap_region进行映射。
get_unmapped_area(struct file *file, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags) { unsigned long (*get_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long); unsigned long error = arch_mmap_check(addr, len, flags); if (error) return error; /* Careful about overflows.. */ if (len > TASK_SIZE) return -ENOMEM; get_area = current->mm->get_unmapped_area;//匿名映射直接调用该函数获取虚拟内存空间 if (file && file->f_op && file->f_op->get_unmapped_area) get_area = file->f_op->get_unmapped_area;//文件映射则使用文件的相关哈数获取虚拟内存空间 addr = get_area(file, addr, len, pgoff, flags); if (IS_ERR_VALUE(addr)) return addr; if (addr > TASK_SIZE - len) return -ENOMEM; if (addr & ~PAGE_MASK) return -EINVAL; addr = arch_rebalance_pgtables(addr, len); error = security_mmap_addr(addr); return error ? error : addr; }
可以看到在get_unmapped_area中主要进行了匿名映射与文件映射的区分。如果为匿名映射,直接使用mm->get_unmapped_are直接获取虚拟内存空间(VMA),如果为文件映射,那么从file->f_op->get_unmapped_area中获取虚拟内存空间,但是通过看源码可以发现ext2_file_operation或ext3_file_operation都没有定义get_unmapped_area的方法,因此还是调用内存描述符中的get_unmapped_area,该方法根据不同架构调用arch_get_unmapped_area()函数,在arch_get_unmapped_area函数中当addr非空,表示指定了一个特定的优先选用地址,内核会检查该区域是否与现存区域重叠,由find_vma函数完成查找功能。当addr为空或是指定的优先地址不满足分配条件时,内核必须遍历进程中可用的区域,设法找到一个大小适当的空闲区域,由vm_unmapped_area函数做实际的工作,看一下vm_unmapped_area:
static inline unsigned long vm_unmapped_area(struct vm_unmapped_area_info *info) { if (!(info->flags & VM_UNMAPPED_AREA_TOPDOWN)) return unmapped_area(info); else return unmapped_area_topdown(info); }
该函数根据标志位的不同调用了不同的unmapped_area函数,他们的区别在于unmapped_area函数完成从低地址向高地址创建新的映射,而unmapped_area_topdown函数完成从高地址向低地址创建新的映射。unmapped_area函数代码就不贴上来了,该函数先检查进程虚拟地址空间中可用于映射空间的边界,不满足要求返回错误代号到上层应用程序。当满足时,执行以下操作,以找到最小的空闲的虚拟地址空间满足这次分配请求,便于两个相邻的vma区合并:
在while循环中 unmapped_area具体步骤如下:
1. 从vma红黑树的根开始遍历
2. 若当前结点有左子树则遍历其左子树,否则指向其右孩子。
3. 当某结点rb_subtree_gap可能是最后一个满足分配请求的空隙时,遍历结束。 rb_subtree_gap是当前结点与其前驱结点之间空隙 和 当前结点其左右子树中的结点间的最大空隙的最大值。
4. 检测这个结点,判断这个结点与其前驱结点之间的空隙是否满足分配请求。满足则跳出循环。
5. 不满足分配请求时,指向其右孩子,判断其右孩子的rb_subtree_gap是否满足当前请求。
6. 满足则返回到2。不满足,回退其父结点,返回到4
unmapped_area完成之后还是回到函数do_mmap_pgoff,get_unmapped_area的一系列操作只是返回了新线性区的地址,然后do_mmap_pgoff会对get_unmapped_area返回的addr进行校验,如果addr不满足页对齐,那么说明get_unmapped_area函数返回的是一个错误码,直接将这个错误码返回即可。如果addr正常,则调用calc_vm_prot_bits函数将mmap系统调用的prot 参数合并到内部使用的 vm_flags 中,之后判断是否为文件映射,若是则获取文件的inode结点,当打开一个文件之后,系统就会以inode来识别这个文件。对vm_flags的一系列设置之后,最终调用mmap_region:
if (find_vma_links(mm, addr, addr + len, &prev, &rb_link, &rb_parent)) { if (do_munmap(mm, addr, len)) return -ENOMEM; goto munmap_back; }
mmap_region调用find_vma_links函数确定处于新区间之前的线性区对象的位置,以及在红-黑树中新线性区的位置。同时find_vma_link函数也检查是否还存在与新区建重叠的线性区。如果这样就调用do_munmap函数删除新的区间,然后重复判断。检查无误后,再检查内存的可用性,可用就继续往下通过vma_merge函数返回了一个vma:
/* * Can we just expand an old mapping? */ vma = vma_merge(mm, prev, addr, addr + len, vm_flags, NULL, file, pgoff, NULL); if (vma) goto out;
调用vma_merge检查前一个线性区是否可以以这样的方式进行扩展来包含新的区间。同时需要保证,前一个线性区必须与在vm_flags局部变量中存放的那些线性区具有完全相同的标志。如果前一个线性区可以扩展,那么vm_merge函数就会试图把它与随后的线性区进行合并,如果合并成功就直接跳转到out。如果合并不成功,就继续往下执行,调用kmem_cache_zalloc函数,其中调用了slab分配函数为新的线性区分配一个vm_area_struct结构,之后开始对vma进行赋值,初始化新的线性区对象。
vma->vm_file = get_file(file); error = file->f_op->mmap(file, vma); if (error) goto unmap_and_free_vma;
然后判断是否为文件映射,若是则获取文件描述符,将其赋给vm_file,如果是匿名映射,则vm_file设为dev/zero,这样不需要对所有页面进行提前置0,只有当访问到某具体页面的时候才会申请一个0页。
mmap_region之后就是调用vma_link把新的线性区插入到线性区链表的红黑树中,最后将内存描述符的total_vm字段中的进程地址空间大小进行了增加,最后返回addr。
函数的大致流程就是这样,最后我们重点还是关注文件映射,上面可以看到文件映射调用了file->f_op->mmap,以ext3文件系统为例,最终会调用generic_file_mmap.
const struct file_operations ext3_file_operations = { .llseek = generic_file_llseek, .read = do_sync_read, .write = do_sync_write, .aio_read = generic_file_aio_read, .aio_write = generic_file_aio_write, .unlocked_ioctl = ext3_ioctl, #ifdef CONFIG_COMPAT .compat_ioctl = ext3_compat_ioctl, #endif .mmap = generic_file_mmap, .open = dquot_file_open, .release = ext3_release_file, .fsync = ext3_sync_file, .splice_read = generic_file_splice_read, .splice_write = generic_file_splice_write, };
看一下generic_file_mmap:
int generic_file_mmap(struct file * file, struct vm_area_struct * vma) { struct address_space *mapping = file->f_mapping; if (!mapping->a_ops->readpage) return -ENOEXEC; file_accessed(file); vma->vm_ops = &generic_file_vm_ops; return 0; }
这里首先获取文件f_mapping指向的地址空间,然后判断其readpage是否为空,若空则返回错误码,因为之后需要使用该函数读取真正的文件内容,所以不能为空。这里的mapping->a_ops就是文件address_space对特定地址空间的操作函数指针,还是以ext3文件系统为例,具体的函数赋值为:
static const struct address_space_operations ext3_writeback_aops = { .readpage = ext3_readpage, .readpages = ext3_readpages, .writepage = ext3_writeback_writepage, .write_begin = ext3_write_begin, .write_end = ext3_writeback_write_end, .bmap = ext3_bmap, .invalidatepage = ext3_invalidatepage, .releasepage = ext3_releasepage, .direct_IO = ext3_direct_IO, .migratepage = buffer_migrate_page, .is_partially_uptodate = block_is_partially_uptodate, .error_remove_page = generic_error_remove_page, };
函数generic_file_mmap最后还将该vma对应的操作赋值为generic_file_vm_ops:
const struct vm_operations_struct generic_file_vm_ops = { .fault = filemap_fault, .page_mkwrite = filemap_page_mkwrite, .remap_pages = generic_file_remap_pages, };
mmap文件映射的流程大致就是这样,主要就是分配一个可用的vma,然后将其和文件对应,这里还没有真正地将文件的内容映射进内存。等到真正读取内容的时候发生缺页异常,内核调用函数do_page_fault(/arch/x86/mm/fault.c)来处理,所需处理的情况比较复杂,这里我还是重点关注文件映射部分的缺页异常,其他部分暂不涉及。
该函数进一步调用__do_page_fault,如果异常并非出现在中断期间,也有相关的上下文,则内核检查进程的地址空间是否包含异常地址所在的区域:
vma = find_vma(mm, address);
find_vma用于完成上述工作,在内核发现地址有效或无效时,会分别跳转到good_area和bad_area,当找到异常地址所在的区域后,还需根据权限判断当前的访问是否有效,如error_code是不可写/不可执行的错误,但addr所属vma线性区本身就不可写/不可执行,那么就直接返回,因为问题根本不是缺页,而是访问无效,之后调用handle_mm_fault负责校正缺页异常。
fault = handle_mm_fault(mm, vma, address, flags);
handle_mm_fault不依赖底层体系结构,其在内存管理的框架下独立于系统实现。该函数确认在各级页目录中,通向对应于异常地址的页表项的各个页目录项都存在,不存在则分配。最后调用函数handle_pte_fault分析缺页异常的原因:
int handle_pte_fault(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *pte, pmd_t *pmd, unsigned int flags) { pte_t entry; spinlock_t *ptl; entry = *pte; //pte为指向相关页表项(pte_t)的指针,此处获得页表项 if (!pte_present(entry)) {//页不在物理内存中 if (pte_none(entry)) {/*没有对应的页表项,则内核必须从头开始加载该页,对匿名映射称之为按需分配,对基于文件的映射称为按需调页。如果vm_ops为空,则必须使用do_anonymous_page返回一个匿名页*/ if (vma->vm_ops) {//如果该vma的操作函数集合实现了fault函数,说明是文件映射而不是匿名映射,将调用do_linear_fault分配物理页 if (likely(vma->vm_ops->fault)) return do_linear_fault(mm, vma, address, pte, pmd, flags, entry); } return do_anonymous_page(mm, vma, address, pte, pmd, flags);//匿名映射分配物理页 } if (pte_file(entry))//检查页表项是否属于非线性映射 return do_nonlinear_fault(mm, vma, address, pte, pmd, flags, entry); return do_swap_page(mm, vma, address, pte, pmd, flags, entry);//该页已换出,从交换区换入 } //下方是关于写时复制的处理,此处不贴出
之前在处理mmap系统调用时,通过vma->vm_ops = &generic_file_vm_ops给vma->vm_ops进行赋值,因此直接调用函数do_linear_fault进行处理,该函数在转换一些参数之后,将工作委托给__do_fault:
ret = vma->vm_ops->fault(vma, &vmf);
__do_fault中调用定义好的fault函数,将所需的文件数据读入到内存页,从前面mmap系统调用可知vma->vm_ops->fault赋值为filemap_fault,该函数首先去页缓存中查找所需的页是否存在,由于现在刚完成mmap,对应的页肯定不在页缓存中,因此调用函数page_cache_read
no_cached_page: /* * We're only likely to ever get here if MADV_RANDOM is in * effect. */ error = page_cache_read(file, offset);
page_cache_read分配一个页面,将其加入页缓存,并调用mapping->a_ops->readpage读取数据,以ext3文件系统为例,mapping->a_ops->readpage赋值为ext3_readpage,该函数基于io调度将文件内容读取到分配的缓存页中。
static int page_cache_read(struct file *file, pgoff_t offset) { struct address_space *mapping = file->f_mapping; struct page *page; int ret; do { page = page_cache_alloc_cold(mapping); if (!page) return -ENOMEM; ret = add_to_page_cache_lru(page, mapping, offset, GFP_KERNEL); if (ret == 0) ret = mapping->a_ops->readpage(file, page); else if (ret == -EEXIST) ret = 0; /* losing race to add is OK */ page_cache_release(page); } while (ret == AOP_TRUNCATED_PAGE); return ret; }
这样页缓存中就有了该文件内容对应的页,将其赋值给vmf->page,返回至__do_fault,之后的操作流程如下:
mmap系统调用的flags参数有几个选项,其中MAP_SHARED表示创建一个共享映射的区域,多个进程可以通过共享映射方式来映射一个文件,这样其他进程也可以看到映射内容的改变,修改后的内容会同步到磁盘文件中;MAP_PRIVATE则是创建一个私有的写时复制的映射。多个进程可以通过私有映射的方式来映射一个文件,这样其他进程不会看到映射内容的改变,修改后的内容也不会同步到磁盘文件中;MAP_ANONYMOUS创建一个匿名映射,即没有关联到文件的映射,还有其他参数此处不再说明。回到函数__do_fault,如果是对私有映射的写访问,那么需要拷贝该页的一个副本,这样的话其实就还是两次复制操作。
但如果是共享映射的话,则调用vma->vm_ops->page_mkwrite通知进程地址空间page变为可写,一个页面变成可写,那么进程有可能需要等待这个page的内容回写成功。
page = vmf.page; if (flags & FAULT_FLAG_WRITE) {//写访问 if (!(vma->vm_flags & VM_SHARED)) {//私有映射 page = cow_page; anon = 1; copy_user_highpage(page, vmf.page, address, vma); __SetPageUptodate(page); } else {//共享映射 /* * If the page will be shareable, see if the backing * address space wants to know that the page is about * to become writable */ if (vma->vm_ops->page_mkwrite) { int tmp; unlock_page(page); vmf.flags = FAULT_FLAG_WRITE|FAULT_FLAG_MKWRITE; tmp = vma->vm_ops->page_mkwrite(vma, &vmf); if (unlikely(tmp & (VM_FAULT_ERROR | VM_FAULT_NOPAGE))) { ret = tmp; goto unwritable_page; } if (unlikely(!(tmp & VM_FAULT_LOCKED))) { lock_page(page); if (!page->mapping) { ret = 0; /* retry the fault */ unlock_page(page); goto unwritable_page; } } else VM_BUG_ON(!PageLocked(page)); page_mkwrite = 1; } } }
之后判断该异常地址对应的硬件页表项pte的内容是否与之前一致,若和orig_pte不一样说明期间有人修改了pte,那么需要释放page。如果一致,则将其加入进程的页表,再合并到逆向映射数据结构中。在完成这些操作之前,使用flush_icache_page更新缓存,确保页的内容在用户空间可见。之后根据读写权限产生页表项,根据映射类型集成到逆向映射,最后更新处理器的MMU,因为页表已经修改。
/* Only go through if we didn't race with anybody else... */ if (likely(pte_same(*page_table, orig_pte))) {//判读一致性 flush_icache_page(vma, page); entry = mk_pte(page, vma->vm_page_prot);//产生指向只读页的页表项 if (flags & FAULT_FLAG_WRITE) entry = maybe_mkwrite(pte_mkdirty(entry), vma);//设置为可写 if (anon) { inc_mm_counter_fast(mm, MM_ANONPAGES); page_add_new_anon_rmap(page, vma, address);//将匿名页集成到逆向映射 } else { inc_mm_counter_fast(mm, MM_FILEPAGES); page_add_file_rmap(page);//将文件映射的页集成到逆向映射 if (flags & FAULT_FLAG_WRITE) { dirty_page = page; get_page(dirty_page); } } set_pte_at(mm, address, page_table, entry);//填充页表项 /* no need to invalidate: a not-present page won't be cached */ update_mmu_cache(vma, address, page_table);//更新mmu } else { if (cow_page) mem_cgroup_uncharge_page(cow_page); if (anon) page_cache_release(page);//如果不一致则释放 else anon = 1; /* no anon but release faulted_page */ }
到这里也可以看到,共享映射的情况下就是将文件内容复制到页缓存,然后建立进程虚拟内存到页缓存的映射关系,达到用户进程通过操作虚拟内存来操作文件的效果。
3、块缓存
这块以后看到了再加上来吧。