内存管理(下)

五、物理内存的管理

在内核初始化完成后,内存管理的责任由伙伴系统(高效、高速)承担。

1、伙伴系统的结构

系统内存中的每个物理内存页(页帧),都对应于一个struct page实例。每个内存域都关联了一个struct zone的实例,其中保存了用于管理伙伴数据的主要数组。

1 struct zone {
2 ...
3   struct free_area free_area[MAX_ORDER];    //不同长度的空闲区域
4 ...
5 } ;

sruct free_area是一个辅助结构,如下所示。

1 struct free_area {
2     struct list_head free_list[MIGRATE_TYPES];    //用于连接空闲页的链表
3     unsigned long nr_free;    //当前内存区中空闲页块的数目
4 };

 

阶(order)是伙伴系统中一个非常重要的术语。它描述了内存分配的数量单位内存区的管理单位,内存块的长度是2的order次方。图1是伙伴系统中相互连接的内存区,内存区中第1页内的链表元素,可用于将内存区维持在链表中。因此,也不必引入新的数据结构来管理物理上连续的页,否则这些页不可能在同一内存区中,MAX_ORDER根据硬件不同而设置不同的值,表示一次分配可以请求的最大页数的以2为底的对数。

1 伙伴系统相互连接的内存区

伙伴不必是彼此连接的。如果一个内存区在分配其间分解为两半,内核会自动将未用的一半加入到对应的链表中。如果在未来的某个时刻,由于内存释放的缘故,两个内存区都处于空闲状态,可通过其地址判断其是否为伙伴。

基于伙伴系统的内存管理专注于某个结点的某个内存域,例如,DMA或高端内存域。但所有内存域和结点的伙伴系统都通过备用分配列表连接起来。如图2所示。

2 伙伴系统和内存域/结点之间的关系

2、避免碎片

Linux系统启动并长期运行后,物理内存会产生很多碎片。这对用户空间应用程序没有问题(其内存通过页表进行映射,物理内存分布与应用程序看到的内存无关),但对内核来说,碎片是一个问题(大多数物理内存一致映射到地址空间内核部分)。

1)依据可移动性组织页

文件系统的碎片主要通过碎片合并工具解决,不同于物理内存,许多物理内存页不能移动到任意位置,阻碍了该方法的实施。内核处理避免碎片的方法是反碎片(版本2.6.24),试图从最初开始尽可能防止碎片。

内核将已分配页划分为以下3种不同类型:

  • 不可移动页:在内存中有固定位置,不能移动到其他地方。核心内核分配的大多数内存属于该类别;
  • 可回收页:不能直接移动,但可以删除,其内容可以从某些源重新生成。例如,映射自文件的数据属于该类别,kswapd守护进程会根据可回收页访问的频繁程度,周期性释放此类内存;
  • 可移动页:可以随意地移动。属于用户空间应用程序的页属于该类别。它们是通过页表映射的。如果它们复制到新位置,页表项可以相应地更新,应用程序不会注意到任何事。

页的可移动性,依赖该页属于3种类别的哪一种。内核使用的反碎片技术,将具有相同可移动性的页进行分组。根据页的可移动性,将其分配到不同的列表中,防止不可移动的页位于可移动内存区中间的情况出现。这样对于不可移动页中仍然难以找到较大的连续空闲时间,但对可回收的页就相对容易了。

内核定义了一些宏来表示迁移类型:

1 #define MIGRATE_UNMOVABLE 0    //类型
2 #define MIGRATE_RECLAIMABLE 1    //类型
3 #define MIGRATE_MOVABLE 2        //类型
4 #define MIGRATE_RESERVE 3    //向具有特定可移动性的列表请求分配内存失败,从MIGRATE_RESERVE分配内存(紧急分配)
5 #define MIGRATE_ISOLATE 4 //不能从这里分配,特殊的虚拟区域,用于跨越NUMA结点移动物理内存页
6 #define MIGRATE_TYPES 5

对伙伴系统的主要数据结构影响是将空闲列表分解为MIGRATE_TYPE个列表,代码如下:

1 struct free_area {
2     struct list_head free_list[MIGRATE_TYPES];
3     unsigned long nr_free;    //所有列表上空闲页的数目
4 };

内核提供了一个备用列表,规定了在指定列表中无法满足分配请求时,接下来使用的迁移类型的种类。(在内核想要分配不可移动页时,如果对应链表为空,则后退到可回收页链表,接下来到可移动页链表,最后到紧急分配链表。)

页可移动性分组特性总是编译到内核中,但只有在系统中有足够内存可以分配到多个迁移类型对应的链表时,才会起作用。两个全局变量pageblock_order和pageblock_nr_pages提供每个迁移链表对应的适当数量的内存。第一个表示内核认为是“大”的一个分配阶,pageblock_nr_pages则表示该分配阶对应的页数。如果体系结构提供了巨型页机制,则pageblock_order通常定义为巨型页对应的分配阶(IA-32巨型页长度是4MB),如果体系结构不支持巨型页,则将其定义为第二高的分配阶(MAX_ORDER-1)。如果各迁移类型的链表中没有一块较大的连续内存,那么页面迁移不会提供任何好处,因此在可用内存太少时内核会通过设置全局变量page_group_by_mobility为0关闭该特性(一旦停用了页面迁移特性,所有页都是不可移动的)。

在内存子系统初始化期间,memmap_init_zone负责处理内存域的page实例。它将所有的页最初都标记为可移动的,此时如果需要分配不可移动的内存,则必须“盗取”(见4分配API)。实际上,启动期间分配可移动内存区的情况较少,分配器有很高的几率分配长度最大的内存区,并将其从可移动列表转换到不可移动列表。由于分配的内存区长度是最大的,因此不会向可移动内存中引入碎片。这种做法避免了启动期间内核分配的内存(经常在系统的整个运行时间都不释放)散布到物理内存各处,从而使其他类型的内存分配免受碎片的干扰,这也是页可移动性分组框架的最重要的目标之一。

2)虚拟可移动内存域

依据可移动性组织页是防止物理内存碎片的一种可能方法,内核还提供了另一种阻止该问题的手段:虚拟内存域ZONE_MOVABLE,其特性必须由管理员显示激活。

基本思想:可用的物理内存划分为两个内存域,一个用于可移动分配,一个用于不可移动分配。

kernelcore参数用来指定用于不可移动分配的内存数量(用于既不能回收也不能迁移的内存数量)。参数movablecore控制用于可移动内存分配的内存数量。如果同时指定两个参数,内核会按照一定的方法进行计算,取指定值与计算值中较大的一个。

ZONE_MOVABLE并不关联到任何硬件上有意义的内存范围,该内存域中的内存取自高端内存域或普通内存域,因此称虚拟内存域。

从物理内存域提取用于ZONE_MOVABLE的内存数量主要考虑以下两个因素:

  • 用于不可移动分配的内存会平均地分布到所有内存结点上;
  • 只使用来自最高内存域的内存(在内存较多的32位系统上,通常是ZONE_HIGHMEM,对于64位系统,使用ZONE_NORMAL或ZONE_DMA32)。

最后是计算结果,用于为虚拟内存域ZONE_MOVABLE提取内存页的物理内存域,保存在全局变量movable_zone中;对每个结点来说,zone_movable_pfn[node_id]表示ZONE_MOVABLE在movable_zone内存域中所取得内存的起始地址。

(虚拟内存域具体的实现在4分配API中)

3、初始化内存域和结点数据结构

在启动期间,各体系结构相关的代码需要确立系统中各内存域的页帧的边界(max_zone_pfn数组);确定各结点页帧的分配情况(全局变量early_node_map)。

1)管理数据结构的创建

3概述了管理数据结构建立的过程。

3 管理数据结构构建过程示意图

 

4 free_area_init_nodes的代码流程图

free_area_init_nodes代码流程图如图4所示,完成以下工作:

  • 首先分析并改写特定于体系结构的代码提供的信息(对照在zone_max_pfn和zone_min_pfn中指定的内存域的边界,计算各个内存域可使用的最低和最高的页帧编号);
  • 根据结点的第一个页帧start_pfn,对early_node_map中的各项进行排序;
  • 以[low, high]形式描述各个内存域的页帧区间,存储在对应的全局变量中;
  • 接下来构建其他内存域的页帧区间,方法很直接:第n个内存域的最小页帧,即前一个(第n-1个)内存域的最大页帧(当前内存域的最大页帧由max_zone_pfn给出);
  • 最后遍历所有活动结点,并分别对各个结点调用free_area_init_node建立数据结构。

2)对各个结点创建数据结构

在内存域边界已经确定之后,free_area_init_nodes分别对各个内存域调用free_area_init_node创建数据结构。这涉及到几个辅助函数(见图4):

  • calculate_node_totalpages首先累计各个内存域的页数,计算结点中页的总数;
  • alloc_node_mem_map负责初始化一个简单但非常重要的数据结构(struct page);
  • free_area_init_core依次遍历结点的所有内存域,负责初始化内存域数据结构涉及的繁重工作(内存域的真实长度、系统中的页数、初始化zone结构中各个表头、将各个结构成员初始化为0)。

此时,空闲页的数目(nr_free)当前仍然规定为0,这显然没有反映真实情况。直至停用bootmem分配器、普通的伙伴分配器生效,才会设置正确的数值。

4、分配器API

伙伴系统接口对于NUMA和UMA体系结构没有差别,但是它只能分配2的整数幂个页(分配必须指定阶),内核中的细粒度分配只能借助于slab分配器(或者slub、slob分配器)。

  • alloc_pages(mask, order)分配2order页并返回一个struct page的实例,表示分配的内存块的起始页;
  • get_zeroed_page(mask)分配一页并返回一个page实例,页对应的内存填充0(所有其他函数,分配之后页的内容是未定义的);
  • __get_free_pages(mask, order)和__get_free_page(mask)的工作方式与上述函数相同,但返回分配内存块的虚拟地址,而不是page实例;
  • get_dma_pages(gfp_mask, order)用来获得适用于DMA的页。
  • 4个函数用于释放不再使用的页:
  • free_page(struct page *)和free_pages(struct page *, order)用于将一个或2的order次幂的页返回给内存管理子系统,内存区的起始地址由指向该内存区的第一个page实例的指针表示;
  • __free_page(addr)和__free_pages(addr, order)的语义类似于前两个函数,但在表示需要释放的内存区时,使用了虚拟内存地址而不是page实例。

1)分配掩码

分配器API中的mask参数,称为掩码,它包含了图5所示的内容。 (GFP表示get free page)

5 GFP掩码布局

  • 内存域修饰符(最低4个比特位)用于指定从哪个内存孕育分配所需的页;
  • 标志位在不限制从哪个物理内存段分配内存的基础上,改变分配器的行为(比如查找空闲内存时的积极程度)。(具体含义及用法见源码及手册)

 (2)内存分配宏

通过使用标志、内存域修饰符和各个分配函数,内核提供了一种非常灵活的内存分配体系,所有接口函数都可以追溯到一个基本函数alloc_pages_node,如图6所示。

 

6 伙伴系统的各分配函数之间关系

  • 分配单页的函数alloc_page和__get_free_page是借助于宏定义的,alloc_pages也是同样;
  • get_zeroed_page的实现是对alloc_pages使用__GFP_ZERO标志,即可分配填充字节0的页;
  • __get_free_pages访问了alloc_pages,而alloc_pages又借助了alloc_pages_node。

类似地,内存释放函数也可以归约到一个主要的函数__free_pages,如图7所示(只是调用参数不同)。

 

7 伙伴系统各内存释放函数之间关系

free_pages和__free_pages之间的关系通过函数而不是宏建立,因为首先必须将虚拟地址转换为指向struct page的指针。

5、分配页

内核源代码将__alloc_pages称之为“伙伴系统的心脏”,因为它处理的是实质性的内存分配。

1)选择页

内核定义了一些函数使用的标志,用于控制到达各水印指定的临界状态时的行为。

#define ALLOC_NO_WATERMARKS 0x01 /* 完全不检查水印 */
#define ALLOC_WMARK_MIN 0x02 /* 使用pages_min水印 */
#define ALLOC_WMARK_LOW 0x04 /* 使用pages_low水印 */
#define ALLOC_WMARK_HIGH 0x08 /* 使用pages_high水印 */
#define ALLOC_HARDER 0x10 /* 试图更努力地分配,即放宽限制 */
#define ALLOC_HIGH 0x20 /* 设置了__GFP_HIGH */
#define ALLOC_CPUSET 0x40 /* 检查内存结点是否对应着指定的CPU集合 */

默认情况下(即没有因其他因素带来的压力而需要更多的内存),只有内存域包含页的数目至少为zone->pages_high时,才能分配页。这对应于ALLOC_WMARK_HIGH标志。如果要使用较低(zone->pages_low)或最低(zone->pages_min)设置,则必须相应地设置ALLOC_WMARK_MIN或ALLOC_WMARK_LOW。ALLOC_HARDER通知伙伴系统在急

需内存时放宽分配规则。在分配高端内存域的内存时,ALLOC_HIGH进一步放宽限制。最后,ALLOC_CPUSET告知内核,内存只能从当前进程允许运行的CPU相关联的内存结点分配,当然该选项只对NUMA系统有意义。

__alloc_pages是伙伴系统的主函数,函数比较复长,可用内存足够时必要工作很快完成,可用内存太少或逐渐用完时,函数就会变得比较复杂。

在最简单的情形中,分配空闲内存区只涉及调用一次get_page_from_freelist,然后返回所需数目的页(由标号got_pg处的代码处理)。

其他情况中,会进行多次内存分配尝试:

  • 第一次内存分配尝试不会特别积极。如果在某个内存域中无法找到空闲内存,则意味着内存没剩下多少了,内核需要增加较多的工作量才能找到更多内存。内核再次遍历备用列表中的所有内存域,每次都会唤醒负责换出页的kswapd守护进程(任务见页面回收和页同步),此时,空闲内存可以通过缩减内核缓存和页面回收获得。
  • 此后,内核开始新的尝试,在内存域之一查找适当的内存块。这一次进行的搜索更为积极,对分配标志进行了调整,修改为一些在当前特定情况下更有可能分配成功的标志。同时,将水印降低到最小值。然后用修改的标志集,再一次调用get_page_from_freelist,试图获得所需的页。
  • 如果再次失败,若设置了PF_MEMALLOC或进程设置了TIF_MEMDIE标志,会再次调用get_page_from_freelist试图获得所需的页(完全忽略水印);若没有设置PF_MEMALLOC,内核仍然还有一些选项可以尝试,进入一条低速路径,分配掩码中设置__GFP_WAIT标志,为使守护进程取得一定的进展,其他进程可能进入睡眠状态,然后使用辅助函数try_to_free_pages查找当前不急需的页,以便换出(如果需要分配多页,那么per-CPU缓存中的页也会被try_to_free_pages拿回到伙伴系统),最后内核再次调用get_page_from_freelist尝试分配内存。
  • 如果依然申请不到内存(会涉及到一些对VFS层的影响,此处不作介绍),内核只能放弃,并向用户返回NULL指针,并输出一条内存请求无法满足的警告消息。

2)移除选择的页

如果内核找到适当的内存域,具有足够的空闲页可供分配,那么还有两件事情需要完成。首先它必须检查这些页是否是连续的;其次,必须按伙伴系统的方式从free_lists移除这些页,这可能需要分解并重排内存区。

内核将工作委托给辅助函数buffered_rmqueue完成,其代码流程图如图8所示。

8 buffered_rmqueue代码流程图

首先,判断阶数,若为0,则表示只请求一页。此时,内核试图借助于per-CPU缓存加速请求的处理。如果缓存为空,内核可借机检查缓存填充水平。如果per-CPU缓存中无法找到适当的页,则向缓存添加一些符合当前要求迁移类型的页,然后从per-CPU列表移除一页,接下来进一步处理。

若不是0,则表示请求多页。内核调用__rmqueue(要求页连续)会从内存域的伙伴列表中选择适当的内存块。如有必要,该函数会自动分解大块内存,将未用的部分放回列表中。若分配失败,则会返回NULL指针。所有失败情形都跳转到标号failed处理,这可以确保内核到达当前点之后,page指向一系列有效的页。在返回指针之前,prep_new_page需要做一些准备工作,以便内核能够处理这些页(如果所选择的页出了问题,则该函数返回正值。在这种情况下,分配将从头重新开始)。

6、释放页

 __free_pages是一个基础函数,用于实现内核API中所有涉及内存释放的函数。其代码流程图如图9所示。

9 __free_pages代码流程图

__free_pages首先判断所需释放的内存是单页还是较大的内存块?如果释放单页,则不还给伙伴系统,而是置于per-CPU缓存中,对很可能出现在CPU高速缓存的页,则放置到热页的列表中。出于该目的,内核提供了free_hot_page辅助函数,该函数只是作一下参数转换,接下来调用free_hot_cold_page。如果释放多个页,那么__free_pages将工作委托给__free_pages_ok,最后到__free_one_page。与其名称不同,该函数不仅处理单页的释放,也处理复合页释放。

7、内核中不连续页的分配

物理上连续的映射对内核是最优的,但不可能总是成功使用。对此,内核分配了其虚拟地址空间的一部分,用于建立连续映射。如图10所示,在IA-32系统中,紧随直接映射的前892 MiB物理内存,在插入的8 MiB安全隙之后,是一个用于管理不连续内存的区域。这一段具有线性地址空间的所有性质。通过修改负责该区域的内核页表,可以将其中的页映射到物理内存的任何地方。每个vmalloc分配的子区域都是自包含的,与其他vmalloc子区域通过一个内存页分隔。类似于直接映射和vmalloc区域之间的边界,不同vmalloc子区域之间的分隔也是为防止不正确的内存访问操作。

10 IA-32系统上内核的虚拟地址空间中的vmalloc区域

1)用vmalloc分配内存

vmalloc是一个接口函数,内核使用它来分配虚拟内存中连续但在物理内存中不一定连续的内存。

void *vmalloc(unsigned long size);

该函数只需要一个参数,用于指定所需内存区的长度(字节)。

内核对模块的实现中,有很多使用vmalloc的地方,因为函数可能在任何时候加载,如果模块数比较多,那么无法保证有足够的连续内存可用(尤其是系统已经运行了比较长时间的情况下)。因为用于vmalloc的内存页总是必须映射在内核地址空间中,因此使用ZONE_HIGHMEM内存域的页要优于其他内存域。这使得内核可以节省更宝贵的较低端内存域,而又不会带来额外的坏处。

vmalloc的代码流程图如图11所示。

11 vmalloc代码流程图

vmalloc的实现分为三个部分,首先,get_vm_area在vmalloc地址空间中找到一个适当的区域。接下来从物理内存分配各个页,最后将这些页连续地映射到vmalloc区域中,完成分配虚拟内存的工作。

2)备选映射方法

  • 除了vmalloc之外,还有其他方法可以创建虚拟连续映射:
  • vmalloc_32的工作方式与vmalloc相同,但会确保所使用的物理内存总是可以用普通32位指针寻址;
  • vmap使用一个page数组作为起点,来创建虚拟连续内存区;
  • 不同于上述的所有映射方法,ioremap是一个特定于体系结构上的函数,它将取自物理地址空间、由系统总线用于I/O操作的一个内存块,映射到内核的地址空间中。

3)释放内存

有两个函数用于向内核释放内存,vfree用于释放vmalloc和vmalloc_32分配的区域,而vunmap用于释放由vmap或ioremap创建的映射。两个函数都会归结到__vunmap。其代码流程图如图12所示。

12 __vunmap代码流程图

  • __vunmap首先在__remove_vm_area(由remove_vm_area在完成锁定之后调用)中扫描该链表,以找到相关项;
  • 然后使用找到的vm_area实例,从页表删除不再需要的项;
  • 如果__vunmap的参数deallocate_pages设置为1(在vfree中),内核会遍历指向所涉及的物理内存页的page实例的指针,然后对每一项调用__free_page,将页释放到伙伴系统;
  • 最后释放用于管理该内存区的内核数据结构。

8、内核映射

 

尽管vmalloc函数族可用于从高端内存域向内核映射页帧,但这并不是这些函数的实际用途。内核提供了其他函数用于将ZONE_HIGHMEM页帧显式映射到内核空间。

1)持久内核映射

如果需要将高端页帧长期映射(作为持久映射)到内核地址空间中,必须使用kmap函数。需要映射的页用指向page的指针指定,作为该函数的参数。如果没有启用高端支持,该函数只需要返回页的地址;如果启用了高端支持,则类似于vmalloc,内核首先必须建立高端页和所映射到的地址之间的关联,在虚拟地址空间中分配一个区域以映射页帧,最后,内核必须记录该虚拟区域的哪些部分在使用中,哪些仍然是空闲的。

内核在IA-32平台上vmalloc区域之后分配了一个区域,从PKMAP_BASE到FIXADDR_START,该区域用于持久映射,不同体系结构使用的方案是类似的。

pkmap_count是一容量为LAST_PKMAP的整数数组,其中每个元素都对应于一个持久映射页。它实际上是被映射页的一个使用计数器,0意味着相关的页没有使用,1有特殊语义,n代表内核中有n-1处使用该页(n≥2)。)

kmap映射的页,如果不再需要,必须用kunmap解除映射。

2)临时内核映射

kmap函数不能用于中断处理程序,因为它可能进入睡眠状态(pkmap数组中没有空闲位置时)。内核提供了kmap_atomic,该函数执行是原子的,比普通的kmap快速,不能用于可能进入睡眠的代码,对于很快就需要一个临时页的简短代码是非常理想的。

kmap_atomic的定义在IA-32、PPC、Sparc32上是特定于体系结构的,但这3种实现只有非常细微的差别,其原型是相同的。

void *kmap_atomic(struct page *page, enum km_type type)       //page是一个指向高端内存页的管理结构的指针,type定义了所需的映射类型

(内核的固定映射机制,使之可以在内核地址空间中访问用于建立原子映射的内存。可以在FIX_KMAP_BEGIN和FIX_KMAP_END之间建立一个用于映射高端内存页的区域,该区域位于fixed_addresses数组中,准确的位置需要根据当前活动的CPU和所需映射类型计算。)

在使用kmap_atomic时不会阻塞。如果发生阻塞,那么另一个进程可能建立同样类型的映射,覆盖现存的项。

kunmap_atomic函数从虚拟内存解除一个现存的原子映射,该函数根据映射类型和虚拟地址,从页表删除对应的项。

3)没有高端内存的计算机上的映射函数

许多体系结构不需要支持高端内存(比如AMD64),为了不需要总是区分高端内存和非高端内存体系结构,内核定义了几个在普通内存实现兼容函数的宏(在支持高端内存的计算机上,如果停用了高端内存,也会使用这些宏)。

 1 #ifdef CONFIG_HIGHMEM
 2 ...
 3 #else
 4 static inline void *kmap(struct page *page)
 5 {
 6     might_sleep();
 7     return page_address(page);
 8 }
 9 #define kunmap(page) do { (void) (page); } while (0)
10 #define kmap_atomic(page, idx) page_address(page)
11 #define kunmap_atomic(addr, idx) do { } while (0)
12 #endif

 

六、slab分配器

类似于C语言中的malloc,slab分配器提供小块内存,同时它也用作一个缓存,主要针对经常分配并释放的对象。slab分配器将释放内存块保存在一个内部列表中,并不马上返回给伙伴系统,以便下一次高速的内存分配。这样内核不必使用伙伴系统算法,处理时间会变短,同时该内存块仍然驻留在CPU告诉缓存的概率较高。

slab分配器有两大好处:

  • 调用伙伴系统的操作对系统的数据和指令高速缓存有相当的影响。内核越浪费这些资源,这些资源对用户空间进程就越不可用。更轻量级的slab分配器在可能的情况下减少了对伙伴系统的调用。
  • 如果数据存储在伙伴系统直接提供的页中,那么其地址总是出现在2的幂次的整数倍附近(许多将页划分为更小块的其他分配方法,也有同样的特征)。这对CPU高速缓存的利用有负面影响,由于这种地址分布,使得某些缓存行过度使用,而其他的则几乎为空。多处理器系统可能会加剧这种不利情况,因为不同的内存地址可能在不同的总线上传输,上述情况会导致某些总线拥塞,而其他总线则几乎没有使用。通过slab着色(slab coloring),slab分配器能够均匀地分布对象,以实现均匀的缓存利用。

 1、备选分配器

在大型系统上仅slab的数据结构就需要很多GB内存。对嵌入式系统来说,slab分配器代码量和复杂性都太高,因此诞生了slob分配器和slub分配器。

slob分配器进行了特别优化,以便减少代码量。它围绕一个简单的内存块链表展开,在分配内存时,使用了同样简单的最先适配算法(速度非最高效,不适用大型系统);

slub分配器通过将页帧打包为组,并通过struct page中未使用的字段来管理这些组,试图最小化所需的内存开销。

所有分配器的前端接口都是相同的。每个分配器都实现了一组特定的函数,用于内存分配和缓存。

  • kmalloc、__kmalloc和kmalloc_node是一般的(特定于结点)内存分配函数;
  • kmem_cache_alloc、kmem_cache_alloc_node提供(特定于结点)特定类型的内核缓存。

13阐释了物理页帧、伙伴系统、通用分配器与一般内核代码接口关联。

13 伙伴系统、通用分配器与一般内核代码接口关联示意图

2、内核中的内存管理

内核中一般的内存分配和释放函数与C标准库中等价函数的名称类似,用法也几乎相同。

  • kmalloc(size, flags)分配长度为size字节的一个内存区,并返回指向该内存区起始处的一个void指针,如果没有足够内存,则结果为NULL指针;
  • kfree(*ptr)释放*ptr指向的内存区。

与用户空间程序设计相比,内核还包括percpu_alloc和percpu_free函数,用于为各个系统CPU分配和释放所需内存区。

所有活动缓存的列表保存在/proc/slabinfo中(终端输入cat /proc/slabinfo即可查看),包含用于标识各个缓存的字符串名称,缓存中活动对象的数量,缓存中对象的总数(已用和未用),所管理对象的长度(按字节计算),一个slab中对象的数量,每个slab中页的数量,活动slab的数量,在内核决定向缓存分配更多内存时,所分配对象的数量。

3、slab分配的原理

slab分配器由一个紧密地交织的数据和内存结构的网络组。如图14所示,slab缓存由保存管理性数据的缓存对象和保存被管理对象的各个slab。

14 slab分配器各个部分

每个缓存只负责一种对象类型,或提供一般性的缓冲区。各个缓存中slab的数目各有不同,这与已经使用的页的数目、对象长度和被管理对象的数目有关。

系统中所有的缓存都保存在一个双链表中。这使得内核有机会依次遍历所有的缓存。

1)缓存的精细结构

15 slab缓存的精细结构

15描述了缓存各组成部分,除了管理性数据,缓存结构包括两个特别重要的成员:

  • 指向一个数组的指针,其中保存了各个CPU最后释放的对象;
  • 每个内存结点都对应3个表头,用于组织slab的链表。第1个链表包含完全用尽的slab,第2个是部分空闲的slab,第3个是空闲的slab。

缓存结构指向一个数组,其中包含了与系统CPU数目相同的数组项。每个元素都是一个指针,指向一个进一步的结构称之为数组缓存(array cache),其中包含了对应于特定系统CPU的管理数据(就总体来看,不是用于缓存)。管理性数据之后的内存区包含了一个指针数组,各个数组项指向slab中未使用的对象。

为最好地利用CPU高速缓存,在分配和释放对象时,采用后进先出原理(LIFO,last in first out)。内核假定刚释放的对象仍然处于CPU高速缓存中,会尽快再次分配它。仅当per-CPU缓存为空时,才会用slab中的空闲对象重新填充它们。这样,对象分配的体系就形成了一个三级的层次结构(分配成本和操作对CPU高速缓存和TLB的负面影响逐级升高):

  • 仍然处于CPU高速缓存中的per-CPU对象;
  • 现存slab中未使用的对象;
  • 刚使用伙伴系统分配的新slab中未使用的对象。

2)slab精细结构

用于每个对象的长度进行了舍入,以满足某些对齐方式的要求,对于对齐方案,有两种:

slab创建时使用标志SLAB_HWCACHE_ALIGN,slab用户可以要求对象按硬件缓存行对齐;

如果不要求按硬件缓存行对齐,那么内核保证对象按BYTES_PER_WORD对齐,该值是表示void指针所需字节的数目。

32位处理器上,void指针需要4个字节。因此,对有6个字节的对象,则需要8 = 2×4个字节,多余的字节称为填充字节。填充字节可以加速对slab中对象的访问,如果使用对齐的地址,那么在几乎所有的体系结构上,内存的访问都会更快。

slab的起始处是管理结构,保存了所有的管理数据(和用于连接缓存链表的链表元素)。其后面是一个数组,每个(整数)数组项对应于slab中的一个对象(只有在对象没有分配时,相应的数组项才有意义)。此时,它指定了下一个空闲对象的索引。由于最低编号的空闲对象的编号还保存在slab起始处的管理结构中,内核无需使用链表或其他复杂的关联机制,即可找到当前可用的所有对象。数组的最后一项总是一个结束标记,值为BUFCTL_END。管理数组与slab对象的关系如图16所示。

16 slab中空闲对象的管理

管理数据可以放置在slab自身,也可以放置到使用kmalloc分配的不同内存区中。内核的选择取决于slab的长度和已用对象的数量。

最后,内核通过对象自身(page结构的一个链表元素lru.next和lru.prev)识别slab(以及对象驻留的缓存)。根据对象的物理内存地址,找到相关的页,从而在全局mem_map数组中找到对应的page实例。

4、实现

 slab系统带有大量调试选项,代码中遍布着预处理语句:

  • 危险区(Red Zoning):在每个对象的开始和结束处增加一个额外的内存区,其中填充已知的字节模式。如果模式被修改,程序员在分析内核内存时注意到,可能某些代码访问了不属于它们的内存区;
  • 对象毒化(Object Poisoning):在建立和释放slab时,将对象用预定义的模式填充。如果在对象分配时注意到该模式已经改变,此处已经发生了未授权访问等。

1)数据结构

每个缓存由kmem_cache结构的一个实例表示。结构内容如下:

 1 struct kmem_cache {
 2 /* 1) per-CPU数据,在每次分配/释放期间都会访问 */
 3     struct array_cache *array[NR_CPUS];    //指向数组的指针,每个数组项都对应于系统中的一个CPU。每个数组项都包含了另一个指针,指向下文讨论的array_cache结构的实例
 4 /* 2) 可调整的缓存参数。由cache_chain_mutex保护 */
 5     unsigned int batchcount;    //per-CPU列表为空时,从缓存的slab中获取对象的数目;还表示在缓存增长时分配的对象数目
 6     unsigned int limit;    //指定了per-CPU列表中保存的对象的最大数目
 7     unsigned int shared;
 8     unsigned int buffer_size;        //指定了缓存中管理的对象的长度
 9     u32 reciprocal_buffer_size;
10 /* 3) 后端每次分配和释放内存时都会访问 */
11     unsigned int flags; /* 常数标志 */
12     unsigned int num; /* 每个slab中对象的数量 */
13 /* 4) 缓存的增长/缩减 */
14     unsigned int gfporder;    //指定了slab包含的页数目以2为底的对数
15 /* 强制的GFP标志,例如GFP_DMA */
16     gfp_t gfpflags;        
17     size_t colour;     //颜色的最大数目
18     unsigned int colour_off;     //着色偏移 
19     struct kmem_cache *slabp_cache;    //slab头部的管理数据存储在slab外部时,指向分配所需内存的一般性缓存; slab头部在slab上时,为NULL指针
20     unsigned int slab_size;
21     unsigned int dflags;     // 标志集合,描述slab的“动态性质”
22     void (*ctor)(struct kmem_cache *, void *);    //指向在对象创建时调用的构造函数
23 /* 5) 缓存创建/删除 */
24     const char *name;    //是一个字符串,包含该缓存的名称
25     struct list_head next;    //用于将kmem_cache的所有实例保存在全局链表cache_chain上
26 /* 6) 统计量 */
27 ...
28     struct kmem_list3 *nodelists[MAX_NUMNODES];
29 };

2)初始化

为初始化slab数据结构,内核需要若干小内存块(最适合由kmalloc分配),但是只有slab系统启用之后,才能使用kmalloc,因而内核借助了一些技巧。

kmem_cache_init函数用于初始化slab分配器。它在内核初始化阶段(start_kernel)、伙伴系统启用之后调用。第一步:kmem_cache_init创建系统中的第一个slab缓存,以便为kmem_cache的实例提供内存,内核使用的主要是在编译时创建的静态数据;第二步:kmem_cache_init接下来初始化一般性的缓存,用作kmalloc内存的来源(针对所需的各个缓存长度,分别调用kmem_cache_create);第三步:在kmem_cache_init的最后一步,把到现在为止一直使用的数据结构的所有静态实例化的成员,用kmalloc动态分配的版本替换。

3)创建缓存

创建新的slab缓存必须调用kmem_cache_create,这是一个冗长的过程,其代码示意图如图17所示。

17 kmem_cache_create的代码流程图

  • 首先,进行参数检查,以确保没有指定无效值,然后才执行第一个重要步骤,计算对齐所需填充字节数;
  • 接着在数据对齐值计算完毕后,分配struct kmem_cache一个实例(一个独立的slab缓存,名为cache_cache);
  • 然后确定是否将slab头存储在slab之上,如果对象长度大于页帧的1/8,则将头部管理数据存储在slab之外,否则存储在slab上,随后,增加对象的长度size,直至对应到上文计算的对齐值;
  • 至此,对象长度定义完成,以下定义slab长度(选择适当的页数作为slab长度)。
  • 首先,内核通过calculate_slab_order进行迭代,找到理想的slab长度(基于给定对象长度,cache_estimate针对特定的页数,来计算对象数目、浪费的空间、着色所需的空间);
  • 接着计算颜色(即slab上的浪费空间除以颜色偏移量的商);
  • 然后通过enable_cpucache产生per-CPU缓存;
  • 最后将初始化过的kmem_cache实例添加到全局链表,表头为cache_chain。

4)分配对象

kmem_cache_alloc用于从特定的缓存获取对象,它需要用于获取对象的缓存,以及精确描述分配特征的标志变量两个参数,结果可能是指向分配内存区的指针,也可能分配失败返回NULL。

18 kmem_cache_alloc的代码流程图

  • 首先,kmem_cache_alloc基于参数相同的内部函数__cache_alloc,后者可以直接调用(采用这种结构,目的是尽快合并kmalloc和kmem_cache_alloc的实现)。__cache_allloc只是一个前端函数,只执行了所有必要的锁定操作。实际工作委托给____cache_alloc进行;
  • 然后选择被缓存对象,如果在per-CPU缓存中有对象,则从缓存中获取对象后返回;如果没有对象在per-CPU缓存中,需要调用cache_alloc_refill重新填充缓存,内核先按一定的顺序扫描slab,如果找到空闲对象则返回,如果没有找到空闲对象,那么必须使用cache_grow扩大缓存(见下)。

5)缓存的增长

19描述了cache_grow代码流程图。

19 cache_grow的代码流程图

  • 首先计算颜色和偏移量,如果达到了颜色的最大数目,则内核重新开始从0计数,这自动导致零偏移;
  • 接着使用kmem_getpages辅助函数从伙伴系统逐页分配所需的内存空间;
  • 然后调用相关的alloc_slabmgmt函数分配所需空间;
  • 接下来,调用slab_map_pages创建slab的各页与slab或缓存之间的关联;
  • 随后cache_init_objs调用各个对象的构造器函数(假如有的话),初始化新slab中的对象;
  • 最后将完全初始化的slab添加到缓存的slabs_free链表中。

6)释放对象

当一个分配的对象不再需要时,使用kmem_cache_free将其返回给slab分配器。图20为kmem_cache_free代码流程图。

20 kmem_cache_free的代码流程图

立即调用__cache_free,根据per-CPU缓存的状态不同,执行以下两种操作:

  • 如果per-CPU缓存中的对象数目低于允许的限制,则在其中存储一个指向缓存中对象的指针;
  • 否则,必须将一些对象(准确的数目由array_cache->batchcount给出)从缓存移回slab,从编号最低的数组元素开始:缓存的实现依据先进先出原理,这些对象在数组中已经很长时间,因此不太可能仍然驻留在CPU高速缓存中。此后,将slab重新插入到缓存的链表中,如果删除后,slab中所有对象都未使用,则置于slabs_free链表,如果同时包含使用和未使用对象,则插入slabs_partial链表。

7)销毁缓存

如果要销毁只包含未使用对象的一个缓存,则必须调用kmem_cache_destroy函数。该函数主要在删除模块时调用,此时需要将分配的内存都释放。主要步骤如下:

  • 依次扫描slabs_free链表上的slab。首先对每个slab上的每个对象调用析构器函数,然后将slab的内存空间返回给伙伴系统;
  • 释放用于per-CPU缓存的内存空间;
  • cache_cache链表移除相关数据。

5、通用缓存

如果不涉及对象缓存,而是传统意义上的分配/释放内存,则必须调用kmalloc和kfree函数。kmalloc和kfree实现为slab分配器的前端,其语义尽可能地模仿C标准库malloc和free。

七、处理器高速缓存和TLB控制

内核提供了一些命令直接作用于处理器的高速缓存和TLB,用于维护缓存内容的一致性,确保不出现不正确和过时的缓存项。

不同体系结构上,高速缓存和TLB的硬件实现不同,因此内核需要建立TLB和高速缓存的视图,在其中考虑到各种不同的硬件实现方法,兼顾各个体系结构的特定性质。

TLB的语义抽象是将虚拟地址转换为物理地址的一种机制;

内核将高速缓存视为通过虚拟地址快速访问数据的一种机制,该机制无需访问物理内存。数

据和指令高速缓存并不总是明确区分。

内核中各个特定于CPU的部分都必须提供下列函数(即使只是空操作),以便控制TLB和高速缓存:

  • flush_tlb_all和flush_cache_all刷出整个TLB/高速缓存;
  • flush_tlb_mm和flush_cache_mm刷出所有属于地址空间mm的TLB/高速缓存项;
  • flush_tlb_range和flush_cache_range刷出地址范围vma->vm_mm中虚拟地址start和end之间的所有TLB/高速缓存项;
  • flush_tlb_page和flush_cache_page刷出虚拟地址在[page, page + PAGE_SIZE]范围内所有的TLB/高速缓存项;
  • update_mmu_cache在处理页失效之后调用。它在处理器的内存管理单元MMU中加入信息,使得虚拟地址address由页表项pte描述。

内核对数据和指令高速缓存不作区分。如果需要区分,特定于处理器的代码可根据vm_area_struct->flags的VM_EXEC标志位是否设置,来确定高速缓存包含的是指令还是数据。

flush_cache_...和flush_tlb_...函数经常成对出现。

比如在使用fork复制进程地址空间时,操作的顺序是:刷出高速缓存、操作内存、刷出TLB,原因有两个:

  • 如果顺序反过来,那么在TLB刷出之后、正确信息提供之前,多处理器系统中的另一个CPU可能从进程的页表取得错误的信息。
  • 在刷出高速缓存时,某些体系结构需要依赖TLB中的“虚拟->物理”转换规则。flush_tlb_mm必须在flush_cache_mm之后执行,以确保这点。
posted @ 2018-11-07 22:54  雾封尘  阅读(1275)  评论(0编辑  收藏  举报