1. 从一次内存分配异常说起:dma_heap ioctl 到底在干什么
做过嵌入式 Linux 或者 Android 系统开发的朋友,大概率都遇到过这样的场景:摄像头预览花屏、视频解码器报错ION_IOC_ALLOC failed、GPU 渲染管线突然卡死,翻内核日志发现一堆dma_heap相关的报错。这些问题追到根上,往往都指向同一个东西——DMA 缓冲区分配。而dma_heap就是 Linux 内核在 5.6 版本之后引入的、用来替代老 ION 框架的 DMA-BUF 堆管理器。它对外暴露的核心接口,就是通过/dev/dma_heap/*设备节点上的ioctl系统调用来完成的。
这篇文章我想聊的就是dma_heap的 ioctl 机制。不是那种照本宣科念内核文档的写法,而是从一个实际调试者的角度,把DMA_HEAP_IOCTL_ALLOC这个核心命令拆开揉碎,讲清楚用户态一次ioctl(fd, DMA_HEAP_IOCTL_ALLOC, &data)调用背后,内核到底做了哪些事、参数怎么传、返回值怎么解读、踩坑点在哪里。如果你正在做 Android 的 Gralloc、Codec2、Camera HAL,或者在做嵌入式 Linux 的显示/视频子系统,这篇文章应该能帮你省下不少翻源码的时间。
先给个全局印象:dma_heap的 ioctl 接口设计得非常克制,整个头文件include/uapi/linux/dma-heap.h里定义的命令屈指可数,核心就一个DMA_HEAP_IOCTL_ALLOC。这种极简设计是刻意的——它把"分配"和"使用"彻底解耦,分配出来的东西是一个dma_buf文件描述符,后续所有的映射、同步、共享都走 DMA-BUF 通用框架,而不是在 heap 这一层堆砌命令。理解了这一点,后面所有的细节就都顺了。
2. dma_heap 的设计哲学与 ioctl 接口全貌
2.1 为什么内核要用 dma_heap 取代 ION
要理解dma_heap的 ioctl 为什么长这样,得先知道它替代的 ION 有什么问题。ION 当年是 Android 为了统一管理各种物理内存(system heap、carveout、cma、chunk heap)搞出来的,它把分配器和缓冲区管理揉在一起,一个ION_IOC_ALLOC命令背后藏着大量厂商自定义逻辑。结果是每个 SoC 厂商都在 ION 里塞私货,内核主线根本没法维护,代码越滚越大,最后成了一个"谁都不敢动"的庞然大物。
dma_heap的思路完全反过来:只做分配,不做管理。它把每种内存类型抽象成一个独立的 heap 设备,比如/dev/dma_heap/system、/dev/dma_heap/system-uncached、/dev/dma_heap/cma、/dev/dma_heap/reserved。用户态打开哪个设备,就代表你要从哪种内存池里分配。分配动作本身通过一个统一的 ioctl 完成,拿到dma_buffd 之后,剩下的生命周期管理全部交给 DMA-BUF 框架。这种"一个设备一种策略、一个命令一个动作"的设计,让内核维护者终于能睡个安稳觉了。
从用户态视角看,这个变化带来的直接好处是:你不再需要记住一堆ION_HEAP_TYPE_*的枚举值,也不用处理厂商私有的 flag 位。打开设备、发 ioctl、拿 fd,三步走完。
2.2 dma-heap.h 里到底定义了什么
我们直接把 UAPI 头文件的核心内容摆出来看,这是理解一切的基础:
/* include/uapi/linux/dma-heap.h */ #define DMA_HEAP_IOCTL_ALLOC _IOWR('H', 0x0, struct dma_heap_allocation_data) struct dma_heap_allocation_data { __u64 len; /* 要分配的字节数,必须是页对齐 */ __u32 fd; /* 输出参数:分配成功后返回的 dma_buf fd */ __u32 fd_flags; /* 期望的 fd 标志,如 O_CLOEXEC、O_RDWR */ __u64 heap_flags; /* 传给 heap 的额外标志,目前必须为 0 */ };就这么点东西。_IOWR('H', 0x0, ...)这个宏展开后是一个 32 位的 ioctl 命令码,方向是读写(因为fd字段是内核回填给用户态的),幻数'H'是 dma-heap 子系统专属的。整个结构体 24 字节,字段布局在 32 位和 64 位系统上都是固定的,因为用了显式的__u64/__u32类型。
这里有个细节值得注意:len是__u64,fd和fd_flags是__u32,heap_flags又是__u64。这种排列不是随便来的,是为了保证结构体在 64 位系统上自然对齐、没有 padding 空洞,同时 32 位系统上也能正确解析。内核在dma_heap_ioctl入口处会做一次copy_from_user,把用户态结构体拷进来,校验完再copy_to_user把 fd 写回去。
2.3 一次 ioctl 调用的完整生命周期
从用户态发起ioctl(fd, DMA_HEAP_IOCTL_ALLOC, &data)到拿到可用的 dma_buf fd,中间经历了这些阶段:
- VFS 层分发:ioctl 命令码先到
dma_heap_fops的.unlocked_ioctl回调,也就是dma_heap_ioctl()。 - 命令码校验:内核检查命令码是否等于
DMA_HEAP_IOCTL_ALLOC,不是就直接返回-ENOTTY。 - 参数拷贝与校验:
copy_from_user拿到dma_heap_allocation_data,然后检查heap_flags是否为 0、len是否页对齐、fd_flags是否合法。 - 调用 heap 的分配回调:通过
heap->ops->allocate(heap, len, fd_flags)进入具体 heap 实现,比如system_heap_allocate()。 - 生成 dma_buf:分配器拿到物理页后,构造
dma_buf对象,注册 exporter ops。 - 安装 fd:调用
dma_buf_fd()把 dma_buf 包装成一个匿名文件描述符,回填到data.fd。 - 回写用户态:
copy_to_user把填好 fd 的结构体写回,ioctl 返回 0 表示成功。
整个链路里,第 4 步是真正干活的地方,也是不同 heap 差异最大的地方。system heap 走的是 buddy 分配器 + sg_table,cma heap 走的是cma_alloc,reserved heap 走的是预留内存的物理地址映射。但无论哪种,对用户态暴露的 ioctl 接口是完全一致的。
3. 核心参数逐字段拆解与实操要点
3.1 len 字段:页对齐不是建议,是硬性要求
len是你想分配的字节数。很多人第一次写代码时会直接传一个任意值,比如1920 * 1080 * 4 = 8294400,然后发现 ioctl 返回-EINVAL。原因很简单:内核要求 len 必须是页大小的整数倍。在dma_heap_ioctl里有一句:
if (!data.len || !PAGE_ALIGNED(data.len)) return -EINVAL;PAGE_ALIGNED宏检查低 12 位(4KB 页)是否全为 0。所以正确做法是:
size_t raw_size = width * height * bytes_per_pixel; size_t aligned_size = (raw_size + PAGE_SIZE - 1) & ~(PAGE_SIZE - 1); data.len = aligned_size;这里有个实操心得:不要自己硬编码 4096。有些 ARM64 平台页大小是 64KB,有些 x86 是 4KB,用sysconf(_SC_PAGESIZE)或者内核头文件里的PAGE_SIZE才靠谱。我见过有团队在 64KB 页的平台上写死 4096 对齐,结果分配出来的缓冲区尾部总是差一截,调试了两天才发现是页大小搞错了。
另外,len为 0 也会被拒绝。有些场景你想"先占个 fd 后面再扩",dma_heap 不支持这种玩法,必须一次性把大小定死。
3.2 fd_flags 字段:O_CLOEXEC 几乎是必选项
fd_flags控制返回的 dma_buf fd 的属性。内核会做一次合法性检查:
if (data.fd_flags & ~DMA_HEAP_VALID_FD_FLAGS) return -EINVAL;其中DMA_HEAP_VALID_FD_FLAGS定义为(O_CLOEXEC | O_RDWR | O_WRONLY)。也就是说,你只能传这三个标志的组合。
实际项目里,O_CLOEXEC强烈建议加上。原因在于 dma_buf fd 会持有物理内存,如果 fork 出子进程后 exec 新程序,fd 被继承下去而没人关闭,这块内存就泄漏了。加上O_CLOEXEC后,exec 时内核自动关闭 fd,避免这种隐蔽的泄漏。我调过一个相机预览内存暴涨的问题,最后定位到就是 HAL 层 fork 子进程做 JPEG 编码时,父进程的 dma_buf fd 被继承且没关,几十帧下来内存直接爆掉。
O_RDWR和O_WRONLY的选择取决于你后续要不要往缓冲区写数据。纯显示输出(比如从解码器读、往屏幕送)用O_RDWR最保险,因为很多 mmap 映射操作需要读写权限。只读场景理论上可以O_WRONLY,但实践中很少这么用,容易在 mmap 时踩权限坑。
3.3 heap_flags 字段:目前必须为 0
heap_flags是留给未来的扩展位。当前内核实现里,dma_heap_ioctl会检查:
if (data.heap_flags) return -EINVAL;任何非 0 值都会被拒绝。所以你现在写代码,老老实实填 0 就行。有些老 ION 代码迁移过来的人会习惯性想传ION_FLAG_CACHED之类的,在 dma_heap 里这些语义已经通过"打开不同的 heap 设备"来表达了——要 cached 就开/dev/dma_heap/system,要 uncached 就开/dev/dma_heap/system-uncached,不再通过 flag 区分。
3.4 fd 字段:输出参数,别提前赋值
fd是内核回填的输出参数。用户态在调用前不要给它赋任何有意义的值,内核会覆盖它。调用成功后,data.fd就是可用的 dma_buf 文件描述符。调用失败时这个字段的值是未定义的,不要拿它去 close。
一个常见的错误写法是:
struct dma_heap_allocation_data data = { .len = size, .fd = -1, /* 错误:fd 是 __u32,赋 -1 会变成 0xFFFFFFFF */ .fd_flags = O_RDWR | O_CLOEXEC, .heap_flags = 0, };fd是__u32无符号类型,赋 -1 会变成 4294967295。虽然内核会覆盖它,但这种写法容易让人误以为失败时能靠fd == -1判断,实际上应该靠 ioctl 的返回值判断。
4. 完整实操:从打开设备到释放缓冲区
4.1 用户态分配代码的完整写法
下面这段代码是我在实际项目里反复用过的模板,可以直接抄:
#include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #include <linux/dma-heap.h> #include <stdio.h> #include <string.h> #include <errno.h> int dma_heap_alloc(const char *heap_path, size_t size, int *out_fd) { int heap_fd = open(heap_path, O_RDWR | O_CLOEXEC); if (heap_fd < 0) { fprintf(stderr, "open %s failed: %s\n", heap_path, strerror(errno)); return -1; } long page_size = sysconf(_SC_PAGESIZE); size_t aligned = (size + page_size - 1) & ~(page_size - 1); struct dma_heap_allocation_data data = { .len = aligned, .fd = 0, .fd_flags = O_RDWR | O_CLOEXEC, .heap_flags = 0, }; int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &data); if (ret < 0) { fprintf(stderr, "DMA_HEAP_IOCTL_ALLOC failed: %s\n", strerror(errno)); close(heap_fd); return -1; } *out_fd = (int)data.fd; close(heap_fd); /* heap 设备 fd 用完即关,dma_buf fd 独立存活 */ return 0; }注意最后那句close(heap_fd)。很多人以为关了 heap 设备 fd 缓冲区就没了,其实不是——dma_buffd 是独立的内核对象,有自己的引用计数。heap 设备 fd 只是用来发 ioctl 的"入口",发完就可以关。这个设计很妙,意味着你不需要长期持有 heap 设备 fd,减少了 fd 泄漏的风险。
4.2 分配之后:mmap 映射与 CPU 访问
拿到 dma_buf fd 后,最常见的操作是 mmap 到用户态做 CPU 读写:
void *addr = mmap(NULL, aligned_size, PROT_READ | PROT_WRITE, MAP_SHARED, dma_buf_fd, 0); if (addr == MAP_FAILED) { perror("mmap dma_buf failed"); /* 处理错误 */ }这里有个关键点:mmap 的 offset 必须是 0,dma_buf 不支持部分映射。而且映射长度可以小于分配长度,但通常建议按分配长度映射。
对于 cached heap(/dev/dma_heap/system),CPU 写入后如果要给设备(GPU、显示控制器)用,需要做 cache 同步。dma_heap 本身不提供 ioctl 做同步,得走 DMA-BUF 的DMA_BUF_IOCTL_SYNC:
struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_RW, }; ioctl(dma_buf_fd, DMA_BUF_IOCTL_SYNC, &sync); /* ... CPU 读写 ... */ sync.flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_RW; ioctl(dma_buf_fd, DMA_BUF_IOCTL_SYNC, &sync);这是另一个 ioctl,但作用在 dma_buf fd 上而不是 heap fd 上,别搞混了。uncached heap 分配的内存不需要这步,但 CPU 访问速度会慢一些,适合纯设备访问的场景。
4.3 跨进程共享:fd 传递的正确姿势
dma_buf 的一大优势是能跨进程共享。做法是通过 Unix domain socket 的SCM_RIGHTS机制传递 fd:
/* 发送端 */ struct msghdr msg = {0}; struct iovec iov = { .iov_base = &dummy, .iov_len = 1 }; char ctrl[CMSG_SPACE(sizeof(int))]; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = ctrl; msg.msg_controllen = sizeof(ctrl); struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg->cmsg_level = SOL_SOCKET; cmsg->cmsg_type = SCM_RIGHTS; cmsg->cmsg_len = CMSG_LEN(sizeof(int)); memcpy(CMSG_DATA(cmsg), &dma_buf_fd, sizeof(int)); sendmsg(sock_fd, &msg, 0);接收端用recvmsg拿到 fd。这里有个坑:接收到的 fd 号跟发送端不一样,是接收进程自己的 fd 表里的新条目,但底层指向同一个 dma_buf 对象。所以不要试图用 fd 号做跨进程的标识,要用别的机制(比如自己维护的 buffer id)。
4.4 释放:close 就够了
dma_buf 的释放极其简单——close(dma_buf_fd)。内核的引用计数减到 0 时,自动调用 exporter 的 release 回调,把物理内存还给分配器。不需要任何额外的 ioctl。
但要注意:所有映射必须先 munmap,所有引用必须先释放。如果还有进程 mmap 着这块内存,close 不会立即释放物理页,要等最后一个映射解除。我遇到过显示驱动持有 buffer 引用不放,导致应用层 close 后内存迟迟不回收的情况,最后是靠dma_buf的 debugfs 节点/sys/kernel/debug/dma_buf/bufinfo才定位到是谁在持有。
5. 常见问题排查与避坑实录
5.1 ioctl 返回值速查表
把常见的错误码和原因整理成表,调试时对着查能省不少时间:
| 返回值 | errno | 典型原因 | 排查方向 |
|---|---|---|---|
| -1 | ENOTTY | 命令码不对 | 检查是否用了DMA_HEAP_IOCTL_ALLOC,头文件版本是否匹配 |
| -1 | EINVAL | 参数非法 | len 未页对齐、heap_flags 非 0、fd_flags 含非法位 |
| -1 | ENOMEM | 内存不足 | heap 池耗尽,检查是否有泄漏,或换更大的 heap |
| -1 | EPERM | 权限不足 | 设备节点权限,SELinux 策略是否放行 |
| -1 | EBADF | fd 无效 | heap 设备 fd 是否已关闭 |
| -1 | ENODEV | 设备不存在 | /dev/dma_heap/xxx节点是否创建,内核是否使能对应 heap |
ENOMEM是最难查的,因为表面看是"内存不够",实际往往是泄漏。建议在怀疑泄漏时,周期性读取/sys/kernel/debug/dma_buf/bufinfo,看 buffer 数量和总大小是否持续增长。
5.2 那些年踩过的坑
坑一:以为 heap 设备 fd 要一直开着。前面说过,heap fd 发完 ioctl 就能关。有团队为了"保险"一直持有,结果 fd 数量随分配次数线性增长,最后撞到RLIMIT_NOFILE上限。正确做法是每次分配临时 open、发完 ioctl 立即 close。
坑二:在 32 位进程里用 64 位结构体。dma_heap_allocation_data里有两个__u64字段,32 位进程编译时如果没包含正确的 UAPI 头文件,结构体布局可能错位,导致内核读到的 len 是垃圾值。务必用内核导出的linux/dma-heap.h,不要自己手写结构体。
坑三:cached 和 uncached 混用导致花屏。从/dev/dma_heap/system分配的 cached 内存,CPU 写完直接给显示控制器用,没做 cache flush,屏幕上就会出现撕裂或旧数据。要么用 uncached heap,要么老老实实做DMA_BUF_IOCTL_SYNC。这个问题的隐蔽性在于,它在小数据量、cache 没被挤出的情况下可能不复现,压力测试时才暴露。
坑四:SELinux 策略没放行。Android 上访问/dev/dma_heap/*需要对应的 SELinux 规则。新加一个 heap 设备时,如果file_contexts和te规则没同步更新,ioctl 会返回EPERM,但 dmesg 里可能只有一条 avc denied,容易被忽略。用adb shell dmesg | grep avc能快速定位。
坑五:误以为 dma_heap 支持 realloc。没有这回事。要改大小只能 close 旧的、分配新的。有些场景(比如视频分辨率动态切换)需要提前规划好 buffer 池,避免频繁分配释放带来的抖动。
5.3 调试工具与手段
内核提供了几个很有用的调试入口。/sys/kernel/debug/dma_buf/bufinfo列出当前所有存活的 dma_buf,包括大小、exporter、附件数量。/sys/kernel/debug/dma_heap/下能看到各个 heap 的统计信息。如果内核编译时开了CONFIG_DMABUF_DEBUG,还能追踪每个 buffer 的分配调用栈。
用户态这边,strace -e ioctl能直接看到 ioctl 的参数和返回值,排查参数错误特别快。我一般先用 strace 确认 ioctl 命令码和结构体内容对不对,再去内核侧看具体 heap 的实现。
6. 从 ioctl 看 dma_heap 的扩展性与未来
dma_heap的 ioctl 接口之所以这么简单,是因为它把复杂性都推到了"设备"这一层。想加一种新的内存类型?写个新的 heap 驱动,注册一个/dev/dma_heap/xxx节点,实现allocate回调就行,UAPI 完全不用动。这种设计对下游厂商很友好——高通、联发科各自的私有内存池都能以独立 heap 的形式接入,而不用像 ION 那样往一个大框架里塞。
从内核社区的趋势看,dma_heap 还在持续演进。比如DMA_HEAP_IOCTL_ALLOC之外,社区讨论过要不要加查询 heap 能力的 ioctl、要不要支持分配时的对齐提示,但都因为"保持接口最小化"的原则被搁置了。目前的做法是:需要特殊能力就开新设备,而不是加新命令。这个哲学短期内不会变。
对做系统集成的同学来说,我的建议是:把 dma_heap 的 ioctl 封装成一个薄薄的库,把页对齐、fd_flags 选择、错误处理这些细节都收进去,上层业务代码只调alloc_buffer(size)和free_buffer(fd)。这样既避免了每个模块重复踩坑,将来内核接口有变化时也只需要改一处。我自己维护的这套封装已经跨了三个 Android 大版本没动过,稳定性还是经得起考验的。
最后分享一个排查内存泄漏的小技巧:在怀疑泄漏的进程里,定期ls -l /proc/self/fd | grep dma_buf数一下 dma_buf fd 的数量。如果这个数字只增不减,那基本可以确定有地方分配了没释放。配合/sys/kernel/debug/dma_buf/bufinfo里的 exporter 信息,能快速缩小到是哪个模块的问题。这套组合拳我在实际项目里用过很多次,比盲目加日志高效得多。