☰
Linux内核启动早期内存管理:memblock初始化深度解析
2026/10/16 5:20:34 网站建设 项目流程

提到Linux内核的启动流程,我猜你早晚会撞上“memblock”这个名字。它既不是伙伴系统那样的正式分配器,也不是slab那样的对象缓存,但它偏偏承担着整个内核生命周期里最早期、最脏最累的那段内存管理工作——从汇编代码交出物理内存布局开始,到伙伴系统正式接管之前,所有跟内存相关的操作都绕不开它。这篇文章我打算把memblock的初始化讲透,包括它的数据结构、启动路径、分配规则,以及我在实际调试中踩过的几个坑。如果你正在读内核源码,或者准备做早期启动相关的开发,这篇应该能帮你省不少时间。

1. 为什么需要memblock:从bootmem说起

1.1 先解决“先有鸡还是先有蛋”的问题

熟悉伙伴系统的读者都知道,buddy system是Linux内核内存管理的绝对核心,它按2的幂次把物理页分成不同阶的链表,分配、释放效率都很高。但这里有个逻辑悖论:伙伴系统本身需要初始化,而初始化它又需要先分配内存。内核不可能在没有任何内存管理机制的情况下,凭空给伙伴系统分配管理数据结构。

所以内核必须有一个“前伙伴系统时代”的过渡方案。早期内核用的是bootmem分配器,思路非常朴素:把物理内存按页为单位,用一张位图记录每一页的使用状态,分配时从位图里找空闲页,逐页分配。这个东西能用,但缺点也很明显:分配粒度太粗,只能整页操作;位图随内存增大而膨胀;对大块连续内存的支持很差,分配一个大的连续物理区域要多次循环扫描位图。

实际上我翻了几个老版本的内核代码,bootmem在CPU数量和内存规模都还小的年代确实够用。但到了多核、NUMA、内存热插拔逐渐普及之后,bootmem的问题就被放大了。它的数据结构和节点绑定太死,热插拔内存时很难优雅地调整位图;在内存碎片化严重的情况下,找到一个足够大的连续区域要付出很高的代价。

1.2 bootmem 的瓶颈与 memblock 的取舍

memblock正是在这个背景下被引入的。它不再用“位图逐页记账”的方式,而是把物理内存抽象成若干个连续区域(region),每个区域记录起始地址、大小和其他属性。整段内存要么被记到memory集合里,要么被记到reserved集合里,逻辑非常直观。

你可能会问:区域管理的粒度是不是太粗了?没错,memblock本来就不是给普通业务分配内存用的,它只服务内核启动早期的少量分配需求,比如建立页表、分配struct page数组、保留initrd、给DMA/CMA预留区域等。它的使命是在“内存布局还不清晰”和“伙伴系统还没就绪”的窗口期,提供一种足够简单、可靠可见的内存记账和分配手段。

相比bootmem,memblock还有一个关键优势:它把“记录”和“分配”分开了。memblock先把所有物理内存登记到memory集合,把不能动的地方登记到reserved集合,之后再从“memory减去reserved”的空隙里找合适地址分配。这个“先登记、后分配”思路极大降低了早期初始化代码的复杂度。

2. memblock 数据结构的深度拆解

2.1 三个核心结构体:memblock、memblock_type、memblock_region

看memblock源码时,你只需要抓住三个结构体,其余一切都围绕它们展开。

struct memblock_region { phys_addr_t base; phys_addr_t size; enum memblock_flags flags; int nid; }; struct memblock_type { unsigned long cnt; unsigned long max; phys_addr_t total_size; struct memblock_region *regions; char *name; }; struct memblock { bool bottom_up; phys_addr_t current_limit; struct memblock_type memory; struct memblock_type reserved; };
  • memblock_region描述一段连续物理内存:base是起始物理地址,size是长度,flags描述该区域的性质,nid表示它所属的NUMA节点。
  • memblock_type是同类型region的动态数组:cnt是当前区域个数,max是数组容量,total_size是区域内所有size之和,regions指向区域数组,name用于调试打印。
  • memblock是全局总控:memory记录所有可用物理内存区域,reserved记录所有被占用的区域,bottom_up决定分配方向,current_limit限制分配地址上限。

默认情况下,memory和reserved各自使用一个静态初始数组,容量由INIT_MEMBLOCK_REGIONS和INIT_MEMBLOCK_RESERVED_REGIONS定义,通常都是128。也就是说,在没有动态扩容之前,最多只能记录128段不连续区域。

这里有一点值得注意:total_size不是max乘以容量之类的估算值,而是所有region的size求和,它等于当前类型所代表的物理内存总量。调试时看这个字段特别有用,能直接判断是不是丢了一段内存。

2.2 静态数组、动态扩容与“递归分配”陷阱

既然初始数组是静态的,那么一旦系统内存布局碎片化严重,或者reserved区域特别多,128个槽位就会不够用。这时候内核会调用memblock_double_array来扩容,把原来的静态数组换成一个动态分配的新数组。

这个扩容过程有个非常容易让人头晕的点:分配新数组本身需要内存,而内存分配又要经过memblock,这看起来像“鸡生蛋”死循环。实际实现并没有直接调用memblock_alloc走完整分配路径,而是通过底层查找接口在现有空隙中找一个满足大小的物理区域,把新数组放进去,更新regions指针后再继续原有的插入操作。如果此时内存真的紧张到连一个新的region数组都放不下,内核只能直接panic。

我提醒各位:调试早期启动问题时,如果看到memblock_double_array相关的崩溃,不要先怀疑算法逻辑,先看看是不是物理内存布局过于碎片化,或者预留区域过多导致初始槽位被撑爆。修改INIT_MEMBLOCK_REGIONS虽然能缓解,但根治办法还是要检查哪些区域值得保留、哪些可以合并。

另外,动态扩容之后,原本静态数组占用的空间会被释放回memblock,这属于启动阶段非常底层的细节。普通驱动开发基本碰不到,但如果你在做早期启动代码或移植新板子,搞清楚这一环能省很多排查时间。

2.3 区域类型与标志位:不仅分 memory 和 reserved

memblock_type的name字段通常被设置为"memory"和"reserved",但region层面还有一个flags字段,常见的有:

  • MEMBLOCK_NONE:默认值,表示这是普通可用内存。
  • MEMBLOCK_NOMAP:表示这段内存虽然存在,但内核不会建立直接映射,典型用于某些IO保留区域或不适合被内核线性映射的地址。
  • MEMBLOCK_HOTPLUG:表示该区域参与内存热插拔,后续可以被在线/离线操作。

这个设计的好处是,内核在后续遍历memblock时,可以根据flags做精细过滤。例如建立页表映射时,MEMBLOCK_NOMAP区域会被跳过;释放内存给伙伴系统时,MEMBLOCK_NOMAP区域也不会被当作普通可用内存处理。如果你在某块板子上发现一段内存“凭空消失”,先查它是不是被标记了MEMBLOCK_NOMAP。

3. 启动阶段:memblock 在内存初始化中的完整路径

3.1 从固件到内核:内存节点的录入

以ARM64为例,整个流程可以简化成下面几步。

CPU复位后,固件(比如UEFI或引导程序)会把设备树或ACPI表放到某个地址,把控制权交给内核。内核在最前面的汇编代码里只做了极少量初始化,比如设置栈指针、使能MMU,然后跳转到C代码入口start_kernel。此时内核还不知道物理内存长什么样,唯一的信息来源是设备树或ACPI里的内存描述节点。

在setup_arch过程中,内核会解析设备树的内存节点,逐个调用memblock_add,把固件报告的物理内存范围登记到memblock.memory。这一步是整个内存初始化的地基,如果固件给的地址范围错了,后面所有工作都白搭。

调用链路大致是:

start_kernel() -> setup_arch() -> arm64_memblock_init() -> early_init_fdt_scan_nodes() -> early_init_dt_scan_memory() -> early_init_dt_add_memory_arch() -> memblock_add()

memblock_add内部会检查新区域与已有区域是否重叠、是否相邻,如果相邻就尝试合并,保证region数组尽可能紧凑。

我当时第一次看这段代码时有个疑惑:为什么固件给的内存范围和内核镜像实际加载地址不在同一个段里?后来才明白,内核镜像(包括text、rodata、data)本身也占用物理内存,而这段内存在memblock_add之后会被立刻登记到reserved集合,否则后续分配内存时极可能把内核自身的镜像覆盖掉。

3.2 一个典型启动序列:以 ARM64 为例

这里我以一次典型启动序列为例,梳理关键步骤:

  1. 解析设备树,把所有物理内存加入memblock.memory。
  2. 保留内核镜像本身:调用memblock_reserve,把从_text到_end的整个镜像区域标记为reserved。
  3. 保留设备树二进制(DTB)、initrd镜像、EFI系统表等启动信息所在的内存。
  4. 解析设备树里的reserved-memory节点,为DMA、CMA等预保留区域,这些区域后续会被特定的驱动或子系统使用。
  5. 调用paging_init,根据memblock.memory中可用的物理内存范围建立内核页表,创建线性映射。
  6. 在内核页表建立后,把直接映射中不再需要的早期临时映射清理掉。

每一步都对应一段代码,但如果你只看arm64_memblock_init这个函数,它并不会一次性把所有保留动作做完。更准确地说,它的职责是把“当前时刻能确定的保留项”全部登记到memblock.reserved,而更晚的一些保留动作则分散在DMA初始化、内存热插拔准备的过程中。

这也解释了为什么memblock_reserve和memblock_alloc在启动早期会交替出现:先保留已知固定资源,再分配运行时需要的数据结构,分配出来的区域又会自动进入reserved集合。

3.3 memblock 分配器的工作模式:分配时机与剩余内存去向

当伙伴系统还没就绪时,内核里如果有临时内存需求,就会调用memblock_alloc一族的接口。比如分配页表、分配struct page数组、分配早期log buffer等。

这里有几个分配参数的细节:

  • 对齐:大部分memblock分配都会要求对齐到某个边界,比如SMP_CACHE_BYTES、页大小或者2MB。因为早期分配出来的内存要直接承载页表或数据结构,不满足对齐会给后续映射带来麻烦。
  • current_limit:memblock有一个地址上限,默认可能被设定为某个值(例如DMA能够访问的范围或内核映射范围)。分配器只会在这个上限以下搜索空闲地址,保证分配结果对DMA引擎可访问。
  • bottom_up:默认通常是自低地址向上分配,但某些场景下会改成自高地址向下,比如需要优先保留低端内存供DMA使用时。

分配完成后,如果这块内存后续不再需要,可以调用memblock_free或memblock_phys_free把它从reserved集合里移回memory集合。这也是memblock强调“记账”胜过“管理”的体现:它不维护复杂的空闲链表,只维护两个region数组。

到了内存初始化的最后阶段,内核会调用memblock_free_all,把memblock.memory里所有不在reserved集合中的区域,逐页释放给伙伴系统。这个过程会初始化每个物理页对应的struct page,并把它们挂到伙伴系统的空闲链表上。此后,日常内存分配完全交给伙伴系统和slab分配器,memblock就基本完成了历史使命。

4. memblock 调试与常见问题实录

4.1 “memblock=debug”和必要内核配置

遇到memblock相关的问题时,第一件该做的事是打开调试输出。内核启动参数里加一个memblock=debug,就能让memblock在启动过程中打印每一次add、reserve、alloc操作。这个输出对早期布局排查极其有用。

加参数之后,dmesg里能看到类似这样的信息:

MEMBLOCK: memory size = 0x... type = memory

先确认memblock汇总的物理内存总量是否和板子硬件匹配,再看reserved集合里的每个区域是否合理,基本能定位绝大多数“内存少了”“地址冲突”的问题。

另外,有两个Kconfig选项需要关注:CONFIG_ARCH_KEEP_MEMBLOCK和CONFIG_DEBUG_FS。如果都打开,系统起来后还能在debugfs里查看memblock的最终状态,一般在/sys/kernel/debug/memblock下能看到memory和reserved的明细。这个信息对分析“内存被谁占用了”非常方便。

需要注意的是,生产内核通常不保留memblock数据,因为memblock本身的regions数组占用内存,保留它相当于增加常驻开销。调试时打开、发布时关掉,是常见做法。

4.2 容量不足与初始数组溢出

我遇到过的典型问题之一,是memblock初始region数组容量不足。现象通常有两种:一种是启动过程直接panic,另一种更隐蔽——系统能启动,但部分内存区域没有被伙伴系统接管。

先说说panic的情况。当cnt超过max时,扩容路径如果失败,内核会调用panic。这种情况下,先用memblock=debug确认region数量增长趋势,再考虑在memblock.h里调大INIT_MEMBLOCK_REGIONS或INIT_MEMBLOCK_RESERVED_REGIONS。

更隐蔽的是第二种。某些预留操作在扩容时没有严格检查,导致后续遍历时漏掉一部分区域。这种问题往往要结合完整启动日志和硬件内存布局图才能定位。我的经验是:拿到板子的内存布局后,先把dmesg里所有memblock_add的地址范围画出来,和硬件手册对比,基本一眼能看出哪个region丢了。

另外,大量小的reserve操作会快速消耗槽位。比如固件把设备树拆成很多小块,或者reserved-memory节点特别多。如果确认是这种情况,可以先把相邻的reserved区域合并,或者调整固件侧的描述方式,再考虑修改内核常量。

4.3 重叠区域、非法地址与早期 crash 的排查

memblock里插入region时,会做基本的重叠检查和合并。但不代表固件给的地址一定靠谱,我遇到过一次设备树里同时描述了两段重叠的物理内存,导致后续伙伴系统的页状态错乱,系统起来后一跑压力测试就崩。

排查这类问题的利器还是memblock=debug。看日志时重点检查是否存在类似“同一块物理地址被add了两次”的记录。如果发现重叠,就要回固件侧改设备树或ACPI表。

还有一种常见问题是地址越界:固件报的物理内存末尾超出了实际RAM范围。memblock虽然会做部分检查,但早期阶段很多校验并不严格,错误地址会一路传到伙伴系统。这类问题光看memblock日志也能发现,比如memory的total_size异常偏大,或者region base不在DDR地址区间内。

我在实际调试中还踩过一个跟current_limit有关的坑:某块板子的DMA控制器只能访问低32位地址空间,但早期分配时没注意限制,导致给DMA分配的缓冲区落在高地址上,设备一操作就总线错误。后续的修复是在调用memblock分配前,把current_limit显式设置到DMA可访问范围。

4.4 经验:我通常在代码里保留哪些打印

对于早期启动问题,常规printk可能工作得不够早。我一般会在arm64_memblock_init或对应的x86初始化函数里临时加一些打印,输出每次关键memblock操作后的内存总览:

pr_info("memblock: memory cnt=%lu total=%pa\n", memblock.memory.cnt, &memblock.memory.total_size); pr_info("memblock: reserved cnt=%lu total=%pa\n", memblock.reserved.cnt, &memblock.reserved.total_size);

用phys_addr_t打印时,记得转换成u64或使用%pa配合物理地址类型,否则32位环境下会打印出截断后的值。这个问题看起来小,实际排查时很容易被误导。

另外,早期启动阶段如果连printk都还没完全可用,需要先确认早期串口控制台是否初始化。可以打开earlycon支持,这样在非常早的阶段就能看到内核打印。否则,你加的日志可能根本没机会输出,排查起来会非常痛苦。

5. 从 memblock 到伙伴系统:一次顺利交接的关键

memblock的最终归宿是把自己管理的内存“移交”给伙伴系统。这个交接不是简单地把region列表倒过去就行,背后涉及两步核心工作:

第一步,初始化每个物理页对应的struct page。这个数组本身就很大,通常放在物理内存的起始位置附近,内核先通过memblock分配这片空间,然后逐页设置页状态。

第二步,按memblock.memory中每个region的可用部分,把页框按顺序释放到伙伴系统。释放时会依据NUMA节点信息,把页挂到对应节点的空闲链表上。这一步之后,内核的__alloc_pages就可以正常工作了。

交接顺利与否,直接影响系统稳定性和性能。我见过一个案例,memmap数组和对齐配置出现问题,导致伙伴系统初始化后空闲页总数对不上,系统在内存压力下频繁触发oom。最后查下来是memblock.reserved里有一小块内存没被正确排除,既不在memmap管理范围内,又没有从可用内存里扣除。

所以我的习惯是:在memblock_free_all之后,立即输出一下伙伴系统统计信息,确认空闲页总数和理论可用页数一致。

从开发者视角看,memblock的代码并不复杂,复杂的是它运行在启动流程里一个“什么都不能太依赖”的特殊阶段。你既要看懂数据结构,还要对启动顺序、固件行为有足够敏感度。希望这篇文章的拆解能帮你省些力气,至少在踩坑时知道先去看哪里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询