1. 为什么我会盯上内核模块里的CMA分配
先交代一下背景。我手上有个模拟项目X,需要在某嵌入式平台上跑一个视频采集模块,传感器过来的原始帧数据量不小,一帧1080p的RAW图差不多8MB左右,而且采集频率是30fps。最初图省事,直接在驱动里用kmalloc加dma_alloc_coherent来申请缓冲区,结果跑到高负荷场景时系统卡顿明显,偶尔还出现分配失败。查了一圈,问题基本出在内存碎片化:长时间运行后物理内存被拆得七零八落,大块连续内存越来越难拿,而DMA和摄像头控制器偏偏就吃这一套——它们要的是物理连续的内存,不是虚拟地址连续就行的。
后来调研下来,发现Linux内核早就提供了一个专门对付这类场景的机制:CMA(Contiguous Memory Allocator,连续内存分配器)。它跟普通伙伴系统最大的不同在于,它平时把内存“借”给其他子系统用,一旦有设备申请大块连续内存,它会通过迁移页面把这些内存收回来,再以连续区域的形式交给申请者。相当于你在公司里有一块专属停车区,白天大家都能临时停,但你的车要回来时,保安会清场把位置腾给你。这个设计理念很优雅,实际调起来却有不少门道,尤其是和内核模块结合使用时,踩坑的地方比想象中多。
这篇文章想聊的,就是我在内核模块里实际使用CMA内存分配的经验:怎么配置、怎么在模块里声明和使用、DMA映射怎么做、调试时怎么确认内存真的来自CMA池、以及那些文档里不会写清楚的坑。适合正在做驱动开发、尤其是涉及视频采集、网络收包、GPU显存这类需要大块连续内存场景的人参考。已经熟悉CMA基础概念的,可以直接跳到第4节看实战踩坑。
2. CMA的分配原理与内核模块的接入方式
2.1 CMA在内核里到底是怎么工作的
要讲清楚CMA在模块里怎么用,得先明白它的运作机制。CMA区域在内核启动阶段就会从物理内存中划出来,比如在设备树里配置一个reserved-memory节点,指定reusable属性,或者在内核命令行里用cma=参数指定大小。这个区域对伙伴系统而言是一块“可回收”的保留区:普通页面分配器在内存紧张时可以到这里拿页用,但这些页面随时可能被CMA机制回收。
当某个驱动调用cma_alloc时,内核会做三件事:
- 在CMA区域内找到一块足够大的空闲连续区间。
- 如果区间内某些页面正被其他进程或子系统占用,就通过页面迁移(migrate)把它们挪走——具体来说是把这些页的内容复制到普通内存区域的新页上,再更新页表映射。
- 迁移完成后,把这块连续区间标记为“专有”,交给驱动使用。
这个迁移操作代价不低,尤其是页面属于匿名映射或文件映射时,处理方式还不一样。所以CMA分配不保证实时性,中断上下文里是没法直接调用的,这也是为什么很多驱动在probe阶段或者工作队列里做初始化分配。
2.2 内核模块如何接入CMA池
对内核模块来说,接入CMA池有两种常见路径。一种是直接调cma_alloc,这需要先拿到CMA区域的struct cma *对象,一般通过cma_get_base或设备树API来获取。另一种是更推荐的做法——通过DMA子系统走通用API,比如dma_alloc_coherent或dma_alloc_from_contiguous,只要设备设备的DMA掩码和CMA区域配置正确,DMA子系统会自动从CMA池里分配连续内存。
我实际用的就是第二种方式。设备树里配置好了CMA区域,驱动里用device_managed_dma_alloc或者直接dma_alloc_coherent申请缓冲区,底层如果CMA区域可用且内存紧张时,分配器会自动落到CMA池里。但这里有个容易被忽略的前提:不是所有dma_alloc_coherent都保证走CMA。如果系统CMA区域没配置或者大小不够,DMA子系统会退回伙伴系统的普通连续分配,效果就打折扣了。
2.3 设备树里的CMA配置示例
先看一个典型的设备树配置:
reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; cma_buffer: cma_buffer@a0000000 { compatible = "shared-dma-pool"; reusable; size = <0x0 0x10000000>; /* 256MB */ alignment = <0x0 0x200000>; linux,cma-default; }; }; video_cap: video-cap@... { /* ... */ memory-region = <&cma_buffer>; };需要注意,reusable属性是必须的,表示这块保留内存可以临时借给伙伴系统用。没有这个属性,这块区域就变成纯保留区,白白占着256MB没有任何灵活性,系统可用内存会肉眼可见地减少。linux,cma-default表示把它设为系统默认CMA池,这样dma_alloc_coherent这类API可以自动使用它。
在驱动侧,可以用of_reserved_mem_device_init_by_idx或of_reserved_mem_device_init把设备和这块CMA区域关联起来。之后dma_alloc_coherent就会优先从这个区域分配。
3. 在模块里申请与使用CMA内存的完整流程
3.1 驱动代码里的初始化顺序
我在模拟项目X里新增的采集驱动初始化流程大概是这样的:
static int video_cap_probe(struct platform_device *pdev) { struct video_cap_dev *dev = platform_get_drvdata(pdev); struct device *dev = &pdev->dev; int ret; /* 把设备和CMA region关联起来 */ ret = of_reserved_mem_device_init_by_idx(dev, dev->of_node, 0); if (ret) { dev_err(dev, "failed to init reserved mem\n"); return ret; } /* 申请DMA缓冲区,这里会尝试从CMA池分配 */ dev->buf_size = 8 * 1024 * 1024; /* 8MB,一帧RAW图 */ dev->buf_virt = dma_alloc_coherent(dev, dev->buf_size, &dev->buf_phys, GFP_KERNEL); if (!dev->buf_virt) { dev_err(dev, "failed to alloc coherent buffer\n"); return -ENOMEM; } /* 后续再申请描述符表、状态缓冲区等 */ /* ... */ }这里有个细节:of_reserved_mem_device_init_by_idx必须在任何DMA分配之前调用,因为它负责建立设备到CMA区域的映射关系。如果漏了这步,后续dma_alloc_coherent拿到的内存可能来自普通伙伴系统分配,而不是CMA池。
3.2 释放与清理
模块卸载时对应释放:
static void video_cap_remove(struct platform_device *pdev) { struct video_cap_dev *dev = platform_get_drvdata(pdev); if (dev->buf_virt) { dma_free_coherent(&pdev->dev, dev->buf_size, dev->buf_virt, dev->buf_phys); dev->buf_virt = NULL; } of_reserved_mem_device_release(&pdev->dev); }of_reserved_mem_device_release负责解除设备和CMA区域的关联,这一步容易被新手忽略。不调用也不会立刻报错,但会导致设备残留对CMA区域的引用,后续如果重新绑定驱动或热插拔,可能出现内存泄漏或分配异常。
3.3 为什么用dma_alloc_coherent而不是直接cma_alloc
我见过一些人图省事,直接cma_alloc一个struct cma *上手。这确实能拿到CMA内存,但有个问题:DMA API和CMA API维护了两套不同的抽象。直接用cma_alloc,你拿到的是物理地址,还得自己想办法映射到虚拟地址、处理DMA掩码、做cache一致性维护。而dma_alloc_coherent把这些全包了——它同时返回虚拟地址和物理地址,并且保证cache一致性,也就是所谓的“coherent”映射,对大多数DMA场景正好够用。
所以我的建议是:能用DMA API就别裸调CMA API,除非你在做CMA子系统本身的调试或扩展。内核里暴露的CMA API更多是给底层子系统用的,不是给普通驱动作者用的。
4. 调试时如何确认内存真的来自CMA池
4.1 从设备树到/proc的双重验证
配置做完后,怎么知道自己申请的内存到底是不是从CMA池来的?我一般用两个手段:
第一个是看启动日志。内核启动时如果CMA配置成功,会有类似这样的输出:
cma: Reserved 256 MiB at 0x00000000a0000000如果看到这行,说明CMA区域已经reserve成功了。第二个是运行时查看/proc/meminfo和/proc/buddyinfo,CMA区域的内存会体现在特定zone里。更直接的方式是看/sys/kernel/debug/cma/下的调试节点,分配情况会记录在count和used等字段里。
我常用的一个快速检查命令:
cat /sys/kernel/debug/cma/cma-0/used如果驱动已经成功从CMA池分配了内存,这里的used值会增加。没有挂载debugfs的话先挂载:
mount -t debugfs none /sys/kernel/debug4.2 通过物理地址判断来确认
还有一个更硬核的验证方法:打印分配到的物理地址,看它是否落在CMA区域范围内。设备树里配置的cma_buffer@a0000000,物理起始地址是0xa0000000,大小256MB,所以只要分配的物理地址落在0xa0000000到0xb0000000之间,基本可以确定来自CMA池。
我在驱动里临时加了一行调试日志:
dev_info(dev, "buf phys: 0x%llx size: 0x%zx\n", (unsigned long long)dev->buf_phys, dev->buf_size);实测下来,当系统内存压力较大时,分配到的地址会落在CMA区域内;系统空闲时反而可能拿到普通内存。这个现象让我意识到,CMA分配不是“必须从CMA池出”,而是“内存紧张时优先从CMA池保底”。对很多场景来说,这正是我们想要的:平时不挤占普通内存,关键时刻保证有连续大块可用。
4.3 DMA方向与一致性处理
申请到内存只是第一步。DMA传输真正跑起来之前,还必须正确设置DMA方向和同步操作。
以视频采集为例,摄像头传感器通过DMA把数据写入我们申请的缓冲区,CPU再读取处理。这个方向是从设备到内存,对应DMA方向应为DMA_FROM_DEVICE。在启动每次采集前,需要调用:
dma_map_single(dev, buf_virt, buf_size, DMA_FROM_DEVICE);或直接使用coherent映射时,配合dma_sync_single_for_device/dma_sync_single_for_cpu做同步。我之前犯过一个错误:忘了在读取前调用dma_sync_single_for_cpu,导致CPU读到的是cache里旧的脏数据,画面出现随机花屏。后来改用coherent映射加屏障,问题才稳定解决。
5. 实战中容易踩的坑与排查链路
5.1 CMA区域配置了但驱动分配仍然失败
现象:设备树配置了256MB CMA区域,启动日志也显示reserve成功,但驱动里dma_alloc_coherent仍然返回NULL。排查链路如下:
第一反应看分配大小和区域大小是否匹配。视频帧是8MB一帧,如果单次分配超过CMA区域剩余可分配大小,就会失败。用/sys/kernel/debug/cma/cma-0/used查看当前占用,如果剩余空间低于申请大小,那就是典型的容量配置不足。
第二件事查设备DMA掩码。dma_set_mask_and_coherent设置不对,DMA子系统可能认为设备无法访问CMA区域的高地址,直接拒绝分配。我遇到过一次,设备树里CMA区域放在0xa0000000,而驱动里只设置了32位DMA掩码,结果地址超过4GB范围被拒绝,表现就是分配失败。解决办法是把CMA区域放在设备可寻址范围内,或者提高DMA掩码位数。
第三种可能比较隐蔽:of_reserved_mem_device_init_by_idx返回成功,但设备其实没有绑定到正确的CMA区域。尤其是当系统里有多个CMA区域时,索引或者memory-region属性指向错误,驱动会默默从默认CMA池或普通内存分配,看起来“成功”但实际不符合预期。核对设备树配置和实际运行时行为,最直接的方式就是看分配的物理地址区段。
5.2 CMA分配导致系统卡顿的根因
在高负载场景下,我遇到过一个更棘手的问题:CMA分配偶尔会伴随明显的系统卡顿,持续时间几百毫秒到一秒不等。最开始我以为是DMA传输本身的问题,抓了sched调度器日志后发现,卡顿恰好发生在cma_alloc调用期间。
根因在页面迁移。当CMA区域内已有页面被匿名映射或文件页占用时,cma_alloc要把这些页面迁移出去,迁移过程中要写回数据、更新页表、处理TLB失效,这些操作在内存压力大时会变得非常慢。更麻烦的是,如果进程正忙hot地访问这些页面,迁移过程还可能触发反复的缺页异常。
缓解方案有几个方向:
cma_alloc调用加上GFP_NOWAIT标志,避免在原子上下文或要求低延迟的场景中触发耗时迁移。- 在驱动初始化时一次性分配好所有缓冲区,避免运行中频繁调用
cma_alloc。 - 如果迁移确实无法避免,把CMA区域中的页面提前用
migrate_pages预迁移,或者通过mlock等方式减少可移动页的驻留。
我在模拟项目X里最终采用的是方案2:初始化时分配好三个帧缓冲轮转使用,运行中不再做任何CMA分配。卡顿问题基本消失,代价是多占了两倍内存,但换来稳定性和实时性,这个取舍在视频采集场景里是划算的。
5.3 CMA内存被DMA之外的其他模块占用
还有一个坑需要特别提醒:CMA区域内的内存对伙伴系统开放,意味着普通进程的页面也可能被放到CMA区域里。如果某个进程占用着CMA区域里的一块页面且处于不可迁移状态(比如锁定的匿名页或某些内核对象),而你恰好需要分配那块区域,系统可能会失败或者触发慢速迁移路径。
排查这个问题的思路是检查页面的迁移类型。内核里/proc/pagetypeinfo能看到各迁移类型的页分布,如果CMA区域里的不可移动页偏多,说明配置上存在问题。更常见的做法是在设备树CMA区域里指定alloc-ranges,限制CMA池只覆盖某段合适的内存,减少被普通进程占用的概率。
我之前在一个项目里,把CMA区域配置在了DMA设备访问不到的地址范围,同时又离开了ZONE_NORMAL区域,导致dma_alloc_coherent总是回退到普通内存分配。后来通过调整CMA区域到设备可寻址的范围,并限制分配类型,问题才解决。
6. CMA与伙伴系统交互时的行为边界
6.1 为什么CMA区域不是“越早分配越好”
初学CMA时,我很自然地认为CMA区域是“专属内存池”,应该尽快占为己有。实际内核的做法恰恰相反:CMA区域在没有人使用时会尽量借给伙伴系统。借用机制让CMA区域参与整体内存管理,系统内存不足时能临时使用这些页面,这样既保证了连续内存供应,又不浪费内存。
但这带来一个隐藏问题:如果你的驱动加载比较晚,CMA区域里的页面可能已经被其他子系统占满了。此时你想一次性分配几十MB,就可能因为迁移开销太大而失败或超时。解决方案除了提前初始化外,还可以通过cma_alloc时指定GFP_KERNEL搭配较高的优先级,让分配器更积极地迁移页面。
6.2GFP_KERNEL/GFP_ATOMIC分配上下文的影响
cma_alloc支持的标志位会影响分配行为。GFP_KERNEL允许睡眠和页面迁移,适合在进程上下文里使用;GFP_ATOMIC不允许睡眠,若CMA区域内页面需要迁移就会直接失败。我的习惯是:驱动初始化阶段用GFP_KERNEL做完整分配,运行时的突发分配改为GFP_ATOMIC加降级策略,宁可拿不到也不要卡住中断路径。
6.3 CMA区域的大小与碎片化
配置CMA区域时,大小不是越大越好。CMA区域过大会导致可用内存减少,毕竟它的大部分页面平时虽然“借”给伙伴系统,但所有权仍归属于CMA管理。区域过小则在高分辨率场景下容易分配失败。推荐的做法是:
- 先估算需要几块缓冲区,每块多大。比如视频采集需要三块8MB帧缓冲,那就是24MB。
- 再留一定余量,应对迁移损耗和异步分配,一般乘以1.5到2倍,配置为48MB左右。
- 用
/proc/buddyinfo和/sys/kernel/debug/cma/观察实际使用峰值,逐步调整。
在我这个模拟项目X里,最初配了128MB,后来发现利用率极低,平白浪费了内存。降到48MB后,功能不变,可用内存明显多了。这里也引出一个经验:不要迷信“大CMA”,按需配置才是合理的。
7. 性能影响与实测数据
7.1 分配耗时对比
我专门做了个简单测试,对比kmalloc、dma_alloc_coherent(无CMA压力时)和CMA紧张时的分配耗时:
| 分配方式 | 场景 | 平均耗时 |
|---|---|---|
kmalloc8MB | 系统空闲 | 约50微秒 |
dma_alloc_coherent8MB | 系统空闲,CMA区域有货 | 约120微秒 |
dma_alloc_coherent8MB | 系统高负载,需要页面迁移 | 约8毫秒,极端时30毫秒 |
可以看到,页面迁移成本极其昂贵。单纯从API角度你根本看不出问题,只有压力测试才能暴露。这也是我强调“初始化时一次性分配”的原因。
7.2 运行期零分配的设计
针对视频采集模块,我最终采用的方案是固定池化缓冲:
#define FRAME_NUM 3 #define FRAME_SIZE (8 * 1024 * 1024) static struct frame_buf { void *virt; dma_addr_t phys; bool in_use; } frame_bufs[FRAME_NUM];采集驱动启动时,把所有帧缓冲全部从CMA池分配好,运行时循环切换。每个缓冲在DMA传输完成后归还到空闲链表,不再调用任何分配函数。这种设计让系统稳定性和中断延迟都恢复到正常水平,同时也规避了CMA迁移带来的抖动。
7.3 监测DMA性能时注意的指标
用CMA内存做DMA传输,除了分配延迟外,还要关注传输带宽和Cache一致性开销。在ARM平台上,coherent映射和non-coherent映射性能差异明显,尤其是CPU频繁访问DMA写入的数据时。如果不需要CPU高频访问,dma_alloc_noncoherent配合手动同步可能比dma_alloc_coherent更高效。视频采集场景里,CPU通常只需要做少量帧头解析,用coherent映射反而简单直接。
8. 最后再分享两条实操经验
第一次调CMA时,我栽过一个大跟头:为了验证分配是否来自CMA池,我在驱动里加了大量临时代码,结果其中一个错误修改让dma_alloc_coherent白白多分配了一倍的内存,系统可用内存骤降。排查了好久才发现是调试代码里多申请了一块缓冲区。所以无论是调CMA还是做其他驱动开发,临时调试代码一定要做好标记和记录,用完后立即删除,不然坑的是自己。
另一条经验是关于文档阅读的:内核源码树里Documentation/devicetree/bindings/reserved-memory/reserved-memory.yaml和cma相关代码注释是最权威的参考,网上很多教程和博客信息已经过时,尤其是低版本内核和高版本内核在CMA行为上有不少差异。我手里用的这个平台内核版本较新,CMA默认行为已经和几年前的资料描述不一致了。遇到问题优先看内核文档和源码,而不是直接照搬网上的帖子。
CMA这玩意,用好了是解决大块连续内存分配的利器,用不好就是一场性能灾难。希望这篇基于我自己实际调试经验的分享,能帮你少走几条弯路。下次如果你的驱动也遇到大块连续内存分配失败,不妨先看看CMA日志,再查查设备树配置,多半答案就在那里。