系列文章目录
Linux 内核设计与实现
深入理解 Linux 内核(一)
深入理解 Linux 内核(二)
Linux 设备驱动程序(一)
Linux 设备驱动程序(二)
Linux 设备驱动程序(三)
Linux设备驱动开发详解
深入理解Linux虚拟内存管理
文章目录
- 系列文章目录
- 第1章 简介
- 第2章 描述物理内存
- 2.1 节点
- 2.2 管理区
- 2.2.1 管理区极值
- 2.2.2 计算管理区大小
- 2.2.3 管理区等待队列表
- 2.3 管理区初始化
- 2.4 初始化mem_map
- 2.5 页面
- 2.6 页面映射到管理区
- 2.7 高端内存
- 2.8 Linux2.6版本中有哪些新特性
- (1)节 点
- (2)管理区
- (3)页面
- (4)Per-CPU 上的页面链表
- 第3章 页表管理
- 3.1 描述页目录
- 3.2 描述页表项
- 3.3 页表项的使用
- 3.4 页表项的转换和设置
- 3.5 页表的分配和释放
- 3.6 内核页表
- 3.6.1 引导初始化
- 3.6.2 收尾工作
- 3.7 地址和struct page之间的映射
- 3.7.1 物理和虚拟内核地址之间的映射
- 3.7.2 struct page和物理地址间的映射
- 3.8 转换后援缓冲区(TLB)
- 3.9 一级CPU高速缓存管理
- 3.10 2.6中有哪些新特性
- 第4章 进程地址空间
- 4.1 线性地址空间
- 4.2 地址空间的管理
- 4.3 进程地址空间描述符
- 4.3.1 分配一个描述符
- 4.3.2 初始化一个描述符
- 4.3.3 销毁一个描述符
- 4.4 内存区域
- 4.4.1 内存区域的操作
- 4.4.2 有后援文件/设备的内存区域
- 4.4.3 创建内存区域
- 4.4.4 查找已映射内存区域
- 4.4.5 查找空闲内存区域
- 4.4.6 插入内存区域
- 4.4.7 合并邻接区域
- 4.4.8 重映射和移动内存区域
- 4.4.9 对内存区域上锁
- 4.4.10 对区域解锁
- 4.4.11 上锁后修正区域
- 4.4.12 删除内存区域
- 4.4.13 删除所有的内存区域
- 4.5 异常处理
- 4.6 缺页中断
- 4.6.1 处理缺页中断
- 4.6.2 请求页面分配
- (1)处理匿名页面
- (2)处理文件/设备映射页
- 4.6.3 请求换页
- 4.6.4 写时复制(COW)页
- 4.7 复制到用户空间/从用户空间复制
- 4.8 2.6中有哪些新特性
- 第5章 引导内存分配器
- 5.1 表示引导内存映射
- 5.2 初始化引导内存分配器
- (1)start_kernel
- (2)setup_arch
- (3)setup_memory
- (4)init_bootmem
- (5)init_bootmem_core
- 5.3 初始化bootmem_data
- 5.4 分配内存
- 5.5 释放内存
- 5.6 销毁引导内存分配器
- (1)start_kernel
- (2)初始化 mem_map
- (a)setup_arch
- (b)paging_init
- (c)zone_sizes_init
- (d)free_area_init
- (e)free_area_init_core
- (3)mem_init
- (a)mem_init
- (b)free_pages_init
- (c)free_all_bootmem
- (d)free_all_bootmem_core
- 5.7 2.6中有哪些新特性
第1章 简介
本文基于 Linux 2.4.22。
- 开始启程
- 管理源码
- 浏览代码
- 阅读代码
- 提交补丁
第2章 描述物理内存
Linux 适用于广泛的体系结构,因此需要用一种与体系结构无关的方式来描述内存。本章描述了用于记录影响 VM 行为的内存簇、页面和标志位的结构。
在 VM 中首要的普遍概念就是非一致内存访问(NUMA)。对大型机器而言,内存会分成许多簇,依据簇与处理器 “距离” 的不同,访问不同的簇会有不同的代价。比如,可能把内存的一个簇指派给每个处理器,或者某个簇和设备卡很近,很适合内存直接访问(DMA),那么就指派给该设备。
每个簇都被认为是一个节点,在 Linux 中的 struct pg_data_t 体现了这一概念,既便在一致内存访问(UMA)体系结构中亦是如此。该结构通常用 pg_data_t 来引用。系统中的每个节点链接到一个以 NULL 结尾的 pgdat_list 链表中,而其中的每个节点利用 pg_data_t node_next 字段链接到下一个节点。对于像 PC 这种采用 UMA 结构的机器,只使用了一个称作 contig_page_data 的静态 pg_data_t 结构。在 2.1 节中会对节点作进一步的讨论。
struct pg_data_t 在内存中,每个节点被分成很多的称为管理区(zone)的块,用于表示内存中的某个范围。不要混淆管理区和基于管理区的分配器,因为两者是完全不相关的。一个管理区由一个 struct zone_struct 描述,并被定义为 zone_t ,且每个管理区的类型都是 ZONE_DMA,ZONE_NORMAL 或者 ZONE_HIGHMEM 中的一种。不同的管理区类型适合不同类型的用途。ZONE_DMA 指低端范围的物理内存,某些工业标准体系结构(ISA)设备需要用到它。
ZONE_NORMAL 部分的内存由内核直接映射到线性地址空间的较高部分,在 4.1 节会进一步讨论。ZONE_HIGHMEM 是系统中预留的可用内存空间,不被内核直接映射。
对于 x86 机器,管理区的示例如下:
- ZONE_DMA 内存的首部 16 MB;
- ZONE_NORMAL 16 MB~896MB;
- ZONE_HIGHMEM 896 MB~末尾(128M)。
许多内核操作只有通过 ZONE_NORMAL 才能完成,因此 ZONE_NORMAL 是影响系统性能最为重要的管理区。这些管理区会在 2.2 节讨论。系统的内存划分成大小确定的许多块,这些块也称为页面帧。每个物理页面帧由一个 struct page 描述,所有的结构都存储在一个全局 mem_map 数组中,该数组通常存放在 ZONE_NORMAL 的首部,或者就在小内存系统中为装入内核映象而预留的区域之后。2.4 节讨论了 struct page 的细 节问题,在 3.7 节将讨论全局 mem_map 数组的细节问题。在图 2.1 中解释了所有这些结构之间的基本关系。
由于能够被内核直接访问的内存空间(ZONE_NORMAL)大小有限,所以 Linux 提出高端内存的概念,它将会在 2.7 节中进一步讨论。这一章讨论在引入高端内存管理以前,节点、管理区和页面如何工作。
2.1 节点
正如前面所提到的,内存中的每个节点都由 pg_data_t 描述,而 pg_data_t 由 struct pglist_data 定义而来。在分配一个页面时,Linux 采用节点局部分配的策略,从最靠近运行中的 CPU 的节点分配内存。由于进程往往是在同一个 CPU 上运行,因此从当前节点得到的内存很可能被用到。结构体在 <linux/mmzone.h> 文件中声明如下所示:
#define ZONE_DMA 0
#define ZONE_NORMAL 1
#define ZONE_HIGHMEM 2
#define MAX_NR_ZONES 3
typedef struct pglist_data {
zone_t node_zones[MAX_NR_ZONES];
zonelist_t node_zonelists[GFP_ZONE MASK + 1];
struct page *node_mem_map;
unsigned long *valid_addr_bitmap;
struct bootmem_data *bdata;
unsigned long node_start_paddr;
unsigned long node_start_mapnr;
unsigned long node_size;
int node_id;
struct pglist_data *node_next;
} pg_data_t;
现在我们简要地介绍每个字段。
- node_zones:
该节点所在管理区为 ZONE_HIGHMEM,ZONE_NORMAL 和 ZONE_DMA。 - node_zonelists:
它按分配时的管理区顺序排列。在调用 free_area_init_core() 时,通过 mm/page_alloc.c 文件中的 build_zonelists() 建立顺序。如果在 ZONE_HIGHMEM 中分配失败,就有可能还原成 ZONE_NORMAL 或 ZONE_DMA。 - nr_zones:
表示该节点中的管理区数目,在 1 到 3 之间。并不是所有的节点都有 3 个管理区。例如,一个 CPU 簇就可能没有 ZONE_DMA。 - node_mem_map:
指 struct page 数组中的第一个页面,代表该节点中的每个物理帧。它被放置在全局 mem_map 数组中。 - valid_addr_bitmap:
一张描述内存节点中 “空洞” 的位图,因为并没有实际的内存空间存在。事实上,它只在 Sparc 和 Sparc64 体系结构中使用,而在任何其他体系结构中可以忽略掉这种情况。 - bdata:
指向内存引导程序,在第 5 章中有介绍。 - node_start_paddr:
节点的起始物理地址。无符号长整型并不是最佳选择,因为它会在 ia32 上被物理地址拓(PAE)以及一些 PowerPC 上的变量如 PPC440GP 拆散。PAE 在第 2.7 节有讨论。一种更好的解决方法是用页面帧号(PFN)记录该节点的起始物理地址。一个 PFN 仅是一个简单的物理内存索引,以页面大小为基础的单位计算。物理地址的 PFN 一般定义为(page_phys_addr >> PAGE_SHIFT)。 - node_start_mapnr:
它指出该节点在全局 mem_map 中的页面偏移。在 free_area_init_core() 中,通过计算 mem_map 与该节点的局部 mem_map 中称为 Imem_map 之间的页面数,从而得到页面偏移。 - node_size:
这个管理区中的页面总数。 - node_id:
节点的 ID 号(NID),从 0 开始。 - node_next:
指向下一个节点,该链表以 NULL 结束。
所有节点都由一个称为 pgdat_list 的链表维护。这些节点都放在该链表中,均由函数 init_bootmem_core() 初始化节点,在 5.3 节会描述该函数。截至最新的 2.4 内核( >2.4.18 ),对该链表操作的代码段基本上如下所示:
pg_data_t * pgdat;
pgdat = pgdat_list;
do {
/* do something with pgdata_t */
} while((pgdat = pgdat->node_next));
在最新的内核版本中,有一个宏 for_each_pgdat() ,它一般定义成一个 for 循环,以提高代码的可读性。
2.2 管理区
每个管理区由一个 struct zone_struct 描述。 zone_struct 用于跟踪诸如页面使用情况统计数,空闲区域信息和锁信息等。在 <linux/mmzone.h> 中它的声明如下所示:
typedef struct zone_struct {
/*
* Commonly accessed fields:
*/
spinlock_t lock;
unsigned long offset;
unsigned long free_pages;
unsigned long inactive_clean_pages;
unsigned long inactive_dirty_pages;
unsigned long pages_min, pages_low, pages_high;
/*
* free areas of different sizes
*/
struct list_head inactive_clean_list;
free_area_t free_area[MAX_ORDER];
/*
* rarely used fields:
*/
char *name;
unsigned long size;
/*
* Discontig memory support fields.
*/
struct pglist_data *zone_pgdat;
unsigned long zone_start_paddr;
unsigned long zone_start_mapnr;
struct page *zone_mem_map;
} zone_t;
下面是对该结构的每个字段的简要解释。
- lock:
并行访问时保护该管理区的自旋锁。 - free_pages:
该管理区中空闲页面的总数。 - pages_min,pages_low,pages_high:
这些都是管理区极值,在下一节中会讲到这些极值。 - need_balance:
该标志位通知页面换出 kswapd 平衡该管理区。当可用页面的数量达到管理区极值的某一个值时,就需要平衡该管理区了。极值会在下一节讨论。 - free_area:
空闲区域位图,由伙伴分配器使用。 - wait_table:
- 等待队列的哈希表,该等待队列由等待页面释放的进程组成。这对 wait_on_page() 和 unlock_page() 非常重要。虽然所有的进程都可以以在一个队列中等待,但这可能会导致所有等待进程在被唤醒后,都去竞争依旧被锁的页面。大量的进程像这样去尝试竞争一个共享资源,有时被称为惊群效应。等待队列表会在 2.2.3 小节作进一步的讨论。
- wait_table_size:
该哈希表的大小,它是 2 的幕。 - wait_table_shift:
定义为一个 long 型所对应的位数减去上述表大小的二进制对数。 - zone_pgdat:
指向父 pg_data_t。 - zone_mem_map:
涉及的管理区在全局 mem_map 中的第一页。 - zone_start_paddr:
同 node_start_paddr。 - zone_start_mapnr:
同 node_start_mapnr。 - name:
该管理区的字符串名字,“DMA”,“Normal” 或者 “HighMem”。 - size:
该管理区的大小,以页面数计算。
2.2.1 管理区极值
当系统中的可用内存很少时,守护程序 kswapd 被唤醒开始释放页面(见第 10 章)。如果内存压力很大,进程会同步地释放内存,有时候这种情况被引用为 direct-reclaim 路径。影响页面换出行为的参数与 FreeBSD[McK96] 和 Solaris[MM01] 中所用的参数类似。
每个管理区都有三个极值,分别称为 pages_low,pages_min 和 pages_high,这些极值用于跟踪一个管理区承受了多大的压力。它们之间的关系如图 2.2 所示。pages_min 的页面数量在内存初始化阶段由函数 free_area_init_core() 计算出来,并且是基于页面的管理区大小的一个比率。计算值初始化为 ZoneSizeInPages/128。它所能取最小值是 20 页(在 x86 上是 80KB),而可能的最大值是 255 页(在 x86 上是 1 MB)。
每个极值在表示内存不足时的行为都互不相同。
- pages_low:
在空闲页面数达到 pages_low 时,伙伴分配器就会唤醒 kswapd 释放页面。与之对应的是 Solaris 中的 lotsfree 和 FreeBSD 中的 freemin。pages_low 的默认值是 pages_min 的两倍。 - pages_min:
当达到 pages_min 时,分配器会以同步方式启动 kswapd,有时候这种情况被引用为 direct-reclaim 路径。在 Solaris 中没有与之等效的参数,最接近的是 desfree 或 min-free,这两个参数决定了页面换出扫描程序被唤醒的频率。 - pages_high:
kswpad 被唤醒并开始释放页面后,在 pages_high 个页面被释放以前,是不会认为该管理区已经 “平衡” 的。当达到这个极值后,kswapd 就再次睡眠。在 Solaris 中,它被称为 lotsfree,在 BSD 中,它被称为 free_target。 pages_high 的默认值是 pages_min 的三倍。
在任何操作系统中,无论调用什么样的页面换出参数,它们的含义都是相同的。它们都用于决定页面换出守护程序或页面换出进程释放页面的频繁程度。
2.2.2 计算管理区大小
每个管理区的大小在 setup_memory() 中计算出来,如图 2.3 所示。
PFN 物理内存映射,以页面计算的偏移量。系统中第一个可用的 PFN(min_low_pfn) 分配在被导入的内核映象末尾 _end 后的第一个页面位置。其值作为一个文件范围变量存储在 mm/bootmem.c 文件中,与引导内存分配器配套使用。
系统中的最后一个页面帧的 max_pfn 如何计算完全与体系结构相关。在 x86 的情况下,通过函数 find_max_pfn() 计算出 ZONE_NORMAL 管理区的结束位置值 max_low_pfn。这一块管理区是可以被内核直接访问的物理内存,并通过 PAGE_OFFSET 标记了内核/用户空间之间划分的线性地址空间。这个值与其他的一些值,都存储在 mm/bootmem.c 文件中。在内存很少的机器上,max_pfn 的值等于 max_low_pfn。
通过 min_low_pfn,max_low_pfn 和 max_pfn 这三个变量,系统可以直接计算出高端内存的起始位置和结束位置,表示文件范围的变量 highstart_pfn 和 highend_pfn 存储在 arch/i286/mm/init.c 文件中。这些值接着被物理页面分配器用来初始化高端内存页面,见 5.6 节。
2.2.3 管理区等待队列表
当页面需要进行 I/O 操作时,比如页面换入或页面换出,I/O 必须被锁住以防止访问不一致的数据。使用这些页面的进程必须在 I/O 能访问前,通过调用 wait_on_page() 被添加到一个等待队列中。当 I/O 完成后,页面通过 UnlockPage() 解锁,然后等待队列上的每个进程都将被唤醒。理论上每个页面都应有一个等待队列,但是系统这样会花费大量的内存存放如此
多分散的队列。Linux 的解决办法是将等待队列存储在 zone_t 中。基本进程如图 2.4 所示。
在管理区中只有一个等待队列是有可能的,但是这意味着等待该管理区中任何一个页面的所有进程在页面解锁时都将被唤醒。这会引起惊群效应的问题。Linux 的解决办法是将等待队列的哈希表存储在 zone_wait_table 中。在发生哈希冲突时,虽然进程也有可能会被无缘无故地唤醒,但冲突不会再发生得如此频繁了。
该表在 free_area_init_core() 时就被初始化了。它的大小通过 wait_table_size() 计算,并存储在 zone_t->wait_table_size 中。 等待队列最多有 4096 个。而小一点的队列的大小是 NoPages/PAGE_PER_WAITQUEUE 个队列数和 2 的幂次方的最小值,其中 NoPages 是该管理区中的页面数,PAGE_PER_WAITQUEUE 被定义为 256,即表的大小由如下等式计算出的整数部分表示:
w a i t _ t a b l e _ s i z e = l o g 2 ( N o P a g e s × 2 P A G E _ P E R _ W A I T Q U E U E − 1 ) wait\_table\_size = log_2(\frac{NoPages×2}{PAGE\_PER\_WAITQUEUE}-1) wait_table_size=log2(PAGE_PER_WAITQUEUENoPages×2−1)
zone_t->wait_table_shift 字段通过将一个页面地址的比特数右移以返回其在表中的索引而计算出来。函数 page_waitqueue() 用于返回某管理区中一个页面对应的等待队列。它一般采用基于已经被哈希的 struct page 的虚拟地址的乘积哈希算法。
page_waitqueue 一般需要用 GOLDEN_RATIO_PRIME 乘以该地址,并将结果 zone_t→wait_table_shift 的比特数右移以得到其生哈希表中的索引结果。GOLDEN_RATIO_PRIME [Lev00] 是在系统中最接近所能表达的最大整数的 golden ratio[Knu68] 的最大素数。
2.3 管理区初始化
管理区的初始化在内核页表通过函数 paging_init() 完全建立起来以后进行。页面表的初始化在 3.6 节会涉及。可以肯定地说,不同的系统执行这个任务虽然不一样,但它们的目标都是相同的:应当传递什么样的参数给 UMA 结构中的 free_area_init() 或者 NUMA 结构中的 free_area_init_node() 。UMA 唯一需要得到的参数是 zones_size。参数的完整列表如下。
// mm/page_alloc.c
void __paginginit free_area_init_node(int nid, unsigned long *zones_size,
unsigned long node_start_pfn, unsigned long *zholes_size);
- nid:
被初始化管理区中节点的逻辑标识符,NodeID。 - pgdat:
节点中被初始化的 pa_data_t。在 UMA 结构里为 contig_page_data。 - pmap:
被 free_area_init_core() 函数用于设置指向分配给节点的局部 lmem_map 数组的指针 UMA 结构中,它往往被忽略掉,因为 NUMA 将 mem_map 处理为起始于 PAGE_OFF_SET 的虚拟数组。而在 UMA 中,该指针指向全局 mem_map 变量,目前还有 mem_map,都在 UMA 中被初始化。 - zones_sizes:
一个包含每个管理区大小的数组,管理区大小以页面为单位计算。 - zone_start_paddr:
第一个管理区的起始物理地址。 - zone_holes:
一个包含管理区中所有内存空洞大小的数组。
核心函数 free_area_init_core() 用于向每个 zone_t 填充相关的信息,并为节点分配 mem_map 数组。释放管理区中的哪些页面的信息在这里并不考虑。这些信息直到引导内存分配器使用完之后才可能知道,这将在第 5 章进行讨论。
2.4 初始化mem_map
mem_map 区域在系统启动时会被创建成下列两种方式中的某一种。在 NUMA 系统中,全局 mem_map 被处理为一个起始于 PAGE_OFFSET 的虚拟数组。free_area_init_node() 函数在系统中被每一个活动节点所调用,在节点被初始化的时候分配数组的一部分。而在 UMA 系统中,free_area_init() 使用 contig_page_data 作为节点,并将全局 mem_map 作为该节点的局部 mem_map。在图 2.5 中显示了这两个函数的调用图。
核心函数 free_area_init_core() 为已经初始化过的节点分配局部 lmem_map。而该数组的内存通过引导内存分配器中的 alloc_bootmem_node()(见第 5 章)分配得到。在 UMA 结构中,新分配的内存变成了全局的 mem_map,但是这和 NUMA 中的还是稍有不不同的。
NUMA 结构 中,分配给 lmem_map 的内存在它们自己的内存节点中。全局mem_map 从未被明确地分配过,取而代之的是被处理成起始于 PAGE_OFFSET 的虚拟数组。局部映射的地址存储在 pg_data_t→node_mem_map 中,也存在于虚拟 mem_map 中。对节点中的每个管理区而言,虚拟 mem_map 中表示管理区的地址存储在 zone_t→zone_mem_map 中。余下的节点都将 mem_map 作为真实的数组,因为其中只有有效的管理区会被节点所使用。
2.5 页面
系统中的每个物理页面都有一个相关联的 struct page 用以记录该页面的状态。在内核 2.2 版本[BC00]中,该结构类似它在 System V 中的等价物,就像 unix 中的其他分支一样,该结构经常变动。它在 linux/mm.h 中声明如下:
typedef struct page {
struct list_head list;
struct address_space *mapping;
unsigned long index;
struct page *next_hash;
atomic_t count;
unsigned long flags; /* atomic flags, some possibly updated asynchronously */
struct list_head lru;
unsigned long age;
wait_queue_head_t wait;
struct page **pprev_hash;
struct buffer_head * buffers;
void *virtual; /* non-NULL if kmapped */
struct zone_struct *zone;
} mem_map_t;
下面是对该结构中的各个字段的简要介绍。
-
list:
页面可能属于多个列表,此字段用作该列表的首部。例如,映射中的页面将属于 address_space 所记录的 3 个循环链表中的一个。这 3 个链表是 clean_pages,dirty_pages 以及 locked_pages。在 slab 分配器中,该字段存储有指向管理页面的 slab 和高速缓存结构的指针。它也用于链接空闲页面的块。 -
mapping:
如果文件或设备已经映射到内存,它们的索引节点会有一个相关联的 address_space。如果这个页面属于这个文件,则该字段会指向这个 address_space。如果页面是匿名的,且设置了 mapping,则 address_space 就是交换地址空间的 swapper_space。 -
index:
这个字段有两个用途,它的意义与该页面的状态有关。如果页面是文件映射的一部分,它就是页面在文件中的偏移。如果页面是交换高速缓存的一部分,它就是在交换地址空间中(swapper_space)address_space 的偏移量。此外,如果包含页面的块被释放以提供给
一个特殊的进程,那么被释放的块的顺序(被释放页面的 2 的幕)存放在 index 中。这在函数 __free_pages_ok() 中设置。 -
next_hash:
属于一个文件映射并被散列到索引节点及偏移中的页面。该字段将共享相同的哈希桶的页面链接在一起。 -
count:
页面被引用的数目。如果 count 减到 0,它就会被释放。当页面被多个进程使用到,或者被内核用到的时候,count 就会增大。 -
flags:
这些标志位用于描述页面的状态。所有这些标志位在 <linux/mm.h> 中声明,并在表 2.1 中列出。有许多已定义的宏用于测试、清空和设置这些标志位,已在表 2.2 中列出。其中最为有用的标志位是 SetPageUptodate(),如果在设置该位之前已经定义,那么它会调用体系结构相关的函数 arch_set_page_uptodate()。 -
Iru:
根据页面替换策略,可能被交换出内存的页面要么会存放于 page_alloc.c 中所声明的 active_list 中,要么存放于 inactive_list 中。这是最近最少使用(LRU)链表的链表首部。这两个链表的细节将在第 10 章讨论。 -
pprev_hash:
是对 next_hash 的补充,使得哈希链表可以以双向链表工作。 -
buffers:
如果一个页面有相关的块设备缓冲区,该字段就用于跟踪 buffer_head。如果匿名页面有一个后援交换文件,那么由进程映射的该匿名页面也有一个相关的 buffer_head。这个缓冲区是必不可少的,因为页面必须与后援存储器中的文件系统定义的块同步。 -
virtual:
通常情况下,只有来自 ZONE_NORMAL 的页面才由内核直接映射。为了定位 ZONE_HIGHMEM 中的页面,kmap() 用于为内核映射页面。这些在第 9 章有进一步的讨论,但只有一定数量的页面会被映射到。当某个页面被映射时,这就是它的虚拟地址。
类型 mem_map_t 是对 struct page 的类型定义,因此在 mem_map 数组中可以很容易就引用它。
2.6 页面映射到管理区
在最近的 2.4.18 版本的内核中,struct page 存储有一个指向对应管理区的指针 page->zone。该指针在后来被认为是一种浪费,因为如果有成千上万的这样的 struct page 存在,那么即使是很小的指针也会消耗大量的内存空间。在更新后的内核版本中,已经删除了该 zone 字段,取 而代之的是 page→flags 的最高 ZONE_SHIFT(在 x86 下是 8 位)位,该 ZONE_SHIFT 位记录该页面所属的管理区。首先,建立管理区的 zone_table,在 linux/page_alloc.c 中它的声明如下:
zone_t * zone_table[MAX_NR_ZONES * MAX_NR_NODES];
EXPORT_SYMBOL(zone_table);
// MAX_NR_ZONES 是一个节点中所能容纳的管理区的最大数,如 3 个。
MAX_NR_NODES 是可以存在的节点的最大数。函数 EXPORT_SYMBOL() 使得 zone_table 可以被载入模块访问。该表处理起来就像一个多维数组。在函数 free_area_init_core() 中,一个节点中的所有页面都会初始化。首先它设置该表的值
zone_table[nid * MAX_NR_ZONES + j] = zone;
其中,nid 是节点 ID,是管理区索引号,zone 是结构 zone_t。对每个页面,函数 set_page_zone() 的调用方式如下所示:
set_page_zone(page, nid * MAX_NR_ZONES + j);
其中,参数 page 是管理区被设置了的页面。因此,zone_table 中的索引被显 式地存储在页面中。
2.7 高端内存
由于内核(ZONE_NORMAL)中可用地址空间是有限的,所以 Linux 内核已经支持了高端内存的概念。高端内存的两个阈值都存在于 32 位 x86 系统中,分别是 4 GB 和 64 GB。4 GB 的限制与 32 位物理地址定位的内存容量有关。为了访问 1 GB 和 4 GB 之间的内存,内核通过 kmap() 将高端内存的页面临时映射成 ZONE_NORMAL。这一点会在第 9 章深入讨论。
第二个 64 GB 的限制与物理地址扩展(PAE)有关,物理地址扩展是 Intel 发明的用于允许 32 位系统使用 RAM 的。它使用了附加的 4 位来定位内存中的地址,实现了 236 字节(64GB)内存的定位。
在理论上,物理地址扩展(PAE)允许处理器寻址到 64 GB,但实际上,Linux 中的进程仍然不能访问如此大的 RAM,因为虚拟地址空间仍然是 4 GB。这就导致一些试图用 malloc() 函数分配所有 RAM 的用户感到失望。
其次,地址扩展空间(PAE)不允许内核自身拥有大量可用的 RAM。用于描述页面的 struct page 仍然需要 44 字节,并且用到了 ZONE_NORMAL 中的内核虚拟地址空间。这意味着,为了描述 1 GB 的内存,需要大约 11 MB 的内核内存。同理,为了描述 16 GB 的内存则需要耗费 176 MB 的内存,这将对 ZONE_NORMAL 产生非常大的压力。在不考虑其他的数据结构而使用 ZONE_NORMAL 时,情况看起来还不太槽糕。在最坏的情况下,甚至像页面表项(PTE)之类的很小的数据结构也需要大约 16 MB。所以在 x86 机器上的 Linux 的可用物理内存实际被限制为 16 GB。如果确实需要访问更多的内存,我的建议一般是直接去买一台 64 位机器。
2.8 Linux2.6版本中有哪些新特性
(1)节 点
大致看去,内存如何被描述好像没有多大的变化似的,但是表面上细微的变化实际上涉及的面却相当广。节点描述器 pg_data_t 增加了如下新的字段。
// include/linux/mmzone.h
typedef struct pglist_data {
/* ... */
unsigned long node_start_pfn;
wait_queue_head_t kswapd_wait;
unsigned long node_present_pages; /* total number of physical pages */
unsigned long node_spanned_pages; /* total size of physical page
range, including holes */
/* ... */
};
-
node_start_pfn:
替换了 node_start_paddr 字段。惟一的一个差异便是新的字段是一个 PFN,而不是一个物理地址。之所以变更是因为 PAE 体系结构可以访问比 32 位更多的内存,因此大于 4 GB 的节点用原来的字段是访问不到的。 -
kswapd_wait:
为 kswapd 新添加的等待队列链表。在 2.4 里面,页面交换守护程序有一个全局等待队列。而在 2.6 里面,每个节点都有一个 kswapdN,其中 N 是节点的标识符,而每个 kswapd 都有其自己的等待队列对应该字段。 -
node_size:
字段被移除了,取而代之的是两个新字段。之所以如此变更是因为认识到节点中会有空洞的存在,空洞是指地址后面其实没有真正存在的物理内存。 -
node_present_pages:
节点中所有物理页面的总数 。 -
node_spanned_pages:
通过节点访问的所有区域,包括任何可能存在的空洞。
(2)管理区
初始看来,两个版本中的管理区也是有很大差异的。它们不再被称为 zone_t,取而代之被简单地引用为 struct zone。第二个主要的不同是 LRU 链表。正如我们在第 10 章中看到的,2.4 的内核有一个全局的页面链表,决定了被释放页面或进行换出页面的顺序。这些链表目前被存储在 struct zone 中。相关的字段如下所示。
// include/linux/mmzone.h linux 2.6.34
struct zone {
/* ... */
/* Fields commonly accessed by the page reclaim scanner */
spinlock_t lru_lock;
int all_unreclaimable; /* All pages pinned */
unsigned long pages_scanned; /* since last reclaim */
/* zone_start_pfn == zone_start_paddr >> PAGE_SHIFT */
unsigned long zone_start_pfn;
unsigned long spanned_pages; /* total size, including holes */
unsigned long present_pages; /* amount of memory (excluding holes) */
struct per_cpu_pageset __percpu *pageset;
/* ... */
};
-
lru_lock:
该管理区中 LRU 链表的自旋锁。在 2.4 里面,它是个被称为 pagemap_lru_lock 的全局锁。 -
active_list:
该管理区中的活动链表。这个链表和第 10 章中描述的一样,但它现在不再是全局的,而是每个管理区一个了。 -
inactive_list:
该管理区中的非活动链表。在 2.4 中,它是全局的。 -
refill_counter:
是从 active_list 链表上一次性移除的页面的数量,而且只有在页面替换时才考虑它。 -
nr_active active_list:
链表上的页面数量。 -
nr_inactive inactive_list:
链表上的页面数量。 -
all_unreclaimable:
当页面换出守护程序第二次扫描整个管理区里的所有页面时,依旧无法释放掉足够的页面,该字段置为 1。 -
pages_scanned:
自最后一次大量页面被回收以来,被扫描过的页面数量。在 2.6 里面,页面被一次性释放掉,而不是单独地释放某个页面,在 2.4 里面采取的是后者。 -
pressure:
权衡该管理区的扫描粒度。它是个衰退的均值,影页面扫描器回收页面时的工作强度。
其他 3 个字段是新加进去的,但它们和管理区的尺度是有关系的。如下所示。
-
zone_start_pfn:
管理区中 PFN 的起始位置。它取代了 2.4 中的 zone_start_paddr 和 zone_start_mapnr。 -
spanned_pages:
该管理区范围内页面的数量,包括某些体系结构中存在的内存空洞。 -
present_pages :
管理区中实际存在的页面的数量。对一些体系结构而言,其值和 spanned_pages 是一样的。
另外一个新加的是 struct per_cpu_pageset,用于维护每个 CPU 上的一系列页面,以减少自旋锁的争夺。zone→pageset 字段是一个关于 struct per_cpu_pageset 的 NR_CPU 大小的数组,其中 NR_CPU 是系统中可以编译的 CPU 数量上限。而 per-cpu 结构会在本节的最后部分作更进一步的讨论。
最后一个新加入 struct zone 的便是结构中零填充。在 2.6 内核 VM 的开发过程中,逐步认识到一些自旋锁会竞争得非常厉害,很难被获取。因为大家都知道有些锁总是成对地被获取,同时又必须保证它们使用不同的高速缓冲行,这是一种很普遍的缓冲编程技巧[Sea00]。该填充管理区在 struct zone 中由 ZONE_PADDING() 宏标记,并被用于保证 zone→lock,zone→lru_lock,以及 zone→pageset 字段使用不同的高速缓冲行。
(3)页面
最值得注意的变更就是该字段的顺序被改变了,因此相关联的项目看起来都使用了同一个高速缓冲行。该字段除了新加了两个特性以外,本质上还是一样的。第一个特性就是采用了一个新的联合来创建 PTE 链。PTE 和页表管理相关联的会在第 3 章的结尾处进行讨论。另外一个特性便是添加了 page->private 字段,它包括了映射中详实的私有信息。比如,当页面是一个缓冲区页面时,该字段被用于存储一个指向 buffer_head 的指针。这意味着 page->buffers 字段也被移除掉了。最后一个重要的变更是 page→virtual 对高端内存的支持不再必要,只有在特定的体系结构需要时才会考虑它的存在。如何支持高端内存将在第 9 章进一步讨论。
(4)Per-CPU 上的页面链表
在 2.4 里面,只有一个子系统会积极地尝试为任何对象维护 per-cpu 上的链表,而这个子系统就是 slab 分配器,在第 8 章会进行讨论。在 2.6 里面,这个概念则更为普遍一些,存在一个关于活动页面和不活动页面的正式概念。
在 <linux/mmzone.h> 文件中声明的 struct per_cpu_pageset 具有一个字段,该字段是一个具有两个 per_cpu_pages 类型的元素的数组。该数组中第 0 位的元素是为活动页面预留的,而另一个元素则是为非活动页面预留的,其中活动页面和非活动页面决定了高速缓存中的页面的活跃状态。如果知道了页面不会马上被引用,比如 I/O 预读中,那么它们就会被置为
非活动页面。
struct per_cpu_pages 维护了链表中目前已有的一系列页面,高极值和低极值决定了何时填充该集合或者释放一批页面,变量决定了一个块当中应当分配多少个页面,并最后决定在页面前的实际链表中分配多少个页面。
为建立每个 CPU 的链表,需要有一个计算每个 CPU 上有多少页面的方法。struct page_state 具有一系列计算的变量,比如 pgalloc 字段,用于跟踪分配给当前 CPU 的 页面数量;而 pswpin 字段,用于跟踪读进交换的页面数量。该结构在 <linux/page-flags.h> 文件中作了详细的注解。函数 mod_page_state() 用于为运行中的 CPU 更新 page_state 字段,另外其还提供了辅助的三个宏,分别调用 inc_page_state(),dec_page_state() 和 sub_page_state()。
第3章 页表管理
3.1 描述页目录
可参考 ==> 1.1 两级页表结构
每个进程都有一个指向其自己 PGD 的指针(mn_struct→pgd),它其实就是一个物理页面帧。该帧包括了一个 pgd_t 类型的数组,但 pgd_t 因不同的体系结构也有所不同,它的定义在 <asm/page.h> 文件中。不同的结构中载入页表的方式也有所不同。在 x86 结构中,进程页表的载入是通过把 mm_struct→pgd 复制到 cr3 寄存器完成,这种方式对 TLB 的刷新有副作用。事实上,这种方式体现了在不同的结构中 __flush_tlb() 的实现情况。
PGD 表中每个有效的项都指向一个页面帧,此页面帧包含着一个 pmd_t 类型的 PMD 项数组,每一个 pmd_t 又指指向另外的页面帧,这些页面帧由很多个 pte_t 类型的 PTE 构成,而 pte_t 最终指向包含真正用户数据的页面。当页面被交换到后援设备时,存储在 PTE 里的将是交换项,这个交换项在系统发生页面错误时在调用 do_swap_page 时作为查找页面数据的依据。页表的布局如图 3.1 所示。
为了能在这三个不同的页表层里产生不同的偏移量以及在实际的页内产生偏移量,任何一个给定的线性地址将会被划分成几个部分。为了有助于将线性地址划分成几个部分,每个页表层均提供了 3 个宏来完成此工作,它们是 SHIFT,SIZE 和 MASK 宏。SHIFT 宏主要用于指定在页面每层映射的长度,以位为单位计算,如图 3.2 所示。
3.2 描述页表项
如前所述,三层页表中的每一个项 PTE,PMD,PGD 分别由 pte_t,pmd_t,pgd_t 描述。它们实际上都是无符号的整型数据,之所以定义成结构,是出于两个原因。第一是为了起到类型保护的作用,以使得它们不会被滥用。第二是为了满足某些特性,如在支持 PAE 的 x86 中,将有额外的 4 位用于对大于 4 GB 内存的寻址。为了存储用于保护的位,内核中使用 pgprot_t 定义了一些相关的标志位,它们一般被放在页表项的低位。
出于类型转换的考虑,内核在 asm/page.h 文件中定义了 4 个宏,这些宏管理先前讨论的类型并返回数据结构中相关的部分。它们是 pte_val(), pmd_val(), pgd_val() 和 pgprot_val() 。为了能反向转换,内核又提供了另外 4 个宏 __pte(),__pmd(),__pgd() 和 __pgprot()。
3.3 页表项的使用
定义在 <asm/pgtable.h> 文件中的一些宏,对于页表项的定位及检查是很重要的。为了定位一个页目录,这里提供了 3 个宏,用以把线性地址分成了 3 个不同的部分。pgd_offset() 通过对一个线性地址和 mm_struct 结构的操作覆盖所需地址的 PGD 项。pmd_offset() 通过对一个 PGD 项和一个线性地址的操作返回相应的 PMD。pte_offset() 通过对一个 PMD 的操作返回相应的 PTE。线性地址剩下的部分就是在此页面内的偏移量部分。这些字段之间的关系如图 3.1 所示。
第 2 组宏决定了一个页表项是否存在或者是否正在使用中。
- pte_none(), pmd_none() 和 pgd_none() 在相应的项不存在时返回 1。
- pte_present(), pmd_present() 和 pgd_present() 在相应的页表项的 PRESENT 位被置位时返回 1。
- pte_clear(),pmd_clear() 和 pgd_clear() 将相应的页表项清空。
- pmd_bad() 和 pgd_bad() 用于在页表项作为函数的参数传递时并且这个函数有可能改变这个表项的值时,对页表项进行检查。它们是否返回 1 需要看某些体系结构是如何定义这些宏。即便是很明确地定义了这些宏,也应确保页面项被设置为存在并被访问。
3.4 页表项的转换和设置
下面的函数和宏用于映射地址和页面到 PTE,并设置个别的项。
宏 mk_pte() 用于把一个 struct page 和一些保护位合成一个 pte_t,以便插入到页表当中。另一个宏 mk_pte_phys() 也具有类似的功能,它将一个物理页面的地址作为参数。
宏 pte_page() 返回一个与 PTE 项相对应的 struct page。pmd_page() 则返回包含页表项集的 struct page。
宏 set_pte() 把诸如从 mk_pte 返回的一个 pte_t 写到进程的页表里。pte_clear() 则是反向操作。此外还有一个函数 ptep_get_and_clear() 用于清空进程页表的一个项并返回 pte_t。不论是对 PTE 的保护还是 struct page 本身,一旦需要修改它们时这个工作则是很重要的。
3.5 页表的分配和释放
最后一组函数用于对页表进行分配和释放。如前所述,页表是一些包含一个项数组的物理页面。而分配和释放物理页面的工作,相对而言代价很高,这不仅体现在时间上,还体现在页面分配时是关中断的。无论在 3 级的哪一级,分配已经被删除页表的操作都是非常频繁的,所以要求这些操作尽可能地快很重要。
于是,这些物理页面被缓存在许多被称作快速队列的不同队列里。不同的结构实现它们虽都有所不同,但原理相同。例如,并不是所有的系统都会缓存 PGD,因为对它们的分配和释放只发生在进程创建和退出的时候。因为这些操作花费的代价较大,其他页面的分配就显得微不足道了。
PGD,PMD 和 PTE 有两组不同的函数分别用作分配页表和释放页表。分配的函数有 pgd_alloc(),pmd_alloc() 和 pte_alloc(),相对应的释放函数有 pgd_free(),pmd_free() 和 pte_free()。
具体而言,存在三种不同的用作缓冲的高速缓存,分别称为 pgd_quicklist, pmd_quicklist 以及 pte_quicklist。不同的结构实现它们的方式虽都有所不同,但都使用了一种叫做后入先出(LIFO)的结构。一般而言,一个页表项包含着很多指向其他包括页表或数据的页面的指针。当队列被缓存时,队列里的第一个元素将指向下一个空闲的页表。在分配时,这个队列里最后进入的元素将被弹出来,而在释放时,一个元素将被放入这个队列中作为新的队列首部。
使用一个计数器对这个高速缓存中所包含的页面数量进行计数。
虽然可以选用 get_pgd_fast() 作为 pgd_quicklist 上快速分配函数的名称,但 Linux 并未独立于体系结构对它进行显式的定义。PMD 和 PTE 的缓存分配函数明确地定义为 pmd_alloc_one_fast() 和 pte_alloc_one_fast()。
如果高速缓存中没有多余的页面,页面的分配将通过物理页面分配器(见第 6 章)完成。分别对应 3 级的函数为 get_pgd_slow(),pmd_alloc_one() 以及 pte_alloc_one()。
显然,在这些高速缓存中可能有大量的页面存在,所以应当有一种机制来管理高速缓存的空间。每当高速缓存增大或收缩时,就通过一个计数器增大或减小来计数,并且该计数器有最大值和最小值。在两个地方中可以调用 check_pgt_cache() 检查这些极值。当计数器达到最大值时,系统就会释放一些高速缓存里的项,直到它重新到达最小值。在调用 clear_page_tables() 后,在可能有大量的页表到达时,系统就会调用 check_pgt_cache(),这个函数同时也可以被系统的空闲任务所调用。
3.6 内核页表
3.6.1 引导初始化
在 arch/i386/kernel/head.S 的汇编程序中的函数 startup_32() 主要用于开启页面单元。由于所有在 vmlinuz 中的普通内核代码都编译成以 PAGE_OFFSET+1 MB 为起始地址,实际上系统将内核装载到以第一个 **1MB(0x00100000)**为起始地址的物理空间中。第一个 1MB 的地址常在一些设备用作和 BIOS 进行通讯的地方自行跳过。该文件中的引导初始化代码总是把虚拟地址减去 __PAGE_OFFSET,从而能获得以 1 M 为起始地址的物理地址。所以在开启换页单元以前,必须首先建立相应的页表映射,从而将 8 MB 的物理空间转换为虚拟地址 PAGE_OFFSET。
初始化工作在编译过程中开始进行,它先静态地定义一个称为 swapper_pg_dir 的数组,使用链接器指示在地址 0x00101000。然后分别为两个页面 pg0 和 pg1 创建页表项。如果处理器支持页面大小拓展(PSE)位,那么该位将被设置,使得调动的页面大小是 4 MB,而不是通常的 4 KB。第一组指向 pg0 和 pg1 的指针被放在能覆盖 1~9 MB 内存空间的位置;而第二组则被放置在 PAGE_OFFSET+1 MB 的位置。这样一旦开启换页,在上述页表及页表项指针建立后系统可以保证,在内核映象中不论是采用物理地址还是采用虚拟地址,页面之间的映射关系都是正确的。
映射建立后,系统通过对 cr0 寄存器某一位置位开启换页单元,接着通过一个跳跃指令保证指令指针(EIP 寄存器)的正确性。
3.6.2 收尾工作
用于完成页表收尾工作的对应函数是 paging_init()。x86 上该函数的调用图如图 3.4 所示。
系统首先调用 pagetable_init() 初始化对应于 ZONE_DMA 和 ZONE_NORMAL 的所有物理内存所必要的页表。注意在 ZONE_HIGHMEM 中的高端内存不能被直接引用,对其的映射都是临时建立起来的。对于内核所使用的每一个 pgd_t,系统会调用引导内存分配器(见第 5 章)来分配一个页面给 PGD。若可以使用 4 MB 的 TLB 项替换 4 KB,则 PSE 位将被设置。如果系统不支持 PSE 位,那么系统会为每个 pmd_t 分配一个针对 PTE 的页面。若 CPU 支持 PGE 标志位,系统也会设置该标志以使得页表项是全局性的,以对所有进程可见。
接下来,pagetable_init() 函数调用 fixrange_init() 建立固定大小地址空间,以映射从虚拟地址空间的尾部即起始于 FIXADDR_START 的空间。映射用于局部高级编程中断控制器(APIC),以及在 FIX_KMAP_BEGIN 和 FIX_KMAP_END 之间 kmap_atomic() 的原子映射。最后,函数调用 fixrange_init() 初始化高端内存映射中 kmap() 函数所需要的页表项。
在 pagetable_init() 函数返回后,内核空间的页表则完成了初始化,此时静态 PGD(swapper_pg_dir)被载入 CR3 寄存器中,换页单元可以使用静态表。
paging_init() 接下来的工作是调用 kmap_init() 初始化带有 PAGE_KERNEL 标志位的每个 PTE。最终的工作是调用 zone_sizes_init(),用于初始化所有被使用的管理区结构。
3.7 地址和struct page之间的映射
对 Linux 而言,必须有一种快速的方法把虚拟地址映射到物理地址或把 struct page 映射到它们的物理地址。Linux 为实现该机制,在虚拟和物理内存使用了一个 mem_map 的全局数组,因为这个数组包含着指向系统物理内存所有 struct page 的指针。所有的结构都采用了非常相似的机制,为了表述简单,在这里我们只详细讨论 x86 结构中的情况。本节我们首先讨论物理地址如何被映射到内核虚拟地址,以及又是如何利用 mem_map 数组的。
3.7.1 物理和虚拟内核地址之间的映射
正如在 3.6 节看到的,在 x86 中 Linux 把从 0 开始的物理地址直接映射成从 PAGE_OFFSET 即 3GB 开始的虚拟地址。这意味着在 x86 上,可以简单地将任意一个虚拟地址减去 PAGE_OFFSET 而获得其物理地址,就像函数 virt_to_phys() 和宏 __pa() 所做的那样:
/* from <asm-i386/page.h> */
# define __pa(x)((unsigned long)(x)-PAGE_OFFSET)
/* from <asm-i386/io.h */
static inline unsigned long virt_to_phys(volatile void * address)
{
return __pa(address);
}
很明显,逆操作只需简单加上 PAGE_OFFSET 即可,它通过 phys_to_virt() 和宏 __va() 完成。接下来我们将看到内核如何利用这些功能将 struct pages 映射成物理地址。
有一个例外,就是 virt_to_phys() 不能用于将虚拟地址转换成物理地址。尤其是在 PPC 和 ARM 的结构中,virt_to_phys 不能转换由 consistent_alloc() 函数返回的地址。在 PPC 和 ARM 结构中,使用 consistent_alloc() 函数从无缓冲的 DMA 返回内存。
3.7.2 struct page和物理地址间的映射
正如在 3.6.1 所看到的一样,系统将内核映象装载到 1 MB 物理地址起始位置,当然,这个物理地址就是虚拟地址 PAGE_OFFSET + 0x00100000。此外,物理内存为内核映象预留了 8 MB 的虚拟空间,这个空间可以被 2 个 PGD 所访问到。这好像意味着第一个有效的内存空间应在 0xC0800000 开始的地方,但事实并非如此。Linux 还为 ZONE_DMA 预留了 16 MB 的内存空间,所以真正能被内核分配使用的内存起始位置应在 0xC1000000,这个位置即为全局量 mem_map 所在的位置。虽然 Linux 还使用 ZONE_DMA,但是只会在非常有必要的情况下使用。
通过把物理地址作为 mem_map 里的一个下标,从而将其转换成对应的 struct pages。通过把物理地址位右移 PAGE_SHIFT 位,从而将右移后的物理地址作为从物理地址 0 开始的页面帧号 (PFN),它同样也是 mem_map 数组的一个下标 。 正如宏 virt_to_page() 所做的那样,其声明在 <asm-i386/pae.h> 中如下:
#define virt_to_page(kaddr)(mem_map +(__pa(kaddr)>> PAGE_SHIFT))
宏 virt_to_page() 通过 __pa() 把虚拟地址 kaddr 转换成物理地址,然后再通过右移 PAGE_SHIFT 位转换成 mem_map 数组的一个下标,接着通过简单的加法操作就可以在 mem_map 中查找它们。Linux 中不存在将页面转换成物理地址的宏,但你应该知道如何计算。
3.8 转换后援缓冲区(TLB)
最初,当处理器需要映射一个虚拟地址到一个物理地址时,需要遍历整个页面目录以搜索相关的 PTE。通常这表现为每个引用内存的汇编指令实际上需要多个页表截断的相分离的内存引用 [Tan01]。为了避免这种情况的过度出现,许多体系结构中都利用了这样一个事实,就是大多数的进程都是采用局部引用,或者,换句话说,少量的页面却使用了大量的内存引用。它们提供一个转换后援缓冲区(TLB)来利用这种引用的局部性原理,这个高速缓存是一个联合内存,用来缓存虚拟到物理页表转换的中间结果。
Linux 假设大多数的体系结构都是支持 TLB 的,即便独立于体系结构的代码并不关心它如何工作。相反,与体系结构相关的钩子都分散在 VM 的代码中,大家知道,一些带有 TLB 的硬件是需要提供一个 TLB 相关的操作的。 例如,在页表更新后,诸如在一个页面错误发生时,处理器可能需要更新 TLB 以满足虚拟地址的映射要求。
不是所有的体系结构都需要这种类型的操作,但是,因为有些体系结构是需要的,所以 Linux 中就需要存在钩子。 如果某个体系结构并不需要诸如此类的操作,那么在这个体系结构中完成 TLB 操作的函数就是一个空函数,这在编译时就进行过优化。
大部分关于 TLB 的 API 钩子列表都在 <asm/pgtable.h> 中声明,如表 3.2 和表 3.3 所列,在内核源码的 Documentation/cachetlb.txt[Miloo] 文件已写明了这些 API。在某些情况下可能仅只有一个 TLB 刷新操作,但由于 TLB 刷新操作和 TLB 填充工作都是开销非常大的操作,所以应尽可能避免不必要的 TLB 刷新操作。例如,切换上下文时,Linux 会使用延迟 TLB 刷新以避免载入新的页表,这将在 4.3 节作进一步的讨论。
3.9 一级CPU高速缓存管理
3.10 2.6中有哪些新特性
- MMU-less 体系结构的支持
- 反向映射
- 基于对象的反向映射
- 高端内存中的 PTE
- 大型 TLB 文件系统
- 高速缓存刷新管理
第4章 进程地址空间
4.1 线性地址空间
从用户的角度来看,地址空间是一个平坦的线性地址空间,但从内核的角度来看却大不一样。地址空间分为两个部分:一个是随上下文切换而改变的用户空间部分,一个是保持不变的内核空间部分。两者的分界点由 PAGE_OFFSET 决定,在 x86 中它的值是 0xC0000000(3G)。这意味着有 3 GB 的空间可供用户使用,与此同时,内核可以映射剩余的 1 GB 空间。内核角度的线性虚拟地址空间如图 4.1 所示。
系统为了载入内核映象,需要保留从 PAGE_OFFSET 开始的 8 MB (两个 PGD 定位的内存大小)空间,这 8 MB 只是为载入内核而保留的合适空间。如 3.6.1 小节所述,内核映象在内核页表初始化时被放置到此保留的 8 MB 空间内,紧随其后的是供 UMA 体系结构使用的 mem_map 数组,这已经在第 2 章 中讨论过。该数组通常位于标记为 16 MB 的位置,但为避免用到 ZONE_DMA,也不是经常这样。对于 NUMA 体系结构,虚拟 mem_map 各部分分散在该区域内,各部分所在具体位置由不同的体系结构决定。
// include/asm-i386/pgtable.h
#define VMALLOC_OFFSET (8*1024*1024)
#define VMALLOC_START (((unsigned long) high_memory + 2*VMALLOC_OFFSET-1) & \
~(VMALLOC_OFFSET-1))
#define VMALLOC_VMADDR(x) ((unsigned long)(x))
#define VMALLOC_END (FIXADDR_START)
// include/asm-i386/fixmap.h
#define FIXADDR_TOP (0xffffe000UL)
#define FIXADDR_SIZE (__end_of_fixed_addresses << PAGE_SHIFT)
#define FIXADDR_START (FIXADDR_TOP - FIXADDR_SIZE)
// include/asm-i386/page.h
#define PAGE_SHIFT 12
#define __PAGE_OFFSET (0xC0000000)
// arch/i386/kernel/setup.c
/*
* 128MB for vmalloc and initrd
*/
#define VMALLOC_RESERVE (unsigned long)(128 << 20)
从 PAGE_OFFSET 到 VMALLOC_START - VMALLOC_OFFSET 是物理内存映射的部分。这个区域的大小由可用 RAM 的大小决定。正如我们将在 4.6 节所看到的,它通过页表项把物理内存映射到 PAGE_OFFSET 开始的虚拟地址。为防止边界错识,在物理内存映射和 vmalloc 地址空间之间存在一个大小为 VMALLOC_OFFSET 的空隙。在 x86 上,这个空间大小为 8 MB。例如,在一个 RAM 大小为 32 MB 的 x86 系统上,VMALLOC_START 等于 PAGE_OFFSET + 0x02000000(32M) + 0x00800000(8M)。
在小内存的系统中,为了能使 vmalloc() 在一个连续的虚拟地址空间里表示一个非连续的内存分配情况,余下的虚拟地址空间减去 2 个页面空隙的大小将全部用于 vmalloc()。而在大内存系统中,vmalloc 的区域则扩大到 PKMAP_BASE 减去 2 个页面空隙的大小,此外还引入 2 个区域。第 1 个是从 PKMAP_BASE 开始的区域,这部分保留给 kmap() 使用,而 kmap() 的作用是把高端内存页面映射到低端内存,如第 9 章所述。第 2 个区域是从 FXADDR_STAT 至 FIXADDR_TOP 的固定虚拟地址映射区域,这个区域供在编译时需要知道虚拟地址的子系统使用,例如高级可编程的中断控制器(APIC)。FIXADDR_TOP 在 x86 中静态地定义为 0xFFFFE000,这个位置在虚拟地址空间结束的前一页上。固定映射区域的大小通过在编译时的 __FIXADDR_SIZE 变量计算,再从 FIXADDR_TOP 向后索引 __FIXADDR_SIZE 大小,从而标识 FIXADDR_START 区域的起始地址。
vmalloc(),kmap() 以及固定映射区域所需的区域大小限制了 ZONE_NORMAL 的大小。由于运行中的内核需要这些函数,所以在地址空间的顶端至少需要保留 VMALLOC_RESERVE 大小的区域。VMALLOC_RESERVE 在每个体系结构中都有所不同,在 x86 中它是128 MB。这正是 ZONE_NORMAL 大小通常只有 896 MB 的原因。vmalloc 区域由线性地址空间上端 1 GB 空间大小减去保留的 128 MB 区域所得。
4.2 地址空间的管理
进程可使用的地址空间由 mm_struct 管理,它类似于 BSD 中的 vmspace 结构[McK96]。
每个进程地址空间中都包含许多使用中的页面对齐的内存区域。它们不会相互重叠,而
且表示了一个地址的集合,这个集合包含那些出于保护或其他目的而相互关联的页面。这些区域由 struct vm_area_struct 管理,它们类似于 ESD 中的 vm_map_entry 结构。具体而言,一个区域可能表示 malloc() 所使用的进程堆,或是一个内存映射文件(例如共享库),又或是一块由 mmap() 分配的匿名内存区域。这些区域中的页面可能还未被分配,或已分配,或常驻内存中又或已被交换出去。
如果一个区域是一个文件的映象,那么它的 vm_file 字段将被设置。通过查看 vm_file→f_dentry→d_inode→i_mapping 可以获得这段区域所代表的地址空间内容。这个地址空间包含所有与文件系统相关的特定信息,这些信息都是为了实现在磁盘上进行基于页面的操作。
图 4.2 中图示了各种地址空间相关结构之间的关联。表 4.1 则列举了许多影响地址空间
和区域的系统调用。
4.3 进程地址空间描述符
进程地址空间由 mm_struct 结构描述,这意味着一个进程只有一个 mm_struct 结构,且该结构在进程用户空间中由多个线程共享。事实上,线程正是通过任务链表里的任务是否指
向同一个 mm_struct 来判定的。
内核线程不需要 mm_struct,因为它们永远不会发生缺页中断或访问用户空间。惟一的例外是 vmalloc 空间的缺页中断。缺页中断的处理代码认为该例外是一种特殊情况,并借助主页表中的信息更新当前页表。由于内核线程不需要 mm_struct,故 task_struct->mm 字段总为 NULL。对某些任务如引导空闲任务 ,mm_struct 永远不会被设置,但对于内核线程而言,调用 daemonize() 也会调用 exit_mm() 以减少对它的使用计数。
由于 TLB 的刷新需要很大的开销,特别是像在 PPC 这样的体系结构中,由于地址空间的内核部分对所有进程可见,那些未访问用户空间的进程所做的 TLB 刷新操作就是无效的,而 Linux 采用了一种叫 “延迟 TLB” 的技术避免了这仲刷新操作。Linux 通过借用前个任务的 mm_struct,并放入 task_struct->active_mm 中,避免了调用 switch_mm() 刷新 TLB。这种技术在上下文切换次数上取得了很大的进步。
进入延迟 TLB 时,在对称多处理机(Symmetric Multiprocessing,SMP)上,系统会调用 enter_lazy_tlb() 函数以确保 mm_struct 不会被 SMP 的处理器所共享。而在 UP 机器上这是一个空操作。第二次用到延迟 TLB 是在进程退出时,系统会在该进程等待被父进程回收时,调用 start_lazy_tlb() 函数。
该结构有两个引用计数,分别是 mm_users 和 mm_count。mm_users 描述存取这个 mm_struct 用户空间的进程数,存取的内容包括页表、文件的映象等。例如,线程以及 swap_out() 代码会增加这个计数以确保 mm_struct 不会被过早地释放。当这个计数值减为 0 时,exit_mmap() 会删除所有的映象并释放页表,然后减小 mm_count 值。
mm_count 是对 mm_sturct 匿名用户的计数。初始化为 1 则表示该结构的真实用户。匿名用户不用关心用户空间的内容,它们只是借用 mm_struct 的用户。例子用户是使用延迟 TLB 转换的核心线程。当这个计数减为 0 时,就可安全释放掉 mm_struct。存在两种计数是因为匿名用户需要 mm_struct,即便 mm_struct 中的用户映象已经被释放。但它不会延迟页表的释放操作。
// include/linux/sched.h
struct mm_struct {
struct vm_area_struct * mmap;
rb_root_t mm_rb;
struct vm_area_struct * mmap_cache;
pgd_t * pgd;
atomic_t mm_users;
atomic_t mm_count;
int map_count;
struct rw_semaphore mmap_sem;
spinlock_t page_table_lock;
struct list_head mmlist;
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long rss, total_vm, locked_vm;
unsigned long def_flags;
unsigned long cpu_vm_mask;
unsigned long swap_address;
unsigned dumpable: 1;
/ * Architecture-specific MM context * /
mm_context_context;
};
该结构中各个字段的含义如下。
- mmap VMA:地址空间中所有 VMA 的链表首部。
- mm_rb VMA:VMA 都排列在一个链表中,且存放在一个红黑树中以加快查找速度。该字段表示树的根部。
- mmap_cache:最后一次通过 find_vma() 找到的 VMA 存放处,前提假设是该区可能会被再次用到。
- pgd:全局目录表的起始地址。
- mm_users:访问用户空间部分的用户计数值,本节已经介绍过。
- mm_count:匿名用户计数值。访问 mm_struct 的匿名用户技术值。本节已经介绍过。数值 1 针对真实用户。
- map_count:正被使用中的 vma 的数量。
- mmap_sem:这是一个读写保护锁并长期有效。因为用户需要这种锁作长时间的操作或者可能进入睡眠,所以不能用自旋锁。一个读操作通过 down_read() 来获得这个信号量。如果需要进行写操作,则通过 down_write() 获得该信号量,并在 VMA 链表更新后,获得 page_table_lock 锁。
- page_table_lock:该锁用于保护 mm_struct 中大部分字段,与页表类似,它防止驻留集大小(Resident Set Size,RSS)(见 rss)计数和 VMA 被修改。
- mmlist:所有的 mm_struct 结构通过它链接在一起。
- start_code,end_code:代码段的起始地址和结束地址。
- start_data,end_data:数据段的起始地址和结束地址。
- start_brk,brk:堆的起始和结束地址。
- start_stack:栈的起始地址。
- arg_start,arg_end:命令行参数的起始地址和结束地址。
- env_start,env_end:环境变量区域的起始和结束地址。
- rss:驻留集的大小是该进程常驻内存的页面数,注意,全局零页面不包括在 RSS 计数之内。
- total_vm:进程中所有 vma 区域的内存空间总和。
- locked_vm:内存中被锁住的常驻页面数。
- def_flags:只有一种可能值,VM_LOCKED。它用于指定在默认情况下将来所有的映射是上锁还是未锁。
- cpu_vm_mask:代表 SMP 系统中所有 CPU 的掩码值,内部处理器中断(IPI)用这个值来判定一个处理器是否应执行一个特殊的函数。这对于每一个 CPU 的 TLB 刷新很重要。
- swap_address:当换出整个进程时,页换出进程记录最后一次被换出的地址。
- dumpable:由 prctl() 设置,只有在跟踪一个进程时,这个字段才有用。
- context:跟体系结构相关的 MMU 上下文。
对 mm_struct 结构进行操作的函数描述如表 4.2 所列。
4.3.1 分配一个描述符
系统有两个函数用于分配 mm_struct 结 构。它们本质上相同,但有一个重要的区别。allocate_mm() 只是一个预处理宏,它从 slab allocator (见第 8 章) 中分配一个 mm_struct。而 mm_alloc() 从 slab 中分配,然后调用 mm_init() 函数对其初始化。
4.3.2 初始化一个描述符
系统中第一个 mm_struct 通过 init_mm() 初始化。因为后继的子 mm_struct 都通过复制进行设置,所以第 1 个 mm_struct 在编译时静态设置,通过宏 INIT_MM(完成设置。
第 1 个 mm_struct 创建后,系统将该 mm_struct 作为一个模板来创建新的 mm_struct。copy_mm() 函数完成复制操作,它调用 init_mm() 初始化与具体进程相关的字段。
4.3.3 销毁一个描述符
新的用户通过 atomic_inc(&mm->mm_users) 增加使用计数,同时通过 mmput() 减少该计数。如果 mm_users 变成 0,所有的映射区域通过 exit_mmap() 释放,同时释放页表,因为已经没有用户使用这个用户空间。mm_count 之所以通过 mmdrop() 减 1,是因为所有页表和 VMA 的使用者都被看成是一个 mm_struct 用户。在 mm_count 变成 0 时,mm_struct 会被释放。
// kernel/fork.c
void mmput(struct mm_struct *mm)
{
if (atomic_dec_and_lock(&mm->mm_users, &mmlist_lock)) {
list_del(&mm->mmlist);
spin_unlock(&mmlist_lock);
exit_mmap(mm);
mmdrop(mm);
}
}
4.4 内存区域
进程的地址空间很少能全部用满,一般都只是用到了其中一些分离的区域。区域由 vm_area_struct 来表示。区域之间不会交叉,它们各自代表一个有着相同属性和用途的地址集
合。如一个被装载到进程堆空间的只读共享库就包含在这样的一个区域。一个进程所有已被映射的区域都可以在 /proc/PID/maps 里看到,其中 PID 是该进程的进程号。
一个区域可能有许多与它相关的结构,如图 4.2 所示。在图的顶端,有一个 vm_area_struct 结构,它足以用来表示一个匿名区域。
如果一个文件被映射到内存,则可以通过 vm_file 字段得到 struct file。 vm_file 字段有一个指针指向 struct inode,索引节点用于找到 struct address_space,而 struct address_space 中包含与文件有关的所有信息,包括一系列指向与文件系统相关操作函数的指针,如读磁盘页面和写磁盘页面的操作。
vm_area_struct 结构在 <linux/mm.h> 中声明如下:
struct vm_area_struct {
struct mm_struct * vm_mm; /* The address space we belong to. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next;
pgprot_t vm_page_prot; /* Access permissions of this VMA. */
unsigned long vm_flags; /* Flags, listed below. */
rb_node_t vm_rb;
/*
* For areas with an address space and backing store,
* one of the address_space->i_mmap{,shared} lists,
* for shm areas, the list of attaches, otherwise unused.
*/
struct vm_area_struct *vm_next_share;
struct vm_area_struct **vm_pprev_share;
/* Function pointers to deal with this struct. */
struct vm_operations_struct * vm_ops;
/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units, *not* PAGE_CACHE_SIZE */
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_raend; /* XXX: put full readahead info here. */
void * vm_private_data; /* was vm_pte (shared mem) */
};
下面是对该结构中字段的简要解释。
-
vm_mm:这个 VMA 所属的 mm_struct。
-
vm_start:这个区域的起始地址。
-
vm_end:这个区间的结束地址。
-
vm_next:在一个地址空间中的所有 VMA 都按地址空间次序通过该字段简单地链接在一起。可以很有趣地发现该 VMA 链表在内核中所使用的单个链表中是非常少见的。
-
vm_page_prot:这个 VMA 对应的每个 PTE 里的保护标志位。其不同的位在表 3.1 中
有描述。 -
vm_flags:这个 VMA 的保护标志位和属性标志位。它们都定义在 <linux/mm.h> 中,
描述见表 4.3。
-
vm_rb:同链表一样,所有的 VMA 都存储在一个红黑树上以加快查找速度。这对于在发生缺页时能快速找到正确的区域非常重要,尤其是大量的映射区域。
-
vm_next_share:这个指针把由文件映射而来的 VMA 共享区域链接在一起(如共享库)。
-
vm_pprev_share:vm_next_share 的辅助指针。
-
vm_ops:包含指向与磁盘作同步操作时所需函数的指针。vm_ops 字段包含有指向 open(),close() 和 nopage() 的函数指针。
-
vm_pgoff:在已被映射文件里对齐页面的偏移。
-
vm_file:指向被映射的文件的指针。
-
vm_raend:预读窗口的结束地址。在发生错误时,一些额外的页面将被收回,这个值决定。
了这些额外页面的个数。 -
vm_private_data:一些设备驱动私有数据的存储,与内存管理器无关。
所有的区域按地址排序由指针 vm_next 链接成一个链表。寻找一个空闲的区间时,只需要遍历这个链表即可。但若在发生缺页中断时搜索 VMA 以找到一个指定区域,则是一个频繁操作。正因为如此,才有了红黑树,因为它平均只需要 O(logN) 的遍历时间。红黑树通过排序使得左结点的地址小于当前结点的地址,而当前结点的地址又小于右结点的地址。
4.4.1 内存区域的操作
VMA 提供三个操作函数,分别是 open(),close() 和 nopage() 。VMA 通过类型 vm_operations_struct 的 vma->vm_ops 提供上述几个操作函数 。该结构包含三个函数指针,它在 <linux/mm.h> 中的声明如下所示:
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
struct page * (*nopage)(struct vm_area_struct * area, unsigned long address,
int unused);
};
每当创建或者删除一个区域时系统会调用 open() 和 close() 函数。只有一小部分设备使用这些操作函数,如文件系统和 system v 的共享区域。在打开或关闭区域时,需要执行额外的操作。例如 ,system V 中 open() 回调函数会递增使用共享段的 VMA 的数量。
我们关心的主要操作函数是 nopage() 回调函数,在发生缺页中断时 do_no_page() 会使用该回调函数。该回调函数负责定位该页面在高速缓存中的位置,或者分配一个新页面并填充请求的数据,然后返回该页面。
大多数被映射的文件会用到名为 generic_file_vm_ops 的 vm_operations_struct() 。它只注册一个函数名为 filemap_nopage() 的 nopage() 函数。该 nopage() 函数或者定位该页面在页面高速缓存中的位置,或者从磁盘上读取数据。该结构在 mm/filemap.c 中声明如下:
static struct vm_operations_struct generic_file_vm_ops = {
nopage: filemap_nopage,
};
4.4.2 有后援文件/设备的内存区域
如表 4.2 所列,在有后援文件的区域中,vm_file 引出了相关的 address_space。address_space 结构包含一些与文件系统相关的信息,如必须写回到磁盘的脏页面数目 。该结构在 <linux/fs.h> 中声明如下:
struct address_space {
struct list_head clean_pages; /* list of clean pages */
struct list_head dirty_pages; /* list of dirty pages */
struct list_head locked_pages; /* list of locked pages */
unsigned long nrpages; /* number of total pages */
struct address_space_operations *a_ops; /* methods */
struct inode *host; /* owner: inode, block_device */
struct vm_area_struct *i_mmap; /* list of private mappings */
struct vm_area_struct *i_mmap_shared; /* list of shared mappings */
spinlock_t i_shared_lock; /* and spinlock protecting it */
int gfp_mask; /* how to allocate the pages */
};
各字段简要描述如下。
- clean_pages:不需要后援存储器同步的干净页面链表。
- dirty_pages:需要后援存储器同步的脏页面链表。
- locked_pages:在内存中被锁住的页面链表。
- nrpages:在地址空间中正被使用且常驻内存的页面数。
- a_ops:是一个操纵文件系统的函数结构。每一个文件系统都提供其自身的 address_space_operations,即便在某些时候它们使用普通的函数。
- host:这个文件的索引节点。
- i_mmap:使用 address_space 的私有映射链表。
- i_mmap_shared:该地址空间中共享映射的 VMA 链表。
- i_shared_lock:保护此结构的自旋锁。
- gfp_mask:调用 __alloc_pages() 所要用到的掩码。
内存管理器需要定期将信息写回磁盘。但是内存管理器并不知道也不关心信息如何写回到磁盘,因此需要 a_ops 结构来调用相关的函数。它在 <linux/fs.h> 中的声明如下所示:
struct address_space_operations {
int (*writepage)(struct page *);
int (*readpage)(struct file *, struct page *);
int (*sync_page)(struct page *);
/*
* ext3 requires that a successful prepare_write() call be followed
* by a commit_write() call - they must be balanced
*/
int (*prepare_write)(struct file *, struct page *, unsigned, unsigned);
int (*commit_write)(struct file *, struct page *, unsigned, unsigned);
/* Unfortunately this kludge is needed for FIBMAP. Don't use it */
int (*bmap)(struct address_space *, long);
int (*flushpage) (struct page *, unsigned long);
int (*releasepage) (struct page *, int);
#define KERNEL_HAS_O_DIRECT /* this is for modules out of the kernel */
int (*direct_IO)(int, struct inode *, struct kiobuf *, unsigned long, int);
#define KERNEL_HAS_DIRECT_FILEIO /* Unfortunate kludge due to lack of foresight */
int (*direct_fileIO)(int, struct file *, struct kiobuf *, unsigned long, int);
void (*removepage)(struct page *); /* called when page gets removed from the inode */
};
该结构中的字段都是函数指针,它们描述如下。
- writepage:把一个页面写到磁盘。写到文件里的偏移量保存在页结构中。寻找磁盘块的工作则由具体文件系统的代码完成,参考 buffer.c:block_write_full_page() 。
- readpage:从磁盘读一个页面。参考 buffer.c:block_read_full_page()。
- sync_page:同步一个脏页面到磁盘。参考 buffer.c :blobk_sync_page() 。
- prepare_write:在复制用户空间数据到将要写回磁盘的页面之前,调用此函数。对于一个日志文件系统,该操作保证了文件系统日志是最新的。而对于一般的文件系统而言,它也确保了分配所需要的缓冲区页面。参考 buffer.c:block_prepare_write() 。
- commit_write:在从用户空间复制数据之后,会调用该函数将数据提交给磁盘。参考 buffer.c:block_commit_write() 。
- bmap:映射一个磁盘块使得裸设备 I/O 操作能够执行。虽然它在后援为一个交换文件而非交换分区时也用于换出页面,但它还是主要与具体的文件系统有关。
- flushpage:该函数确保在释放一个页面之前已不存在等待该页面的 I/O 操作。参考 buffer.c:discard_bh_page() 。
- releasepage:该函数在释放掉页面之前将刷新所有与这个页面有关的缓冲区。参考 try_to_free_buffers()。
- direct_IO:该函数在对一个索引节点执行直接 I/O 时使用。由于 #define 的存在,因此外部模块可以决定在编译时间该函数是否可用,因为它仅仅在 2.4.21 中被引入。
- direct_fileIO:用于对 struct file 进行直接 I/O。并且,#define 也由于外部模块而存在,因为该 API 只在 2.4.22 中被引入。
- removepage:一个候选回调函数,当页面从页面高速缓存中由 remove_page_from_inode_queue() 移除时使用。
4.4.3 创建内存区域
系统调用 mmap() 为一个进程创建新的内存区域。x86 中,mmap() 会调用 sys_mmap2(),而 sys_mmap2() 进一步调用 do_mmap2(),三个函数都使用相同的参数。do_mmap2() 负责获得 do_mmap_pgoff() 所需要的参数。而 do_mmap_pgoff() 才是所有体系结构中创建新区域的主要函数。
do_mmap2() 首先清空 flags 参数中的 MAP_DENYWRITE 和 MAP_EXECUTABLE 位,因为 Linux 用不到这两个标志位,这一点在 mmap() 的操作手册里有说明。如果映射一个文件,do_mmap2() 将通过文件描述符查找到相应的 struct file,并在调用 do_mmap_pgoff() 前获得 mm_struct mmap_sem 信号量。
do_mmap_pgoff 首先做一些合法检查。它首先检查在文件或设备被映射时,相应的文件系统和设备的操作函数是否有效。然后,它检查需要映射的大小是否与页面对齐,并且保证
不会在内核空间创建映射。最后,它必须保证映射的大小不会超过 pgoff 的范围以及这个进程没有过多的映射区域。
这个函数的余下部分比较大,大致有以下几个步骤:
- 参数的合法检查。
- 找出内存映射所需的空闲线性地址空间。如果系统提供了基于文件系统和设备的 get_unmapped_area() 函数,那么会调用它,否则将使用 arch_get_unmapped_area() 函数。
- 获得 VM 标志位,并根据文件存取权限对它们进行检查。
- 如果在映射的地方有旧区域存在,系统会修正它,以便新的映射能用这一部分的区域。
- 从 slab 分配器里分配一个 vm_area_struct,并填充它的各个字段。
- 把新的 VMA 链接到链表中。
- 调用与文件系统或设备相关的 mmap() 函数。
- 更新数据并返回。
mmap
一文搞懂 mmap 涉及的所有内容
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
int munmap(void *addr, size_t length);
start:用户进程中要映射的用户空间的起始地址,通常为NULL(由内核来指定)
length:要映射的内存区域的大小
prot:期望的内存保护标志
flags:指定映射对象的类型
fd:文件描述符(由open函数返回)
offset:设置在内核空间中已经分配好的的内存区域中的偏移,例如文件的偏移量,大小为PAGE_SIZE的整数倍
返回值:mmap()返回被映射区的指针,该指针就是需要映射的内核空间在用户空间的虚拟地址
// arch/i386/kernel/sys_i386.c
asmlinkage long sys_mmap2(unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long pgoff)
{
return do_mmap2(addr, len, prot, flags, fd, pgoff);
}
static inline long do_mmap2(
unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long pgoff)
{
int error = -EBADF;
struct file * file = NULL;
flags &= ~(MAP_EXECUTABLE | MAP_DENYWRITE);
if (!(flags & MAP_ANONYMOUS)) {
file = fget(fd);
if (!file)
goto out;
}
down_write(¤t->mm->mmap_sem);
error = do_mmap_pgoff(file, addr, len, prot, flags, pgoff);
up_write(¤t->mm->mmap_sem);
if (file)
fput(file);
out:
return error;
}
// mm/mmap.c
unsigned long do_mmap_pgoff(struct file * file, unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags, unsigned long pgoff)
{
struct mm_struct * mm = current->mm;
struct vm_area_struct * vma, * prev;
unsigned int vm_flags;
int correct_wcount = 0;
int error;
rb_node_t ** rb_link, * rb_parent;
if (file && (!file->f_op || !file->f_op->mmap))
return -ENODEV;
if (!len)
return addr;
len = PAGE_ALIGN(len);
if (len > TASK_SIZE || len == 0)
return -EINVAL;
/* offset overflow? */
if ((pgoff + (len >> PAGE_SHIFT)) < pgoff)
return -EINVAL;
/* Too many mappings? */
if (mm->map_count > max_map_count)
return -ENOMEM;
/* Obtain the address to map to. we verify (or select) it and ensure
* that it represents a valid section of the address space.
*/
addr = get_unmapped_area(file, addr, len, pgoff, flags);
if (addr & ~PAGE_MASK)
return addr;
/* Do simple checking here so the lower-level routines won't have
* to. we assume access permissions have been handled by the open
* of the memory object, so we don't do any here.
*/
vm_flags = calc_vm_flags(prot,flags) | mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
/* mlock MCL_FUTURE? */
if (vm_flags & VM_LOCKED) {
unsigned long locked = mm->locked_vm << PAGE_SHIFT;
locked += len;
if (locked > current->rlim[RLIMIT_MEMLOCK].rlim_cur)
return -EAGAIN;
}
if (file) {
switch (flags & MAP_TYPE) {
case MAP_SHARED:
if ((prot & PROT_WRITE) && !(file->f_mode & FMODE_WRITE))
return -EACCES;
/* Make sure we don't allow writing to an append-only file.. */
if (IS_APPEND(file->f_dentry->d_inode) && (file->f_mode & FMODE_WRITE))
return -EACCES;
/* make sure there are no mandatory locks on the file. */
if (locks_verify_locked(file->f_dentry->d_inode))
return -EAGAIN;
vm_flags |= VM_SHARED | VM_MAYSHARE;
if (!(file->f_mode & FMODE_WRITE))
vm_flags &= ~(VM_MAYWRITE | VM_SHARED);
/* fall through */
case MAP_PRIVATE:
if (!(file->f_mode & FMODE_READ))
return -EACCES;
break;
default:
return -EINVAL;
}
} else {
vm_flags |= VM_SHARED | VM_MAYSHARE;
switch (flags & MAP_TYPE) {
default:
return -EINVAL;
case MAP_PRIVATE:
vm_flags &= ~(VM_SHARED | VM_MAYSHARE);
/* fall through */
case MAP_SHARED:
break;
}
}
/* Clear old maps */
munmap_back:
vma = find_vma_prepare(mm, addr, &prev, &rb_link, &rb_parent);
if (vma && vma->vm_start < addr + len) {
if (do_munmap(mm, addr, len))
return -ENOMEM;
goto munmap_back;
}
/* Check against address space limit. */
if ((mm->total_vm << PAGE_SHIFT) + len
> current->rlim[RLIMIT_AS].rlim_cur)
return -ENOMEM;
/* Private writable mapping? Check memory availability.. */
if ((vm_flags & (VM_SHARED | VM_WRITE)) == VM_WRITE &&
!(flags & MAP_NORESERVE) &&
!vm_enough_memory(len >> PAGE_SHIFT))
return -ENOMEM;
/* Can we just expand an old anonymous mapping? */
if (!file && !(vm_flags & VM_SHARED) && rb_parent)
if (vma_merge(mm, prev, rb_parent, addr, addr + len, vm_flags))
goto out;
/* Determine the object being mapped and call the appropriate
* specific mapper. the address has already been validated, but
* not unmapped, but the maps are removed from the list.
*/
vma = kmem_cache_alloc(vm_area_cachep, SLAB_KERNEL);
if (!vma)
return -ENOMEM;
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = protection_map[vm_flags & 0x0f];
vma->vm_ops = NULL;
vma->vm_pgoff = pgoff;
vma->vm_file = NULL;
vma->vm_private_data = NULL;
vma->vm_raend = 0;
if (file) {
error = -EINVAL;
if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
goto free_vma;
if (vm_flags & VM_DENYWRITE) {
error = deny_write_access(file);
if (error)
goto free_vma;
correct_wcount = 1;
}
vma->vm_file = file;
get_file(file);
error = file->f_op->mmap(file, vma);
if (error)
goto unmap_and_free_vma;
} else if (flags & MAP_SHARED) {
error = shmem_zero_setup(vma);
if (error)
goto free_vma;
}
/* Can addr have changed??
*
* Answer: Yes, several device drivers can do it in their
* f_op->mmap method. -DaveM
*/
if (addr != vma->vm_start) {
/*
* It is a bit too late to pretend changing the virtual
* area of the mapping, we just corrupted userspace
* in the do_munmap, so FIXME (not in 2.4 to avoid breaking
* the driver API).
*/
struct vm_area_struct * stale_vma;
/* Since addr changed, we rely on the mmap op to prevent
* collisions with existing vmas and just use find_vma_prepare
* to update the tree pointers.
*/
addr = vma->vm_start;
stale_vma = find_vma_prepare(mm, addr, &prev,
&rb_link, &rb_parent);
/*
* Make sure the lowlevel driver did its job right.
*/
if (unlikely(stale_vma && stale_vma->vm_start < vma->vm_end)) {
printk(KERN_ERR "buggy mmap operation: [<%p>]\n",
file ? file->f_op->mmap : NULL);
BUG();
}
}
vma_link(mm, vma, prev, rb_link, rb_parent);
if (correct_wcount)
atomic_inc(&file->f_dentry->d_inode->i_writecount);
out:
mm->total_vm += len >> PAGE_SHIFT;
if (vm_flags & VM_LOCKED) {
mm->locked_vm += len >> PAGE_SHIFT;
make_pages_present(addr, addr + len);
}
return addr;
unmap_and_free_vma:
if (correct_wcount)
atomic_inc(&file->f_dentry->d_inode->i_writecount);
vma->vm_file = NULL;
fput(file);
/* Undo any partial mapping done by a device driver. */
zap_page_range(mm, vma->vm_start, vma->vm_end - vma->vm_start);
free_vma:
kmem_cache_free(vm_area_cachep, vma);
return error;
}
4.4.4 查找已映射内存区域
4.4.5 查找空闲内存区域
映射内存时,先得获取足够大的空闲区域,get_unmapped_area() 就用于查找一个空闲区域。
调用图如图 4.4 所示,获取空闲区域并不复杂。get_unmapped_area() 有很多参数:struct file 表示映射的文件或设备;pgoff 表示文件的偏移量;address 表示请求区域的起始地址;length 表示请求区域的长度;flags 表示此区域的保护标志位。
如果映射的是设备,比如视频卡,就还要使用 f_op->get_unmapped_area()。这是因为设备或文件有额外的操作要求,而通用代码并不能完成额外的要求,比如映射的地址必须对齐到一个特殊的虚拟地址。
如果没有特殊的要求,系统将调用体系结构相关的函数 arch_get_unmapped_area() 。并不是所有的体系结构都提供自己的函数,这时将调用 mm/mmap.c 中提供的通用版本函数。
4.4.6 插入内存区域
4.4.7 合并邻接区域
如果文件和权限都匹配,Linux 一般使用函数 merge_segments() [Hac02] 合并相邻的内存区域。其目的是减少 VMA 的数量,因为大量的操作会导致创建大量的映射,如系统调用 sys_mprotect()。该合并操作的开销很大,它会遍历大部分的映射,接着被删掉,特别是存在大量的映射时,merge_segments() 将会花费很长时间。
目前与上述函数等价的函数是 vma_merge(),它仅在 2 个地方用到。第 1 个是在 sys_mmap() 中,当映射匿名区域时会调用它,因为匿名区域通常可以合并。第 2 个是在 do_brk() 中,给区域扩展一个新分配的区域后,应该将这 2 个区域合并,这时就调用 vma_merge()。
vma_merge() 不仅合并两个线性区,还检查现有的区域能否安全地扩展到新区域,从而无需创建一个新区域。在没有文件或设备映射且两个区域的权限相同时,一个区域才能扩展其他地方也可能合并区域,只不过没有显式地调用函数。第 1 个就是在修正区域过程中系统调用 sys_mprotect() 时,如果两个区域的权限一致,就要确定它们是否要合并。第 2 个是在 move_vma() 中,它很可能把相似的区域移到一起。
4.4.8 重映射和移动内存区域
系统调用 mremap() 用于扩展或收缩现有区域,由函数 sys_mremap() 实现。在一个区域扩展时有可能移动该区域,或者在一个区域将要与其他区域相交并且没有指定标志位 MREMAP_FIXED 时,也可能移动该区域。调用图如图 4.6 所示。
移动一个区域时,do_mremap() 将首先调用 get_unmapped_area(),找到一个足以容纳扩展后的映射空闲区域,然后调用 move_vma() 把旧 VMA 移到新位置。move_vma() 调用图如图 4.7 所示。
move_vma() 首先检查新区域能否和相邻的 VMA 合并。如果不能合并,将创建一个新 VMA,每次分配一个 PTE。然后调用 move_page_tables() (调用图如图 4.8 所示),将旧映射中所有页表项都复制过来。尽管可能有更好的移动页表的方法,但是采用这种方法使得错误恢复更直观,因为相对而言,回溯更直接。
页面的内容并没有被复制,而是调用 zap_page_range() 将旧映射中的所有页都交换出去或者删除,通常缺页中断处理代码会将辅存、文件中的页交换回至内存,或调用设备相关函数 do_nopage() 。
4.4.9 对内存区域上锁
4.4.10 对区域解锁
系统调用 munlock() 和 munlockall() 分别是相应的解锁函数,它们分别由 sys_munlock() 和 sys_munlockall() 实现。它们比上锁函数简单得多,因为不必做过多的检查,它们都依赖于同一个函数 do_mmap() 来修整区域。
4.4.11 上锁后修正区域
在上锁或解锁时,VMA 将受到 4 个方面的影响,每个必须由 mlock_fixup() 修正。 上锁可能影响到所有的 VMA 时,系统就要调用 mlock_fixuo_all() 进行修正。第 2 个条件是被锁住区域地址的起始值,由 mlock_fixup_start() 处理,Linux 需要分配一个新的 VMA 来映射新区域。第 3 个条件是被锁住区域地址的结束值,由 mlock_fixup_end() 处理。 最后,mlock_fixup_middle() 处理映射区域的中间部分,此时需要分配 2 个新的 VMA。
值得注意的是创建上锁的 VMA 时从不合并,即使该区域被解锁也不能合并。一般而言,已经锁住某个区域的进程没必要再次锁住同一区域,因为经常开销处理器计算能力来合并和
分裂区域是不值得的。
4.4.12 删除内存区域
do_munmap() 负责删除区域。它相对于其他的区域操作函数,比较简单,它基本 上可分为 3 个部分。第 1 部分是修整红黑树,第 2 部分是释放和对应区域相关的页面和页表项,第 3 部分是如果生成了空洞就修整区域。
为保证红黑树已排好序,所有要删除的 VMA 都添加到称为 free 的链表,然后利用 rb_erase() 从红黑树中删除。如果区域还存在,那么在后来的修整中将以它们的新地址添加到系统中。
接下来遍历 free 所指向的 VMA 链表,即使删除线性区的一部分,系统也会调用 remove_shared_vm_struct() 把共享文件映射删掉。再次说明,如果仅是部分分删除,在修整中也会重新创建。zap_page_range() 删掉所有与区域相关的页面,而部分删除则调用 unmap_fixup 处理。
最后调用 free_pgtables() 释放所有和对应区域相关的页表项。这里注意到页表项并没有彻底释放完很重要。它反而释放所有的 PGD 和页目录项,因此,如果仅有一半的 PGD 用于映射,则不需要释放页表项。这是因为释放少量的页表项和数据结构代价很大,而且这些结构很小,另外它们还可能会再次被使用。
4.4.13 删除所有的内存区域
进程退出时,必须删除与其 mm_struct 相关联的所有 VMA,由函数 exit_mmap() 负责操作。这是个非常简单的函数,在遍历 VMA 链表前将刷新 CPU 高速缓存,依次删除每一个 VMA 并释放相关的页面,然后刷新 TLB 和删除页表项,这个过程在代码注释中有详细描述。
4.5 异常处理
VM 中很重要的一个部分就是如何捕获内核地址空间异常,这并不是内核的 bug。这部分不讨论如何处理诸如除数为零的异常错误,仅关注由于页面中断而产生的异常。有两种情况会发生错误的引用。第 1 种情况是进程通过系统调用向内核传递了一个无效的指针,内核必须能够安全地陷入,因为开始只检查地址是否低于 PAGE_OFFSET。第 2 种情况是内核使用 copy_from_user() 或 copy_to_user() 读写用户空间的数据。
编译时,连接器将在代码段中的 __ex_table 处创建异常表,异常表开始于 __start_ex_table,结束于 __stop_ex_table。每个表项的类型是 exception_table_entry,由可执行点和修整子程序二者组成。在产生异常时,缺页中断处理程序不能处理,它调用 search_exception_table() 查看是否为引起中断的指令提供了修整子程序,若系统支持模块,还要搜索每个模块的异常表。
4.6 缺页中断
进程线性地址空间里的页面不必常驻内存。例如,进程的分配请求并被立即满足,空间仅保留为满足 vm_area_struct 的空间。其也非常驻内存页面的例子有,页面可能被交换到后援存储器,还有就是写一个只读页面。
和大多操作系统一样,Linux 采用请求调页技术来解决非常驻页面的问题。在操作系统捕捉到由硬件发出的缺页中断异常时,它才会从后援存储器中调入请求的页面。由后援存储器的特征可知,采取页面预取技术可以减少缺页中断[MM87],但是 Linux 在这方面相当原始。在将一个页面读入交换区时,swapin_readahead() 会预取该页面后多达 2page_cluster 的页面,并放置在交换区中。不幸的是,很快要用到的页面只有一次机会邻近交换区,从而导致预约式换页技术很低效。Linux 将采用适合应用程序的预约式换页策略[KMC02]。
有两种类型的缺页中断,分别是主缺页中断和次缺页中断。当要费时地从磁盘中读取数据时,就会产生主缺页中断,其他的就是次缺页中断,或者是轻微的缺页中断。Linux 通过字段 task_struct→maj_fault 和 task_struct→min_fault 来统计各自的数目。
4.6.1 处理缺页中断
4.6.2 请求页面分配
(1)处理匿名页面
如果 vm_area_struct→ vm_ops 字段没有被填充或者没有提供 nopage() 函数,则调用 do_anonymous_page() 处理匿名访问。只有两中处理方式,第一次读和第一次写。由于是匿名页面,第一次读很简单,因为不存在数据,所以系统一般使用映射 PTE 的全零页 empty_zero_page,并且 PTE 是写保护的,所以如果进程要写页面就发生另一个缺页中断。在 x86 中,函数 mem_init() 负责把全局零页面归零。
如果是第一次写页面,就调用 alloc_page() 分配一个由 clear_user_highpage() 用零填充的空闲页(见第 7 章)。假定成功地分配了这样一个页面,mm_struct 中的 Resident Set Size (RSS) 字段将递增;在一些体系结构中,为保证高速缓存的一致性,当一个页面插入进程空间时要调用 flush_page_to_ram() 。然后页面插入到 LRU 链表中,以后就可以被页面回收代码处理。最后需要更新进程的页表项来反映新映射。
(2)处理文件/设备映射页
如果被文件或设备映射,VMA 中的 vm_operation_struct 将提供 nopage() 函数。如果是文件映射,函数 filemap_nopage() 将替代 nopage() 分配一个页面并从磁盘中读取一个页面大小的数据。如果页面由虚文件映射而来,就使用函数 shmem_nopage() (见第 12 章)。每种设备驱动程序将提供不同的 nopage() 函数,内部如何实现对我们来说并不重要,只要知道该函数返回一个可用的 struct page 即可。
在返回页面时,要先做检查以确定分配是否成功,如果不成功就返回相应的错误。然后检查提前 COW 失效是否发生。如果是向页面写,而在受管 VMA 中没有包括 VM_SHARED 标志,就会发生提前 COW 失效。提前 COW 失效是指分配一个新页面,在减少 nopage() 返回页面的引用计数前就将数据交叉地复制过来。
4.6.3 请求换页
4.6.4 写时复制(COW)页
4.7 复制到用户空间/从用户空间复制
4.8 2.6中有哪些新特性
线性地址空间基本上保持了与 2.4 版本中相同的内容,几乎没有什么可以容易识别的变更。主要的变化是在用户空间增加一个新的页面以映射到固定的虚拟地址。在 x86 上,该页面位于 0xFFFFF000 处称为 vsyscall 页。该页中的代码提供了从用户空间进入内核空间最理想的方法。一个用户空间的程序现在可以通过调用 0xFFFFF000 来代替传统的 int 0x80 中断进入内核空间。
struct mm_struct 这个结构没有很重大的变化。首先的变化是结构中新增加了一个 free_area_cache 字段,该字段初始化为 TASK_UNMAPPED_BASE。这个字段用于标记第一个空洞在线性地址空间中的位置,以改善搜索的时间开销。在该结构的尾部新增加了少量的字段,这与内核转储有关,但已经超出本书的范围。
struct vm_area_struct 这个结构也没有很重大的变化。主要的区别是 vm_next_share 和 vm_pprev_share 两个字段中的特定链表被称为 shared 的新字段所替换。vm_raend 字段已被彻底地删除,因为文件预读在 2.6 内核中实现起来非常难。预读主要由储存在 struct file→f_ra 中的一个 struct file_ra_state 管理。如何实现预读的许多细节在 mm/readahead.c 文件中有描述。
struct address_space 第一种变化相比之下是较次要的。gfp_mask 字段被 flags 字段取代,该标志字段首部 __GFP_BITS_SHIFT 个位用作 gfp_mask,并且由 mapping_gfp_mask() 函数访问。余下的多个位用于存储异步 I/O 的状态。这两个标志位可能被设置为 AS_EIO,以表明是一个 I/O 错误,或者被设置为 AS_ENOSPC,以表示在异步写期间文件系统空间已耗尽。
该结构增加了较多的东西,它们主要与页面高速缓存和预读有关。由于这些字段十分独特,所以我们将会详细地介绍它们。
- page_tree:这个字段是通过索引映射的页面高速缓存所有页面的基树,所有的数据都位于物理磁盘的块上。在 2.4 内核中,搜索页面高速缓存需要遍历整个链表。而在 2.6 内核中,它是一种基树查找,可以减少相当多的搜索时间。这个基树的实现在 lib/radix-tree.c 文件中。
- page_lock:这个是保护页树的自旋锁。
- io_pages:在写出脏页以及调用 do_writepages() 函数前,这些脏页被添加到这个链表中。正如前面注释所解释的一样,mpage_writepages() 函数在 fs/mpage.c 文件中,被写出的页放在这个链表中,以避免因为锁住一个已经被 I/O 锁住的页面而造成死锁。
- dirtied_when:这个字段记录一个索引节点第一次变脏瞬间的时间点。这个字段决定索引节点在super_block→s_dirty 链表上的位置。这样就防止了经常变脏的索引节点仍然逗留在链表的首部而导致在其他一些索引节点上出现不能写出而饿死 (starving) 的情况。
- backing_dev_info:这个字段记录预读相关的信息。该结构在 include/linux/backingdev.h 中有声明,而且还有注释来解释这些字段的作用。
- private_list:这是一个可用的针对 address_space 的私有链表。如果已经使用了辅助函数 mark_buffer_dirty_inode() 和 sync_mapping_buffers(),则该链表就通过 buffer_head→b_assoc_buffers 字段链接到缓冲区的首部。
- private_lock:这是一个可用的针对 address_space 的自旋锁。该锁的使用情况虽然是令人费解的,但是在针对 2.5.17( Iwn.net/2002/0523/a/2.5.17.php3 )版本的冗长变更日志中解释了它的一部分使用情况。它主要保护在这个映射中共享缓冲区的其他映射中的相关信息。该锁虽然不保护 private_list 这个字段,但是它保护该 映射中其他 address_space 共享缓冲区的 private_list 字段。
- assoc_mapping:映射 private_list 链表包含的 address_space 中的后援缓冲区。
- truncate_count:这是在一个区域由函数 invalidate_mmap_range() 收缩时的一个增量。当发生缺页中断时通过函数 do_no_page() 检查该计数器,以确保在一个页面没有错误时该计数器无效。
- struct address_space_operations:大多数关于该结构的变更初看起来,似乎相当简单,但实际上非常練手。以下是作了变更的字段。
- writepage; 函数 writepage() 的回调函数已经改变,另外增加了 一个参数 struct writeback_control。这个结构负责记录一些回写信息,如是否阻塞了或者页面分配器对于写操作是否是直接回收的或者 kupdated 的,并且它包含-个备份 backing_dev_info 的句柄以控制预读。
writepages:这个字段在把所有的页面写出之前,将所有的页从 dirty_pages 转移到 io_pages 中。 - set_page_dirty:这是一个与 address_space 相关的设置某页为脏的方法。主要是供后援
存储器的 address_space_operations 使用,以及那些与脏页相关联却没有缓冲区的匿名共享页面所使用。 - readpages:这个字段用于页面读取,使预读能够得到正确的控制。
- bmap:这个字段已经变更为处理磁盘扇区而不是设备的大于 232 个字节的无符号长整型。
- invalidatepage:这是一种更名的变化。函数 block_flushpage() 和回调函数 flushpage() 已经被重命名为 block_invalidatepage() 和 invalidatepage() 。
- direct I/O:已经改变成用 2.6 版本中新的 I/O 机制。新的机制已经超出了本书的范围。
第5章 引导内存分配器
由于硬件配置多种多样,所以在编译时就静态初始化所有的内核存储结构是不现实的。下一章将讨论到物理页面的分配,即使是确定基本数据结构也需要分配一定的内存空间来完成其自身的初始化过程。但物理页面分配器如何分配内存去完成自身的初始化呢?
为了说明这一点,我们使用一种特殊的分配器 —— 引导内存分配器(boot memory allocator)。它基于大多数分配器的原理,以位图代替原来的空闲块链表结构来表示存储空间 [Tan01]。若位图中某一位为 1,表示该页面已经被分配;否则表示为被占有。该分配机制通过记录上一次分配的页面帧号(PFN)以及结束时的偏移量来实现分配大小小于一页的空间。连续的小的空闲空间将被合并存储在同一页上。
读者也许会问,当系统运行时为何不使用这种分配机制呢? 其中的一个关键原因在于:首次适应分配机制虽然不会受到碎片的严重影响[JW98],但是它每次都需要通过对内存进行线性搜索来实现分配。若它检查的是位图,其成本将是相当高的。尤其是首次适应算法容易在内存的起始端留下许多小的空闲碎片,在需要分配较大的空间时,检查位图这一过程就显得代价很高 [WJNB95]。
在该分配机制中,存在着两种相似但又不同的 API。一种是如表 5.1 所列的 UMA 结构,另一种是如表 5.2 所列的 NUMA 结构。两者主要区别在于:NUMA API 必须附带对节点的操作,但由于 API 函数的调用者来自于与体系结构相关的层中,所以这不是一个很大的问题。
本章首先描述内存分配机制的结构,该结构用于记录每一个节点的可用物理内存。然后举例阐述如何设定物理内存的界定和每一个管理区的大小。接下来讨论如何使用这些信息初始化引导内存分配机制的结构。在解决了引导内存分配机制不再被使用后,我们开始研究其中的一些分配算法和函数。
5.1 表示引导内存映射
系统内存中的每一个节点都存在一个 bootmem_data 结构。它含有引导内存分配器给节点分配内存时所需的信息,如表示已分配页面的位图以及分配地点等信息。它在 <linux/bootmem.h> 文件中定义如下:
typedef struct bootmem_data {
unsigned long node_boot_start;
unsigned long node_low_pfn;
void *node_bootmem_map;
unsigned long last_offset;
unsigned long last_pos;
} bootmem_data_t;
该结构各个字段如下。
- node_boot_start:表示块的起始物理地址。
- node_low_pfn:表示块的结束地址,或就是该节点表示的 ZONE_NORMAL 的结束。
- node_bootmem_map:以位表示的已分配和空闲页面的位图的地址。
- last_offset:最后一次分配所在页面的偏移。如果为 0,则表示该页全部使用。
- last_pos:最后一次分配时的页面帧数。使用 last_offset 字段,我们可以检测在内存分配时,是否可以合并上次分配所使用的页,而不用重新分配新页。
5.2 初始化引导内存分配器
每一种体系结构中都提供了一个 setup_arch() 函数,用于获取初始化引导内存分配器时所必须的参数信息。
各种体系结构都有其函数来获取这些信息。在 x86 体系结构中为 setup_memory() ,而在其他体系结构中,如在 MIPS 或 Sparc 中为 bootmem_init(),PPC 中为 do_init_bootmen()。除体系结构不同外各任务本质上是相同的。参数信息如下。
- min_low_pfn:系统中可用的最小 PFN。
- max_low_pfn:以低端内存区域表示的最大 PFN。
- highstart_pfn:高端内存区域的起始 PFN。
- highend_pfn:高端内存区域的最后一个 PFN。
- max_pfn:表示系统中可用的最大 PFN。
(1)start_kernel
// init/main.c
asmlinkage void __init start_kernel(void) {
// ...
setup_arch(&command_line);
// ...
}
(2)setup_arch
// arch/i386/kernel/setup.c
void __init setup_arch(char **cmdline_p) {
// ...
max_low_pfn = setup_memory();
paging_init();
// ...
}
(3)setup_memory
static unsigned long __init setup_memory(void) {
unsigned long bootmap_size, start_pfn, max_low_pfn;
start_pfn = PFN_UP(__pa(&_end));
find_max_pfn();
max_low_pfn = find_max_low_pfn();
bootmap_size = init_bootmem(start_pfn, max_low_pfn);
register_bootmem_low_pages(max_low_pfn);
reserve_bootmem(HIGH_MEMORY, (PFN_PHYS(start_pfn) +
bootmap_size + PAGE_SIZE-1) - (HIGH_MEMORY));
reserve_bootmem(0, PAGE_SIZE);
// ...
return max_low_pfn;
}
(4)init_bootmem
// mm/bootmem.c
unsigned long __init init_bootmem (unsigned long start, unsigned long pages) {
max_low_pfn = pages;
min_low_pfn = start;
return(init_bootmem_core(&contig_page_data, start, 0, pages));
}
(5)init_bootmem_core
static unsigned long __init init_bootmem_core (pg_data_t *pgdat,
unsigned long mapstart, unsigned long start, unsigned long end)
{
bootmem_data_t *bdata = pgdat->bdata;
unsigned long mapsize = ((end - start)+7)/8;
pgdat->node_next = pgdat_list;
pgdat_list = pgdat;
mapsize = (mapsize + (sizeof(long) - 1UL)) & ~(sizeof(long) - 1UL);
bdata->node_bootmem_map = phys_to_virt(mapstart << PAGE_SHIFT);
bdata->node_boot_start = (start << PAGE_SHIFT);
bdata->node_low_pfn = end;
/*
* Initially all pages are reserved - setup_arch() has to
* register free RAM areas explicitly.
*/
memset(bdata->node_bootmem_map, 0xff, mapsize);
return mapsize;
}
5.3 初始化bootmem_data
一旦 setup_memory() 确定了可用物理页面的界限,系统将从两个引导内存的初始化函数中选择一个,并以待初始化的节点的起始和终止 PFN 作为调用参数。在 UMA 结构中,init_bootmem() 用于初始化 contig_page_data,而在 NUMA,init_bootmem_node() 则初始化一个具体的节点。这两个函数主要通过调用 init_bootmem_core() 来完成实际工作。
内核函数首先要把 pgdat_data_t 插入到 pgdat_list 链表中,因为这个节点在函数末尾很快就会用到。然后它记录下该节点的起始和结束地址(该节点与 bootmem_data_t 有关)并且分配一个位图来表示页面的分配情况。位图所需的大小以字节计算,计算公式如下:
m a p s i z e = ( e n d _ p f n − s t a r t _ p f n ) + 7 8 mapsize =\frac{(end\_pfn-start\_pfn)+7}{8} mapsize=8(end_pfn−start_pfn)+7
该位图存放于由 bootmem_data_t→node_boot_start 指向的物理地址处,而其虚拟地址的映射由 bootmem_data_t→node_bootmem_map 指定。由于不存在与结构无关的方式来检测内存中的空洞,整个位图就被初始化为 1 来有效地标志所有已分配的页。将可用页面的位设置为 0 的工作则由与结构相关的代码完成,但在实际中只有 Spare 结构使用到这个位图。在 x86 结构中,register_bootmem_low_pages() 通过检测 e820 映射图,并在每一个可用页面上调用 free_bootmem() 函数,将其位设为 1,然后再调用 reserve_bootmem() 为保存实际位图所需的页面预留空间。
5.4 分配内存
// mm/bootmem.c
void __init reserve_bootmem (unsigned long addr, unsigned long size)
{
reserve_bootmem_core(contig_page_data.bdata, addr, size);
}
static void __init reserve_bootmem_core(bootmem_data_t *bdata, unsigned long addr,
unsigned long size) {
unsigned long i;
/*
* round up, partially reserved pages are considered
* fully reserved.
*/
unsigned long sidx = (addr - bdata->node_boot_start)/PAGE_SIZE;
unsigned long eidx = (addr + size - bdata->node_boot_start +
PAGE_SIZE-1)/PAGE_SIZE;
unsigned long end = (addr + size + PAGE_SIZE-1)/PAGE_SIZE;
for (i = sidx; i < eidx; i++)
if (test_and_set_bit(i, bdata->node_bootmem_map))
printk("hm, page %08lx reserved twice.\n", i*PAGE_SIZE);
}
reserve_bootmem() 函数用于保存调用者所需的页面,但对于一般的页面分配而言它是相当麻烦的。在 UMA 结构中,有四个简单的分配函数:alloc_bootmem(),alloc_bootmem_low() ,alloc_bootmem_pages() 和 alloc_bootmem_low_pages() 。 对它们的详细描述如表 5.1 所列。
这些函数都以不同的参数调用 __alloc_bootmem(),如图 5.1 所示:
在 NUMA 结构中,同样存在几个相似的函数,alloc_bootmem_node(),alloc_bootmem_pages_node() 和 alloc_bootmem_low_pages_node() ,只不过它们多了一个节点作为参数。同样,它们也都调用 __alloc_bootmem_node(),只是参数不同。
无论是 __alloc_bootmem() 还是 __alloc_bootmem_node() ,本质上它们的参数相同。
// mm/bootmem.c
void * __init __alloc_bootmem (unsigned long size, unsigned long align,
unsigned long goal) {
// ...
for_each_pgdat(pgdat)
if ((ptr = __alloc_bootmem_core(pgdat->bdata, size,
align, goal)))
return(ptr);
// ...
return NULL;
}
void * __init __alloc_bootmem_node (pg_data_t *pgdat, unsigned long size,
unsigned long align, unsigned long goal) {
void *ptr = __alloc_bootmem_core(pgdat->bdata, size, align, goal);
// ...
return NULL;
}
static void * __init __alloc_bootmem_core (bootmem_data_t *bdata,
unsigned long size, unsigned long align, unsigned long goal) {
// ...
}
- pdgat:要分配的节点。在 UMA 结构中,它被省掉了,其默认值是 contig_page_data。
- size:所需要分配的空间大小;
- align:要求对齐的字节数。如果分配空间比较小,就以 SMP_CACHE_BYTES 对齐,在 x86 结构中,它是硬件一级高速缓存的对齐方式。
- goal:最佳分配的起始地址。底层函数一般从物理地址 0 开始,其他函数则开始于 MAX_DMA_ADDRESS,它是这种结构中 DMA 传输模式的最大地址。
__alloc_bootmem_core() 是所有 API 分配函数的核心。它是一个非常大的函数,因为它拥有许多可能出错的小步骤。该函数从 goal 地址开始,在线性范围内扫描一个足够大的内存空间以满足分配要求。对于 API 来说,这个地址或者是 0(适合 DMA 的分配方式),或者就是 MAX_DMA_ADDRESS。
该函数最巧妙、最主要的一部分在于判断新的分配空间是否能与上一个合并这一问题。
满足下列条件则可合并:
- 上次分配的页与此次分配(bootmem_date→pos)所找到的页相邻;
- 上一页有一定的空闲空间,即 bootmem_dataoffset != 0;
- 对齐小于 PAGE_SIZE。
不管分配是否能够合并,我们都必须更新 pos 和 offset 两个字段来表示分配的最后一页,以及该页使用了多少。如果该页完全使用,则 offset 为 0;
5.5 释放内存
与分配的函数不同,Linux 只提供了释放的函数,即用于 UMA 的 free_bootmem(),和用于 NUMA 的 free_bootmem_node() 。 两者都调用 free_bootmem_core(),只是 NUMA 中的不提供参数 pgdat。
相比分配器的其他函数而言,核心函数较为简单。对于受释放影响的每个完整页面的相应位被设为 0。如果原来就是 0,则调用 BUG() 提示发生重复释放错误。BUG() 用于由于内核故障而产生的不能消除的错误。它终止正在运行中的程序,使内核陷入循环,并打印出栈内信息和调试信息供开发者调试。
对释放函数的一个重要限制是只有完整的页面才可释放,它不会记录何时一个页面被部
分分配。所以当一个页面要部分释放时,整个页面都将保留。其实这并非是一个大问题,因为分配器在整个系统周期中都驻留内存,但启动时间内,它却是对开发者的一个重要限制。
// mm/bootmem.c
static unsigned long __init free_all_bootmem_core(pg_data_t *pgdat)
{
struct page *page = pgdat->node_mem_map;
bootmem_data_t *bdata = pgdat->bdata;
unsigned long i, count, total = 0;
unsigned long idx;
if (!bdata->node_bootmem_map) BUG();
count = 0;
idx = bdata->node_low_pfn - (bdata->node_boot_start >> PAGE_SHIFT);
for (i = 0; i < idx; i++, page++) {
if (!test_bit(i, bdata->node_bootmem_map)) {
count++;
ClearPageReserved(page);
set_page_count(page, 1);
__free_page(page);
}
}
total += count;
/*
* Now free the allocator bitmap itself, it's not
* needed anymore:
*/
page = virt_to_page(bdata->node_bootmem_map);
count = 0;
for (i = 0; i < ((bdata->node_low_pfn-(bdata->node_boot_start >> PAGE_SHIFT))/8 + PAGE_SIZE-1)/PAGE_SIZE; i++,page++) {
count++;
ClearPageReserved(page);
set_page_count(page, 1);
__free_page(page);
}
total += count;
bdata->node_bootmem_map = NULL;
return total;
}
5.6 销毁引导内存分配器
启动过程的末期,系统调用函数 start_kernel(),这个函数知道此时可以安全地移除启动分配器和与之相关的所有数据结构。 每种体系都要求提供 mem_init() 函数,该函数负责消除启动内存分配器和与之相关的结构。
这个函数的功能相当简单。它负责计算高、低端内存的维度并向用户显示出信息消息。若有需要,还将运行硬盘的最终初始化。在 x86 平台,有关 VM 的主要函数是 free_pages_init()。
这个函数首先使引导内存分配器调用函数回收自身,UMA 结构中是调用 free_all_bootmem() 函数,NUMA 中是调用 free_all_bootmem_node() 函数。这两个函数都调用内核函数 free_all_bootmem_core() ,但是使用的参数不同。free_all_bootmem_core() 函数原理很简单,它执行如下任务。
- 对于这个节点上分配器可以识别的所有未被分配的页面:
- 将它们结构页面上的 PG_reserved 标志位清 0;
- 将计数器设置为 1;
- 调用 __free_pages() 以使伙伴分配器(下章将讨论)能建立释放列表。
- 释放位图使用的所有页面,并将这些页面交给伙伴分配器。
在这个阶段,伙伴分配器控制了所有在低端内存下的页面。free_all_bootmem() 返回后首先清算保留页面的数量。高端内存页面由 free_pages_init() 负责处理。但此时需要理解的是如何分配和初始化整个 mem_map 序列,以及主分配器如何获取页面。图 5.3 显示了单节点系统中初始化低端内存页面的基本流程。free_all_bootmem() 返回后,ZONE-NORMAL 中的所有页面都为伙伴分配器所有。为了初始化高端内存页面,free_pages_init() 对 highstart_pfn 和 highend_pfn 之间的每一个页面都调用了函数 one_highpage_init() 。此函数简单将 PG_reserved 标志位清 0 ,设置 PG_highmem 标志位,设置计数器为 1 并调用 __free_pages() 将自已释放到伙伴分配器中。这与 free_all_bootmem_core() 操作一样。
此时不再需要引导内存分配器,伙伴分配器成为系统的主要物理页面分配器。值得注意的是,不仅启动分配器的数据被移除,它所有用于启动系统的代码也被移除了。所有用于启动系统的初始函数声明都被标明为 __init,如下所示:
unsigned long __init free_all_bootmem(void)
连接器将这些函数都放在 init 区。x86 平台上 free_initmem() 函数可以释放 __init_begin 到 __init_end 的所有页面给伙伴分配器。通过这种方法,Linux 能释放数量很大的启动代码所使用的内存,已不再需要启动代码。
(1)start_kernel
// init/main.c
asmlinkage void __init start_kernel(void)
{
// ...
setup_arch(&command_line);
// ...
mem_init();
// ...
}
(2)初始化 mem_map
(a)setup_arch
void __init setup_arch(char **cmdline_p)
{
// ...
max_low_pfn = setup_memory();
paging_init();
// ...
}
(b)paging_init
// arch/i386/mm/init.c
void __init paging_init(void)
{
pagetable_init();
load_cr3(swapper_pg_dir);
#if CONFIG_X86_PAE
/*
* We will bail out later - printk doesn't work right now so
* the user would just see a hanging kernel.
*/
if (cpu_has_pae)
set_in_cr4(X86_CR4_PAE);
#endif
__flush_tlb_all();
#ifdef CONFIG_HIGHMEM
kmap_init();
#endif
zone_sizes_init();
}
(c)zone_sizes_init
// arch/i386/mm/init.c
static void __init zone_sizes_init(void)
{
unsigned long zones_size[MAX_NR_ZONES] = {0, 0, 0};
unsigned int max_dma, high, low;
max_dma = virt_to_phys((char *)MAX_DMA_ADDRESS) >> PAGE_SHIFT;
low = max_low_pfn;
high = highend_pfn;
if (low < max_dma)
zones_size[ZONE_DMA] = low;
else {
zones_size[ZONE_DMA] = max_dma;
zones_size[ZONE_NORMAL] = low - max_dma;
#ifdef CONFIG_HIGHMEM
zones_size[ZONE_HIGHMEM] = high - low;
#endif
}
free_area_init(zones_size);
}
(d)free_area_init
// mm/page_alloc.c
void __init free_area_init(unsigned long *zones_size)
{
free_area_init_core(0, &contig_page_data, &mem_map, zones_size, 0, 0, 0);
}
(e)free_area_init_core
void __init free_area_init_core(int nid, pg_data_t *pgdat, struct page **gmap,
unsigned long *zones_size, unsigned long zone_start_paddr,
unsigned long *zholes_size, struct page *lmem_map)
{
// ...
}
(3)mem_init
(a)mem_init
// arch/i386/mm/init.c
void __init mem_init(void)
{
int codesize, reservedpages, datasize, initsize;
// ...
set_max_mapnr_init();
high_memory = (void *) __va(max_low_pfn * PAGE_SIZE);
/* clear the zero-page */
memset(empty_zero_page, 0, PAGE_SIZE);
reservedpages = free_pages_init();
codesize = (unsigned long) &_etext - (unsigned long) &_text;
datasize = (unsigned long) &_edata - (unsigned long) &_etext;
initsize = (unsigned long) &__init_end - (unsigned long) &__init_begin;
// ...
#ifndef CONFIG_SMP
zap_low_mappings();
#endif
}
(b)free_pages_init
// arch/i386/mm/init.c
static int __init free_pages_init(void)
{
// ...
/* this will put all low memory onto the freelists */
totalram_pages += free_all_bootmem();
reservedpages = 0;
for (pfn = 0; pfn < max_low_pfn; pfn++) {
/*
* Only count reserved RAM pages
*/
if (page_is_ram(pfn) && PageReserved(mem_map+pfn))
reservedpages++;
}
#ifdef CONFIG_HIGHMEM
for (pfn = highend_pfn-1; pfn >= highstart_pfn; pfn--)
one_highpage_init((struct page *) (mem_map + pfn), pfn, bad_ppro);
totalram_pages += totalhigh_pages;
#endif
return reservedpages;
}
(c)free_all_bootmem
// mm/bootmem.c
unsigned long __init free_all_bootmem (void)
{
return(free_all_bootmem_core(&contig_page_data));
}
(d)free_all_bootmem_core
// mm/bootmem.c
static unsigned long __init free_all_bootmem_core(pg_data_t *pgdat)
{
struct page *page = pgdat->node_mem_map;
bootmem_data_t *bdata = pgdat->bdata;
unsigned long i, count, total = 0;
unsigned long idx;
if (!bdata->node_bootmem_map) BUG();
count = 0;
idx = bdata->node_low_pfn - (bdata->node_boot_start >> PAGE_SHIFT);
for (i = 0; i < idx; i++, page++) {
if (!test_bit(i, bdata->node_bootmem_map)) {
count++;
ClearPageReserved(page);
set_page_count(page, 1);
__free_page(page);
}
}
total += count;
/*
* Now free the allocator bitmap itself, it's not
* needed anymore:
*/
page = virt_to_page(bdata->node_bootmem_map);
count = 0;
for (i = 0; i < ((bdata->node_low_pfn-(bdata->node_boot_start >> PAGE_SHIFT))/8 + PAGE_SIZE-1)/PAGE_SIZE; i++,page++) {
count++;
ClearPageReserved(page);
set_page_count(page, 1);
__free_page(page);
}
total += count;
bdata->node_bootmem_map = NULL;
return total;
}
5.7 2.6中有哪些新特性
引导内存分配器自 2.4 内核就没有什么重要的变化,主要是涉及一些优化和一些次要的有关 NUMA 体系结构的修改。第 1 个优化是在 bootmem_data_t 结构中增加了 last_success 字段。正如名字所表达的意思,该字段记录最近的一次成功分配的位置以减少搜索次数。 如果在 last_success 之前的一个地址释放,则该地址将会被改成空闲的区域。
第 2 个优化也和线性搜索有关。在搜索一个空闲页面时,2.4 内核将测试每个位,这样的开销是很大的。2.6 内核中使用测试一个长度为 BITS_PER_LONG 的块是否都是 1 来取代原来的操作。如果不是,就测试该块中单独的每个比特位。为加快线性搜索,通过函数 init_bootmem() 以节点的物理地址为序将它们排列起来。
最后的一个变更与 NUMA 体系结构及相似的体系结构相关。相似的体系结构现在定义了自己的 init_bootmem() 函数,并且任一个体系结构都可以有选择地定义它们自己的 reserve_bootmem() 函数。