1. 为什么边缘AI视觉一定绕不开跨进程通信
先说结论:在RK3588上做边缘AI视觉,如果还没把“零拷贝跨进程通信”纳入架构设计,那你大概率已经或者即将被性能问题按在地上摩擦。
我去年接手了一个基于RK3588的智能视频分析盒子,硬件资源其实挺豪华:四核Cortex-A76加四核Cortex-A55,内置6 TOPS算力的NPU,支持8K视频硬编解码的VPU,还有RGA做图像加速。但软件架构一开始是按“单进程全搞定”的思路写的,摄像头采集、AI推理、RTSP推流、Web展示全塞在一个进程里,开发的时候还行,一旦把模型换成YOLOv8s、分辨率提到1080P甚至4K,问题就全出来了。后来果断把整个流水线拆成多进程,才理解了“跨进程通信”这件事在边缘设备上的分量。
为什么要拆进程?回到应用场景就清楚了。一个典型的路口/园区/工厂边缘AI盒子,软件栈通常是这样的:
- 采集进程:从MIPI CSI或USB摄像头取流,拿到RAW图或YUV图;
- 预处理进程:做缩放、裁剪、格式转换(这步经常用RGA);
- 推理进程:跑RKNN的YOLOv8检测模型,输出目标框和类别;
- 编码推流进程:把带标注或不带标注的画面用VPU硬编码成H.264/H.265,走RTSP出去;
- 展示/业务进程:处理报警事件、叠加OSD、上报平台。
这些模块如果做成独立进程,任何一个崩溃都可以单独重启,不会把整机“带崩”;而且RK3588是大小核架构,可以让实时采集和编码跑在A76大核,把网络服务和业务逻辑放到A55小核,这种资源隔离和故障隔离在多进程架构下是天然支持的。
但一旦拆了进程,就逃不开一个问题:图像帧怎么在进程之间传?
一帧1080P RGBA图像是1920×1080×4,约8.3MB;4K分辨率直接飙到33MB。如果按30fps算,每秒光图像原始数据就是250MB到1GB的吞吐量。你用传统的Socket、管道、消息队列去传?我实测过,TCP loopback在RK3588上大概能跑800MB/s到1GB/s,但CPU占用高得吓人,A76核心直接干到60%以上,因为内核协议栈里至少发生两次内存拷贝。更别提你还得考虑NPU推理和VPU编码,这些硬件单元根本不会读你用户态malloc出来的普通内存,它要么需要物理连续内存,要么需要经过IOMMU映射的DMA内存。
所以边缘AI视觉的跨进程通信,核心诉求就三个字:零拷贝。
这里的“零拷贝”不是指数据完全不复制,而是说数据在“进程A的地址空间”到“进程B的地址空间”这个过程里,不需要CPU参与逐字节搬运。理想情况下,同一块物理内存被映射到多个进程的虚拟地址空间,采集进程往这块内存写数据,推理进程直接读同一块内存,中间CPU零参与。这就得靠Linux下真正适合硬件的共享内存机制:DMA-BUF,或者老一点项目里常见的ION。
2. 跨进程通信的方案选型与零拷贝原理
2.1 主流的进程间通信方案,挨个对比一遍
很多没在嵌入式Linux下搞过高吞吐数据流的朋友,一上来会想用“常规”方案,我先列个表把它们的适用边界讲清楚。
| 方案 | 吞吐量 | CPU开销 | 是否适合图像帧 | 备注 |
|---|---|---|---|---|
| 管道/命名管道(PIPE/FIFO) | 低 | 高(每帧多次拷贝) | 不适合 | 设计给字节流,无帧边界概念 |
| System V消息队列 | 低 | 高 | 不适合 | 有大小上限,通常几十KB封顶 |
| POSIX消息队列 | 中低 | 高 | 不适合 | 同样有长度限制 |
| UNIX域字节流Socket | 中 | 中高 | 勉强,但不推荐 | 可携带FD做fd传递,帧需要自己封装 |
| UNIX域数据报Socket | 中 | 中高 | 不推荐 | 数据报长度上限大约200KB左右 |
| 传统共享内存(shm_open+mmap) | 高 | 低 | 适合应用层 | 但硬件模块(NPU/VPU/RGA)访问它有障碍 |
| DMA-BUF/ION共享内存 | 高 | 极低 | 非常适合 | 硬件设备与用户态进程间共享的唯一正道 |
使用Socket或消息队列传图像,等于把每一帧当成普通网络包去发,内核里要经历“用户态→内核态→用户态”的搬运,一次发送就是两次拷贝,接收又是两次,一帧4K图像来回就是上百MB的拷贝量,CPU占用自然爆炸。另外Socket还有粘包分包问题,传图像还要自己设计帧协议,麻烦。
用传统共享内存会好很多,但有个致命问题:你在用户态分配的内存,地址是虚拟地址,物理页面可能是不连续的。而RK3588的NPU、VPU、RGA、ISP这些硬件单元去访问内存,走的是DMA引擎,它们要么需要物理连续内存,要么需要IOMMU帮你建立映射关系。普通shm内存块没有经过DMA-BUF框架,硬件模块没法直接使用,最终还是得把数据拷到DMA缓冲区里。相当于你还是没有做到“零拷贝”。
2.2 零拷贝的三个层次,别把概念搞混了
先掰开揉碎讲清楚,免得大家写代码时踩概念坑。零拷贝在Linux下分好几种含义:
- 网络发送场景的零拷贝:比如sendfile/splice,让数据从文件直接到网卡,绕过用户态。这个场景其实和嵌入式视觉关系不太大,但很多人提到零拷贝就以为是这个。
- 用户态进程之间的零拷贝:A进程和B进程通过mmap映射同一块物理内存,A写完B直接读,不需要内核帮忙搬运。这是我们这个主题的主角。
- 用户态到硬件的零拷贝:图像数据从内存到NPU、VPU、ISP,不走CPU搬运,而是通过硬件DMA引擎直接访问。RK3588上这块做得比较好,RKNPU和MPP都支持直接吃DMA-BUF。
真正的边缘AI视觉,需要第二层和第三层同时成立。你不仅希望采集进程和推理进程共享一块内存,还希望这块内存能被NPU直接读、被VPU直接读。这就是为什么DMA-BUF(或老ION)是标准答案,而不是普通的System V共享内存。
DMA-BUF的核心设计思想,是把“一块内存”抽象成一个“缓冲区对象”,这个对象可以导出成一个文件描述符(fd),然后这个fd可以跨进程传递。谁拿到这个fd,谁就把它mmap到自己的用户态地址空间,读写同一块物理内存。fd本身还能被硬件驱动识别,驱动拿到fd后可以获取底层的DMA地址,丢给NPU/VENC/RGA去访问。
这套机制总结下来就是:DMA-BUF = 内核管理的DMA内存块 + 跨进程共享能力 + 硬件访问能力。设计得非常优雅,它把一个原本散落在驱动层面的能力,抽象成了用户态可操作的标准接口。
3. RK3588平台上的零拷贝方案:从ION到DMA-BUF
3.1 RK3588到底集成了哪些需要DMA内存的硬件模块
先盘点一下RK3588的硬件资源,这样你才知道为什么DMA-BUF在这个芯片上几乎是绕不开的。
- NPU:RKNN工具链支持输入格式有普通内存、DMA-BUF、以及带物理地址的ion内存(旧版本)。推理时如果能把输入图像直接指向DMA-BUF,可以省掉一次rknn_inputs的memcpy,尤其当图像要二次进模型的时候,节省的时间很可观。
- VPU视频编解码单元:Rockchip的MPP(Media Process Platform)库,输入输出的帧缓冲区必须是MPP自己分配的内存,本质上就是DMA-BUF或ION内存。你想从别的进程丢一帧画面给编码器,如果不把这段内存转成DMA-BUF,MppEncoder根本不会接受。
- RGA 2D硬件加速器:做缩放、旋转、格式转换,它的输入输出缓冲区要求也是DMA内存。我在项目里用RGA把YUV转成RGB再喂给NPU,如果输入输出都是DMA-BUF,整条链路可以在几百微秒内完成。
- ISP图像信号处理器:MIPI-CSI进来之后,ISP输出的buffer也是DMA memory,通常由camera驱动直接分配到ion/dmabuf。
回到上一节说的数据流:摄像头采集进程从V4L2拿到的buffer,本身就是DMA-BUF;推理进程如果收的是普通shm,就得拷一次;但如果直接跨进程传DMA-BUF的fd,整条链路都是同一个物理内存,一次拷贝都省了。
3.2 DMA-BUF和ION在RK3588上的真实使用情况
RK3588主线的内核(5.10以上)已经全面切换到了DMA-BUF体系,老安卓项目里那种/dev/ion节点在新内核里默认是关闭的。实际开发时,你更常碰到的是下面这几种情况:
- 用/dev/dma_heap/system这个节点来分配通用DMA-BUF(驱动名字叫system heap)。这是标准主线内核方式,RK3588的Debian/Ubuntu镜像里默认能看到这个节点。
- 用Rockchip MPP的mpp_buffer接口来创建DMA-BUF,这个主要给编解码用。
- 用v4l2的request buffers拿到的CAPTURE buffer,本身就是DMA-BUF,配合V4L2_MEMORY_DMABUF或MMAP模式使用。
- 用RKNN的rknn_create_mem接口从NPU驱动申请DMA内存(rknn专用内存),也可以导出成fd走共享。
我在项目里最常用的是/dev/dma_heap/system。如果你打开板子上的终端,输入ls /dev/dma_heap/,大概率能看到system这个节点。它就是从内核里分配连续物理内存或IOMMU可访问内存的入口。
关于dma_heap,最核心的接口是ioctl:
- DMA_HEAP_IOCTL_ALLOC:分配一块内存,返回fd。需要指定分配大小和对齐方式。
- 然后对这个fd做mmap,就能把这块物理内存映射到用户态虚拟地址空间。
- 需要硬件访问的时候,把这个fd传给对应的驱动/库(比如rknn、mpp、rga),它们内部会调用dma_buf的API来map到硬件总线地址。
这里有个大家容易忽略的细节:dma_heap.system分配出来的内存,默认不是物理连续,而是通过IOMMU映射成连续的DMA地址。但对应用透明,你完全可以当作一块连续的DMA内存来用。
如果非要物理连续内存(比如某些旧的ISP驱动或者没有IOMMU的外设),那就得用reserved memory或者cma分配,这块在应用层不太可控,通常驱动内部已经处理好,我们不直接操作。
3.3 为什么传统mmap共享内存在这里行不通
这个问题值得单独拿出来解释,因为我在实际交流中发现太多人踩了坑。
你随手用shm_open或者memfd_create创建一个共享内存,然后mmap到用户态,两个进程确实都能读写。看起来没问题,对吧?但是当你把这帧数据喂给RKNN的模型推理接口时,rknn_inputs的输入缓冲区类型如果不被支持,NPU驱动的处理逻辑通常是先从这个用户态地址拷贝到自己的内部DMA buffer,再做硬件推理。这一步拷贝在1080P、30fps的情况下,大约能占到推理进程15%-25%的CPU时间。
更麻烦的是MPP编码器。MPP分配buffer的模式比较特殊,它是从自己的mpp_buffer_pool里面统一管理的,你从外部传入一个DMA-BUF fd它是认的,但从外部传入普通shm的指针它直接返回参数错误。
所以,结论很明确:在RK3588边缘视觉流水线上,用DMA-BUF作为进程间共享内存的载体,不是一个可选项,而是性能架构的必选项。它把“进程间通信”和“硬件访问”两件事统一到了同一套机制上。
4. 实操:基于DMA-BUF的零拷贝图像传输
理论说完了,下面写一套完整可跑的代码套路。我在RK3588上验证过的方案是:
- 进程A(采集端):从/dev/dma_heap/system分配DMA-BUF并mmap,把一帧图像数据填进去,然后通过Unix域Socket把fd发给进程B。
- 进程B(消费端):通过Socket收到fd,把同一块内存mmap到自己的虚拟地址空间,直接读取图像数据,然后交给RGA或NPU处理。
4.1 第一步:用dma-heap分配一块DMA-BUF
先放分配端的核心代码:
#include <linux/dma-heap.h> #include <linux/dma-buf.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <fcntl.h> #include <unistd.h> int alloc_dmabuf(size_t size, void **vaddr) { int heap_fd = open("/dev/dma_heap/system", O_RDWR); if (heap_fd < 0) { perror("open dma_heap/system failed"); return -1; } struct dma_heap_allocation_data data = {0}; data.len = size; data.fd_flags = O_CLOEXEC; data.heap_flags = 0; if (ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &data) < 0) { perror("DMA_HEAP_IOCTL_ALLOC failed"); close(heap_fd); return -1; } int dmabuf_fd = data.fd; close(heap_fd); void *map = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, dmabuf_fd, 0); if (map == MAP_FAILED) { perror("mmap dmabuf failed"); close(dmabuf_fd); return -1; } *vaddr = map; return dmabuf_fd; }这段代码做完之后,dmabuf_fd就是这块共享内存的句柄,vaddr就是你能直接写图像数据的用户态地址。你完全可以像操作普通内存一样,把从摄像头读到的YUV帧memcpy到这里面。
但注意一个关键点:memcpy进内存之后,如果数据要直接被NPU或VPU这些硬件读取,驱动内部通常会做缓存同步处理。如果是纯软件消费(比如OpenCV读图),那你直接在mmap出来的地址上读写就完事了,没什么同步问题。
4.2 第二步:跨进程传递文件描述符
文件描述符是进程私有的,你不能直接把整数传过去在另一个进程里用。标准做法是通过Unix域Socket的SCM_RIGHTS辅助消息来传fd。这个技巧很关键,很多人不知道,我详细讲一下。
发送端:
#include <sys/socket.h> #include <sys/un.h> int send_fd(int sock_fd, int fd_to_send) { struct msghdr msg = {0}; char buf[CMSG_SPACE(sizeof(int))] = {0}; char dummy = 'F'; struct iovec iov = { .iov_base = &dummy, .iov_len = 1 }; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = buf; msg.msg_controllen = sizeof(buf); 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), &fd_to_send, sizeof(int)); if (sendmsg(sock_fd, &msg, 0) < 0) { perror("sendmsg fd"); return -1; } return 0; }接收端:
int recv_fd(int sock_fd) { struct msghdr msg = {0}; char buf[CMSG_SPACE(sizeof(int))] = {0}; char dummy; struct iovec iov = { .iov_base = &dummy, .iov_len = 1 }; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = buf; msg.msg_controllen = sizeof(buf); if (recvmsg(sock_fd, &msg, 0) < 0) { perror("recvmsg fd"); return -1; } struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); if (cmsg && cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) { int received_fd; memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(received_fd)); return received_fd; } return -1; }为什么能这么做?因为SCM_RIGHTS这个机制在内核里会把发送进程的fd指向的file对象引用计数加一,然后在接收进程里新建一个fd编号指向同一个file。fd编号可能不同,但底层的物理内存、DMA-BUF对象是同一个。
这个socket还可以承担控制信令的职责,比如告诉接收端这帧数据是YUV还是RGB、宽高是多少、时间戳是多少,这些元信息用一个普通结构体跟fd一起发过去就行。我习惯的做法是每次发送都连续发送两个sendmsg:第一个sendmsg发送一个普通的结构体(含宽高、格式、pts),第二个sendmsg发送fd。这样接收端先收到元数据,然后收到fd,逻辑非常清晰。
4.3 第三步:接收端映射同一块内存
接收端拿到fd之后,就非常简单了:
int dmabuf_fd = recv_fd(sock_fd); // 需要先拿到buffer的大小信息,可以从元数据结构体里拿 size_t buf_size = frame_meta.size; void *addr = mmap(NULL, buf_size, PROT_READ | PROT_WRITE, MAP_SHARED, dmabuf_fd, 0); if (addr == MAP_FAILED) { perror("mmap received dmabuf failed"); return -1; } // 到这里addr就是进程A写图像数据的那块内存,直接读就行 process_image(addr, frame_meta.width, frame_meta.height, frame_meta.format);这一步做完,进程A和进程B操作的是同一块物理内存,内核层面没有发生任何数据拷贝。整个过程CPU只参与了mmap的页表建立,之后读写全部是直接内存访问。
使用完毕记得munmap,fd记得close。这一点特别容易漏,后面在问题章节里细讲。
4.4 第四步:把DMA-BUF喂给NPU和MPP
这是零拷贝跨进程通信真正发挥威力的场景。比如推理进程收到dmabuf_fd之后,要跑YOLOv8。
RKNN工具链在较新版本(rknn-toolkit2的1.5+)里,支持了直接用dma-buf fd作为输入。你可以在rknn_inputs结构体里设置buf_type为RKNN_INPUT_TYPE_DMA_BUF,然后传入你的dmabuf_fd对应的映射地址。我做过的实测是:相比普通RKNN_INPUT_TYPE_NORMAL,推理接口内部不会再做一次memcpy,CPU占用能降个10-20%,具体降幅取决于图像大小和模型输入尺寸。
MPP那边也一样。用MPP做硬编码推流时,如果你的输入帧是DMA-BUF,MPP可以直接引用这块内存做编码,不需要先导入再拷贝。通过MPP的MPP_FRAME_GET_FD接口可以拿到解码或编码buffer的fd,这也是一种获取DMA-BUF的方式。
下面给一个MPP编码的大致流程示意:
// 假设你已经有一个dmabuf_fd,里面是NV12格式的YUV图像 MppFrame frame = NULL; mpp_frame_init(&frame); mpp_frame_set_width(frame, width); mpp_frame_set_height(frame, height); mpp_frame_set_fmt(frame, MPP_FMT_NV12); mpp_frame_set_buffer(frame, dmabuf_fd); // 直接关联DMA-BUF // 然后丢给encoder这里MppFrame的buffer就是一个dma buf fd,MPP内部会通过dma_buf的接口拿到底层内存描述符,然后配置给VPU硬件去读取。整条链路没有一次memcpy。
4.5 多缓冲无锁同步:别让生产者和消费者抢同一块内存
前面说的都是单buffer的情况,实际工程里这远远不够。如果采集端在写帧0,推理端同时在读帧0,就会产生数据竞争。
我用的方案是双缓冲或三缓冲加环形队列。核心思路:
- 分配N块DMA-BUF(通常3块够用),组成一个“待填充队列”;
- 采集端从待填充队列取一块buffer,填充图像数据,然后把它放到“待消费队列”,同时把对应的fd通过socket发出去;
- 消费端从socket收到fd,在“待消费队列”登记这块buffer,处理完后把它归还到“待填充队列”。
实现同步的时候,我建议用两个互斥锁加两个条件变量,分别保护两个队列。因为生产者消费者速率不一致是常态,直接用无锁环形队列在边缘设备上反而容易出问题,尤其是调试初期。
关于同步,还有一个更贴近硬件的方式:DMA-BUF的fence机制(dma_fence)。硬件在访问buffer前后会post一个fence,这样软件可以在fence上面wait。但应用层直接用fence比较痛苦,我见过不少方案是在驱动里处理,用户态只做简单的队列同步。对我来说,主力方案是条件变量+互斥锁,简单、稳,性能足够。
另外,如果你同时有多个消费者(比如推理进程和编码进程都要同一帧数据),每个消费者都需要收到fd副本。DMA-BUF的fd可以复制任意多份,底层引用同一块物理内存。但此时要注意生命周期管理:每个进程都要维护好自己收到的fd,防止一个进程结束就把buffer释放了导致其他进程崩溃。
5. 性能对比与实测结果:零拷贝到底能省多少
纯讲概念没感觉,我直接给一组我在RK3588板卡上的实测数据,测试环境:
- 板卡:RK3588 8GB内存,Debian 11
- 图像:1080P NV12,一帧大小约3MB,30fps
- 链路:采集进程(模拟从文件读取)→ 跨进程传输 → 推理进程(YOLOv8s,输入640x640)
- 对比方案:Unix域Socket发送裸图像数据 vs DMA-BUF fd传递
| 指标 | Socket裸传 | DMA-BUF fd传递 | 提升幅度 |
|---|---|---|---|
| 单帧传输平均延迟 | 约1.8ms | 约0.15ms | 提升92% |
| 接收端CPU占用(每帧处理) | 约0.35ms | 约0.02ms | 提升94% |
| 推理前预处理总耗时(含数据传输+预处理) | 约5.2ms | 约2.3ms | 提升56% |
| 30fps下整链路CPU总占用 | 约68% | 约36% | 降低一半 |
数据可能有波动,不同板卡不同内核会有差异,但趋势非常明显。尤其是当你把图像分辨率提升到4K,Socket裸传基本就直接把CPU打满了,DMA-BUF方案却依然很从容。
再说一个更精确的指标:页面缓存命中率与TLB压力。传统Socket收发会经过内核协议栈的socket buffer,每次都是一次新的内存分配和释放,CPU缓存亲和性差。DMA-BUF因为是固定的物理内存反复使用,CPU缓存命中率明显更高。这个不太好直接量化,但实测中进程的cache miss事件是下降的。
还有一个大家容易忽略的点:延迟抖动。Socket传输在系统负载高时,内核协议栈的处理时间水涨船高,严重的单帧延迟能到5ms以上;而DMA-BUF方案基本稳定,延迟曲线是一条接近直线的横线。对于视觉业务来说,延迟稳定比平均低更重要,因为它直接影响跟踪算法的稳定性和事件时间戳的准确度。
6. 常见问题与排查技巧实录
6.1 缓存一致性问题导致的花屏和错位
这是DMA-BUF在ARM平台上最容易踩的坑,没有之一。RK3588的CPU和硬件设备(NPU、VPU、RGA)访问相同内存时,存在CPU cache与DMA设备缓存不一致的问题。
场景还原:采集进程往DMA-BUF的mmap地址里写入了图像数据,然后直接把fd发给推理进程,推理进程把fd交给NPU去读,结果推理结果一塌糊涂,特征全乱。为什么会这样?因为写入时CPU cache命中了,数据还留在cache里,没有刷到物理内存,NPU从物理内存读到的还是旧数据。
解决办法是在合适的时机做缓存同步。应用层可以通过DMA-BUF的sync ioctl来做:
#include <linux/dma-buf.h> struct dma_buf_sync sync = {0}; sync.flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_WRITE; // 采集端在写完数据后、交给硬件前,执行write sync ioctl(dmabuf_fd, DMA_BUF_IOCTL_SYNC, &sync); // 消费端在硬件读完、CPU要读之前,执行read sync sync.flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ; ioctl(dmabuf_fd, DMA_BUF_IOCTL_SYNC, &sync); // 用完记得发END sync.flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ; ioctl(dmabuf_fd, DMA_BUF_IOCTL_SYNC, &sync);RK3588上有的驱动(比如RKNN)内部已经帮你做了sync,有的没做。我的调试经验是:先加上sync,确认没花屏之后,再逐渐移除,看哪些环节驱动已经处理了,哪些环节必须靠自己。这个排查过程可能要来回折腾几次,但值得做,因为它直接决定系统的稳健度。
6.2 接收端fd冻结或者指针飞掉
fd传递成功,mmap成功,但读取时段错误(segmentation fault),这是生命周期没有管理好。
典型错误:发送进程在sendmsg之后立刻关闭了本地fd,关闭这块DMA-BUF。按Linux的引用计数机制,SCM_RIGHTS已经增加了file的引用计数,即使发送端把fd关了,接收端手里的fd依然有效。这是对的。真正的问题往往反过来:接收端在处理完一帧后主动close了fd,但发送端以为这块buffer还可以继续复用,结果发送端再写这块已经释放的内存,直接段错误。
我的建议:统一约定buffer的所有权关系。我项目里是这么定的:
- 发送端分配到buffer后,负责填充数据;发送fd后,保留本地fd不关闭,作为“复用池”的成员;
- 接收端只能映射和使用,禁止关闭fd;如果需要关闭,需要通过socket发送一个“release”消息,告诉发送端这块buffer可以回收复用;
- 所有buffer的最终释放由发送端统一管理。
这套约定虽然原始,但在嵌入式项目里非常有效,远比依赖内核引用计数靠谱得多,因为业务逻辑的“释放”和“引用计数归零”并不同步。
6.3 ioctl DMA_HEAP_IOCTL_ALLOC失败,返回ENOMEM
板卡内存明明还很充足,但分配失败。这是因为DMA-BUF的物理连续内存和CMA区域相关。dma_heap的system heap在底层通常使用CMA(连续内存分配器),而CMA区域的大小在dtb里是固定的。
RK3588默认CMA大小可能只有128MB或256MB。如果你同时跑camera、MPP、多个NPU buffer,CMA区域很容易耗尽。排查方法:
cat /proc/meminfo | grep Cma如果CmaFree已经很小或者为0,说明CMA区域被吃满了。解决思路:
- 修改内核dts,增大linux,cma的size。RK3588的Dtbo里一般有reserved-memory节点,把size从0x10000000改成0x20000000(512MB)甚至更多,注意不要抢占系统可用内存太多。
- 检查是否有驱动长期占用CMA不释放。MPP库有个buffer池机制,默认会缓存很多buffer,你可以在初始化时设置MPP_BUFFER_FLAGS_IO或调整池大小,让MPP少缓存一些。
- 对于RKNN的输入输出,可以考虑用rknn_create_mem单独管理NPU内存,而不是全部挤在system heap。
6.4 多路视频流时fd数量暴涨
如果你跑8路甚至16路视频流,每个流都要分配多块DMA-BUF,fd数量会迅速膨胀。Linux默认的fdlimit通常是1024,一不小心就会打满。
建议:
- 在进程启动时用setrlimit把RLIMIT_NOFILE调高,RK3588这种嵌入式设备,建议设置到65535。
- 对每帧处理完的fd及时关闭。很多新手以为DMA-BUF可以反复用就懒得关,结果fd泄漏。
- 用工具实时监控:
ls /proc/<pid>/fd | wc -l,如果你发现fd数量只涨不跌,那一定有地方漏了。
6.5 常见问题速查表
| 现象 | 可能原因 | 排查/解法 |
|---|---|---|
| 图像花屏/出现旧帧残影 | CPU cache未同步 | 检查DMA_BUF_IOCTL_SYNC是否调用,确保写侧写后sync,读侧读前sync |
| 推理结果全乱 | NPU读到了脏数据 | 确认输入buffer是DMA-BUF且格式对齐,检查rknn_inputs.buf_type是否正确 |
| ioctl分配返回ENOMEM | CMA耗尽 | 查看/proc/meminfo中CmaFree;调整内核cma size;减少buffer池缓存 |
| 接收端mmap段错误 | buffer被发送端提前回收 | 统一buffer所有权与生命周期管理,禁止随意close |
| fd大量泄漏 | 忘记close/fd传递异常 | 用/proc/ /fd统计,配合strace抓open/close行为 |
| 分配回来的地址在64位进程里正常,32位进程失败 | DMA地址超出32位可见范围 | 检查进程编译架构,尽量统一64位;或者内核开启CMA区域低位分配 |
| socket发送fd失败,errno=EBADF | 本地fd已经关闭或无效 | 检查发送端fd生命周期,确认发送前fd还处于打开状态 |
6.6 调试工具与实用命令
提几个我在实际调试中使用频率很高的方法,这些比gdb还好使:
- 查映射关系。用
/proc/<pid>/maps可以看到每个进程的mmap区域,包括匿名映射和DMA-BUF映射。DMA-BUF的映射通常会显示/dmabuf后缀,方便确认buffer是否映射成功。 - 跟踪系统调用。
strace -f -e trace=sendmsg,recvmsg,mmap,ioctl,close ./your_process,基本能看清fd传递和内存映射的全过程。 - 查硬件访问状态。RK3588的NPU和MPP在系统内都会占用dma-buf,可以通过
debugfs或者/sys/kernel/debug/dma_buf/bufinfo查看当前所有DMA-BUF的引用情况。
7. 最后分享几个实战心得
整套东西做了大半年,有几个踩过坑之后沉淀下来的体会,单独拎出来说说。
第一,零拷贝跨进程通信不是买了RK3588就自动有的能力,而是要把内存分配策略、进程边界、硬件访问路径三者对齐。很多项目调不动,问题不在AI模型本身,而在数据传输路径上。你把DMA-BUF这条链路打通了,YOLOv8跑30fps压根不是难事。
第二,DMA-BUF的抽象非常简洁,但简洁不代表简单。你必须在架构设计初期就确定好“谁分配、谁消费、谁释放”的规则,并且写进团队的代码规范里。否则到了后期联调,fd满天飞,谁都不愿意背锅。我在项目里是写了一份一页纸的共享内存管理约定,贴在文档首页,后来所有接手的人都先看这个。
第三,不要过度优化。如果你只是两个纯软件进程之间传图像,没有硬件参与,那传统的memfd_create加mmap就够了,不必非要走DMA-BUF。DMA-BUF的同步和管理是有额外复杂度的,只有在数据确实要流经NPU、VPU、RGA这些硬件模块时,它的价值才最大化。我记得我们后期还把编码前的预处理挪到了RGA里面做,直接把CPU占用从25%压到了5%,这才是RK3588这种异构SoC的正确打开方式。
最后再补一个小技巧:在调试DMA-BUF跨进程通信时,如果画面显示异常,先在发送端和接收端各自算一遍图像的CRC校验,确认两端看到的数据是否一致。这一步能瞬间判断问题出在“数据没传对”还是“数据传对了但后续处理不对”,省下来的是大量无效联调时间。
这套东西,后续还可以往系统层面扩展:比如多个推理进程共享同一路视频流时,如何在应用层仲裁DMA-BUF的调度优先级;再比如把DMA-BUF和Linux的v4l2 request API结合,做更底层的硬件pipeline编排。这些等后续有时间,我再单独开一篇聊。