尧图精选

RK3588边缘AI零拷贝跨进程通信:共享内存与DMA-BUF实战

🕒 发布时间:2026/9/6 5:49:23 📁 来源:尧图网络
1. 写在前面为什么边缘AI视觉会卡在“传数据”上RK3588做边缘AI视觉项目时一个很常见的架构是把采集、推理、编码拆成好几个进程跑。比如摄像头采集进程负责读MIPI-CSI数据推理进程专门跑yolov8模型还有一个编码进程用硬件把结果帧压成RTSP视频流输出。这么拆的好处很直白某个环节崩了不会拖垮全局模型升级只需要重启推理进程各模块由不同工程师维护时边界也足够清晰。但拆成多进程后一个要命的问题立刻浮出水面——视频帧怎么在进程之间传我第一次做这个方案时用的是最简单的办法采集进程拿到一帧NV12数据后直接通过本地socket把二进制流发给推理进程。结果机器一跑起来就露馅了1080P60fps的NV12帧一帧裸数据约3MB每秒要传约180MB数据加上socket协议开销和两次拷贝发送端从用户态到内核态接收端从内核态到用户态CPU占用直接飙到70%以上推理还没开始跑系统先被数据搬运拖死了。后来换成共享内存方案把socket传数据改成共享内存传指针同样是1080P60fpsCPU占用立刻降到了个位数。这就是这一期要聊的核心在RK3588这种以硬件编解码、硬件加速为卖点的平台上怎么把跨进程视频帧传输的拷贝成本压到最低也就是标题里说的“零拷贝跨进程通信”。这篇文章会覆盖三部分内容零拷贝技术选型的底层逻辑、共享内存框架的具体实现步骤以及怎么让RGA和VPU这类硬件模块直接访问共享buffer做到真正的“零拷贝”而不是表面上的“少拷贝一次”。适合正在做RK3588边缘AI盒子、实时视频监控系统、或任何需要多进程协同处理视频流的开发者参考。2. 内容整体设计与方案选型思路2.1 先搞清楚瓶颈视频传输的“拷贝”到底发生在哪里要设计零拷贝方案首先得知道拷贝发生在哪里。一条完整的视频流从摄像头到推理进程数据路径大概是这样MIPI-CSI接口把RAW数据送入ISPISP做去马赛克、降噪、色彩校正后输出NV12格式的帧然后这帧数据进入采集进程的内存空间。采集进程如果走常规的IPC方案比如socket或管道那么数据会先被CPU从用户态buffer拷贝到内核态socket buffer接收端再从内核态拷到用户态一来一回至少两次CPU memcpy。这两次拷贝的代价有多大在一张1080P NV12帧上做一次memcpy实测大约要0.6到0.8毫秒两次就是1.2到1.6毫秒。60fps的帧间隔是16.7毫秒看起来还占不到10%。但问题在连续传输时CPU cache会被打乱内存带宽被大量占用推理进程的cache命中率会明显下降整个系统的实时性会变得极不稳定。跑yolov8这类模型时INT8推理一帧大概15到30毫秒再加上传输开销60帧的节奏基本就守不住了。2.2 三条可选的“去拷贝”路线要解决这个问题业界有几条不同的路线。我画了张对比表方便直接看清楚各自的适用场景方案原理优点缺点适用场景POSIX/SysV共享内存两进程直接mmap同一块物理内存实现简单、CPU访问快、完全跨进程不含硬件加速感知缓存一致性需自己管理纯CPU处理链路DMA-BUF跨进程共享内核导出dma-buf fd通过unix域fd传递共享给另一进程RK3588硬件RGA/VPU/ISP可以直接访问、可配合掩码映射需要驱动支持、代码复杂度高涉及硬件编解码的链路零拷贝网络sendfile等socket直接发送时避免用户态缓冲适合跨设备、网络传输本机进程间传输延迟仍偏高不推荐用于本机进程间帧传输这三种方案里纯CPU推理链路上用POSIX共享内存够用了它让采集进程和推理进程能直接访问同一块物理内存省掉内核态那两次搬运。但如果你想让RGA做图像缩放、让VPU做硬编码共享内存里的buffer未必满足这些硬件模块的访问要求这时必须走DMA-BUF路线。瑞芯微官方提供的librga和mpp库都支持DMA-BUF操作配合dma_fd跨进程传递RGA和VPU就能直接“看到”对方手里的buffer彻底跳过CPU中转。2.3 为什么最终选择“共享内存RGA/VPU旁路”的组合我最终的设计方案是把两条路线结合起来一条路是采集进程用V4L2拉到帧后写入共享内存环形缓冲推理进程直接读取并做AI分析另一条路是当推理完成、需要把结果帧推给编码进程做RTSP输出时通过dma-buf fd把这帧直接送给VPU由VPU硬件编码全程CPU不碰像素数据。选这个组合的原因有三点第一共享内存实现成本低一套简单的生产者消费者模型就可以解决采集到推理的传输第二RK3588的VPU只认dma-buf输入必须走fd这条路线才能发挥硬编能力第三把两条路线拆开各段链路的调试难度都降低了——出问题时先查共享内存还是先查dma-buf边界非常清楚。这也是我调试踩了很多坑后觉得最顺手的架构。3. 核心细节解析与实操要点3.1 共享内存里到底要放什么帧数据加帧元信息很多人以为共享内存就是切一块buffer然后往里写像素数据真做起来会发现不够用。视频流里每帧不仅有像素数据还有宽高、像素格式、时间戳、帧序号、是否关键帧等元数据。如果每个生产者消费者各自通过消息队列传元数据再通过共享内存传像素逻辑会很割裂也容易出现元数据和像素不同步的问题。我建议在共享内存里定义两个区域固定大小的帧元信息区和像素数据区。元信息用自定义结构体保存像素数据严格放在对齐后的偏移处。以1080P NV12为例每帧像素数据是1920x1080x1.5约为3.1MB我定义结构体如下typedef struct frame_meta { uint32_t magic; uint32_t frame_id; uint64_t timestamp_ns; uint32_t width; uint32_t height; uint32_t format; // V4L2_PIX_FMT_NV12等 uint32_t size; // 像素数据总字节数 uint32_t plane_offset[3]; // 各plane在像素区内的偏移NV12有两个plane uint32_t flags; // 关键帧标记等 } frame_meta_t; typedef struct frame_buffer { frame_meta_t meta; uint8_t data[]; // 像素区按64字节对齐 } frame_buffer_t;每个frame_buffer条目按2MB对齐分配一帧一个条目。这样消费者拿到元信息里的size和offset后直接用data地址当作指针传给推理库就行完全不需要重新组织内存布局。3.2 环形缓冲区的同步多生产者多消费者怎么保证不错帧共享内存用起来容易但同步机制设计不好就会出现错帧、撕裂、时间戳错位这些问题。我先说一套在实践中验证过的可靠结构用“多槽位环形缓冲区”加“每槽状态位”的方式。槽位数量建议取2的幂次比如8个或16个每个槽位对应一个frame_buffer_t。每个槽位有一个原子状态标志取值有三态空闲、写入中、就绪。生产者申请下一个空闲槽时将状态从空闲原子性地CAS成写入中写入完成后释放并置为就绪。消费者读取时将就绪改为读取中读完释放回空闲。这套流程用C原子操作就能实现不依赖锁延迟只有几十纳秒。关键点是状态转换必须用CAS比较并交换不能先读后写否则两个生产者会同时抢同一个槽位。我在实际项目中还加入了一个“顺序保证”机制每个生产者维护自己的帧序号消费者维护last_processed_id只有连续帧序号才允许消费跳号说明中间帧丢了直接跳过等待下一帧避免因为一帧丢失造成连锁阻塞。这套机制在压缩海思编码器掉帧等情况时非常有效。3.3 一次性把“共享内存也算零拷贝”这件事说清楚经常有人问共享内存在用户态通过mmap映射到进程地址空间数据还是在物理内存里为什么算零拷贝这里的关键是“零拷贝”指的是CPU不做数据搬运而不是数据不经过内存。在一个进程里采集到帧写入共享内存另一个进程直接通过指针拿到这个像素buffer整个过程CPU只做了指针传递和状态同步像素数据始终在同一块物理内存里没动过。这就是零拷贝中的“CPU零参与”语义。不过要补一个容易踩的坑共享内存虽然避免了用户态到内核态的拷贝但如果生产者和消费者跑在两个不同大小核上ARM大小核架构下cache不共享消费者读到的数据可能不是最新的。我在RK3588上实测过不显式做内存屏障时偶尔会出现推理结果对应的是上一帧画面。解决方法是写入完成后调用__sync_synchronize()或C11的std::atomic_thread_fence(std::memory_order_release)读取前用acquire栅栏。这个细节不处理测试阶段偶尔出问题上线后一定翻车。4. 实操过程与核心环节实现4.1 环境准备与基础工程搭建我这次演示基于RK3588的公版SDKLinux 5.10内核开发板用香橙派5或者瑞芯微官方的RK3588 EVB都行。编译环境里需要linux-libc-dev提供头文件、librga用来做2D图形加速、mpp用来做视频编解码。如果是从SDK编译的固件这些库通常在镜像里已经自带如果用Debian11桌面版系统需要手动安装。我把整个测试工程拆成了三个独立进程producer模拟V4L2采集、consumer_infer模拟yolov8推理、consumer_encode模拟VPU编码。三个进程通过共享内存文件/dev/shm/ai_vision_frames和/dev/shm/ai_vision_cfg互相通信。producer写帧consumer_infer读帧推理并把结果加到元信息里consumer_encode负责把带结果的帧编码输出。工程目录结构如下edge_ai_vision/ ├── common/ │ ├── shm_fifo.h # 环形缓冲区和同步逻辑 │ └── frame_meta.h # 帧结构体定义 ├── producer/ │ ├── main.c # 生产者进程 │ └── v4l2_fake.c # 模拟相机采集也可以换成真实v4l2设备 ├── consumer_infer/ │ └── main.c # 推理消费者进程 └── consumer_encode/ └── main.c # 编码消费者进程用mpp API调用VPU先把生产者写出来。核心逻辑是打开共享内存对象、设置大小、mmap映射到用户空间然后用环形缓冲区的CAS流程把帧数据写进去#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include sys/mman.h #include sys/stat.h #include unistd.h #include stdatomic.h #define FRAME_CNT 16 #define FRAME_SIZE (4096*4096*2) // 预留足够大的像素空间 // 共享内存对象头部放配置后面放帧槽位 typedef struct shm_layout { uint32_t frame_cnt; uint32_t frame_size; uint64_t write_idx; uint64_t read_idx; atomic_int slot_state[FRAME_CNT]; // 0空闲 1写入中 2就绪 3读取中 frame_buffer_t slots[]; } shm_layout_t; int main() { int fd shm_open(/ai_vision_frames, O_CREAT | O_RDWR, 0666); ftruncate(fd, sizeof(shm_layout_t) FRAME_CNT * FRAME_SIZE); shm_layout_t *shm mmap(NULL, sizeof(shm_layout_t) FRAME_CNT * FRAME_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); // 初始化槽位状态为空闲 for (int i 0; i FRAME_CNT; i) atomic_store(shm-slot_state[i], 0); // 模拟采集循环 uint64_t frame_id 0; while (1) { // 找一个空闲槽 int slot -1; for (int i 0; i FRAME_CNT; i) { int expected 0; if (atomic_compare_exchange_strong(shm-slot_state[i], expected, 1)) { slot i; break; } } if (slot 0) { usleep(1000); continue; } // 填充帧数据 frame_buffer_t *fb shm-slots[slot]; fb-meta.magic 0x46424100; fb-meta.frame_id frame_id; fb-meta.width 1920; fb-meta.height 1080; fb-meta.format V4L2_PIX_FMT_NV12; fb-meta.size 1920 * 1080 * 3 / 2; fb-meta.plane_offset[0] 0; fb-meta.plane_offset[1] 1920 * 1080; // 模拟填充Y和UV平面 memset(fb-data, 128, fb-meta.size); __sync_synchronize(); // 写入屏障 // 状态置为就绪 atomic_store(shm-slot_state[slot], 2); usleep(16000); // 模拟60fps采集 } return 0; }消费者进程的流程正好相反先扫描状态为“就绪”的槽位CAS改成“读取中”解析元信息拿到像素数据直接传入推理接口。这里用到acquire栅栏防止读取到未写完的数据void consumer_infer_loop(shm_layout_t *shm) { uint64_t last_processed_id 0; while (1) { for (int i 0; i FRAME_CNT; i) { int expected 2; // 就绪 if (atomic_compare_exchange_strong(shm-slot_state[i], expected, 3)) { frame_buffer_t *fb shm-slots[i]; if (fb-meta.frame_id ! last_processed_id 1) { // 跳帧处理不阻塞 last_processed_id fb-meta.frame_id; } // 先做acquire屏障保证看到生产者的完整写入 __sync_synchronize(); // 这里把fb-data传给推理接口 // infer_result rknn_run(fb-data, ...); atomic_store(shm-slot_state[i], 0); // 释放为空闲 last_processed_id fb-meta.frame_id; break; } } usleep(100); // 极短的轮询间隔 } }这个模型还有个好处是支持多个消费者。比如推理进程和编码进程可以同时读同一个“就绪”槽位的数据不需要复制只要各自最终不重复释放槽位即可。实际做的时候最好让推理和编码进程分别维护自己的“已消费位置”避免互相干扰。我在项目中就是让推理进程消费后把帧序号写进元信息编码进程只消费已经推理过并且标记了结果的槽位。4.2 进阶让RGA和VPU直接访问共享buffer上面这套共享内存方案解决的是CPU全链路操作的场景。但RK3588最大的价值在于内部的RGA2/RGA3图形加速器和VPU视频编解码器。如果推流时把P帧交给VPU硬编码再走RTSP输出能省下大量CPU。问题是VPU硬编码的输入buffer一般要求是物理连续内存、且满足特定对齐要求而普通共享内存可能是不连续的物理页。内核的标准解法是DMA-BUF由设备驱动分配物理连续或通过IOMMU映射为连续地址空间的buffer导出为一个fd通过unix域socket把fd传给另一个进程。简单说DMA-BUF的跨进程传递流程是这样的采集进程持有RGA输出的一块dma-buf fd需要把这张fd发给编码进程。发送fd时用SCM_RIGHTS辅助消息在unix domain socket上传递接收方拿到的是一个全新的fd编号但指向的是同一块底层物理内存。V4L2、DRM、RGA和MPP的API都围绕dma-buf fd设计所以这个fd是可以直接在VPU编码器里用的。我在RK3588上最常用的生产dma-buf的方法有两个。一个是从V4L2设备直接获取VIDIOC_EXPBUFioctl把V4L2的buffer导出为dma-buf fd另一个是用DRM的gem创建接口。更实用的场景是从RGA导入buffer做格式转换RGA的输入输出描述符可以直接用dma-buf fd调用rga_import_buffer或直接使用librga的rga_set_buffer_info接口传入fd转换完成后输出给编码器全程CPU零介入。以RK3588MPP硬编码为例核心流程是这样// 编码进程里收到dma-buf fd后 MppBufferGroup group; mpp_buffer_group_get_internal(group, MPP_BUFFER_TYPE_DRM); mpp_buffer_import(group, fb_meta.buffer_fd); // 导入外部dma-buf // 设置编码参数 MppEncCfg cfg; mpp_enc_cfg_init(cfg); mpp_enc_cfg_set_s32(cfg, prep:width, 1920); mpp_enc_cfg_set_s32(cfg, prep:height, 1080); mpp_enc_cfg_set_s32(cfg, prep:format, MPP_FMT_NV12); // ... 其他参数设置 ... // 编码一帧 MppFrame frame; mpp_frame_init(frame); mpp_frame_set_buffer(frame, buffer); // 直接用导入的buffer不需要拷贝 // 送入编码通道这段代码里mpp_buffer_import是关键它把dma-buf fd转换为MPP可以直接访问的buffer对象内部不再复制像素数据。编码完成后的码流数据很小可以走常规IPC交给RTSP推流进程此时因为数据量小普通socket毫无压力。4.3 完整链路跑通采-推-编-RTSP推流把上面的模块串起来一个典型的RK3588实时视频监控系统就跑起来了。我实际搭的测试环境是这样的使用IMX415摄像头通过MIPI-CSI接口接入RK3588的ISPISP输出NV12格式帧给V4L2节点。V4L2采集进程把帧写入共享内存槽位。推理进程从共享内存里取帧跑yolov8检测如果有人形目标就在元信息里标记目标的bounding box坐标。编码进程读取标记后的帧共享内存里的同一条数据调用MPP硬编码成H.264码流再通过自研的RTSP服务推送出去。实测结果让我印象很深1080P60fps的输入视频流整个“采集到编码输出”流程的CPU占用只有不到20%其中大头还是yolov8推理本身。而原来的socket文件传输方案相同CPU负载下只能推到25fps左右CPU还被拷数据拖得没法提升推理精度。零拷贝方案对实时性的改善是立竿见影的。5. 常见问题排查与经验技巧5.1 共享内存不共享权限和路径导致的坑共享内存在Linux里是基于tmpfs的路径默认挂载在/dev/shm下面。不同进程要能打开同一个共享内存对象除了路径一致外文件权限也很关键。我遇到最典型的错误是producer用0666权限创建consumer用0600打开结果返回EACCES。还遇到过producer进程异常退出后共享内存文件残留导致下一次启动时数据是旧的。最佳实践是producer创建时用shm_open加O_CREAT | O_EXCL如果已经存在就想办法清掉旧文件或让消费者忽略残留数据。另外要注意的是不同用户运行进程时比如用systemd服务跑消费进程而生产者是手动启动的它们的临时目录可能不同shm_open路径有差异就会找不到对象。统一把共享内存文件放在固定的绝对路径下权限设为0777整个系统内可访问调试时能省不少事。5.2 DMA-BUF fd传递时的生命周期管理dma-buf fd跨进程传递时发送方和接收方对fd的生命周期管理非常容易出问题。接收方拿到的是新的fd编号底层使用的是同一个引用计数。如果发送方提前关闭了原fd接收方的fd仍然有效但如果接收方用完不关闭fd会一直占着内存导致DMA-BUF泄漏表现为运行几个小时后内存持续增长。我常用的排查手段是看/proc/net/unix里的socket残留和/proc/pid/fd里的fd数量增长。如果dma-buf fd数量一直涨一定是接收方忘记close了。编码进程里每编码一帧后要及时调用mpp_buffer_put释放引用mpp_frame_deinit也要同步做。理论上DMA-BUF有内核引用计数但依赖内核自动释放不现实还是要自己在用户态把生命周期管清楚。5.3 RGA输入buffer对齐要求的坑把共享内存buffer直接传给RGA做缩放或格式转换时RGA对输入buffer地址有严格的128字节对齐要求。早期我的共享内存数据区是从结构体头部直接偏移计算的buffer地址经常不是128的倍数调用RGA后返回EINVAL或者输出花屏。后来我在定义frame_buffer_t时手动做了对齐填充把data成员用__attribute__((aligned(128)))指定问题就消失了。还有个更隐蔽的问题是RGA在RK3588上有两个版本RGA2和RGA3能力不同。RGA2支持输入NV12、输出RGB888RGA3只支持部分格式组合支持的格式要查芯片手册。我在DTS里没有显示使能RGA3时默认走的是RGA2路径实测转换一帧1080P只需不到0.5毫秒吞吐完全够用。如果初始化librga时发现某些格式组合报错优先查一查是不是选到了RGA3。5.4 缓存一致性问题现象与对策linux大内核运行时CPU对内存的访问会经过cache。RK3588是大小核架构生产者跑大核、消费者跑小核两边cache不共享。生产者写入共享内存后如果不做同步消费者读到的可能是cache里旧的数据。这个问题在低画质、高帧率时特别容易暴露画面运动剧烈时会出现“前一帧和这一帧混在一起”的撕裂现象其实就是数据没同步好。我的解决办法是在每个槽位状态变更后生产者做release写屏障、消费者做acquire读屏障。如果使用MPP的DMA-BUFMPP框架内部会调用dma_buf_sync显式做cache flush/invalidate所以MPP路径一般不需要额外操心。纯CPU的共享内存链路一定要自己加屏障否则只能靠运气出正确结果。5.5 常见错误速查表现象可能原因排查与解决共享内存打开失败路径不一致、权限不足、tmpfs空间满确认shm_open路径检查/dev/shm下空间消费者看到旧帧未做cache同步、槽位状态乱检查release/acquire屏障位置梳理状态机RGA调用返回错误buffer地址不对齐、格式不支持用128字节对齐查询RGA版本支持格式VPU编码花屏DMA-BUF导入格式与编码器不匹配检查NV12还是I420mpp编码帧格式设置fd数量持续增长DMA-BUF或socket fd泄漏检查mpp_buffer_put、close调用推理结果跳帧错位消费者未按帧序号消费为消费者增加last_processed_id校验5.6 性能调优的三个实用技巧第一槽位数量不是越多越好。我测试过8、16、64个槽位16个槽位性能最好。太多槽位会导致消费者轮询范围大cache miss率高太少槽位容易在生产者突发写入时阻塞。第二生产者用V4L2 MMAP方式拿摄像头buffer时可以直接把这个V4L2 buffer导出为dma-buf fd共享给其他进程省掉一次ISP到共享内存的CPU拷贝。这需要调用VIDIOC_REQBUFS、VIDIOC_QUERYBUF、VIDIOC_EXPBUF三个ioctl配合MMAP类型的buffer来实现。效果是在1080P下每帧帧率能额外提升5到8fps。第三多个消费者共享同一帧时不建议每个消费者都去轮询状态数组。可以在每个槽位里放一个consumer_mask位图每位代表一个消费者是否消费过这帧当所有消费者都消费过之后再释放槽位。这样可以让推理和编码并行处理同一帧而不会互相强制串行。6. 我跑这套方案时踩过的一些额外体验实际的坑永远比文档里描述得多。调试共享内存时最常见的幻觉是“代码看起来没问题但结果不对”这时候别怀疑编译器先去打印生产者和消费者的内存地址确认两边mmap返回的地址是否落在同一物理区域。在RK3588上如果分别用root和非root用户跑两个进程哪怕共享内存路径相同也容易因为地址空间布局差异导致状态数组访问越界表现就是偶发的崩溃。关于DMA-BUF和V4L2摄像头buffer还有一点要注意V4L2导出的dma-buf fd在用户态使用前最好做一次dma_buf_sync操作特别是在摄像头buffer写入后直接交给推理进程时。虽然RK3588的ISP通常已经做了cache一致性但不同驱动版本的实现有差异做了同步操作才最稳妥。最后提醒一句这套零拷贝方案能显著降低CPU占用但也意味着内存布局必须稳定一旦某个进程写越界脏数据会直接污染所有共享该buffer的进程。强烈建议在共享内存头部加一个magic number和CRC校验每次写入后更新CRC消费方读取前校验虽然增加一点点开销但定位问题的时间能缩短一个数量级。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →