25 | 用户态内存映射:如何找到正确的会议室?
下载APP
关闭
渠道合作
推荐作者
25 | 用户态内存映射:如何找到正确的会议室?
2019-05-24 刘超 来自北京
《趣谈Linux操作系统》
课程介绍
讲述:刘超
时长23:04大小18.45M
前面几节,我们既看了虚拟内存空间如何组织的,也看了物理页面如何管理的。现在我们需要一些数据结构,将二者关联起来。
mmap 的原理
在虚拟地址空间那一节,我们知道,每一个进程都有一个列表 vm_area_struct,指向虚拟地址空间的不同的内存块,这个变量的名字叫 mmap。
其实内存映射不仅仅是物理内存和虚拟内存之间的映射,还包括将文件中的内容映射到虚拟内存空间。这个时候,访问内存空间就能够访问到文件里面的数据。而仅有物理内存和虚拟内存的映射,是一种特殊情况。
前面咱们讲堆的时候讲过,如果我们要申请小块内存,就用 brk。brk 函数之前已经解析过了,这里就不多说了。如果申请一大块内存,就要用 mmap。对于堆的申请来讲,mmap 是映射内存空间到物理内存。
另外,如果一个进程想映射一个文件到自己的虚拟内存空间,也要通过 mmap 系统调用。这个时候 mmap 是映射内存空间到物理内存再到文件。可见 mmap 这个系统调用是核心,我们现在来看 mmap 这个系统调用。
如果要映射到文件,fd 会传进来一个文件描述符,并且 mmap_pgoff 里面通过 fget 函数,根据文件描述符获得 struct file。struct file 表示打开的一个文件。
接下来的调用链是 vm_mmap_pgoff->do_mmap_pgoff->do_mmap。这里面主要干了两件事情:
调用 get_unmapped_area 找到一个没有映射的区域;
调用 mmap_region 映射这个区域。
我们先来看 get_unmapped_area 函数。
这里面如果是匿名映射,则调用 mm_struct 里面的 get_unmapped_area 函数。这个函数其实是 arch_get_unmapped_area。它会调用 find_vma_prev,在表示虚拟内存区域的 vm_area_struct 红黑树上找到相应的位置。之所以叫 prev,是说这个时候虚拟内存区域还没有建立,找到前一个 vm_area_struct。
如果不是匿名映射,而是映射到一个文件,这样在 Linux 里面,每个打开的文件都有一个 struct file 结构,里面有一个 file_operations,用来表示和这个文件相关的操作。如果是我们熟知的 ext4 文件系统,调用的是 thp_get_unmapped_area。如果我们仔细看这个函数,最终还是调用 mm_struct 里面的 get_unmapped_area 函数。殊途同归。
我们再来看 mmap_region,看它如何映射这个虚拟内存区域。
还记得咱们刚找到了虚拟内存区域的前一个 vm_area_struct,我们首先要看,是否能够基于它进行扩展,也即调用 vma_merge,和前一个 vm_area_struct 合并到一起。
如果不能,就需要调用 kmem_cache_zalloc,在 Slub 里面创建一个新的 vm_area_struct 对象,设置起始和结束位置,将它加入队列。如果是映射到文件,则设置 vm_file 为目标文件,调用 call_mmap。其实就是调用 file_operations 的 mmap 函数。对于 ext4 文件系统,调用的是 ext4_file_mmap。从这个函数的参数可以看出,这一刻文件和内存开始发生关系了。这里我们将 vm_area_struct 的内存操作设置为文件系统操作,也就是说,读写内存其实就是读写文件系统。
我们再回到 mmap_region 函数。最终,vma_link 函数将新创建的 vm_area_struct 挂在了 mm_struct 里面的红黑树上。
这个时候,从内存到文件的映射关系,至少要在逻辑层面建立起来。那从文件到内存的映射关系呢?vma_link 还做了另外一件事情,就是 __vma_link_file。这个东西要用于建立这层映射关系。
对于打开的文件,会有一个结构 struct file 来表示。它有个成员指向 struct address_space 结构,这里面有棵变量名为 i_mmap 的红黑树,vm_area_struct 就挂在这棵树上。
到这里,内存映射的内容要告一段落了。你可能会困惑,好像还没和物理内存发生任何关系,还是在虚拟内存里面折腾呀?
对的,因为到目前为止,我们还没有开始真正访问内存呀!这个时候,内存管理并不直接分配物理内存,因为物理内存相对于虚拟地址空间太宝贵了,只有等你真正用的那一刻才会开始分配。
用户态缺页异常
一旦开始访问虚拟内存的某个地址,如果我们发现,并没有对应的物理页,那就触发缺页中断,调用 do_page_fault。
在 __do_page_fault 里面,先要判断缺页中断是否发生在内核。如果发生在内核则调用 vmalloc_fault,这就和咱们前面学过的虚拟内存的布局对应上了。在内核里面,vmalloc 区域需要内核页表映射到物理页。咱们这里把内核的这部分放放,接着看用户空间的部分。
接下来在用户空间里面,找到你访问的那个地址所在的区域 vm_area_struct,然后调用 handle_mm_fault 来映射这个区域。
到这里,终于看到了我们熟悉的 PGD、P4G、PUD、PMD、PTE,这就是前面讲页表的时候,讲述的四级页表的概念,因为暂且不考虑五级页表,我们暂时忽略 P4G。
pgd_t 用于全局页目录项,pud_t 用于上层页目录项,pmd_t 用于中间页目录项,pte_t 用于直接页表项。
每个进程都有独立的地址空间,为了这个进程独立完成映射,每个进程都有独立的进程页表,这个页表的最顶级的 pgd 存放在 task_struct 中的 mm_struct 的 pgd 变量里面。
在一个进程新创建的时候,会调用 fork,对于内存的部分会调用 copy_mm,里面调用 dup_mm。
在这里,除了创建一个新的 mm_struct,并且通过 memcpy 将它和父进程的弄成一模一样之外,我们还需要调用 mm_init 进行初始化。接下来,mm_init 调用 mm_alloc_pgd,分配全局页目录项,赋值给 mm_struct 的 pgd 成员变量。
pgd_alloc 里面除了分配 PGD 之外,还做了很重要的一个事情,就是调用 pgd_ctor。
pgd_ctor 干了什么事情呢?我们注意看里面的注释,它拷贝了对于 swapper_pg_dir 的引用。swapper_pg_dir 是内核页表的最顶级的全局页目录。
一个进程的虚拟地址空间包含用户态和内核态两部分。为了从虚拟地址空间映射到物理页面,页表也分为用户地址空间的页表和内核页表,这就和上面遇到的 vmalloc 有关系了。在内核里面,映射靠内核页表,这里内核页表会拷贝一份到进程的页表。至于 swapper_pg_dir 是什么,怎么初始化的,怎么工作的,我们还是先放一放,放到下一节统一讨论。
至此,一个进程 fork 完毕之后,有了内核页表,有了自己顶级的 pgd,但是对于用户地址空间来讲,还完全没有映射过。这需要等到这个进程在某个 CPU 上运行,并且对内存访问的那一刻了。
当这个进程被调度到某个 CPU 上运行的时候,咱们在调度那一节讲过,要调用 context_switch 进行上下文切换。对于内存方面的切换会调用 switch_mm_irqs_off,这里面会调用 load_new_mm_cr3。
cr3 是 CPU 的一个寄存器,它会指向当前进程的顶级 pgd。如果 CPU 的指令要访问进程的虚拟内存,它就会自动从 cr3 里面得到 pgd 在物理内存的地址,然后根据里面的页表解析虚拟内存的地址为物理内存,从而访问真正的物理内存上的数据。
这里需要注意两点。第一点,cr3 里面存放当前进程的顶级 pgd,这个是硬件的要求。cr3 里面需要存放 pgd 在物理内存的地址,不能是虚拟地址。因而 load_new_mm_cr3 里面会使用 __pa,将 mm_struct 里面的成员变量 pgd(mm_struct 里面存的都是虚拟地址)变为物理地址,才能加载到 cr3 里面去。
第二点,用户进程在运行的过程中,访问虚拟内存中的数据,会被 cr3 里面指向的页表转换为物理地址后,才在物理内存中访问数据,这个过程都是在用户态运行的,地址转换的过程无需进入内核态。
只有访问虚拟内存的时候,发现没有映射到物理内存,页表也没有创建过,才触发缺页异常。进入内核调用 do_page_fault,一直调用到 __handle_mm_fault,这才有了上面解析到这个函数的时候,我们看到的代码。既然原来没有创建过页表,那只好补上这一课。于是,__handle_mm_fault 调用 pud_alloc 和 pmd_alloc,来创建相应的页目录项,最后调用 handle_pte_fault 来创建页表项。
绕了一大圈,终于将页表整个机制的各个部分串了起来。但是咱们的故事还没讲完,物理的内存还没找到。我们还得接着分析 handle_pte_fault 的实现。
这里面总的来说分了三种情况。如果 PTE,也就是页表项,从来没有出现过,那就是新映射的页。如果是匿名页,就是第一种情况,应该映射到一个物理内存页,在这里调用的是 do_anonymous_page。如果是映射到文件,调用的就是 do_fault,这是第二种情况。如果 PTE 原来出现过,说明原来页面在物理内存中,后来换出到硬盘了,现在应该换回来,调用的是 do_swap_page。
我们来看第一种情况,do_anonymous_page。对于匿名页的映射,我们需要先通过 pte_alloc 分配一个页表项,然后通过 alloc_zeroed_user_highpage_movable 分配一个页。之后它会调用 alloc_pages_vma,并最终调用 __alloc_pages_nodemask。
这个函数你还记得吗?就是咱们伙伴系统的核心函数,专门用来分配物理页面的。do_anonymous_page 接下来要调用 mk_pte,将页表项指向新分配的物理页,set_pte_at 会将页表项塞到页表里面。
第二种情况映射到文件 do_fault,最终我们会调用 __do_fault。
这里调用了 struct vm_operations_struct vm_ops 的 fault 函数。还记得咱们上面用 mmap 映射文件的时候,对于 ext4 文件系统,vm_ops 指向了 ext4_file_vm_ops,也就是调用了 ext4_filemap_fault。
ext4_filemap_fault 里面的逻辑我们很容易就能读懂。vm_file 就是咱们当时 mmap 的时候映射的那个文件,然后我们需要调用 filemap_fault。对于文件映射来说,一般这个文件会在物理内存里面有页面作为它的缓存,find_get_page 就是找那个页。如果找到了,就调用 do_async_mmap_readahead,预读一些数据到内存里面;如果没有,就跳到 no_cached_page。
如果没有物理内存中的缓存页,那我们就调用 page_cache_read。在这里显示分配一个缓存页,将这一页加到 lru 表里面,然后在 address_space 中调用 address_space_operations 的 readpage 函数,将文件内容读到内存中。address_space 的作用咱们上面也介绍过了。
struct address_space_operations 对于 ext4 文件系统的定义如下所示。这么说来,上面的 readpage 调用的其实是 ext4_readpage。因为我们还没讲到文件系统,这里我们不详细介绍 ext4_readpage 具体干了什么。你只要知道,最后会调用 ext4_read_inline_page,这里面有部分逻辑和内存映射有关就行了。
在 ext4_read_inline_page 函数里,我们需要先调用 kmap_atomic,将物理内存映射到内核的虚拟地址空间,得到内核中的地址 kaddr。 我们在前面提到过 kmap_atomic,它是用来做临时内核映射的。本来把物理内存映射到用户虚拟地址空间,不需要在内核里面映射一把。但是,现在因为要从文件里面读取数据并写入这个物理页面,又不能使用物理地址,我们只能使用虚拟地址,这就需要在内核里面临时映射一把。临时映射后,ext4_read_inline_data 读取文件到这个虚拟地址。读取完毕后,我们取消这个临时映射 kunmap_atomic 就行了。
至于 kmap_atomic 的具体实现,我们还是放到内核映射部分再讲。
我们再来看第三种情况,do_swap_page。之前我们讲过物理内存管理,你这里可以回忆一下。如果长时间不用,就要换出到硬盘,也就是 swap,现在这部分数据又要访问了,我们还得想办法再次读到内存中来。
do_swap_page 函数会先查找 swap 文件有没有缓存页。如果没有,就调用 swapin_readahead,将 swap 文件读到内存中来,形成内存页,并通过 mk_pte 生成页表项。set_pte_at 将页表项插入页表,swap_free 将 swap 文件清理。因为重新加载回内存了,不再需要 swap 文件了。
swapin_readahead 会最终调用 swap_readpage,在这里,我们看到了熟悉的 readpage 函数,也就是说读取普通文件和读取 swap 文件,过程是一样的,同样需要用 kmap_atomic 做临时映射。
通过上面复杂的过程,用户态缺页异常处理完毕了。物理内存中有了页面,页表也建立好了映射。接下来,用户程序在虚拟内存空间里面,可以通过虚拟地址顺利经过页表映射的访问物理页面上的数据了。
为了加快映射速度,我们不需要每次从虚拟地址到物理地址的转换都走一遍页表。
页表一般都很大,只能存放在内存中。操作系统每次访问内存都要折腾两步,先通过查询页表得到物理地址,然后访问该物理地址读取指令、数据。
为了提高映射速度,我们引入了 TLB(Translation Lookaside Buffer),我们经常称为快表,专门用来做地址映射的硬件设备。它不在内存中,可存储的数据比较少,但是比内存要快。所以,我们可以想象,TLB 就是页表的 Cache,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。
有了 TLB 之后,地址映射的过程就像图中画的。我们先查块表,块表中有映射关系,然后直接转换为物理地址。如果在 TLB 查不到映射关系时,才会到内存中查询页表。
总结时刻
用户态的内存映射机制,我们解析的差不多了,我们来总结一下,用户态的内存映射机制包含以下几个部分。
用户态内存映射函数 mmap,包括用它来做匿名映射和文件映射。
用户态的页表结构,存储位置在 mm_struct 中。
在用户态访问没有映射的内存会引发缺页异常,分配物理页表、补齐页表。如果是匿名映射则分配物理内存;如果是 swap,则将 swap 文件读入;如果是文件映射,则将文件读入。
课堂练习
你可以试着用 mmap 系统调用,写一个程序来映射一个文件,并读取文件的内容。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
分享给需要的人,Ta购买本课程,你将得20元
生成海报并分享
赞 12
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
24 | 物理内存管理(下):会议室管理员如何分配会议室?
下一篇
26 | 内核态内存映射:如何找到正确的会议室?
精选留言(42)
- why2019-05-25- 申请小块内存用 brk; 申请大块内存或文件映射用 mmap - mmap 映射文件, 由 fd 得到 struct file - 调用 ...->do_mmap - 调用 get_unmapped_area 找到一个可以进行映射的 vm_area_struct - 调用 mmap_region 进行映射 - get_unmapped_area - 匿名映射: 找到前一个 vm_area_struct - 文件映射: 调用 file 中 file_operations 文件的相关操作, 最终也会调用到 get_unmapped_area - mmap_region - 通过 vm_area_struct 判断, 能否基于现有的块扩展(调用 vma_merge) - 若不能, 调用 kmem_cache_alloc 在 slub 中得到一个 vm_area_struct 并进行设置 - 若是文件映射: 则调用 file_operations 的 mmap 将 vm_area_struct 的内存操作设置为文件系统对应操作(读写内存就是读写文件系统) - 通过 vma_link 将 vm_area_struct 插入红黑树 - 若是文件映射, 调用 __vma_link_file 建立文件到内存的反映射 - 内存管理不直接分配内存, 在使用时才分配 - 用户态缺页异常, 触发缺页中断, 调用 do_page_default - __do_page_fault 判断中断是否发生在内核 - 若发生在内核, 调用 vmalloc_fault, 使用内核页表进行映射 - 若不是, 找到对应 vm_area_struct 调用 handle_mm_fault - 得到多级页表地址 pgd 等 - pgd 存在 task_struct.mm_struct.pgd 中 - 全局页目录项 pgd 在创建进程 task_struct 时创建并初始化, 会调用 pgd_ctor 拷贝内核页表到进程的页表 - 进程被调度运行时, 通过 switch_mm_irqs_off->load_new_mm_cr3 切换内存上下文 - cr3 是 cpu 寄存器, 存储进程 pgd 的物理地址(load_new_mm_cr3 加载时通过直接内存映射进行转换) - cpu 访问进程虚拟内存时, 从 cr3 得到 pgd 页表, 最后得到进程访问的物理地址 - 进程地址转换发生在用户态, 缺页时才进入内核态(调用__handle_mm_fault) - __handle_mm_fault 调用 pud_alloc, pmd_alloc, handle_pte_fault 分配页表项 - 若不存在 pte - 匿名页: 调用 do_anonymous_page 分配物理页 ① - 文件映射: 调用 do_fault ② - 若存在 pte, 调用 do_swap_page 换入内存 ③ - ① 为匿名页分配内存 - 调用 pte_alloc 分配 pte 页表项 - 调用 ...->__alloc_pages_nodemask 分配物理页 - mk_pte 页表项指向物理页; set_pte_at 插入页表项 - ② 为文件映射分配内存 __do_fault - 以 ext4 为例, 调用 ext4_file_fault->filemap_fault - 文件映射一般有物理页作为缓存 find_get_page 找缓存页 - 若有缓存页, 调用函数预读数据到内存 - 若无缓存页, 调用 page_cache_read 分配一个, 加入 lru 队列, 调用 readpage 读数据: 调用 kmap_atomic 将物理内存映射到内核临时映射空间, 由内核读取文件, 再调用 kunmap_atomic 解映射 - ③ do_swap_page - 先检查对应 swap 有没有缓存页 - 没有, 读入 swap 文件(也是调用 readpage) - 调用 mk_pte; set_pet_at; swap_free(清理 swap) - 避免每次都需要经过页表(存再内存中)访问内存 - TLB 缓存部分页表项的副本展开共 1 条评论52
- 活的潇洒2019-05-29比起《深入浅出计算机组成原理》和《Linux性能优化实战》的篇幅 本节花了三天,每天不少于2小时,才把笔记做完,估计老师也花费不少时间 day25笔记:https://www.cnblogs.com/luoahong/p/10916458.html
作者回复: 是啊是啊,理解万岁
共 3 条评论23 - zzuse2019-05-24我感觉学得很吃力,调用链太长了
作者回复: 忽略调用链,记住重点节点,调用链就是为了证明的确这样过去的
共 2 条评论16 - 超超2019-07-17#include<unistd.h> #include<stdio.h> #include<stdlib.h> #include<string.h> #include<sys/types.h> #include<sys/stat.h> #include<sys/time.h> #include<fcntl.h> #include<sys/mman.h> #define MAX 10000 //实现把存有10000个整数的文件的每个整数值加1,再写回文件 int main() { int i=0; int fd=0; int *array = (int *)malloc( sizeof(int)*MAX ); if(!array) { return -1;} /*mmap*/ fd = open( "mmap_test", O_RDWR ); array = mmap( NULL, sizeof(int)*MAX, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0 ); for( i=0; i<MAX; ++i ) ++array[ i ]; munmap( array, sizeof(int)*MAX ); msync( array, sizeof(int)*MAX, MS_SYNC ); free( array ); close( fd ); return 0; }展开8
- LDxy2019-05-26请问老师,内核里面这些复杂的机制的实现,在当初软件开发开始前有详细的设计文档的吗?分布在全球各地的开发者是如何能达成这种复杂设计的共识的呢?这些内核里的函数相互依赖又和底层硬件相关,是如何进行单元测试的呢?
作者回复: 可以参考一下开源软件的运作模式,要写设计,大牛review,通过后写代码,大牛组成委员会,看够不够资格合并进去,要合并进去就要有相应的测试用例,覆盖率等,有邮件列表,实时对话工具
共 2 条评论7 - 趁早2021-01-04个人感觉可以适当缩减部分流程,用老师理解完了之后的流程抽象出来,提炼才是精华,这些其实操作系统课程上大部分都有,但是进一步提炼出来让大家看完就能记住大概流程是不是更好?共 1 条评论6
- garlic2020-07-26mmap 创建可以通过文件, 也可以匿名创建,通过文件创建, 如果文件大小和映射区域大小不一致, 超过映射访问边界会触发SIGSEGV, 大于文件边界小于映射区域的访问会触发SIGBUS, 有种特殊情况是·Hole Punching·, SPARSE FILE稀疏文件,映射的时候中间有空洞, 如果访问空洞是不会抛出异常的,进程多了要申请内存大于实际内存的话可以通过 overcommit来调节。 mmap配合dax文件系统,可以绕开文件系统减少swap,提升性能, 笔记https://garlicspace.com/2020/07/22/mmap%e7%9b%b8%e5%85%b3api/展开6
- 啦啦啦2019-07-21这篇看了四五遍,都是看了一半就没看了,这是第一次全部看完这篇文章,发现后半部分比前面好理解
作者回复: 还是要坚持,一遍不行,再来一遍
5 - Run2021-11-30每过一段时间回来看看都有新的收获啊3
- mooneal2020-05-21难道堆中数据也是通过匿名映射来获取具体的物理地址?
作者回复: 对的
共 2 条评论2 - 何柄融2020-02-02这里有个很久以前使用mmap进行文件读取和打印数据的demo(虽然当时是想用来表达进程通信的) 希望对大家有所帮助 https://zhuanlan.zhihu.com/p/574545652
- 小橙子2019-10-31当时看内存映射有些懵,可能陷入各种调用了,突然间怎么又出来这么一个调用,其实讲的前面已经提到的调用。看完文件与输入输出后 反过来又看了一遍内存映射,嗯,基本都理解了。 有一个问题,就是比如内核内存管理模块分配物理内存的时候,是要保证并发安全的吧,因为可能多个核上的程序都发生了缺页中断,也要分配物理内存展开2
- 玉剑冰锋2019-05-28分配全局页目录项,赋值给mm_struct的pdg成员变量。这里应该是pgd吧老师?
作者回复: Page Global Directory,PGD,是的,老是倒
1 - 一笔一画2019-05-24请教下老师,内核线程的task struct上的mm为什么为空?另外看代码还有个active_mm,这个设计上有什么考虑吗?
作者回复: 内核线程没有用户地址空间。 如果是用户进程,则两者一样。如果是内核线程,没有mm,active_mm指向此时用户态的地址空间。
共 2 条评论1 - 安排2019-05-24打卡,通俗易懂1
- andy66892022-11-23 来自广东这个系列的章节我来回看了好几遍,真是每次看都有不同的收获,应证了”书读百遍,其意自现“
- 涛子2022-08-02 来自浙江之前还能一天看4课,现在一课都吃力了
- 涛子2022-08-02 来自浙江已经吐了。。。被各种调用链搞得找不着北
- 浅陌2022-07-09请问物理页最少是分配4k的吗
- 小鳄鱼2022-05-06另外一个问题:既然硬件有专门的MMU(内存管理单元,它会使用TLB),那么,这篇文章里面的相关处理是Linux的代码,还是MMU的?Linux的内存管理子系统跟硬件MMU是什么关系?