内存管理(上)

一、概述

内存管理涵盖领域:

  • 内存中的物理内存页管理;
  • 分配大块内存的伙伴系统;
  • 分配较小块内存的slab、slub和slob分配器;
  • 分配连续内存块的vmalloc机制;
  • 进程的地址空间。

Linux内核一般将处理器的虚拟地址分为两个部分,以IA-32为例,地址空间在用户进程和内核之间的划分比例为3:1。4GB的虚拟地址空间,3GB用于用户空间,1GB用于内核。

IA-32系统中,假设物理内存4GB,则所有物理内存无法直接映射到内核态(内核的地址空间小于1GB),对于用户空间的内存,无法进行直接映射。为了便于管理,定义内核空间896MB内存用于DMA和直接映射到物理内存,剩余的部分被称为高端内存,内核借助内核空间剩余的128MB空间实现对所有物理内存(896MB-4GB)的管理。

如果采用PAE(page address extension)技术,在IA-32中可以管理64GB内存,但每次只能寻址一个4GB的内存段。(目前内存超过4GB的IA-32系统很少,主要使AMD64体系结构替代,目前64位无需高端内存模式)

有两种类型计算机,对应着两种方法管理内存:

  • UMA(uniform memory access,一致内存访问)计算机将可用内存以连续方式组织起来(可能有小的缺口)。SMP系统中的每个处理器访问各个内存区都是同样快。
  • NUMA(non-uniform memory access,非一致内存访问)计算机总是多处理器计算机。系统的各个CPU都有本地内存,可支持特别快速的访问。各个处理器之间通过总线连接起来,以支持对其他CPU的本地内存的访问,速度比访问本地内存慢。

1 UMA和NUMA系统

1描述了UMA核NUMA计算机的区别。两种类型计算机的混合也是存在的,比如在UMA系统中,内存不是连续的,有比较大的洞,这里应用NUMA体系结构的原理通常有所帮助,可以使内核的内存访问更简单。

内核会区分3 种配置选项: FLATMEM(平坦内存模型)、DISCONTIGMEM(不连续内存模型)和SPARSEMEM(稀疏内存模型)。SPARSEMEM和DISCONTIGMEM实际上作用相同。一般认为SPARSEMEM更多是试验性的,不那么稳定,但有一些性能优化。DISCONTIGMEM相关代码更稳定一些,但不具备内存热插拔之类的新特性。多数配置中都使用该内存组织类型为FLATMEM(内核的默认值)。真正的NUMA需要设置CONFIG_NUMA,对于UMA系统,则不用不考虑(不意味着NUMA相关的数据结构可以完全忽略。由于UMA系统可以在地址空间包含比较大的洞时选择配置选项CONFIG_DISCONTIGMEM,这种情况下在不采用NUMA技术的系统上也会有多个内存结点)。图2综述了内存布局有关的各种可能配置选项。

2 UMA和NUMA计算机上可能的内存配置(平摊、稀疏和不连续模型)

*对于分配阶(alloction order),表示内存区中页的数目取以2为底的对数。

二、(N)UMA模型中的内存组织

 内核对一致和非一致内存访问系统使用相同的数据结构,在UMA系统上,只使用一个NUMA结点来管理整个系统内存,内存管理的其他部分认为是在处理一个伪NUMA系统。

1、概述

3是对于NUMA系统内存划分的示例。

3 NUMA系统中的内存划分

将内存划分为结点。每个结点关联到系统中的一个处理器,在内核中表示为pg_data_t的实例。各个节点又划分为内存域(从低到高为DMA内存域,普通内存域和超出内核段物理内存的高端内存域)。

内核使用常量枚举系统中的所有内存域:

 1 enum zone_type {
 2 #ifdef CONFIG_ZONE_DMA
 3     ZONE_DMA,        //标记适合DMA的内存域,长度依赖于处理器类型
 4 #endif
 5 #ifdef CONFIG_ZONE_DMA32
 6     ZONE_DMA32,        //标记了使用32位地址字可寻址、适合DMA的内存域。
 7 #endif
 8     ZONE_NORMAL,        //标记了可直接映射到内核段的普通内存域
 9 #ifdef CONFIG_HIGHMEM
10     ZONE_HIGHMEM,
11 #endif
12     ZONE_MOVABLE,
13     MAX_NR_ZONES
14 };

2、数据结构 

pg_data_t是用于表示结点的基本元素,定义如下:

 1 typedef struct pglist_data {
 2     struct zone node_zones[MAX_NR_ZONES];     //包含了结点中各内存域的数据结构
 3     struct zonelist node_zonelists[MAX_ZONELISTS];    //指定了备用结点及其内存域的列表
 4     int nr_zones;    //结点中不同内存域的数目
 5     struct page *node_mem_map;    //指向page实例数组的指针,用于描述结点的所有物理内存页
 6     struct bootmem_data *bdata;     //指向自举内存分配器数据结构的实例
 7     unsigned long node_start_pfn;    //该NUMA结点第一个页帧的逻辑编号
 8     unsigned long node_present_pages; /* 物理内存页的总数 */
 9     unsigned long node_spanned_pages; /* 物理内存页的总长度,包含洞在内 */
10     int node_id;    //全局结点ID,系统中的NUMA结点都从0开始编号
11     struct pglist_data *pgdat_next;    //连接到下一个内存结点
12     wait_queue_head_t kswapd_wait;    //交换守护进程(swap daemon)的等待队列
13     struct task_struct *kswapd;    //指向负责该结点的交换守护进程的task_struct
14     int kswapd_max_order;        //用来定义需要释放的区域的长度
15 } pg_data_t;
struct pglist_data

 

如果系统中结点多于一个,内核会维护一个位图,用以提供各个结点的状态信息。状态是用位掩码指定的,可使用下列值:

 1 enum node_states {
 2     N_POSSIBLE, /* 结点在某个时候可能变为联机,用于内存的热插拔 */
 3     N_ONLINE, /* 结点是联机的 ,用于内存的热插拔*/
 4     N_NORMAL_MEMORY, /* 结点有普通内存域 */
 5 #ifdef CONFIG_HIGHMEM
 6     N_HIGH_MEMORY, /* 结点有普通或高端内存域 */
 7 #else
 8     N_HIGH_MEMORY = N_NORMAL_MEMORY,
 9 #endif
10     N_CPU, /* 结点有一个或多个CPU ,用于CPU的热插拔*/
11     NR_NODE_STATES
12 };
enum node_states

如果内核编译为只支持单个结点(即使用平坦内存模型),则没有结点位图。

内核使用zone结构来描述内存域。

 1 struct zone {
 2 /*该结构是由ZONE_PADDING分隔为几个部分,内核使用ZONE_PADDING宏生成“填充”字段添加到结构中,以确保每个自旋锁都处于自身的缓存行中。还使用了编译器关键字__cacheline_maxaligned_in_smp,用以实现最优的高速缓存对齐方式。该结构的最后两个部分也通过填充字段彼此分隔开来。两者都不包含锁,主要目的是将数据保持在一个缓存行中,便于快速访问,从而无需从内存加载数据*/
 3 
 4 /*通常由页分配器访问的字段 */
 5     unsigned long pages_min, pages_low, pages_high;     //页换出时使用的“水印”(内存不足内核可以将页写入硬盘)
 6     unsigned long lowmem_reserve[MAX_NR_ZONES];    //分别为各种内存域指定了若干页,用于一些无论如何都不能失败的关键性内存分配
 7     struct per_cpu_pageset pageset[NR_CPUS];    //用于实现每个CPU的热/冷页帧列表
 8 /*
 9 * 不同长度的空闲区域
10 */
11     spinlock_t lock;
12     struct free_area free_area[MAX_ORDER];    //用于实现伙伴系统
13 ZONE_PADDING(_pad1_)
14 /*第二部分涉及的结构成员,用来根据活动情况对内存域中使用的页进行编目。*/
15 /* 通常由页面收回扫描程序访问的字段 */
16     spinlock_t lru_lock;
17     struct list_head active_list;    //活动页的集合
18     struct list_head inactive_list;    //不活动页的集合
19     unsigned long nr_scan_active;    //在回收内存时需要扫描的活动页的数目
20     unsigned long nr_scan_inactive;    //在回收内存时需要扫描的不活动页的数目
21     unsigned long pages_scanned; /* 上一次回收以来扫描过的页 */
22 unsigned long flags; /* 内存域标志*/
23 /* 内存域统计量 */
24     atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];    //维护了大量有关该内存域的统计信息
25     int prev_priority;    //存储了上一次扫描操作扫描该内存域的优先级
26 ZONE_PADDING(_pad2_)
27 /* 很少使用或大多数情况下只读的字段 */
28     wait_queue_head_t * wait_table;
29     unsigned long wait_table_hash_nr_entries;
30     unsigned long wait_table_bits;    // 以上三个变量实现了一个等待队列,可供等待某一页变为可用的进程使用
31 /* 支持不连续内存模型的字段。 */
32     struct pglist_data *zone_pgdat;    //建立内存域和父结点之间的关联
33     unsigned long zone_start_pfn;    //内存域第一个页帧的索引
34     unsigned long spanned_pages; /* 总长度,包含空洞 */
35     unsigned long present_pages; /* 内存数量(除去空洞) */
36 /*
37 * 很少使用的字段:
38 */
39     char *name;
40 } ____cacheline_maxaligned_in_smp;
struct zone

内存域水印值:需要为关键性分配保留的内存空间的最小值;该值该值随可用内存的大小而非线性增长,保存在全局变量min_free_kbytes中。

数据结构中水印值的填充由init_per_zone_pages_min处理,该函数由内核在启动期间调用,无需显式调用。图4为主内存大小与可用于关键性分配的内存空间最小值之间的关系示意图。

4 主内存大小与可用于关键性分配的内存空间最小值之间的关系

冷热页:热页表示页已加载到CPU告诉缓存,冷页则没有。struct zone的pageset成员用于实现冷热分配器(hot-n-cold allocator)。在多处理器系统上每个CPU都有一个或多个高速缓存,各个CPU的管理必须是独立的。

尽管内存域可能属于一个特定的NUMA结点,因而关联到某个特定的CPU,但其他CPU的高速缓存仍然可以包含该内存域中的页。所以,每个处理器都可以访问系统中所有的页,尽管速度不同(特定于内存域的数据结构不仅要考虑到所属NUMA结点相关的CPU,还必须照顾到系统中其他的CPU)。

相关数据结构及定义如下:

1 struct zone {
2 ...
3     struct per_cpu_pageset pageset[NR_CPUS];    // NR_CPUS是是内核支持的CPU的最大数目
4 ...
5 };
struct zone

 

1 struct per_cpu_pages {
2     int count; /* 列表中页数 */
3     int high; /* 页数上限水印,在需要的情况下清空列表 */
4     int batch; /* 添加/删除多页块的时候,块的大小 */
5     struct list_head list; /* 页的链表 */
6 };
struct per_cpu_pages

页帧:页帧代表系统内存的最小单位,对内存中的每个页都会创建struct page的一个实例(内核尽力保持这个结构尽可能小)。

页的广泛使用,增加了保持结构长度的难度:内存管理的许多部分都使用页,用于各种不同的用途。对此,内核的一个部分可能完全依赖于struct page提供的特定信息,而该信息对内核的另一部分可能完全无用,该部分依赖于struct page提供的其他信息,为了使struct page结构尽可能小,C语言的联合(union)对某些字段进行了双重解释。

page的定义如下:

 1 struct page {
 2     unsigned long flags; /* 原子标志,有些情况下会异步更新 */
 3     atomic_t _count; /* 使用计数,见下文。 */
 4     union {
 5         atomic_t _mapcount; /* 内存管理子系统中映射的页表项计数,
 6 * 用于表示页是否已经映射,还用于限制逆向映射搜索。
 7 */
 8         unsigned int inuse; /* 用于SLUB分配器:对象的数目 */
 9 };
10     union {
11         struct {
12             unsigned long private; /* 由映射私有,不透明数据:
13 *如果设置了PagePrivate,通常用于buffer_heads;
14 *如果设置了PageSwapCache,则用于swp_entry_t;
15 * 如果设置了PG_buddy,则用于表示伙伴系统中的阶。
16 */
17             struct address_space *mapping; /* 如果最低位为0,则指向inode
18 * address_space,或为NULL。
19 * 如果页映射为匿名内存,最低位置位,
20 * 而且该指针指向anon_vma对象:
21 * 参见下文的PAGE_MAPPING_ANON。
22 */
23         };
24 ...
25         struct kmem_cache *slab; /* 用于SLUB分配器:指向slab的指针 */
26         struct page *first_page; /* 用于复合页的尾页,指向首页 */
27 };
28 union {
29     pgoff_t index; /* 在映射内的偏移量 */
30     void *freelist; /* SLUB: freelist req. slab lock */
31 };
32     struct list_head lru; /* 换出页列表,例如由zone->lru_lock保护的active_list!
33 */
34 #if defined(WANT_PAGE_VIRTUAL)
35     void *virtual; /* 内核虚拟地址(如果没有映射则为NULL,即高端内存) */
36 #endif /* WANT_PAGE_VIRTUAL */
37 };
38                                                                                         
struct page

体系结构无关的页标志页的不同属性通过一系列页标志描述,存储为struct page的flags成员中的各个比特位。这些标志独立于使用的体系结构,因而无法提供特定于CPU或计算机的信息(该信息保存在页表中)。各个标志是由page-flags.h中的宏定义的,此外还生成了一些宏,用于标志的设置、删除、查询。这样做时,内核遵守了一种通用的命名方案(宏的实现是原子操作)。

三、页表

层次化的页表用于支持对大地址空间的快速、高效的管理。

页表用于建立用户进程的虚拟地址空间和系统物理内存(内存、页帧)之间的关联。页表用于向每个进程提供一致的虚拟地址空间。应用程序看到的地址空间是一个连续的内存区。该表也将虚拟内存页映射到物理内存,因而支持共享内存的实现(几个进程同时共享的内存),还可以在不额外增加物理内存的情况下,将页换出到块设备来增加有效的可用内存空间。

页表管理分为两个部分,第一部分依赖于体系结构(不同的CPU的实现有一些较大差别),第二部分是体系结构无关的。

1、数据结构

C语言中,通常用void *定义可能指向内存中任何字节位置的指针。在Linux支持的所有体系结构中,sizeof(void *) == sizeof(unsigned long),所以它们之间进行强制转换不会损失信息。内存管理多使用unsigned long类型变量,它更易于处理和操作(技术上两者都有效)。

(1)内存地址的分解

根据四级页表结构的需要,虚拟内存地址分为5部分(4个表项用于选择页,1个索引表示页内位置)。各个体系结构不仅地址字长度不同,而且地址字拆分的方式也不同。内核定义了宏将地址分解为各个分量。

5 分解虚拟内存地址

5为用比特位移定义地址字各分量位置的方法。每个指针末端的几个比特位,用于指定所选页帧内部的位置。比特位的具体数目由PAGE_SHIFT指定。PMD_SHIFT指定了页内偏移量和最后一级页表项所需比特位的总数。PUD_SHIFT 由PMD_SHIFT加上中间层页表索引所需的比特位长度组成。PGDIR_SHIFT由PUD_SHIFT加上上层页表索引所需的比特位长度组成。

在各级页目录/页表中所能存储的指针数目通过宏定义确定。PTRS_PER_PGD指定了全局页目录中项的数目,PTRS_PER_PMD对应于中间页目录,PTRS_PER_PUD对应于上层页目录中项的数目,PTRS_PER_PTE则是页表中项的数目。两级页表的体系结构会将PTRS_PER_PMD和PTRS_PER_PUD定义为1。

(2)页表的格式

内核提供了4个数据结构来标识

  • pgd_t用于全局页目录项。
  • pud_t用于上层页目录项。
  • pmd_t用于中间页目录项。
  • pte_t用于直接页表项。

此外,根据不同的体系结构,内核通过宏定义或内联函数定义了一些用于分析页表项的标准函数。

尽管使用了C结构来表示页表项,但大多数页表项都只有一个成员,通常是unsigned long类型,AMD64体系结构的页表项如下:

1 typedef struct { unsigned long pte; } pte_t;
2 typedef struct { unsigned long pmd; } pmd_t;
3 typedef struct { unsigned long pud; } pud_t;
4 typedef struct { unsigned long pgd; } pgd_t;

使用struct而不是基本类型,以确保页表项的内容只能由相关的辅助函数处理,而决不能直接访问。

虚拟地址分为几个部分,用作各个页表的索引。根据使用的体系结构字长不同,各个单独的部分长度小于32或64个比特位。内核(以及处理器)使用32或64位类型来表示页表项(不管页表的级数)。这意味着并非表项的所有比特位都存储了有用的数据,即下一级表的基地址。多余的比特位用于保存额外的信息。

(3)特定于PTE的信息

最后一级页表中的项不仅包含了指向页的内存位置的指针,还在上述的多余比特位包含了与页有关的附加信息(有关页访问控制的一些信息)。

_PAGE_PRESENT指定了虚拟内存页是否存在于内存中;CPU每次访问页时,会自动设置_PAGE_ACCESSED(活跃程度);_PAGE_DIRTY表示该页内容是否已修改;_PAGE_FILE的数值与_PAGE_DIRTY相同,用于页不在内存中的时候;_PAGE_USER指定是否允许用户空间代码访问该页;_PAGE_READ、_PAGE_WRITE和_PAGE_EXECUTE指定了普通的用户进程是否允许读取、写入、执行该页中的机器代码;IA-32和AMD64提供了_PAGE_BIT_NX,作为保护位用于将页标记为不可执行的功能。

内核还定义了各种函数,用于查询和设置内存页与体系结构相关的状态。(不详述)

2、页表项的创建和操作

 

6 用于创建新页表项的函数

所有体系结构都实现了图6中的函数,以便于内存管理代码创建和销毁页表。

四、初始化内存管理

许多CPU需要显式设置适用于Linux内核的内存模型(IA-32需要切换到保护模式),内核在内存管理完全初始化之前就需要使用内存,在系统启动过程期间,使用一个额外的简化形式的内存管理模块,然后丢弃,确认系统中内存的总数量,及其在各个结点和内存域之间的分配情况。

1、建立数据结构

对相关数据结构的初始化是从全局启动start_kernel开始的,由于内存管理在内核中非常重要,特定于体系结构的设置步骤中检测内存并确定系统中内存的分布情况后,会立即执行内存的初始化。此时,已经对各种系统内存模式生成了一个pgdata_t实例,用于保存诸如结点中内存数量以及内存在各个内存域之间分配情况的信息。

(1)先决条件

内核在mm/page_alloc.c中定义了一个pg_data_t实例(称作contig_page_data)管理所有的系统内存,以保证内存管理代码的可移植性。

体系结构相关的初始化代码将numnodes变量设置为系统中结点的数目。在UMA系统上因为只有一个(形式上的)结点,因此该数量是1。

(2)系统启动

7 从内存管理看内核初始化

7是start_kernel代码流程图,首先是设置函数setup_arch(其中会初始化自举分配器);然后setup_per_cpu_areas定义静态per_cpu变量(非SMP系统上为空操作);接着build_all_zonelists建立结点和内存域的数据结构;之后mem_init停用bootmem分配器并迁移到实际的内存管理函数;最后setup_per_cpu_pageset为pageset数组的第一个数组元素分配内存。

(3)结点和内存域初始化

内核定义了内存的一个层次结构,首先试图分配“廉价的”内存。如果失败,则根据访问速度和容量,逐渐尝试分配“更昂贵的”内存。高端内存是最廉价的,普通内存域的情况其次,DMA内存域最昂贵。内核还针对当前内存结点的备选结点,定义了一个等级次序(build_zonelists作用)。用于当前结点所有内存域的内存都用尽时,确定备选结点。

build_all_zonelists将所有工作都委托给__build_all_zonelists,后者对系统中的各个NUMA结点分别调用build_zonelists,对所有内存创建内存域列表。UMA系统中,build_zonelists在当前处理的结点和系统中其他结点的内存域之间建立一种等级次序(这种次序在期望的结点内存域中没有空闲内存时很重要)。

8以某个系统的结点2为例,描述了一个备用列表在多次循环中不断填充的过程。(numnodes=4)

 

8 连续填充备用列表

第一步之后,列表中的分配目标是高端内存,接下来是第二个结点的普通和DMA内存域。第二步检查大于当前结点编号的一个结点,最后检查编号小于当前结点的结点生成备用列表项。备用列表中项的数目一般无法准确知道,因为系统中不同结点的内存域配置可能并不相同。因此列表的最后一项赋值为空指针,显式标记列表结束。

对总数N个结点中的结点m来说,内核生成备用列表时,选择备用结点的顺序总是:m、m+1、m+2、…、N1、0、1、…、m1。

9为4个结点系统中为第三个结点建立的备用列表。

 

9 完成的备用列表

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

(1)内核在内存中的布局

IA-32体系结构中,对于其物理内存,前4KB(第一个页帧)留给BIOS用,接下来640K空白,该区域后用于映射各种ROM(通常是系统BIOS和显卡ROM)。IA-32内核使用0x100000作为起始地址,对应于内存中第二兆字节的开始处。

内核占据的内存分为几个段,其边界保存在变量中:

  • _text和_etext是代码段的起始和结束地址,包含了编译后的内核代码;
  • 数据段位于_etext和_edata之间,保存了大部分内核变量;
  • 初始化数据在内核启动过程结束后不再需要(例如,包含初始化为0的所有静态全局变量的BSS段)保存在最后一段,从_edata到_end。

编译器在编译时,只有在目标文件链接完成后,才能知道内核大小确切的数值,接下来则打包为二进制文件。该操作是由arch/arch/vmlinux.ld.S控制的(对IA-32来说,该文件是arch/x86/vmlinux_32.ld.S),其中也划定了内核的内存布局。

每次编译内核时,都生成一个文件System.map并保存在源代码目录下(cat System.map命令查看)。除了所有其他(全局)变量、内核定义的函数和例程的地址,该文件还包括_text,_etext,_edata,_end等常数的值。

用户和内核地址空间之间采用标准的3 : 1划分,内核段的起始地址0xC0000000,该地址是虚拟地址,因为物理内存映射到虚拟地址空间的时候,采用了从该地址开始的线性映射方式。减去0xC0000000,则可获得对应的物理地址。

(2)初始化步骤

内核载入内存、初始化的汇编部分执行完毕后,内核需要执行的内存管理相关操作流程如图10所示。

10 IA-32系统上内存初始化的代码流程图

  • 首先调用machine_specific_memory_setup,创建一个列表,包括系统占据的内存区和空闲内存区。
  • 内核接下来用parse_cmdline_early分析命令行,主要关注类似mem=XXX[KkmM]、highmem=XXX[kKmM]或memmap=XXX[KkmM]" "@XXX[KkmM]之类的参数。
  • 下一个主要步骤在setup_memory中执行,该函数有两个版本。一个用于连续内存系统,另一个用于不连续内存系统,二者的效果相同:确定(每个结点)可用的物理内存页的数目;初始化bootmem分配器;接下来分配各种内存区。
  • paging_init初始化内核页表并启用内存分页。
  • 最后调用zone_sizes_init会初始化系统中所有结点的pgdata_t实例。
  • AMD64计算机上内存有关的初始化次序非常类似。

 (3)分页机制初始化

 IA-32系统上,内核通常将总4GB可用虚拟地址空间按3:1的划分,划分原因有两个:

  • 在用户应用程序的执行切换到核心态时,内核必须装载在一个可靠的环境中。因此有必要将地址空间的一部分分配给内核专用。
  • 物理内存页则映射到内核地址空间的起始处,以便内核直接访问,而无需复杂的页表操作(安全问题:不能让所有物理内存页都映射到用户空间进程的地址空间中)。

虽然用于用户层进程的虚拟地址部分随进程切换而改变,但是内核部分总是相同的。

如图11所示,内核地址空间自身又分为各个段,标明了虚拟地址空间的各个区域的用途(与物理内存的分配无关)。

11 IA-32系统上内核地址空间划分

地址的第一段与系统的所有物理内存页映射到内核虚拟地址空间中,是一个简单的线性平移。直接映射区域从0xC0000000到high_memory(最高896MB)地址,如果物理内存超过896MB,则内核无法直接映射全部物理内存。剩余的128MB(超过896MB到1GB的部分)用作以下三个用途:

  • 虚拟内存中连续、但物理内存中不连续的内存区,可以在vmalloc区域分配。该机制通常用于用户过程,内核自身会试图尽力避免非连续的物理地址。内核通常会成功,因为大部分大的内存块都在启动时分配给内核,那时内存的碎片尚不严重。如果在已经运行了很长时间的系统上,在内核需要物理内存时,就可能出现可用空间不连续的情况。此类情况,主要出现在动态加载模块时;
  • 持久映射用于将高端内存域中的非持久页映射到内核中;
  • 固定映射是与物理地址空间中的固定页关联的虚拟地址空间项,但具体关联的页帧可以自由选择。它与通过固定公式与物理内存关联的直接映射页相反,虚拟固定映射地址与物理内存位置之间的关联可以自行定义,定义后不能改变。优点在于,在编译时对此类地址的处理类似于常数,内核一启动即为其分配了物理地址(地址解引用比普通指针快)。

__VMALLOC_RESERVE设置了vmalloc区域的长度, MAXMEM表示内核可以直接寻址的物理内存的最大可能数量,内核中,将内存划分为各个区域是通过图11所示的各个常数控制的(常数值可能不同),直接映射的边界由high_memory指定)

vmalloc区域的起始地址,取决于在直接映射物理内存时,使用了多少虚拟地址空间内存(high_memory)。

vmalloc区域在何处结束取决于是否启用了高端内存支持。如果没有启用,那么就不需要持久映射区域,因为整个物理内存都可以直接映射。

IA-32系统中,将虚拟地址空间按3:1比例划分不是唯一选项,通过修改__PAGE_OFFSET实现。按3∶1之外的比例划分地址空间,在特定的应用场景下可能是有意义的。比如对主要在内核中运行代码的计算机,例如网络路由器。

 

paging_init负责建立只能用于内核的页表,用户空间无法访问。代码流程图如图12所示。

12 paging_init代码流程图

  • 首先划分虚拟地址空间;
  • 然后由pagetable_init以swapper_pg_dir(全局页目录指针)为基础初始化系统的页表。接下来启用在所有现代IA-32系统上可用的两个扩展(只有一些非常古老的Pentium实现不支持这些)。
  • 对超大内存页的支持。这些特别标记的页,其长度为4 MiB,而不是普通的4 KiB。该选项用于不会换出的内核页。增加页大小,意味着需要的页表项变少,这对地址转换后备缓冲器(TLB)的影响是正面的,可以减少其中来自内核的缓存项。
  • 如有可能,内核页会设置另一个属性(__PAGE_GLOBAL),这也是__PAGE_KERNEL和__PAGE_KERNEL_EXEC变量中__PAGE_GLOBAL比特位已经置位的原因。这些变量指定内核自身分配页帧时的标志集,因此这些设置会自动地应用到内核页。
  • 接着借助于kernel_physical_mapping_init,将物理内存页(或前896 MiB)映射到虚拟地址空间中从PAGE_OFFSET开始的位置。内核接下来扫描各个页目录的所有相关项,将指针设置为正确的值。
  • 随后建立固定映射项和持久内核映射对应的内存区。同样是用适当的值填充页表。
  • 最后将cr3寄存器设置为指向全局页目录(swapper_pg_dir)的指针,激活新的页表,使用__flush_all_tlb刷出于TLB缓存项仍然包含了启动时分配的一些内存地址数据,将kmap_init初始化全局变量kmap_pte(用于从高端内存域将页映射到内核地址空间)。

 

对于冷热(per-CPU)缓存,zone_pcp_init负责初始化该缓存。

 1 static __devinit void zone_pcp_init(struct zone *zone)
 2 {
 3     int cpu;
 4     unsigned long batch = zone_batchsize(zone);
 5     for (cpu = 0; cpu < NR_CPUS; cpu++) {
 6         setup_pageset(zone_pcp(zone,cpu), batch);
 7 }
 8     if (zone->present_pages)
 9         printk(KERN_DEBUG " %s zone: %lu pages, LIFO batch:%lu\n",
10         zone->name, zone->present_pages, batch);
11 }
__devinit void zone_pcp_init

在用zone_batchsize算出批量大小(用于计算最小和最大填充水平的基础)后,代码将遍历系统中的所有CPU,同时调用setup_pageset填充每个per_cpu_pageset实例的常量。在调用该函数时,使用了zone_pcp宏来选择与当前CPU相关的内存域的pageset实例。

 对于冷热页的水印计算,内核首先会计算出batch,batch = zone->present_pages / 1024,大约相当于内存域中的页数的0.25‰。对热页来说,下限为0,上限为6*batch,缓存中页的平均数量大约是4*batch,因为内核不会让缓存水平降到太低。batch*4相当于内存域中页数的千分之一。冷页列表的水印稍低一些,因为冷页并不放置到缓存中,只用于一些不太关注性能的操作,其上限是batch值的两倍。

(4)注册活动内存区

活动内存区就是不包含空洞的内存区。必须使用add_active_range在全局变量early_node_map中注册内存区。当前注册的内存区数目记载在nr_nodemap_entries中。不同内存区的最大数目由MAX_ACTIVE_REGIONS给出。该值可以由特定于体系结构的代码使用CONFIG_MAX_ACTIVE_REGIONS设置。如果不设置,在默认情况下内核允许每个内存结点注册256个活动内存区(如果在超过32个结点的系统上,允许每个NUMA结点注册50个内存区)。

  • IA-32上:除了调用add_active_range之外,zone_sizes_init函数以页帧为单位,存储了不同内存区的边界。物理内存页映射到从PAGE_OFFSET开始的虚拟地址空间,而物理内存的前16 MiB适合于DMA操作,十六进制表示就是前0x1000000字节。用virt_to_phys转换,可以获得物理内存地址,而右移PAGE_SHIFT位则相当于除以页大小,计算最后得到适用于DMA的页数。
  • AMD64上:根据BIOS提供的信息遍历所有的内存区,并针对每个内存区找到活动内存区,因此与IA-32对比,add_active_range可能会调用多次。

(5)AMD64地址空间设置

64位地址空间没有高端内存域,当前使用的地址字宽度多为48位,可以寻址256TB地址空间。虚拟地址使用的是64位指针,虚拟地址空间的某些部分无法寻址,对此,采用了符号扩展(sign extension)的方式作为解决方案。

13 AMD64计算机上虚拟地址空间到物理地址空间可能的映射方式

虚拟地址的低47位,即[0, 46],可以任意设置。而比特位[47, 63]的值总是相同的:或者全0,或者全1。此类地址称之为规范的。因此整个地址空间划分为3部分:下半部、上半部、二者之间的禁用区。

14 AMD64系统上虚拟地址空间组织

14位Linux内核在AMD64计算机上对虚拟地址空间布局示意图。

可访问的地址空间的整个下半部用作用户空间,而整个上半部专用于内核。由于两个空间都极大,无须调整划分比例之类的参数。

内核地址空间起始于一个起防护作用的空洞,以防止偶然访问地址空间的非规范部分,如果发生这种情况,处理器会引发一个一般性保护异常(general protection exception);另一个防护性空洞位于一致映射内存区和vmalloc内存区之间,后者的范围从VMALLOC_START到VMALLOC_END。

虚拟内存映射(virtual memory map,VMM)内存区紧接着vmalloc内存区之后,长为1 TiB。只有内核使用了稀疏内存模型,该内存区才是有用的。

内核代码段映射到从__START_KERNEL_MAP开始的内存区,还有一个编译时可配置的偏移量CONFIG_PHYSICAL_START。

最后,还必须提供一些空间用于映射模块,该内存区从MODULES_VADDR到MODULES_END。

3、启动过程期间的内存管理

在启动过程期间,尽管内存管理尚未初始化,但内核仍然需要分配内存以创建各种数据结构。bootmem分配器用于在启动阶段早期分配内存。对该分配器的需求集中于简单性方面,而非通用性。因此内核开发者决定实现一个最先适配(first-fit)分配器用于在启动阶段管理内存。该分配器使用一个位图来管理页,位图比特位的数目与系统中物理内存页的数目相同。比特位为1,表示已用页;比特位为0,表示空闲页。在需要分配内存时,分配器逐位扫描位图,直至找到一个能提供足够连续页的位置。该过程不是很高效,因为每次分配都必须从头扫描比特链。因此在内核完全初始化之后,不能将该分配器用于内存管理。伙伴系统(连同slab、slub或slob分配器)是一个好得多的备选方案。

(1)数据结构

最先适配分配器必须管理一些数据。内核(为系统中的每个结点都)提供了一个bootmem_data结构的实例,用于该用途。该结构所需的内存无法动态分配,必须在编译时分配给内核。

UMA系统上该分配的实现与CPU无关(NUMA系统采用了特定于体系结构的解决方案)。bootmem_data结构定义如下:

1 typedef struct bootmem_data {
2     unsigned long node_boot_start;    //保存了系统中第一个页的编号,大多数体系结构下都是零
3     unsigned long node_low_pfn;        //可以直接管理的物理地址空间中最后一页的编号
4     void *node_bootmem_map;    //指向存储分配位图的内存区的指针
5     unsigned long last_offset;    //若没有请求分配整个页,则last_offset用作该页内部的偏移量
6     unsigned long last_pos;    //上一次分配的页的编号
7     unsigned long last_success;    //定位图中上一次成功分配内存的位置
8     struct list_head list;    //所有注册的分配器的链表的表头
9 } bootmem_data_t;
struct bootmem_data

(2)初始化

bootmem分配器的初始化是一个特定于体系结构的过程,还取决于所述计算机的内存布局。

15为IA-32系统上初始化bootmem分配器涉及的各个步骤。图16为AMD64系统上初始化的步骤。

 

15 IA-32计算机上初始化bootmem分配器

IA-32系统中,首先由setup_memory分析检测到的内存区,以找到低端内存区中最大的页帧号,基于该信息,setup_bootmem_allocator负责发起所有必要的步骤,以初始化bootmem分配器。它首先调用通用函数init_bootmem将所有页标记为已用,然后由register_bootmem_low_pages通过将位图中对应的比特位清零,释放所有潜在可用的内存页, bootmem分配器需要一些内存页管理分配位图,需要首先调用reserve_bootmem分配这些内存页。

有一些其他的内存区已经在使用中,必须相应地标记出来。因此,还需要用reserve_bootmem注册相应的页。需要注册的内存区的确切数目,高度依赖于内核配置。其他的reserve_bootmem调用则分配与内核配置相关的内存区,例如,用于ACPI数据或SMP启动时的配置。

 

16 AMD64计算机上初始化bootmem分配器

AMD64中,由contig_initmem负责分配任务。首先,bootmem_bootmap_bitmap计算bootmem位图所需页的数目,然后,使用init_bootmem将该信息填充到体系结构无关的bootmem数据结构中,最后,调用一次reserve_bootmem注册bootmem分配位图所需的空间。与IA-32相反,AMD64不需要为遗留信息在内存中分配空间。

 (3)对内核接口

内核提供了各种函数,用于在初始化期间分配内存:

  • alloc_bootmem(size)和alloc_bootmem_pages(size)按指定大小在ZONE_NORMAL内存域分配内存。
  • alloc_bootmem_low和alloc_bootmem_low_pages的工作方式类似于上述函数,只是从ZONE_DMA内存域分配内存。
  • 内核提供了free_bootmem函数来释放内存。它需要两个参数:需要释放的内存区的起始地址和长度。

NUMA系统上等价函数的名称为free_bootmem_node,它需要一个额外的参数来指定结点。

(4)释放初始化数据

许多内核代码块和数据表只在系统初始化阶段需要,此后这些数据便可丢弃,free_initmem负责释放用于初始化的内存区,并将相关的页返回给伙伴系统。内核提供了两个属性(__init和__initcall)用于标记初始化函数和数据,这些必须置于函数或数据的声明之前。

1 #define __init __attribute__ ((__section__ (".init.text"))) __cold
2 #define __initdata __attribute__ ((__section__ (".init.data")))

__attribute__是一个特殊的GNU C关键字,属性就通过该关键字使用。__section__属性用于通知编译器将随后的数据或函数分别写入二进制文件的.init.data和.init.text段(ELF文件结构)。前缀__cold还通知编译器,通向该函数的代码路径可能性较低,即该函数不会经常调用,对初始化函数通常是这样。

posted @ 2018-10-28 23:15  雾封尘  阅读(1790)  评论(0编辑  收藏  举报