Linux 内核设计与实现
深入理解 Linux 内核(一)
深入理解 Linux 内核(二)
Linux 设备驱动程序(一)
Linux 设备驱动程序(二)
Linux 设备驱动程序(三)
Linux设备驱动开发详解
深入理解Linux虚拟内存管理(一)
深入理解Linux虚拟内存管理(二)
深入理解Linux虚拟内存管理(三)
深入理解Linux虚拟内存管理(四)
本文基于 Linux 2.4.22。
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_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 节中进一步讨论。这一章讨论在引入高端内存管理以前,节点、管理区和页面如何工作。
正如前面所提到的,内存中的每个节点都由 pg_data_t 描述,而 pg_data_t 由 struct pglist_data 定义而来。在分配一个页面时,Linux 采用节点局部分配的策略,从最靠近运行中的 CPU 的节点分配内存。由于进程往往是在同一个 CPU 上运行,因此从当前节点得到的内存很可能被用到。结构体在
#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_ZONEMASK+1];
int nr_zones;
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;
现在我们简要地介绍每个字段。
所有节点都由一个称为 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 循环,以提高代码的可读性。
每个管理区由一个 struct zone_struct 描述。 zone_struct 用于跟踪诸如页面使用情况统计数,空闲区域信息和锁信息等。在
/*
* On machines where it is needed (eg PCs) we divide physical memory
* into multiple physical zones. On a PC we have 3 zones:
*
* ZONE_DMA < 16 MB ISA DMA capable memory
* ZONE_NORMAL 16-896 MB direct mapped by the kernel
* ZONE_HIGHMEM > 896 MB only page cache and user processes
*/
typedef struct zone_struct {
/*
* Commonly accessed fields:
*/
spinlock_t lock;
unsigned long free_pages;
unsigned long pages_min, pages_low, pages_high;
int need_balance;
/*
* free areas of different sizes
*/
free_area_t free_area[MAX_ORDER];
/*
* wait_table -- the array holding the hash table
* wait_table_size -- the size of the hash table array
* wait_table_shift -- wait_table_size
* == BITS_PER_LONG (1 << wait_table_bits)
*
* The purpose of all these is to keep track of the people
* waiting for a page to become available and make them
* runnable again when possible. The trouble is that this
* consumes a lot of space, especially when so few things
* wait on pages at a given time. So instead of using
* per-page waitqueues, we use a waitqueue hash table.
*
* The bucket discipline is to sleep on the same queue when
* colliding and wake all in that wait queue when removing.
* When something wakes, it must check to be sure its page is
* truly available, a la thundering herd. The cost of a
* collision is great, but given the expected load of the
* table, they should be so rare as to be outweighed by the
* benefits from the saved space.
*
* __wait_on_page() and unlock_page() in mm/filemap.c, are the
* primary users of these fields, and in mm/page_alloc.c
* free_area_init_core() performs the initialization of them.
*/
wait_queue_head_t * wait_table;
unsigned long wait_table_size;
unsigned long wait_table_shift;
/*
* Discontig memory support fields.
*/
struct pglist_data *zone_pgdat;
struct page *zone_mem_map;
unsigned long zone_start_paddr;
unsigned long zone_start_mapnr;
/*
* rarely used fields:
*/
char *name;
unsigned long size;
} zone_t;
下面是对该结构的每个字段的简要解释。
当系统中的可用内存很少时,守护程序 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)。
每个极值在表示内存不足时的行为都互不相同。
在任何操作系统中,无论调用什么样的页面换出参数,它们的含义都是相同的。它们都用于决定页面换出守护程序或页面换出进程释放页面的频繁程度。
每个管理区的大小在 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 节。
当页面需要进行 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] 的最大素数。
管理区的初始化在内核页表通过函数 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);
核心函数 free_area_init_core() 用于向每个 zone_t 填充相关的信息,并为节点分配 mem_map 数组。释放管理区中的哪些页面的信息在这里并不考虑。这些信息直到引导内存分配器使用完之后才可能知道,这将在第 5 章进行讨论。
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 作为真实的数组,因为其中只有有效的管理区会被节点所使用。
系统中的每个物理页面都有一个相关联的 struct page 用以记录该页面的状态。在内核 2.2 版本[BC00]中,该结构类似它在 System V 中的等价物,就像 unix 中的其他分支一样,该结构经常变动。它在 linux/mm.h 中声明如下:
/*
* Each physical page in the system has a struct page associated with
* it to keep track of whatever it is we are using the page for at the
* moment. Note that we have no way to track which tasks are using
* a page.
*
* Try to keep the most commonly accessed fields in single cache lines
* here (16 bytes or greater). This ordering should be particularly
* beneficial on 32-bit processors.
*
* The first line is data used in page cache lookup, the second line
* is used for linear searches (eg. clock algorithm scans).
*
* TODO: make this structure smaller, it could be as small as 32 bytes.
*/
typedef struct page {
struct list_head list; /* ->mapping has some page lists. */
struct address_space *mapping; /* The inode (or ...) we belong to. */
unsigned long index; /* Our offset within mapping. */
struct page *next_hash; /* Next page sharing our hash bucket in
the pagecache hash table. */
atomic_t count; /* Usage count, see below. */
unsigned long flags; /* atomic flags, some possibly
updated asynchronously */
struct list_head lru; /* Pageout list, eg. active_list;
protected by pagemap_lru_lock !! */
struct page **pprev_hash; /* Complement to *next_hash. */
struct buffer_head * buffers; /* Buffer maps us to a disk block. */
/*
* On machines where all RAM is mapped into kernel address space,
* we can simply calculate the virtual address. On machines with
* highmem some memory is mapped into kernel virtual memory
* dynamically, so we need a place to store that address.
* Note that this field could be 16 bits on x86 ... ;)
*
* Architectures with slow multiplication can define
* WANT_PAGE_VIRTUAL in asm/page.h
*/
#if defined(CONFIG_HIGHMEM) || defined(WANT_PAGE_VIRTUAL)
void *virtual; /* Kernel virtual address (NULL if
not kmapped, ie. highmem) */
#endif /* CONFIG_HIGMEM || WANT_PAGE_VIRTUAL */
} 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:
这些标志位用于描述页面的状态。所有这些标志位在
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.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 中的索引被显 式地存储在页面中。
由于内核(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 位机器。
大致看去,内存如何被描述好像没有多大的变化似的,但是表面上细微的变化实际上涉及的面却相当广。节点描述器 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:
通过节点访问的所有区域,包括任何可能存在的空洞。
初始看来,两个版本中的管理区也是有很大差异的。它们不再被称为 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 字段使用不同的高速缓冲行。
最值得注意的变更就是该字段的顺序被改变了,因此相关联的项目看起来都使用了同一个高速缓冲行。该字段除了新加了两个特性以外,本质上还是一样的。第一个特性就是采用了一个新的联合来创建 PTE 链。PTE 和页表管理相关联的会在第 3 章的结尾处进行讨论。另外一个特性便是添加了 page->private 字段,它包括了映射中详实的私有信息。比如,当页面是一个缓冲区页面时,该字段被用于存储一个指向 buffer_head 的指针。这意味着 page->buffers 字段也被移除掉了。最后一个重要的变更是 page→virtual 对高端内存的支持不再必要,只有在特定的体系结构需要时才会考虑它的存在。如何支持高端内存将在第 9 章进一步讨论。
在 2.4 里面,只有一个子系统会积极地尝试为任何对象维护 per-cpu 上的链表,而这个子系统就是 slab 分配器,在第 8 章会进行讨论。在 2.6 里面,这个概念则更为普遍一些,存在一个关于活动页面和不活动页面的正式概念。
在
非活动页面。
struct per_cpu_pages 维护了链表中目前已有的一系列页面,高极值和低极值决定了何时填充该集合或者释放一批页面,变量决定了一个块当中应当分配多少个页面,并最后决定在页面前的实际链表中分配多少个页面。
为建立每个 CPU 的链表,需要有一个计算每个 CPU 上有多少页面的方法。struct page_state 具有一系列计算的变量,比如 pgalloc 字段,用于跟踪分配给当前 CPU 的 页面数量;而 pswpin 字段,用于跟踪读进交换的页面数量。该结构在
可参考 ==> 1.1 两级页表结构
每个进程都有一个指向其自己 PGD 的指针(mn_struct→pgd),它其实就是一个物理页面帧。该帧包括了一个 pgd_t 类型的数组,但 pgd_t 因不同的体系结构也有所不同,它的定义在
PGD 表中每个有效的项都指向一个页面帧,此页面帧包含着一个 pmd_t 类型的 PMD 项数组,每一个 pmd_t 又指指向另外的页面帧,这些页面帧由很多个 pte_t 类型的 PTE 构成,而 pte_t 最终指向包含真正用户数据的页面。当页面被交换到后援设备时,存储在 PTE 里的将是交换项,这个交换项在系统发生页面错误时在调用 do_swap_page 时作为查找页面数据的依据。页表的布局如图 3.1 所示。
为了能在这三个不同的页表层里产生不同的偏移量以及在实际的页内产生偏移量,任何一个给定的线性地址将会被划分成几个部分。为了有助于将线性地址划分成几个部分,每个页表层均提供了 3 个宏来完成此工作,它们是 SHIFT,SIZE 和 MASK 宏。SHIFT 宏主要用于指定在页面每层映射的长度,以位为单位计算,如图 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()。
定义在
第 2 组宏决定了一个页表项是否存在或者是否正在使用中。
下面的函数和宏用于映射地址和页面到 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 级的哪一级,分配已经被删除页表的操作都是非常频繁的,所以要求这些操作尽可能地快很重要。
于是,这些物理页面被缓存在许多被称作快速队列的不同队列里。不同的结构实现它们虽都有所不同,但原理相同。例如,并不是所有的系统都会缓存 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(),这个函数同时也可以被系统的空闲任务所调用。
在 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 寄存器)的正确性。
用于完成页表收尾工作的对应函数是 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(),用于初始化所有被使用的管理区结构。
对 Linux 而言,必须有一种快速的方法把虚拟地址映射到物理地址或把 struct page 映射到它们的物理地址。Linux 为实现该机制,在虚拟和物理内存使用了一个 mem_map 的全局数组,因为这个数组包含着指向系统物理内存所有 struct page 的指针。所有的结构都采用了非常相似的机制,为了表述简单,在这里我们只详细讨论 x86 结构中的情况。本节我们首先讨论物理地址如何被映射到内核虚拟地址,以及又是如何利用 mem_map 数组的。
正如在 3.6 节看到的,在 x86 中 Linux 把从 0 开始的物理地址直接映射成从 PAGE_OFFSET 即 3GB 开始的虚拟地址。这意味着在 x86 上,可以简单地将任意一个虚拟地址减去 PAGE_OFFSET 而获得其物理地址,就像函数 virt_to_phys() 和宏 __pa() 所做的那样:
/* from */
# define __pa(x)((unsigned long)(x)-PAGE_OFFSET)
/* from
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.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() 所做的那样,其声明在
#define virt_to_page(kaddr)(mem_map +(__pa(kaddr)>> PAGE_SHIFT))
宏 virt_to_page() 通过 __pa() 把虚拟地址 kaddr 转换成物理地址,然后再通过右移 PAGE_SHIFT 位转换成 mem_map 数组的一个下标,接着通过简单的加法操作就可以在 mem_map 中查找它们。Linux 中不存在将页面转换成物理地址的宏,但你应该知道如何计算。
最初,当处理器需要映射一个虚拟地址到一个物理地址时,需要遍历整个页面目录以搜索相关的 PTE。通常这表现为每个引用内存的汇编指令实际上需要多个页表截断的相分离的内存引用 [Tan01]。为了避免这种情况的过度出现,许多体系结构中都利用了这样一个事实,就是大多数的进程都是采用局部引用,或者,换句话说,少量的页面却使用了大量的内存引用。它们提供一个转换后援缓冲区(TLB)来利用这种引用的局部性原理,这个高速缓存是一个联合内存,用来缓存虚拟到物理页表转换的中间结果。
Linux 假设大多数的体系结构都是支持 TLB 的,即便独立于体系结构的代码并不关心它如何工作。相反,与体系结构相关的钩子都分散在 VM 的代码中,大家知道,一些带有 TLB 的硬件是需要提供一个 TLB 相关的操作的。 例如,在页表更新后,诸如在一个页面错误发生时,处理器可能需要更新 TLB 以满足虚拟地址的映射要求。
不是所有的体系结构都需要这种类型的操作,但是,因为有些体系结构是需要的,所以 Linux 中就需要存在钩子。 如果某个体系结构并不需要诸如此类的操作,那么在这个体系结构中完成 TLB 操作的函数就是一个空函数,这在编译时就进行过优化。
大部分关于 TLB 的 API 钩子列表都在
从用户的角度来看,地址空间是一个平坦的线性地址空间,但从内核的角度来看却大不一样。地址空间分为两个部分:一个是随上下文切换而改变的用户空间部分,一个是保持不变的内核空间部分。两者的分界点由 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 区域所得。
进程可使用的地址空间由 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 则列举了许多影响地址空间
和区域的系统调用。
进程地址空间由 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; /* list of VMAs */
rb_root_t mm_rb;
struct vm_area_struct * mmap_cache; /* last find_vma result */
pgd_t * pgd;
atomic_t mm_users; /* How many users with user space? */
atomic_t mm_count; /* How many references to "struct mm_struct" (users count as 1) */
int map_count; /* number of VMAs */
struct rw_semaphore mmap_sem;
spinlock_t page_table_lock; /* Protects task page tables and mm->rss */
struct list_head mmlist; /* List of all active mm's. These are globally strung
* together off init_mm.mmlist, and are protected
* by mmlist_lock
*/
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_t context;
};
该结构中各个字段的含义如下。
对 mm_struct 结构进行操作的函数描述如表 4.2 所列。
系统有两个函数用于分配 mm_struct 结 构。它们本质上相同,但有一个重要的区别。allocate_mm() 只是一个预处理宏,它从 slab allocator (见第 8 章) 中分配一个 mm_struct。而 mm_alloc() 从 slab 中分配,然后调用 mm_init() 函数对其初始化。
系统中第一个 mm_struct 通过 init_mm() 初始化。因为后继的子 mm_struct 都通过复制进行设置,所以第 1 个 mm_struct 在编译时静态设置,通过宏 INIT_MM 完成设置。
#define INIT_MM(name) \
{ \
mm_rb: RB_ROOT, \
pgd: swapper_pg_dir, \
mm_users: ATOMIC_INIT(2), \
mm_count: ATOMIC_INIT(1), \
mmap_sem: __RWSEM_INITIALIZER(name.mmap_sem), \
page_table_lock: SPIN_LOCK_UNLOCKED, \
mmlist: LIST_HEAD_INIT(name.mmlist), \
}
第 1 个 mm_struct 创建后,系统将该 mm_struct 作为一个模板来创建新的 mm_struct。copy_mm() 函数完成复制操作,它调用 init_mm() 初始化与具体进程相关的字段。
新的用户通过 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);
}
}
进程的地址空间很少能全部用满,一般都只是用到了其中一些分离的区域。区域由 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 结构在
/*
* This struct defines a memory VMM memory area. There is one of these
* per VM-area/task. A VM area is any part of the process virtual memory
* space that has a special rule for the page-fault handlers (ie a shared
* library, the executable area etc).
*/
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_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) 的遍历时间。红黑树通过排序使得左结点的地址小于当前结点的地址,而当前结点的地址又小于右结点的地址。
VMA 提供三个操作函数,分别是 open(),close() 和 nopage() 。VMA 通过类型 vm_operations_struct 的 vma->vm_ops 提供上述几个操作函数 。该结构包含三个函数指针,它在
/*
* These are the virtual MM functions - opening of an area, closing and
* unmapping it (needed to keep files on disk up-to-date etc), pointer
* to the functions called when a no-page or a wp-page exception occurs.
*/
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.2 所列,在有后援文件的区域中,vm_file 引出了相关的 address_space。address_space 结构包含一些与文件系统相关的信息,如必须写回到磁盘的脏页面数目 。该结构在
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 */
};
各字段简要描述如下。
内存管理器需要定期将信息写回磁盘。但是内存管理器并不知道也不关心信息如何写回到磁盘,因此需要 a_ops 结构来调用相关的函数。它在
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 */
};
该结构中的字段都是函数指针,它们描述如下。
系统调用 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 的范围以及这个进程没有过多的映射区域。
这个函数的余下部分比较大,大致有以下几个步骤:
mmap
一文搞懂 mmap 涉及的所有内容
#include
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;
}
映射内存时,先得获取足够大的空闲区域,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 中提供的通用版本函数。
如果文件和权限都匹配,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() 中,它很可能把相似的区域移到一起。
系统调用 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() 。
系统调用 munlock() 和 munlockall() 分别是相应的解锁函数,它们分别由 sys_munlock() 和 sys_munlockall() 实现。它们比上锁函数简单得多,因为不必做过多的检查,它们都依赖于同一个函数 do_mmap() 来修整区域。
在上锁或解锁时,VMA 将受到 4 个方面的影响,每个必须由 mlock_fixup() 修正。 上锁可能影响到所有的 VMA 时,系统就要调用 mlock_fixuo_all() 进行修正。第 2 个条件是被锁住区域地址的起始值,由 mlock_fixup_start() 处理,Linux 需要分配一个新的 VMA 来映射新区域。第 3 个条件是被锁住区域地址的结束值,由 mlock_fixup_end() 处理。 最后,mlock_fixup_middle() 处理映射区域的中间部分,此时需要分配 2 个新的 VMA。
值得注意的是创建上锁的 VMA 时从不合并,即使该区域被解锁也不能合并。一般而言,已经锁住某个区域的进程没必要再次锁住同一区域,因为经常开销处理器计算能力来合并和
分裂区域是不值得的。
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 用于映射,则不需要释放页表项。这是因为释放少量的页表项和数据结构代价很大,而且这些结构很小,另外它们还可能会再次被使用。
进程退出时,必须删除与其 mm_struct 相关联的所有 VMA,由函数 exit_mmap() 负责操作。这是个非常简单的函数,在遍历 VMA 链表前将刷新 CPU 高速缓存,依次删除每一个 VMA 并释放相关的页面,然后刷新 TLB 和删除页表项,这个过程在代码注释中有详细描述。
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() 查看是否为引起中断的指令提供了修整子程序,若系统支持模块,还要搜索每个模块的异常表。
进程线性地址空间里的页面不必常驻内存。例如,进程的分配请求并被立即满足,空间仅保留为满足 vm_area_struct 的空间。其也非常驻内存页面的例子有,页面可能被交换到后援存储器,还有就是写一个只读页面。
和大多操作系统一样,Linux 采用请求调页技术来解决非常驻页面的问题。在操作系统捕捉到由硬件发出的缺页中断异常时,它才会从后援存储器中调入请求的页面。由后援存储器的特征可知,采取页面预取技术可以减少缺页中断[MM87],但是 Linux 在这方面相当原始。在将一个页面读入交换区时,swapin_readahead() 会预取该页面后多达 2page_cluster 的页面,并放置在交换区中。不幸的是,很快要用到的页面只有一次机会邻近交换区,从而导致预约式换页技术很低效。Linux 将采用适合应用程序的预约式换页策略[KMC02]。
有两种类型的缺页中断,分别是主缺页中断和次缺页中断。当要费时地从磁盘中读取数据时,就会产生主缺页中断,其他的就是次缺页中断,或者是轻微的缺页中断。Linux 通过字段 task_struct→maj_fault 和 task_struct→min_fault 来统计各自的数目。
如果 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 链表中,以后就可以被页面回收代码处理。最后需要更新进程的页表项来反映新映射。
如果被文件或设备映射,VMA 中的 vm_operation_struct 将提供 nopage() 函数。如果是文件映射,函数 filemap_nopage() 将替代 nopage() 分配一个页面并从磁盘中读取一个页面大小的数据。如果页面由虚文件映射而来,就使用函数 shmem_nopage() (见第 12 章)。每种设备驱动程序将提供不同的 nopage() 函数,内部如何实现对我们来说并不重要,只要知道该函数返回一个可用的 struct page 即可。
在返回页面时,要先做检查以确定分配是否成功,如果不成功就返回相应的错误。然后检查提前 COW 失效是否发生。如果是向页面写,而在受管 VMA 中没有包括 VM_SHARED 标志,就会发生提前 COW 失效。提前 COW 失效是指分配一个新页面,在减少 nopage() 返回页面的引用计数前就将数据交叉地复制过来。
线性地址空间基本上保持了与 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,以表示在异步写期间文件系统空间已耗尽。
该结构增加了较多的东西,它们主要与页面高速缓存和预读有关。由于这些字段十分独特,所以我们将会详细地介绍它们。
由于硬件配置多种多样,所以在编译时就静态初始化所有的内核存储结构是不现实的。下一章将讨论到物理页面的分配,即使是确定基本数据结构也需要分配一定的内存空间来完成其自身的初始化过程。但物理页面分配器如何分配内存去完成自身的初始化呢?
为了说明这一点,我们使用一种特殊的分配器 —— 引导内存分配器(boot memory allocator)。它基于大多数分配器的原理,以位图代替原来的空闲块链表结构来表示存储空间 [Tan01]。若位图中某一位为 1,表示该页面已经被分配;否则表示为被占有。该分配机制通过记录上一次分配的页面帧号(PFN)以及结束时的偏移量来实现分配大小小于一页的空间。连续的小的空闲空间将被合并存储在同一页上。
读者也许会问,当系统运行时为何不使用这种分配机制呢? 其中的一个关键原因在于:首次适应分配机制虽然不会受到碎片的严重影响[JW98],但是它每次都需要通过对内存进行线性搜索来实现分配。若它检查的是位图,其成本将是相当高的。尤其是首次适应算法容易在内存的起始端留下许多小的空闲碎片,在需要分配较大的空间时,检查位图这一过程就显得代价很高 [WJNB95]。
在该分配机制中,存在着两种相似但又不同的 API。一种是如表 5.1 所列的 UMA 结构,另一种是如表 5.2 所列的 NUMA 结构。两者主要区别在于:NUMA API 必须附带对节点的操作,但由于 API 函数的调用者来自于与体系结构相关的层中,所以这不是一个很大的问题。
本章首先描述内存分配机制的结构,该结构用于记录每一个节点的可用物理内存。然后举例阐述如何设定物理内存的界定和每一个管理区的大小。接下来讨论如何使用这些信息初始化引导内存分配机制的结构。在解决了引导内存分配机制不再被使用后,我们开始研究其中的一些分配算法和函数。
系统内存中的每一个节点都存在一个 bootmem_data 结构。它含有引导内存分配器给节点分配内存时所需的信息,如表示已分配页面的位图以及分配地点等信息。它在
/*
* node_bootmem_map is a map pointer - the bits represent all physical
* memory pages (including holes) on the node.
*/
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;
该结构各个字段如下。
每一种体系结构中都提供了一个 setup_arch() 函数,用于获取初始化引导内存分配器时所必须的参数信息。
各种体系结构都有其函数来获取这些信息。在 x86 体系结构中为 setup_memory() ,而在其他体系结构中,如在 MIPS 或 Sparc 中为 bootmem_init(),PPC 中为 do_init_bootmen()。除体系结构不同外各任务本质上是相同的。参数信息如下。
传送门 setup_memory
一旦 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() 为保存实际位图所需的页面预留空间。
// 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) {
// ...
}
__alloc_bootmem_core() 是所有 API 分配函数的核心。它是一个非常大的函数,因为它拥有许多可能出错的小步骤。该函数从 goal 地址开始,在线性范围内扫描一个足够大的内存空间以满足分配要求。对于 API 来说,这个地址或者是 0(适合 DMA 的分配方式),或者就是 MAX_DMA_ADDRESS。
该函数最巧妙、最主要的一部分在于判断新的分配空间是否能与上一个合并这一问题。
满足下列条件则可合并:
不管分配是否能够合并,我们都必须更新 pos 和 offset 两个字段来表示分配的最后一页,以及该页使用了多少。如果该页完全使用,则 offset 为 0;
与分配的函数不同,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;
}
启动过程的末期,系统调用函数 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() 函数原理很简单,它执行如下任务。
在这个阶段,伙伴分配器控制了所有在低端内存下的页面。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 能释放数量很大的启动代码所使用的内存,已不再需要启动代码。
传送门 mem_init
引导内存分配器自 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() 函数。
如上所述,分配器维护空闲页面所组成的块,这里每一块都是 2 的方幂个页面。方幂的指数被称为阶。如图 6.1 所示,内核对于每一个阶都维护结构数组 free_area_t,用于指向一个空闲页面块的链表。
所以,数组的第 0 个元素将会指向具有 20 或 1 个页面大小的块的链表,第一个元素将会是一个具有 21(2) 个页面大小的块的链表,直到 2MAX_ORDER-1 个页面大小的块,MAX_ORDER 的值一般为 10。这消除了一个大页面被分开来满足一个小页面块即能满足的要求的可能性。页面块通过 page→list 这个线性链表来维护。
每一个管理区都有一个 free_area_t 结构数组,即 free_area[MAX_ORDER],它在
// include/linux/mmzone.h
typedef struct zone_struct {
//...
free_area_t free_area[MAX_ORDER];
//...
} zone_t;
typedef struct free_area_struct {
struct list_head free_list;
unsigned long *map;
} free_area_t;
此结构的字段如下:
Linux 通过使用一位而非两位来表示每一对伙伴从而节省内存。每当一个伙伴被分配出去或者被释放,这对伙伴的位就会被切换。因此,如果这对页面块都是空闲的或者都在使用中,这个位就是 0;如果仅仅有一个在使用则这个位就是 1。为了切换到正确的位,我们使用文件 page_alloc.c 中的宏 MARK_USED() ,对它的解释如下:
#define MARK_USED(index, order, area) \
__change_bit((index) >> (1+(order)),(area)->map)
index 就是全局的 mem_map 数组中的页面下标。将它向右移动 1+order 位就可以得到代表伙伴对的映射中的位。
分配通常依据一个特定的幂次进行,0 代表需要一个单独的页面。如果空闲块不能满足所需的层,则一个高次的块会被分成两个伙伴,一个用于分配,另一个放入低次的空闲链表中。图 6.3 展示了一个块在何处被分开以及伙伴如何被加入到空闲链表直至找到一个适合进程的块。
当这个块被最终释放时,其伙伴将会被检查。如果两个都是空闲块,它们就会合并为一个次数较高的块并被放入高次链表。在此链表中伙伴被检测。如果伙伴并不空闲,则被释放的页面块就会加入当前次数的空闲链表。在这些链表的操作中,当一个进程处于不一致状态时,必须禁止中断,以防其他中断处理操作链表。这些措施通过使用中断安全的自旋锁来实现。
第二步决定使用哪个内存节点以及哪个 pg_data_t。Linux 使用了本地节点的分配策略,这是为了使用和运行与页面分配进程的 CPU 相关联的内存库。此处函数 __alloc_pages() 非常重要,因为它根据内核是建立在 UMA(mm/page_alloc.c 中提供的功能)上还是建立在 NUMA(mm/numa.c 提供的功能)上而有所不同。
无论使用哪一个 API,mm/page_alloc.c 中的函数 __alloc_pages() 都是分配器的核心。这个函数从不被直接调用,它会检查选定的管理区,看这个管理区可用的页面数量上是否适合分配。如果这个管理区不适合,分配器将会退回到其他管理区。退回的管理区的次在启动时由函数 build_zonelists() 决定。但通常是从 ZONE_HIGHMEM 退回到 ZONE_NORMAL,从 ZONE_NORMAL 再退回到 ZONE_DMA。如果空闲页面的数量达到 pages_low 的要求,系统激活 kswapd 开始从管理区中释放页面。如果内存空间极度紧张,调用者将自己完成 kswapd 的工作。
一旦最终选定了管理区,系统会调用函数 rmqueue() 分配页面块,或在没有合适大小的情况下切分高次的块。
用于释放页面的主要函数是 __free_pages_ok(),它不能被直接调用,而是提供了函数 __free_pages(),它会首先进行一些简单的检查,如图 6.4 所示。
在释放伙伴时,Linux 会试着尽可能快地合并它们。但这并不是最优的做法,因为在最坏的情况下,分开块后即会有许多次的合并 [Vah96]。
为了探测伙伴们是不是可以合并,Linux 在 free_areamap 中检查与受影响的伙伴相对应的位。由于一个伙伴刚刚被此函数释放,很显然它知道至少有一个伙伴是空闲的。如果切换以后位图中的位是 0,那么另外一个伙伴也肯定是空闲的,因为如果这个位是 0,就意味着两个伙伴或者同为空闲或者同被分配。如果两个都是空闲的,系统可以合并它们。
计算这个伙伴地址有一个著名的方法 [Knu68]。由于分配是以 2k 个块进行的,块的地址,或者说至少它在 zone_mem_map 的起始地址将会是 2k 的整次幂。最终的结论是总会有至少 k 个数字的 0 在地址的右边。为了得到伙伴的地址,从右边数的第 k 位检测。如果为 0,伙伴将会翻转自己的位。为了得到这个位的值,Linux 引入了一个掩码,其计算如下:
mask=(~0<<k)
我们感兴趣的掩码是:
imask=1+~imask
Linux 在计算此掩码时采用了这个技巧:
imask=-mask=1+~imask
合并伙伴后,它便从空闲链表中被移除,并且这个新的合并对会被移到下一个高次链表中以确定是否可以再次合并。
传送门 __free_pages_ok
获得空闲页面(GFP)标志位是贯穿整个虚拟内存的永恒概念。这个标志位决定分配器以及 kswapd 如何分配和释放页面。例如,一个中断处理程序也许不需要睡眠,所以它不需要设定 __GFP_WAIT 标志位,设置这个标志位表明调用者可以睡眠。存在 3 组 GFP 标志位,在 linux/mm.h 中有说明。
这 3 组中的第 1 组是列于表 6.3 中的管理区修饰符。这些标志位意味着调用者必须尽可能在一个特定管理区中进行分配。读者需要注意的是,这里没有一个适用于 ZONE_NORMAL 的管理区修饰符,因为管理区修饰符标志位在一个数组里是被用作开端的,0 则暗示在 ZONE_NORMAL 中进行分配。
下一个标志位是列于表 6.4 中的动作修饰符。它们改变了虚拟内存以及调用进程的运作。低级的标志位由于过于简单而不易使用。
要知道每一个实例的正确组合是非常困难的,所以 Linux 中给出了一些高级组合的定义,列于表 6.5 中。具体地,__GFP_ 从列表组合中被移除,所以 __GFP_HIGH 标志位在表中将被称作 HIGH。表 6.6 给出了形成高级标志位的组合。为了帮助理解,我们以 GFP_ATOMIC 为例子说明。它仅仅设置了 __GFP_HIGH 标志位,这意味着它具有高优先级,并使用紧急情况池(如果存在),但不会睡眠,也不会执行 I/O 或访问文件系统。比如这个标志位将会在中断处理程序中使用。
一个进程也许也会在 task_struct 中设置一些影响分配器动作的标志位。linux/shed.h 对所有进程标志位都有定义,表 6.7 只列举了影响虚拟内存的进程标志位。
一个必须提出的分配器普遍存在的重要问题是内外部的碎片问题。外部碎片是由于可用内存全部是小块而不能满足要求。内部碎片是由于一个大的块必须被分开来响应一些小的请求而浪费的空间。在 Linux 中,外部碎片不是一个非常严重的问题,因为对大块连续页面的请求是非常少见的,通常 vmalloc() 就可以满足这些请求。空闲块的链表确保大的块在不必要的情况下不被分开。
内部碎片是二进制伙伴系统所特有的一个非常严重的问题。尽管预计碎片只存在于 28% 的空间中 [WJNB95],但与首先适应分配器的仅 1% 相比,碎片已达到了 60% 的面积。即使使用多种伙伴系统也无法显著改善这种状况 [PN77]。为了解决这个问题,Linux 使用了一个 slab 分配器 [Bon94] 将页面块切割为小的内存块进行分配 [Tan01],这将在第 9 章中详细讨论。有了这种分配器的组合,内核可以使由内部分片导致的内存消耗量保持在最低限度。
尤其值得注意的不同之处似乎带有表面性。以前定义在
在 2.6 内核中,函数 alloc_pages() 调用 numa_node_id() 返回一个当前正在运行的 CPU 相关节点的逻辑 ID。系统把该 NID 传递给 to_alloc_pages() 函数,作为该函数调用 NODE_DATA() 时的参数。在 UMA 结构中,将无条件地返回结果给 contig_page_data,在 NUMA 结构中取而代之的是建立了一个数组,其中存储了函数 NODE_DATA() 以 NID 作为的偏移量。或者说,体系结构负责为 NUMA 内存节点映射建立一个 CPU 的 ID 号。这在 2.4 内核中的节点局部分配策略下依然非常有效,而现在 2.6 中它的定义更加清晰。
前面在 2.8 节中已经讨论过,页面分配中最重要的改变便是为 Per-CPU 增加的链表。
在 2.4 内核中,分配页面时,需要加上一个中断 —— 安全的自旋锁。在 2.6 内核中,内存页面都由函数 buffered_rmqueue() 从 struct per_cpu_pageset 中分配。如果还没有达到内存下限(per_pu_pageset→low),则在不需要加自旋锁的情况下页面的分配从页面集中分配。在到达内存下限后,系统将会成批地分配大量的页面并加上中断 —— 安全自旋锁,然后添加到 Per-CPU 的链表中,最后返回一个链表给调用函数。
通常更高次的分配是比较少见的,当然它也需要加上中断-安全自旋锁,这样分离和合并伙伴时才没有时间延迟。在 0 次的分配,分离时会有延迟,直到在 Per-CPU 集中到达内存下限为止,而合并时的延迟则会一直持续到达内存上限。
然而,严格意义上,这不是一个延迟伙伴关系算法 [BL89]。尽管页面集中为 0 次分配引入了一个合并延迟,但它看起来更像是一个副作用而不是一个特意设计的特征,并且不存在什么有效的方法释放页面集以及合并伙伴。或者说,尽管 Per-CPU 的代码和新加入的代码占了 mm/page_alloc.c 文件中的大量代码,核心的伙伴关系算法却仍然和 2.4 内核中一样。
这个变化的意义是直截了当的;它减少了为保护伙伴链表的必须加锁的次数。在 Linux 中,高次的分配相对来说较少,因此优化适用于普通的情况。这个变化在有多个 CPU 的机器上更容易看出来,对于单个的 CPU 基本上没有什么差异。当然,页面集也有少量的问题,但它们都并不严重。
第 1 个问题是在页面集中如果有一些 0 次页以正常状态被合并到邻近的高次块中,则系统在进行高次分配时就有可能失败。第 2 个是如果内存很少,而当前 CPU 页面集为空并且其他 CPU 的页集都满时,由于不存在用作从远程页集中回收页面的机制,0 次页分配就有可能失败。最后一个潜在的问题是,伙伴关系中一些新的空闲页可能在其他的页面集中,这可能导致碎片问题。
前面已经介绍过两个新的释放页面的 API 函数 free_hot_page() 和 free_cold_page()。就是这两个函数决定了是否把 Per-CPU 页面集中的页面放在活动或者非活动链表中。然而,尽管设计了 free_cold_page() 函数,但实际上它从没有被调用过。
函数 __free_pages() 所释放的 0 次页面和函数 __page_cache_release() 所释放的页面高速缓存中的空闲页面都存放在活动链表中;反之,高次分配的页面都立即由函数**__free_pages_ok()** 释放。0 次页面通常和用户空间相关,它的分配和释放是最普通的类型。由于大多数分配都是在 0 次上的,所以通过保持页面为 CPU 本地页面,将可以减少锁冲突。
最终,系统必须把页面链表传递给 free_pages_bulk() 函数,否则页面集链表将会占用所有的空闲页。 函数 free_pages_bulk() 将获得一个链表,其 中包括页面块分配、每一个块的次以及从该链表释放的块的计数。这里调用有两种情况:第 1 种情况是高次的页面被释放后传递给 __free_pages_ok() 函数。这时,页面块被放置在一个指定的次序链表中并被计数 1。第 2 种情况是,在运行的 CPU 中,页面集到达了内存上限。在这种情况下,页面集被赋予次 0 且计数为 pageset-batch。
在内核函数 __free_pages_bulk() 开始运行后,释放页面的机制与 2.4 中的伙伴链表非常类似。
仍然只有 3 个管理区,因此管理区修饰符仍然相同。然而,增加了 3 个新的 GFP 标志位,它们将影响 VM 的响应请求时工作力度,或者不工作。这些标志位如下:
下面介绍的另外一个 GFP 标志位是一个称为 __GFP_COLD 的分配修改器,用于保证非活动页面从 Per-CPU 的链表中分配出来。以 VM 的观点来看,只有函数 page_cache_alloc_cold() 使用这个标志位,而该函数主要在 I/O 预读中使用。通常,页面分配基活动页面链表中取得页面。
最后一个新标志位是 __GFP_NO_GROW。这个标志位是 slab 分配器(在第 8 章中讨论)所使用的内部标志位,它的别名是 SLAB_NO_GROW。它用于表明新的 slab 任何时候都不应该从特定的高速缓存中分配。实际上,系统引入 GFP 标志位以补充旧的 SLAB_NO_GROW 标志位,后者目前在主流内核中并不被使用。
在处理高速缓存相关和内存存取所需大量内存的问题时,内存中使用连续的物理页应该是可取的,但由于伙伴系统的外部分页问题,这种做法又经常行不通。所以 Linux 使用一种叫 vmalloc() 的机制,在这种机制中,非连续的物理内存在虚存中是连续的。
Linux 中虚拟地址空间在 VMALLOC_START 和 VMALLOC_END 之间保留了一块区域,VMALLOC_START 的位置取决于可以访问的物理内存大小,该存储区域的大小至少是 VMALLOC_RESERVE,x86 上它的大小为 127 MB。5.1 节中已经讨论过该存储区域的精确的大小。
该存储区域的页表可以按照请求修改以指向物理页指示器分配的物理页,这就意味着分配的大小必须是硬件页面大小的整数倍。由于分配内存需要改变内核页表,而且仅可用在 VMALLOC_START 和 VMALLOC_RESERVE 之间的虚拟地址空间,所以在内存映射到 vmalloc() 时,内存的数量是有限制的。正是由于这个原因,页表仅保留在核心内核中使用。在 2.4.22 中,页表仅用于存储映射信息 (见第 11 章) 和把内核模块装载到内存。
本章首先描述内核如何跟踪使用 vmalloc 地址空间中区域,接着是如何分配和释放存储区域。
vmalloc 地址空间由一个资源映射分配器管理 [Vah06],struct vm_struct 负责存储基地址/大小键对,在 linux/vmalloc.h 中 vm_struct 定义为:
// include/linux/vmalloc.h
struct vm_struct {
unsigned long flags;
void * addr;
unsigned long size;
struct vm_struct * next;
};
Linux 中的 VMA 的字段不止这么一些,它还包括其他的不属于 vmalloc 领域的信息,该结构体中的字段可以如下简要描述。
很显然,内存区域由 next 字段链到一起,并且为了查找简单,它们以地址为次序。为了防止溢出,每个区域至少由一个页面隔离开。如图 7.1 中的空隙所示。
当内核要分配一块新的内存时,函数 get_vm_area() 线性查找 vm_struct 链表,然后由函数 kmalloc() 分配结构体所需的空间。为了重新映射一块内存以完成 I/O,需要使用虚拟区域 (习惯上称之为 ioremapping) ,系统将直接调用该函数完成请求区域的映射。
如表 7.1 所列,Linux 提供了函数 vmalloc() ,vmalloc_dma() 和 vmalloc_32() 以在连续的虚拟地址空间分配内存。它们都只有一个参数 size,它的值是下一页的边距向上取整。这几个函数都返回新分配区域的线性地址。
图 7.2 中的调用图很清楚地表明,在分配内存时有两个步骤。第 1 步使用 get_vm_area() 找到满足请求大小的区域,get_vm_area() 查找 vm_struct 的线性链表,然后返回一个描述该区域的新结构体。
第 2 步首先使用 vmalloc_area_pages() 分配所需的 PGD 记录,然后使用 alloc_area_pmd() 分配 PMD 记录,接着使用 alloc_area_pte() 分配 PTE 记录,最后使用 alloc_page() 分配页面。
vmalloc() 更新的页表并不属于当前进程,属于当前进程的是在 init_mm→pgd 中的引用页表。这意味着当进程访问 vmalloc 区域时将发生缺页中断异常,因为它的页表并没有指向正确的区域。在这个缺页中断处理代码中有个特殊的地方,那就是中断处理代码知道在 vmalloc 区域有个缺页中断,它利用主页表的信息更新当前进程的页表。使用 vmalloc() 处理伙伴分配器以及缺页中断的方法如图 7.3 所示。
函数 vfree() (如表 7.2 所列) 负责释放一块虚拟内存区域,它首先线性查找 vm_struct 链表,在找到需要释放的区域后,就在其 s 上调用 vmfree_area_pages()。如图 7.4 所示。
// 表 7.2 非连续内存释放 API
void vfree(void addr)
// 释放由 vmalloc(), vmalloc_dma() 或 vmalloc_32() 分配的内存
vmfree_area_pages() 与 vmalloc_area_pages() 正好相反,它遍历页表,并清除该区域的页表记录和相应的页面。
2.6 中的非连续内存分配基本与 2.4 中的保持不变。主要的区别是分配页面的内部 API 有一些细微区别。在 2.4 中,vmalloc_area_pages() 负责遍历页表,调用 alloc_area_pte() 分配 PTE 以及页表。在 2.6 中,所有的页表都由 __vmalloc() 预先分配,然后存储在一个数组中并传递给函数 map_vm_area(),由它向内核页表中插入页面。
API 函数 get_vm_area() 也有细微的改变。在调用它时,它和以前一样遍历整个 vmalloc 虚拟地址空间,寻找一块空闲区域。但是,调用者也可以直接调用 __get_vm_area() 并指明范围,就可以只遍历 vmalloc 地址空间的一部分。这仅用于高级 RISC 机器 (ARM) 装载模块。
最后一个显著的改变是引入了一个新的接口 vmap(),它负责向 vmalloc 地址空间插入页面数组 。vmap() 仅用于声音子系统的内核。这个接口向后兼容到 2.4.22,但那时根本没有用到过它。它的引入仅仅是偶然性的向后兼容,或者是为了减轻那些需要 vmap() 的特定供应商补丁的应用负担。