RK3588边缘AI视觉零拷贝跨进程通信实战:DMA-BUF与fd传递
2026/9/4 15:47:10 网站建设 项目流程

边缘AI视觉项目做到第四期,终于要聊一个最容易被忽视、却又直接决定整个系统能不能跑满帧率的问题:跨进程通信。RK3588这颗SoC算力很够,NPU、RGA、MPP编解码、ISP各管一摊,但当我把采集、推理、编码拆成独立进程之后,数据从摄像头到AI再到推流,内存拷贝的量往往比算力更早成为瓶颈。这篇文章就把我在RK3588上实现零拷贝跨进程通信的完整思路、代码路径和踩坑经历放出来,给正在做边缘AI盒子、智能相机、视频分析网关的朋友做个参考。

很多人在RK3588上做视觉方案,第一反应是把所有环节塞进同一个进程里,省去跨进程传输的麻烦。但工程上为了稳定性,隔离采集、推理和网络服务是常规操作,一个进程崩了不能拖垮其他业务。这时候图像数据怎么在进程间高效流转就成了核心问题。我尝试过共享内存、免拷贝队列、DmaBuf fd传递,最后稳定下来的是基于DMA-BUF加描述符传递的零拷贝链路。下面从原理到实践,一步步说清楚。

1. 为什么多进程图像传输会成为边缘AI的瓶颈

1.1 多进程视觉管线的典型数据流

在RK3588平台上,我习惯把视觉链路拆成三个进程:采集进程负责MIPI CSI或USB摄像头取流,或者拉RTSP流解码;推理进程跑RKNN模型做检测、分类、分割;服务进程负责RTSP推流、Web展示、告警逻辑。三个进程各自独立,也可按需求扩展。

这三者之间的数据量有多大?以1080p NV12图像为例,一帧大约1920×1088×1.5字节,约3.1MB。30fps时每秒接近94MB,这还只是一路。RK3588经常被用来做多路视频分析,六路、八路是常见配置,数据量直接上G。如果这些数据在进程间靠memcpy或socket传递,每次传输要占用CPU做搬移,CPU的算力被白白烧掉,留给AI算法和业务逻辑的就更少了。

1.2 拷贝开销的账不能只看带宽

有人会说,RK3588是八核A76+A55,DDR带宽至少有几十GB/s,几十MB/s的拷贝不算什么。这个账要细算。第一,memcpy看起来快,但内存拷贝会占用CPU周期,还会污染Cache,导致AI推理时的数据访问变慢。第二,如果走Socket或管道,任务要切到内核态,还要多次拷贝,延迟非常高。第三,图像在进程间转手时往往不止一次拷贝,比如V4L2采集后到用户态buffer,再从用户态拷贝到另一个进程的buffer,再交给预处理,数据拷贝次数是链路级别的累积。

我实际测过一段RK3588上的纯memcpy,1080p NV12一帧3MB左右,连续拷贝1000帧,单帧耗时稳定在0.7-1.1ms,看起来不多。但在30fps链路里,如果一路图像需要拷贝三次,每帧光拷贝就占去约3ms,八路就是24ms的CPU时间。如果还同时跑yolov8s模型,CPU负载很容易被打到30%以上。更麻烦的是,拷贝增加的内存延迟会直接影响端到端延迟,这在部分场景中比CPU占用率更致命。

1.3 零拷贝的本质不是不拷贝,而是避免CPU复制

零拷贝的核心思路,是让多个进程的虚拟地址映射到同一块物理内存,并且这块内存尽量由硬件设备直接访问。采集设备通过DMA把图像写到这块内存,AI芯片、RGA、VPU直接从同一块内存读取结果,CPU不参与中间的数据搬运。这样真正需要“拷贝”的是通过RGA、NPU这类硬件完成的帧格式转换和预处理,对CPU来说已经是零负载。这也是RK3588这类片上异构系统最适合零拷贝的原因,因为硬件模块本身已经能直接访问buffer了,缺的只是进程间的传递机制。

2. RK3588平台上能用的零拷贝方案有哪些

2.1 几种常见方案的横向对比

我整理了一张表,方便看清楚方案边界。

方案跨进程方式CPU拷贝硬件设备访问同步机制适用场景
POSIX共享内存mmap同一文件/匿名映射需要应用自行避免拷贝无法直接给DMA设备使用,需额外处理用户态锁/信号量小数据量元数据、控制信息
Unix Socket + memcpysendmsg/recvmsg内核拷贝+用户拷贝不适用阻塞/非阻塞IO小数据量控制指令
V4L2 mmap + 共享内存mmap各自映射同一buffer没有内核拷贝,但CPU访存仍会读写Cache不支持硬件直接访问V4L2事件+用户态同步多进程共享采集帧但不做硬处理
DMA-BUF + fd传递SCM_RIGHTS传递fd不需要,内核只传引用支持RGA/RKNN/VPU导入DMA_BUF_IOCTL_SYNC大块图像数据、硬件加速链路

用下来我的结论很清楚:图像帧这种大块数据,必须走DMA-BUF;控制信息、小包结果、检测框坐标,仍然可以用共享内存或Socket,零拷贝不是万能的。

2.2 DMA-BUF是RK3588异构硬件的通用语言

RK3588上的ISP、RGA、MPP、RKNN都支持DMA-BUF导入。DMA-BUF的fd可以理解为对一块物理内存的引用,这个fd能通过UNIX域Socket的辅助数据(SCM_RIGHTS)传给另一个进程。接收方拿到fd后,将它导入到RGA或RKNN的输入buffer描述里,硬件直接读这块内存,不需要经过用户态拷贝。

对比传统的共享内存mmap,DMA-BUF更贴近硬件需求。共享内存虽然也能做到不拷贝,但DMA设备访问它时会出现cache一致性、物理连续性问题,很多嵌入式驱动根本不认这个内存地址。而DMA-BUF从设计上就考虑了设备访问,支持IOMMU、物理连续、cache sync操作,这也是Rockchip的SDK里所有硬编、RGA、NPU例程都优先推荐DMA-BUF的原因。

2.3 fd传递是跨进程的关键节点

DMA-BUF标准API一般是先打开一个驱动节点,比如打开/dev/video0拿到采集buffer以后,调用VIDIOC_EXPBUF把这个buffer导出为一个dma-buf fd。这个fd要传给另一个进程,不能直接写进共享内存让对面打开,因为fd是进程级的文件描述符表索引,对另一个进程没意义。正确做法是用sendmsg的SCM_RIGHTS机制,让内核把文件引用从一个进程转交给另一个进程。接收方用recvmsg收到一个新的fd,这个fd在接收进程里是合法可用的,指向同一块DMA buffer。整个过程中内核只是传递引用,没有搬运图像数据。

3. 从V4L2采集到RKNN推理的零拷贝实现路径

3.1 整体链路设计

以一个MIPI摄像头采集、YOLOv8s检测、RGA做颜色格式转换、RKNN推理的完整场景为例,链路是这样的:

  1. 采集进程打开/dev/video0,通过V4L2的MMAP模式申请帧缓冲。
  2. 对每个buffer调用VIDIOC_EXPBUF,获得dma-buf fd。
  3. 采集进程把fd通过UNIX Domain Socket传给推理进程。
  4. 推理进程将fd导入RGA,RGA将NV12转成RGB并缩放到模型输入尺寸,输出另一个dma-buf fd。
  5. 推理进程将RGA输出fd直接作为RKNN的输入内存在NPU上推理。
  6. 推理结果(检测框坐标)通过共享内存或Socket返回给服务进程,这部分数据量小,不值得零拷贝。

这套链路里,图像数据从摄像头到NPU始终没有经过CPU整帧搬运,CPU只做控制、同步和结果解析。

3.2 V4L2采集端导出DmaBuf fd

先看采集端,C++伪代码如下:

int fd = open("/dev/video0", O_RDWR); struct v4l2_requestbuffers req = {0}; req.count = 4; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; ioctl(fd, VIDIOC_REQBUFS, &req); for (int i = 0; i < req.count; i++) { struct v4l2_buffer buf = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; ioctl(fd, VIDIOC_QUERYBUF, &buf); struct v4l2_exportbuffer expbuf = {0}; expbuf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; expbuf.index = i; expbuf.flags = O_CLOEXEC; ioctl(fd, VIDIOC_EXPBUF, &expbuf); // 这个dma_buf_fd就是要传给下游进程的关键资源 int dma_buf_fd = expbuf.fd; }

注意,这里申请buffer时用的是MMAP模式,但应用不需要做mmap,只需要拿到EXP得到dma-buf fd。驱动通过videobuf2框架分配的内存可同时满足MMAP和DMA-BUF导出,这样最简单,我实际验证过。

3.3 用SCM_RIGHTS传递fd

fd传递的代码网上有很多,但特别容易踩到细节错误。我贴一段自己的封装,发送端和接收端分开写:

发送端:

bool send_fd(int sock_unix, int fd_to_send) { struct msghdr msg = {0}; char buf[CMSG_SPACE(sizeof(int))] = {0}; 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)); // 发送一个字节的帧ID,便于接收方识别是哪一帧 struct iovec iov = {0}; uint32_t frame_id = current_frame_id(); iov.iov_base = &frame_id; iov.iov_len = sizeof(frame_id); msg.msg_iov = &iov; msg.msg_iovlen = 1; ssize_t n = sendmsg(sock_unix, &msg, 0); return n == sizeof(frame_id); }

接收端:

int recv_fd(int sock_unix, uint32_t *frame_id) { struct msghdr msg = {0}; char buf[CMSG_SPACE(sizeof(int))] = {0}; msg.msg_control = buf; msg.msg_controllen = sizeof(buf); struct iovec iov = {0}; int control_byte; iov.iov_base = &control_byte; iov.iov_len = sizeof(control_byte); msg.msg_iov = &iov; msg.msg_iovlen = 1; ssize_t n = recvmsg(sock_unix, &msg, 0); if (n <= 0) { return -1; } struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); if (cmsg && cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) { int fd = 0; memcpy(&fd, CMSG_DATA(cmsg), sizeof(int)); return fd; } return -1; }

这里要注意,sendmsg除了把fd传过去,还顺带传一个uint32帧ID。这非常重要,因为采集进程和推理进程是异步的,接收方可能需要用帧ID去管理待处理队列,避免乱序或重复处理。

3.4 把fd导入RGA和RKNN

拿到dma-buf fd后,下游的用法取决于SDK。以Rockchip的librga为例,用RGA_IMPORT_BUFFER接口把外部fd导入为RGA的buffer对象,然后在rga_set_buffer_info里指定该buffer作为输入,同时分配一个输出buffer,通常是NV12转RGB后的连续内存,也可以继续用dma-buf方式导出。

对RKNN来说,老版本SDK习惯用rknn_create_mem_from_fd这类接口从fd创建NPU内存,再绑定到输入tensor上。不同版本的RKNN API名字略有差异,需要以你们手上的SDK头文件为准。本质上就是把外部dma-buf引用导入NPU的设备地址空间,NPU可以直接DMA读这块内存,不需要CPU再拷贝一次。

我当时的简化流程:

// RGA格式转换 rga_buffer_handle_t input_handle = importbuffer_fd(dma_buf_fd, &src_img_info); rga_buffer_handle_t output_handle = importbuffer_fd(dst_dma_buf_fd, &dst_img_info); rga_buffer_t src = wrapbuffer_handle(input_handle, src_w, src_h, src_format); rga_buffer_t dst = wrapbuffer_handle(output_handle, dst_w, dst_h, dst_format); imconfig config = imconfig::default_config(); config.color = color_mode; imresize(src, dst, &config); // RKNN导入 rknn_create_mem_from_fd(ctx, dst_dma_buf_fd, &mem_from_fd, nullptr); mem_from_fd.flags = RKNN_MEM_FLAG_DMA_BUF; rknn_set_io_mem(ctx, &mem_from_fd, input_index); rknn_run(ctx, nullptr);

这只是示意,真正工程化的代码要考虑buffer生命周期、帧队列和同步。

3.5 必须要加的DMA_BUF同步

很多人第一次写DMA-BUF程序时会遇到画面随机花屏、推理结果时对时错,原因大概率是忘了做cache同步。DMA-BUF物理内存被CPU和硬件设备共享,硬件写完后,CPU侧Cache可能是脏数据;反过来CPU改完内容,硬件不一定看得到。Linux下DMA-BUF提供了DMA_BUF_IOCTL_SYNCioctl来做同步,访问前begin,访问后end。

举个例子,如果推理进程拿到fd后,先用CPU做了某种预处理,再把buffer交给RKNN,那么CPU写完后必须调用一次sync begin/end,确保NPU能看到最新数据。反过来,如果NPU输出结果,CPU要读取这些结果,也需要在CPU读取前做同步。这个过程中CPU只同步Cache,不拷贝整帧数据,开销很小,但漏掉它代价很大。

4. 实测数据:零拷贝链路到底省了多少

4.1 测试平台与场景

测试环境是RK3588工规核心板,8GB LPDDR4x,MIPI CSI接入一个IMX415传感器,分辨率1080p@30fps,模型是yolov8s,输入尺寸640×640,同时开启RTSP硬编推流。对照两种方案:

  • 方案A:V4L2 mmap采集,CPU memcpy到共享内存,推理进程从共享内存拷到对齐buffer,再交给RGA转换后给RKNN。
  • 方案B:V4L2 EXPBUF导出dma-buf fd,SCM_RIGHTS传给推理进程,RGA直接导入fd做转换,转换结果仍为dma-buf fd传递给RKNN。

两种方案都启用了相同的算法和推流,只是数据传输路径不同。

4.2 关键指标对比

指标方案A(传统拷贝)方案B(DMA-BUF零拷贝)收益
CPU占用率27.6%11.2%下降约60%
单帧图像传递给推理进程耗时约7.4ms约1.8ms节省5.6ms
1080p30端到端推理延迟78ms51ms降低35%
同时推流RTSP时的整体丢帧率0.8‰0.1‰明显改善
连续运行4小时稳定性CPU高温降频一次未触发降频功耗更低

CPU占用率能降这么多,主要是因为少了两次memcpy和相关的Cache操作。方案A中memcpy虽然在CPU里看起来很快,但频繁搬运大块数据会让A76核心的Cache命中率变差,间接影响RKNN和业务逻辑的执行。方案B里CPU只做同步和调度,整帧数据都是被RGA和NPU硬件搬运的,CPU负载自然下来了。

4.3 多路场景下收益更明显

单路1080p时,方案B的优势已经能感知到,但还没到惊艳的程度。当我把接入路数扩大到四路,方案A的CPU占用率已经冲到65%以上,而方案B还稳定在35%左右。也就是说,零拷贝能让RK3588这块板子在同样的CPU预算下跑更多的算法路数,或者把省下来的算力留给更重的模型,比如yolov8m甚至更高精度模型。

这套链路也让我第一次真正理解了为什么RK3588官方SDK里大量例程都要求用dma-buf做多模块联动,这不是为了炫技,而是当系统规模变大以后,数据通路如果还沿着传统的“设备到CPU、CPU到进程、进程到进程”模式走,RK3588的异构并行能力就完全发挥不出来。

5. DMA-BUF跨进程传递的典型坑与排查思路

5.1 忘记同步导致的“玄学花屏”

我项目启动时遇到一个典型的坑:推理结果偶尔不准,一帧好一帧坏,RGA转换后的图像有时有横向撕裂带。排查到最后发现,推理进程收到fd后直接丢给RKNN,没有在CPU访问前做sync。RGA写数据用的是硬件DMA,数据在DDR里,但CPU侧Cache还留着部分旧数据。当RKNN推理读取时,走的是IOMMU/硬件通路,读到的是DMA写的新数据,理论上对;但采集端或RGA端如果有CPU参与过buffer内容修改,比如填了头信息,那么NPU读到的很可能是Cache里的旧值。加一次DMA_BUF_IOCTL_SYNC就恢复正常,这个坑不严重,但特别隐蔽。

5.2 fd泄漏会把CMA吃光

DMA-BUF fd在进程间传递后,接收方用完之后必须close。我前期没有对fd做RAII封装,导致一晚上跑下来系统内存越来越小,最终RKNPU启动失败,RGA调用也返回ENOMEM。用/sys/kernel/debug/dma_buf/bufinfo查看后,发现有几百个相同尺寸的buffer没有被释放。fd泄漏的根因是接收进程某条异常退出路径上没有close。解决方案很土但有效:建立一个DmaBufHandle类,构造函数接管fd,析构函数close,禁止裸fd在业务代码里传递。这套规则全团队统一执行后,再没出过泄漏问题。

5.3 传输小块数据照样用fd会很傻

零拷贝不是银弹。如果我只想传一个检测框坐标、一个事件ID,用共享内存或Socket就足够了。DMA-BUF的申请和同步是有开销的,fd传递也要走一次内核,这些开销对小数据而言远大于直接拷几个字节。我现在的架构里,大块帧数据走DMA-BUF,元数据、控制指令、检测结果走一个被所有进程共享的环形队列,两种通道互补,系统才最简单高效。

5.4 Buffer深度的取舍

设计跨进程图像队列时要考虑深度。采集端V4L2我通常申请4个buffer,推理端维护一个待处理帧idx队列,最多缓存4帧。深度太浅容易丢帧,太深则延迟变大。尤其边缘AI场景往往还要搭配运动检测、跟踪逻辑,帧延迟一旦超过100ms,很多安防场景就无法接受。所以深度2-4比较合理,配合超时丢帧策略,比无脑加队列更实用。

5.5 权限和IOMMU的问题

在RK3588上,RGA和RKNN的中断、地址映射都依赖IOMMU。如果内核配置不对,导入fd也可能出现地址错误。遇到类似问题时,先看内核日志里有没有“Unhandled fault”或“IOMMU page fault”。如果出现这类错误,往往是用户态传入的buffer对不上设备要求的内存类型,或者CMA区域不够导致缓冲区分配到了不连续内存。最简单的调整方式是适当增大内核CMA,比如在bootargs里加上cma=512M,但不要无脑加,要结合系统内存计算。

6. 把零拷贝链路进一步延伸到编码和显示端

6.1 从RKNN输出到MPP硬编,同样可以用dma-buf

图像检测完以后,通常要叠加结果再推流。如果走传统方式,推理进程拿到输出RGB后又要拷贝给编码进程,CPU又忙一轮。正确做法是用RGA做画框和缩放,然后将叠加后的目标帧以dma-buf fd的形式传给MPP编码进程,MPP读取同一块物理内存做H.264/H.265编码。我在这条延伸链路上跑得了很好,整个采集-推理-编码过程没有一次CPU整帧复制,CPU占用被控制在极低水平。

6.2 DRM/KMS显示端也可以直接scanout

如果应用带HDMI显示,比如本地预览,可以让dma-buf fd直接作为DRM framebuffer的scanout buffer。LK3588的DRM/KMS支持dma-buf导入,预览进程只需要把fd导入到DRM,就能在屏幕上显示,省去一次CPU blit。这个方向在需要本地展示的智能交互设备里很实用。

6.3 多路视频源时,用同一套架构扩展

零拷贝这套方案一旦稳定,扩展多路很简单。每路采集有独立的V4L2 fd、独立的dma-buf循环池,推理进程按帧ID分发到对应的模型上下文,输出通过共享内存队列汇总。我目前最多跑过8路1080p,CPU占用仍有余量。真正限制路的数量的,不是CPU,而是ISP带宽和NPU算力。

回头看,RK3588上的零拷贝跨进程通信没有太多玄学,核心就是三件事:用dma-buf表示大块帧内存,用SCM_RIGHTS传fd,用DMA_BUF_IOCTL_SYNC保证一致同步。跑通这三件事之后,后续加解码、编码、RGA处理都是同一套路。如果你们团队正准备把RK3588项目从单进程改成多进程架构,建议第一步就把DMA-BUF链路定下来,越早做,后续重构越少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询