在前面的博文里,我们讲解了基于80x86体系的Linux内核分段和分页机制,并详细地讨论了Linux的内存布局。有了这些基本概念以后,我们就来详细讨论内核如何动态地管理那些可用的内存空间。
对于80386这种32位的处理器结构,Linux采用4KB页框大小作为标准的内存分配单元。内核必须记录每个页框的当前状态,例如,区分哪些页框包含的是属于进程的页,而哪些页框包含的是内核代码或内核数据。内核还必须能够确定动态内存中的页框是否空闲,如果动态内存中的页框不包含有用的数据,那么这个页框就是空闲的。在以下情况下页框是不空闲的:包含用户态进程的数据、某个软件高速缓存的数据、动态分配的内核数据结构、设备驱动程序缓冲的数据、内核模块的代码等等。
内核用数据结构page描述一个页框的状态信息,所有的页描述符存放在全局mem_map数组中,其数组的下标为页框号(pfn)。因为每个描述符长度为32字节,所以mem_map所需要的空间略小于整个RAM的1%。
那么一个页描述符怎样与一个占据4k的页框相联系(映射)呢?有了mem_map数组,这个问题就很简单了。因为如果知道了page数据的地址pd,用pd去减去mem_map就得到了pd的页框号pfn。那么这个物理页的物理地址是physAddr = pfn << PAGE_SHIFT 。
在得知该物理页的物理地址是physAddr后,就可以视physAddr的大小得到它的虚拟地址:
1.physAddr < 896M 对应虚拟地址是 physAddr + PAGE_OFFSET (PAGE_OFFSET=3G)
2.physAddr >= 896M 对应虚拟地址不是静态映射的,通过内核的高端虚拟地址映射得到一个虚拟地址。
在得到该页的虚拟地址之后,内核就可以正常访问这个物理页了。
内核提供一个virt_to_page(addr)宏来产生线性地址addr对应的页描述符地址。pfn_to_page(pfn)宏产生与页框号pfn对应的页描述符地址。相反,也提供page_to_pfn(pg)宏来产生页描述符对应的页的页框号pfn。注意,针对80x86结构,上述宏并不是直接通过men_map数组来确定页框号的,而是通过内存管理区的zone_mem_map来确定的,不过原理是一样的:
#define page_to_pfn(pg) /
({ /
struct page *__page = pg; /
struct zone *__zone = page_zone(__page); /
(unsigned long)(__page - __zone->zone_mem_map) /
+ __zone->zone_start_pfn; /
})
这里千万要注意!不要混淆一个概念。这里的physAddr虽然表示物理地址,但是并不能说明该地址的数据就一定存在于物理内存中。那么如何判断这个页到底在不在内存中呢?你看,前面的知识就用到了——分页机制。也就是说,如果这个页因为各种各样五花八门的原因被交换出去了,那么它对应的页的Present标志就为0。这里就牵涉到缺页异常了,要深入了解,请关注笔者后面的博文。
在这里我们只需要对数据结构page详细讨论以下两个字段:
1、_count:页的引用计数器。如果该字段为-1,则相应页框空闲,并可被分配给任一进程或内核本身;如果该字段的值大于或等于0,则说明页框被分配给一个或多个进程,或用于存放一些内核数据结构。page_count()函数返回_count加1后的值,也就是该页的使用者的数目。
2、flags:包含多达32个用来描述页框状态的标志。对于每个PG_xyz标志,内核都定义了操纵其值的一些宏。通常,PageXyz宏返回标志的值,而SetPageXyz和ClearPageXyz宏分别设置和清除相应的位。
含义 | |
PG_locked | 页被锁定,例如,在磁盘I/O操作中涉及的页。 |
PG_error | 在传输页时发生错误 |
PG_referenced | 刚刚访问过的页 |
PG_uptodate | 在完成读操作后置位,除非发生磁盘I/O 错误 |
PG_dirty | 页已经被修改 |
PG_lru | 页在活动或非活动页链表中 |
PG_active | 页在活动页链表中 |
PG_slab | 包含在slab 中的页框 |
PG_highmem | 页框属于ZONE_HIGHMEM 管理区 |
PG_checked | 由一些文件系统(如Ext2 和Ext3)使用的标志 |
PG_arch_1 | 在80x86 体系结构上没有使用 |
PG_reserved | 页框留给内核代码或没有使用 |
PG_private | 页描述符的private字段存放了有意义的数据 |
PG_writeback | 正在使用writepage方法将页写到磁盘上 |
PG_nosave | 系统挂起 / 唤醒时使用 |
PG_compound | 通过扩展分页机制处理页框 |
PG_swapcache | 页属于对换高速缓存 |
PG_mappedtodisk | 页框中的所有数据对应于磁盘上分配的块 |
PG_reclaim | 为回收内存对页已经做了写入磁盘的标记 |
PG_nosave_free | 系统挂起 / 恢复时使用 |
系统是怎么为进程或内核分配一个内存空间,或者说怎么给他们分配一个线性页描述符所对应线性地址的页面呢?这个需要借助内核的分区页框分配器和伙伴系统算法。在讨论这些细节之前,先介绍一些必要的概念。
1 非统一内存访问(NUMA)架构
Linux2.6支持非统一内存访问(NUMA)模型,在这种模型中,给定CPU对不同内存单元的访问时间可能不一样。系统的物理内存被划分为几个节点(node)。在一个单独的节点内,任一给定CPU访问页面所需要的时间都是相同的,而对于不同的CPU,这个时间就不同。对每个CPU而言,内核都试图把耗时节点的访问次数减到最少,这就必须要将那些CPU最常引用的内核数据结构的存放位置选好。
每个节点由一个类型为pg_data_t的描述符表示,所有节点的描述符存放在一个单向链表中,它的第一个元素由内核全局变量pgdat_list指向。在x86体系中,即使是多核,内存访问时间也是相同的,所以不需要NUMA,但是内核还是使用节点,不过,这只是一个单独的节点,它包含了系统中所有的物理内存。因此,pgdat_list变量指向一个链表,此链表只有一个元素组成的,这个元素就是节点0描述符,它被存放在contig_page_data变量中。
pg_data_t描述符中要注意到的三个字段分别是node_zones、node_zonelists、node_mem_map,分别是zone_t[]、zonelist_t[]和page类型。前两个是用来描述内存管理区的,下面马上要谈到;node_mem_map是本节点所有页的页描述符数组。内核将这三个字段放在里边,就是为内存区、页框建立一些列的联系。
2 内存管理区
由于Linux内核必须处理80x86体系结构中的两种硬件约束:
(1)ISA总线的直接内存存取(DMA)处理器有一个严格的限制:他们只能对RAM的前16MB寻址。
(2)在具有较大容量RAM的现代32位计算机中,CPU不能直接访问所有的物理内存,因为线性地址空间太小。
为了应对这两种限制,Linux2.6把每个内存节点的物理内存划分成3个管理区(zone)。在80x86的UMA体系结构中的管理区分为:
ZONE_DMA:包含低于16MB的内存页框
ZONE_NORMAL:包含高于16MB而低于896MB的内存页框
ZONE_HIGHMEM:包含从896MB开始高于896MB的内存页框
ZONE_DMA和ZONE_NORMAL区包含内存“常规”页框,通过把他们线性地址映射到线性地址空间的第4个GB,内核就可以直接进行访问。ZONE_HIGHMEM区包含的内存页不能由内核直接访问,尽管它们也可以通过高端内存内核映射,线性映射到线性地址空间的第4个GB。
每个内存管理区都有自己的描述符zone_t,其字段中很多用于回收页框时使用。其实每个页描述符page都有到内存节点和到内存节点管理区的链接。那我们为啥看不到呢,原因是为了节省空间,这些链接的存放方式和典型的指针不同,是被编码成索引存放在flags字段的高位。
zone_t字段如下:
类型 | 名称 | 说明 |
unsigned long | free_pages | 管理区中空闲页的数量 |
unsigned long | pages_min | 管理区中保留页的数目 |
unsigned long | pages_low | 回收页框使用的下界;同时也被管理区分配器作为阈值使用 |
unsigned long | pages_high | 回收页框使用的上界;同时也被管理区分配器作为阈值使用 |
unsigned long [] | lowmem_reserve | 指明在处理内存不足的临界情况下每个管理区必须保留的页框数目 |
struct per_cpu_pageset[] | pageset | 数据结构用于实现单一页框的特殊高速缓存 |
spinlock_t | lock | 保护该描述符的自旋锁 |
struct free_area [] | free_area | 标识出管理区中的空闲页框块 |
spinlock_t | lru_lock | 活动以及非活动链表使用的自旋锁 |
struct list head | active_list | 管理区中的活动页链表 |
struct list head | inactive_list | 管理区中的非活动页链表 |
unsigned long | nr_scan_active | 回收内存时需要扫描的活动页数目 |
unsigned long | nr_scan_inactive | 回收内存时需要扫描的非活动页数目 |
unsigned long | nr_active | 管理区的活动链表上的页数目 |
unsigned long | nr_inactive | 管理区的非活动链表上的页数目 |
unsigned long | pages_scanned | 管理区内回收页框时使用的计数器 |
int | all_unreclaimable | 在管理区中填满不可回收页时此标志被置位 |
int | temp_priority | 临时管理区的优先级(回收页框时使用) |
int | prev_priority | 管理区优先级,范围在12 和0 之间(由回收页框算法使用) |
wait_queue_head_t * | wait_table | 进程等待队列的散列表,这些进程正在等待管理区中的某页 |
unsigned long | wait_table_size | 等待队列散列表的大小 |
unsigned long | wait_table_bits | 等待队列散列表数组大小,值为2order |
struct pglist_data * | zone_pgdat | 内存节点 |
struct page * | zone_mem_map | 指向管理区的第一个页描述符的指针 |
unsigned long | zone_start_pfn | 管理区第一个页框的下标 |
unsigned long | spanned_pages | 以页为单位的管理区的总大小,包括洞 |
unsigned long | present_pages | 以页为单位的管理区的总大小,不包括洞 |
char * | name | 指针指向管理区的传统名称:“DMA”,“NORMAL”或“HighMem” |
实际上,刻画页框的标志的数目是有限的,因此保留flags字段的最高位来编码内存节点和管理区是绰绰有余的。Linux提供page_zone()函数用来接收一个页描述符的地址作为它的参数;它读取该描述符中的flags字段的最高位,然后通过查看zone_table数组来确定相应管理区描述符的地址。顺便提一下,在系统启动时用,内核将所有内存节点的所有管理区描述符的地址放到这个zone_table数组里边。
当内核调用一个内存分配函数时,必须指明请求页框所在的管理区。内核通常指明它愿意使用哪个管理区。为了在内存分配请求中指定首选管理区,内核使用zonelist数据结构,这就是管理区描述符指针数组,在80x86中只有三个zone,所以zonelist数据结构中指向这三个zone的指针按照一定规则排列。如图,则zonelist数组就是这三个zone的排列组合。
例如,要分配一个用来做DMA的页框,则在指定zonelist数组中的某个zonelist元素中获得首选的zone,应该是ZONE_DMA,如果该区空间已使用完,就选ZONE_NORMA区,随后再是ZONE_HIGHMEM。