做内核驱动,尤其是跟硬件打交道的那一类,迟早会碰上一个绕不开的需求:要一大块、物理连续的、可以长时间持有的内存。这块内存不是 kmalloc 能给的几个页框,也不是 alloc_pages 能碰运气拿到的零散页——视频编解码、图像采集、FPGA 数据交互,全都需要它。我前一阵子接手一个视频采集模块,硬件手册第一页就写死了:DMA 目标地址必须是一块 8MB、物理连续、且 1MB 对齐的缓冲区。kmalloc 直接歇菜,alloc_pages 在系统跑了几天之后也拿不到这么大连续块。后来认认真真换成了 CMA(Contiguous Memory Allocator,连续内存分配器),问题才算彻底解决。这篇文章就从我在 Linux 内核模块里使用 CMA 的实战经验出发,把配置、代码、坑和排查手段一次讲透,希望帮到正在被连续内存折磨的同行。
1. 先把 CMA 的设计逻辑讲清楚:它到底凭什么能连续?
1.1 kmalloc、alloc_pages 和碎片化之间的死结
在内核态拿内存,新手最先接触的是 kmalloc。它接口简单,跟用户态 malloc 差不多,但它能给的大小上限其实很有限:受 SLUB 实现、MAX_ORDER 和 kmalloc 对象尺寸表限制,一般撑死也就几十 KB 到几 MB,而且它保证的是物理连续。真需要 8MB、16MB 这种连续块,kmalloc 基本可以直接放弃。
再往上是 alloc_pages(gfp_mask, order),一次最多分配 2^order 个连续页。order 上限由 MAX_ORDER 决定,主流内核 MAX_ORDER 默认 10 或 11,也就是 4MB 或 8MB 封顶。这个上限还只是“理论”——系统刚启动时可能做到,跑几天业务后,物理内存碎片化,想凑出那么长的连续区域几乎靠运气。而且 8MB 只是采集模块的最低要求,如果硬件 IP 需要 32MB、64MB,alloc_pages 连理论值都不够。
这个问题的本质:普通页分配器是为“小而多”的服务设计,而大块连续物理内存是“少而大”的诉求。两者天然矛盾。传统 reserved memory 的思路虽然能解决连续性,但代价太大:一块 64MB 的区域如果常年只给一个驱动用,系统就永远少了 64MB。CMA 的核心创新,就是把这个“提前预留”变成了“弹性预留”。
1.2 CMA 的弹性机制:平时共享,紧急时回收
CMA 的工作原理可以这样理解:启动阶段,内核把一块物理区域(大小由你定)从伙伴系统中征用为 CMA 区域,但征用之后并不锁死。区域内的物理页面仍然通过伙伴系统提供给普通申请,只不过这些页面被标记成“可移动”(movable)。当某个驱动调用 cma_alloc 需要连续大块时,内核看中这块区域里哪些位置的空闲页不够,就启动页面迁移,把区域内正在使用的可移动页面搬到别处,腾出一片干净、物理连续的页框返回给调用者。
这个过程有点像我在地下停车场见过的弹性车位:平时谁都能停,一旦有贵宾车队要进,系统提前通知车主挪车,把整条通道让出来。页面迁移的代价是时间和 TLB/缓存开销,所以 cma_alloc 不是一个轻量操作,但它换来的是“平时不浪费内存,紧急时又有连续内存可用”这个巨大收益。
从实现细节说,CMA 区域内部用 bitmap 管理页框占用情况(order_per_bit 决定 bitmap 粒度),cma_alloc 的本质是:在 bitmap 里找一段连续空闲位,再用 alloc_contig_range 真正把区域内相关的页框隔离出来,如果这里面有移动中的页面就等它们完成迁移,最终把这段 PFN 转成 struct page 返回。这一点搞清楚之后,你就能理解为什么 cma_alloc 不是 O(1) 接口,为什么它必须放在进程上下文调用——后者我在第 4 章会重点展开。
1.3 CMA 和传统预留内存的区别
很多刚接触 CMA 的人容易把它理解成“一块专用缓冲区”,“我预留了 256MB,这 256MB 就该是我的”。不对。CMA 区域并不是固定属于某个驱动,它更像一个“公共连续内存池”。传统 reserved-memory 里用 no-map 标注的区域,那才是真·预留:映射都不建立,普通分配永远用不到。CMA 的标注是 reusable,意思是“这块区域可以被复用,但要保证能随时回收回来连续”。所以配置 CMA 时,大小不是“我要用多少就留多少”,而是“我希望系统在极端碎片化时还能兜底拿出多少”。
这种设计也让 CMA 有了一层天然的“兜底池”意义:就算驱动没有主动持有 CMA 页面,区域内那些空闲的、可移动的页面在整体内存紧缺时,一样会被内存回收机制利用。换句话说,CMA 区域平时并没有完全脱离伙伴系统。
2. 环境准备:内核开关、启动参数和设备树一个都不能少
2.1 内核配置怎么确认
动手写模块之前,先看目标内核有没有开 CMA。最简单的方法:
zcat /proc/config.gz | grep -E "CONFIG_CMA|CONFIG_DMA_CMA"如果系统没挂 configfs,也可以看 /boot/config-$(uname -r)。至少需要:
- CONFIG_CMA=y:CMA 核心机制,必须开
- CONFIG_DMA_CMA=y:让 DMA API 也认识 CMA,强烈建议开。这样做设备树 reserved-memory 方式的设备专属区域时,dev->cma_area 才能被正确初始化,后面用 dma_alloc_coherent 也不会出问题
如果这两个不是 y 而是 n,最省事的做法是重新配置内核编译目标平台。注意你在做内核模块,运行的宿主内核不支持 CMA,模块单独有代码也白搭——cma_alloc 这个符号根本不会存在,insmod 时会直接报 unknown symbol。
调试配置也不建议省:
- CONFIG_CMA_DEBUG=y:让 CMA 内部操作打印更多跟踪信息
- CONFIG_CMA_DEBUGFS=y:在 debugfs 下暴露 CMA 区域的状态,排查分配失败非常有用
2.2 三种划出 CMA 区域的方式
有了内核支持,还要把物理内存真正划出 CMA 区域。常见有三种做法。
第一种,内核启动参数,最简单粗暴:
cma=64M这会在内存里动态找一块 64MB 区域作为默认 CMA。想指定起始地址也可以,比如 cma=64M@0x40000000,但嵌入式平台上由于固件保留区域、DDR 布局等原因,手工指定地址很容易撞车,我一般只在调试时这么干。
第二种,设备树默认区域。在 reserved-memory 里定义一个 compatible 为 shared-dma-pool、带 reusable 属性的节点,并用 linux,cma-default 属性标注为默认 CMA:
/ { reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; linux,cma { compatible = "shared-dma-pool"; linux,cma-default; reusable; size = <0x0 0x10000000>; /* 256MB */ }; }; };这种方式的好处是区域位置可控,能避开关键外设的 DMA 地址限制,社区也推荐在设备树里显式表达内存布局。
第三种,per-device CMA(设备专属区域)。如果你不想让某个驱动吃垮全局 CMA,或者某块硬件对地址、大小有特殊要求,可以给它单独立一块:
reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; codec_cma: codec_cma@0 { compatible = "shared-dma-pool"; reusable; size = <0x0 0x800000>; /* 8MB */ alignment = <0x0 0x100000>; /* 1MB */ }; }; mycodec { compatible = "vendor,mycodec"; memory-region = <&codec_cma>; };这里核心是两个词:compatible = "shared-dma-pool" 告诉内核这是一个 DMA 共享池;reusable 告诉内核这个区域可以被普通 movable 页面复用,CMA 分配时再回收。少了 reusable,内核会把它当作普通预留区,cma_alloc 界面根本用不了。memory-region 属性把这块区域绑定到 mycodec 设备上,probe 时就能通过 dev->cma_area 拿到它。
三种方式不是互斥的:默认 CMA 可以同时存在,per-device CMA 也可以单独存在。优先级上,设备绑定区域优先于全局默认区域,适合隔离不同硬件通路。
2.3 验证 CMA 区域是否真的生效
配置改完、内核起来后,先看一眼:
cat /proc/meminfo | grep -i cma预期能看到类似:
CmaTotal: 262144 kB CmaFree: 262144 kBCmaTotal 就是 CMA 区域总大小,CmaFree 是当前空闲可分配的连续内存量。如果你设了 256MB,CmaTotal 显示只有 250MB 或 253MB,不用慌——内核在划分区域时会做页框对齐、可能扣除与系统其他保留区冲突的部分,区域略小于请求值属于正常现象。真正要警惕的是 CmaTotal 为 0,那说明 CMA 压根没创建,优先去查内核配置和启动参数有没有生效。
如果开启了 CONFIG_CMA_DEBUGFS,还能在 debugfs 里逐块看 CMA 区域状态:
mount -t debugfs none /sys/kernel/debug ls /sys/kernel/debug/cma/节点里通常能看到每个区域的分配与释放情况,后面排查“区域明明有空间但 cma_alloc 失败”时很有用。
3. 内核模块实操:从拿区域到释放的完整代码
3.1 获取 CMA 区域:dev->cma_area 是正路
模块代码第一步是拿到 struct cma *。很多人第一反应是在代码里写:
extern struct cma *dma_contiguous_default_area;然后直接用它。这个变量在内核某个源文件里确实存在,但它没有 EXPORT_SYMBOL,你的模块链接时会报 unknown symbol,根本加载不了。除非你改内核源码重新编译,否则别走这条路。
正确姿势是跟设备树结合。以 platform driver 为例,probe 里加上:
#include <linux/of_reserved_mem.h> static int mycodec_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct cma *cma; int ret; ret = of_reserved_mem_device_init_by_idx(dev, dev->of_node, 0); if (ret) { dev_err(dev, "failed to init reserved mem, ret=%d\n", ret); return ret; } cma = dev->cma_area; if (!cma) { dev_err(dev, "no cma area bound to device\n"); return -ENOENT; } /* ... */ }of_reserved_mem_device_init_by_idx 会解析设备节点 memory-region 属性(索引 0),把该 reserved-memory 区域与设备关联,内部还会根据区域类型初始化。如果这块区域是 shared-dma-pool + reusable,内核就会把它转换为 CMA 并把指针填进 dev->cma_area。这一步执行完后,dma_alloc_coherent 和 cma_alloc 都能识别它。
如果你的模块不是 platform driver,或者你更想自己掌控节点查找,可以直接用 of_reserved_mem_lookup:
struct device_node *np; struct reserved_mem *rmem; np = of_parse_phandle(dev->of_node, "memory-region", 0); if (!np) return -ENOENT; rmem = of_reserved_mem_lookup(np); of_node_put(np); if (!rmem || !rmem->priv) return -ENOENT; cma = (struct cma *)rmem->priv;rmem->priv 对 shared-dma-pool 区域来说,就是对应的 struct cma *。两种方式本质都是从 reserved-memory 机制拿区域引用,我个人更推荐前一种,因为 dev->cma_area 与 DMA API 保持一致,后面想改成 dma_alloc_coherent 也不费劲。
3.2 cma_alloc 分配:参数含义先搞清楚
拿到 struct cma * 之后,分配调用的完整原型是:
struct page *cma_alloc(struct cma *cma, size_t count, unsigned int align, gfp_t gfp);四个参数按顺序说。第一个是区域指针。第二个 count 是页数,不是字节数!要分配 8MB 且页大小 4KB,得传 2048,不是传 8MB。写代码时别手算,用宏:
#define BUF_SIZE SZ_8M size_t nr_pages = BUF_SIZE / PAGE_SIZE;第三个 align 是最容易出错的参数。它不表示字节对齐,而是表示“页阶”(order),实际对齐到 2^align 个页。比如 4KB 页的系统,1MB 对齐就是 2^8 页,要传 align=8,不是传 1048576。如果只是想让页框自然对齐,传 0 即可。另外 align 会被内核钳制在 CONFIG_CMA_ALIGNMENT(默认 8)以内,想突破这个上限,需要重编内核。
第四个 gfp 标志。cma_alloc 内部可能做页面迁移和回收,必然伴随阻塞,建议用 GFP_KERNEL,同时加 __GFP_NOWARN 防止失败时页分配器打印一长串又长又难看的警告栈——但失败原因你要自己打印清楚,后面排查要靠它。
一个完整分配示例:
struct page *pages; void *vaddr; pages = cma_alloc(cma, nr_pages, 8 /* 1MB align */, GFP_KERNEL | __GFP_NOWARN); if (!pages) { dev_err(dev, "cma_alloc failed, nr_pages=%zu\n", nr_pages); return -ENOMEM; }3.3 地址映射:拿到的是 struct page,不是虚地址
cma_alloc 返回的是 struct page *,你可以直接用 kmap 映射单个页,但要连续访问整块缓冲区,推荐 vmap 一次性把这段物理页映射成连续的虚拟地址:
vaddr = vmap(pages, nr_pages, VM_MAP, PAGE_KERNEL); if (!vaddr) { cma_release(cma, pages, nr_pages); return -ENOMEM; } memset(vaddr, 0, BUF_SIZE); /* 后面第 4 章会解释为什么要清零 */有的驱动也习惯拿物理地址操作:
phys_addr_t paddr = page_to_phys(pages);然后往相关硬件的 DMA 描述符里填 paddr。如果只用物理地址访问,用 memremap 映射安全地址区间也行:
vaddr = memremap(paddr, BUF_SIZE, MEMREMAP_WB);但 vmap 的优势是操作对象是 page 数组,不依赖物理连续性判断,对多数模块更顺。用完后别忘了反向操作 vunmap 或 memunmap。
这里有个容易忽略的问题:vmap/vunmap 有页表建立和销毁开销,如果驱动每次采集帧都做一次分配加映射,性能会很难看。视频类驱动通常是 open 时分配并永远映射好,采集时用同一个地址跑,close 才释放。CMA 绝对不是让你做高频小对象分配用的。
3.4 释放:先解映射再归还区域
释放顺序是:先 vunmap,再 cma_release。千万别反过来,否则虚拟地址映射悬空,下次复用同一段物理页时,旧映射可能还在,访问的数据就会串。
vunmap(vaddr); if (cma_release(cma, pages, nr_pages)) dev_info(dev, "cma buffer released\n"); else dev_err(dev, "cma_release failed!\n");代码里 cma_release 返回布尔值,失败时打印出来有助于发现“反复释放同一块内存”之类的逻辑问题。释放后,这组页框回到 CMA bitmap,并作为 movable 页面归还伙伴系统,可以被普通内存申请复用,下次 cma_alloc 再聚拢。
3.5 编译和加载的注意事项
模块 Makefile 本身没太特别的地方:
obj-m := cma_demo.o KDIR ?= /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean但要特别提醒:cma_alloc、cma_release、of_reserved_mem_device_init_by_idx 这些符号都是 GPL 导出的。如果模块里没写 MODULE_LICENSE("GPL"),或者写了 MODULE_LICENSE("Proprietary"),insmod 时会直接报 unknown symbol。我第一次踩这个坑时查了很久,最终发现只是 license 声明不对。这也是很多“按教程写但是加载不了”问题的根源。
4. 避坑清单:几个让我熬夜排查的细节
4.1 align 的语义到底有多坑
我专门把这节提出来,是因为代码里写过:
cma_alloc(cma, nr_pages, 1024 * 1024, GFP_KERNEL);心想 1MB 对齐,传字节数总没错吧。结果 align 被内核当作页阶,1048576 远超上限,内核直接把它钳到 CONFIG_CMA_ALIGNMENT(默认 8),也就是 2^8 页 = 1MB。那一次恰好就是我想要的 1MB 对齐,所以代码跑通了。可当我想改 2MB 对齐时,传 2097152 依然只有 1MB,调研源码才明白:非但 align 的单位是页阶,而且默认内核还把它钳在上限 8,要更大的对齐只能重新配置 CONFIG_CMA_ALIGNMENT。
这个转换关系拿捏住之后就再不会错了:4KB 页系统上,align=0 对齐 4KB,align=8 对齐 1MB,align=9 对齐 2MB,依此类推。如果你的硬件手册要求“首地址 2MB 对齐”,记得先确认内核配置允许这个 align 值。
4.2 cma_alloc 会在内部休眠
cma_alloc 不是“快”操作。页面迁移要等被移动的 page cache 写回、匿名页 swap 完成,某些场景下还要遍历 LRU 找可移动页。这些动作都是可以睡眠的。在中断上下文、spinlock 持锁区间、原子上下文调用,轻则分配永远失败,重则直接触发内核调度器警告,甚至 panic。这也是为什么设备驱动必须在 open、probe、worker 线程里做 CMA 分配,而不是在 DMA 完成中断里做。
如果确实需要“中断里立刻拿连续内存”,要把分配动作提前到中断未发生时完成,或者用其他机制提前备好缓冲。不要跟内核的睡眠机制硬刚。
4.3 CMA 返回的页面不保证清零
kzalloc 会把内存清零,cma_alloc 不会。它返回的页可能残留上一个使用者的数据。对硬件 DMA 写缓冲区来说可能无所谓,但如果这块 CMA 内存要暴露给用户态作为共享内存,不清零就等于数据泄露。我之前调试一个采集模块时,一帧图像里时不时冒出上一次会话的旧画面,查了很久才发现 CMA 区域复用了,驱动又没在每次分配后做 memset,最后在分配成功后补了一行:
memset(vaddr, 0, BUF_SIZE);问题立刻消失。这是一条成本极低、收益极高的防护线,建议每次分配后无脑清零,除非你能明确证明硬件不会读旧数据。
4.4 默认 CMA 符号未导出,不要依赖 extern
前面已经说过 dma_contiguous_default_area 没导出。但还有一个隐患:即使在你自己移植的树里把模块编进内核,突然某天换成模块化加载,立刻暴露。所以代码里别依赖这种内部符号,统一通过 dev->cma_area 或 of_reserved_mem_lookup 拿区域,才是面向内核 ABI 的正确写法。
4.5 别把 CMA 当大内存池,长期霸占
有的团队图省事,开机就 cma_alloc 几百 MB 留着,以后慢慢用。这会带来两个问题。其一,这些页一直处于区域占用状态,CmaFree 下降,其他需要 CMA 的模块(比如显示、ISP)分配时会直接失败。其二,CMA 区域虽然平时允许普通页复用,但长期占用会让区域内普通页越来越少,系统在内存紧张时反而失去一个弹性缓冲。合理做法是:只在真正需要连续内存的硬件生命周期内持有,其他时间释放回区域。
5. 常见问题排查速查表
直接给一张表,保存下来排错方便。
| 现象 | 可能原因 | 排查/处理方向 |
|---|---|---|
| modprobe 报 unknown symbol: cma_alloc | 模块 license 非 GPL,或运行内核没开 CONFIG_CMA | 模块加 MODULE_LICENSE("GPL");用 /proc/config.gz 确认内核配置 |
| cma_alloc 返回 NULL,但 CmaFree 明明还有空间 | 对齐要求超过区域实际可对齐范围;目标位置页正被不可移动占用;区域被其他持有块堵住 | 检查 align 值;开启 CONFIG_CMA_DEBUG 看迁移日志;检查其他模块占用 |
| CmaTotal 为 0 | 内核配置没开、启动参数丢失、设备树节点没解析 | 确认 CONFIG_CMA/CONFIG_DMA_CMA;确认 dmesg 里 reserved-memory 报错;/proc/meminfo 复查 |
| 内核 panic: sleeping function called from invalid context | 在中断/原子上下文调用 cma_alloc | 审查调用路径,移到进程上下文;中断里用预分配缓冲 |
| 分配到的内存内容有残留旧数据 | cma_alloc 不清零 | 分配后 memset,或用 kzalloc 风格封装 |
| 频繁分配释放后系统卡顿 | vmap/vunmap 页表开销大,加上 cma_alloc 迁移开销 | 改为长期持有映射;减少 CMA 分配频率 |
| dma_alloc_coherent 拿到地址超出设备 DMA 范围 | CMA 区域落在设备 DMA mask 之外 | 设备树里给该区域加地址约束,检查 DMA mask |
| 系统内存不足时 cma_alloc 仍然失败 | 区域内页面迁移太慢或无法迁移,伙伴系统又没有可交换空间 | 优化内存水位;考虑增大 CMA 区域或换更宽松的区域配置 |
排错时还要养成看 dmesg 的习惯。CMA 分配失败时内核会记录迁移失败的关键信息,比如 “Unable to isolate free pages”、“failed to migrate pages” 等关键词。建议直接:
dmesg | grep -iE "cma|migrate|isolate"再配合 /proc/buddyinfo 看物理内存分布,一般能很快定位是“区域真的满了”还是“页面迁移碰上钉子户”。
另一个排错小技巧:开启 CONFIG_CMA_DEBUGFS 后,用 debugfs 节点看每个 CMA 区域的剩余量和持有者的信息。我在一次定位“某驱动把 256MB 全部占光导致显示模块挂掉”的问题里,就是靠这个节点看到持有者是哪个驱动的。
6. 选型建议:什么时候别直接调用 cma_alloc
6.1 dma_alloc_coherent 和 cma_alloc 怎么选
很多场景其实用不到这么底层的 cma_alloc。如果设备只需要一块用于 DMA 的一致映射内存,标准做法是 dma_alloc_coherent:
dma_addr_t dma_handle; void *cpu_addr; cpu_addr = dma_alloc_coherent(dev, BUF_SIZE, &dma_handle, GFP_KERNEL);在开启 CONFIG_DMA_CMA 后,dma_alloc_coherent 内部对较大的连续需求也会走 CMA 区域(优先 dev->cma_area,其次全局默认 CMA),而且它会替你处理 DMA mask、地址转换、一致性缓存操作。少写不少代码,也更不容易错。
这两条路线的对比:
| 维度 | dma_alloc_coherent | cma_alloc + vmap |
|---|---|---|
| 接口复杂度 | 低,一行拿可用地址 | 高,要自行管理 page、映射、释放 |
| 物理连续性 | 由内核保证,大块时走 CMA | 完全由 CMA 保证 |
| DMA 一致性 | 自动处理 | 需要自己注意 cache 同步 |
| 生命周期控制 | 间接,release 释放 | 完全可控,更细 |
| 适用场景 | 普通外设驱动、简单 DMA 缓冲 | 多媒体、编解码、需要精确对齐和长期映射的场景 |
6.2 我实际项目里的决策经验
如果你只是给一块普通传感器芯片做 DMA,用 dma_alloc_coherent 就够了,不要给自己加戏。但如果你的硬件对缓冲区有特殊要求,比如“首地址必须 2MB 对齐,长度达几十 MB,跨帧复用同一个映射”,那 dma_alloc_coherent 的参数空间比较有限,直接 cma_alloc 反而更符合需求。
还有一条经验:per-device CMA 和默认 CMA 的取舍,本质上是“隔离”与“弹性”的取舍。设备专属区域把风险圈定在单个设备里,但内存利用率低;默认全局区域利用率高,一个驱动霸占会祸害全部模块。项目里如果只有一个大块内存消费者,默认区域足够;如果有多个高带宽多媒体设备,强烈建议各自独立区域,别互相挤兑。
除此之外,还有一种更贴用户态的路子:dma_buf 配合 mmap,把 CMA 页面导出给用户态,应用程序直接访问。这套在视频编解码框架里非常常见,它本质上也是先用 cma_alloc 或 dma_alloc 拿到物理连续页,再包装成 dma_buf。如果你要做的模块最终面向用户态展示,建议从一开始就往 dma_buf 方向规划,避免后面从内核缓冲复制到用户态的开销。
我个人在实际项目里把这套跑通之后,最大的体会是:大块连续物理内存这个问题,选型比硬扛重要。一开始我还在 kmalloc 和 alloc_pages 之间反复试,浪费了不少时间;后来回头看,CMA 从设计上就是为这个问题准备的,只要把区域配好、API 参数吃透,它用起来比想象中简单很多。真要说建议,新手可以先从 dma_alloc_coherent 入手建立感觉,遇到特殊对齐、生命周期控制、多缓冲复用的需求,再落到 cma_alloc + vmap 这套方案上。希望这篇笔记能让你少熬几个夜,也欢迎在实际项目中遇到过类似坑的朋友多交流,尤其是那些不写在文档里的地址映射和迁移失败问题,往往是整块功能能否稳定跑起来的关键。