Linux内核内存分配实战:从伙伴系统到kmalloc/vmalloc
搞Linux内核这摊事绕不开的就是内存分配。很多人在业务代码里写malloc写习惯了转到内核模块或者底层排障时面对__get_free_pages、kmalloc、kzalloc、vmalloc直接就懵了这几个到底差在哪为什么有人告诉我驱动里别用vmalloc为什么我的模块一加载系统就报page allocation failure还有/proc/buddyinfo那些数字到底能看出什么东西这篇东西就是把我这些年在内核内存分配上踩过的坑、看过的源码、调过的参数从头到尾捋一遍。不整那种教科书式的啰嗦直接讲清楚内核里物理内存怎么管、分配器怎么工作、你写代码时应该用哪个接口、出了问题怎么定位最后再给一份实战排查清单。适合刚入门内核开发的、在做嵌入式Linux项目的朋友以及已经在服务器上看过OOM但没深究的人。1. 先搞清楚一个核心问题你分配的内存到底在哪很多新手写内核模块第一反应是我要一块内存。但内存这词太模糊了在Linux内核里至少得分三层看物理内存、内核虚拟地址、用户进程虚拟地址。你不要管CPU怎么走MMU先把这层逻辑理清楚。1.1 虚拟地址与物理页帧的关系应用程序通过malloc拿到的是虚拟地址背后对应的物理页是内核通过缺页异常按需分配的。内核自己也有一片独立的虚拟地址空间通常在64位系统上内核线性的映射区域比如x86_64上的直接映射区会把大部分物理内存直接映射到固定的虚拟地址范围。这里面最重要的一条规则是内核代码直接访问的地址基本都是虚拟地址但kmalloc这类接口返回的虚拟地址和物理内存之间存在直接的线性偏移关系所以它分配出来的内存物理上也是连续的。而vmalloc返回的虚拟地址背后对应的物理页可能东一块西一块只是页表把它们映射成了连续的虚拟空间。理解这个差异是后续所有选择的基础。很多驱动出问题本质就是没搞清楚自己到底需要物理连续还是只需要虚拟连续。1.2 内核里的分配器家族Linux内核为了应对不同场景有几套并存的分配机制各管一段伙伴系统Buddy System管理物理页帧的底层分配器所有物理页的分配和回收最终都要通过它。它解决的问题是物理上连续的页块怎么高效分配和回收。SLUB分配器在伙伴系统之上专门服务于内核里频繁创建、销毁的小对象比如task_struct、inode、sk_buff。它解决了伙伴系统分配小对象时碎片浪费和效率低的问题。vmalloc机制负责在内核虚拟地址空间中分配大块连续虚拟内存但无需保证物理连续。percpu分配器为每个CPU分配独立副本避免多核并发访问同一内存时的锁竞争。看到这里你应该明白平时写驱动用的kmalloc并不是另一个独立的内存池它就是SLUB分配器的一个前端封装。而__get_free_pages则直接绕到伙伴系统去要物理页。后面我会逐个展开。2. 伙伴系统物理内存的大管家2.1 分段与分区的设计逻辑物理内存不能简单地当成一整块大池子来分因为硬件有不同的访问限制。内核把物理内存划分成了不同的zone最典型的是三个ZONE_DMA通常指低端物理内存可用于ISA设备DMA的地址范围。部分平台还有ZONE_DMA32表示32位设备能访问的范围。ZONE_NORMAL普通内存区域内核直接映射区主要覆盖这里。ZONE_HIGHMEM在32位系统上超出内核直接映射范围的物理内存。64位系统已经没有这个zone的实际意义但为了兼容性还保留着概念。每个zone内部再按页块大小分成不同级别的链表这就是伙伴系统的核心结构。你可以把每个zone看作一个货架仓库里面按照不同规格order分类摆放着不同大小的连续页块。order为0就是一个物理页通常是4KBorder为1是两个连续页8KBorder为2是4个页16KB依此类推order上限通常是10或11。2.2 伙伴算法的合并与分裂伙伴算法的工作方式简单说就是有大给大有大拆小还回去时能合并就合并。比如你想分配一个order为0的页系统发现order 0链表中没有空闲页就会从order 1链表中取出一块2页的块劈成两个1页的块一个给你另一个挂回order 0链表。释放时反向操作若发现和你相邻的伙伴也是空闲的就把两块合并成上一级的大块再继续尝试向上合并。这套算法保证了内存碎片不至于无限恶化也让大块连续内存的分配始终有迹可循。我在一个嵌入式项目里遇到过一个经典场景反复进行大量小请求后虽然系统总体内存还够但想分配一个order 5以上的连续大块时频繁失败。这就是因为空闲页虽然多但都是碎片化的没有一个连续的大块被伙伴系统完整保留。此时光等是没用的得靠内存规整compaction或者干脆从设计上减少大块连续内存的需求。2.3 常用的物理页分配接口直接和伙伴系统打交道的接口主要是这几个:struct page *alloc_pages(gfp_t gfp_mask, unsigned int order); struct page *alloc_page(gfp_t gfp_mask); unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order); unsigned long __get_free_page(gfp_t gfp_mask); void free_pages(unsigned long addr, unsigned int order);alloc_pages返回的是struct page指针这是物理页的元数据结构。__get_free_pages返回的是直接映射的虚拟地址底层和alloc_pages其实是一回事只是做了个地址转换。模块开发中后者用起来更方便但要注意直接映射区的物理页在内核线性地址空间里是连续的可这不代表它在用户空间或者DMA场景下就一定合适关键还是看用途。多页分配时order每加1需要的连续页数翻倍。申请order为3的块就是连续8页共32KB。这些接口分配出来的内存物理上是连续的这在编写设备驱动时是最常见的需求来源。当然代价就是低order分配和碎片风险纠缠在一起。2.4 分配路径上的水位与回收每个zone内部有几条关键的水位线WMARK_MIN、WMARK_LOW、WMARK_HIGH。系统对zone的空闲页数进行实时监控空闲页在HIGH以上说明充足掉到LOW以下kswapd内核线程会开始异步回收掉到MIN以下直接回收路径将被触发在分配时同步进行内存回收这时候分配动作会变得很慢。这里有个很多人不知道的细节min_free_kbytes这个内核参数直接决定了WMARK_MIN的基准值。我看到过有人为了省内存把min_free_kbytes设得特别低结果系统内存明明没满却频繁出现分配延迟和卡顿因为一旦低于MIN水位分配线程就要亲自下场回收。反过来设太高也会浪费内存。实践经验是一般的服务器留个0.1%到0.5%的总内存给min_free_kbytes即可具体还要结合NUMA拓扑和应用负载来定。3. SLUB分配器小对象的快车道3.1 为什么伙伴系统不适合管理小对象直接用伙伴系统分配一个100字节的对象理论上没问题分配1页order 0就够了但这一页里可能有大量空间被浪费而且每次分配都要走页表管理和zone锁。内核里每秒产生的task_struct、dentry、inode不计其数如果都按页分配内存浪费和锁竞争会是灾难。SLUB分配器的思路是从伙伴系统批量申请若干页作为一个slab缓存然后在这个缓存内部切分成多个同尺寸对象。你请求一个对象时直接从缓存里拿一个出来释放时还回去继续用不会立刻归还伙伴系统。这有点像食堂提前备好几百份套餐你来就拿一份比你每次来食堂才现买菜要快得多。3.2 创建和使用slab缓存内核代码里常见的是kmem_cache_create创建专用缓存struct kmem_cache *my_cache; static int __init my_init(void) { my_cache kmem_cache_create(my_obj_cache, sizeof(struct my_obj), ARCH_KMALLOC_MINALIGN, 0, NULL); if (!my_cache) return -ENOMEM; return 0; } static void __exit my_exit(void) { if (my_cache) kmem_cache_destroy(my_cache); }分配时用kmem_cache_alloc(my_cache, GFP_KERNEL)释放时用kmem_cache_free(my_cache, obj)。专门建缓存的好处是对象尺寸精确、无内部碎片而且可以按NUMA节点分布对象但不能每个小结构体都动不动建一个缓存缓存本身也有元数据开销。如果你的对象生命周期长、数量大才适合单独建slab临时短命的小结构体直接用kzalloc往往更省事。3.3 kmalloc到底是哪来的kmalloc本质上是调用SLUB分配器里预置的几个通用缓存之一。内核按2的幂次尺寸准备了一批通用缓存比如kmalloc-8、kmalloc-16、kmalloc-32……直到kmalloc-4k甚至更大。所以你用kmalloc(100)时实际是从kmalloc-128这个缓存里拿对象内部浪费了28字节。正常情况下这不算什么问题但如果你用kmalloc一次性分配几兆字节那不光是从slab系统里拿底层还走到伙伴系统而且缓存对象尺寸越大伙伴系统碎片影响越明显。我见过一个网络驱动把每个数据包的长度直接当kmalloc大小使用结果有些包接近64KB导致系统频繁分配大对象整体性能崩了。这就是典型的该用vmalloc或skb专用池却用了kmalloc的例子。4. vmalloc虚拟连续物理离散4.1 原理与适用场景vmalloc的工作方式是在内核虚拟地址空间中找一块连续区域然后为每个物理页单独建立页表映射。换句话说它保证了虚拟地址连续但不保证物理连续。这也意味着每次vmalloc都需要操作页表、刷新TLB开销远大于kmalloc。什么时候用vmalloc模块需要分配一块很大的内存比如数MB以上并且这块内存不会被DMA设备直接访问也不追求极致的分配速度。典型例子是内核模块里的某些数据缓存、kexec镜像加载、以及各种内核临时缓冲。我自己的经验是一般超过一个页或两个页且非性能关键路径才考虑用vmalloc具体阈值要看你的访问频率。void *p vmalloc(8 * 1024 * 1024); if (p) { // 使用内存物理页不连续但虚拟地址连续 vfree(p); }vmalloc还有两个变体vzalloc分配并清零、vmap把一组已分配的page映射成连续虚拟地址。在驱动里如果要管理分散页vmap偶尔能帮上忙。4.2 kmalloc、alloc_pages、vmalloc怎么选这是个高频问题我直接给一张对照表维度kmallocalloc_pagesvmalloc物理连续性连续底层来自伙伴系统连续不保证连续虚拟连续性连续直接映射连续连续分配速度快快慢需建页表适用大小小到中建议单次1MB页为单位大块内存DMA访问可以可以不可以除非另做映射典型场景驱动小缓存、结构体页表、DMA缓冲区模块大缓冲、内核临时区经验法则非常简单需要给DMA用的、需要物理连续的优先kmalloc或alloc_pages只是内核软件逻辑用的、容量又大用vmalloc。很多人以性能下降为理由绝对不用vmalloc实际上多数场景里它没那么慢真正慢的是你频繁vmalloc和vfree导致TLB抖动而不是偶尔一次大块分配。4.3 大小限制和边界kmalloc虽然在SLUB里封了一层但最终还是要从伙伴系统分配物理页所以它同样受伙伴系统碎片影响。单个kmalloc分配太大时会退化到从伙伴系统分配较高order的页碎片严重时照样失败。vmalloc则主要受内核虚拟空间和物理内存总量限制它不需要连续物理页所以碎片敏感性低很多。一个常踩的坑是kmalloc分配失败日志里却是page allocation failure: order:...。这时候别急着怀疑SLUB直接去看伙伴系统状态确认是不是对应zone的水位和碎片问题。5. GFP掩码分配行为的开关GFPGet Free Page标志是整个内存分配最容易被忽视、却最能体现水平的部分。很多人写GFP_KERNEL写顺手了进了中断上下文也照抄然后panic教做人。这一节把标志体系讲透。5.1 两大维度行为能力和并发上下文GFP标志至少包含两个维度能不能睡眠GFP_KERNEL允许睡眠因为它可以触发磁盘I/O、页面回收等慢操作。GFP_ATOMIC不允许睡眠用于中断上下文、自旋锁临界区等不能调度的场景。没记错的话从某个内核版本开始GFP_ATOMIC的含义还更细分了但大致上不能睡这层理解不过时。内存来源和回收策略__GFP_IO、__GFP_FS表示是否允许在回收时进行磁盘I/O和文件系统操作__GFP_DIRECT_RECLAIM表示是否允许调用直接回收__GFP_ZERO要求分配后清零内存__GFP_HIGH表示允许使用紧急保留页。把这些组合起来就能明白内核源码里常见的组合#define GFP_KERNEL (__GFP_RECLAIM | __GFP_IO | __GFP_FS) #define GFP_ATOMIC (__GFP_HIGH | __GFP_ATOMIC) #define GFP_NOWAIT (__GFP_KSWAPD_RECLAIM)5.2 常见场景的正确姿势从实践角度总结一份选择清单进程上下文、可以睡眠、普通数据分配GFP_KERNEL。中断处理函数、软中断、持有自旋锁GFP_ATOMIC。别多睡多睡就是死锁或panic。只需要临时映射一小块内存不想影响回收GFP_NOWAIT失败就失败调用方能容忍失败。必须物理连续、又用于DMAGFP_KERNEL | __GFP_DMA或GFP_KERNEL | __GFP_DMA32具体看设备寻址能力。分配失败可以接受的场景明确地用__GFP_NORETRY避免无限重试导致系统阻塞。还要特别提醒一个坑有锁情况下别用GFP_KERNEL分配内存。我之前在一个字符驱动里持有一把全局互斥锁然后kmalloc(..., GFP_KERNEL)表面看没什么问题。但分配路径一旦进入直接回收或文件系统回写就可能触发同一把锁的竞争者间接造成ABBA死锁。教训就是持锁分配要么明确这个锁绝不会进入回收路径要么改用GFP_ATOMIC要么先把锁释放了再分配。6. 现场调试从内核日志和proc文件看内存状态6.1 分配失败日志到底在说什么真实服务器或嵌入式板子上最常见的内核告警大概长这样page allocation failure: order:4, mode:0xcc0(GFP_KERNEL), nodemask(null) cpuset/ mems_allowed0 CPU: 2 PID: 1234 Comm: kworker/u4:2 Not tainted 5.10.0 #1 ... [ffffffff81234567] __alloc_pages_nodemask0x... [ffffffff81234567] alloc_pages_current0x... [ffffffff81234567] kmalloc_order0x...这条日志包含三层信息。第一order:4意味着失败的是连续16页即64KB的分配。第二回溯里的kmalloc_order说明这是kmalloc分配大对象时直接走到了伙伴系统。第三mode:0xcc0展开后包含GFP_KERNEL表示它允许回收和睡眠。此时不要只盯日志末尾的Out of memory之类信息重点的是看日志附带的各zone状态Node 0 DMA32: ... free:xxx min:xxx low:xxx high:xxx ...。如果free明显低于min那是真的内存紧缺如果free还很多那就是碎片问题。两种病的药方完全不同。6.2 /proc/buddyinfo 和 /proc/pagetypeinfo 的用法我最爱用的两个文件是/proc/buddyinfo和/proc/pagetypeinfo。前者展示每个zone里不同order的空闲页块数量cat /proc/buddyinfo Node 0, zone DMA 1 0 1 0 2 1 1 0 1 1 3 Node 0, zone DMA32 456 289 250 430 349 149 45 12 8 2 0 Node 0, zone Normal 12345 6789 3456 1234 567 89 10 3 1 0 0从左到右是order 0到order 10。倒数第三列order 8是256页即1MB连续块最后三列几乎都是0的话说明该zone已经拿不出大块连续内存。如果你看到DMA32里order大于4的块很少而驱动还频繁申请order 4以上的连续块那就别纠结了要么启用CMA要么改成分散-收集映射要么触发compact_memory做一次性规整echo 1 /proc/sys/vm/compact_memory cat /proc/buddyinfo # 再看大块是否变多/proc/pagetypeinfo还能显示每个zone里Movable、Reclaimable、Unmovable页的占比。Unmovable类型的页占多了会严重阻碍内存规整和CMA迁移这也是为什么内核里有些内存要用__GFP_MOVABLE标记目的就是给它可搬家的身份好让大块连续内存能腾挪出来。6.3 slabinfo 与 kmemleak 追泄漏内核模块常见的内存泄漏不像用户态那么好查推荐三板斧cat /proc/slabinfo看每个slab缓存的对象总数和活跃对象数。如果某个你熟悉的缓存对象数只涨不降基本可以认定是未释放。slabtop -o实时按缓存大小排序交互式观察异常增长。kmemleak内核自带的内存泄漏检测器启用后扫描潜在的已经被忘记地址的对象输出疑似泄漏点。启用kmemleak非常直接echo scan /sys/kernel/debug/kmemleak cat /sys/kernel/debug/kmemleak不过要提醒一句kmemleak是启发式扫描误报不少更多是用来缩小范围。真正确认泄漏还是得靠代码走查和kmem_cache创建时的调用栈追踪。6.4 用ftrace跟踪分配路径如果分配失败时能直接抓到现场最好但很多问题偶发建议用ftrace的kmem事件组做一个分配事件追踪echo kmem:mm_page_alloc /sys/kernel/tracing/set_event echo 1 /sys/kernel/tracing/tracing_on # 执行复现操作 cat /sys/kernel/tracing/trace想抓调用者还可以打开stacktrace触发条件echo kmem:mm_page_alloc stacktrace /sys/kernel/tracing/events/kmem/mm_page_alloc/filter这里会记录每一次页分配的调用栈量会非常大所以我一般只在复现窗口内开启并配合trace_pipe实时导出到文件再离线分析。对于偶发的order分配失败用这个方法抓到调用方几乎是一抓一个准。7. 一通真实案例网络驱动的连续分配失败7.1 症状现场之前我在一块多核ARM平台上调一个网卡驱动压力测试跑到约20分钟时控制台开始刷eth0: failed to allocate RX buffer, order 3但free -m显示还有200多MB空闲内存。显然不是内存不够是拿不到物理上连续8页的缓冲区。当时RX ring用的还是传统的dma_alloc_coherent分配连续DMA缓冲区本来就是嵌入式场景的老大难。7.2 定位过程我先cat /proc/buddyinfo发现Normal区order 3以上的块数量为0而order 0、order 1的空闲页其实不少——典型的碎片化。再cat /proc/pagetypeinfo看到Unmovable类型的内存占了大头多半是长期频繁启停连接、网络协议栈里分配的不可移动页钉在了各处。当时我第一反应是触发compact_memory看看能否缓解结果规整之后大块确实出来了但跑一阵又打回原形。这说明是持续性的碎片生成单纯一次规整治标不治本。7.3 最终解决办法试过直接增大min_free_kbytes没有本质改善只是把告警往后推迟。后来把方案改成了两层第一RX缓冲区采用预备多块连续DMA内存池启动时一次性分配好运行时不再频繁申请释放第二真正需要临时连续小块的路径改用kmalloc配合SG表让DMA引擎发挥分散收集能力避免总是找大块。最终压力测试24小时不再告警。这个案例想说明的是连续内存分配失败最忌讳的是头疼医头、脚疼医脚一定要先判断是总量不足还是碎片导致。前者靠调低预留、回收页面后者靠规整、CMA、对象池、SG等结构性方案。8. 避坑清单与实用调优参数8.1 写代码阶段的避坑清单不用GFP_KERNEL分配超大块内存超过一个页就评估vmalloc或专用池。持锁、中断、软中断环境统一用GFP_ATOMIC并尽量让分配动作越少越好。不要频繁地kmallockfree做循环缓冲直接预先分配并复用性能能高一个量级。需要清零的结构体直接用kzalloc不要自己memset减少踩内存的几率。用alloc_pages分配后别忘了判断NULL用完free_pages不要混用kfree。给DMA用的内存优先dma_alloc_coherent它会处理好一致性映射和地址范围别自己拼。8.2 调优参数经验值这里给出的是我实际用过的起点值具体的还要看机器参数经验值/建议说明vm.min_free_kbytes总内存的0.1%~0.5%太低导致紧急时无页可用太高浪费内存vm.zone_reclaim_mode通常为0NUMA环境下置1可能导致本地zone过度回收vm.swappiness交互式1080p还是100中文系统常设10强烈影响匿名页回收但不是内存分配的核心vm.compact_memory按需写1手动触发规整用于临时缓解碎片vm.extfrag_threshold100~500控制是否触发规整的阈值默认500较保守一个常见的误区是把min_free_kbytes当零用钱随便调。在64GB机器上调成64MB系统空闲内存看起来多了但一旦触发直接回收大量页面在分配路径上被同步换出整机延迟会剧增。反过来在内存很小的嵌入式设备上把这个值设太高等于给系统锁死了一部分内存不给用户态用业务直接就OOM了。所以这东西真的按需调没有一劳永逸的魔法值。8.3 从一次排查里提炼的经验最后说一句非常个人的体会内核内存分配这块九成的问题不是源码理解不了而是没有建立内存状态手感。我在排查问题时习惯永远先看三层状态——/proc/buddyinfo看碎片、/proc/meminfo看总量和水位、/proc/slabinfo看对象缓存——然后才去看代码和调用栈。只要这三样在手配合kmemleak和ftrace绝大多数内存问题都能在一小时内圈定范围。调试工具和API你背得再熟也不如实实在在地在板上跑几次压力测试动手观察那些数字的变化来得真切。内核内存分配没有银弹但只要你分得清物理连续与虚拟连续分得清能睡与不能睡分得清总量不足与碎片扎堆你就能在问题面前稳住了。