signed

QiShunwang

“诚信为本、客户至上”

深入浅出Linux内核内存管理基础,看完醍醐灌顶

2021/6/3 16:53:16   来源:

 

1 背景

内存管理是Linux内核通过软硬件协作来管理内存的分配及回收的一种方法。在Linux系统上电自检(POST,Power-On-Self-Test)阶段使用临时内存,而系统启动后正常运行阶段的内存使用又有两大类:(1)固定的内存分配,是永久的不变的,主要给内核代码及数据使用;(2)动态内存分配,主要源于进程的创建和空间的扩张。本文主要针对操作系统正常运行时对动态内存的管理。

我们知道,Linux内存可以划分为两大类:物理内存和虚拟内存。物理内存是系统中由RAM芯片控制得可用内存。虚拟内存的产生主要来自于操作系统多进程应用程序透明访问内存的需求。虚拟内存依靠透明地使用磁盘空间,支持进程访问比系统物理内存大得多的内存空间,因此容量大且价格低廉的磁盘空间(磁盘本身不是内存,这也是“虚拟”概念的来源之一)可作为物理内存的扩充。

Linux内核的内存管理通过分页机制维护虚拟地址和物理地址的映射关系。

注意,为防止概念混淆,在阐述虚拟地址和物理地址的转换关系时,本文使用page(页)专指虚拟内存管理单元virtual page,而page Frame(页框与页帧是同一个概念)专指物理地址内存单元physical page。详见 http://blog.csdn.net/acs713/article/details/70036359(理解pages和page Frames的区别)

另请注意: 页框的状态信息保存在一个structpage的页描述符中.如实在还有不解,请参阅代码。欢迎指正。

 

1.1 虚拟内存的划分:用户空间与内核空间

从Linux操作系统层次上,可将Linux虚拟内存划分为用户空间内存和内核空间内存。

虚拟地址空间(用户空间+内核空间)的大小取决于处理器体系结构的字长。32位的CPU,最大寻址范围为2^32 - 1也就是4G的线性地址空间。Linux简化了分段机制,使得虚拟地址与线性地址总是一致的。Linux一般把这个4G的地址空间划分为两个部分:其中 0~3G为用户程序地址空间,虚地址0x00000000到0xBFFFFFFF,供各个进程使用;3G~4G为内核的地址空间,虚拟地址 0xC0000000到0xFFFFFFFF, 供内核使用。(注意,ARM架构不是3G/1G划分的,而是2G/2G划分。这里以3G/1G划分作讲解)。用户进程通常情况下只能访问用户空间( 0~3G)的虚拟地址,不能访问内核空间的虚拟地址。例外情况只有用户进程进行系统调用(代表用户进程在内核态执行)等时刻可以访问到内核空间。如下图所示:

可以看出,每个进程(1,2,3......,n)都有自己的私有用户空间(0-3GB),这个空间对系统中的其他进程是不可见的。最高的1GB内核空间则由则由所有进程以及内核共享。可见,内核最多寻址1G的虚拟地址空间。用户空间对应进程,所以每当进程切换,用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程变化,是固定的。内核空间地址有自己对应的页表,用户进程各自有不同的页表。每个进程的用户空间都是完全独立、互不相干的。

【Linux内核内存管理专题训练营】火热开营!!

最新Linux内核技术详解 独家Linux内核内存管理干货分享 两天持续技术输出:

--------------------

第一天:

1.物理内存映射及空间划分

2.ARM32/64页表的映射过程

3.分配物理页面及Slab分配器

4.实战:VMA查找/插入/合并 -

-------------------

第二天:

5.实战:mallocap系统调用实现

6.缺页中断处理/反向映射

7.回收页面/匿名页面生命周期

8.KSM实现/Dirty COW内存漏洞

原价“198”,现“0.02”特惠!

限时特价入营地址

https://ke.qq.com/course/3485817?flowToken=1036017​ke.qq.com

立即抢购加入吧

 

Linux 内核采用了最简单的映射方式来映射物理内存,即把物理地址+PAGE_OFFSET按照线性关系直接映射到内核空间。PAGE_OFFSET大小为 0xC0000000 (=3G).

但是Linux内核并没有把整个1G空间用于线性映射,而只映射了最多896M物理内存,预留了最高端的128M虚拟地址空间给IO 设备和其他用途。有关此知识点的深入理解,读者可随后参考章节1.2.2内存区域(ZONE)的概念。

Linux内核使用页表(page tables)来建立用户进程的虚拟内存与系统物理内存RAM ( physial page frames)的联系.页表使得各个用户进程可以使用统一的虚拟地址空间,即一块连续的4G内存区域。页表还可以将多个虚拟页面(virtual pages)映射到物理RAM physical page frames,进而支持多个进程共享内存。 页表也支持不增加RAM,直接使用硬盘交换空间来提高可用内存。

细心的读者可能会问,虚拟地址是通过MMU来对外产生物理地址呀,那页表作为一个软件实体,到底是起到什么作用呢?简单理解:整个查表过程硬件完成,但是页表由操作系统维护。也就是说,页表可以认为是对MMU的一个软件配置,即它描述MMU的映射规则,即虚拟内存哪(几)个页映射到物理内存哪(几)个页帧。 页表由一条条代表映射规则的记录组成,每一条称为一个页表条目(Page Table Entry,即PTE),整个页表保存在片外内存,MMU通过查找页表确定一个虚拟地址应该映射到什么物理地址,以及是否有权限映射。(这里参考http://blog.csdn.net/ipmux/article/details/19167605)。下面是直接引用,以帮助有兴趣的朋友了解其详:

 

如果MMU存在且启用,CPU执行单元产生的地址信号在发送到内存芯片之前将被MMU截获,这个地址信号称为虚拟地址(virtual address),简称VA,MMU会负责把VA翻译成另一个地址,然后发到内存芯片地址引脚上,即VA映射成PA,如下图:

 

.

所以物理地址①是通过CPU对外地址总线②传给Memory Chip③使用的地址;而虚拟地址④是CPU内部执行单元⑤产生的,发送给MMU⑥的地址。硬件上MMU⑥一般封装于CPU芯片⑦内部,所以虚拟地址④一般只存在于CPU⑦内部,到了CPU外部地址总线引脚上②的信号就是MMU转换过的物理地址①。

软件上MMU对用户程序不可见,在启用MMU的平台上(没有MMU不必说,只有物理地址,不存在虚拟地址),用户C程序中变量和函数背后的数据/指令地址等都是虚拟地址,这些虚拟内存地址从CPU执行单元⑤发出后,都会首先被MMU拦截并转换成物理地址,然后再发送给内存。也就是说用户程序运行*pA =100;"这条赋值语句时,假设debugger显示指针pA的值为0x30004000(虚拟地址),但此时通过硬件工具(如逻辑分析仪)侦测到的CPU与外存芯片间总线信号很可能是另外一个值,如0x8000(物理地址)。当然对一般程序员来说,只要上述语句运行后debugger显示0x30004000位置处的内存值为100就行了,根本无需关心pA的物理地址是多少。但进行OS移植或驱动开发的系统程序员不同,他们必须清楚软件如何在幕后辅助硬件MMU完成地址转换。

 

操作系统和MMU

实际上MMU是为满足操作系统越来越复杂的内存管理而产生的。OS和MMU的关系简单说:

a.系统初始化代码会在内存中生成页表,然后把页表地址设置给MMU对应寄存器,使MMU知道页表在物理内存中的什么位置,以便在需要时进行查找。之后通过专用指令启动MMU,以此为分界,之后程序中所有内存地址都变成虚地址,MMU硬件开始自动完成查表和虚实地址转换。

b.OS初始化后期,创建第一个用户进程,这个过程中也需要创建页表,把其地址赋给进程结构体中某指针成员变量。即每个进程都要有独立的页表。

c.用户创建新进程时,子进程拷贝一份父进程的页表,之后随着程序运行,页表内容逐渐更新变化。

虚拟地址到物理地址的映射流程:

VA到PA的映射过程就一目了然:MMU得到VA后先在TLB内查找,若没找到匹配的PTE条目就到外部页表查询,并置换进TLB;根据PTE条目中对访问权限的限定检查该条VA指令是否符合,若不符合则不继续,并抛出exception异常;符合后根据VA的地址分段查询页表,保持offset(广义)不变,组合出物理地址,发送出去。

在这个过程中,软件的工作核心就是生成和配置页表。

 

1.2 物理内存管理基础概念

 

Linux为了用统一的代码获得最大的兼容性,引入了以下物理内存管理概念:(1)物理内存结点(node);(2)物理内存区域(Zone);(3)物理内存页框(page Frame)

 

1.2.1 物理内存节点Node

一般来说,存在两种物理内存管理模型:(1)UMA ,一致内存访问模型;(2)NUMA,非一直内存访问模型。

与UMA不同,NUMA模型下处理器访问本地内存的速度要快于其他内存访问本地内存的速度。

Linux源码中,以struct pglist_data数据结构表示单个内存节点。对于NUMA模型,多个内存节点通过链表串接起来;而UMA模型只有一个内存节点。

 

 

 

 

1.2.2 物理内存区域Zone

 

从理论上来讲,一个页框就是一个物理内存存储单元,可用于做任何事情,如存储内核数据或用户数据,缓存磁盘数据,等等。然而,实际中,由于不同计算机类型的硬件架构不同,页帧的用途可能会有所限制。例如,在80X86架构下,Linux内核必须正确处理以下两个硬件限制:

(1)旧的ISA总线的DMA处理器只能访问RAM的前16MB;

(2)现代的32位计算机ram内存越来越大,因为线性地址空间过小,CPU没办法访问所有内存。

为了解决以上两个限制,进而对物理页面进行有效的管理,对于每个内存节点Node, Linux又把物理页面划分为三个区:

· 专供DMA使用的ZONE_DMA区(小于16MB,其页帧可以被旧的ISA总线访问)

· 常规的ZONE_NORMAL区(大于16MB小于896MB)

· 内核不能直接映射的区ZONE_HIGME区(大于896MB)。

也就是说,Linux系统的物理内存被分配到几个内存节点Node, 而每个节点又划分为几个内存区域Zone.

high memory也是被内核管理的(有对应的page Frame结构),只是没有映射到内核虚拟地址空间。当内核需要分配high memory时,通过kmap等从预留的地址空间中动态分配一个地址,然后映射到high memory,从而访问这个物理页。high memory映射到内核地址空间一般是暂时性的映射,不是永久映射。

 

1.2.3 物理内存页框page Frames

内存页框(page frame) 是物理管理内存管理中的最小单位。Linux系统为物理内存的每个页框创建一个struct page对象,并用全局对象struct page *mem_map (数组)来存放所有物理页框对象的指针。

对于一个应用程序(进程)来说,它看到的是虚拟内存,这根本看不到物理内存。对于同一程序运行起来的两个进程,它们的虚拟空间布局可能完全一样,但他们真实使用的物理内存空间则不相同。就像广州人民和上海人民都说中山路一样,说法一样,但所指的地址是不一样的(地点的经纬度不一样)。

Linux内核内存管理模块的页表page tables,可以实现虚拟地址到物理地址的转换。

在前面1.1节我已指出,在进程的内核空间,以ZONE_NORMAL为例,虚拟地址到物理地址的转换是相当直接的。即把物理地址+PAGE_OFFSET按照线性关系直接映射到内核虚拟地址空间。PAGE_OFFSET大小为 0xC0000000 (=3G).(也就是在ZONE_NORMAL, 物理地址=内核虚拟地址-PAGE_OFFSET)。

在进程的用户空间,各个进程都实现了自己的页表(page table),通过页表映射来访问实际的物理地址。

早期, 对于32位的计算机而言,页式管理是这么进行的,逻辑地址格式如下:

0 -11位:页内偏移OFFSET

12-21位:页面表偏移PT

22-31位:页面目录偏移PGD

寻址过程如下:

1)操作系统从寄存器CR3获得当前page目录指针(基地址);

2)基地址+page目录偏移->page Table指针(基地址);

3)Page Table指针+page table偏移->物理内存页框(page Frame)基址;

4)Page Frame基址+页内偏移->具体物理内存单元 (specific Page Frame)。

显然,12位的页内偏移可以寻址4K,所以一张内存页为4K;而总共可寻内存为4G=2^10 * 2^10 * 2^12;因此在32位机器上内存上限一般为4G。

 

从内核2.6.11版本开始,Linux定义了四种类型的页表(page tables):

 

(1)总目录PGD(Page Global Directory),包含PUD的地址。

(2)上级目录PUD(Page Upper Derectory),包含PMD的地址

(3)中间目录PMD(Page Middle Derectory),包含PT的地址

(4)页表PT(Page Table),Page Table Entry指向页框

其页面寻址模型可用下图表示:

 

上述模型没有对线性地址按不同页表类型分配不同的位数,原因在于不同的架构表现不同。那么CR3是什么?请参考如下寄存器描述:

 

标志寄存器 :EFLAGS

指令指针:EIP

机器状态字:CR0

CR1是未定义的控制寄存器,供将来的处理器使用。

CR2是页故障线性地址寄存器,保存最后一次出现页故障的全32位线性地址。

CR3是页目录基址寄存器,保存页目录表的物理地址,页目录表总是放在以4K字节为单位的存储器边界上,因此,它的地址的低12位总为0,不起作用,即使写上内容,也不会被理会。

 

2 内存管理模型深入

 

2.1 内存模型总览

以NUMA模型为例,其内存管理组织架构大体如下图所示:

 

 

对于每个内存区Zone,它采用伙伴系统算法管理内存,其大致结构如下:

 

 

什么是Per-CPU page frame cache呢?

由于内核经常性的会请求一个页帧然后又释放它,这会带来系统性能问题。为了提高性能,每个内存区域zone定义了一个Per-CPU page frame cache。每个Per-CPU page frame cache包含了一些预分配的页帧。

在前面的总览图中可以看到,内核划分为节点Node。每个节点关联到系统中的一个处理器,在内核中表示为pg_data_t。示意图如下:

 

每个内存zone结构体都有一个成员struct free_area free_area[MAX_ORDER], 它用于实现伙伴系统,每个数组元素都表示某种固定长度的一些连续内存区,对于包含在每个区域中的空闲内存页的管理,free_area是一个起点。

每个内存zone结构体都有一个成员struct page *zone_mem_map,它指向内存区域zone的第一个页帧。

总之,memory node对应于结构pg_data_t,memory zone对应于结构zone,page frame对应于结构page,由zone_mem_map指向。

从数据结构的角度,可以绘制出如下关系图:

 

 

也有网友按照架构分层的概念,提出了如下内存管理架构模型:

 

 

详见:http://blog.csdn.net/S_E_A_N/article/details/5829978

此架构模型是我较为推崇的,下面按照其思想从下往上分析。

 

2.2 内存节点Node数据结构分析

 

NUMA(Non-Uniform Memory Access)即非一致内存访问。在多CPU系统中有可能出现给定CPU对不同内存单元访问的时间不同。为了让指定CPU总能最先使用访问时间最短的内存,Linux把物理内存分成几块并以节点(node)标识。这样一来,每个CPU都有最快访问内存的节点,但并不等于只能访问这个节点。下表中node_zonelists将其他节点的各管理区也链了进来,但均排在本节点管理区之后,以示其它节点优先级低于本节点。

内核中用struct pglist_data结构体来存放节点信息,各成员解释如下:

 

2.3 内存域Zone数据结构分析

内核用struct zone来管理内存区,各成员解释如下:

 

2.4 内存页数据结构分析

 

内核必须记录每个页框当前的状态。例如,内核必须能区分哪些页框包含的是属于进程的页,而哪些页框包含的是内核代码或内核数据。类似地,内核还必须能够确定动态内存中的页框是否空闲。如果动态内存中的页框不包含有用的数据,那么这个页框就是空闲的。在以下情况下页框是不空闲的:包含用户态进程的数据、某个软件高速缓存的数据、动态分配的内核数据结构、设备驱动程序缓冲的数据、内核模块的代码等等。

页框的状态信息保存在一个类型为page的页描述符中,其中的字段如下表所示。所有的页描述符存放在mem_map数组中。因为每个描述符长度为32字节,所以mem_map所需要的空间略小于整个内存的1%。virt_to_page(addr)宏产生线性地址addr对应的页描述符地址。pfn_to_page(pfn)宏产生与页框号pfn对应的页描述符地址。

页描述符,在内核中表示为struct page:

下面这两个字段非常重要,需作重点描述:

_count

页的引用计数器。如果该字段为-1,则相应页框空闲,并可被分配给任一进程或内核本身;如果该字段的值大于或等于0,则说明页框被分配给了一个或多个进程,或用于存放一些内核数据结构。page_count()函数返回_count加1后的值,也就是该页的使用者的数目。

flags

包含多达32个用来描述页框状态的标志,如下表。对于每个PG_xyz标志,内核都定义了操作其值的一些宏。通常,PageXyz宏返回标志的值,而SetPageXyz和ClearPageXyz宏分别设置和清除相应的位。页标志32位分布如下:

Page flags: | [SECTION] | [NODE] | ZONE | ... | FLAGS |

 

2.5 伙伴系统算法减少外部碎片

 

从总体上把握Linux内存管理

读者可参考如上文章建立内存碎片的概念。这里为了方便,复习如下:

外部碎片是出于任何已分配区域或页面外部的空闲存储块。这些存储块的总和可以满足当前申请的长度要求,但是由于它们的地址不连续或其他原因,使得系统无法满足当前申请。 造成问题:系统虽有足够的内存,但却都是分散的碎片,无法满足对大块“连续内存”的需求。为了避免“外部碎片”这种问题的发生,有两种办法: (1)通过使用页转换电路,把那些不连续的未分配的页帧转换为线性地址; (2)开发出一种算法,追踪现存的连续的未分配的页帧,尽可能避免拆分一个大得多的内存块去满足较小的内存开辟请求。 即将大内存分成各种固定大小的块,每次分配时,尽量使用最接近申请大小的那一块,尽量避免为满足小块内存的申请而分拆大的块。

上述办法(1)即是vmalloc的思想,但有时后要求申请到内存必须是连续,这就需要方法II。其实方法(1)还有一个缺点:它需要修改页表,需要硬件的频繁动作,这增加了内存申请和使用的时间。因此多数情况都采用方法(2)来申请内存。伙伴系统算法即是方法(2)的一个使用算法,使用它可减少外部碎片。

伙伴系统特征如下:

I 把所有的空闲页框分组为MAX_ORDER(ICE为11)个块链表,每个块链表分别包含大小为2n(n为0~10的整数)个连续的页框,即1、2、4、6、8、16、32、64、128、256、512和1024个连续的页框。

II 每个块的第一个页框的物理地址是该块大小的整数倍。

前面提到了,内存区域zone数据结构,但是在zone中主要关心的是下面的这个结构体数组: struct zone { .......... struct free_area free_area[MAX_ORDER]; .......... } struct free_area定义如下: struct free_area { struct list_head free_list[MIGRATE_TYPES]; unsigned long nr_free; }; 其中free_list用来连接不用的页描述符,nr_free指定了当前区中空闲页的数目,free_list用于连接空闲页的链表。而在free_area数组中MAX_ORDER定义为11,其数组下标对应为该内存块的阶。也就是说,伙伴算法把内存块按大小分组管理,即将所有空闲页帧分组为11个块链表(block List),每个块链表分别包含1,2,4,8,16,32,64,128,256,512,1024个连续的页帧(也就是2的0次方,到2的10次方)。

一个简单的图示:

 

 

更详细点的图示:

 

 

下面详细叙述伙伴系统的工作原理:

比如说我们要申请一个b阶大小的页块,那么系统会直接在b阶块中查找看这个链表是否为空,如果不为空则说明恰好有这么大的页可以用于分配。如果该链表为空,则会在b+1阶中寻找,如过b+1阶链表不为空,则将b+1中页块一分为二,一半用于分配,另一半加入b阶链表中。如果b+1阶链表也为空那么就继续向上寻找,如果都没找到空闲地址,就只能返回NULL。

上面的过程是分配空间的过程,在释放页的时候正好是分配的一个逆过程,内核会试图将两个b阶的页块合并程一个2b阶的大页块,如果可以合并就将这两个页块称为伙伴,满足伙伴的的要求如下:

1)两个块具有相同的大小,记作b。

2)它们的物理地址是连续的。

3) 第一块的第一个页的物理地址是2*b*PAGE_SIZE的倍数即第0块和第1块是伙伴,第2块和第3块是伙伴,但是第1块和第2块不是伙伴。这样规定的目的是确保一对伙伴中的两个块可以合并成更高级的大块。

 

 

2.6 页框分配器

 

页框分配器核心函数是__alloc_pages_internal()它主要调用的函数是__rmqueue()。简单的说页框分配器是对伙伴系统的一种封装。我们常用的页框分配函数又是对__alloc_pages_internal()的封装,如:alloc_pages_node()。alloc_pages_node()返回给调用者的是页框描述符。下面slab分配器使用的kmem_getpages()仅是将alloc_pages_node()返回的页框描述符转换成了线性地址。

 

 

 

2.7 SLAB机制管理内存,减少内部碎片

内部碎片是处于区域内部或页面内部的存储块。占有这些区域或页面的进程并不使用这个块。而在进程占有这块存储存储块时,系统无法利用它。直到进程释放它,或进程结束时,系统才有可能利用这个存储块。造成原因一般为:系统为了一小段连续内存区的需要,不得已给它分配了一大块连续内存,从而造成了内存浪费。 为了满足内核对这种小内存块的需要,Linux系统采用了一种被称为slab分配器的技术。Slab分配器的实现相当复杂,但原理不难,其核心思想就是“存储池”的运用。内存片段(小块内存)被看作对象,当被使用完后,并不直接释放而是被缓存到“存储池”里,留做下次使用,这无疑避免了频繁创建与销毁对象所带来的额外负载。

slab分配器有三个目标:

I 减少系统分配小块内存时产生的碎片;

II 把经常使用的对象缓存起来,减少分配、初始化及释放对象的时间开销;

III 调整对象以更好的使用硬件高速缓存。 Slab技术不但避免了内存内部分片(下文将解释)带来的不便(引入Slab分配器的主要目的是为了减少对伙伴系统分配算法的调用次数——频繁分配和回收必然会导致内存碎片——难以找到大块连续的可用内存),而且可以很好利用硬件缓存提高访问速度。

Slab并非是脱离伙伴关系而独立存在的一种内存分配方式,slab仍然是建立在页面基础之上。SLAB分配器的基本思想是先利用页面分配器分配出单个或连续的物理页面,然后在此基础上将整块页面分割成多个相等的小内存单元,以满足小内存空间分配的需要。换句话说,Slab将页面(来自于伙伴关系管理的空闲页框链)撕碎成众多小内存块以供分配,slab中的对象分配和销毁使用kmem_cache_alloc()与kmem_cache_free()。 常用的kmalloc建立在slab分配器的基础之上。

SLAB机制的工作层次示意如下:

 

 

注:本文参考大量网友著作,并经过了本人的系统研究、总结或组合。

转载参考列表:

(1)http://blog.csdn.net/S_E_A_N/article/details/5829978