☰
RK3588边缘AI视觉项目零拷贝跨进程通信实践
2026/10/27 17:13:24 网站建设 项目流程

1. 边缘AI视觉项目里,为什么偏要死磕零拷贝通信

我先交代一下背景。这是个基于RK3588的边缘AI视觉项目,整条流水线大概是:MIPI/USB相机采集图像,送入NPU做YOLO类模型推理,推理结果再叠加到视频流上,通过网络或屏幕输出。项目写到第04篇,前面把硬件架构、系统环境、NPU推理的基本流程都跑通了,这一篇专门聊进程间的数据传输——更准确地说,是零拷贝的跨进程通信方案。

先说我在这个项目里遇到的具体痛点,这比任何抽象理论都直接。

RK3588这颗SoC比较特殊,它内部有四核Cortex-A76加四核Cortex-A55,还集成了一颗6 TOPS算力的NPU。在实际项目里,我不会把所有功能塞进一个进程里,原因很简单:采集进程要保证帧率稳定,推理进程要保证NPU使用率,显示/推流进程要保证延迟可控,混在一个进程里,一个模块出问题(比如网络阻塞)就会拖垮整个采集链路。拆成多进程后,数据怎么在进程之间高效搬运就成了瓶颈。

一开始我用的是传统方案:进程A把图像数据写入共享内存,进程B读出来处理。听起来没问题,但图像数据是malloc出来的普通内存,写入和读取都要经过内核态拷贝。以1080p的YUV数据举例,一帧大概是1920乘1080乘1.5字节,约3MB。30帧每秒就是90MB/s的数据量,这还没算推理结果、控制消息这些边角料。如果采集端和推理端各自再做一些图像缩放、格式转换,内存拷贝次数会乘好几倍。

实测下来,CPU开销相当可观,而且延迟抖动明显——系统一忙,拷贝速度就受影响,推理端时不时等一下帧,整体帧率从宣称的30fps掉到20出头,这在视觉检测场景里是不能接受的。

所以这个项目的核心诉求浮出水面:进程间共享图像数据时,要么直接从硬件设备映射内存,要么进程间传递内存句柄而不是复制数据本身。这就是零拷贝通信的原始动机——不搬运数据,只搬运数据的引用。

再说清楚一点。所谓零拷贝,在跨进程场景下指的是:数据从采集设备(比如MIPI CSI摄像头)到NPU输入,再到推理输出、显示buffer,整个链路中用户态不需要主动做内存拷贝。数据始终在内核管理的内存区域或DMA缓冲区里,进程之间的“通信”只是传递一个文件描述符、一个索引或一个指针映射关系。

RK3588上实现零拷贝有几个现成的通道,我在项目里实际用的是下面两条主线:

  1. ION/DMA-BUF:RK3588的VPU、NPU、ISP、RGA这些硬件模块都基于DMA-BUF机制管理缓冲区。DMA-BUF本身是内核级的内存对象,可以通过文件描述符在不同进程间传递。拿到fd后,进程把自己那部分地址空间映射过去,各进程访问的是同一块物理内存,不需要拷贝。

  2. 共享内存 + 无锁环形缓冲:纯CPU侧的进程间大数据传输,用mmap映射匿名共享内存,加上自旋锁或无锁队列做同步。虽然映射过程有页表开销,但一旦映射完成,读写就是直接访存,比每次read/write走内核拷贝快一个量级。

这一篇我会把两条路线的工程细节铺开讲。包括DMA-BUF句柄如何跨进程传递、ION heap和dma-heap的区别、共享内存的缓存一致性问题,以及我在RK3588上踩过的几个坑。适合正在做边缘AI盒子、智能相机、RK3588开发板视觉项目的朋友参考。

2. 先搞明白RK3588的数据通路:谁在产生数据,谁在消费数据

2.1 图像数据的完整流转链路

不先把RK3588的硬件数据通路搞清楚,后面谈零拷贝就是空中楼阁。我画了一条我们项目实际的图像流经路径(不用mermaid,我用文字描述):

MIPI CSI 相机 → RKISP(图像信号处理器)→ DMA-BUF(RAW/YUV 帧) ↓ RGA(缩放/旋转/格式转换) ↓ DMA-BUF(YUV/RGB 帧) ↓ NPU(RKNN 推理) ↓ DMA-BUF(推理结果 + 叠加图层) ↓ 显示控制器/RTSP 推流

这条链路里每个箭头都是一次潜在的数据交接点。传统做法是:ISP输出到内存 → CPU拷贝到用户态 → 应用程序处理 → 再拷贝到NPU输入。RK3588的驱动其实已经提供了DMA-BUF接口,完全可以做到ISP输出直接给RGA,RGA输出直接给NPU,全程不需要CPU搬数据。

这就是零拷贝在这颗平台上的真正姿势——不是用某个魔法函数实现,而是把整条数据链路设计成基于DMA-BUF句柄流转。

2.2 哪些硬件模块拥有自己的DMA buffer

RK3588的媒体相关内核驱动几乎全部基于DMA-BUF。下表是我在实际开发中验证过的模块和对应设备节点:

模块作用DMA-BUF 相关接口设备节点/API
RKISP图像信号处理输出帧为dma-buf fd/dev/video0 等,VIDIOC_QUERYBUF + VIDIOC_EXPBUF
RGA2D图形加速(缩放、旋转、格式转换)输入和输出都接受dma-buf fd/dev/rga,DRM_IOCTL_RGA_BUF 或 librga
VPU视频编解码编解码帧为dma-buf fd/dev/mpp_service,MPP 组件
NPU神经网络推理输入输出基于dma-buflibrknnrt 的 rknn_create_mem 系列
显示控制器LCD/HDMI输出plane扫出dma-buf fdDRM/KMS,drmModeAddFB2

让我用我们项目里的实际例子来说明。我们用一路MIPI相机采1080p30的YUV422图像,RKISP默认会在内核里分配buffer。用户态通过V4L2的VIDIOC_REQBUFS申请缓冲区,普通的V4L2读帧方式(read或mmap)是把数据从内核拷贝到用户态,而我们要做的是在VIDIOC_QUERYBUF之后调用VIDIOC_EXPBUF,把内核buffer导出为一个DMA-BUF的fd。

拿到这个fd之后,就可以直接传给RGA做缩放,或者传给NPU做输入——传的是fd不是数据。目标进程通过这个fd映射到同一块物理内存,读到的是刚才ISP写好的图像。这个过程中CPU不参与数据搬运。

2.3 关键认知:DMA-BUF fd本质是“句柄”

我反复强调fd的含义,是因为很多做应用层开发的工程师第一次接触DMA-BUF时会懵:进程A拿到一个fd,怎么进程B就能用了?

实际上,DMA-BUF fd是一个文件描述符,背后对应内核里的struct dma_buf对象。这个对象包含指向真实物理内存页面的指针数组,以及一组操作函数(映射、同步、attach等)。进程A通过dma_buf_ioctl(或直接使用驱动暴露的接口)申请到这个fd,然后通过UNIX域socket的SCM_RIGHTS机制把fd本身发送给进程B。进程B的进程表里会出现一个新的fd,两个fd指向同一个struct dma_buf。

之后进程B用mmap把这个dma_buf映射到自己的地址空间,读写的就是同一块物理内存。

这一步是整个零拷贝通信的基石。fd传递的开销很小(一次socket sendmsg),数据本身不复制。

3. ION与DMA-BUF的选择:RK3588上的历史包袱与新机制

3.1 ION曾是Android时代的王者

如果搜RK3588零拷贝相关的资料,大概率会看到“ION”这个词。ION是Google在Android内核里引入的内存分配器,统一管理不同类型的内存(物理连续内存、系统堆内存、DMA内存等),并对外提供fd句柄。RK3588的官方内核里依然保留了Ion模块,设备节点是/dev/ion。

我在项目初期直接用的ION。流程大概是:

// 打开ION设备 int ion_fd = open("/dev/ion", O_RDONLY); if (ion_fd < 0) { perror("open /dev/ion failed"); return -1; } struct ion_allocation_data alloc_data; memset(&alloc_data, 0, sizeof(alloc_data)); alloc_data.len = buffer_size; alloc_data.heap_id_mask = 1 << ION_HEAP_TYPE_DMA; // 选择DMA heap alloc_data.flags = 0; int ret = ioctl(ion_fd, ION_IOC_ALLOC, &alloc_data); if (ret < 0) { perror("ION_IOC_ALLOC failed"); close(ion_fd); return -1; } // 获取dma-buf fd int dma_fd = alloc_data.fd;

分配完之后,你要共享给其他进程,就把这个dma_fd通过socket发过去。目标进程用mmap直接映射。

3.2 两个我踩过的ION坑

第一个坑是兼容性。ION的用户态接口在不同内核版本上变动挺大,struct ion_allocation_data的字段在不同版本里有差异。我在RK3588的某个BSP内核上写好的代码,换到另一个内核版本就编译不过去。如果是长期维护的项目,这很头疼。

第二个坑是权限与分配策略。ION在较新的内核里被标记为deprecated,RK3588官方内核编译时甚至会有一个空heap的配置。有些BSP默认只开启了system heap,DMA heap没开,你以为自己在用零拷贝,实际上ION内部可能走了额外的分配路径。排查起来非常隐蔽。

3.3 现代方案:直接使用/dev/dma_heap

好在从内核5.x开始,Linux主线推荐用/dev/dma_heap统一管理DMA-BUF分配器。RK3588的主线内核和较新的BSP内核都支持。我在项目后期把ION全部换成了dma-heap,代码清爽很多。

dma-heap的使用套路是固定的:

#include <linux/dma-heap.h> #include <linux/dma-buf.h> int heap_fd = open("/dev/dma_heap/system", O_RDWR); if (heap_fd < 0) { perror("open dma_heap failed"); return -1; } struct dma_heap_allocation_data heap_data; memset(&heap_data, 0, sizeof(heap_data)); heap_data.len = buffer_size; heap_data.fd_flags = O_CLOEXEC | O_RDWR; heap_data.heap_flags = 0; int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &heap_data); if (ret < 0) { perror("DMA_HEAP_IOCTL_ALLOC failed"); close(heap_fd); return -1; } int dma_fd = heap_data.fd;

这里特别提醒几点:

  • /dev/dma_heap/system对应系统堆,分配的内存不保证物理连续,但支持scatter-gather,DMA设备基本都能用。需要物理连续时选/dev/dma_heap/linux,cma。
  • fd_flags建议加O_CLOEXEC,防止fd被子进程意外继承。
  • 分配完的dma_fd就是标准的DMA-BUF fd,可以传给RGA/NPU/VPU等任何支持DMA-BUF的驱动。

在RK3588的边缘AI项目里,我最终推荐dma-heap而非ION,核心原因是维护状态清晰、接口稳定、且与NXP/TI等平台的习惯一致,代码迁移方便。

3.4 通过socket传递fd:SCM_RIGHTS实战

拿到dma_fd之后,怎么把它从采集进程交给推理进程?答案是用Unix域socket的SCM_RIGHTS辅助数据。这段代码我实测过,可以直接用:

// 发送端:传递 fd int send_fd(int sock_fd, int fd_to_send) { struct msghdr msg = {0}; char buf[1] = {'F'}; struct iovec io = { .iov_base = buf, .iov_len = sizeof(buf) }; char control[CMSG_SPACE(sizeof(int))] = {0}; msg.msg_iov = &io; msg.msg_iovlen = 1; msg.msg_control = control; msg.msg_controllen = sizeof(control); 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)); return sendmsg(sock_fd, &msg, 0); } // 接收端:接收 fd int recv_fd(int sock_fd) { struct msghdr msg = {0}; char buf[1] = {0}; struct iovec io = { .iov_base = buf, .iov_len = sizeof(buf) }; char control[CMSG_SPACE(sizeof(int))] = {0}; msg.msg_iov = &io; msg.msg_iovlen = 1; msg.msg_control = control; msg.msg_controllen = sizeof(control); if (recvmsg(sock_fd, &msg, 0) < 0) { return -1; } struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); if (cmsg && cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) { int fd; memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd)); return fd; } return -1; }

注意一个容易被坑的地方:发送fd时,套接字必须是AF_UNIX的SOCK_STREAM或SOCK_DGRAM,不能用TCP。我第一次就栽在这——试图用TCP socket传fd,结果服务端永远收不到。看内核源码才知道,SCM_RIGHTS辅助数据只在AF_UNIX协议族里实现,TCP的sendmsg会直接丢弃这个控制信息。

4. 共享内存方案:纯CPU场景下的高性能通信

4.1 什么时候必须退回到共享内存

DMA-BUF方案听起来很完美,但有个实际限制:并不是所有数据都天然有DMA-BUF对象。比如你在用户态用OpenCV处理完一帧图像、生成一个检测结果结构体、要传递给显示进程做叠加绘制,这些数据如果强行分配DMA-BUF,要么浪费物理连续内存(CMA),要么得走一堆没必要的ioctl。

这种情况下,用经典的POSIX共享内存(shm_open + mmap)或者匿名共享内存(memfd_create)更直接。

这里说下我项目的实际分配:图像帧和NPU输入输出用DMA-BUF,推理结果(检测框坐标、类别、置信度)和控制命令用共享内存环形缓冲。两种方案各有各的主场,不冲突。

4.2 memfd_create:比shm_open更安全的共享内存

传统的shm_open需要指定一个名字,在/dev/shm下创建文件,多进程通过名字打开。这在同一台设备上没问题,但名字管理有点烦,而且创建出来的文件是全局可见的,安全性略差。

一个更干净的方式是用memfd_create——它创建的是一个完全匿名的共享内存对象,只返回一个fd:

#include <sys/mman.h> #include <linux/memfd.h> #include <sys/syscall.h> int shm_fd = syscall(SYS_memfd_create, "vision_shm", MFD_CLOEXEC); if (shm_fd < 0) { perror("memfd_create failed"); return -1; } // 设置大小 if (ftruncate(shm_fd, SHM_SIZE) < 0) { perror("ftruncate failed"); close(shm_fd); return -1; } // 映射到本进程地址空间 void *addr = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0); if (addr == MAP_FAILED) { perror("mmap failed"); close(shm_fd); return -1; }

注意,memfd_create在较老的glibc里可能没有封装,我代码里直接用了syscall来调用系统调用。这个fd同样可以通过SCM_RIGHTS传给其他进程,接收方mmap后就能读写同一块内存了。

匿名共享内存的好处是:进程退出时,fd关闭,内存自动释放,没有残留文件问题。多个进程共享同一个memfd,只要在创建后fork,子进程天然继承这个fd,不需要额外的socket传递。我们这个项目采集进程fork出了推理子进程,所以直接用继承的方式,连SCM_RIGHTS都省了。

4.3 无锁环形队列:多生产者消费者的数据同步

共享内存解决了“数据不拷贝”的问题,但没解决“谁先读谁先写”的同步问题。我在这里用的是无锁SPSC(单生产者单消费者)环形队列。

核心数据结构设计如下:

#define BUFFER_COUNT 4 #define BUFFER_SIZE (3 * 1024 * 1024) // 1080p YUV420 大小 typedef struct { int32_t head; // 写入位置(生产者维护) int32_t tail; // 读取位置(消费者维护) int32_t buffer_size; int32_t data_len[BUFFER_COUNT]; int64_t timestamp[BUFFER_COUNT]; char padding[64]; // 避免伪共享 char data[BUFFER_COUNT][BUFFER_SIZE]; } SharedRingBuffer;

生产者逻辑:

  1. 申请空闲buffer,写入图像数据
  2. 写入完成后,更新data_len[slot]和timestamp[slot]
  3. 更新head = (head + 1) % BUFFER_COUNT,加内存屏障(__sync_synchronize())
  4. 更新head时用release语义,确保消费者看到完整数据

消费者逻辑:

  1. 读取head,判断head != tail
  2. 如果相等说明没有新帧,轮询或睡眠等待
  3. 读取data_len[slot],然后读图像数据
  4. 读取完毕后更新tail = (tail + 1) % BUFFER_COUNT

这里有个关键细节必须强调:更新head之前必须保证图像数据完全写入内存。因为head的更新是消费者判断数据是否可用的唯一依据。如果生产者写完数据后没有做内存屏障就直接改head,消费者可能看到head已经变了,但图像数据还在CPU缓存里没落内存,读出乱码。

RK3588是ARM架构,内存模型是弱一致性。我在生产者的写数据末尾和head更新之间,加了一个__sync_synchronize()全屏障,消费者在读取data_len之前也加了acquire语义的屏障。这个细节没处理好的话,跑久了会出现偶发花屏,特别难查。

实测下来,这套无锁环形队列在RK3588 A76核心上,单帧1080p YUV420数据的传递延迟在微秒级,CPU占用几乎可以忽略。

5. DMA-BUF的缓存同步与mmap细节,搞不定就花屏

5.1 为什么DMA-BUF需要显式sync

DMA-BUF映射到用户态后,你可能会直接读写它的内存。但在DMA设备(比如NPU、RGA)访问这块内存之前,必须保证CPU的写操作已经刷出缓存;反过来,DMA设备写完内存后,CPU再读之前,必须使缓存失效。否则,CPU读到的是旧数据。

这就像交接班:CPU在写字楼里写完一份文件放在桌上,DMA设备是另一个楼里的工人,他必须等文件被放到文件柜(内存)里才能拿;他放回文件后,CPU也得重新从文件柜取,而不是相信自己桌面上的旧副本。

DMA-BUF提供了一套同步接口,在/usr/include/linux/dma-buf.h里定义:

#include <linux/dma-buf.h> struct dma_buf_sync sync_args; memset(&sync_args, 0, sizeof(sync_args)); sync_args.flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_RW; int ret = ioctl(dma_fd, DMA_BUF_IOCTL_SYNC, &sync_args); // 访问数据... sync_args.flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_RW; ret = ioctl(dma_fd, DMA_BUF_IOCTL_SYNC, &sync_args);

开始访问前调用DMA_BUF_SYNC_START,结束访问后调用DMA_BUF_SYNC_END。内核会在这两个时刻执行cache flush或invalidate。

5.2 RK3588上的一个现实问题:谁负责sync

这里有个容易迷惑的点。你在应用层用V4L2采集图像,通过VIDIOC_EXPBUF导出的dma_fd,其实已经在驱动内部做过必要的cache操作了。V4L2驱动的buffer管理模块会在帧完成(dequeue)时确保数据对CPU可见。所以如果你只是把V4L2的dma_fd直接mmap读取,通常不需要自己调sync。

但如果你把这个dma_fd传给RGA做缩放,RGA驱动在访问buffer时,dma_buf框架会自动处理attach和sync。具体机制是:RGA驱动调用dma_buf_begin_cpu_access或类似的函数,告诉内核“我要在这个方向访问这个buffer”。如果你走的是用户态librga,它会封装这些细节。

真正踩坑的地方在NPU。Rockchip的rknn_api对输入输出buffer的处理比较特殊。如果输入是dma_fd映射出来的内存,rknn的内部实现会对这个buffer做cache flush——但前提是你得用rknn_create_mem_from_fd这类接口,让NPU驱动知道这是一个DMA-BUF对象。如果你直接用mmap后的用户态指针传给rknn_inputs,NPU驱动会把它当作普通内存,走默认的拷贝路径,零拷贝效果就没了。

我在项目里实际用的NPU零拷贝接口长这样:

// 假设 dma_fd 是图像buffer的fd,buf_size是大小 rknn_tensor_mem *input_mem = rknn_create_mem_from_fd(ctx, dma_fd, buf_size, 0); if (input_mem == NULL) { printf("rknn_create_mem_from_fd failed\n"); return -1; } rknn_input inputs[1]; memset(&inputs, 0, sizeof(inputs)); inputs[0].index = 0; inputs[0].type = RKNN_TENSOR_UINT8; inputs[0].size = buf_size; inputs[0].fmt = RKNN_TENSOR_NHWC; inputs[0].buf = input_mem->virt_addr; // 直接使用映射地址 inputs[0].pass_through = 0; ret = rknn_inputs_set(ctx, 1, inputs); // 推理 ret = rknn_run(ctx, NULL);

这里rknn_create_mem_from_fd是关键,它让NPU驱动知道这块内存是DMA-BUF,驱动内部会建立SMMU映射(IOMMU),NPU直接通过IOMMU访问物理内存,CPU和NPU共享同一份数据。而如果你只是把普通用户态内存传给NPU,哪怕数据是同一份内容,NPU也得先把数据拷入自己的内部buffer,性能差距很大。

5.3 mmap DMA-BUF时的偏移量和页对齐

DMA-BUF的mmap有一个容易被忽视的问题:偏移量。mmap(dma_fd, size, PROT_READ|PROT_WRITE, MAP_SHARED, 0)中最后一个参数offset,必须对应dma_buf内部定义的偏移。对于大多数情况(整个buffer映射),offset为0是对的。但如果这个dma_buf是某个大buffer的一部分(比如从POOL里切出来的),offset可能不是0。

RK3588的dma-heap分配器返回的fd,一般offset就是0,从0到len都是有效范围。但你如果用的是V4L2的export buffer,注意V4L2的v4l2_exportbuffer结构体里有一个plane_index字段。对多plane格式(比如NV12的Y和UV分离),可能需要export出多个fd。

我在使用V4L2导出NV12帧时,遇到过一个花屏问题,排查了很久发现是只export了plane 0,UV plane的数据没有映射,图像下半部分全是绿的。后来把plane 0和plane 1分别export,并分别传入RGA和NPU,问题才解决。

struct v4l2_exportbuffer expbuf; memset(&expbuf, 0, sizeof(expbuf)); expbuf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; expbuf.index = buf_index; expbuf.plane = plane_index; // 必须遍历所有plane expbuf.flags = O_RDONLY; int ret = ioctl(fd, VIDIOC_EXPBUF, &expbuf); if (ret == 0) { int dma_fd = expbuf.fd; // 传递dma_fd到消费进程 }

5.4 多进程同一DMA-BUF的refcount

DMA-BUF fd在多个进程之间传递时,每个fd都对应同一个struct dma_buf对象,内核会维护引用计数。发送进程关闭fd,只要接收进程还持有着,buffer就不会释放。这一点是安全的。

但有一个场景要注意:如果发送进程退出时,接收进程还拿着fd,但内核驱动已经释放了原始buffer(比如V4L2 streamoff之后),接收进程再mmap访问就是访问已释放的内存,表现为段错误或访问非法地址。

我在项目里用了一个简单协议:采集进程退出前,通过socket给所有消费进程发一个“再见”消息,消费进程收到后主动close对应的dma_fd,再执行清理。这比依赖内核引用计数更可控——引用计数保证的是“不崩溃”,协议保证的是“来得及优雅处理”。

6. 整套方案的工程架构与实测数据

6.1 进程角色划分

我们用四个进程来解耦这条视觉流水线:

进程职责与数据的关系
sensor_processV4L2采集、DMA-BUF导出生产者,持有ISP buffer fd
preprocess_processRGA缩放、格式转换、ROI裁剪消费者/生产者,接收采集fd,输出处理fd
infer_processRKNN NPU推理、后处理消费者,接收预处理结果fd
output_process显示、RTSP推流、结果叠加消费者,接收推理结果fd与图像fd

进程间的所有图像数据都基于DMA-BUF fd传递,推理结果这种小数据用memfd共享内存 + 无锁队列。每个进程启动时建立一对AF_UNIX socket通道,用于fd传递和控制消息。

6.2 关键流程串联:从相机帧到推理框

实际运行时的数据流大概是这样的:

  1. sensor_process通过V4L2请求4个buffer,循环VIDIOC_QBUF和VIDIOC_DQBUF。
  2. 拿到一帧后,VIDIOC_EXPBUF导出dma_fd,通过SCM_RIGHTS发送给preprocess_process。
  3. preprocess_process接收到dma_fd后,调用librga的接口,把图像缩放成YOLO输入尺寸(例如640x640),RGA输出也是dma_fd。
  4. RGA输出fd传给infer_process,由rknn_create_mem_from_fd构建NPU输入,运行推理。
  5. 推理结果(检测框、类别、置信度)写入memfd共享内存,通过无锁队列通知output_process。
  6. output_process从共享内存读取检测结果,用CPU绘图API从DMA-BUF映射区域读取原图像,绘制box后推流。

注意第6步里,output_process需要读取原图像来画框。这里会有一个cache一致性问题:画框时CPU直接读写mmap内存没问题,但推流编码器(VPU)要访问这块内存时,需要在编码前加一次sync。如果用的是Rockchip的MPP库,它会要求传入一个buffer fd,内部完成同步。

6.3 实测:传统拷贝 vs 零拷贝

我在同一个RK3588平台上分别用两种方式做了对比实验。测试条件是:1080p30 MIPI输入,YOLOv8s模型(640x640输入),四进程架构,输出RTSP。

指标传统拷贝方案(malloc+memcpy)零拷贝方案(DMA-BUF+共享内存)
CPU占用(图像数据搬运)约28%(四核A76平均)约4%
端到端延迟(采集到输出)平均68ms,抖动±15ms平均42ms,抖动±4ms
帧率稳定性25~30fps波动稳定30fps(V4L2帧率上限)
内存占用每进程各存一份图像副本整个链路只存2~3份buffer

最明显的差异在CPU占用和延迟抖动上。传统方案里CPU有近三成算力在搬数据,边缘设备上NPU在跑,内存带宽本来就不宽裕,多出来的拷贝开销直接影响其他任务。零拷贝方案下,CPU只在SCM_RIGHTS传递和结果解析上花费极少量时间,大部分算力留给业务逻辑。

6.4 我踩过的一个严重问题:内存带宽成为瓶颈

有朋友可能觉得,零拷贝之后性能就完美了。其实还有一个隐藏瓶颈——DDR带宽。

RK3588的内存带宽是有限的,多路视频流同时读写时,DDR带宽会被占满。我这里做过一个压力测试:同时开启两路4K30的MIPI输入、一路1080p30 RTSP推流、NPU同时跑两个模型。纯零拷贝已经消除了CPU拷贝,但ISP写DDR、RGA读DDR写DDR、NPU读DDR、VPU编码读DDR,几路操作同时压上去,DDR忙等大幅增加,实测帧率下降约15%。

这个问题的解决思路不是从通信层面下手,而是要在架构设计时考虑尽量减少数据在DDR中的中间态。比如RGA做缩放时可以直接指定ISP输出作为输入、显示buffer作为输出,一步到位;NPU推理结果直接在显存里做后处理(RKNN的zero copy后处理接口),避免把结果拷回CPU再看一遍。这些都是后续优化的方向,但在通信设计时提前架构,能少走弯路。

7. 调试工具与常用排查方法

7.1 用节点日志验证DMA-BUF是否真的生效

零拷贝失败最典型的症状是:代码逻辑没错,数据也是对的,但性能没提升。这时需要确认dma_fd是否真的传递成功,以及NPU/RGA是否真的用了DMA-BUF路径。

我常用的调试技巧是在关键节点打印fd数值和/proc/self/fd。确认接收进程的fd确实指向同一个dma_buf对象,可以看/sys/kernel/debug/dma_buf/bufinfo(需要root权限),里面列出了所有dma_buf的引用计数和exp_name。

$ cat /sys/kernel/debug/dma_buf/bufinfo Dma-buf Objects: size flags mode count exp_name 3145728 0x00000000 0x00000000 2 rkisp

如果count为1,说明fd没传递成功或对方已经关闭;如果exp_name是你预期的驱动名(这里是rkisp),说明buffer确实来自ISP。

7.2 排查fd传递失败的三种情况

  1. SCM_RIGHTS返回成功但接收端收到-1:检查socket类型是否AF_UNIX,TCP绝对不行。
  2. 接收端mmap成功但读到全零:检查发送端是否在发送前调用了VIDIOC_QBUF把buffer还给驱动了——如果已经重新入队,ISP可能会覆写这块内存,读到全零说明buffer已经被重用。
  3. RGA或NPU报参数错误:检查dma_fd是否直接来自V4L2但buffer是没有经过VIDIOC_REQBUFS的——有些驱动导出fd的时机有要求,必须是在buffer已入队且驱动分配完成之后。

7.3 使用perf和ftrace定位性能热点

如果零拷贝跑起来性能还是不及预期,我建议用perf top先看CPU热点分布。如果DMA-BUF方案里CPU占用还居高不下,往往不是拷贝问题,而是页错误或cache miss。perf stat -e dTLB-load-misses,dTLB-store-misses能验证是否有严重的TLB压力。

另一个实用工具是ftrace的dma_buf事件。内核里dma_buf的attach/detach/map/unmap都有tracepoint,你可以在运行前打开:

echo 1 > /sys/kernel/debug/tracing/events/dma_buf/enable echo 1 > /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace

这能看到每个进程对dma_buf的map/unmap调用次数,如果某个进程频繁map/unmap,说明你的mmap生命周期管理有问题——应该一次映射长期复用,而不是每一帧都重新map。

8. 一套适用于大多数视觉项目的零拷贝通信抽象

说了这么多,最后我在项目里沉淀下来一套可复用的抽象,分享给打算自己动手的朋友。

8.1 接口设计:屏蔽fd传递与mmap细节

我不建议在每个业务代码里直接写SCM_RIGHTS和mmap,而是抽象出一个简单的消息层。

// vision_ipc.h typedef struct { int dma_fd; // DMA-BUF fd,-1表示不使用 void *virt_addr; // mmap后的虚拟地址 size_t size; int64_t timestamp_ns; uint32_t frame_id; uint32_t width, height, format; } VisionBuffer; // 服务端(生产者)接口 int vision_ipc_server_create(const char *name); int vision_ipc_server_send_buffer(int server_fd, VisionBuffer *buf); // 客户端(消费者)接口 int vision_ipc_client_connect(const char *name); int vision_ipc_client_recv_buffer(int client_fd, VisionBuffer *buf); void vision_ipc_buffer_unmap(VisionBuffer *buf);

底层逻辑是:服务端把dma_fd通过SCM_RIGHTS发送,同时把宽高格式帧号等元数据作为常规消息内容一起送过去;客户端收到后自动mmap,填充virt_addr。用完调用unmap。

这样业务层就不用关心fd还是指针,只需要处理VisionBuffer结构体。以后要扩展新的数据类型(比如深度图、点云),只需要加字段,不用改传输框架。

8.2 buffer生命周期建议:池化而非按需分配

一个特别重要的工程经验:不要每帧都分配新DMA-BUF,DMA-BUF分配是有代价的。内核要分配物理页、建立映射关系,这个过程虽然比memcpy快,但也远不是免费的。

正确做法是初始化时分配一个buffer池(比如5~8个buffer),循环使用。生产者每次从池里取一个,写完发送给消费者,消费者处理完归还给池子。这跟V4L2的QBUF/DQBUF循环是一个道理。

我项目里分配了6个1080p YUV420的buffer,够采集、预处理、推理三四个进程之间周转,实测没有任何buffer不足的抖动。

8.3 关于CPU亲和性和中断绑定的补充

零拷贝通信本身已经让CPU占用很低了,但如果想进一步追求实时性,可以把sensor_process绑定到A55核心(低功耗),把infer_process绑定到A76核心(高算力)。同时把V4L2的中断绑到相应核心上,减少跨核缓存同步开销。

这一步不是必须的,但在边缘AI场景(比如带运动控制的视觉引导)里,延迟抖动指标比平均延迟更重要,中断绑定能明显减少抖动。我实测把ISP中断绑到CPU2后,采集到输出延迟的p99从48ms降到了40ms,效果立竿见影。

9. 写在最后:零拷贝不是银弹,但它是标配

RK3588上的零拷贝跨进程通信,核心就三件事:用DMA-BUF fd传递硬件buffer、用共享内存传小数据、用无锁队列做同步。这三件事组合起来,能解决边缘AI视觉项目里90%以上的数据搬运问题。

但每颗芯片、每套BSP都有细微差异。同样一段代码,在RK3588的10.0 SDK和11.0 SDK上表现就可能不同,librga和librknnrt的版本更新也会影响接口行为。我做项目时的习惯是:跑通第一版后,立刻把关键接口(rknn_create_mem_from_fd、VIDIOC_EXPBUF、dma_heap ioctl)的日志打印出来,确认版本行为,再固化到代码里。这比到线上排查快得多。

最后分享一个调试小技巧:在开发阶段,我给每个dma_fd的创建和传递都加了一段哈希校验——不是校验内容,而是校验buffer的物理地址信息(可以通过DMA_BUF_IOCTL_SYNC访问前后的数据特征推断)。如果哈希对不上,说明数据拷贝路径里被哪个进程偷偷复制过了,这时回去查你的驱动版本和API用法,通常都能发现“零拷贝没有真正生效”。等你确信所有环节都验证过,再把这个开关关掉,换来的就是一套既能跑得快、也经得起追踪的通信骨架。

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

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

立即咨询