4. 内存管理子系统初始化:memblock分配器的诞生
在系统启动早期,内核面临一个“先有鸡还是先有蛋”的困境:伙伴系统(Buddy System)需要内存管理结构(如 struct page)来管理物理内存,但这些结构本身又需要分配内存来存放。memblock 分配器正是为了解决这一矛盾而诞生的——它是一个早期、简单、静态的物理内存分配器,在伙伴系统就绪之前提供临时的内存管理服务。
4.1 为什么需要 memblock?
内核启动顺序决定了内存管理子系统的初始化必须分阶段进行:
| 阶段 | 分配器 | 特点 | 依赖条件 |
|---|---|---|---|
| 1. 汇编启动 | 无(直接使用物理地址) | 极简,无管理 | 无 |
| 2. 早期C初始化 | memblock | 静态、线性、不可回收 | 仅需物理内存映射信息 |
| 3. 伙伴系统就绪 | Buddy + Slab | 动态、可回收、支持碎片管理 | 需要 struct page 数组 |
memblock 分配器在阶段 2 中扮演了桥梁角色:它负责分配存放 struct page 数组所需的内存,以及早期驱动、设备树、页表等关键数据结构的内存。
4.2 memblock 的核心数据结构
memblock 使用两个核心数组来跟踪物理内存:可用内存区域(memory)和预留内存区域(reserved)。每个区域由 struct memblock_region 描述:
struct memblock_region {
phys_addr_t base; // 物理基地址
phys_addr_t size; // 区域大小
enum memblock_flags flags; // 属性标志(如可热插拔)
};
struct memblock_type {
unsigned long cnt; // 当前区域数量
unsigned long max; // 最大区域数量
struct memblock_region *regions; // 区域数组
char *name; // 类型名称
};
struct memblock {
bool bottom_up; // 分配方向:从低地址还是高地址
phys_addr_t current_limit; // 分配上限
struct memblock_type memory; // 可用物理内存
struct memblock_type reserved; // 已预留内存
};
4.3 memblock 初始化流程
memblock 的初始化发生在 setup_arch() 函数中,大致分为以下步骤:
- 解析固件提供的内存信息(如 UEFI 内存映射、设备树
/memory节点) - 调用
memblock_add()将可用物理内存区域加入memblock.memory - 调用
memblock_reserve()标记内核镜像、页表、initrd 等已占用的区域 - 设置
memblock.current_limit限制分配范围(通常为 DMA 区域上限) - 调用
memblock_alloc()分配struct page数组所需的内存
regions 数组本身也需要内存。在早期,这个数组使用静态定义的 INIT_MEMBLOCK_REGIONS(通常为 128 个条目),如果启动过程中区域数量超过这个值,memblock 会尝试动态扩展——但此时它只能依赖自身来分配扩展所需的内存,形成一种“自举”过程。
4.4 memblock 分配与预留操作
memblock 提供两组核心 API:
| 操作 | API 函数 | 说明 |
|---|---|---|
| 添加可用内存 | memblock_add(base, size) |
将一段物理内存标记为可用 |
| 预留内存 | memblock_reserve(base, size) |
标记一段内存为已占用,不可再分配 |
| 分配内存 | memblock_alloc(size, align) |
从可用区域分配对齐后的物理内存 |
| 分配(指定上限) | memblock_alloc_range(size, align, start, end) |
在指定地址范围内分配内存 |
| 释放内存 | memblock_free(phys_addr, size) |
将预留的内存重新标记为可用 |
| 查询信息 | memblock_phys_mem_size() |
返回总的可用物理内存大小 |
4.5 memblock 分配策略:自底向上 vs 自顶向下
memblock 支持两种分配方向,由 memblock.bottom_up 标志控制:
- 自底向上(bottom-up):从低地址开始分配,适用于大多数架构,有利于 DMA 设备访问。
- 自顶向下(top-down):从高地址开始分配,在某些架构(如 x86)的早期阶段使用,以避免覆盖低地址区域的固件数据。
分配算法非常直接:遍历 memblock.memory 中的区域,找到第一个满足大小和对齐要求的空闲区间(即该区间不与任何 memblock.reserved 区域重叠),然后将其标记为预留。
4.6 memblock 与 DMA 的早期关联
在设备驱动加载的上下文中,memblock 与 DMA 的关联体现在两个方面:
- DMA 区域限制:
memblock.current_limit通常被设置为 DMA 区域的物理上限(如 16MB 或 4GB),确保早期分配的内存都在 DMA 可访问范围内。 - DMA 页表分配:某些架构在启动早期使用 memblock 分配 DMA 页表所需的物理内存。
dma_alloc_coherent() 时,如果伙伴系统尚未完全初始化(极少数情况),内核会回退到 memblock 分配器。但通常情况下,驱动加载时伙伴系统已经就绪,memblock 已完成其历史使命。
4.7 memblock 的生命周期与退出
memblock 分配器在伙伴系统初始化完成后被废弃,但它的数据结构并不会被销毁——它们被保留用于调试和内存信息查询。转换过程发生在 mm_init() 函数中:
// mm/page_alloc.c
void __init memblock_free_all(void)
{
unsigned long pages, total_pages = 0;
struct memblock_region *reg;
// 遍历所有 memblock 中的可用区域
for_each_memblock(memory, reg) {
pages = memblock_free_memory_pages(reg);
total_pages += pages;
}
// 将释放的页添加到伙伴系统
pr_info("Freeing %lu pages to buddy system\n", total_pages);
}
此后,所有内存分配请求都通过伙伴系统和 slab 分配器处理,memblock 只作为只读的信息源存在。
4.8 memblock 初始化全景图
下面的 SVG 图展示了 memblock 分配器在内核启动过程中的位置及其与前后阶段的关系:
4.9 关键代码路径示例
以下是一个简化的 memblock 初始化代码路径,展示了从固件信息到 memblock 区域建立的过程:
// arch/arm64/mm/init.c
void __init arm64_memblock_init(void)
{
// 1. 从设备树获取物理内存信息
early_init_fdt_scan_reserved_mem();
// 2. 添加可用内存
memblock_add(__pa(_text), (u64)(_end - _text));
// 3. 预留内核镜像自身
memblock_reserve(__pa(_text), (u64)(_end - _text));
// 4. 预留设备树
memblock_reserve(__pa(initial_boot_params),
fdt_totalsize(initial_boot_params));
// 5. 预留 initrd
if (initrd_start)
memblock_reserve(__pa(initrd_start), initrd_end - initrd_start);
// 6. 设置 DMA 区域限制
if (IS_ENABLED(CONFIG_ZONE_DMA))
memblock_set_current_limit(arm64_dma_phys_limit);
}
4.10 总结:memblock 的设计哲学
| 设计原则 | 具体体现 | 对驱动开发的影响 |
|---|---|---|
| 极简 | 只维护两个区域列表,无碎片管理 | 早期驱动分配内存时无需考虑释放 |
| 自举 | 自身数据结构可动态扩展 | 保证即使复杂系统也能完成初始化 |
| 临时性 | 伙伴系统就绪后即被取代 | 驱动 probe 时通常已使用伙伴系统 |
| DMA 感知 | 通过 current_limit 限制分配范围 | 确保早期分配的内存对 DMA 友好 |
memblock 分配器的诞生解决了内核启动过程中的“鸡生蛋”问题,它为后续伙伴系统的建立铺平了道路,也为早期设备驱动(如中断控制器、定时器)提供了必要的内存支持。理解 memblock 的工作原理,有助于深入把握内核内存初始化的完整脉络,以及在驱动开发中遇到早期内存分配问题时的调试思路。