dma_heap ioctl 机制详解:DMA缓冲区分配与避坑指南
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是__u64fd和fd_flags是__u32heap_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_tablecma heap 走的是cma_allocreserved 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 字段目前必须为 0heap_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 必须是 0dma_buf 不支持部分映射。而且映射长度可以小于分配长度但通常建议按分配长度映射。对于 cached heap/dev/dma_heap/systemCPU 写入后如果要给设备GPU、显示控制器用需要做 cache 同步。dma_heap 本身不提供 ioctl 做同步得走 DMA-BUF 的DMA_BUF_IOCTL_SYNCstruct 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典型原因排查方向-1ENOTTY命令码不对检查是否用了DMA_HEAP_IOCTL_ALLOC头文件版本是否匹配-1EINVAL参数非法len 未页对齐、heap_flags 非 0、fd_flags 含非法位-1ENOMEM内存不足heap 池耗尽检查是否有泄漏或换更大的 heap-1EPERM权限不足设备节点权限SELinux 策略是否放行-1EBADFfd 无效heap 设备 fd 是否已关闭-1ENODEV设备不存在/dev/dma_heap/xxx节点是否创建内核是否使能对应 heapENOMEM是最难查的因为表面看是内存不够实际往往是泄漏。建议在怀疑泄漏时周期性读取/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 信息能快速缩小到是哪个模块的问题。这套组合拳我在实际项目里用过很多次比盲目加日志高效得多。