资讯动态

Linux内核启动早期内存管理:memblock初始化深度解析

发布时间:2026/10/16 5:20:37 来源:尧图企业网站定制
提到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预留区域等。它的使命是在“内存布局还不清晰”和“伙伴系统还没就绪”的窗口期提供一种足够简单、可靠可见的内存记账和分配手段。相比bootmemmemblock还有一个关键优势它把“记录”和“分配”分开了。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 和 reservedmemblock_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 为例这里我以一次典型启动序列为例梳理关键步骤解析设备树把所有物理内存加入memblock.memory。保留内核镜像本身调用memblock_reserve把从_text到_end的整个镜像区域标记为reserved。保留设备树二进制DTB、initrd镜像、EFI系统表等启动信息所在的内存。解析设备树里的reserved-memory节点为DMA、CMA等预保留区域这些区域后续会被特定的驱动或子系统使用。调用paging_init根据memblock.memory中可用的物理内存范围建立内核页表创建线性映射。在内核页表建立后把直接映射中不再需要的早期临时映射清理掉。每一步都对应一段代码但如果你只看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_limitmemblock有一个地址上限默认可能被设定为某个值例如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 “memblockdebug”和必要内核配置遇到memblock相关的问题时第一件该做的事是打开调试输出。内核启动参数里加一个memblockdebug就能让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。这种情况下先用memblockdebug确认region数量增长趋势再考虑在memblock.h里调大INIT_MEMBLOCK_REGIONS或INIT_MEMBLOCK_RESERVED_REGIONS。更隐蔽的是第二种。某些预留操作在扩容时没有严格检查导致后续遍历时漏掉一部分区域。这种问题往往要结合完整启动日志和硬件内存布局图才能定位。我的经验是拿到板子的内存布局后先把dmesg里所有memblock_add的地址范围画出来和硬件手册对比基本一眼能看出哪个region丢了。另外大量小的reserve操作会快速消耗槽位。比如固件把设备树拆成很多小块或者reserved-memory节点特别多。如果确认是这种情况可以先把相邻的reserved区域合并或者调整固件侧的描述方式再考虑修改内核常量。4.3 重叠区域、非法地址与早期 crash 的排查memblock里插入region时会做基本的重叠检查和合并。但不代表固件给的地址一定靠谱我遇到过一次设备树里同时描述了两段重叠的物理内存导致后续伙伴系统的页状态错乱系统起来后一跑压力测试就崩。排查这类问题的利器还是memblockdebug。看日志时重点检查是否存在类似“同一块物理地址被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的代码并不复杂复杂的是它运行在启动流程里一个“什么都不能太依赖”的特殊阶段。你既要看懂数据结构还要对启动顺序、固件行为有足够敏感度。希望这篇文章的拆解能帮你省些力气至少在踩坑时知道先去看哪里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑