十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RK3588边缘AI视觉:基于DMA-BUF的零拷贝跨进程通信实战

RK3588边缘AI视觉:基于DMA-BUF的零拷贝跨进程通信实战 在RK3588上做边缘AI视觉真正让人头疼的往往不是模型跑多快而是数据从摄像头到NPU、再到硬编码和RTSP推流这一路上究竟被memcpy搬了多少次。很多教程只教你怎么在板子上把YOLOv8的demo跑起来可真正做产品时采集、推理、编码往往是三个进程帧数据如果隔着socket和共享内存来回拷贝4K分辨率下光是搬数据就能吃掉好几个毫秒CPU占用也压不下去。这篇文章想和你聊聊在RK3588架构下怎么做零拷贝跨进程通信把视频帧的整个生命周期都留在DMA-BUF里让多个进程和NPU、VPU、RGA这些硬件模块直接引用同一块物理内存而不是你搬给我、我搬给他。这套方法也是边缘AI视觉工程化绕不开的一环。不管你是准备把采集、AI推理、硬编码拆成独立进程还是想优化现有项目的帧率瓶颈下面这套基于DMA-BUF加SCM_RIGHTS的方案都能直接参考。我尽量把原理、代码、踩坑细节都写透。1. 为什么需要零拷贝跨进程通信先算清楚拷贝这笔账1.1 边缘AI视觉典型的数据流和它真正的开销在哪先还原一个典型场景。一块RK3588板卡上做实时视频监控或边缘计算盒子通常会有这么几个角色采集进程负责从MIPI CSI或USB摄像头拿图像走Rockchip的rockit或V4L2框架AI进程负责跑NPU推理常见的是YOLOv8或者各种检测分类模型编码进程负责把处理后的画面交给MPP硬编码成H.264/H.265再走RTSP推给客户端另外可能还有一个业务进程负责叠加OSD、保存录像之类。如果按照最朴素的做法采集进程拿到一帧图像后先把数据从内核buffer拷到用户态内存然后通过socket发给AI进程AI进程再拷到自己内存跑完模型后再拷一次发给编码进程编码进程为了把数据交给硬件编码器可能还要再拷一次。这条路走下来一帧4K NV12图像数据量大约12MB拷三到四次什么概念RK3588的A76大核跑memcpy理想情况能到4到6GB/s左右但实际受DDR频率、cache状态、内存是否对齐影响往往打折扣。一帧4K大约12MB单次memcpy耗时大概2到4毫秒。算上socket收发过程中内核缓冲区的那几次搬运一帧画面光是数据搬运就吃掉6到15毫秒。如果跑30fps帧预算33毫秒拷贝占了三分之一甚至接近一半这还没算模型推理和编码的时间。多路视频时这个开销还会成倍往上翻。1.2 多进程还是多线程怎么选更合理有人会问既然进程间传数据这么麻烦为什么不把所有逻辑塞进一个进程用多线程加指针直接共享内存天然零拷贝这个问题的标准答案是看你做的是demo还是产品。demo当然可以全塞一个进程但真实产品里采集、AI、编码往往来自不同的SDKRockchip的RKMPP、rockit和RKNN runtime对资源的管理方式不同错误处理和崩溃影响范围也不一样。多线程单一进程的好处是共享数据简单坏处是一个模块崩了整条流水线跟着崩权限也难隔离。采集进程可能需要访问摄像头设备节点AI进程其实没必要拿那么高的权限拆开之后可以单独降权、单独重启这对边缘盒子的稳定性和可维护性是很重要的。所以很多RK3588上的商业方案都会选择多进程架构把摄像头上层、AI业务、编码推流分开部署。既然选了多进程跨进程传输大块图像数据就是一个必须解决的问题而且必须高效。1.3 “共享内存”不等于“零拷贝”别被概念骗了不少人在这一步会陷入一个误区用POSIX共享内存shm_open加mmap两个进程不就能直接读写同一块内存了吗这算不算零拷贝算也不完全算。shm_open拿到的是一块普通匿名内存CPU可以直接访问没错但NPU、VPU、RGA这些硬件模块访问内存需要内核驱动拿到这块内存的物理地址信息把它映射进IOMMU或做DMA映射。普通共享内存没有这个机制硬件不认。所以即便你用shm共享了一块内存最后为了让硬编码器能读这帧数据还是得把数据从shm内存拷贝到MPP分配的硬件可访问buffer里白白多一次搬运。真正的方案是让整条数据链路都建立在DMA-BUF之上。DMA-BUF导出的fd同时具备两个能力用户态可以mmap访问硬件驱动也能通过它拿到物理内存的访问权。RGA、VPU、NPU、ISP这些驱动全都认这个fd。这才是“零拷贝”跨进程通信的地基。2. 两块基石DMA-BUF 和 SCM_RIGHTS2.1 DMA-BUF 到底是什么为什么硬件都认它DMA-BUF是Linux内核里一个标准的buffer共享机制。可以这样理解一个DMA-BUF对象代表一块“能被硬件访问的物理内存”。它由某个exporter设备创建然后通过fd的形式把这块内存的访问权分享给其他设备或进程。每个拿到fd的进程都能通过mmap把同一块物理内存映射到自己的虚拟地址空间每个硬件驱动也能从fd那里拿到内核态的sg_table完成DMA映射。打个比方DMA-BUF fd就像一张图书馆的借书卡。大家拿着同一张卡借同一本书而不是每次有人要看就去复印一份再递过去。书只有一本放在图书馆固定的书架上谁借谁来看。在RK3588平台上最常用的分配接口是DMA-BUF heaps。内核启动后一般能看到这样的节点ls /dev/dma_heap/常见的有system、system-uncached、linux,cma等。不同开发板、不同内核版本节点名会有差异有些老SDK还是用的/dev/ion。如果你想确认当前平台支持哪些堆直接看这个目录最靠谱。正点原子、香橙派这类开发板厂商的内核配置不太一样节点名和权限都要以实际为准。2.2 RK3588上常见buffer来源摄像头、MPP、自分配具体到一条边缘AI视觉链路DMA-BUF fd通常有三个来源。第一个来源是摄像头采集。Rockchip的rockit框架或者V4L2配合rkcif/rkisp驱动导出的采集buffer本身就是DMA-BUF fd。比如你通过V4L2申请一组用于采集的bufferVIDIOC_REQBUFS之后用VIDIOC_QUERYBUF拿到的fd就是可以传给下游的DMA-BUF fd。第二个来源是MPP的编解码buffer。用MPP做硬编码或硬解时通过mpp_buffer_get拿到的buffer本质上也是基于DMA-BUF分配的可以通过mpp_buffer_get_fd把fd取出来传给其他进程。第三个来源是自分配。你想要一块不属于任何采集或编码器、纯粹用于进程间共享的buffer时可以直接怼dma-heap#include fcntl.h #include sys/ioctl.h #include sys/mman.h #include unistd.h #include linux/dma-heap.h int heap_fd open(/dev/dma_heap/system-uncached, O_RDWR); if (heap_fd 0) { perror(open dma_heap); return -1; } struct dma_heap_allocation_data data {0}; data.len size; // 建议按4096向上对齐 data.fd_flags O_CLOEXEC | O_RDWR; if (ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, data) 0) { perror(DMA_HEAP_IOCTL_ALLOC); close(heap_fd); return -1; } close(heap_fd); int buffer_fd data.fd; void *ptr mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, buffer_fd, 0);这里有个点要提醒如果这块内存主要是给硬件设备读、CPU不经常碰优先选uncached的堆如果进程本身要用CPU频繁读写又希望硬件访问时一致那就需要处理cache同步后面会单独讲。2.3 SCM_RIGHTS把fd“快递”给另一个进程有了DMA-BUF fd下一个问题很直接fd只是一个整数这个整数在进程A里指向某块buffer进程B里随便传一个数字过去内核可不会认为它有效因为每个进程的文件描述符表是独立的。所以需要一种内核帮你“打开文件引用”的机制这就是Unix domain socket上的SCM_RIGHTS辅助消息。本质上是让内核从发送进程的fd表里取出对应的struct file复制一份引用安装到接收进程的fd表里然后返回一个新的fd整数给接收进程。发送方和接收方各自持有的fd指向同一个内核对象DMA-BUF引用计数会相应增加生命周期由双方共同维护。发送端的核心代码#include sys/socket.h #include sys/types.h void send_fd(int sockfd, int fd_to_send) { struct msghdr msg {0}; char buf[1] {F}; struct iovec io { .iov_base buf, .iov_len sizeof(buf), }; char cmsg_buf[CMSG_SPACE(sizeof(int))]; msg.msg_iov io; msg.msg_iovlen 1; msg.msg_control cmsg_buf; msg.msg_controllen sizeof(cmsg_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(sockfd, msg, 0) 0) { perror(sendmsg); } }接收端的核心代码int recv_fd(int sockfd) { struct msghdr msg {0}; char buf[1]; struct iovec io { .iov_base buf, .iov_len sizeof(buf), }; char cmsg_buf[CMSG_SPACE(sizeof(int))]; msg.msg_iov io; msg.msg_iovlen 1; msg.msg_control cmsg_buf; msg.msg_controllen sizeof(cmsg_buf); if (recvmsg(sockfd, msg, 0) 0) { perror(recvmsg); return -1; } struct cmsghdr *cmsg CMSG_FIRSTHDR(msg); if (!cmsg || cmsg-cmsg_type ! SCM_RIGHTS) { return -1; } int received_fd; memcpy(received_fd, CMSG_DATA(cmsg), sizeof(int)); return received_fd; }注意SCM_RIGHTS要求socket必须是Unix domain socket也就是AF_UNIX本地回环TCP可不行。还有一点辅助数据必须伴随着至少一个字节的普通数据一起发送也就是说msg_iov里不能真的为空。3. 动手实现一套可复用的零拷贝帧传输模块3.1 总体设计预分配缓冲区池控制通道到了实际操作层面我并不建议谁需要哪个buffer就现场分配一个那会带来两个问题一是运行期频繁分配和释放DMA-BUF容易产生碎片二是在多进程、多路视频并发时内存使用不可控。更稳妥的做法是预分配一个缓冲区池。启动阶段一次性分配N块固定大小的DMA-BUF整条生命周期内都别释放等系统退出时统一清理。进程间传输的其实不是“数据”而是一个“token”这个token里有buffer索引、frame_id、宽高、时间戳这些元信息再加上对应的DMA-BUF fd。缓冲区池的状态管理可以设计成这样的结构#define MAX_FRAME_BUFFERS 8 struct frame_buffer_desc { int fd; // 生产者持有的fd size_t size; // 缓冲区大小 uint64_t frame_id; // 帧序号 uint32_t width; uint32_t height; uint32_t stride; uint32_t format; int state; // 0空闲 1已填充待消费 2消费中 3可回收 uint64_t timestamp; };这个状态数组可以放在一块单独的共享内存页里进程之间通过原子变量或者简单的一个自旋锁来修改。实际项目中我用过两种状态同步方式。一种是“生产者不等消费者”的丢帧策略。生产者每帧都去找空闲buffer找不到就直接丢帧用最新帧覆盖策略或跳过这一帧。这种方式采集进程永远不会被阻塞适合对实时性要求高的场景代价是极端情况下会丢帧。另一种是“消费完成通知”的回收策略。消费者处理完一帧后通过socket或者共享状态把buffer标记为空闲。生产者只有拿到空闲标记才能复用这块内存。这样能保证每帧都被完整处理代价是如果消费者处理太慢生产者会被拖住。我的建议是如果是视频监控这类流水线任务选丢帧策略更合理。宁可偶发丢帧也别让采集线程卡死。3.2 生产者端分配、填充、发布生产者进程的任务是把采集到的一帧数据灌进DMA-BUF然后把fd和帧描述信息发出去。整套流程大概是启动阶段通过dma-heap分配一大块buffer池按帧大小切分成N块每块对应一个fd然后mmap到自己的用户态虚拟地址空间。每来一帧摄像头数据从buffer池里挑一个空闲buffer。把图像数据填进去。这里有个关键选择如果采集buffer本身就是DMA-BUF fd最好的办法是直接把采集buffer的fd发给下游连独立buffer池都不需要。但实际工程中采集和AI往往需要解耦AI进程可能还要做格式转换、多路帧缓存所以独立pool更常见。填充动作可以用CPU memcpy也可以用RGA做硬件搬运。后者不占CPU是更“零拷贝”的做法。更新帧描述信息包括frame_id、宽高、时间戳。通过Unix domain socket把frame_id作为普通数据、fd作为辅助数据一起发送。发送消息时可以打包一个小的协议头struct frame_msg { uint32_t buffer_index; uint32_t format; uint32_t width; uint32_t height; uint32_t stride; uint64_t frame_id; uint64_t timestamp; };然后发送时普通数据就是frame_msg辅助数据要附带的fd就是当前buffer的fd。接收方拿到的fd和发送方的fd指向同一个DMA-BUF对象。3.3 消费者端接收、映射、使用、归还消费者进程这边的流程是在socket上recvmsg同时拿到frame_msg和继承来的fd。检查这个fd是否已经mmap过。如果进程里维护一个“fd到虚拟地址”的映射表发现同一个DMA-BUF已经映射过就直接复用地址不用反复mmap/munmap。这一步能省不少系统调用。拿到指针之后交给后续模块。如果是AI进程就喂给RKNN如果是编码进程就导入给MPP。处理完毕把buffer状态标记为可回收或者通过socket回一个ack给生产者。消费者mmap的逻辑很简单int received_fd recv_fd(sockfd); // 假设已经有buffer元信息 void *mapped mmap(NULL, frame_size, PROT_READ | PROT_WRITE, MAP_SHARED, received_fd, 0); if (mapped MAP_FAILED) { perror(mmap dma-buf in consumer); } // 使用 mapped 指向的图像数据这里有个经验如果buffer池是预分配的消费者最好也按“buffer索引”来缓存mmap结果。第一次收到某个索引的fd时map一次之后同一索引复用。这样既省了mmap/munmap的开销也避免了fd反复打开关闭带来的引用计数管理混乱。3.4 与RKNN、RGA、MPP对接的关键接口零拷贝的价值只有在和硬件模块对接时才真正体现出来。否则你就算用DMA-BUF传了半天fd最后在AI进程里还是把数据memcpy到普通内存再喂给RKNN那就没意义了。对接RKNN时核心思路是让NPU直接访问这块DMA-BUF而不是经过用户态指针中转。RKNN SDK通常提供从外部fd创建内存的方法类似#include rknn_api.h rknn_tensor_mem *external_mem NULL; // fd 就是我们从socket里收到的DMA-BUF fd // virt_addr 是mmap后的虚拟地址size 是buffer大小 rknn_create_mem_from_fd(ctx, fd, virt_addr, size, external_mem);然后推理时把external_mem塞进输入tensor的内存指针。这样NPU做DMA读取时直接访问的是物理内存不需要CPU先把数据从某个用户态地址拷贝到NPU可达的buffer。具体函数签名会随SDK版本略有差异但大方向就是这样。对接RGA时RK的RGA库librga或im2d支持直接导入fd做格式转换和缩放。比如AI进程要把NV12转成RGB同时缩放到模型输入尺寸可以走RGA硬件#include im2d.h #include rga.h rga_buffer_t src wrapbuffer_fd_t(fd, width, height, RK_FORMAT_NV12); rga_buffer_t dst wrapbuffer_fd_t(dst_fd, dst_w, dst_h, RK_FORMAT_RGB888); imresize(src, dst);wrapbuffer_fd_t这种接口就是让RGA驱动通过fd访问源和目标内存整个过程CPU不参与像素搬运。对接MPP硬编码时MPP也支持从外部fd导入buffer#include mpp_buffer.h MppBuffer enc_buf NULL; // fd 是共享的DMA-BUF fd mpp_buffer_import(NULL, enc_buf, fd);导入之后就能把这份buffer当普通MppBuffer喂给编码通道。编码出的H.264/H.265码流拿去做RTSP推流这就是我们常说的“基于RK3588硬编码的实时视频监控系统”。所以完整链路可以是这样的采集进程输出DMA-BUF fdNV12→ 零拷贝IPC传给AI进程 → 通过rknn_create_mem_from_fd交给NPU跑YOLOv8 → 推理结果通过RGA画框/转格式 → 再把fd传给编码进程 →mpp_buffer_import导入MPP硬编码 → 码流走RTSP推出去。整个过程里CPU没有参与任何一帧像素数据的搬移。4. 真实项目中的坑RK3588上最常踩的五个问题4.1 花屏、乱帧的深层原因cache一致性和对齐很多人第一次把跨进程零拷贝链路跑通后会看到画面时而正常、时而花屏或者模型推理结果偶尔不对又查不出代码逻辑漏洞。这种情况十有八九是cache一致性问题。如果生产者用CPU往一块cached的DMA-BUF里写数据写完直接发给NPU或VPU硬件DMA读取时可能拿到的是还没写回内存的脏cache数据。解决办法有这么几条一是分配buffer时直接用uncached堆例如/dev/dma_heap/system-uncached。代价是CPU访问这块内存的性能会明显下降所以只适合“CPU不怎么碰数据”的链路。二是保留cached堆但在关键位置主动做sync。用户态可以通过DMA_BUF_IOCTL_SYNC告诉内核在硬件访问前把cache刷回内存struct dma_buf_sync sync {0}; sync.flags DMA_BUF_SYNC_START | DMA_BUF_SYNC_RW; ioctl(buffer_fd, DMA_BUF_IOCTL_SYNC, sync); // CPU 读写这块 buffer sync.flags DMA_BUF_SYNC_END | DMA_BUF_SYNC_RW; ioctl(buffer_fd, DMA_BUF_IOCTL_SYNC, sync);三是让整条链路尽量使用采集或MPP导出的buffer因为这些buffer在驱动层已经把同步处理好了用户态不太需要操心。只有自己分配、自己用CPU填充时才需要注意这个坑。还有一类花屏和cache无关纯粹是对齐问题。RGA、VPU、NPU对buffer的起始地址和大小都有对齐要求一般建议按2MB或1MB对齐至少也要按64字节对齐。分配时可以让size ALIGN(frame_size, 4096)再用一个ALIGN_UP宏把所有宽高参数按硬件要求的stride对齐。4.2 帧串号和丢帧缓冲区复用管理另一个高频问题是帧号错乱。现象是消费者收到的frame_id顺序不对或者画面内容是上一帧的时间戳却是这一帧的。这多半是因为生产者把同一块buffer发出去两次或者消费者还在读某块buffer生产者就把它重新填充并再次发布了。解决思路很直接buffer状态机必须严格。空闲buffer只能被生产者拿到消费者处理完必须显式标记为可回收中间不能跳状态。我踩过的一个坑是为了省事生产者发完fd就立刻把buffer状态改成空闲结果消费者还没读完下一帧数据已经覆写进来了。后来改成消费者处理完通过共享内存标记回收生产者只从“已回收”状态的buffer里挑问题就消失了。4.3 fd生命周期和内存泄漏fd生命周期问题是零拷贝链路里隐藏最深的一类bug。SCM_RIGHTS传递fd后发送端和接收端各自都有一个fd指向同一个DMA-BUF。如果发送端在sendmsg之后直接close没有别的影响因为接收端持有的fd已经增加了引用计数。但如果你两边都close了而mmap还在DMA-BUF仍然不会释放因为mmap本身也持有引用。反过来说如果接收端用完fd直接close又忘了munmap就会内存泄漏。长时间运行的边缘设备这种泄漏积累到最后会致命。我的建议是每个进程维护一张“buffer索引到虚拟地址”的映射表mmap一次后不随便munmapfd在mmap成功后也可以立即close掉因为mmap已经持有引用。等整个会话结束统一munmap所有映射再统一close所有残留fd。这样管理起来清爽得多。4.4 内核没有DMA-BUF节点或者权限被限制新版RK3588 SDK基本都支持/dev/dma_heap但有些老SDK或者定制内核只有/dev/ion或者两个节点都没有。拿到新板子第一件事就是检查这些节点ls -l /dev/dma_heap/ 2/dev/null || ls -l /dev/ion 2/dev/null如果节点不存在大概率是内核配置里没开CONFIG_DMABUF_HEAPS或者DTS里没有使能对应的heap。这时候要么改内核配置重新编译要么就只能退回MPP的mpp_buffer_get这类封装接口。如果改完内核发现系统起不来也别纠结RK3588开发板刷机很快recovery或maskrom模式下用USB Type-C连电脑就能装回来。还有权限问题。容器化部署边缘盒子时/dev/dma_heap下的节点不会自动映射进容器必须在docker run时手动加--device /dev/dma_heap:/dev/dma_heap否则容器里open必然失败。4.5 常见错误速查表现象可能原因排查方向另一进程mmap返回EPERM或ENOMEMfd不是真实DMA-BUF fd或权限不够检查SCM_RIGHTS是否真正传成功确认socket是AF_UNIX检查容器device映射RGA调用返回错误码buffer地址或stride未对齐格式不支持分配时按64字节或更大对齐确认RGA版本支持目标格式VPU编码画面花屏cache未同步或stride设置和buffer实际宽度不一致加DMA_BUF_IOCTL_SYNC核对stride和width关系RKNN输入无效或推理异常外部fd创建内存失败或buffer是cached且未同步用rknn_create_mem_from_fd前确认fd可用uncached优先帧号乱序、画面内容错帧buffer被过早复用状态机不严严格“空闲→填充→发布→消费→回收”流程内存持续上涨mmap或fd未释放用cat /proc/pid/smaps检查映射区间清点每个buffer的映射生命周期8K或4K多路时分配失败内存池过大或heap碎片化减小缓冲池深度或改用CMA堆5. 效果评估和性能对比这套方案到底值不值得上最后说点实际的。零拷贝跨进程通信听起来很酷但它不是银弹它解决的是“大块图像数据在进程间频繁传输”这个特定问题。如果你的应用只是偶尔传一些小的结构化数据用它反而复杂了。我整理了两种方案的粗略对比数据来自我们RK3588板卡上的实测不同板卡、不同内存频率会有差异思路比数值更重要。项目传统socket传整帧DMA-BUF零拷贝传fd一帧1080p NV12传输额外耗时2至4ms左右含内核拷贝和用户态拷贝小于0.1ms只传元信息和fdCPU占用每次拷贝都会占用A76核心几乎为零硬件DMA直读是否适合多路视频4路以上CPU占用吃紧多路时依然能保持低CPU负载实现复杂度简单但低效需要设计buffer池和状态机和RKNN/MPP/RGA对接需要额外拷贝到硬件可访问buffer直接导fd无缝对接如果你跑的是单路1080p30fps的简单demo传统socket方式也许够用CPU多花几个点没什么感觉。但一旦跨到4路1080p或者4K60fps拷贝开销会成倍放大这时零拷贝就不是优化项而是刚需。就算自身业务暂时用不到多进程我个人也建议在RK3588上做视觉方案时一开始就把帧传输通路设计成基于DMA-BUF的形式。后面无论是想把AI推理独立成进程、还是加一路硬编码推流改动都会小很多不至于推倒重来。这也是我在RK3588架构上做边缘AI视觉项目后期体会最深的一点数据通路的设计比一开始用的模型大小、算力预算更值得花时间提前规划。
返回列表