
1. 先搞清楚内核内存分配到底在解决什么问题做内核开发和嵌入式Linux的老哥应该都有过这样的经历用户态程序内存不够了malloc一个NULL回来你能清晰感受到问题出在哪。但内核不一样内存分配失败的后果往往不是返回个NULL就完了——可能是内核panic、宕机或者整个系统完全卡死。所以在Linux内核里内存分配从来不是有就分没有就报错这么简单而是一套有多层缓存、多种分配策略、大量优化手段的复杂体系。这篇文章主要面向三类读者一是正在啃内核源码的初学者想弄明白kmalloc、vmalloc、slab这些概念到底怎么回事二是做嵌入式Linux或驱动开发的工程师经常要跟内存分配打交道但又说不清底层机制三是做底层性能优化的运维遇到内存问题只知道看free命令却不知道内核内部发生了什么。文章会从整体设计思路讲起把物理页分配、slab缓存、vmalloc这些核心机制拆开揉碎最后再结合我实际排查过的内存问题说一些经验教训。内核的内存分配之所以难学跟它的设计目标有直接关系。用户态的内存分配可以只考虑够不够用但内核必须同时照顾到性能、碎片控制、DMA和设备访问需求、NUMA拓扑的局部性等等。举个例子你在驱动里分配一个DMA缓冲区如果随便用kmalloc去分得到的物理内存可能因为页表映射关系没法直接给设备用你在高优先级中断上下文里想分内存如果代码里去睡眠等待内存回收那整个系统就死给你看。这些约束条件叠加起来才有了今天我们看到的这套复杂的分配体系。2. 内核内存分配的分层架构与设计思路2.1 三层结构伙伴系统、slab缓存、vmalloc区域内核的内存分配从宏观上看是分层的。最底层是伙伴系统Buddy System它管理的是物理页帧Page Frame以2的幂次方为单位分配连续的物理页。往上走slab缓存层基于伙伴系统分配来的大块内存做细粒度管理专门服务那些频繁创建和销毁的内核对象比如task_struct、inode、dentry这些。最上层是vmalloc区域它分配的是地址空间连续但物理页不连续的内存适用于那些需要大块内存但对物理连续性不敏感的场景。这三层各自解决不同层面的问题。伙伴系统解决的是物理页管理的根本问题——如何快速找到合适的连续物理页slab层解决的是对象重复创建导致的开销问题vmalloc层解决的是物理碎片化导致大块内存分配失败的问题。理解这个层级关系你就明白为什么内核里会有这么多不同的内存分配API它们并不是冗余而是各有各的适用场景。2.2 为什么需要NUMA感知和内存策略现在服务器动辄几十个核、几百GB内存硬件上基本都是NUMA架构——不同CPU访问不同内存节点的速度不一样。如果内核分配内存时不考虑这个就可能出现CPU0频繁访问CPU1节点上的内存性能损耗非常明显。为了处理NUMA问题伙伴系统在设计上就考虑了节点node和区域zone的概念。每个内存节点维护自己的伙伴系统分配内存时可以通过GFP_THISNODE等标志强制从当前节点分配。内核还提供了numa_node_id()、cpumask等机制来辅助决策。写驱动时如果我们不做特殊处理内核会默认优先在当前CPU所在节点分配内存这也是很多性能敏感型驱动在初始化时要绑定CPU并分配内存的原因。2.3 水线和回收机制内存不是用完了才回收内核设计了一套水位线watermark机制来提前干预内存压力。每个内存区域都有WMARK_MIN、WMARK_LOW、WMARK_HIGH三个水位线当空闲内存低于WMARK_LOW时kswapd内核线程会被唤醒开始回收内存如果内存继续下降触碰到WMARK_MIN则正常的内存分配路径会进入直接回收direct reclaim也就是同步等待内存被回收释放。这套机制的巧妙之处在于它把内存快用完的预警做成了异步和同步两级响应而不是等内存彻底耗尽了才手忙脚乱。我见过很多内核崩溃的现场其实早在几分钟前系统就已经进入频繁的直接回收状态了只是没有监控到。了解这个水位线机制不仅对读源码有帮助对排查系统响应突然变慢这类问题也很有用。3. 核心分配机制详解从伙伴系统到slab3.1 伙伴系统的order机制与页块分配伙伴系统把物理内存划分为大小不同的块每个块包含2的order次方个连续物理页。order0是一页通常4KBorder1是两页8KB以此类推最大order取决于系统配置。分配时系统会先找恰好能满足需求的最小order块如果没有就把大块逐级拆半直到拆出合适大小的块为止释放时则检查相邻的伙伴块是否也是空闲的如果是就合并成更大的块。这里有个很容易踩坑的点连续物理页并不等于连续虚拟地址。如果你用__get_free_pages这类接口拿到了物理连续的页但在内核虚拟地址空间里它们是通过线性映射访问的地址是连续的在典型的x86_64和ARM64配置下内核线性映射区域是恒等映射加上一个偏移。但如果你在写驱动时用vmalloc得到的虚拟地址连续、物理地址不连续这时候如果要给DMA设备用就可能出问题。我见过有人拿vmalloc的内存直接填DMA描述符结果设备读到一半数据出错排查了整整两天。伙伴系统的实现核心是alloc_pages和free_pages两个路径前者最终会走到__alloc_pages_nodemask后者走到free_unref_page或__free_pages。理解代码的关键点是**rmqueue函数**——它负责从指定的order链表中摘块并做拆分合并。读这部分代码时建议先忽略CMA、page_owner、memory cgroup这些附加逻辑抓主干不然很容易被绕晕。3.2 slab/slub分配器的对象缓存机制直接跟伙伴系统打交道其实挺低效的——每次分配至少是一整页而且很多内核对象只有几十上百字节如果每次都从伙伴系统拿一整页内部碎片会非常严重。于是slab分配器出现了它的核心思想是从伙伴系统拿一整页或几页然后切成大小相等的对象维护一个对象池。Linux内核现在默认用的是SLUB分配器它跟早期的slab相比简化了很多元数据管理对多核扩展性更好。在SLUB里每个kmem_cache维护了多个cpu_partial链表每个CPU核心有自己的部分空闲对象链表分配时优先从当前CPU的partial链表取对象这样可以避免跨CPU锁竞争。当partial链表空了就向对应节点的slab页列表申请新的整页释放时优先放回当前CPU的partial链表。写驱动时最常用的kmalloc接口其实就是若干个预置kmem_cache的封装。kmalloc有128、192、256、512等一系列固定大小档位选最接近需求且能容纳对象的档位。这里有个隐藏细节kmalloc(DMA)分配的内存除了大小要匹配还要看GFP标志位是否包含__GFP_DMA或__GFP_DMA32这类内存必须来自低地址区域因为老式设备只有24位或32位地址线。3.3 vmalloc与线性映射的边界vmalloc在内核里是个特殊的存在。它分配的是虚拟地址连续的内存区域物理页可以分散在各处。它解决了物理内存碎片化导致无法分配大块连续内存的问题比如要分配一个16MB的缓冲区物理上可能没有16MB连续页但vmalloc可以在vmalloc区通常是内核地址空间的特定区域映射出16MB连续虚拟地址。但vmalloc有两个代价一是TLB压力大因为vmalloc区的页表项通常是每个页单独建立的访问这些内存可能频繁刷新TLB二是性能稍差vmalloc分配需要处理页表操作和flush比kmalloc慢不少。所以内核里能用kmalloc的地方尽量不用vmalloc只有确实需要大块内存才选择vmalloc。这里补充一个容易混淆的点kmalloc和vmalloc返回的都是内核虚拟地址但你没法从指针本身判断它是kmalloc来的还是vmalloc来的。要确认某个地址属于哪个区域可以查/proc/kallsyms里的vmlist信息或者在内核里用内核提供的is_vmalloc_addr()函数判断。3.4 GFP标志位决定分配行为的开关GFP标志位是整个分配机制里最容易被忽视但最要命的部分。举例说GFP_KERNEL允许睡眠、允许进行磁盘IO来回收内存适合进程上下文GFP_ATOMIC不允许睡眠适合中断上下文和自旋锁保护区域GFP_NOWAIT不等待回收直接失败返回。我踩过最深的坑是在中断上下文里用了GFP_KERNEL——编译没报错运行时偶尔出现死锁或者说崩溃最后查源码才确认是睡眠导致的。所以写代码前先确认自己在什么上下文里再选择GFP标志位。简单说中断处理、软中断、自旋锁临界区、原子上下文必须用GFP_ATOMIC普通进程上下文可以用GFP_KERNEL但也要考虑是否允许回收内存。__GFP_ZERO这个标志也很好用它在分配后自动清零页内容能避免未初始化内存泄漏敏感数据的风险。__GFP_HIGH则提高分配优先级让分配过程可以吃掉一些紧急保留页。需要强调的是GFP_ATOMIC和GFP_KERNEL在实现上的核心区别其实就是__GFP_ATOMIC标志和__GFP_IO、__GFP_FS这些标志的有无。4. 实操如何用工具定位内存分配问题4.1 从free、/proc/buddyinfo和slabtop看内存分布很多运维和开发对free -m的理解停留在看容量层面其实free命令展示的数值跟内核内部的分配器状态密切相关。free里的buff/cache大部分是页缓存page cache和slab可回收内存在内存紧张时可以被回收。如果你看到available已经很低但free还有几百MB说明系统正在靠可回收缓存硬撑着这时候性能大概率已经劣化了。/proc/buddyinfo直接展示每个内存节点的伙伴系统各order空闲块数量。比如某节点order0的空闲页数量很多但order4以上的连续块几乎为0说明物理内存碎片化严重。此时就算整体空闲内存還够分配大块连续页也可能失败。slabtop能实时显示各kmem_cache的对象数量和内存占用排查slab内存异常增长时非常好用。我遇到过一个dentry缓存无限膨胀的问题——某个文件系统不停地创建临时文件但没unlink目录项缓存越积越多最后系统直接内存耗尽。用slabtop看dentry这个cache的占用一秒就定位到了。4.2 用tracepoint和bpftrace抓分配失败现场实际排查内存问题时光看快照数据往往不够需要抓动态事件。内核提供了kmem:kmalloc、kmem:kmalloc_node、kmem:kfree等tracepoint可以统计各调用点的分配次数和大小。我来分享一个实际案例某驱动模块每次分发数据包时调用kmalloc(16KB, GFP_KERNEL)本来没问题但在内存碎片严重时频繁失败导致数据包丢弃。我第一反应是加大分配缓存但排查后发现问题的根源是内存碎片化后order2的页经常分配失败最后解决方案是改用内存池或预分配缓冲池。bpftrace也可以用比如追踪某个特定模块的分配调用并打印调用栈bpftrace -e kprobe:__kmalloc { if (arg2 16384) { printf(%s\n, kstack); } }这个命令在每次__kmalloc被调用且申请16KB时打印内核调用栈。用这个方法可以快速找出谁在频繁分配大块内存。不过bpftrace需要root权限且内核需要开启BTF支持部分发行版默认没开启需要装linux-tools或bpftrace时确认下环境。4.3 自己写内核模块验证分配行为纸上谈兵终觉浅我建议真正想理解内存分配的人写一个内核模块用不同GFP标志和不同分配大小做对比实验。比如写一个模块分别用kmalloc分配1KB、64KB、1MB内存打印返回值再用__get_free_pages分配order0和order4的页再用vmalloc分配1MB最后检查/proc/slabinfo和/proc/vmallocinfo的变化。这个实验的代码很简单但能直观感受到三者的差异。比如你会发现kmalloc(64KB)实际上是从order4的伙伴块里切的但如果系统碎片严重可能就直接返回NULL了而同样大小的vmalloc大概率能成功。多做几次这种实验你对什么时候该用哪种分配方式的判断会准确很多。4.4 内存压测与回收触发观察要观察内存回收和OOM行为可以用stress-ng或直接写个进程不断分配内存同时用vmstat 1看si和so字段的变化swap in/out用dmesg -T看内核有没有触发OOM Killer。做这类测试时建议在虚拟机里做避免把工作机搞挂。一个值得观察的实验是写一个进程不断malloc并touch内存写入数据以触发实际物理页分配同时另一个线程每秒读取/proc/vmstat里的pgscan_kswapd和pgscan_direct字段。你会发现内存触顶前pgscan_kswapd先增长这是后台回收等到内存继续下降pgscan_direct开始飙升说明内存分配已经开始同步等待回收了。这个瞬间通常是系统性能暴跌的临界点。5. 常见问题与排查技巧实录5.1 kmalloc失败但free还有很多内存这种现象几乎每次排查内存问题都会遇到特别是在嵌入式设备上。原因通常是内存碎片化虽然总空闲内存不少但没有足够大的连续物理块。解决思路有几个方向用vmalloc替代kmalloc如果对物理连续性没有硬性要求。预先分配内存池或DMA缓冲池在初始化阶段就取走大块内存。开启内存compaction内存规整内核会尝试移动可移动页来凑出大块连续内存嵌入式内核里可以开启CONFIG_COMPACTION。如果是嵌入式设备长时间运行导致的碎片可以考虑定期echo 1到/proc/sys/vm/compact_memory触发手动规整。5.2 slab内存只涨不降的奇葩场景遇到过一个奇怪的场景slab内存不断增长但系统整体内存和其他cache都正常。排查过程比较曲折最后发现是某个驱动在每次IO时创建bio结构但没有正确释放导致bio这个kmem_cache的对象数无限增加。怎么定位的呢盯/proc/slabinfo里每个cache的active_objs变化同时对驱动的每个IO操作做计数统计两者关联起来就水落石出了。这也提醒一个通用思路看到slab异常增长先看是哪个cache在涨再去代码里找这个cache的创建和销毁逻辑是否成对。5.3 中断上下文分配导致系统卡死内核在中断上下文只能调用GFP_ATOMIC这点很多初学者容易忽略。但即使用了GFP_ATOMIC中断上下文频繁分配内存也容易因为buddy系统锁竞争导致中断延迟飙升。经验之谈中断路径尽量零分配最好在驱动的open或初始化阶段把需要的缓冲区全部预分配好中断处理里只复用不重新分配。5.4 内存回收导致IO卡顿当系统内存紧张时内核会回收page cache这会导致文件读写性能剧烈下降因为缓存被清了磁盘IO重新变多。这时候看/proc/meminfo里的Dirty和Writeback字段如果Dirty数值长期很高说明系统在不断把脏页刷盘IO系统面临压力。可以适当调高/proc/sys/vm/dirty_ratio和dirty_background_ratio来缓解频繁刷盘但这只是治标真正的解决办法还是增加内存。6. 相关工具与后续学习建议调试内存问题没有万能钥匙但有几个工具组合是我长期在用的/proc/meminfo看总览/proc/buddyinfo看碎片slabtop看对象级占用vmstat看动态变化dmesg看OOM和recall事件bpftrace抓调用栈。这套组合基本能覆盖九成以上的内存问题场景。深入学内核内存分配我建议按这个顺序读源码先读mm/page_alloc.c的分配主路径__alloc_pages_nodemask再读mm/slub.c的slab_alloc_node再看mm/vmalloc.c的__vmalloc_node_range。这三个文件对应本文提到的三层机制抓主干逻辑把order链表的拆分合并、CPU partial链表的周转、vmalloc区的红黑树映射这几个核心概念理清就够了。初学者不用把每个宏定义都搞清楚会把自己心态搞崩掉。在我个人的实践里Linux内核内存分配最好的学习方法不是看文档而是做实验、翻源码、踩坑。建议备一台虚拟机专门用来做内存压测和内核模块实验遇到问题大胆用crash工具或gdb去分析vmcore。内核内存分配的复杂度确实高但一旦理解了三层结构和GFP标志位这两个核心框架再复杂的现象也能逐渐拆解成几个基础规则的综合作用。后面如果你们在实战中遇到具体的内存问题欢迎来交流具体的定位思路。