2023-08-16 06:32:54 来源 : 博客园
提要:本系列文章主要参考MIT 6.828课程
以及两本书籍《深入理解Linux内核》
《深入Linux内核架构》
对Linux内核内容进行总结。内存管理的实现覆盖了多个领域:
(资料图片)
上一节介绍了内存管理相关的主要数据结构以及它们之间的关系,本节主要介绍这些数据结构的初始化,方便在介绍内存分配时读者思路更加清晰(这部分内容主要参考《深入Linux内核架构》
)。
函数start_kernel()负责完成Linux内核的初始化工作,内存管理相关数据结构的初始化也是从这里进行的。下图给出 start_kernel的代码流程图,其中只包括内存管理相关的系统初始化函数。
各个函数的作用简述如下:
函数名 | 描述 |
---|---|
setup_arch | 特定于体系结构的设置函数,由于内存管理实际上管理的是真正物理内存的应用,务必会与不同体系结构有联系,在本方法中还会初始化自举分配器 |
setup_per_cpu_areas | 在SMP系统上,初始化源代码中定义的per-cpu变量(这种变量每个CPU有一个副本,因此叫per-cpu),在非SMP系统上该函数是一个空操作 |
build_all_zonelists | 建立结点和内存域的数据结构(重要) |
mem_init | 用于停用bootmem分配器(自举分配器)并迁移到实际的内存管理函数 |
kmem_cache_init | 初始化内核内部用于小块内存区的分配器 |
setup_per_cpu_pageset | 为struct zone中的pageset数组的第一个元素分配内存,负责冷热分配器 相关的设置 |
为了简化记忆,笔者做了如下的总结:
自举分配器
完成这项工作自举分配器
就可以创建3层数据结构,即下图结构:自举分配器
占用的内存释放掉或者交由伙伴系统管理那我们依序开始。
由于内存管理管理的是真实的物理内存,因此,需要和体系结构强相关,setup_arch函数主要负责这一部分内容。下图给出了setup_arch中与内存管理相关的代码流程图:
各个函数职责如下:
方法名 | 职责 |
---|---|
machine_specific_memory_setup | 正如上一节中介绍的,为了获取物理内存中的保留内存地址(没有被使用的,即未来管理的真实内存),BIOS提供了一个一组物理地址范围和其对应的内存类型的表 ,生成该表就是在这个方法 |
parse_cmdline_early | 内核通过该方法分析命令行,进而获取mem=XXX[KkmM]、highmem=XXX[kKmM]或memmap=XXX[KkmM]" "@XXX[KkmM]这类参数。 |
setup_memory | 该函数主要负责如下3件事:1. 确定(每个结点)可用的物理内存页的数目。2. 初始化bootmem分配器(这部分会单独介绍)。3. 接下来分配各种内存区,例如,运行第一个用户空间过程所需的最初的RAM磁盘 |
paging_init | 初始化内核页表并启用内存分页 |
zone_sizes_init | 初始化系统中所有结点的pgdat_t实例,首先使用add_active_range,对可用的物理内存建立一个相对简单的列表。体系结构无关的函数free_area_init_nodes接下来使用该信息建立完备的内核数据结构。 |
本部分主要介绍两个内容:
free_area_init_nodes
,这个函数很重要)paging_init负责按照指定方式(通常是3:1)划分虚拟地址空间,代码流程图如下:
struct zone的pageset成员用于实现冷热分配器(hot-n-cold allocator)。
struct zone { ... struct per_cpu_pageset pageset[NR_CPUS]; ...};
在多处理器系统上每个CPU都有一个或多个高速缓存,各个CPU的管理必须是独立的(从struct 名字也可以看出来per_cpu
_pageset)。这里的NR_CPUS是一个可以在编译时间配置的宏常,表示内核支持的CPU的最大数目。struct per_cpu_pageset
代码如下:
struct per_cpu_pageset { struct per_cpu_pages pcp[2]; /* 索引0对应热页,索引1对应冷页 */} ____cacheline_aligned_in_smp;
由注释可以看到,对于每个cpu都包含了一个struct per_cpu_pages数组
,长度为2,pcp[0]存储热页,pcp[1]存储冷页。struct per_cpu_pages
结构体代码如下:
struct per_cpu_pages { int count; /* 列表中页数 */ int high; /* 页数上限stake,在需要的情况下清空列表 */ int batch; /* 添加/删除多页块的时候,块的大小 */ struct list_head list; /* 页的链表 */};
多处理器下,pageset结构形如下图:
而pageset
属性的初始化,就由setup_arch()
完成,具体来说是free_area_init_nodes()
函数,伙伴系统的数据结构初始化也是由这个方法完成的,因此会看setup_arch处介绍该函数时,描述是体系结构无关的函数free_area_init_nodes接下来使用该信息建立完备的内核数据结构
。
free_area_init_nodes()
调用zone_pcp_init
初始化冷热缓存。可以看到struct per_cpu_pages
主要包括两个属性:
static __devinit void zone_pcp_init(struct zone *zone){ int cpu; unsigned long batch = zone_batchsize(zone); for (cpu = 0; cpu < NR_CPUS; cpu++) { setup_pageset(zone_pcp(zone,cpu), batch); } if (zone->present_pages) printk(KERN_DEBUG " %s zone: %lu pages, LIFO batch:%lu\n",zone->name, zone->present_pages, batch);}
zone_pcp_init
通过调用zone_batchsize
计算batch的具体值,然后再通过setup_pageset
以batch为参考初始化每个struct per_cpu_pages
:
inline void setup_pageset(struct per_cpu_pageset *p, unsigned long batch){ struct per_cpu_pages *pcp; memset(p, 0, sizeof(*p)); pcp = &p->pcp[0]; /* 热 */ pcp->count = 0; pcp->high = 6 * batch; pcp->batch = max(1UL, 1 * batch); INIT_LIST_HEAD(&pcp->list); pcp = &p->pcp[1]; /* 冷 */ pcp->count = 0; pcp->high = 2 * batch; pcp->batch = max(1UL, batch/2); INIT_LIST_HEAD(&pcp->list);}
可以看到:
冷页列表的stake稍低一些,因为冷页并不放置到缓存中,只用于一些关注性能的操作(当然,在内核中这样的操作属于少数)。最后给出batch的计算方法即zone_batchsize
:
static int __devinit zone_batchsize(struct zone *zone){ int batch; batch = zone->present_pages / 1024; if (batch * PAGE_SIZE > 512 * 1024) batch = (512 * 1024) / PAGE_SIZE; batch /= 4; if (batch < 1) batch = 1; batch = (1 << (fls(batch + batch/2)-1)) -1; return batch;}
公式比较复杂(用的时候找得到出处就好),但上述代码计算得到的batch,大约相当于内存域中页数的0.25‰。
注意:初始化系统中所有结点的pgdat_t实例是在setup_arch
中的zone_sizes_init
完成的。
build_all_zonelists负责建立管理结点和其内存域所需的数据结构,这里我们主要关注的是zonelist的组织顺序。在UMA系统中,只有一个结点需要管理,为了方便通过node id获取具体的pg_data_t(后面称作结点描述符)实例信息,linux提供了如下的宏:
#define NODE_DATA(nid)
但对于UMA来说,这个宏的实现就是如下代码:
#define NODE_DATA(nid) (&contig_page_data)
我们在上一节介绍Node时,注意
中提到了contig_page_data
就是UMA中唯一的结点。
但是整个Node中有3个管理区,三个管理区的分配次序如何呢?
我们在Node的数据结构中找到了一个名为node_zonelists
的属性,该属性主要用于指定备用结点及其内存域的列表,以便在当前结点没有可用空间时,在备用结点分配内存
,例如如果ZONE_HIGHMEM
内存域内存不够分配时,可以尝试向ZONE_NORMAL
请求分配内存。
typedef struct pglist_data { ... struct zonelist node_zonelists[MAX_ZONELISTS]; ...} pg_data_t;#define MAX_ZONES_PER_ZONELIST (MAX_NUMNODES * MAX_NR_ZONES)struct zonelist { ... struct zone *zones[MAX_ZONES_PER_ZONELIST + 1]; // NULL分隔};
在介绍Zone时,我们介绍了3类Zone:
名称 | 描述 |
---|---|
ZONE_DMA | 包含低于16MB的内存页框 |
ZONE_DMA32 | 使用32位地址字可寻址、适合DMA的内存域。显然,只有在64位系统上,两种DMA内存域才有差别 |
ZONE_NORMAL | 包含高于16MB且低于896MB的内存页框 |
ZONE_HIGHMEM | 包含从896MB开始高于896MB的内存页框 |
这三类内存中ZONE_DMA
部分,ISA可以对其进行直接寻址,ZONE_NORMAL
也是直接映射到内核空间的,而ZONE_HIGHMEM
就需要选择性的映射到内核空间中。在这3类内存中:
ZONE_DMA
是最昂贵的,它用于外设和系统之间的数据传输ZONE_NORMAL
其次,许多内核数据结构必须保存在该内存域ZONE_HIGHMEM
最廉价,因为内核没有任何部分依赖于从该内存域分配的内存。针对这个情况,内核针对当前内存结点的备选结点,定义了一个等级次序,确定这一等级次序的函数就是build_zonelists
函数。
内核在调用了build_all_zonelists后,会将工作委托给__build_all_zonelists
,该函数只是简单的对每个结点都调用build_zonelists
:
static int __build_all_zonelists(void *dummy){ int nid; for_each_online_node(nid) { pg_data_t *pgdat = NODE_DATA(nid); build_zonelists(pgdat); ... } return 0;}
结构体zonelist的定义如下:
#define MAX_ZONES_PER_ZONELIST (MAX_NUMNODES * MAX_NR_ZONES)struct zonelist { ... struct zone *zones[MAX_ZONES_PER_ZONELIST + 1]; // NULL分隔};
其长度为Node数*Zone数+1
,最后一个结点是NULL
负责标记列表结尾。在pg_data_t中,node_zonelists的长度为MAX_ZONELISTS
typedef struct pglist_data { ... struct zonelist node_zonelists[MAX_ZONELISTS]; ...} pg_data_t;
这个长度是多少呢?build_zonelists()
函数给出了这个答案:
static void __init build_zonelists(pg_data_t *pgdat){ int node, local_node; enum zone_type i,j; local_node = pgdat->node_id; for (i = 0; i < MAX_NR_ZONES; i++) { struct zonelist *zonelist; zonelist = pgdat->node_zonelists + i; j = build_zonelists_node(pgdat, zonelist, 0, i); ...}
build_zonelists()
函数为每个ZONE创建了一个zonelist
用于表示其备用内存域列表,填充该列表的工作主要交给build_zonelists_node()
方法:
static int __init build_zonelists_node(pg_data_t *pgdat, struct zonelist *zonelist, int nr_zones, enum zone_type zone_type){ struct zone *zone; do { // 通过指针获取zone_type类型的zone zone = pgdat->node_zones + zone_type; // 如果有空闲空间,则将zone添加到zonelists中 if (populated_zone(zone)) { zonelist->zones[nr_zones++] = zone; } // 选取更为昂贵的zone判断是否加入到备用列表中 zone_type--; } while (zone_type >= 0); return nr_zones;}
这里有一个小细节,在内核中zone_type使用如下enum来保存:
enum zone_type {#ifdef CONFIG_ZONE_DMA ZONE_DMA,#endif#ifdef CONFIG_ZONE_DMA32 ZONE_DMA32,#endif ZONE_NORMAL,#ifdef CONFIG_HIGHMEM ZONE_HIGHMEM,#endif ZONE_MOVABLE, MAX_NR_ZONES};
因此,每次zone_type --
就是选取了更为昂贵的内存区域。因此,内核在build_zonelists中按分配代价从昂贵到低廉的次序
,迭代了结点
中所有的内存域。而在build_zonelists_node中,则按照分配代价从低廉到昂贵
的次序,迭代了分配代价不低于当前内存域
的内存域。
举一个例子,对于一个系统拥有4个结点,其结点2的高端内存即ZONE_HIGHMEM的备用列表创建过程大致如下:
因为zonelist还会引用其它结点的内存域,因此需要确定结点之间的次序,相关代码如下:
static void __init build_zonelists(pg_data_t *pgdat){ ... for (node = local_node + 1; node < MAX_NUMNODES; node++) { j = build_zonelists_node(NODE_DATA(node), zonelist, j, i); } for (node = 0; node < local_node; node++) { j = build_zonelists_node(NODE_DATA(node), zonelist, j, i); } zonelist->zones[j] = NULL; }}
实际上就是先引用大于当前结点的结点,再引用小于当结点结点。对总数N个结点中的结点m来说,内核生成备用列表时,选择备用结点的顺序总是:m 、m+1、m+2、…、 N-1、0、1、…、 m-1。这确保了不过度使用任何结点。最终结点2即第三个结点上的每个Zone的备用列表如下:
在启动过程期间,尽管内存管理尚未初始化,但内核仍然需要分配内存以创建各种数据结构。bootmem分配器用于在启动阶段早期分配内存。
显然,对该分配器的需求集中于简单性方面,而不是性能和通用性。因此内核开发者决定实现一个 最先适配(first-fit)分配器用于在启动阶段管理内存,这是可能想到的最简单方式。
传统操作系统课本会介绍4种动态分区分配方法:
这里选用的是首次适应算法(通常效果最好):
该分配器使用一个位图来管理页,位图比特位的数目与系统中物理内存页的数目相同。比特位为1,表示已用页;比特位为0,表示空闲页。因为该过程不是很高效,因为每次分配都必须从头扫描比特链。因此在内核完全初始化之后,不能将该分配器用于内存管理。
为了实现该自举分配器,内核使用了如下结构:
typedef struct bootmem_data { // 保存了系统中第一个页的编号,大多数体系结构下都是零 unsigned long node_boot_start; // 是可以直接管理的物理地址空间中最后一页的编号。换句话说,即ZONE_NORMAL的结束页 unsigned long node_low_pfn; // 是指向存储分配位图的内存区的指针。 void *node_bootmem_map; // last_pos是上一次分配的页的编号。如果没有请求分配整个页,则last_offset用作该页内部的偏移量。这使得bootmem分配器可以分配小于一整页的内存区 unsigned long last_offset; unsigned long last_pos; // last_success指定位图中上一次成功分配内存的位置,新的分配将由此开始。尽管这使得最先适配算法稍快了一点,但仍然无法真正代替更复杂的技术。 unsigned long last_success; // 内存不连续的系统可能需要多个bootmem分配器。 struct list_head list;} bootmem_data_t;
bootmem分配器的初始化是一个特定于体系结构的过程,此外还取决于所述计算机的内存布局。IA-32使用setup_memory
,setup_bootmem_allocator
来初始化bootmem分配器,而AMD64则使用contig_initmem_init
。
bootmem对于分配内存提供了如下接口:
这些函数都是__alloc_bootmem的前端,后者将实际工作委托给__alloc_bootmem_nopanic。由于可以注册多个bootmem分配器,__alloc_bootmem_core会遍历所有的分配器,直至分配成功为止。
在NUMA系统上,__alloc_bootmem_node则用于实现该API函数。首先,工作传递到__alloc_bootmem_core,尝试在该结点的bootmem分配器进行分配。如果失败,则后退到__alloc_bootmem,并将尝试所有的结点。
void * __init __alloc_bootmem(unsigned long size, unsigned long align,unsigned long goal)
__alloc_bootmem需要3个参数来᧿述内存分配请求:size是所需内存区的长度,align表示数据的对齐方式,而goal指定了开始搜索适当空闲内存区的起始地址。在进行内存分配时,大致执行如下操作(和课本上讲述的首次适应算法
步骤基本类似):
bootmem提供了free_bootmem和free_bootmem_node(NUMA)来释放内存,但这两个方法都将具体逻辑委托给__free_bootmem_core
。
在系统初始化进行到伙伴系统分配器能够承担内存管理的责任后,必须停用bootmem分配器,停用过程调用free_bootmem和free_bootmem_node(NUMA)函数来完成:
许多内核代码块和数据表只在系统初始化阶段需要,因此不必要在内核内存中保持其数据结构的初始化例程。
内核提供了两个属性(__init和__initcall)用于标记初始化函数和数据,释放内存时,只需要删除这部分数据就可以了。使用样例如下:
int __init hyper_hopper_probe(struct net_device *dev)static char stilllooking_msg[] __initdata = "still searching...";
初始化函数实现的背后,其一般性的思想在于,将数据保持在内核映像的一个特定部分,在启动结束时可以完全从内存删除。可以从上面两个label的宏定义看到:
#define __init __attribute__ ((__section__ (".init.text"))) __cold#define __initdata __attribute__ ((__section__ (".init.data")))
__attribute__是一个特殊的GNU C关键字,属性即通过该关键字使用。__section__属性用于通知编译器将随后的数据或函数分别写入二进制文件(ELF文件)的.init.data和.init.text段。前缀__cold还通知编译器,通向该函数的代码路径可能性较低,即该函数不会经常调用,对初始化函数通常是这样。
因此只需要了解这两个段的起始和终止地址,对应释放这部分就可以了。内核定义了一对变量__init_begin和__init_end,负责完成该工作。
本部分主要介绍了内核管理主要数据结构的初始化内容,下一节我们会开始介绍伙伴系统。
标签: