文章目录
内存管理是内核最复杂同时也是最重要的一部分。其特点在于非常需要处理器和内核之间的协作。
1 概述
内存管理的实现涵盖了许多领域:
- 内存中的物理内存页的管理;
- 分配大块内存的伙伴系统;
- 分配较小块内存的slab、slub和slob分配器;
- 分配虚拟连续内存块的vmalloc机制
- 进程的地址空间
经典IA-32 Linux内核将处理器的虚拟地址空间划分为了两个部分。底部比较大的部分用于用户进程,顶部则专用于内核。虽然进程间的上下文切换期间回改变下半部分,但虚拟地址空间的内核部分总是保持不变。
在IA-32系统上,地址空间在用户进程和内核之间划分的典型比例为3:1。给出4GB虚拟地址空间,3GB将用于用户空间,而1GB将用于内核。通过修改相关的配置选项可以改变该比例。但只有对非常特殊的配置和应用程序,这种修改才会带来好处。
可用的物理内存将映射到内核的地址空间中。访问内存时,如果所用的虚拟地址与内核区域的起始地址之间的偏移量不超出可用物理内存长度,那么该虚拟地址会自动关联到物理页帧。这是可行的,因为在采用该方案时,在内核区域中的内存分配总是落入到物理内存中。不过,还有一个问题。虚拟地址空间的内核部分必然小于CPU理论地址空间的最大长度。如果物理内存比可以映射到内核地址空间中的数量要多,那么内核必须借助于高端内存(highmem)方法来管理“多余的”内存。在IA-32系统上,可以直接管理的内存数量不超过896MB。超过该值的内存只能通过高端内存寻址。
在64为计算机上,由于可用的地址空间非常巨大,因此不需要高端内存模式。即使物理寻址受限于地址字的比特数(例如48或52),也是如此。32位系统超出4GB地址空间的限制也才刚刚几年,有人可能认为64位系统地址空间的限制被突破似乎也只是时间问题,但理论上16EB也能撑些时间了。但技术的发展是无法预测的。
只有内核自身使用高端内存页时,才会有问题。在内核使用高端内存页之前,必须使用下文讨论的kmap和kunmap函数将其映射到内核虚拟地址空间中,对普通内存页是不必要的。但对用户空间进程来说,高端内存页还是普通内存页完全没有任何差别。因为用户空间进程总是通过页表访问内存,不会直接访问。
有两种类型计算机,分别以不同的方法管理物理内存。
- UMA计算机(一致性内存访问,uniform memory access)将可用内存以连续方式组织起来(可能有小的缺口)。SMP系统中的每个处理器访问各个内存区都是同样快。
- NUMA计算机(非一致内存访问,non-uniform memory access)总是多处理器计算机。系统的各个CPU都有本地内存,可支持特别快速的访问。各个处理器之间通过总线连接起来,以支持对其他CPU的本地内存访问,当然比访问本地内存慢些。
两种类型计算机的混合也是可能的,其中使用不连续的内存。即在UMA系统中,内存不是连续的,而有比较大的洞。在这里应用NUMA体系结构的原理通常有所帮助,可以使内核的访问更简单。实际上内核会有3中配置选项:FLATMEM、DISCONTIGMEM、SPARSEMEM。SPARSEMEM和DISCONTIGMEM实际上作用相同,但从开发者角度看来,对应代码的质量有所不同。SPARSEMEM被认为更多是试验性的,不那么稳定,但有一些性能优化。DISCONTIGMEM相关代码更稳定一些,但不具备热插拔之类的新特性。
2. NUMA模型中的内存组织
Linux支持的各种不同体系结构在内存管理方面差别很大。由于内核的明智设计,以及某些情况下插入的兼容层,这些差别被很好的隐藏起来了(一般性的代码通常无需注意这些)。
内核对一致性和非一致性内存访问系统使用相同的数据结构,因此针对各种不同形式的内存布局,各个算法几乎没有什么差别。
在UMA系统上,只使用一个NUMA节点来管理整个系统内存。而内存管理的其他部分则相信它们是在处理一个伪NUMA系统。
内存可划分为结点,每个结点关联到系统中的一个处理器,在内核中表示为pg_data_t的实例。
各个结点内又划分为内存域,是内存的进一步细分。例如,对可用于(ISA设备的)DMA操作的内存区是有限制的。只有前16MB适用,还有一个高端内存区域无法直接映射。在二者之间是通用的“普通”内存区。因此一个结点最多由3个内存域组成。
内核引入了下列常量来区分它们。
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
};
- ZONE_DMA标记适合DMA的内存区域。该区域的长度依赖于处理器类型。
- ZONE_DMA32标记了使用32位地址字寻址、适合DMA的内存域。只在64位系统上有差别,32位上此域为空,长度0MB。
- ZONE_NORMAL标记了可直接映射到内存段的普通内存域。这是在所有体系结构上都保证会有存在的唯一内存域,但无法保证该地址范围对应了实际的物理内存。
- ZONE_HIGHMEM标记了超出内核段的物理内存。
各个内存域都关联了一个数组,用来组织属于该内存域的物理内存页(内核中称之为页帧)。对每个页帧,都分配了一个struct page实例以及所需的管理数据。
各个内存结点保存在一个单链表中,供内核遍历。出于性能考虑,在为进程分配内存时,内核总是试图在当前运行的CPU相关联的NUMA结点上进行。但这并不总是可行的,例如,该结点的内存可能已经用尽。对此类情况,每个结点都提供了一个备用列表(借助于struct zonelist)。该列表包含了其他结点(和相关的内存域),可用于代替当前结点分配内存。列表项的位置越靠后,就越不适合分配。
在UMA系统上是何种情况呢?UMA只有一个结点,上图灰色背景的内存结点减少为1个,其他的都不变。
*2.1 数据结构(拓展阅读)
2.1.1 结点管理
- pd_data_t是用于表示结点的基本元素,定义如下:
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES];
struct zonelist node_zonelists[MAX_ZONELISTS];
int nr_zones; /* number of populated zones in this node */
struct page *node_mem_map;
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;
struct task_struct *kswapd;
} pg_data_t;
- node_zones是一个数组,包含了结点中各内存域的数据结构。
- node_zonelists指定了备用节点以及其内存域的列表,以便在当前结点没有可用空间时,在备用节点分配内存。
- nr_zones记录不同内存域的数量
- node_mem_map是指向page实例数组的指针,用于描述结点的所有物理内存页。它包含了结点中所有内存域的页。
- node_start_pfn是该NUMA结点第一个页帧的逻辑编号。系统中所有结点的页帧是依次编号的,每个页帧的号码都是全局唯一的(不只是结点内唯一)。
- node_start_pfn在UMA系统中总是0,因为其中只有一个结点,因此其低一个页帧编号总是0。
- node_present_pages指定了结点中页帧的数目,而node_spanned_pages则给出了该结点以页帧为单位计算的长度。两者的值不一定相同,因为结点中可能有一些空洞,并不对应真正的页帧。
- node_id是全局结点ID。系统中的NUMA结点都从0开始编号。
- kswapd_wait是交换守护进程(swap daemon)的等待队列,在将页帧换出结点时会用到。
- kswapd指向负责该结点的交换守护进程的task_struct。
- 结点状态管理
如果系统中结点多于一个,内核会维护一个位图,用以提供各个结点的状态信息。状态是用位掩码指定的,可使用下列值:
enum node_states {
N_POSSIBLE,
N_ONLINE,
N_NORMAL_MEMORY,
#ifdef CONFIG_HIGHMEM
N_HIGH_MEMORY,
#else
N_HIGH_MEMORY = N_NORMAL_MEMORY,
#endif
N_CPU,
NR_NODE_STATES
};
状态N_POSSIBLE、N_ONLINE和N_CPU用于CPU和内存的热插拔。对内存管理有必要的标志是N_HIGH_MEMORY,仅当结点没有高端内存才设置N_NORMAL_MEMORY。
2.1.2 内存域
内核使用zone结构来描述内存域。其定义如下:
struct zone {
unsigned long pages_min, pages_low, pages_high;
unsigned long lowmem_reserve[MAX_NR_ZONES];
struct per_cpu_pageset pageset[NR_CPUS];
spinlock_t lock;
struct free_area free_area[MAX_ORDER];
ZONE_PADDING(_pad1_)
spinlock_t lru_lock;
struct list_head active_list;
struct list_head inactive_list;
unsigned long nr_scan_active;
unsigned long nr_scan_inactive;
unsigned long pages_scanned;
unsigned long flags;
atimic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
int prev_priority;
ZONE_PADDING(_pad2_);
wait_queue_head_t *wait_table;
unsigned long wait_table_hash_nr_entries;
unsigned long wait_table_bits;
struct pglist_data *zone_pgdat;
unsigned long zone_start_pfn;
unsigned long spanned_pages; /* 总长度,包含空洞 */
unsigned long present_pages; /* 内存数量(除去空洞) */
char *name;
} ___cacheline_maxaligned_in_smp;
该结构比较特殊的方面是它由ZONE_PADDING分隔为几个部分。这是因为对zone结构的访问非常频繁。在多处理器系统上,通常会有不同的CPU试图同时访问结构成员。因此使用锁防止他们彼此干扰,避免错误和不一致。由于内核对该结构的访问非常频繁,因此会经常性的获取该结构的两个自旋锁zone->lock和zone->lru_lock。
如果数据保存在CPU高速缓存中,那么会处理得更快速。高速缓存分为行,每一行负责不同的内存区。内存使用ZONE_PADDING宏生成"填充"字段添加到结构中,已确保每个自旋锁都处于自身的缓存行中。还是用了编译器关键字__cacheline_maxaligned_in_smp,用以实现最优的高速缓存对齐方式。
该结构各个成员的语义是什么?由于内存管理是内核中一个复杂而牵涉颇广的部分,因此在这里将该结构所有成员的确切语义都讲解清楚是不大可能的,本章和后续章节相当一部分都会专注于讲述相关的结构数据和机制。
- pages_min、pages_high、pages_low是页换出时使用的"水印"。如果内存不足,内核可以将页写到硬盘。这3个成员会影响交换守护进程的行为。
- 如果空闲页多于pages_high,则内存域的状态是理想的。
- 如果空闲页的数目低于pages_low,则内核开始将页换出到硬盘。
- 如果空闲页的数目低于pages_min,那么页回收工作的压力就比较大,因为内存域中急需空闲页。
- lowmem_reserve数组分别为各种内存域指定了若干页,用于一些无论何时都不能失败的关键性内存分配。各个内存域的份额根据重要性确定。
- pageset是一个数组,用于实现每个CPU的热/冷页帧列表。内核使用这些列表来保存可用于满足实现的"新鲜"页。但冷热页帧对应的高速缓存状态不同:有些页帧也很可能仍然在高速缓存中,因此可以快速访问,故称之为热的;未缓存的页帧与此相对,故称之为冷的。
- free_area是同名数据结构的数组,用于实现伙伴系统。每个数据元素都表示某种固定长度的一些连续内存区。对于包含在每个区域中的空闲内存页的管理,free_area是一个起点。
- 如果页访问频繁,则内核认为它是活动的;而不活动页则显然相反。在需要换出页时,这种区别是很重要的。如果可能的话,频繁使用的页应该保持不动,而多余的不活动页则可以换出而没有什么损害。
- active_list是活动也的集合,而inactive_list则是不活动页的集合。
- nr_scan_active和nr_scan_inactive指定了上一次换出一页依赖,有多少页未能成功扫描。
- flags描述内存域的当前状态。允许使用以下标志:
typedef enum {
ZONE_ALL_UNRECLAIMABLE, /* 所有的页都已经"钉"住 */
ZONE_RECLAIM_LOCKED, /* 防止并发回收 */
ZONE_OOM_LOCKED, /* 内存域即可被回收 */
} zone_flags_t;
也有可能这些标志均未设置。这是内存域的正常状态。ZONE_ALL_UNRECLAIMABLE状态出现在内核试图重用该内存域的一些页时(页面回收),但因为所有的页都被钉住而无法回收。例如,用户空间应用程序可以是使用mlock系统调用通知内核页不能从物理内存移出,比如换出到磁盘上。这样的页称之为钉住的。如果一个内存域中的所有页都被钉住,那么该内存域时无法被回收的,即设置该标志。为不浪费时间,交换守护进程在寻找可供回收页时,只会简要地扫描一下此类内存域。
在SMP系统上,多个CPU可能试图并发地回收一个内存域。ZONE_RECLAIM_LOCKED标志可防止这种情况:如果一个CPU在回收某个内存域,则设置该标志。这防止了其他CPU的尝试。ZONE_OOM_LOCKED专用于某种不走运的情况:如果进程消耗了大量的内存,导致必要的操作都无法完成,那么内核会试图杀死消耗内存最多的进程,以获得更多的空闲页。该标志可以防止多个CPU同时进行这种操作。
内核提供了3个辅助函数用于测试和设置内存域的标志:
void zone_set_flag(struct zone *zone, zone_flags_t flag)
int zone_test_and_set_flag(struct zone *zone, zone_flags_t flag)
void zone_clear_flag(strcut zone *zone, zone_flags_t flag)
zone_set_flag和zone_clear_flag分别用于设置和清除某一标志。zone_test_and_set_flag首先测试是否设置了给定标志,如果没有设置,则设置该标志。标志原状态返回给调用者。
- vm_stat维护了大量有关该内存域的统计信息。辅助函数zone_page_state用来读取vm_stat中的信息:
static inline unsigned long zone_page_state(struct zone *zone, enum zone_stat_item item)
例如,可以将item参数设置为NR_ACTIVE或IN_INACTIVE,来查询存储在上文讨论的active_list和inactive_list中的活动和不活动页的数目。而设置未NR_FREE_PAGES则可以获得内存域中空闲页的数目。
- prev_priority存储了上一次扫描操作扫描该内存域的优先级,扫描操作时由try_to_free_pages进行的,直至释放足够的页帧。
- wait_table、wait_table_bits和wait_table_hash_nr_entries实现了一个等待队列,可供等待某一页变为可用的进程使用。进程排成一个队列,等待某些条件。在条件变为真时,内核会通知进程恢复工作。
- 内存域和父结点之间的关联由zone_pgdat建立,zone_pgdat指向对应的pglist_data实例。
- zone_start_pfn是内存域第一个页帧的索引。
- 剩余的3个字段很少使用,因此置于数据结构末尾。
name是一个字符串,保存该内存域的惯用名称。目前有3个选项可用:Normal、DMA和HighMem。
spanned_pages指定内存域中页的总数,但并非所有都是可用的。内存域中可能有一些小的空洞。另一个计数器(present_pages)则给出了实际上可用的页数目。该计数器的值通常与spanned_pages相同。
3. 实践:aarch64内存布局
理论上,64bit内存地址可用空间为0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF(16位十六进制数),这是个相当庞大的空间,Linux实际上只用了其中一小部分(256T)。
Linux64位操作系统仅使用低47位,高17位做扩展(只能是全0或全1)。所以,实际用到的地址为空间为0x0000000000000000 ~ 0x00007FFFFFFFFFFF(user space)和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF(kernel space),其余的都是unused space
user space 也就是用户区由以下几部分组成:代码段,数据段,BSS段,heap,stack。
下图为kernel 5.15 aarch64为例的内存参考布局:
3.1 VMEMMAP - 页表映射区
vmemmap区域是一块起始地址是VMEMMAP_START,范围是2TB
的虚拟地址区域,位于kernel space。以section为单位来存放strcut page结构的虚拟地址空间,然后线性映射到物理内存。
3.2 PCI_IO - PCI设备地址空间
PCI外设专用,大小为16MB
。
3.3 FIXMAP - 固定映射区
FIXADDR_START到FIXADDR_TOP的空间称为固定映射区,大小为4.04MB
,主要用于映射dtb、串口和一些启动阶段特殊映射。fixmap初始化操作在early_fixmap_init函数中完成。主要是建立PGD/PUD/PMD页表。
在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。
3.4 VMALLOC - 动态映射区
用于kernel vmalloc分配内存,大小为123TB
。
buddy system是基于页框分配器,kmalloc是基于slab分配器,而且这些分配的地址都是物理内存连续的。但是随着碎片化的积累,连续物理内存的分配就会变得困难,对于那些非DMA访问,不一定非要连续物理内存的话完全可以像malloc那样,将不连续的物理内存页框映射到连续的虚拟地址空间中,这就是vmap的来源)(提供把离散的page映射到连续的虚拟地址空间),vmalloc的分配就是基于这个机制来实现的。
除此外,kernel image部分也包含在VMALLOC范围内,根据kernel image大小的不同,从VMALLOC起始地址开始,使用kernel_memsize大小。
3.5 MODULES - 驱动模块映射区
用于驱动模块的映射,大小为128MB
。
当用户空间执行insmod xxx.ko
时,会触发在module_alloc_base
和module_alloc_end
之间寻找一段可用地址进行映射。调用栈为:
load_module -> layout_and_allocate -> move_module -> module_alloc -> __vmalloc_node_range(size, MODULE_ALIGN, module_alloc_base, module_alloc_end…)
值得一提的是,处于安全上的考虑,如果开启了CONFIG_RANDOMIZE_BASE
,kernel会在MODULES_VADDR
和MODULES_END
之间产生随机偏移,使得MODULES
地址不固定,避免外部攻击,提升安全性。
通常kernel image的text代码段(_text, _etext)包含在MODULES
地址范围中,再加上后续加载ko时映射的地址,所有kernel text代码段都在MODULES
范围中了。
3.6 Linear Mapping - 直接(线性)映射区
(PAGE_OFFSET, PAGE_END)
为直接映射区,大小为128TB
,所映射的虚拟地址在物理上是连续的,映射关系为1:1,用于映射ZONE_DMA和ZONE_NORMAL。
该区域的线性地址和物理地址存在线性转换关系「线性地址 = PAGE_OFFSET + 物理地址」也可以用 virt_to_phys()函数将内核虚拟空间中的线性地址转化为物理地址。