深度解析Linux的内存管理体系

一、内存管理概览
内存是计算机最重要的资源之一,内存管理是操作系统最重要的任务之一。内存管理并不是简单地管理一下内存而已,它还直接影响着操作系统的风格以及用户空间编程的模式。可以说内存管理的方式是一个系统刻入dna的秉性。既然内存管理那么重要,那么今天我们就来全面系统地讲一讲linux内存管理。
1.1 内存管理的意义
外存是程序存储的地方,内存是进程运行的地方。外存相当于是军营,内存相当于是战场。选择一个良好的战场才有利于军队打胜仗,实现一个完善的内存管理机制才能让进程多快好省地运行。如何更好地实现内存管理一直是操作系统发展的一大主题。在此过程中内存管理的基本模式也经历了好几代的发展,下面我们就来看一下。
1.2 原始内存管理
最初的时候,内存管理是十分的简陋,大家都运行在物理内存上,内核和进程运行在一个空间中,内存分配算法有首次适应算法(firstfit)、最佳适应算法(bestfit)、最差适应算法(worstfit)等。显然,这样的内存管理方式问题是很明显的。内核与进程之间没有做隔离,进程可以随意访问(干扰、窃取)内核的数据。而且进程和内核没有权限的区分,进程可以随意做一些敏感操作。还有一个问题就是当时的物理内存非常少,能同时运行的进程比较少,运行进程的吞吐量比较少。
1.3 分段内存管理
于是第二代内存管理方式,分段内存管理诞生了。分段内存管理需要硬件的支持和软件的配合。在分段内存中,软件可以把物理内存分成一个一个的段,每个段都有段基址和段限长,还有段类型和段权限。段基址和段限长确定一个段的范围,可以防止内存访问越界。段与段之间也可以互相访问,但是不能随便访问,有一定的规则限制。段类型分为代码段和数据段,正好对应程序的代码和数据,代码段是只读和可执行的,数据段有只读数据段和读写数据段。代码段是不可写的,只读数据段也是不可写,数据段是不可执行的,这样又增加了一层安全性。段权限分为有特权(内核权限)和无特权(用户权限),内核的代码段和数据段都设置为特权段,进程的代码段和数据段都设置为用户段,这样进程就不能随意访问内核了。当cpu执行特权段代码的时候会把自己设置为特权模式,此时cpu可以执行所以的指令。当cpu执行用户段代码的时候会把自己设置为用户模式,此时cpu只能执行普通指令,不能执行敏感指令。
至此,分段内存管理完美解决了原始内存管理存在的大部分问题:进程与内核之间的隔离实现了,进程不能随意访问内核了;cpu特权级实现了,进程无法再执行敏感指令了;内存访问的安全性提高了,越界访问和野指针问题得到了一定程度的遏制。但是分段内存管理还有一个严重的问题没有解决,那就是当时的物理内存非常少的问题。为此当时想的办法是用软件方法来解决,而且是进程自己解决。程序员在编写程序的时候就要想好,把程序分成几个模块,关联不大的模块,它们占用相同的物理地址。然后再编写一个overlay manager,在程序运行的时候,动态地加载即将会运行的模块,覆盖掉暂时不用的模块。这样一个程序占用较少的物理内存,也能顺利地运行下去。显然这样的方法很麻烦,每个程序都要写overlay manager也不太优雅。
1.4 分页内存管理
于是第三代内存管理方式,虚拟内存管理(分页内存管理)诞生了。虚拟内存管理也是需要硬件的支持和软件的配合。在虚拟内存中,cpu访问任何内存都是通过虚拟内存地址来访问的,但是实际上最终访问内存还是得用物理内存地址。所以在cpu中存在一个mmu,负责把虚拟地址转化为物理地址,然后再去访问内存。而mmu把虚拟地址转化为物理的过程需要页表的支持,页表是由内核负责创建和维护的。一套页表可以用来表达一个虚拟内存空间,不同的进程可以用不同的页表集,页表集是可以不停地切换的,哪个进程正在运行就切换到哪个进程的页表集。于是一个进程就只能访问自己的虚拟内存空间,而访问不了别人的虚拟内存空间,这样就实现了进程之间的隔离。一个虚拟内存空间又分为两部分,内核空间和用户空间,内核空间只有一个,用户空间有n个,所有的虚拟内存空间都共享同一个内核空间。内核运行在内核空间,进程运行在用户空间,内核空间有特权,用户空间无特权,用户空间不能随意访问内核空间。这样进程和内核之间的隔离就形成了。内核空间的代码运行的时候,cpu会把自己设置为特权模式,可以执行所有的指令。用户空间运行的时候,cpu会把自己设置为用户模式,只能执行普通指令,不能执行敏感指令。
至此,分段内存实现的功能,虚拟内存都做到了,下面就是虚拟内存如何解决物理内存不足的问题了。系统刚启动的时候还是运行在物理内存上的,内核也被全部加载到了物理内存。然后内核建立页表体系并开启分页机制,内核的物理内存和虚拟内存就建立映射了,整个系统就运行在虚拟内存上了。后面运行进程的时候就不是这样了,内核会记录进程的虚拟内存分配情况,但是并不会马上分配物理内存建立页表映射,而是让进程先运行着。进程运行的时候,cpu都是通过mmu访问虚拟内存地址的,mmu会用页表去解析虚拟内存,如果找到了其对应的物理地址就直接访问,如果页表项是空的,就会触发缺页异常,在缺页异常中会去分配物理内存并建立页表映射。然后再重新执行刚才的那条指令,然后cpu还是通过mmu访问内存,由于页表建立好了,这下就可以访问到物理内存了。当物理内存不足的时候,内核还会把一部分物理内存解除映射,把其内容存放到外存中,等其再次需要的时候再加载回来。这样,一个进程运行的时候并不需要立马加载其全部内容到物理内存,进程只需要少量的物理内存就能顺利地运行,于是系统运行进程的吞吐量就大大提高了。
分页内存管理不仅实现了分段内存管理的功能,还有额外的优点,于是分段内存管理就没有存在的意义了。但是这里面还有一个历史包袱问题。对于那些比较新的cpu,比如arm、risc-v,它们没有历史包袱,直接实现的就是分页内存管理,根本不存在分段机制。但是对于x86就不一样了,x86是从直接物理内存、分段内存、分页内存一步一步走过来的,有着沉重的历史包袱。在x86 32上,分段机制和分页机制是并存的,系统可以选择只使用分段机制或者两种机制都使用。linux的选择是使用分页机制,并在逻辑上屏蔽分段机制,因为分段机制是不能禁用的。逻辑上屏蔽分段机制的方法是,所有段的段基址都是0,段限长都是最大值,这样就相当于是不分段了。分段机制无法禁用的原因是因为cpu特权级是在分段机制中实现的,分页机制没有单独的cpu特权级机制。所以linux创建了4个段,__kernel_cs、__kernel_ds用于内核空间,__user_cs、__user_ds用于用户空间,它们在会空间切换时自动切换,这样cpu特权级就跟着切换了。对于x86 64,从硬件上基本屏蔽了分段,因为硬件规定cs、ds、es、ss这些段的段基址必须是0,段限长必须是最大值,软件设置其它值也没用。
因此我们在这里要强调一句,分段机制早就是历史了,x86 64已经从硬件上屏蔽了分段机制,linux早就从软件上屏蔽了分段机制。x86 cpu的寄存器cs、ds、es、fs和内核的__kernel_cs、__kernel_ds、__user_cs、__user_ds,已经不具有分段的意义了,它们的作用是为了实现cpu特权级的切换。
1.5 内存管理的目标
内存管理的目标除了前面所说的进程之间的隔离、进程与内核之间的隔离、减少物理内存并发使用的数量之外,还有以下几个目标。
1.减少内存碎片,包括外部碎片和内部碎片。外部碎片是指还在内存分配器中的内存,但是由于比较分散,无法满足用户大块连续内存分配的申请。内部碎片是指你申请了5个字节的内存,分配器给你分配了8个字节的内存,其中3个字节的内存是内部碎片。内存管理要尽量同时减少外部碎片和内部碎片。
2.内存分配接口要灵活多样,同时满足多种不同的内存分配需求。既要满足大块连续内存分配的需求,又能满足小块零碎内存分配的需求。
3.内存分配效率要高。内存分配要尽量快地完成,比如说你设计了一种算法,能完全解决内存碎片问题,但是内存算法实现得特别复杂,每次分配都需要1毫秒的时间,这就不可取了。
4.提高物理内存的利用率。比如及时回收物理内存、对内存进行压缩。
1.6 linux内存管理体系
linux内存管理的整体模式是虚拟内存管理(分页内存管理),并在此基础上建立了一个庞大的内存管理体系。我们先来看一下总体结构图。
整个体系分为3部分,左边是物理内存,右边是虚拟内存,中间是虚拟内存映射(分页机制)。我们先从物理内存说起,内存管理的基础还是物理内存的管理。
物理内存那么大,应该怎么管理呢?首先要对物理内存进行层级区划,其原理可以类比于我国的行政区划管理。我国幅员辽阔,国家直接管理个人肯定是不行的,我国采取的是省县乡三级管理体系。把整个国家按照一定的规则和历史原因分成若干个省,每个省由省长管理。每个省再分成若干个县,每个县由县长管理。每个县再分成若干个乡,每个乡由乡长管理,乡长直接管理个人。(注意,类比是理解工具,不是论证工具)。对应的,物理内存也是采用类似的三级区域划分的方式来管理的,三个层级分别叫做节点(node)、区域(zone)、页面(page),对应到省、县、乡。系统首先把整个物理内存划分为n个节点,内存节点只是叫节点,大家不能把它看成一个点,要把它看成是相当于一个省的大区域。每个节点都有一个节点描述符,相当于是省长。节点下面再划分区域,每个区域都有区域描述符,相当于是县长。区域下面再划分页面,每个页面都有页面描述符,相当于是乡长。页面再下面就是字节了,相当于是个人。
对物理内存建立三级区域划分之后,就可以在其基础之上建立分配体系了。物理内存的分配体系可以类比于一个公司的销售体系,有工厂直接进行大额销售,有批发公司进行大量批发,有小卖部进行日常零售。物理内存的三级分配体系分别是buddy system、slab allocator和kmalloc。buddy system相当于是工厂销售,slab allocator相当于是批发公司,kmalloc相当于是小卖部,分别满足人们不同规模的需求。
物理内存有分配也有释放,但是当分配速度大于释放速度的时候,物理内存就会逐渐变得不够用了。此时我们就要进行内存回收了。内存回收首先考虑的是内存规整,也就是内存碎片整理,因为有可能我们不是可用内存不足了,而是内存太分散了,没法分配连续的内存。内存规整之后如果还是分配不到内存的话,就会进行页帧回收。内核的物理内存是不换页的,所以内核只会进行缓存回收。用户空间的物理内存是可以换页的,所以会对用户空间的物理内存进行换页以便回收其物理内存。用户空间的物理内存分为文件页和匿名页。对于文件页,如果其是clean的,可以直接丢弃内容,回收其物理内存,如果其是dirty的,则会先把其内容写回到文件,然后再回收内存。对于匿名页,如果系统配置的有swap区的话,则会把其内容先写入swap区,然后再回收,如果系统没有swap区的话则不会进行回收。把进程占用的但是当前并不在使用的物理内存进行回收,并分配给新的进程来使用的过程就叫做换页。进程被换页的物理内存后面如果再被使用到的话,还会通过缺页异常再换入内存。如果页帧回收之后还没有得到足够的物理内存,内核将会使用最后一招,oom killer。oom killer会按照一定的规则选择一个进程将其杀死,然后其物理内存就被释放了。
内核还有三个内存压缩技术zram、zswap、zcache,图里并没有画出来。它们产生的原因并不相同,zram和zswap产生的原因是因为把匿名页写入swap区是io操作,是非常耗时的,使用zram和zswap可以达到用空间换时间的效果。zcache产生的原因是因为内核一般都有大量的pagecache,pagecache是对文件的缓存,有些文件缓存暂时用不到,可以对它们进行压缩,以节省内存空间,到用的时候再解压缩,以达到用时间换空间的效果。
物理内存的这些操作都是在内核里进行的,但是cpu访问内存用的并不是物理内存地址,而是虚拟内存地址。内核需要建立页表把虚拟内存映射到物理内存上,然后cpu就可以通过mmu用虚拟地址来访问物理内存了。虚拟内存地址空间分为两部分,内核空间和用户空间。内核空间只有一个,其页表映射是在内核启动的早期就建立的。用户空间有n个,用户空间是随着进程的创建而建立的,但是其页表映射并不是马上建立,而是在程序的运行过程中通过缺页异常逐步建立的。内核页表建立好了之后就不会再取消了,所以内核是不换页的,用户页表建立之后可能会因为内存回收而取消,所以用户空间是换页的。内核页表是在内核启动时建立的,所以内核空间的映射是线性映射,用户空间的页表是在运行时动态创建的,不可能做到线性映射,所以是随机映射。
有些书上会说用户空间是分页的,内核是不分页的,这是对英语paging的错误翻译,paging在这里不是分页的意思,而是换页的意思。分页是指整个分页机制,换页是内存回收中的操作,两者的含义是完全不同的。
现在我们对linux内存管理体系已经有了宏观上的了解,下面我们就来对每个模块进行具体地分析。
二、物理内存区划
内核对物理内存进行了三级区划。为什么要进行三级区划,具体怎么划分的呢?这个不是软件随意决定的,而是和硬件因素有关。下面我们来看一下每一层级划分的原因,以及软件上是如果描述的。
2.1 物理内存节点
我国的省为什么要按照现在的这个形状来划分呢,主要是依据山川地形还有民俗风情等历史原因。那么物理内存划分为节点的原因是什么呢?这就要从uma、numa说起了。我们用三个图来看一下。
图中的cpu都是物理cpu。当一个系统中的cpu越来越多、内存越来越多的时候,内存总线就会成为一个系统的瓶颈。如果大家都还挤在同一个总线上,速度必然很慢。于是我们可以采取一种方法,把一部分cpu和一部分内存直连在一起,构成一个节点,不同节点之间cpu访问内存采用间接方式。节点内的内存访问速度就会很快,节点之间的内存访问速度虽然很慢,但是我们可以尽量减少节点之间的内存访问,这样系统总的内存访问速度就会很快。
linux中的代码对uma和numa是统一处理的,因为uma可以看成是只有一个节点的numa。如果编译内核时配置了config_numa,内核支持numa架构的计算机,内核中会定义节点指针数组来表示各个node。如果编译内核时没有配置config_numa,则内核只支持uma架构的计算机,内核中会定义一个内存节点。这样所有其它的代码都可以统一处理了。
下面我们先来看一下节点描述符的定义。linux-src/include/linux/mmzone.h
typedef struct pglist_data { /*  * node_zones contains just the zones for this node. not all of the  * zones may be populated, but it is the full list. it is referenced by  * this node's node_zonelists as well as other node's node_zonelists.  */ struct zone node_zones[max_nr_zones]; /*  * node_zonelists contains references to all zones in all nodes.  * generally the first zones will be references to this node's  * node_zones.  */ struct zonelist node_zonelists[max_zonelists]; int nr_zones; /* number of populated zones in this node */#ifdef config_flatmem /* means !sparsemem */ struct page *node_mem_map;#ifdef config_page_extension struct page_ext *node_page_ext;#endif#endif#if defined(config_memory_hotplug) || defined(config_deferred_struct_page_init) /*  * must be held any time you expect node_start_pfn,  * node_present_pages, node_spanned_pages or nr_zones to stay constant.  * also synchronizes pgdat->first_deferred_pfn during deferred page  * init.  *  * pgdat_resize_lock() and pgdat_resize_unlock() are provided to  * manipulate node_size_lock without checking for config_memory_hotplug  * or config_deferred_struct_page_init.  *  * nests above zone->lock and zone->span_seqlock  */ spinlock_t node_size_lock;#endif unsigned long node_start_pfn; unsigned long node_present_pages; /* total number of physical pages */ unsigned long node_spanned_pages; /* total size of physical page          range, including holes */ int node_id; wait_queue_head_t kswapd_wait; wait_queue_head_t pfmemalloc_wait; struct task_struct *kswapd; /* protected by        mem_hotplug_begin/end() */ int kswapd_order; enum zone_type kswapd_highest_zoneidx; int kswapd_failures;  /* number of 'reclaimed == 0' runs */#ifdef config_compaction int kcompactd_max_order; enum zone_type kcompactd_highest_zoneidx; wait_queue_head_t kcompactd_wait; struct task_struct *kcompactd; bool proactive_compact_trigger;#endif /*  * this is a per-node reserve of pages that are not available  * to userspace allocations.  */ unsigned long  totalreserve_pages;#ifdef config_numa /*  * node reclaim becomes active if more unmapped pages exist.  */ unsigned long  min_unmapped_pages; unsigned long  min_slab_pages;#endif /* config_numa */ /* write-intensive fields used by page reclaim */ zone_padding(_pad1_)#ifdef config_deferred_struct_page_init /*  * if memory initialisation on large machines is deferred then this  * is the first pfn that needs to be initialised.  */ unsigned long first_deferred_pfn;#endif /* config_deferred_struct_page_init */#ifdef config_transparent_hugepage struct deferred_split deferred_split_queue;#endif /* fields commonly accessed by the page reclaim scanner */ /*  * note: this is unused if memcg is enabled.  *  * use mem_cgroup_lruvec() to look up lruvecs.  */ struct lruvec  __lruvec; unsigned long  flags; zone_padding(_pad2_) /* per-node vmstats */ struct per_cpu_nodestat __percpu *per_cpu_nodestats; atomic_long_t  vm_stat[nr_vm_node_stat_items];} pg_data_t; 对于uma,内核会定义唯一的一个节点。linux-src/mm/memblock.c
#ifndef config_numastruct pglist_data __refdata contig_page_data;export_symbol(contig_page_data);#endif 查找内存节点的代码如下:linux-src/include/linux/mmzone.h
extern struct pglist_data contig_page_data;static inline struct pglist_data *node_data(int nid){ return &contig_page_data;} 对于numa,内核会定义内存节点指针数组,不同架构定义的不一定相同,我们以x86为例。linux-src/arch/x86/mm/numa.c
struct pglist_data *node_data[max_numnodes] __read_mostly;export_symbol(node_data); 查找内存节点的代码如下:linux-src/arch/x86/include/asm/mmzone_64.h
extern struct pglist_data *node_data[];#define node_data(nid)  (node_data[nid]) 可以看出对于uma,linux是统一定义一个内存节点的,对于numa,linux是在各架构代码下定义内存节点的。由于我们常见的电脑手机都是uma的,后面的我们都以uma为例进行讲解。pglist_data各自字段的含义我们在用到时再进行分析。
2.2 物理内存区域
内存节点下面再划分为不同的区域。划分区域的原因是什么呢?主要是因为各种软硬件的限制导致的。目前linux中最多可以有6个区域,这些区域并不是每个都必然存在,有的是由config控制的。有些区域就算代码中配置了,但是在系统运行的时候也可能为空。下面我们依次介绍一下这6个区域。
zone_dma:由配置项config_zone_dma决定是否存在。在x86上dma内存区域是物理内存的前16m,这是因为早期的isa总线上的dma控制器只有24根地址总线,只能访问16m物理内存。为了兼容这些老的设备,所以需要专门开辟前16m物理内存作为一个区域供这些设备进行dma操作时去分配物理内存。
zone_dma32:由配置项config_zone_dma32决定是否存在。后来的dma控制器有32根地址总线,可以访问4g物理内存了。但是在32位的系统上最多只支持4g物理内存,所以没必要专门划分一个区域。但是到了64位系统时候,很多cpu能支持48位到52位的物理内存,于是此时就有必要专门开个区域给32位的dma控制器使用了。
zone_normal:常规内存,无配置项控制,必然存在,除了其它几个内存区域之外的内存都是常规内存zone_normal。
zone_highmem:高端内存,由配置项config_highmem决定是否存在。只在32位系统上有,这是因为32位系统的内核空间只有1g,这1g虚拟空间中还有128m用于其它用途,所以只有896m虚拟内存空间用于直接映射物理内存,而32位系统支持的物理内存有4g,大于896m的物理内存是无法直接映射到内核空间的,所以把它们划为高端内存进行特殊处理。对于64位系统,从理论上来说,内核空间最大263-1,物理内存最大264,好像内核空间还是不够用。但是从现实来说,内核空间的一般配置为247,高达128t,物理内存暂时还远远没有这么多。所以从现实的角度来说,64位系统是不需要高端内存区域的。
zone_movable:可移动内存,无配置项控制,必然存在,用于可热插拔的内存。内核启动参数movablecore用于指定此区域的大小。内核参数kernelcore也可用于指定非可移动内存的大小,剩余的内存都是可移动内存。如果两者同时指定的话,则会优先保证非可移动内存的大小至少有kernelcore这么大。如果两者都没指定,则可移动内存大小为0。
zone_device:设备内存,由配置项config_zone_device决定是否存在,用于放置持久内存(也就是掉电后内容不会消失的内存)。一般的计算机中没有这种内存,默认的内存分配也不会从这里分配内存。持久内存可用于内核崩溃时保存相关的调试信息。
下面我们先来看一下这几个内存区域的类型定义。linux-src/include/linux/mmzone.h
enum zone_type {#ifdef config_zone_dma zone_dma,#endif#ifdef config_zone_dma32 zone_dma32,#endif zone_normal,#ifdef config_highmem zone_highmem,#endif zone_movable,#ifdef config_zone_device zone_device,#endif __max_nr_zones}; 我们再来看一下区域描述符的定义。linux-src/include/linux/mmzone.h
struct zone { /* read-mostly fields */ /* zone watermarks, access with *_wmark_pages(zone) macros */ unsigned long _watermark[nr_wmark]; unsigned long watermark_boost; unsigned long nr_reserved_highatomic; /*  * we don't know if the memory that we're going to allocate will be  * freeable or/and it will be released eventually, so to avoid totally  * wasting several gb of ram we must reserve some of the lower zone  * memory (otherwise we risk to run oom on the lower zones despite  * there being tons of freeable ram on the higher zones).  this array is  * recalculated at runtime if the sysctl_lowmem_reserve_ratio sysctl  * changes.  */ long lowmem_reserve[max_nr_zones];#ifdef config_numa int node;#endif struct pglist_data *zone_pgdat; struct per_cpu_pages __percpu *per_cpu_pageset; struct per_cpu_zonestat __percpu *per_cpu_zonestats; /*  * the high and batch values are copied to individual pagesets for  * faster access  */ int pageset_high; int pageset_batch;#ifndef config_sparsemem /*  * flags for a pageblock_nr_pages block. see pageblock-flags.h.  * in sparsemem, this map is stored in struct mem_section  */ unsigned long  *pageblock_flags;#endif /* config_sparsemem */ /* zone_start_pfn == zone_start_paddr >> page_shift */ unsigned long  zone_start_pfn; atomic_long_t  managed_pages; unsigned long  spanned_pages; unsigned long  present_pages;#if defined(config_memory_hotplug) unsigned long  present_early_pages;#endif#ifdef config_cma unsigned long  cma_pages;#endif const char  *name;#ifdef config_memory_isolation /*  * number of isolated pageblock. it is used to solve incorrect  * freepage counting problem due to racy retrieving migratetype  * of pageblock. protected by zone->lock.  */ unsigned long  nr_isolate_pageblock;#endif#ifdef config_memory_hotplug /* see spanned/present_pages for more description */ seqlock_t  span_seqlock;#endif int initialized; /* write-intensive fields used from the page allocator */ zone_padding(_pad1_) /* free areas of different sizes */ struct free_area free_area[max_order]; /* zone flags, see below */ unsigned long  flags; /* primarily protects free_area */ spinlock_t  lock; /* write-intensive fields used by compaction and vmstats. */ zone_padding(_pad2_) /*  * when free pages are below this point, additional steps are taken  * when reading the number of free pages to avoid per-cpu counter  * drift allowing watermarks to be breached  */ unsigned long percpu_drift_mark;#if defined config_compaction || defined config_cma /* pfn where compaction free scanner should start */ unsigned long  compact_cached_free_pfn; /* pfn where compaction migration scanner should start */ unsigned long  compact_cached_migrate_pfn[async_and_sync]; unsigned long  compact_init_migrate_pfn; unsigned long  compact_init_free_pfn;#endif#ifdef config_compaction /*  * on compaction failure, 1<> page_shift)#define pfn_down(x) ((x) >> page_shift)#define pfn_phys(x) ((phys_addr_t)(x)  page_shift)) page_shift的值在大部分平台上都是等于12,2的12次方幂正好就是4k。
下面我们来看一下页面描述符的定义。linux-src/include/linux/mm_types.h
struct page { unsigned long flags;  /* atomic flags, some possibly      * updated asynchronously */ /*  * five words (20/40 bytes) are available in this union.  * warning: bit 0 of the first word is used for pagetail(). that  * means the other users of this union must not use the bit to  * avoid collision and false-positive pagetail().  */ union {  struct { /* page cache and anonymous pages */   /**    * @lru: pageout list, eg. active_list protected by    * lruvec->lru_lock.  sometimes used as a generic list    * by the page owner.    */   struct list_head lru;   /* see page-flags.h for page_mapping_flags */   struct address_space *mapping;   pgoff_t index;  /* our offset within mapping. */   /**    * @private: mapping-private opaque data.    * usually used for buffer_heads if pageprivate.    * used for swp_entry_t if pageswapcache.    * indicates order in the buddy system if pagebuddy.    */   unsigned long private;  };  struct { /* page_pool used by netstack */   /**    * @pp_magic: magic value to avoid recycling non    * page_pool allocated pages.    */   unsigned long pp_magic;   struct page_pool *pp;   unsigned long _pp_mapping_pad;   unsigned long dma_addr;   union {    /**     * dma_addr_upper: might require a 64-bit     * value on 32-bit architectures.     */    unsigned long dma_addr_upper;    /**     * for frag page support, not supported in     * 32-bit architectures with 64-bit dma.     */    atomic_long_t pp_frag_count;   };  };  struct { /* slab, slob and slub */   union {    struct list_head slab_list;    struct { /* partial pages */     struct page *next;#ifdef config_64bit     int pages; /* nr of pages left */     int pobjects; /* approximate count */#else     short int pages;     short int pobjects;#endif    };   };   struct kmem_cache *slab_cache; /* not slob */   /* double-word boundary */   void *freelist;  /* first free object */   union {    void *s_mem; /* slab: first object */    unsigned long counters;  /* slub */    struct {   /* slub */     unsigned inuse:16;     unsigned objects:15;     unsigned frozen:1;    };   };  };  struct { /* tail pages of compound page */   unsigned long compound_head; /* bit zero is set */   /* first tail page only */   unsigned char compound_dtor;   unsigned char compound_order;   atomic_t compound_mapcount;   unsigned int compound_nr; /* 1 zone) */}; 在uma上,后备区域只有一个链表,就是本节点内的后备区域,在numa中后备区域有两个链表,包括本节点内的后备区域和其它节点的后备区域。这些后备区域是在内核启动时初始化的。对于本节点的后备区域,是按照区域类型的id排列的,高id的排在前面,低id的排在后面,后面的是前面的后备,前面的区域内存不足时可以从后面的区域里分配内存,反过来则不行。比如movable区域的内存不足时可以从normal区域来分配,normal区域的内存不足时可以从dma区域来分配,反过来则不行。对于其它节点的后备区域,除了会符合前面的规则之外,还会考虑后备区域是按照节点优先的顺序来排列还是按照区域类型优先的顺序来排列。
下面我们再来看一下分配行为的flag都是什么含义。
__gfp_high:调用者的优先级很高,要尽量满足分配请求。
__gfp_atomic:调用者处在原子场景中,分配过程不能回收页或者睡眠,一般是中断处理程序会用。
__gfp_io:可以进行磁盘io操作。
__gfp_fs:可以进行文件系统的操作。
__gfp_kswapd_reclaim:当内存不足时允许异步回收。
__gfp_reclaim:当内存不足时允许同步回收和异步回收。
__gfp_repeat:允许重试,重试多次以后还是没有内存就返回失败。
__gfp_nofail:不能失败,必须无限次重试。
__gfp_noretry:不要重试,当直接回收和内存规整之后还是分配不到内存的话就返回失败。
__gfp_zero:把要分配的页清零。
还有一些其它的flag就不再一一进行介绍了。
如果我们每次分配内存都把这些flag一一进行组合,那就太麻烦了,所以系统为我们定义了一些常用的组合,如下所示:linux-src/include/linux/gfp.h
#define gfp_atomic (__gfp_high|__gfp_atomic|__gfp_kswapd_reclaim)#define gfp_kernel (__gfp_reclaim | __gfp_io | __gfp_fs)#define gfp_noio (__gfp_reclaim)#define gfp_nofs (__gfp_reclaim | __gfp_io)#define gfp_user (__gfp_reclaim | __gfp_io | __gfp_fs | __gfp_hardwall)#define gfp_dma  __gfp_dma#define gfp_dma32 __gfp_dma32#define gfp_highuser (gfp_user | __gfp_highmem)#define gfp_highuser_movable (gfp_highuser | __gfp_movable | __gfp_skip_kasan_poison) 中断中分配内存一般用gfp_atomic,内核自己使用的内存一般用gfp_kernel,为用户空间分配内存一般用gfp_highuser_movable。
我们再来看一下直接返回虚拟内存的接口函数。linux-src/include/linux/gfp.h
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);#define __get_free_page(gfp_mask)  __get_free_pages((gfp_mask), 0)#define __get_dma_pages(gfp_mask, order) __get_free_pages((gfp_mask) | gfp_dma, (order))unsigned long get_zeroed_page(gfp_t gfp_mask);void free_pages(unsigned long addr, unsigned int order);#define free_page(addr) free_pages((addr), 0) 此接口不能分配highmem中的内存,因为highmem中的内存不是直接映射到内核空间中去的。除此之外这个接口和前面的没有区别,其参数函数也跟前面的一样,就不再赘述了。
3.1.5 伙伴系统的实现
下面我们再来看一下伙伴系统的分配算法。linux-src/mm/page_alloc.c
/* * this is the 'heart' of the zoned buddy allocator. */struct page *__alloc_pages(gfp_t gfp, unsigned int order, int preferred_nid,       nodemask_t *nodemask){ struct page *page;  /* first allocation attempt */ page = get_page_from_freelist(alloc_gfp, order, alloc_flags, &ac); if (likely(page))  goto out; page = __alloc_pages_slowpath(alloc_gfp, order, &ac);out: return page;} 伙伴系统的所有分配接口最终都会使用__alloc_pages这个函数来进行分配。对这个函数进行删减之后,其逻辑也比较简单清晰,先使用函数get_page_from_freelist直接从free_area中进行分配,如果分配不到就使用函数 __alloc_pages_slowpath进行内存回收。内存回收的内容在下一章里面讲。
3.2 slab allocator
伙伴系统的最小分配粒度是页面,但是内核中有很多大量的同一类型结构体的分配请求,比如说进程的结构体task_struct,如果使用伙伴系统来分配显然不合适,如果自己分配一个页面,然后可以分割成多个task_struct,显然也很麻烦,于是内核中给我们提供了slab分配机制来满足这种需求。slab的基本思想很简单,就是自己先从伙伴系统中分配一些页面,然后把这些页面切割成一个个同样大小的基本块,用户就可以从slab中申请分配一个同样大小的内存块了。如果slab中的内存不够用了,它会再向伙伴系统进行申请。不同的slab其基本块的大小并不相同,内核的每个模块都要为自己的特定需求分配特定的slab,然后再从这个slab中分配内存。
刚开始的时候内核中就只有一个slab,其接口和实现都叫slab。但是后来内核中又出现了两个slab实现,slob和slub。slob是针对嵌入式系统进行优化的,slub是针对内存比较多的系统进行优化的,它们的接口还是slab。由于现在的计算机内存普遍都比较大,连手机的的内存都6g、8g起步了,所以现在除了嵌入式系统之外,内核默认使用的都是slub。下面我们画个图看一下它们的关系。
可以看到slab在不同的语境下有不同的含义,有时候指的是整个slab机制,有时候指的是slab接口,有时候指的是slab实现。如果我们在讨论问题的时候遇到了歧义,可以加上汉语后缀以明确语义。
3.2.1 slab接口
下面我们来看一下slab的接口:linux-src/include/linux/slab.h
struct kmem_cache *kmem_cache_create(const char *name, unsigned int size,   unsigned int align, slab_flags_t flags,   void (*ctor)(void *));void kmem_cache_destroy(struct kmem_cache *);void *kmem_cache_alloc(struct kmem_cache *, gfp_t flags);void kmem_cache_free(struct kmem_cache *, void *); 我们在使用slab时首先要创建slab,创建slab用的是接口kmem_cache_create,其中最重要的参数是size,它是基本块的大小,一般我们都会传递sizeof某个结构体。创建完slab之后,我们用kmem_cache_alloc从slab中分配内存,第一个参数指定哪个是从哪个slab中分配,第二个参数gfp指定如果slab的内存不足了如何从伙伴系统中去分配内存,gfp的函数和前面伙伴系统中讲的相同,此处就不再赘述了,函数返回的是一个指针,其指向的内存大小就是slab在创建时指定的基本块的大小。当我们用完一块内存时,就要用kmem_cache_free把它还给slab,第一个参数指定是哪个slab,第二个参数是我们要返回的内存。如果我们想要释放整个slab的话,就使用接口kmem_cache_destroy。
3.2.2 slab实现
暂略
3.2.3 slob实现
暂略
3.2.4 slub实现
暂略
3.3 kmalloc
内存中还有一些偶发的零碎的内存分配需求,一个模块如果仅仅为了分配一次5字节的内存,就去创建一个slab,那显然不划算。为此内核创建了一个统一的零碎内存分配器kmalloc,用户可以直接请求kmalloc分配若干个字节的内存。kmalloc底层用的还是slab机制,kmalloc在启动的时候会预先创建一些不同大小的slab,用户请求分配任意大小的内存,kmalloc都会去大小刚刚满足的slab中去分配内存。
下面我们来看一下kmalloc的接口:linux-src/include/linux/slab.h
void *kmalloc(size_t size, gfp_t flags);void kfree(const void *); 可以看到kmalloc的接口很简单,使用接口kmalloc就可以分配内存,第一个参数是你要分配的内存大小,第二个参数和伙伴系统的参数是一样的,这里就不再赘述了,返回值是一个内存指针,用这个指针就可以访问分配到的内存了。内存使用完了之后用kfree进行释放,参数是刚才分配到的内存指针。
我们以slub实现为例讲一下kmalloc的逻辑。kmalloc中会定义一个全局的slab指针的二维数组,第一维下标代表的是kmalloc的类型,默认有四种类型,分别有dma和normal,这两个代表的是gfp中的区域,还有两个是cgroup和reclaim,cgroup代表的是在memcg中分配内存,reclaim代表的是可回收内存。第二维下标代表的是基本块大小的2的对数,不过下标0、1、2是例外,有特殊含义。在系统初始化的时候,会初始化这个数组,创建每一个slab,下标0除外,下标1对应的slab的基本块大小是96,下标2对应的slab的基本块的大小是192。在用kmalloc分配内存的时候,会先处理特殊情况,当size是0的时候直接返回空指针,当size大于8k的时候会则直接使用伙伴系统进行分配。然后先根据gfp参数选择kmalloc的类型,再根据size的大小选择index。如果2^n-1^+1 < size <= 2^n^,则index等于n,但是有特殊情况,当 64 < size <= 96时,index等于1,当 128 < size chosen_points = long_min; struct task_struct *p; rcu_read_lock(); for_each_process(p)  if (oom_evaluate_task(p, oc))   break; rcu_read_unlock();} 函数首先把chosen_points初始化为最小的long值,这个值是用来比较所有的oom_score值,最后谁的值最大就选中哪个进程。然后函数已经遍历所有进程,计算其oom_score,并更新chosen_points和被选中的task,有点类似于选择排序。我们继续看oom_evaluate_task函数是如何评估每个进程的函数。
static int oom_evaluate_task(struct task_struct *task, void *arg){ struct oom_control *oc = arg; long points; if (oom_unkillable_task(task))  goto next; /* p may not have freeable memory in nodemask */ if (!is_memcg_oom(oc) && !oom_cpuset_eligible(task, oc))  goto next; if (oom_task_origin(task)) {  points = long_max;  goto select; } points = oom_badness(task, oc->totalpages); if (points == long_min || points chosen_points)  goto next;select: if (oc->chosen)  put_task_struct(oc->chosen); get_task_struct(task); oc->chosen = task; oc->chosen_points = points;next: return 0;abort: if (oc->chosen)  put_task_struct(oc->chosen); oc->chosen = (void *)-1ul; return 1;} 此函数首先会跳过所有不适合kill的进程,如init进程、内核线程、oom_disable进程等。然后通过select_bad_process算出此进程的得分points 也就是oom_score,并和上一次的胜出进程进行比较,如果小的会话就会goto next 返回,如果大的话就会更新oc->chosen 的task 和 chosen_points 也就是目前最高的oom_score。那么 oom_badness是如何计算的呢?
long oom_badness(struct task_struct *p, unsigned long totalpages){ long points; long adj; if (oom_unkillable_task(p))  return long_min; p = find_lock_task_mm(p); if (!p)  return long_min; adj = (long)p->signal->oom_score_adj; if (adj == oom_score_adj_min ||   test_bit(mmf_oom_skip, &p->mm->flags) ||   in_vfork(p)) {  task_unlock(p);  return long_min; } points = get_mm_rss(p->mm) + get_mm_counter(p->mm, mm_swapents) +  mm_pgtables_bytes(p->mm) / page_size; task_unlock(p); adj *= totalpages / 1000; points += adj; return points;} oom_badness首先把unkiller的进程也就是init进程内核线程直接返回 long_min,这样它们就不会被选中而杀死了,这里看好像和前面的检测冗余了,但是实际上这个函数还被/proc//oom_score的show函数调用用来显示数值,所以还是有必要的,这里也说明了一点,oom_score的值是不保留的,每次都是即时计算。然后又把oom_score_adj为-1000的进程直接也返回long_min,这样用户空间专门设置的进程就不会被kill了。最后就是计算oom_score了,计算方法比较简单,就是此进程使用的rss驻留内存、页表、swap之和越大,也就是此进程所用的总内存越大,oom_score的值就越大,逻辑简单直接,谁用的物理内存最多就杀谁,这样就能够回收更多的物理内存,而且使用内存最多的进程很可能是内存泄漏了,所以此算法虽然很简单,但是也很合理。
可能很多人会觉得这里讲的不对,和自己在网上的看到的逻辑不一样,那是因为网上有很多讲oom_score算法的文章都是基于2.6版本的内核讲的,那个算法比较复杂,会考虑进程的nice值,nice值小的,oom_score会相应地降低,也会考虑进程的运行时间,运行时间越长,oom_score值也会相应地降低,因为当时认为进程运行的时间长消耗内存多是合理的。但是这个算法会让那些缓慢内存泄漏的进程逃脱制裁。因此后来这个算法就改成现在这样的了,只考虑谁用的内存多就杀谁,简洁高效。
五、物理内存压缩
暂略
5.1 zram
5.2 zswap
5.3 zcache
六、虚拟内存映射
开启分页内存机制之后,cpu访问一切内存都要通过虚拟内存地址访问,cpu把虚拟内存地址发送给mmu,mmu把虚拟内存地址转换为物理内存地址,然后再用物理内存地址通过mc(内存控制器)访问内存。mmu里面有两个部件,tlb和ptw。tlb可以意译地址转换缓存器,它是缓存虚拟地址解析结果的地方。ptw可以意译为虚拟地址解析器,它负责解析页表,把虚拟地址转换为物理地址,然后再送去mc进行访问。同时其转换结果也会被送去tlb进行缓存,下次再访问相同虚拟地址的时候就不用再去解析了,可以直接用缓存的结果。
6.1 页表
虚拟地址映射的基本单位是页面不是字节,一个虚拟内存的页面会被映射到一个物理页帧上。mmu把虚拟地址转换为物理地址的方法是通过查找页表。一个页表的大小也是一个页面,4k大小,页表的内容可以看做是页表项的数组,一个页表项是一个物理地址,指向一个物理页帧,在32位系统上,物理地址是32位也就是4个字节,所以一个页表有4k/4=1024项,每一项指向一个物理页帧,大小是4k,所以一个页表可以表达4m的虚拟内存,要想表达4g的虚拟内存空间,需要有1024个页表才行,每个页表4k,一共需要4m的物理内存。4m的物理内存看起来好像不大,但是每个进程都需要有4m的物理内存做页表,如果有100个进程,那就需要有400m物理内存,这就太浪费物理内存了,而且大部分时候,一个进程的大部分虚拟内存空间并没有使用。为此我们可以采取两级页表的方法来进行虚拟内存映射。在多级页表体系中,最后一级页表还叫页表,其它的页表叫做页目录,但是我们有时候也会都叫做页表。对于两级页表体系,一级页表还是一个页面,4k大小,每个页表项还是4个字节,一共有1024项,一级页表的页表项是二级页表的物理地址,指向二级页表,二级页表的内容和前面一样。一级页表只有一个,4k,有1024项,指向1024个二级页表,一个一级页表项也就是一个二级页表可以表达4m虚拟内存,一级页表总共能表达4g虚拟内存,此时所有页表占用的物理内存是4m加4k。看起来使用二级页表好像还多用了4k内存,但是在大多数情况下,很多二级页表都用不上,所以不用分配内存。如果一个进程只用了8m物理内存,那么它只需要一个一级页表和两个二级页表就行了,一级页表中只需要使用两项指向两个二级页表,两个二级页表填充满,就可以表达8m虚拟内存映射了,此时总共用了3个页表,12k物理内存,页表的内存占用大大减少了。所以在32位系统上,采取的是两级页表的方式,每级的一个页表都是1024项,32位虚拟地址正好可以分成三份,10、10、12,第一个10位可以用于在一级页表中寻址,第二个10位在二级页表中寻址,最后12位可以表达一个页面中任何一个字节。
在64位系统上,一个页面还是4k大小,一个页表还是一个页面,但是由于物理地址是64位的,所以一个页表项变成了8个字节,一个页表就只有512个页表项了,这样一个页表就只能表达2m虚拟内存了。寻址512个页表项只需要9位就够了。在x86 64上,虚拟地址有64位,但是64位的地址空间实在是太大了,所以我们只需要用其中一部分就行了。x86 64上有两种虚拟地址位数可选,48位和57位,分别对应着四级页表和五级页表。为啥是四级页表和五级页表呢?因为48=9+9+9+12,57=9+9+9+9+12,12可以寻址一个页面内的每一个字节,9可以寻址一级页表中的512个页表项。
linux内核最多支持五级页表,在五级页表体系中,每一级页表分别叫做pgd、p4d、pud、pmd、pte。如果页表不够五级的,从第二级开始依次去掉一级。
页表项是下一级页表或者最终页帧的物理地址,页表也是一个页帧,页帧的地址都是4k对齐的,所以页表项中的物理地址的最后12位一定都是0,既然都是0,那么就没必要表示出来了,我们就可以把这12位拿来做其它用途了。下面我们来看一下x86的页表项格式。
这是32位的页表项格式,其中12-31位是物理地址。
p,此页表项是否有效,1代表有效,0代表无效,为0时其它字段无意义。
r/w,0代表只读,1代表可读写。
u/s,0代表内核页表,1代表用户页面。
pwt,page-level write-through
pcd,page-level cache disable
a,accessed; indicates whether software has accessed the page
d,dirty; indicates whether software has written to the  page
pat,if the pat is supported, indirectly determines the memory type used to access the page
g,global; determines whether the translation is global
64位系统的页表项格式和这个是一样的,只不过是物理地址扩展到了硬件支持的最高物理地址位数。
6.2 mmu
mmu是通过遍历页表把虚拟地址转换为物理地址的。其过程如下所示:
cr3是cpu的寄存器,存放的是pgd的物理地址。mmu首先通过cr3获取pgd的物理地址,然后以虚拟地址的31-22位为index,在pgd中找到相应的页表项,先检测页表项的p是否存在,r/w是否有读写权限,u/s是否有访问权限,如果检测都通过了,则进入下一步,如果没通过则触发缺页异常。关于中断与异常的基本原理请参看《深入理解linux中断机制》。如果检测通过了,页表项的31-12位代表pte的物理地址,取虚拟地址中的21-12位作为index,在pte中找到对应的页表项,也是先各种检测,如果没通过则触发缺页异常。如果通过了,则31-12位代表最终页帧的物理地址,然后把虚拟地址的11-0位作为页内偏移加上去,就找到了虚拟地址对应的物理地址了,然后送到mc进行访问。64位系统的逻辑和32位是相似的,只不过是多了几级页表而已,就不再赘述了。
一个进程的所有页表通过页表项的指向构成了一个页表树,页表树的根节点是pgd,根指针是cr3。页表树中所有的地址都是物理地址,mmu在遍历页表树时使用物理地址可以直接访问内存。一个页表只有加入了某个页表树才有意义,孤立的页表是没有意义的。每个进程都有一个页表树,切换进程就会切换页表树,切换页表树的方法是给cr3赋值,让其指向当前进程的页表树的根节点也就是pgd。进程的虚拟内存空间分为两部分,内核空间和用户空间,所有进程的内核空间都是共享的,所以所有进程的页表树根节点的内核子树都相同。
6.3 缺页异常
mmu在解析虚拟内存时如果发现了读写错误或者权限错误或者页表项无效,就会触发缺页异常让内核来处理。下面我们来看一下x86的缺页异常处理的过程。linux-src/arch/x86/mm/fault.c
define_idtentry_raw_errorcode(exc_page_fault){ unsigned long address = read_cr2(); irqentry_state_t state; prefetchw(¤t->mm->mmap_lock); if (kvm_handle_async_pf(regs, (u32)address))  return; state = irqentry_enter(regs); instrumentation_begin(); handle_page_fault(regs, error_code, address); instrumentation_end(); irqentry_exit(regs, state);}static __always_inline voidhandle_page_fault(struct pt_regs *regs, unsigned long error_code,         unsigned long address){ trace_page_fault_entries(regs, error_code, address); if (unlikely(kmmio_fault(regs, address)))  return; /* was the fault on kernel-controlled part of the address space? */ if (unlikely(fault_in_kernel_space(address))) {  do_kern_addr_fault(regs, error_code, address); } else {  do_user_addr_fault(regs, error_code, address);  /*   * user address page fault handling might have reenabled   * interrupts. fixing up all potential exit points of   * do_user_addr_fault() and its leaf functions is just not   * doable w/o creating an unholy mess or turning the code   * upside down.   */  local_irq_disable(); }}static voiddo_kern_addr_fault(struct pt_regs *regs, unsigned long hw_error_code,     unsigned long address){ warn_on_once(hw_error_code & x86_pf_pk);#ifdef config_x86_32 if (!(hw_error_code & (x86_pf_rsvd | x86_pf_user | x86_pf_prot))) {  if (vmalloc_fault(address) >= 0)   return; }#endif if (is_f00f_bug(regs, hw_error_code, address))  return; /* was the fault spurious, caused by lazy tlb invalidation? */ if (spurious_kernel_fault(hw_error_code, address))  return; /* kprobes don't want to hook the spurious faults: */ if (warn_on_once(kprobe_page_fault(regs, x86_trap_pf)))  return; bad_area_nosemaphore(regs, hw_error_code, address);}static inlinevoid do_user_addr_fault(struct pt_regs *regs,   unsigned long error_code,   unsigned long address){ struct vm_area_struct *vma; struct task_struct *tsk; struct mm_struct *mm; vm_fault_t fault; unsigned int flags = fault_flag_default; tsk = current; mm = tsk->mm; if (unlikely((error_code & (x86_pf_user | x86_pf_instr)) == x86_pf_instr)) {  /*   * whoops, this is kernel mode code trying to execute from   * user memory.  unless this is amd erratum #93, which   * corrupts rip such that it looks like a user address,   * this is unrecoverable.  don't even try to look up the   * vma or look for extable entries.   */  if (is_errata93(regs, address))   return;  page_fault_oops(regs, error_code, address);  return; } /* kprobes don't want to hook the spurious faults: */ if (warn_on_once(kprobe_page_fault(regs, x86_trap_pf)))  return; /*  * reserved bits are never expected to be set on  * entries in the user portion of the page tables.  */ if (unlikely(error_code & x86_pf_rsvd))  pgtable_bad(regs, error_code, address); /*  * if smap is on, check for invalid kernel (supervisor) access to user  * pages in the user address space.  the odd case here is wruss,  * which, according to the preliminary documentation, does not respect  * smap and will have the user bit set so, in all cases, smap  * enforcement appears to be consistent with the user bit.  */ if (unlikely(cpu_feature_enabled(x86_feature_smap) &&       !(error_code & x86_pf_user) &&       !(regs->flags & x86_eflags_ac))) {  /*   * no extable entry here.  this was a kernel access to an   * invalid pointer.  get_kernel_nofault() will not get here.   */  page_fault_oops(regs, error_code, address);  return; } /*  * if we're in an interrupt, have no user context or are running  * in a region with pagefaults disabled then we must not take the fault  */ if (unlikely(faulthandler_disabled() || !mm)) {  bad_area_nosemaphore(regs, error_code, address);  return; } /*  * it's safe to allow irq's after cr2 has been saved and the  * vmalloc fault has been handled.  *  * user-mode registers count as a user access even for any  * potential system fault or cpu buglet:  */ if (user_mode(regs)) {  local_irq_enable();  flags |= fault_flag_user; } else {  if (regs->flags & x86_eflags_if)   local_irq_enable(); } perf_sw_event(perf_count_sw_page_faults, 1, regs, address); if (error_code & x86_pf_write)  flags |= fault_flag_write; if (error_code & x86_pf_instr)  flags |= fault_flag_instruction;#ifdef config_x86_64 /*  * faults in the vsyscall page might need emulation.  the  * vsyscall page is at a high address (>page_offset), but is  * considered to be part of the user address space.  *  * the vsyscall page does not have a real vma, so do this  * emulation before we go searching for vmas.  *  * pkru never rejects instruction fetches, so we don't need  * to consider the pf_pk bit.  */ if (is_vsyscall_vaddr(address)) {  if (emulate_vsyscall(error_code, regs, address))   return; }#endif /*  * kernel-mode access to the user address space should only occur  * on well-defined single instructions listed in the exception  * tables.  but, an erroneous kernel fault occurring outside one of  * those areas which also holds mmap_lock might deadlock attempting  * to validate the fault against the address space.  *  * only do the expensive exception table search when we might be at  * risk of a deadlock.  this happens if we  * 1. failed to acquire mmap_lock, and  * 2. the access did not originate in userspace.  */ if (unlikely(!mmap_read_trylock(mm))) {  if (!user_mode(regs) && !search_exception_tables(regs->ip)) {   /*    * fault from code in kernel from    * which we do not expect faults.    */   bad_area_nosemaphore(regs, error_code, address);   return;  }retry:  mmap_read_lock(mm); } else {  /*   * the above down_read_trylock() might have succeeded in   * which case we'll have missed the might_sleep() from   * down_read():   */  might_sleep(); } vma = find_vma(mm, address); if (unlikely(!vma)) {  bad_area(regs, error_code, address);  return; } if (likely(vma->vm_start vm_flags & vm_growsdown))) {  bad_area(regs, error_code, address);  return; } if (unlikely(expand_stack(vma, address))) {  bad_area(regs, error_code, address);  return; } /*  * ok, we have a good vm_area for this memory access, so  * we can handle it..  */good_area: if (unlikely(access_error(error_code, vma))) {  bad_area_access_error(regs, error_code, address, vma);  return; } /*  * if for any reason at all we couldn't handle the fault,  * make sure we exit gracefully rather than endlessly redo  * the fault.  since we never set fault_flag_retry_nowait, if  * we get vm_fault_retry back, the mmap_lock has been unlocked.  *  * note that handle_userfault() may also release and reacquire mmap_lock  * (and not return with vm_fault_retry), when returning to userland to  * repeat the page fault later with a vm_fault_nopage retval  * (potentially after handling any pending signal during the return to  * userland). the return to userland is identified whenever  * fault_flag_user|fault_flag_killable are both set in flags.  */ fault = handle_mm_fault(vma, address, flags, regs); if (fault_signal_pending(fault, regs)) {  /*   * quick path to respond to signals.  the core mm code   * has unlocked the mm for us if we get here.   */  if (!user_mode(regs))   kernelmode_fixup_or_oops(regs, error_code, address,       sigbus, bus_adrerr,       arch_default_pkey);  return; } /*  * if we need to retry the mmap_lock has already been released,  * and if there is a fatal signal pending there is no guarantee  * that we made any progress. handle this case first.  */ if (unlikely((fault & vm_fault_retry) &&       (flags & fault_flag_allow_retry))) {  flags |= fault_flag_tried;  goto retry; } mmap_read_unlock(mm); if (likely(!(fault & vm_fault_error)))  return; if (fatal_signal_pending(current) && !user_mode(regs)) {  kernelmode_fixup_or_oops(regs, error_code, address,      0, 0, arch_default_pkey);  return; } if (fault & vm_fault_oom) {  /* kernel mode? handle exceptions or die: */  if (!user_mode(regs)) {   kernelmode_fixup_or_oops(regs, error_code, address,       sigsegv, segv_maperr,       arch_default_pkey);   return;  }  /*   * we ran out of memory, call the oom killer, and return the   * userspace (which will retry the fault, or kill us if we got   * oom-killed):   */  pagefault_out_of_memory(); } else {  if (fault & (vm_fault_sigbus|vm_fault_hwpoison|        vm_fault_hwpoison_large))   do_sigbus(regs, error_code, address, fault);  else if (fault & vm_fault_sigsegv)   bad_area_nosemaphore(regs, error_code, address);  else   bug(); }} 缺页异常首先从cr2寄存器中读取发生异常的虚拟内存地址。然后根据此地址是在内核空间还是在用户空间,分别调用do_kern_addr_fault和do_user_addr_fault来处理。使用vmalloc时会出现内核空间的缺页异常。用户空间地址的缺页异常在做完各种检测处理之后会调用所有架构都通用的函数handle_mm_fault来处理。下面我们来看一下这个函数是怎么处理的。linux-src/mm/memory.c
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,      unsigned int flags, struct pt_regs *regs){ vm_fault_t ret; __set_current_state(task_running); if (!arch_vma_access_permitted(vma, flags & fault_flag_write,         flags & fault_flag_instruction,         flags & fault_flag_remote))  return vm_fault_sigsegv; if (flags & fault_flag_user)  mem_cgroup_enter_user_fault(); if (unlikely(is_vm_hugetlb_page(vma)))  ret = hugetlb_fault(vma->vm_mm, vma, address, flags); else  ret = __handle_mm_fault(vma, address, flags); return ret;}static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,  unsigned long address, unsigned int flags){ struct vm_fault vmf = {  .vma = vma,  .address = address & page_mask,  .flags = flags,  .pgoff = linear_page_index(vma, address),  .gfp_mask = __get_fault_gfp_mask(vma), }; unsigned int dirty = flags & fault_flag_write; struct mm_struct *mm = vma->vm_mm; pgd_t *pgd; p4d_t *p4d; vm_fault_t ret; pgd = pgd_offset(mm, address); p4d = p4d_alloc(mm, pgd, address); if (!p4d)  return vm_fault_oom; vmf.pud = pud_alloc(mm, p4d, address); return handle_pte_fault(&vmf);}static vm_fault_t handle_pte_fault(struct vm_fault *vmf){ pte_t entry; if (!vmf->pte) {  if (vma_is_anonymous(vmf->vma))   return do_anonymous_page(vmf);  else   return do_fault(vmf); } if (!pte_present(vmf->orig_pte))  return do_swap_page(vmf); if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))  return do_numa_page(vmf); vmf->ptl = pte_lockptr(vmf->vma->vm_mm, vmf->pmd); spin_lock(vmf->ptl); entry = vmf->orig_pte; if (unlikely(!pte_same(*vmf->pte, entry))) {  update_mmu_tlb(vmf->vma, vmf->address, vmf->pte);  goto unlock; } if (vmf->flags & fault_flag_write) {  if (!pte_write(entry))   return do_wp_page(vmf);  entry = pte_mkdirty(entry); } entry = pte_mkyoung(entry); if (ptep_set_access_flags(vmf->vma, vmf->address, vmf->pte, entry,    vmf->flags & fault_flag_write)) {  update_mmu_cache(vmf->vma, vmf->address, vmf->pte); } else {  if (vmf->flags & fault_flag_tried)   goto unlock;  if (vmf->flags & fault_flag_write)   flush_tlb_fix_spurious_fault(vmf->vma, vmf->address); }unlock: pte_unmap_unlock(vmf->pte, vmf->ptl); return 0;}static vm_fault_t do_fault(struct vm_fault *vmf){ struct vm_area_struct *vma = vmf->vma; struct mm_struct *vm_mm = vma->vm_mm; vm_fault_t ret; if (!vma->vm_ops->fault) {  if (unlikely(!pmd_present(*vmf->pmd)))   ret = vm_fault_sigbus;  else {   vmf->pte = pte_offset_map_lock(vmf->vma->vm_mm,             vmf->pmd,             vmf->address,             &vmf->ptl);   if (unlikely(pte_none(*vmf->pte)))    ret = vm_fault_sigbus;   else    ret = vm_fault_nopage;   pte_unmap_unlock(vmf->pte, vmf->ptl);  } } else if (!(vmf->flags & fault_flag_write))  ret = do_read_fault(vmf); else if (!(vma->vm_flags & vm_shared))  ret = do_cow_fault(vmf); else  ret = do_shared_fault(vmf); if (vmf->prealloc_pte) {  pte_free(vm_mm, vmf->prealloc_pte);  vmf->prealloc_pte = null; } return ret;} 可以看到handle_mm_fault最终会调用handle_pte_fault进行处理。在handle_pte_fault中,会根据缺页的内存的类型进行相应的处理。
七、虚拟内存空间
cpu开启了分页内存机制之后,就只能通过虚拟内存来访问内存了。内核通过构建页表树来创建虚拟内存空间,一个页表树对应一个虚拟内存空间。虚拟内存空间又分为两部分,内核空间和用户空间。所有的页表树都共享内核空间,它们内核页表子树是相同的。内核空间和用户空间不仅在数量上不同,在权限上不同,在构建方式上也不同。内核空间在系统全局都只有一个,不仅在up上是如此,在smp上也是只有一个,多个cpu共享同一个内核空间。内核空间是特权空间,可以执行所有的操作,也可以访问用户空间。用户空间是非特权空间,很多操作不能做,也不能随意访问内核,唯一能访问内核的方式就是通过系统调用。内核空间和用户空间最大的不同是构建方式。内核空间是在系统启动时就构建好的,是完整构建的,物理内存和虚拟内存是直接一次就映射好的,而且是不会销毁的,因为系统运行着内核就要一直存在。用户空间是在创建进程时构建的,但是并没有完整构建,虚拟内存到物理内存的映射是随着进程的运行通过触发缺页异常一步一步构建的,而且在内存回收时还有可能被解除映射,最后随着进程的死亡,用户空间还会被销毁。下面我们看个图:
这个图是在讲进程调度时画的图,但是也能表明内核空间和用户空间的关系。下面我们再来看一下单个进程角度下内核空间与用户空间的关系图。
在32位系统上默认是内核占据上面1g虚拟空间,进程占据下面3g虚拟空间,有config选项可以选择其它比列,所有cpu架构都是如此。在64位系统上,由于64位的地址空间实在是太大了,linux并没有使用全部的虚拟内存空间,而是只使用其中一部分位数。使用的方法是把用户空间的高位补0,内核空间的高位补1,这样从64位地址空间的角度来看就是只使用了两段,中间留空,方便以后往中间扩展。中间留空的是非法内存空间,不能使用。具体使用多少位,高位如何补0,不同架构的选择是不同的。arm64在4k页面大小的情况下有39位和48位两种虚拟地址空间的选择。x86 64有48位和57位两种虚拟地址空间的选择。arm64是内核空间和用户空间都有这么多的地址空间,x86 64是内核空间和用户空间平分这么多的地址空间,上图中的大小也可以反应出这一点。
7.1 内核空间
系统在刚启动时肯定不可能直接就运行在虚拟内存之上。系统是先运行在物理内存上,然后去建立一部分恒等映射,恒等映射就是虚拟内存的地址和物理内存的地址相同的映射。恒等映射的范围不是要覆盖全部的物理内存,而是够当时内核的运行就可以了。恒等映射建立好之后就会开启分页机制,此时cpu就运行在虚拟内存上了。然后内核再进一步构建页表,把内核映射到其规定好的地方。最后内核跳转到其目标虚拟地址的地方运行,并把之前的恒等映射取消掉,现在内核就完全运行在虚拟内存上了。
由于内核是最先运行的,内核会把物理内存线性映射到自己的空间中去,而且是要把所有的物理内存都映射到内核空间。如果内核没有把全部物理内存都映射到内核空间,那不是因为不想,而是因为做不到。在x86 32上,内核空间只有1g,扣除一些其它用途保留的128m空间,内核能线性映射的空间只有896m,而物理内存可以多达4g,是没法都映射到内核空间的。所以内核会把小于896m的物理内存都映射到内核空间,大于896m的物理内存作为高端内存,可以动态映射到内核的vmalloc区。对于64位系统,就不存在这个烦恼了,虚拟内存空间远远大于物理内存的数量,所以内核会一下子把全部物理内存都映射到内核空间。
大家在这里可能有两个误解:一是认为物理内存映射就代表使用,不使用就不会映射,这是不对的,使用时肯定要映射,但是映射了不代表在使用,映射了可以先放在那,只有被内存分配器分配出去的才算是被使用;二是物理内存只会被内核空间或者用户空间两者之一映射,谁使用了就映射到谁的空间中去,这也是不对的,对于用户空间,只有其使用了物理内存才会去映射,但是对于内核空间,内核空间是管理者,它把所有物理内存都映射到自己的空间比较方便管理,而且映射了不代表使用。
64位和32位还有一个很大的不同。32位上是把小于896m的物理内存都线性映射到从3g开始的内核空间中去,32位上只有一个线性映射区间。64位上有两个线性映射区间,一是把内核代码和数据所在的物理内存映射到一个固定的地址区间中去,二是把所有物理内存都映射到某一段内存区间中去,显然内核本身所占用的物理内存被映射了两次。下面我们画图来看一看内核空间的布局。
32位的内核空间布局比较简单,前896m是直接映射区,后面是8m的的隔离区,然后是大约100多m的vmalloc区,再后面是持久映射区和固定映射区,其位置和大小是由宏决定的。
64位的内核空间布局比较复杂,而且不同的架构之间差异非常大,我们以x86 64 48位虚拟地址为例说一下。图中一列画不下,分成了两列,我们从48位-1看起,首先是由一个8t的空洞,然后是ldt remap,然后是直接映射区有64t,用来映射所有的物理内存,目前来说对于绝大部分计算机都够用了,然后是0.5t的空洞,然后是vmalloc和ioremap区有32t,然后是1t的空洞,然后是vmemmap区有1t,vmemmap就是我们前面所讲的所有页面描述符的数组,然后是1t的空洞,然后是kasan的影子内存有16t,紧接着再看48位-2,首先是2t的空洞,然后是cpu_entry_area,然后是0.5t的空洞,然后是%esp fixup stack,然后是444g的空洞,然后是efi的映射区域,然后是2t的空洞,然后是内核的映射区有512m,然后是ko的映射区有1520m,然后是fixmap和vsyscall,最后是2m的空洞。如果开启了kaslr,内核和映射区会增加512m,相应的ko的映射区会减少512m。
64位的内核空间中有直接映射区和内核映射区两个线性映射区,这两个区域都是线性映射,只不过是映射的起点不同。为什么要把内核再单独映射一遍呢?而且既然直接映射区已经把所有的物理内存都映射一遍了,那么为什么还有这么多的内存映射区呢?直接映射区的存在是为了方便管理物理内存,因为它和物理内存只差一个固定值。各种其它映射区的存在是为了方便内核的运行和使用。比如vmalloc区是为了方便进行随机映射,当内存碎片化比较严重,我们需要的内存又不要求物理上必须连续时,就可以使用vmalloc,它能把物理上不连续的内存映射到连续的虚拟内存上。vmemmap区域是为了在物理内存有较大空洞时,又能够使得memmap在虚拟内存上看起来是个完整的数组。这些都方便了内核的操作。
对比32位和64位的虚拟内存空间可以发现,空间大了就是比较阔绰,动不动就来个1t、2t的空洞。
7.2 用户空间
用户空间的逻辑和内核空间就完全不同了。首先用户空间是进程创建时动态创建的。其次,对于内核,虚拟内存和物理内存是提前映射好的,就算是vmalloc,也是分配时就映射好的,对于用户空间,物理内存的分配和虚拟内存的分配是割裂的,用户空间总是先分配虚拟内存不分配物理内存,物理内存总是拖到最后一刻才去分配。而且对于进程本身来说,它只能分配虚拟内存,物理内存的分配对它来说是不可见的,或者说是透明的。当进程去使用某一个虚拟内存时如果发现还没有分配物理内存则会触发缺页异常,此时才会去分配物理内存并映射上,然后再去重新执行刚才的指令,这一切对进程来说都是透明的,进程感知不到。
管理进程空间的结构体是mm_struct,我们先来看一下(代码有所删减):linux-src/include/linux/mm_types.h
struct mm_struct { struct {  struct vm_area_struct *mmap;  /* list of vmas */  struct rb_root mm_rb;  u64 vmacache_seqnum;                   /* per-thread vmacache */#ifdef config_mmu  unsigned long (*get_unmapped_area) (struct file *filp,    unsigned long addr, unsigned long len,    unsigned long pgoff, unsigned long flags);#endif  unsigned long mmap_base; /* base of mmap area */  unsigned long mmap_legacy_base; /* base of mmap area in bottom-up allocations */  unsigned long task_size; /* size of task vm space */  unsigned long highest_vm_end; /* highest vma end address */  pgd_t * pgd;  atomic_t mm_users;  atomic_t mm_count;#ifdef config_mmu  atomic_long_t pgtables_bytes; /* pte page table pages */#endif  int map_count;   /* number of vmas */  spinlock_t page_table_lock;   struct rw_semaphore mmap_lock;  struct list_head mmlist;   unsigned long hiwater_rss; /* high-watermark of rss usage */  unsigned long hiwater_vm;  /* high-water virtual memory usage */  unsigned long total_vm;    /* total pages mapped */  unsigned long locked_vm;   /* pages that have pg_mlocked set */  atomic64_t    pinned_vm;   /* refcount permanently increased */  unsigned long data_vm;    /* vm_write & ~vm_shared & ~vm_stack */  unsigned long exec_vm;    /* vm_exec & ~vm_write & ~vm_stack */  unsigned long stack_vm;    /* vm_stack */  unsigned long def_flags;  unsigned long start_code, end_code, start_data, end_data;  unsigned long start_brk, brk, start_stack;  unsigned long arg_start, arg_end, env_start, env_end;  unsigned long saved_auxv[at_vector_size]; /* for /proc/pid/auxv */   struct mm_rss_stat rss_stat;  struct linux_binfmt *binfmt;  mm_context_t context;  unsigned long flags; /* must use atomic bitops to access */  struct core_state *core_state; /* coredumping support */  struct user_namespace *user_ns;  /* store ref to file /proc//exe symlink points to */  struct file __rcu *exe_file; } __randomize_layout; unsigned long cpu_bitmap[];}; 可以看到mm_struct有很多管理数据,其中最重要的两个是mmap和pgd,它们一个代表虚拟内存的分配情况,一个代表物理内存的分配情况。pgd就是我们前面所说的页表树的根指针,当要运行我们的进程时就需要把pgd写到cr3上,这样mmu用我们页表树来解析虚拟地址就能访问到我们的物理内存了。不过pgd的值是虚拟内存,cr3需要物理内存,所以把pgd写到cr3上时还需要把pgd转化为物理地址。mmap是vm_area_struct(vma)的链表,它代表的是用户空间虚拟内存的分配情况。用户空间只能分配虚拟内存,物理内存的分配是自动的透明的。用户空间想要分配虚拟内存,最终的唯一的方法就是调用函数mmap来生成一个vma,有了vma就代表虚拟内存分配了,vma会记录虚拟内存的起点、大小和权限等信息。有了vma,缺页异常在处理时就有了依据。如果造成缺页异常的虚拟地址不再任何vma的区间中,则说明这是一个非法的虚拟地址,缺页异常就会给进程发sigsegv。如果异常地址在某个vma区间中并且权限也对的话,那么说明这个虚拟地址进程已经分配了,是个合法的虚拟地址,此时缺页异常就会去分配物理内存并映射到虚拟内存上。
调用函数mmap生成vma的方式有两种,一是内核为进程调用,就是在内核里直接调用了,二是进程自己调用,那就是通过系统调用来调用mmap了。生成的vma也有两种类型,文件映射vma和匿名映射vma,哪种类型取决于mmap的参数。文件映射vma,在发生缺页异常时,分配的物理内存要用文件的内容来初始化,其物理内存也被叫做文件页。匿名映射vma,在发生缺页异常时,直接分配物理内存并初始化为0,其物理内存也被叫做匿名页。
一个进程的text段、data段、堆区、栈区都是vma,这些vma都是内核为进程调用mmap生成的。进程自己也可以调用mmap来分配虚拟内存。堆区和栈区是比较特殊的vma,栈区的vma会随着栈的增长而自动增长,堆区的vma则需要进程用系统调用brk或者sbrk来增长。不过我们在分配堆内存的时候都不是直接使用的系统调用,而是使用libc给我们提供的malloc接口,有了malloc接口,我们分配释放堆内存就方便多了。malloc接口的实现叫做malloc库,目前比较流行的malloc库有ptmalloc、jemalloc、scudo等。
八、内存统计
暂略
8.1 总体统计
8.2 进程统计
九、总结回顾
前面我们讲了这么多的东西,现在再来总结回顾一下。首先我们再重新看一下linux的内存管理体系图,我们边看这个图边进行总结。
首先要强调的一点是,这么多的东西,都是在内核里进行管理的,内核是可以操作这一切的。但是对进程来说这些基本都是透明的,进程只能看到自己的虚拟内存空间,只能在自己空间里分配虚拟内存,其它的,进程什么也看不见、管不着。
目前绝大部分的操作系统采用的内存管理模式都是以分页内存为基础的虚拟内存机制。虚拟内存机制的中心是mmu和页表,mmu是需要硬件提供的,页表是需要软件来操作的。虚拟内存左边连着物理内存管理,右边连着虚拟内存空间,左边和右边有着复杂的关系。物理内存管理中,首先是对物理内存的三级区划,然后是对物理内存的三级分配体系,最后是物理内存的回收。虚拟内存空间中,首先可以分为内核空间和用户空间,两者在很多方面都有着显著的不同。内核空间是内核运行的地方,只有一份,永久存在,有特权,而且其内存映射是提前映射、线性映射,不会换页。用户空间是进程运行的地方,有n份,随着进程的诞生而创建、进程的死亡而销毁。用户空间中虚拟内存的分配和物理内存的分配是分开的,进程只能分配虚拟内存,物理内存的分配是在进程运行过程中动态且透明地分配的。用户空间的物理内存可以分为文件页和匿名页,页帧回收的主要逻辑就是围绕文件页和匿名页展开的。


小米终不负众望!小米5c、红米4x今日开抢!
通过了解寄存器的功能与作用去揭秘CPU核心技术
英伟达DRIVE Thor超级芯片首搭极氪新车
微软发布新的Azure功能,进一步推动50亿美元IoT计划
2020年国际半导体设备大厂“集体起舞”
深度解析Linux的内存管理体系
贸泽电子宣布鼎力支持第十五届电源技术研讨会
信驰达发布ZigBee 3.0 DONGLE RF-DG-52PAS
比特币作为数字货币在流动性方面还存在着很大的缺陷
Aruba发布下一代无线解决方案 基于数字指纹识别和身份认证等技术
机器视觉系统的定位精度如何计算?
AI算法实际应用:人脸识别门禁
过压保护电路原理是什么
电缆路径仪的探测技术和性能分析
全新纯电保时捷Taycan 在长春保时捷中心隆重上市
食品中蛋白质含量测定仪 功能介绍
家用开关电源工作原理图,家用开关电源电路图讲解
口碑最好的国产蓝牙耳机、质量好又便宜的蓝牙耳机
利用VXI总线和计算机测控技术实现自动测试系统的设计
基于MatIab的模糊PID控制系统设计及仿真