Linux 内核 CMA 连续内存分配器详解从 dma_alloc_coherent 到驱动层的完整申请链路与 Page Migration 机制一、DMA 连续页面的寒冬当视频解码器需要 8MB 连续物理内存而 Buddy Allocator 只找得到碎片嵌入式 SoC 的硬件加速模块——视频编解码器、ISP图像信号处理器、2D 图形引擎——通常要求 DMA 缓冲区在物理地址上连续。这不是因为 DMA 引擎不能做 scatter-gather而是出于成本考虑低端 SoC 的 DMA 控制器只有简单的起始地址长度寄存器没有 scatter-gather 描述符链表支持。随着系统运行时间增长物理内存被应用程序和内核对象切割成 4KB 的碎片即使总空闲内存超过 200MB一块 8MB 的连续物理区域也可能已经不存在了。CMAContiguous Memory Allocator是内核提供的解决方案。它在启动时预留一块物理连续区域该区域在空闲时可以被移动页面匿名页、页缓存占用当 CMA 客户端如 DMA 驱动需要连续内存时内核将这些移动页面迁移走腾出连续的物理空间。这种双重用途设计避免了传统方案如启动时静态预留大块内存造成的物理内存浪费。二、CMA 区域的声明、迁移与分配从内核启动参数到 cma_alloc 的五阶段流水线sequenceDiagram participant Boot as 内核启动 participant CMA as CMA Framework participant Buddy as Buddy Allocator participant Migration as Page Migration participant Driver as DMA Driver Boot-CMA: 解析 cma128M0x70000000 CMA-CMA: cma_declare_contiguous_nidbr/保留物理区域 CMA-Buddy: 将 CMA 页面挂入 Buddy 系统br/标记为 MIGRATE_CMA Note over Buddy: CMA 页面可供 Movable 分配使用 Driver-CMA: dma_alloc_coherent(8MB) CMA-CMA: cma_alloc(cma, count, align) CMA-Buddy: alloc_contig_range(start, end) Buddy-Migration: 检查 CMA 页面上是否有br/已分配的 Movable 页 alt 页面上有可移动页面 Migration-Migration: 隔离页面 → 分配新页br/→ 复制内容 → 更新页表 Migration--Buddy: 页面迁移完成 else 页面上无页面或已空闲 Buddy--CMA: 直接标记为已分配 end CMA--Driver: 返回连续物理地址 Driver-Driver: 写入 DMA 描述符寄存器 Note over Driver,CMA: 驱动释放时调用 dma_free_coherentbr/CMA 页面归还 Buddy ( MIGRATE_CMA)关键步骤展开说明。首先CMA 区域通过内核启动参数cmasizebase或设备树中的linux,cma节点声明内核在start_kernel→setup_arch→arm_memblock_init阶段调用dma_contiguous_reserve完成预留。这一步只是从 memblock 中扣除该区域并不实际清零或映射。然后在 Buddy Allocator 初始化阶段mm_init→mem_init→free_all_bootmemCMA 区域的所有页面被标记为MIGRATE_CMA类型一种特殊的 Movable 页面类型释放给 Buddy 系统。此时 CMA 页面可以被任何 Movable 类型的分配使用但不能被 Unmovable 或 Reclaimable 类型的分配占用由gfp_to_alloc_flags中__GFP_MOVABLE的传播保证。当 DMA 驱动调用dma_alloc_coherent时dma_alloc_from_contiguous调用了内核的cma_alloc函数其中核心是alloc_contig_range——它检查目标页面范围内是否有正在使用的 Movable 页面。如果有调用isolate_migratepages_rangemigrate_pages进行页面迁移。迁移的流程是分配新页面从非 CMA 区域→ 复制原页面数据 → 修改触发该页面的进程的页表条目 → 释放原页面。如果迁移失败例如页面被 mlock 锁定或内核持有对页面的 pinalloc_contig_range返回-EBUSY调用链回退到dma_alloc_coherent最终返回 NULL。三、从内核模块侧使用 CMA一份带迁移失败告警的 DMA 缓冲分配器实现/* * dma_cma_buf.c — 基于 CMA 的 DMA 缓冲区管理模块 * * 使用场景为 H.264 硬件解码器分配参考帧缓冲区1280×720 NV12 1.35MB × 16 帧 * 要求物理连续、64 字节对齐满足 AXI 总线突发传输、非缓存映射 */ #include linux/module.h #include linux/kernel.h #include linux/dma-mapping.h #include linux/dma-contiguous.h #include linux/slab.h #include linux/device.h #include linux/sched.h #include linux/delay.h #define MAX_DMA_BUFS 16 #define BUF_ALIGN 64 /* AXI 64 字节对齐 */ struct dma_buf_entry { dma_addr_t dma_handle; /* 总线地址 */ void *cpu_addr; /* 内核虚拟地址 */ size_t size; bool in_use; }; static struct dma_buf_entry dma_bufs[MAX_DMA_BUFS]; static DEFINE_SPINLOCK(dma_buf_lock); /* * 从 CMA 区域分配 DMA 缓冲区 * * 返回值buf 数组中的索引0~MAX_DMA_BUFS-1失败返回 -1 * * 注意事项 * - dma_alloc_coherent 在 CMA 分配失败时会降级到 Buddy Allocator * 如果 Buddy 也失败返回 NULL不是内核 panic * - 对应释放函数为 dma_free_coherent * - flags 使用 GFP_KERNEL 允许睡眠——如果当前上下文不可睡眠中断/软中断 * 需改用 GFP_ATOMIC但 CMA 分配路径可能不支持原子上下文 */ int dma_buf_alloc(struct device *dev, size_t size) { unsigned long flags; int idx -1; gfp_t gfp_flags GFP_KERNEL | __GFP_NOWARN; /* * __GFP_NOWARN 抑制 cma_alloc 失败时的内核 warning 打印 * 生产环境中由本模块负责记录日志避免内核日志被刷屏 */ if (!dev || size 0) return -1; spin_lock_irqsave(dma_buf_lock, flags); /* 找到空闲槽位 */ for (int i 0; i MAX_DMA_BUFS; i) { if (!dma_bufs[i].in_use) { idx i; break; } } if (idx 0) { spin_unlock_irqrestore(dma_buf_lock, flags); pr_err([CMA_BUF] 缓冲区槽位已满 (max%d)\n, MAX_DMA_BUFS); return -1; } /* 提前标记为使用中避免后续失败时的复杂回滚 */ dma_bufs[idx].in_use true; spin_unlock_irqrestore(dma_buf_lock, flags); /* * dma_alloc_coherent 内部链路 * → dma_alloc_attrs → dma_direct_alloc * → dma_alloc_from_contiguous (CMA 尝试) * → __alloc_pages_nodemask (Buddy fallback) */ dma_bufs[idx].cpu_addr dma_alloc_coherent(dev, size, dma_bufs[idx].dma_handle, gfp_flags); if (!dma_bufs[idx].cpu_addr) { /* * 分配失败可能原因 * 1. CMA 区域内的 Movable 页面迁移失败被 mlock 锁定 * 2. CMA 区域耗尽且 Buddy 无更高 order 连续块 * 3. 设备 DMA 掩码不满足地址超过设备寻址范围 */ pr_err([CMA_BUF] dma_alloc_coherent(%zu) 失败, dev%s\n, size, dev_name(dev)); spin_lock_irqsave(dma_buf_lock, flags); dma_bufs[idx].in_use false; spin_unlock_irqrestore(dma_buf_lock, flags); return -1; } dma_bufs[idx].size size; pr_info([CMA_BUF] 分配成功: idx%d, dma0x%pad, virt%px, size%zu\n, idx, dma_bufs[idx].dma_handle, dma_bufs[idx].cpu_addr, size); return idx; } /* * 释放 DMA 缓冲区将 CMA 页归还 Buddy 系统 (MIGRATE_CMA) */ void dma_buf_free(struct device *dev, int idx) { unsigned long flags; if (idx 0 || idx MAX_DMA_BUFS) return; spin_lock_irqsave(dma_buf_lock, flags); if (!dma_bufs[idx].in_use || !dma_bufs[idx].cpu_addr) { spin_unlock_irqrestore(dma_buf_lock, flags); return; /* 已释放或未分配 */ } dma_free_coherent(dev, dma_bufs[idx].size, dma_bufs[idx].cpu_addr, dma_bufs[idx].dma_handle); memset(dma_bufs[idx], 0, sizeof(struct dma_buf_entry)); dma_bufs[idx].in_use false; spin_unlock_irqrestore(dma_buf_lock, flags); } EXPORT_SYMBOL(dma_buf_alloc); EXPORT_SYMBOL(dma_buf_free); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(CMA-based DMA Buffer Manager);四、CMA 迁移的阻塞时间与静默失败场景当视频帧解码器超时背后的内存布局问题CMA 最被忽视的工程问题是页面迁移的延迟不可预测。migrate_pages的耗时取决于(1) 需要迁移的页面数量(2) 每个页面的内容大小匿名页仅复制 4KB页缓存页需要同步到磁盘(3) 目标非 CMA 区域的空闲页面是否充足。在极端情况下128MB CMA 区域被完全占用需要全部迁移在 DDR3-1066 的平台上alloc_contig_range可阻塞 200ms 到 500ms。这对实时性敏感的应用是灾难性的。例如 30fps 的视频解码器每帧有 33ms 的时间预算如果在 gop 边界触发了 CMA 分配新的参考帧需要 8MB 连续内存分配阻塞 200ms 直接导致丢帧和播放卡顿。缓解策略是使用cma_alloc的异步变体或通过cma_alloc_from_contiguous的no_warn参数提前探测可用性在空闲时预分配。另一个陷阱是 CMA 与ION/DMA-BUF的交互。Android 的 ION 堆管理器会在 CMA 之上再包装一层引用计数和延迟释放逻辑。当用户态进程持有 ION 句柄但进程已被 kill 时ION 内部可能有一个异步释放周期通常 1-2 秒在这期间 CMA 区域并未真正归还 Buddy 系统。如果新的 DMA 分配恰好在这 1-2 秒的窗口内触发会因 CMA 区域不可用而降级到 Buddy Allocator——而 Buddy 大概率返回 NULL因为需要的是连续大物理块。五、总结CMA 通过在启动时预留连续物理区域并将其在空闲时借给 Movable 类型分配使用解决了预留内存浪费与DMA 需要连续物理内存之间的矛盾。核心机制是页面迁移——当 CMA 客户端申请内存时将 CMA 区域中的 Movable 页面迁移到非 CMA 区域释放出连续空间。工程落地的关键考量cma参数的合理大小通常为设备总内存的 10%-20%、页面迁移延迟对实时任务的影响需预分配或异步化、以及dma_alloc_coherent失败后的降级路径设计。在 Android/嵌入式 Linux 设备上建议通过cma_alloc_get_pagesGFP_NOWAIT先探测可用性避免在高实时性路径中触发同步的页面迁移。