linux内核内存管理学习

来源:互联网 发布:山东数据恢复 编辑:程序博客网 时间:2024/05/21 09:25

一、概述

1.虚拟地址空间

内存是通过指针寻址的,因而CPU的字长决定了CPU所能管理的地址空间的大小,该地址空间就被称为虚拟地址空间,因此32位CPU的虚拟地址空间大小为4G,这和实际的物理内存数量无关。
Linux内核将虚拟地址空间分成了两部分:
  • 一部分是用户进程可用的,这部分地址是地址空间的低地址部分,从0到TASK_SIZE,称为用户空间
  • 一部分是由内核保留使用的,这部分地址是地址空间的高地址部分,从KERNELBASE到结束,称为内核空间
与之相关的一些宏:
  1. KERNELBASE:内核虚拟地址空间的起始地址,一般和PAGE_OFFSET相同,但是也可能不同
  2. PAGE_OFFSET:内核虚拟地址空间中低端内存的起始地址
  3. PHYSICAL_START:内核物理地址的起始地址
  4. MEMORY_START:内核低端内存的物理起始地址
用户进程可用的部分在进程切换时会发生改变,但是由内核保留使用的部分在进程切换时是不变的。在32位系统上,两部分的典型划分比为3:1(该比例可修改),即4G虚拟地址空间中的3G是用户进程可访问的,而另外1G是保留给内核使用的,在这种划分下用户进程可用的虚拟地址空间是0x00000000-0xbfffffff,内核的虚拟地址空间是0xc0000000-0xffffffff。
不同的进程使用不同的用户空间可以使得不同进程的用户空间部分相互隔离,从而保护进程的用户空间部分。
内核空间的保护是通过CPU的特权等级实现的,所有现代CPU都提供了多个特权等级,每个特权等级可以获得的权限是不同的,当CPU处在某个权限等级时就只能执行符合这个等级的权限限制的操作。Linux使用了两个权限等级,分别对应于内核权限和用户权限,并且给属于内核的内存空间添加了权限限制,使得只有处于内核权限等级时CPU才能访问这些内存区域,这就将内核空间也保护了起来。

2.物理地址到虚拟地址的映射

可用的物理内存会被映射到内核虚拟地址空间中。在32位系统中,内核会将一部分物理内存直接映射到内核的虚拟地址空间中,如果访问内存时所使用的虚拟地址与内核虚拟地址起始值的偏移量不超过该部分内存的大小,则该虚拟地址会被直接关联到物理页帧;否则就必须借助”高端内存“来访问,因此也可以看出之所以使用“高端内存”是因为CPU可寻址的虚拟地址可能小于实际的物理内存,因而不得不借助其它机制(“高端内存”)来访问所有的内存。在IA-32系统上,这部分空间大小为896M。
64位系统不使用高端内存,这是因为64位的系统理论上可寻址的地址空间远大于实际的物理内存(至少现在是如此),因而就不必借助“高端内存”了。而对于用户进程来说,由于它的所有内存访问都通过页表进行,不会直接进行,因而对用户进程来说也不存在高端内存之说。
高端内存由32位架构的内核使用,在32位架构的内核中,要使用高端内存必须首先使用kmap将高端内存映射进内核的虚拟地址空间。

3.内存类型

从硬件角度来说存在两种不同类型的机器,分别用不同的方式来管理内存。
  1. UMA(uniform memory access):一致内存访问机器,它将可用内存以连续的方式组织起来。SMP系统中,每个CPU都可以以同样的速度访问内存。
  2. NUMA(non-uniform memory access):非一致内存访问机器总是多处理器机器。系统的各个CPU都有本地内存,可以支持快速访问。系统中的所有处理器都通过总线连接起来,进而可以访问其它CPU的本地内存,但是不如访问本地内存快。
 

lnux中如果要支持NUMA系统,则需要打开CONFIG_NUMA选项。

二、内存组织

linux内核对一致和不一致的内存访问系统使用了同样的数据结构,因此对于不同的内存布局,内存的管理算法几乎没有区别。对于UMA系统,将其看作只有一个NUMA节点的NUMA系统,即将其看成NUMA的特例。这样就将简化了内存管理的其它部分,其它部分都可以认为它们是在处理NUMA系统。

1.基本概念和相关数据结构

linux引入了一个概念称为node,一个node对应一个内存bank,对于UMA系统,只有一个node。其对应的数据结构为“struct pglist_data”。
对于NUMA系统来讲, 整个系统的内存由一个名为node_data 的struct pglist_data(page_data_t) 指针数组来管理。NUMA系统的内存划分如图所示:
 
每个node又被分成多个zone,每个zone对应一片内存区域。内核引入了枚举常量 zone_type 来描述zone的类型:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. <mmzone.h>  
  2. enum zone_type {  
  3. #ifdef CONFIG_ZONE_DMA   
  4. ZONE_DMA,  
  5. #endif   
  6. #ifdef CONFIG_ZONE_DMA32   
  7. ZONE_DMA32,  
  8. #endif   
  9. ZONE_NORMAL,  
  10. #ifdef CONFIG_HIGHMEM   
  11. ZONE_HIGHMEM,  
  12. #endif   
  13. ZONE_MOVABLE,  
  14. MAX_NR_ZONES  
  15. };  
它们之间的用途是不一样的:
  • ZONE_DMA:可用作DMA的内存区域。该类型的内存区域在物理内存的低端,主要是ISA设备只能用低端的地址做DMA操作。
  • ZONE_NORMAL:直接被内核直接映射到自己的虚拟地址空间的地址。
  • ZONE_HIGHMEM:不能被直接映射到内核的虚拟地址空间的地址。
  • ZONE_MOVABLE:伪zone,在防止物理内存碎片机制中使用
  • MAX_NR_ZONES:结束标记
很显然根据内核配置项的不同,zone的类型是有变化的。每个zone都和一个数组关联在一起,该数组用于组织管理属于该zone的物理内存页。
zone用数据结构struct zone来表示。
所有的node都被保存在一个链表中。在使用时,内核总是尝试从与进程所运行的CPU所关联的NUMA节点申请内存。这是就要用到备用列表,每个节点都通过struct zonelist提供了备用列表,该列表包含了其它节点,可用于代替本节点进行内存分配,其顺序代表了分配的优先级,越靠前优先级越高。

2.阈值计算

当系统中可用内存很少的时候,内核线程kswapd被唤醒,开始回收释放page。pages_min, pages_low and pages_high这些参数影响着回收行为。
每个zone有三个阈值标准:pages_min, pages_low and pages_high,帮助确定zone中内存分配使用的压力状态。kswapd和这3个参数的互动关系如下图:
  
在最新的内核中这三个变量变成了watermark数组的成员,分别对应于WMARK_MIN,WMARK_LOW和WMARK_HIGH。
内核在计算这几个值之前会首先计算一个关键参数min_free_kbytes,它是为关键性分配保留的内存空间的最小值。该关键参数有一个约束:不能小于128k,不能大于64M。其计算公式:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. lowmem_kbytes = nr_free_buffer_pages() * (PAGE_SIZE >> 10);           
  2. min_free_kbytes = int_sqrt(lowmem_kbytes * 16);  
阈值的计算由init_per_zone_pages_min( 最新内核中是init_per_zone_wmark_min)完成。该函数还会完成每个zone的lowmem_reserve的计算,该数组用于为不能失败的关键分配预留的内存页。这几个阈值的含义:
  • page_min:如果空闲页数目小于该值,则该zone非常缺页,页面回收压力很大。
  • page_low: 如果空闲页数目小于该值,kswapd线程将被唤醒,并开始释放回收页面。
  • page_high: 如果空闲页面的值大于该值,则该zone的状态很完美, kswapd线程将重新休眠。

3.Zone等待队列表

当对一个page做I/O操作的时候,page需要被锁住,以防止不正确的数据被访问。做法是:
  1. 进程在访问page前,调用wait_on_page*函数,使进程加入一个等待队列(如果没有其它进程正在访问该页,就直接获得访问权限,否则加入等待队列)。
  2. 当当前访问page的进程完成自己的访问动作后,会调用unlock_page唤醒在该页上wait的进程,因而进程即可获得对页的访问权。
每个page都可以有一个等待队列,但是太多的分离的等待队列使得花费太多的内存访问周期。也可以让一个zone中的所有page都使用同一个队列,但是这就意味着,当一个page unlock的时候,访问这个zone里内存page的所有休眠的进程将都被唤醒,这样就会出现惊群效应(thundering herd)。
内核的解决方法是将所有的队列放在struct zone数据结构中,并通过哈希表zone->wait_table来管理zone中的等待队列。哈希表的方法可能会造成一些进程不必要的唤醒,但是这个是小概率事件是可以容忍的。
等待队列的哈希表的分配和建立在free_area_init_core()函数中(最终是在zone_wait_table_init()函数中)进行。

4.冷热页

zone中的pageset用于实现冷热分配器。热页指的是已经加载到CPU高速缓存的页,这种页的访问速度比在主存中的快。冷页就是不在高速缓存中的页。SMP系统中每个CPU都有一个或多个高速缓存,各个CPU的管理必须是独立的(即便在NUMA中每个CPU也都可以访问所有的内存页,因而其高速缓存也可能缓存所有的内存页)。
每个CPU都有一个struct per_cpu_pages结构,其定义如下:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. struct per_cpu_pages {   
  2.         int count;              /* number of pages in the list */   
  3.         int high;               /* high watermark, emptying needed */   
  4.         int batch;              /* chunk size for buddy add/remove */   
  5.          /* Lists of pages, one per migrate type stored on the pcp-lists */   
  6.         struct list_head lists[MIGRATE_PCPTYPES];   
  7. };   
  • count:列表中页的数目
  • high:一个阈值,如果列表中页数目超过该值,则表示列表中页太多了;没有下限的阈值,如果列表中没有成员,则重新填充。
  • batch:如果可能,每次操作多个页,batch是每次操作页数目的参考值。
  • lists:页列表。
在这些列表中,热页放在列表头部,冷页放在尾部。

5.页(Page)

1.页概念

内核使用struct page作为基本单位来管理物理内存,在内核看来,所有的RAM都被划分成了固定长度的页帧。每一个页帧包含了一个页,也就是说一个页帧的长度和一个页的长度相同。页帧是主存的一部分,是一个存储区域。页和页帧的区别在于,页是抽象的数据结构,可以存放在任意地方,而页帧是真实的存储区域。
struct page包含了跟踪一个物理页帧当前被用于什么的有信息。比如页面计数,标志等等。

2.映射页面到zone

内核使用struct page的flags中的字段来保存页所属于的zone以及node。这是通过set_page_zone和set_page_node,这两个函数由函数set_page_links调用。

三、页表

1.页表机制

CPU管理虚拟地址,因而物理地址需要映射到虚拟地址才能给CPU使用。用于将虚拟地址空间映射到物理地址空间的数据结构称为页表。
在使用4k大小页的情况下,4k地址空间需要2的20次方个页表项。即便每个页表项大小为4字节也需要4M内存,而每个进程都需要有自己的页表,这就成了一个极大的内存开销。而且在大多数情况下,虚拟地址空间的大部分区域都是没有被使用的,因而没必要为虚拟地址空间中的每个页都分配管理结构,因而实际中采用的是如下方案:
  • 使用多级页表,每个线性地址被看为形如“页目录表+页目录表+...+页目录表+页表+页内偏移”的形式,每个比特组按照其含义被用于在相应的表中查找数据,最终找到页表。
  • 进程的页表只包含了它所使用的地址空间。进程不使用的地址空间不需要加入进程的页表。
  • 只有在进程实际需要一个页表时才会给该页分配RAM,而不是在一开始就为进程的所有页都分配空间。
页表中包含了关于该页的信息,例如是否存在于主存中,是否是“脏”的,访问所需权限等级,读写标志,cache策略等等。内核的页表保存在全局变量swapper_pg_dir中,应用进程的页表保存在task_struct->mm->pgd中,在应用进程切换时,会切换进程的页表(schedule-->__schedule-->context_switch-->switch_mm-->switch_mmu_context-->local_flush_tlb_mm)。

linux中采用了4级分页模型。如下:

PGDPUDPMDPTEOFFSET虽然采用了4级模型,但是:
  • 对于32位且未使能物理地址扩展的系统,使用二级页表。Linux的做法是让页上级目录表和页中间目录表所包含的比特数目为0,让页全局目录表的比特数目包含除了页表和偏移量之外的所有比特,从而取消这两级目录。同时为了让代码可以同时运行在32比特环境和64比特环境,linux保留了这两级目录在指针序列中的位置,做法是将这两级目录所包含的表项数设置为1(这里需要注意的是即便只有一个比特,也可以表示两个项,因此需要此设置)。
  • 对于32且使能了物理地址扩展的系统,使用三级页表。
  • 对于64位系统,取决于硬件对线性地址位的划分。
在linux中,每个进程都有自己的页全局目录表(PGD),以及自己的页表集。当发生进程切换时,linux会完成页表的切换。
使用该方案后,每个虚拟地址都划分为相应的比特分组,其中PGD用于索引每个进程所专有的页全局表,以找到PUD,PUD用于索引进程的页上级目录表,以找到PMD依次类推直到找到PTE。PTE即页表数组,该表的表项包含了指向页帧的指针以及页的访问控制相关的信息,比如权限,是否在主存中,是否包含“脏”数据等等,OFFSET用做表内偏移。
使用该机制后,虚拟地址空间中不存在的内存区域对应的PUD,PMD,PTE将不被创建,这就节省了地址空间。

但是使用该机制后每次寻址都需要多次查表,才能找到对应的物理地址,因而降低了速递,CPU使用高速缓存和TLB来加速寻址过程。在访问内存时,如果虚拟地址对应的TLB存在,也就是TLB 命中了,则直接访问,否则就要使用相关的页表项更新TLB(此时可能需要创建新的页表项)然后再继续进行访问。下图是一个CPU的虚拟地址到实地址的转换过程:


当被访问的地址不存在对应的TLB表项时,就会产生TLB中断。在TLB中断中,会:

  1. 首先查找访问地址对应的页表,如果找不到对应的页表,就会生成相应的页表项(powerpc通过调用读写异常的处理函数完成该过程)。

  2. 使用PTE的内容更新TLB。

在TLB的内容更新完后,仍可能产生读写异常(也就是通常说的page fault),因为页表项虽然存在,但是其内容可能是非法的(比如页表并不在内存中),。

2.x86架构中的页

1.地址空间

当使用x86时,必须区分以下三种不同的地址:
  • 逻辑地址:机器语言指令仍用这种地址指定一个操作数的地址或一条指令的地址。这种寻址方式在Intel的分段结构中表现得尤为具体,它使得MS-DOS或Windows程序员把程序分为若干段。每个逻辑地址都由一个段和偏移量组成。
  • 线性地址:线性地址是一个32位的无符号整数,可以表达高达2的32次方(4GB)的地址。通常用16进制表示线性地址,其取值范围为0x00000000~0xffffffff。
  • 物理地址:也就是内存单元的实际地址,用于芯片级内存单元寻址。物理地址也由32位无符号整数表示。
X86中的MMU包含两个部件,一个是分段部件,一个是分页部件,分段部件(段机制)把一个逻辑地址转换为线性地址;接着,分页部件(分页机制)把一个线性地址转换为物理地址。转化过程如图所示:

 

2.分段

1)分段机制

在x86段机制中,逻辑地址由两部分组成,即段部分(选择符)及偏移部分。
段是形成逻辑地址到线性地址转换的基础。如果我们把段看成一个对象的话,那么对它的描述如下:
  1. 段的基地址(Base Address):在线性地址空间中段的起始地址。
  2. 段的界限(Limit):表示在逻辑地址中,段内可以使用的最大偏移量。
  3. 段的属性(Attribute): 表示段的特性。例如,该段是否可被读出或写入,或者该段是否作为一个程序来执行,以及段的特权级等等。
段的界限定义逻辑地址空间中段的大小。段内在偏移量从0到limit范围内的逻辑地址,对应于从Base到Base+Limit范围内的线性地址。在一个段内,偏移量大于段界限的逻辑地址将没有意义,使用这样的逻辑地址,系统将产生异常。另外,如果要对一个段进行访问,系统会根据段的属性检查访问者是否具有访问权限,如果没有,则产生异常。例如,在80386中,如果要在只读段中进行写入,80386将根据该段的属性检测到这是一种违规操作,则产生异常。
下图表示一个段如何从逻辑地址空间,重新定位到线性地址空间。图的左侧表示逻辑地址空间,定义了A,B及C三个段,段容量分别为LimitA、LimitB及LimitC。图中虚线把逻辑地址空间中的段A、B及C与线性地址空间区域连接起来表示了这种转换。
 
段的基地址、界限及保护属性存储在段的描述符表中,在虚拟—线性地址转换过程中要对描述符进行访问。段描述符又存储在存储器的段描述符表中,该描述符表是段描述符的一个数组。简单的说段描述符表里存储了段描述符,而段描述符又包含了硬件进行逻辑地址到线性地址转换所需的所有信息。
每个段描述符都定义了线性地址空间中的一段地址,它的属性以及它和逻辑地址空间之间的映射关系,实际上是如何从逻辑地址空间映射到线性地址空间。

2)linux中的段

各种段描述符都存放于段描述符表中,要么在GDT中,要么在LDT中。
描述符表(即段表)定义了386系统的所有段的情况。所有的描述符表本身都占据一个字节为8的倍数的存储器空间,空间大小在8个字节(至少含一个描述符)到64K字节(至多含8K)个描述符之间。
  1. 全局描述符表(GDT):全局描述符表GDT(Global Descriptor Table),包含着系统中所有任务都共用的那些段的描述符。
  2. 局部描述符表(LDT):局部描述符表LDT(local Descriptor Table),包含了与一个给定任务有关的描述符,每个任务各自有一个的LDT。有了LDT,就可以使给定任务的代码、数据与别的任务相隔离。
每一个任务的局部描述符表LDT本身也用一个描述符来表示,称为LDT描述符,它包含了有关局部描述符表的信息,被放在全局描述符表GDT中。
但是linux很少使用分段机制,这是因为,分段和分页都能用于将物理地址划分为小的地址片段的功能,因而它们是相互冗余的。分段可以为不同的进程分配不同的线性地址空间,而分页可以将相同的线性地址空间映射到不同的物理地址空间。linux采用了分页机制,原因是:
  1. 如果所有的进程都使用相同的线性地址空间,内存管理更简单
  2. 很多其他架构的CPU对分段的支持很有限
在linux中,所有运行在用户模式的进程都使用相同的指令和数据段,因此这两个段也被成为用户数据段和用户指令段。类似的,内核使用自己的内核数据段和内核数据段。这几个段分别用宏_ _USER_CS,_ _USER_DS,_ _KERNEL_CS, and_ _KERNEL_DS定义。这些段都从0开始,并且大小都相同,因而linux中,线性地址和逻辑地址是相同的,而且内核和用户进程都可以使用相同的逻辑地址,逻辑地址也就是虚拟地址,这就和其它架构统一起来了。
单处理器系统只有一个GDT,而多处理器系统中每个CPU都有一个GDT,GDT存放在cpu_gdt_table中GDT包含了用户数据段,用户指令段,内核数据段内核指令段以及一些其他段的信息。
绝大多数的linux用户程序并不使用LDT,内核定义了一个缺省的LDT给大多数进程共享。它存放于default_ldt中。如果应用程序需要创建自己的局部描述附表,可以通过modify_ldt系统调用来实现。使用该系统调用创建的LDT需要自己的段。应用程序也可以通过modify_ldt来创建自己的段。

四、内存管理初始化 

1.初始化流程

内存初始化关键是page_data_t数据结构以及其下级数据结构(zone,page)的初始化。
宏NODE_DATA用于获取指定节点对应的page_data_t,在多节点系统中,节点数据结构为struct pglist_data *node_data[];该宏获取对应节点所对应的数据结构,如果是单节点系统,节点的数据结构为struct pglist_data contig_page_data;该宏直接返回它。

1.初始化代码流程

系统启动代码中与内存管理相关的初始化代码如图:
 
其功能分别为:
  • setup_arch:架构相关的初始化,其中包括了内存管理中与架构相关部分的初始化。boot分配器在这个时候被初始化。
  • setup_per_cpu_areas:SMP中,该函数初始化源代码中静态定义的每CPU变量,该类变量对系统中每一个CPU都一个副本。此类变量保存在内核二进制影响的一个独立的段中。
  • build_all_zonelists:建立节点和zone的数据结构
  • mem_init:初始化内存分配器
  • setup_per_cpu_pageset:遍历系统中所有的zone,对于每一个zone为所有的CPU分配pageset(冷热页缓存)并进行初始化,在这个函数被调用之前,只有boot pagesets可用。

2.节点和zone的初始化

build_all_zonelists会遍历系统中所有的节点,并为每个节点的内存域生成数据结构。它最终会使用节点数据结构调用build_zonelists,该函数会在该节点和系统中其它节点的内存之间建立一种距离关系,距离表达的是从其它节点分配的代价,因而距离越大,分配代价也越大;之后的内存分配会依据这种距离进行,优先选择本地的,如果本地的不可用,则按照距离从近到远来分配,直到成功或者所有的都失败。
在一个节点的内存域中:
  1. 高端内存被看做是最廉价的,因为内核不依赖于高端内存,它被耗尽不会对系统有不良影响
  2. DMA看做是最昂贵的,因为它有特殊用途,它用于和外设交互数据
  3. 普通内存介于两者之间,因为内核有些部分是依赖于普通内存的,所以它耗尽对系统会有影响
当分配内存时,假设指定的内存区域的昂贵程度为A,则分配过程为:
  1. 首先尝试从本节点分配,并且是按照昂贵程度递增的顺序从A开始尝试,直到最昂贵的区域
  2. 如果从本节点分配失败,则按照距离关系依次检查其它几点,在检查每个节点时,仍是按照昂贵程度递增的顺序从A开始尝试,直到最昂贵的区域

2.特定于体系结构的设置

1.内核在内存中的布局

在启动装载器将内核复制到内存,并且初始化代码的汇编部分执行完后,内存布局如图所示:
 
这是一种默认布局,也存在一些例外:
  • PHYSICAL_START可用于配置修改内核在内存中的位置。
  • 内核可以被编译为可重定位二进制程序,此时由启动装载器决定内核的位置。
默认情况下,内核安装在RAM中从物理地址0x00100000开始的地方。也就是第2M开始的那个。没有安装在第1M地址空间开始的地方的原因:
  • 页帧0由BIOS使用,存在上电自检(POST)期间检查到的系统硬件配置。
  • 物理地址从0x000a0000到0x000fffff的范围通常保留给BIOS程序使用
  • 第一个MB内的其它页帧可能由特定计算机模型保留
从_edata到_end之间的初始化数据部分所占用的内存在初始化完成后有些是不再需要的,可以回收利用,可以控制哪些部分可以回收,哪些部分不能回收。
内核占用的内存分为几段,其边界保存在变量中,可以通过System.map查看相关的信息,在系统启动后也可以通过/proc/iomem查看相关的信息。

2.初始化步骤

在start_kernel,在其中会调用setup_arch来进行架构相关的初始化。setup_arch会完成启动分配器的初始化以及创建内核页表的工作(paging_init)。paging_init最终会调用free_area_init_node这是个架构无关的函数,它会完成节点以及zone的数据结构的初始化。

3.分页机制初始化

Linux内核将虚拟地址空间分成了两部分:用户空间和内核空间。用户进程可用的部分在进程切换时会发生改变,但是由内核保留使用的部分在进程切换时是不变的。在32位系统上,两部分的典型划分比为3:1(该比例可修改),即4G虚拟地址空间中的3G是用户进程可访问的,而另外1G是保留给内核使用的。
32位系统中,内核地址空间又被分为几部分,其图示如下:

3.1 直接映射

其中第一部分用于将一部分物理内存直接映射到内核的虚拟地址空间中,如果访问内存时所使用的虚拟地址与内核虚拟地址起始值的偏移量不超过该部分内存的大小,则该虚拟地址会被直接关联到物理页帧;否则就必须借助”高端内存“来访问,在IA-32系统上,这部分空间大小为896M。
对于直接映射部分的内存,内核提供了两个宏:

  • __pa(vaddr):用于返回与虚拟地址vaddr相对应的物理地址。
  • __va(paddr):用于返回和物理地址paddr相对应的虚拟地址。
剩余部分被内核用作其它用途:
  1. 虚拟地址中连续,但是物理地址不连续的内存区域可以从VMALLO区域分配。该机制通常用于用户进程,内核自己会尽量尝试使用连续的物理地址。当然,当直接映射部分不能满足需求时,内核也会使用该区域。在ppc32中ioremap就使用了该区域。
  2. 持久映射区域用于将高端内存中的非持久页映射到内核中。
  3. 固定映射用于与物理地址空间中的固定页关联的虚拟地址页,但是物理地址页即页帧可以自由选择。
内存的各个区域边界由图中所示的常数定义。high_memory定义了直接映射区域的边界。
系统中定义了与页相关的一些常量:
  • num_physpages:最高可用页帧的页帧号
  • totalram_pages:可用页帧的总数目
  • min_low_pfn:RAM中在内核映像之后的第一个可用的页帧号
  • max_pfn:最后一个可用的页帧号
  • max_low_pfn:被内核直接映射的最后一个页帧的页帧号(低端内存中)
  • totalhigh_pages:没有被内核直接映射的页帧的总数(高端内存中)
在直接映射的内存区域和用于vmalloc的内存区域之间有一个大小为VMALLOC_OFFSET的缺口,它用于对内核进行地址保护,防止内核进行越界访问(越过了直接映射区域)。

3.2 vmalloc区

vmalloc区域的起始位置取决于high_memory和VMALLOC_OFFSET。而其结束位置则取决于是否启用了高端内存支持。如果没有启用高端内存支持,就不需要持久映射区域,因为所有内存都可以直接映射。

3.3 持久映射区

持久映射页则开始于PKMAP_BASE,其大小由LAST_PKMAP表示有多少个页。

3.4 固定映射区

固定映射开始于FIXADDR_START结束于FIXADDR_END。这部分区域指向物理内存的随机位置。在该映射中,虚拟地址和物理地址之间的关联是可以自由定义的,但是定义后就不能更改。该区域一直延伸到虚拟地址空间的顶端。

固定映射的优势在于编译时,对该类地址的处理类似于常数,内核一旦启动即为它分配了物理地址。对此类地址的引用比普通指针要快。在上下文切换期间,内核不会将对应于固定地址映射的TLB刷新出去,因此对这类地址的访问总是通过高速缓存。
对于每一个固定地址,都必须创建一个常数并添加到称为fixed_addresses的枚举列表里。内核提供了virt_to_fix和fix_to_virt用于虚拟地址和固定地址常数之间的转换。

set_fixmap用于建立固定地址常量和物理页之间的对应关系。

3.5 冷热页

free_area_init_node最终会调到zone_pcp_init,它会为该zone计算一个batch值。而setup_per_cpu_pageset则会完成冷热缓存的初始化。

3. 启动过程中的内存管理

bootmem分配器用于内核在启动过程中分配和内存。这是一个很简单的最先适配的分配器。它使用位图来管理页面,比特1表示页忙,0表示空闲。需要分配内存时就扫描位图,直到找到第一个能够满足需求的内存区域。

1. 数据结构

内核为每个节点都分配了一个struct bootmem_data结构的实例用来管理该node的内存。

2. 初始化

在不同的架构下初始化的代码不尽相同,但是都是在paging_int中被调用。

3. 分配器接口

alloc_bootmem*用于分配内存free_bootmem*用于释放内存

4. 停用bootmem分配器

当slab系统完成初始化,能够承担内存分配工作时,需要停掉该分配器,这是通过free_all_bootmem(UMA系统)或free_all_bootmem_node(NUMA系统)来完成的

5. 释放初始化数据

内核提供了两个属性__init用于标记初始化函数,__initdata用于标记初始化数据,这意味着这个函数/数据在初始化完成后其内存就不需了,可以进行回收利用。

linux使用伙伴系统来管理物理内存页。

一、伙伴系统原理

1. 伙伴关系

定义:由一个母实体分成的两个各方面属性一致的两个子实体,这两个子实体就处于伙伴关系。在操作系统分配内存的过程中,一个内存块常常被分成两个大小相等的内存块,这两个大小相等的内存块就处于伙伴关系。它满足 3 个条件 :

  •  两个块具有相同大小记为 2^K
  •  它们的物理地址是连续的
  •  从同一个大块中拆分出来

2. 伙伴算法的实现原理

为了便于页面的维护,将多个页面组成内存块,每个内存块都有 2 的方幂个页,方幂的指数被称为阶 order。order相同的内存块被组织到一个空闲链表中。伙伴系统基于2的方幂来申请释放内存页。
当申请内存页时,伙伴系统首先检查与申请大小相同的内存块链表中,检看是否有空闲页,如果有就将其分配出去,并将其从链表中删除,否则就检查上一级,即大小为申请大小的2倍的内存块空闲链表,如果该链表有空闲内存,就将其分配出去,同时将剩余的一部分(即未分配出去的一半)加入到下一级空闲链表中;如果这一级仍没有空闲内存;就检查它的上一级,依次类推,直到分配成功或者彻底失败,在成功时还要按照伙伴系统的要求,将未分配的内存块进行划分并加入到相应的空闲内存块链表
在释放内存页时,会检查其伙伴是否也是空闲的,如果是就将它和它的伙伴合并为更大的空闲内存块,该检查会递归进行,直到发现伙伴正在被使用或者已经合并成了最大的内存块。

二、linux中的伙伴系统相关的结构

系统中的每个物理内存页(页帧)都对应一个struct page数据结构,每个节点都包含了多个zone,每个zone都有struct zone表示,其中保存了用于伙伴系统的数据结构。zone中的

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. struct free_area      free_area[MAX_ORDER];  

用于管理该zone的伙伴系统信息。伙伴系统将基于这些信息管理该zone的物理内存。该数组中每个数组项用于管理一个空闲内存页块链表,同一个链表中的内存页块的大小相同,并且大小为2的数组下标次方页。MAX_ORDER定义了支持的最大的内存页块大小。
struct free_area的定义如下

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. struct free_area {  
  2.        structlist_head       free_list[MIGRATE_TYPES];  
  3.        unsignedlong        nr_free;  
  4. };  

 

  • nr_free:其中nr_free表示内存页块的数目,对于0阶的表示以1页为单位计算,对于1阶的以2页为单位计算,n阶的以2的n次方为单位计算。
  • free_list:用于将具有该大小的内存页块连接起来。由于内存页块表示的是连续的物理页,因而对于加入到链表中的每个内存页块来说,只需要将内存页块中的第一个页加入该链表即可。因此这些链表连接的是每个内存页块中第一个内存页,使用了struct page中的struct list_head成员lru。free_list数组元素的每一个对应一种属性的类型,可用于不同的目地,但是它们的大小和组织方式相同。

因此在伙伴系统看来,一个zone中的内存组织方式如下图所示:


基于伙伴系统的内存管理方式专注于内存节点的某个内存域的管理,但是系统中的所有zone都会通过备用列表连接起来。伙伴系统和内存域/节点的关系如下图所示:


系统中伙伴系统的当前信息可以通过/proc/buddyinfo查看:


这是我的PC上的信息,这些信息描述了每个zone中对应于每个阶的空闲内存页块的数目,从左到右阶数依次升高。

三、避免碎片

1.碎片概念

伙伴系统也存在一些问题,在系统长时间运行后,物理内存会出现很多碎片,如图所示:
 
这是虽然可用内存页还有很多,但是最大的连续物理内存也只有一页,这对于用户程序不成问题,因为用户程序通过页表映射,应用程序看到的总是连续的虚拟内存。但是对于内核来说就不行了,因为内核有时候需要使用连续的物理内存。

2.linux解决方案

碎片问题也存在于文件系统,文件系统中的碎片可以通过工具来解决,即分析文件系统,然后重新组织文件的位置,但是这种方不适用于内核,因为有些物理页时不能随意移动。内核采用的方法是反碎片(anti-fragmentation)。为此内核根据页的可移动性将其划分为3种不同的类型:

  • 不可移动的页:在内存中有固定位置,不能移动。分配给核心内核的页大多是此种类型
  • 可回收的页:不能移动,但是可以删除,其内容可以从某些源重新生成。
  • 可移动的页:可以随意移动。属于用户进程的页属于这种类型,因为它们是通过页表映射的,因而在移动后只需要更新用户进程页表即可。

页的可移动性取决于它属于上述三类中的哪一类,内核将页面按照不同的可移动性进行分组,通过这种技术,虽然在不可移动页中仍可能出现碎片,但是由于具有不同可移动性的页不会进入同一个组,因而其它两个类型的内存块就可以获得较好的“对抗碎片”的特性。
需要注意的是按照可移动性对内存页进行分组时在运行中进行的,而不是在一开始就设置好的。

1.数据结构

内核定义了MIGRATE_TYPES中迁移类型,其定义如下: 

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. enum {  
  2.     MIGRATE_UNMOVABLE,  
  3.     MIGRATE_RECLAIMABLE,  
  4.     MIGRATE_MOVABLE,  
  5.     MIGRATE_PCPTYPES,   /* the number of types on the pcp lists */  
  6.     MIGRATE_RESERVE = MIGRATE_PCPTYPES,  
  7.     MIGRATE_ISOLATE,    /* can't allocate from here */  
  8.     MIGRATE_TYPES  
  9. };  

其中前三种分别对应于三种可移动性,其它几种的含义:

  • MIGRATE_PCPTYPES:是per_cpu_pageset,即用来表示每CPU页框高速缓存的数据结构中的链表的迁移类型数目
  • MIGRATE_RESERVE:是在前三种的列表中都没用可满足分配的内存块时,就可以从MIGRATE_RESERVE分配
  • MIGRATE_ISOLATE:用于跨越NUMA节点移动物理内存页,在大型系统上,它有益于将物理内存页移动到接近于是用该页最频繁地CPU

每种类型都对应free_list中的一个数组项。
类似于从zone中的分配,如果无法从指定的迁移类型分配到页,则会按照fallbacks指定的次序从备用迁移类型中尝试分配,它定义在page_alloc.c中。
虽然该特性总是编译进去的,但是该特性只有在系统中有足够的内存可以分配到每种迁移类型对应的链表时才有意义,也就是说每个可以迁移性链表都要有“适量”的内存,内核需要对“适量”的判断是基于两个宏的:

  • pageblock_order:内核认为够大的一个分配的阶。
  • pageblock_nr_pages:内核认为启用该特性时每个迁移链表需要具有的最少的内存页数。它的定义是基于pageblock_order的。

基于这个“适量”的概念内核会在build_all_zonelists中判断是否要启用该特性。page_group_by_mobility_disabled表示是否启用了该特性。
内核定义了两个标志:__GFP_MOVABLE和 __GFP_RECLAIMABLE分别用来表示可移动迁移类型和可回收迁移类型,如果没有设置这两个标志,则表示是不可移动的。如果页面迁移特性被禁止了,则所有的页都是不可移动页。
struct zone中包含了一个字段pageblock_flags,它用于跟踪包含pageblock_nr_pages个页的内存区的属性。在初始化期间,内核自动保证对每个迁移类型,在pageblock_flags中都分配了足够存储NR_PAGEBLOCK_BITS个比特的空间。
set_pageblock_migratetype用于设置一个以指定的页为起始地址的内存区的迁移类型。
页的迁移类型是预先分配好的,对应的比特位总是可用,在页释放时,必须将其返还给正确的链表。get_pageblock_migratetype可用于从struct page中获取页的迁移类型。
通过/proc/pagetypeinfo可以获取系统当前的信息。
在内存初始化期间memmap_init_zone会将所有的内存页都初始化为可移动的。该函数在paging_init中会最终被调到(会经过一些中间函数,其中就有free_area_init_node)。

3.虚拟可移动内存

内核还提供了一种机制来解决碎片问题,即使用虚拟内存域ZONE_MOVABLE。其思想是:可用内存划分为两个部分,一部分用于可移动分配,一部分用于不可移动分配。这样就防止了不可移动页向可移动内存区域引入碎片。
该机制需要管理员来配置两部分内存的大小。
kernel参数kernelcore用于指定用于不可移动分配的内存数量,如果指定了该参数,其值会保存在required_kernelcore会基于它来计算。
kernel参数movablecore用于指定用于可移动分配的内存数量,如果指定了该参数,则其值会被保存在required_movablecore中,同时会基于它来计算required_kernelcore,代码如下(函数find_zone_movable_pfns_for_nodes):

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. corepages = totalpages - required_movablecore;  
  2. required_kernelcore = max(required_kernelcore, corepages);  

如果计算出来的required_kernelcore为0,则该机制将无效。
该zone是一个虚拟zone,它不和任何物理内存相关联,该域中的内存可能来自高端内存或者普通内存。用于不可移动分配的内存会被均匀的分布到系统的各个内存节点中;同时用于可移动分配的内存只会取自最高内存域的内存,zone_movable_pfn记录了取自各个节点的用于可移动分配的内存的起始地址。

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

在内存管理的初始化中,架构相关的代码要完成系统中可用内存的检测,并要将相关信息提交给架构无关的代码。架构无关的代码free_area_init_nodes负责完成管理数据结构的创建。该函数需要一个参数max_zone_pfn,它由架构相关的代码提供,其中保存了每个内存域的最大可用页帧号。内核定义了两个数组:

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. static unsigned long __meminitdata arch_zone_lowest_possible_pfn[MAX_NR_ZONES];  
  2. static unsigned long __meminitdata arch_zone_highest_possible_pfn[MAX_NR_ZONES];  

这两个数组在free_area_init_nodes用于保存来自max_zone_pfn的信息,并将它转变成[low,high]的形式。
然后内核开始调用find_zone_movable_pfns_for_nodes对ZONE_MOVABLE域进行初始化。

然后内核开始为每一个节点调用free_area_init_node,这个函数将完成:

  1. 调用calculate_node_totalpages计算节点中页的总数
  2. 调用alloc_node_mem_map负责初始化struct pglist_data中的node_mem_map,为它分配的内存将用于存储本节点的所有物理内存的struct page结构。这片内存将对其到伙伴系统的最大分配阶上。而且如果当前节点是第0个节点,则该指针信息还将保存在全局变量mem_map中。
  3. 调用free_area_init_core完成初始化进一步的初始化

free_area_init_core将完成内存域数据结构的初始化,在这个函数中

  1. nr_kernel_pages记录直接映射的页面数目,而nr_all_pages则记录了包括高端内存中页数在内的页数
  2. 会调用zone_pcp_init初始化该内存域的每CPU缓存
  3. 会调用init_currently_empty_zone初始化该zone的wait_table,free_area列表
  4. 调用memmap_init初始化zone的页,所有页都被初始化为可移动的

五、分配器API

伙伴系统只能分配2的整数幂个页。因此申请时,需要指定请求分配的阶。
有很多分配和释放页的API,都定义在gfp.h中。最简单的是alloc_page(gfp_mask)用来申请一个页, free_page(addr)用来释放一个页。
这里更值得关注的获取页面时的参数gfp_mask,所有获取页面的API都需要指定该参数。它用来影响分配器的行为,其中有是分配器提供的标志,标志有两种:
zone修饰符:用于告诉分配器从哪个zone分配内存
行为修饰符:告诉分配器应该如何进行分配
其中zone修饰符定义为

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. #define __GFP_DMA   ((__force gfp_t)___GFP_DMA)  
  2. #define __GFP_HIGHMEM   ((__force gfp_t)___GFP_HIGHMEM)  
  3. #define __GFP_DMA32 ((__force gfp_t)___GFP_DMA32)  
  4. #define __GFP_MOVABLE   ((__force gfp_t)___GFP_MOVABLE)  /* Page is movable */  
  5. #define GFP_ZONEMASK    (__GFP_DMA|__GFP_HIGHMEM|__GFP_DMA32|__GFP_MOVABLE)  

这些定义都一目了然,需要指出的是如果同时指定了__GFP_MOVABLE和__GFP_HIGHMEM,则会从虚拟的ZONE_MOVABLE分配。
更详细的可以参考gfp.h,其中包含了所有的标志及其含义。

1.分配页

__alloc_pages会完成最终的内存分配,它是伙伴系统的核心代码(但是在内核代码中,这种命名方式的函数都是需要小心调用的,一般都是给实现该功能的代码自己调用,不作为API提供出去的,因而它的包装器才是对外提供的API,也就是alloc_pages_node)。

1.选择页

选择页中最重要的函数是get_page_from_freelist,它负责通过标志和分配阶来判断分配是否可以进行,如果可以就进行实际的分配。该函数还会调用zone_watermark_ok根据指定的标识判断是否可以从给定的zone中进行分配。该函数需要struct zonelist的指针指向备用zone,当当前zone不能满足分配需求时就依次遍历该列表尝试进行分配。整体的分配流程是:

  1. 调用get_page_from_freelist尝试进行分配,如果成功就返回分配到的页,否则
  2. 唤醒kswapd,然后再次调用get_page_from_freelist尝试进行分配,如果成功就返回分配的页,否则
  3. 如果分配的标志允许不检查阈值进行分配,则以ALLOC_NO_WATERMARKS为标志再次调用get_page_from_freelist尝试分配,如果成功则返回分配的页;如果不允许不检查阈值或者仍然失败,则
  4. 如果不允许等待,就分配失败,否则
  5. 如果支持压缩,则尝试先对内存进行一次压缩,然后再调用get_page_from_freelist,如果成功就返回,否则
  6. 进行内存回收,然后再调用get_page_from_freelist,如果成功就返回,否则
  7. 根据回收内存并尝试分配的结果以及分配标志,可能会调用OOM杀死一个进程然后再尝试分配,也可能不执行OOM这一步的操作,如果执行了,则在失败后可能就彻底失败,也可能重新回到第2步,也可能继续下一步
  8. 回到第2步中调用get_page_from_freelist的地方或者再尝试一次先压缩后分配,如果走了先压缩再分配这一步,这就是最后一次尝试了,要么成功要么失败,不会再继续尝试了

2.移出所选择的页

在函数get_page_from_freelist中,会首先在zonelist中找到一个具有足够的空闲页的zone,然后会调用buffered_rmqueue进行处理,在分配成功时,该函数会把所分配的内存页从zone的free_list中移出,并且保证剩余的空闲内存页满足伙伴系统的要求,该函数还会把内存页的迁移类型存放在page的private域中。
该函数的步骤如图所示:


可以看出buffered_rmqueue的工作过程为:

  1. 如果申请的是单页,会做特殊处理,内核会利用每CPU的缓存加速这个过程。并且在必要的时候会首先填充每CPU的缓存。函数rmqueue_bulk用于从伙伴系统获取内存页,并添加到指定的链表,它会调用函数__rmqueue。
  2. 如果是分配多个页,则会首先调用__rmqueue从内存域的伙伴系统中选择合适的内存块,这一步可能失败,因为虽然内存域中有足够数目的空闲页,但是页不一定是连续的,如果是这样这一步就会返回NULL。在这一步中如果需要还会将大的内存块分解成小的内存块来进行分配,即按照伙伴系统的要求进行分配。
  3. 无论是分配单页还是多个页,如果分配成功,在返回分配的页之前都要调用prep_new_page,如果这一步的处理不成功就会重新进行分配(跳转到函数buffered_rmqueue的开始),否则返回分配的页。

函数__rmqueue的执行过程:

  1. 首先调用__rmqueue_smallest尝试根据指定的zone,分配的阶,迁移类型进行分配,该函数根据指定的信息进行查找,在找到一个可用的空闲内存页块后会将该内存页块从空闲内存页块链表中删除,并且会调用expand使得剩余的内存页块满足伙伴系统的要求。如果在这一步成功就返回,否则执行下一步
  2. 调用__rmqueue_fallback尝试从备用zone分配。该函数用于根据前一类型的备用列表尝试从其它备用列表分配,但是需要注意的是这里会首先尝试最大的分配阶,依次降低分配的阶,直到指定的分配的阶,采用这个策略是为了避免碎片—如果要用其它迁移类型的内存,就拿一块大的过来,而不是在其它迁移类型的小区域中到处引入碎片。同时如果从其它迁移类型的空闲内存页块分配到的是一个较大的阶,则整块内存页块的迁移类型可能会发生改变,从原来的类型改变为申请分配时所请求的类型(即迁移类型发生了改变)。分配成功时的动作和__rmqueue_smallest类似,移出内存页,调用expand。

函数prep_new_page的操作

  1. 对页进行检查,以确保页确实是可用的,否则就返回一个非0值导致分配失败
  2. 设置页的标记以及引用计数等等。
  3. 如果设置而来__GFP_COMP标志,则调用prep_compound_page将页组织成复合页(hugetlb会用到这个)。

复合页的结构如图所示:


复合页具有如下特性:

  • 复合页中第一个页称为首页,其它所拥有页都称为尾页
  • 组成复合页的所有的private域都指向首页
  • 第一个尾页的lru的next域指向释放复合页的函数指针
  • 第一个尾页的lru的prev域用于指向复合页所对应的分配的阶,即多少个页

2.释放页

__free_pages是释放页的核心函数,伙伴系统提供出去的API都是它的包装器。其流程:

  1. 减小页的引用计数,如果计数不为0则直接返回,否则
  2.  如果释放的是单页,则调用free_hot_cold_page,否则
  3.  调用__free_pages_ok

free_hot_cold_page会把页返还给每-CPU缓存而不是直接返回给伙伴系统,因为如果每次都返还给伙伴系统,那么将会出现每次的分配和释放都需要伙伴系统进行分割和合并的情况,这将极大的降低分配的效率。因而这里采用的是一种“惰性合并”,单页会首先返还给每-CPU缓存,当每-CPU缓存的页面数大于一个阈值时(pcp->high),则一次将pcp->patch个页返还给伙伴系统。free_pcppages_bulk在free_hot_cold_page中用于将内存页返还给伙伴系统,它会调用函数__free_one_page。
函数__free_pages_ok最终页会调到__free_one_page来释放页,__free_one_page会将页面释放返还给伙伴系统,同时在必要时进行递归合并。
在__free_one_page进行合并时,需要找到释放的page的伙伴的页帧号,这是通过__find_buddy_index来完成的,其代码非常简单:

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. __find_buddy_index(unsigned long page_idx,unsigned int order)  
  2. {  
  3.        returnpage_idx ^ (1 << order);  
  4. }  

根据异或的规则,这个结果刚好可以得到邻居的页帧号。因为根据linux的管理策略以及伙伴系统的定义,伙伴系统中每个内存页块的第一个页帧号用来标志该页,因此对于order阶的两个伙伴,它们只有1<<order这个比特位是不同的,这样,只需要将该比特与取反即可,而根据异或的定义,一个比特和0异或还是本身,一个比特和1异或刚好可以取反。因此就得到了这个算式。
如果可以合并还需要取得合并后的页帧号,这个更简单,只需要让两个伙伴的页帧号相与即可。
__free_one_page调用page_is_buddy来对伙伴进行判断,以决定是否可以合并。

六、不连续内存页的分配

内核总是尝试使用物理上连续的内存区域,但是在分配内存时,可能无法找到大片的物理上连续的内存区域,这时候就需要使用不连续的内存,内核分配了其虚拟地址空间的一部分(vmalloc区)用于管理不连续内存页的分配。
每个vmalloc分配的子区域都自包含的,在内核的虚拟地址空间中vmalloc子区域之间都通过一个内存页隔离开来,这个间隔用来防止不正确的访问。

1. 用vmalloc分配内存

vmalloc用来分配在虚拟地址空间连续,但是在物理地址空间不一定连续的内存区域。它只需要一个以字节为单位的长度参数。为了节省宝贵的较低端的内存区域,vmalloc会使用高端内存进行分配。
内核使用struct vm_struct来管理vmalloc分配的每个子区域,其定义如下:

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. struct vm_struct {  
  2.     struct vm_struct    *next;  
  3.     void            *addr;  
  4.     unsigned long       size;  
  5.     unsigned long       flags;  
  6.     struct page     **pages;  
  7.     unsigned int        nr_pages;  
  8.     phys_addr_t     phys_addr;  
  9.     const void      *caller;  
  10. };  

每个vmalloc子区域都对应一个该结构的实例。

  • next:指向下一个vmalloc子区域
  • addr:vmalloc子区域在内核虚拟地址空间的起始地址
  • size:vmalloc子区域的长度
  • flags:与该区域相关标志
  • pages:指针,指向映射到虚拟地址空间的物理内存页的struct page实例
  • nr_pages:映射的物理页面数目
  • phys_addr:仅当用ioremap映射了由物理地址描述的内存页时才需要改域,它保存物理地址
  • caller:申请者

2. 创建vmalloc子区域

所有的vmalloc子区域都被连接保存在vmlist中,该链表按照addr排序,顺序是从小到大。当创建一个新的子区域时需要,需要找到一个合适的位置。查找合适的位置采用的是首次适用算法,即从vmalloc区域找到第一个可以满足需求的区域,查找这样的区域是通过函数__get_vm_area_node完成的。其分配过程以下几步:

  1. 调用__get_vm_area_node找到合适的区域
  2. 调用__vmalloc_area_node分配物理内存页
  3. 调用map_vm_area将物理内存页映射到内核的读你地址空间
  4. 将新的子区域插入vmlist链表

在从伙伴系统分配物理内存页时使用了标志:GFP_KERNEL | __GFP_HIGHMEM
还有其它的方式来建立虚拟地址空间的连续映射:

  1. vmalloc_32:与vmallo工作方式相同,但是确保所使用的物理地址总可以用32位指针寻址
  2. vmap:将一组物理页面映射到连续的虚拟地址空间
  3. ioremap:特定于处理器的分配函数,用于将取自物理地址空间而、由系统总线用于I/O操作的一个内存块,映射到内核的虚拟地址空间

3. 释放内存

vfree用于释放vmalloc和vmalloc_32分配的内存空间,vunmap用于释放由vmap和ioremap分配的空间(iounmap会调到vunmap)。最终都会归结到函数__vunmap。
__vunmap的执行过程:

  1. 调用remove_vm_area从vmlist中找到一个子区域,然后将其从子区域删除,再解除物理页面的映射
  2. 如果设置了deallocate_pages,则将物理页面归还给伙伴系统
  3. 释放管理虚拟内存的数据结构struct vm_struct

七、内核映射

高端内存可通过vmalloc机制映射到内核的虚拟地址空间,但是高端内存往内核虚拟地址空间的映射并不依赖于vmalloc,而vmalloc是用于管理不连续内存的,它也并不依赖于高端内存。

1.持久内核映射

如果想要将高端内存长期映射到内核中,则必须使用kmap函数。该函数需要一个page指针用于指向需要映射的页面。如果没有启用高端内存,则该函数直接返回页的地址,因为所有页面都可以直接映射。如果启用了高端内存,则:

  • 如果不是高端内存的页面,则直接返回页面地址,否则
  • 调用kmap_high进行处理

1.使用的数据结构

vmalloc区域后的持久映射区域用于建立持久映射。pkmap_count是一个有LAST_PKMAP个元素的数组,每个元素对应一个持久映射。每个元素的值是被映射页的一个使用计数器:

  1. 0:相关的页么有被使用
  2. 1:该位置关联的页已经映射,但是由于CPU的TLB没有刷新而不能使用
  3. 大于1的其它值:表示该页的引用计数,n表示有n-1处在使用该页

数据结构

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. struct page_address_map {  
  2.     struct page *page;  
  3.     void *virtual;  
  4.     struct list_head list;  
  5. };  

用于建立物理页和其在虚拟地址空间位置之间的关系。

  • page:指向全局数据结构mem_map数组中的page实例的指针
  • virtual:该页在虚拟地址空间中分配的位置

所有的持久映射保存在一个散列表page_address_htable中,并用链表处理冲突,page_slot是散列函数。
函数page_address用于根据page实例获取器对应的虚拟地址。其处理过程:

  1. 如果不是高端内存直接根据page获得虚拟地址(利用__va(paddr)),否则
  2. 在散列表中查找该page对应的struct page_address_map实例,获取其虚拟地址

2.创建映射

函数kmap_high完成映射的实际创建,其工作过程:

  1. 调用page_address获取对应的虚拟地址
  2. 如果没有获取到,则调用map_new_virtual获取虚拟地址
  3. pkmap_count数组中对应于该虚拟地址的元素的引用计数加1

新映射的创建在map_new_virtual中完成,其工作过程:
 

  1. 执行一个无限循环:
    1. 更新last_pkmap_nr为last_pkmap_nr+1
    2. 同时如果last_pkmap_nr为0,调用flush_all_zero_pkmaps,flush CPU高速缓存
    3. 检查pkmap_count数组中索引last_pkmap_nr对应的元素的引用计数是否为0,如果是0就退出循环,否则
    4. 将自己加入到一个等待队列
    5. 调度其它任务
    6. 被唤醒时会首先检查是否有其它任务已经完成了新映射的创建,如果是就直接返回
    7. 回到循环头部重新执行
  2. 获取与该索引对应的虚拟地址
  3. 修改内核页表,将该页映射到获取到的虚拟地址
  4. 更新该索引对应的pkmap_count元素的引用计数为1
  5. 调用set_page_address将新的映射加入到page_address_htable中

flush_all_zero_pkmaps的工作过程:

  1. 调用flush_cache_kmaps执行高速缓存flush动作
  2. 遍历pkmap_count中的元素,如果某个元素的值为1就将其减小为0,并删除相关映射同时设置需要刷新标记
  3. 如果需要刷新,则调用flush_tlb_kernel_range刷新指定的区域对应的tlb。

3.解除映射

kunmap用于解除kmap创建的映射,如果不是高端内存,什么都不做,否则kunmap_high将完成实际的工作。kunmap_high的工作很简单,将对应的pkmap_count中的元素的引用计数的值减1,如果新值为1,则看是否有任务在pkmap_map_wait上等待,如果有就唤醒它。根据该机制的涉及原理,该函数不能将引用计数减小到小于1,否则就是一个BUG。

2.临时内核映射

kmap不能用于无法休眠的上线文,如果要在不可休眠的上下文调用,则需要调用kmap_atomic。它是原子的,特定于架构的。同样的只有是高端内存时才会做实际的映射。
kmap_atomic使用了固定映射机制。在固定映射区域,系统中每个CPU都有一个对应的“窗口”,每个窗口对应于KM_TYPE_NR中不同的类型都有一项。这个映射的核心代码如下(取自powerpc):

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. type = kmap_atomic_idx_push();  
  2. idx = type + KM_TYPE_NR*smp_processor_id();  
  3. vaddr = __fix_to_virt(FIX_KMAP_BEGIN + idx);      
  4.        __set_pte_at(&init_mm, vaddr, kmap_pte-idx, mk_pte(page, prot), 1);  
  5. local_flush_tlb_page(NULL, vaddr);  

固定映射区域为用于kmap_atomic预留内存区的代码如下:

[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. enum fixed_addresses {  
  2.     FIX_HOLE,  
  3.     /* reserve the top 128K for early debugging purposes */  
  4.     FIX_EARLY_DEBUG_TOP = FIX_HOLE,  
  5.     FIX_EARLY_DEBUG_BASE = FIX_EARLY_DEBUG_TOP+((128*1024)/PAGE_SIZE)-1,  
  6. <strong>#ifdef CONFIG_HIGHMEM  
  7.     FIX_KMAP_BEGIN, /* reserved pte's for temporary kernel mappings */  
  8.     FIX_KMAP_END = FIX_KMAP_BEGIN+(KM_TYPE_NR*NR_CPUS)-1,  
  9. #endif</strong>   
  10.     /* FIX_PCIE_MCFG, */  
  11.     __end_of_fixed_addresses  
  12. };  

 

一、内核内存分配

在linux内核中伙伴系统用来管理物理内存,其分配的单位是页,但是向用户程序一样,内核也需要动态分配内存,而伙伴系统分配的粒度又太大。由于内核无法借助标准的C库,因而需要别的手段来实现内核中动态内存的分配管理,linux采用的是slab分配器。slab分配器不仅可以提供动态内存的管理功能,而且可以作为经常分配并释放的内存的缓存。通过slab缓存,内核能够储备一些对象,供后续使用。需要注意的是slab分配器只管理内核的常规地址空间(准确的说是直接被映射到内核地址空间的那部分内存包括ZONE_NORMAL和ZONE_DMA)。
采用了slab分配器后,在释放内存时,slab分配器将释放的内存块保存在一个列表中,而不是返回给伙伴系统。在下一次内核申请同样类型的对象时,会使用该列表中的内存开。slab分配器分配的优点:

  • 可以提供小块内存的分配支持
  • 不必每次申请释放都和伙伴系统打交道,提供了分配释放效率
  • 如果在slab缓存的话,其在CPU高速缓存的概率也会较高。
  • 伙伴系统的操作队系统的数据和指令高速缓存有影响,slab分配器降低了这种副作用
  • 伙伴系统分配的页地址都页的倍数,这对CPU的高速缓存的利用有负面影响,页首地址对齐在页面大小上使得如果每次都将数据存放到从伙伴系统分配的页开始的位置会使得高速缓存的有的行被过度使用,而有的行几乎从不被使用。slab分配器通过着色使得slab对象能够均匀的使用高速缓存,提高高速缓存的利用率

在引入了slab分配器后,内核的内存管理方案如图所示:


slab分配器也不是万能的,它也有缺陷:

  • 对于微型嵌入式系统,它显得比较复杂,这是可以使用经过优化的slob分配器,它使用内存块链表,并使用最先适配算法
  • 对于具有大量内存的大型系统,仅仅建立slab分配器的数据结构就需要大量内存,这时候可以使用经过优化的slub分配器

无论是slab分配器家族的这三个中的那个一,它们提供的接口都是相同的:
kmalloc,__kmalloc和kmalloc_node用于普通内存的分配
kmem_cache_alloc,kmem_cache_alloc_node用于申请特定类型的内存
内核中普通内存的申请使用kmalloc(size,flags),size是申请的大小,flags告诉分配器分配什么样的内存,如何分配等等。
内核中普通内存的释放使用kfree(*ptr);释放ptr所指向的内存区。
可以通过/proc/slabinfo查看活动的缓存列表。

二、slab分配器的原理

slab算法是1994年开发出来的并首先用于sun microsystem solaris 2.4操作系统。这种算法的使用基于以下几个前提:

  1. 所存放数据的类型可以影响存储器取区的分配方式。
  2. 内核函数倾向于反复请求同一类型的存储器区。
  3. 对存储器区的请求可以根据它们发生的频率来分类。
  4. 所引入的对象大小不是几何分布的。
  5. 硬件高速缓存的高性能。在这种情况下,伙伴函数的每次调用都增加了对内存的平均访问时间。

slab分配器把对象分组放进高速缓存。每个高速缓存都是同种类型对象的一种“储备”。一个cache管理一组大小固定的内存块(也称为对象实体),每个内存块都可用作一种数据结构。cache中的内存块来自一到多个slab。一个slab来自物理内存管理器的一到多个物理页,该slab被分成一组固定大小的块,被称为slab对象(object),一个slab属于一个cache,其中的对象就是该cache所管理的固定大小的内存块。所以一个cache可以有一到多个slab。下图给出了slab分配器的各个部分及其相互关系:

在基于slab的内核内存管理器中,基本的概念是保存管理型数据的缓存(即slab cache,slab缓存)和保存被管理对象的各个slab。每个缓存都负责一种对象类型,比如kmalloc-128会负责管理65-128字节的内存的kmalloc分配。系统中的所有缓存类型都保存在一个链表slab_caches中。

1.slab缓存

slab缓存的详细结构 如图所示:


每个缓存结构都包括了两个重要的成员:

  • struct kmem_list3 **nodelists:kmem_list3结构中包含了三个链表头,分别对应于完全用尽的slab链表,部分用尽的slab链,空闲的slab链表,其中部分空闲的在最开始
  • struct array_cache *array[NR_CPUS + MAX_NUMNODES]:array是一个数组,系统中的每一个CPU,每一个内存节点都对应该数组中的一个元素。array_cache结构包含了一些特定于该CPU/节点的管理数据以及一个数组,每个数组元素都指向一个该CPU/节点刚释放的内存对象。该数组有助于提高高速缓存的利用率。
    • 当释放内存对象时,首先将内存对象释放到该数组中对应的元素中
    • 申请内存时,内核假定刚释放的内存对象仍然处于CPU高速缓存中,因而会先从该数组的对应数组元素中查找,看是否可以申请。
    • 当特定于CPU/节点的缓存数组是空时,会用slab缓存中的空闲对象填充它

因此,对象分配的次序为:

  1. 特定于CPU/节点的缓存列表中的对象
  2. 当前已经存在于slab缓存中中的未用对象
  3. 从伙伴系统获得内存,然后创建的对象

2.slab对象

对象在slab中不是连续排列的,其排列如图所示:


slab对象的长度并不代表其确切的长度,因为需要对长度进行调整以满足对齐要求。对齐要求可能是:

  • 创建slab时指定了SLAB_HWCACHE_ALIGN标志,则会按照cache_line_size的返回值对齐,即对齐的硬件缓存行上。如果对象小于硬件缓存行的一半,则将多个对象放入一个缓存行。
  • 如果没有指定对齐标记,则对齐到BYTES_PER_WORD,即对齐到void指针所需字节数目。

为了使得slab满足对齐要求,会在slab对象中添加填充字节以满足对齐要求,使用对齐的地址可以会加速内存访问。每个slab都对应一个管理结构,它可能位于slab内部也可能位于slab外部专门为它申请的内存中,它保存了所有的管理数据,也包括一个链表域用于将slab连接起来,还包括一个指针指向它所属的cache。
大多数情况下,slab内存区的长度是不能被对象长度整除的,因而就有了一些多余的内存,这些内存可以被用来以偏移量的形式给slab“着色”,着色后,缓存的各个slab成员会指定到不同的偏移量,进而可以将数据定位到不同的缓存行。
内核通过对象自身即可找到它对应的slab,过程是:对象的物理地址->物理地址对应的page结构。然后由page找到对应的slab以及cache(包含在page结构中)。

三、slab分配器的实现

1.使用的数据结构

linux使用struct kmem_cache表示slab缓存,使用struct kmem_list3管理缓存所对应的slab链表的链表头,使用struct array_cache管理特定于CPU的slab对象的缓存(注意不是slab缓存是slab对象的缓存)。

2.内核采用的其它保护机制

为了检测错误,内核采用了一些机制来对内存进行保护,主要的方法有:
危险区:在每个对象的开始和结束处增加一个额外的内存区,其中会填充一些特殊的字段。如果这个区域被修改了,可能就是某些代码访问了不该访问的内存区域
对象毒化:在建立和释放slab时,将对象用预定义的模式填充。如果在对象分配时发现该模式已经改变,就可能是发生了内存越界。

3.初始化

slab分配器的初始化涉及到一个鸡与蛋的问题。为了初始化slab数据结构,内核需要很多远小于一页的内存区,很显然由kmalloc分配这种内存最合适,但是kmalloc只有在slab分配器初始化完才能使用。内核借助一些技巧来解决该问题。
kmem_cache_init函数被内核用来初始化slab分配器。它在伙伴系统启用后调用。在SMP系统中,启动CPU正在运行,其它CPU还未初始化,它要在smp_init之前调用。slab采用多步逐步初始化slab分配器,其工作过程:
创建第一个名为kmem_cache的slab缓存,此时该缓存的管理数据结构使用的是静态分配的内存。在slab分配器初始化完成后,会将这里使用的静态数据结构替换为动态分配的内存。
初始化其它的slab缓存,由于已经初始化了第一个slab缓存,因此这一步是可行。
将初始化过程由于“鸡与蛋”的问题而使用的静态数据结构替换为动态分配的。

4.API

1.创建缓存

slab分配器使用kmem_cache_create创建一个新的slab缓存。该函数的基本工作过程为:

  1. 参数检查
  2. 计算对齐
  3. 分配缓存的管理结构所需的内存
  4. 计算slab所需的物理内存大小以及每个slab中slab对象的个数
  5. 计算slab管理部分应该放在哪里,并存储在缓存的flags域中
  6. 计算slab的颜色,颜色数目存在color中,颜色偏移量存在color_off中
  7. 建立每CPU的缓存
  8. 将新创建的缓存添加到全局slab缓存链表slab_caches中

2.分配对象

kmem_cache_alloc用于从指定的slab缓存分配对象。与kmalloc相比,它多了一个缓存指针的参数,用于指向所要从其中分配内存的缓存。
其工作过程如图:

在NUMA系统中,如果在本节点分配失败,还会尝试其它节点。

cache_grow用于缓存的增长,它会从伙伴系统获取内存。其流程如图所示:


3.释放对象

kmem_cache_free用于将对象归还给指定的slab缓存,类似于kmem_cache_free,它比kfree多了一个指向所归还到的slab缓存指针参数。其流程如图:

free_block会将缓存中前batchcount个对象移动到slab链表中,并且将缓存中剩余的对象向数组的头部移动。根据slab对象所属的slab的状态(inuse域),slab对象可能被归给给部分空闲链表(如果该slab中有些slab对象正在被使用)或者空闲链表(该slab中没有其它对象正在被使用),同时如果加入到空闲slab链表中的slab对象数目超过了free_limit的限制(在kmem_list3结构中),则会调用slab_destroy销毁slab。

4.缓存收缩

可以使用kmem_cache_shrink来回收一个slab缓存所管理的内存。它会释放尽可能多的slab。它会尝试回收用于每CPU缓存的内存空间(调用free_block),以及用于空闲链表的slab内存空间,slab的释放最终都由slab_destroy完成。

5.通用缓存

如果不涉及到特定类型的内存,而只是普通类型的内存,可以使用kmalloc和kfree来申请和释放缓存。内核会找到并使用适用于所申请的大小的通用slab缓存来进行分配和释放。


 

0 0