如果你在RK3588上做过视觉AI,大概率遇到过这个情况:点完YOLO推理接口,打印出来单次推理只要十几毫秒,但整条pipeline跑下来一帧却要三十多毫秒,CPU占用率高得吓人。干过这件事的都懂,瓶颈往往不在NPU本身,而在数据从摄像头搬到NPU的这一整条路上。
RK3588这颗芯片的NPU算力在边缘设备里算相当能打,但很多人在上面跑视频识别时,第一个版本都是“V4L2读到CPU内存 → memcpy拼接 → 再拷给NPU”,结果就是CPU被大量内存拷贝拖死,摄像头帧率稍高一点就开始丢帧,AI推理的延迟也忽高忽低。这篇文章就好好讲清楚,怎么用Linux内核的DMA-BUF机制,在RK3588上把V4L2摄像头采集到的帧直接喂给NPU,全程不经过CPU拷贝,实现真正意义上的零拷贝数据流。我会从原理、接口、代码到踩坑,一条条掰开揉碎讲,保证你看完能自己搭一套出来。
1. 为什么非要用DMA-BUF做零拷贝
先说句实在话:很多做嵌入式Linux开发的朋友,对V4L2的熟悉程度可能只停留在“打开设备、设置格式、read一帧”的层面上。read每帧是什么概念?摄像头驱动在内核里准备一片内存,CPU把它拷贝到用户态缓冲区,你处理完再拷给NPU驱动,NPU拿到的是另一份拷贝。一次采集,光拷贝就有好几趟,而且不止CPU在忙,内存带宽也被白白浪费。
1.1 传统链路里每一份拷贝都在烧CPU
我们可以算一笔很粗略的账。以常见的1080p NV12格式为例,一帧画面大约1920×1080×1.5字节,约3MB。如果你的pipeline是V4L2用read方式读帧,再经过RGA或CPU转成RGB,再通过rknn_api把数据交给NPU,中间发生的数据搬运量轻松超过6MB到10MB。30fps就是每秒接近300MB的搬运量。这个量级在PC上不算什么,但在RK3588这类嵌入式SoC上,CPU核心本身还要跑RTSP推流、业务逻辑、GUI或者别的算法,几下就被拖满,最终结果就是高温降频、画面卡顿、推理节奏不稳定。
有人会想:那用mmap总行了吧?V4L2的mmap模式只是避免了内核态到用户态的read拷贝,数据最终还是留在CPU分配的物理内存里。NPU驱动要拿这份数据时,依然要通过CPU再搬一次。所以mmap只是把read的一部分开销省了,真正的跨设备拷贝问题并没有解决。
1.2 DMA-BUF到底在解决什么问题
DMA-BUF是Linux内核提供的一种跨设备共享内存的通用机制。它的思路是把一块“能被DMA访问的内存”包装成一个文件描述符(fd),这个fd可以在进程间传递,也可以在不同驱动之间传递。摄像头驱动拿到一块buffer,导出成fd;NPU驱动拿到同一个fd,通过IOMMU把它映射到NPU能访问的地址空间。两边操作的是同一块物理内存,谁都不需要把数据从自己的地址空间复制到对方的地址空间。
这个过程可以类比成:你快递一个U盘,以前是先把U盘里的资料复制到电脑,再把复制出来的资料拷到另一个U盘,最后寄出去。现在改成直接把U盘交给对方,中间不需要任何中转拷贝。DMA-BUF就是这个U盘本身。
1.3 RK3588的硬件条件对零拷贝很友好
RK3588集成了ISP、NPU、RGA、VPU等多个多媒体模块,这些模块之间通过IOMMU连接,天然支持共享物理内存。Rockchip的NPU驱动(rknpu2)也直接支持外部DMA-BUF导入。也就是说,内核和硬件两边都给你铺好了路,你只需要把V4L2的buffer通过DMA-BUF导出,再让NPU识别这个fd作为输入,CPU就可以彻底从数据搬运里解放出来。
这里有个关键认知:零拷贝不是某个驱动单独就能实现的,必须是“采集端导出”和“算力端导入”两端配合。V4L2驱动要支持EXPBUF,NPU驱动要支持DMA-BUF类型的输入。好消息是,RK3588官方SDK里的摄像头驱动(比如rkisp)和NPU用户态库都支持这套逻辑,所以方案落地性很强。
2. 先搭好地基:V4L2采集端的DMA-BUF导出
我们要做的第一件事,是把V4L2的采集buffer从“用户态可见的mmap内存”变成“可以传递给其他设备的dma-buf fd”。在RK3588平台上,一般用Media Controller框架配置摄像头链路,然后通过V4L2的video节点采集。这里有个概念要先分清:V4L2本身有两种跟DMA-BUF相关的方向,一个是导出(EXPBUF),一个是导入(DMABUF导入方式)。我们的场景是摄像头出帧给NPU,所以用导出就对了。
2.1 设置格式并申请DMA-BUF缓冲区
摄像头采集格式一般选择NV12,也就是YUV420sp,这是RK3588 ISP和NPU都比较友好的格式。很多模型输入需要RGB,但NPU在rknpu2里可以配置输入格式,如果模型需要RGB,建议中间用RGA做一次格式转换,而不要把NV12先拷到CPU再手动转RGB,否则零拷贝的意义就少了一半。
申请buffer时,内存类型必须选择V4L2_MEMORY_MMAP。这听起来有点反直觉,因为我们要的是DMA-BUF,为什么还用MMAP?其实V4L2驱动的流程是:你用MMAP方式申请buffer,驱动分配一组物理连续或经过IOMMU映射的内存,然后你通过VIDIOC_EXPBUF接口把这块内存导出成一个dma-buf fd。这个fd就是后续要传给NPU的核心句柄。
struct v4l2_requestbuffers req; memset(&req, 0, sizeof(req)); req.count = 4; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; req.memory = V4L2_MEMORY_MMAP; ioctl(fd_video, VIDIOC_REQBUFS, &req);2.2 用VIDIOC_EXPBUF拿到真正能共享的fd
申请完buffer后,每个buffer index对应一个或者多个plane。对NV12来说,单plane布局在rkisp驱动下比较常见,但有些驱动会把它拆成Y和UV两个plane。为了兼容多plane,代码里建议通过V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE配合plane_fd数组来处理。不过对RK3588的官方摄像头链路,我建议你在实际板子上确认一下当前驱动导出的是单fd还是多fd。如果导出来就是单fd,后面传给NPU就非常省事;如果拿到两个fd,需要额外决定怎么合并,这个我放到后面问题排查里细说。
导出fd的代码很简单:
struct v4l2_exportbuffer expbuf; memset(&expbuf, 0, sizeof(expbuf)); expbuf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; expbuf.index = buffer_index; expbuf.plane = 0; ioctl(fd_video, VIDIOC_EXPBUF, &expbuf); int dma_fd = expbuf.fd;拿到dma_fd之后,你就拥有了一份指向摄像头内存的dma-buf文件描述符。这个fd可以dup,也可以在进程间通过Unix socket的SCM_RIGHTS机制传递,甚至可以直接交给其他内核驱动。如果你只是在一个进程里把fd交给NPU,那什么都不用做,直接传给rknn_api即可。
2.3 采集循环里的核心动作:QBUF与DQBUFF
V4L2采集的循环模式是:“把buffer放进队列 → 等待硬件采集完成 → 从队列取出来”。每一次循环你都要保证手中的fd对应的是当前正在处理的那一帧。实际操作中,经常需要在dqueue拿到buffer index后,把对应的dma_fd传给NPU做推理,推理完成后立刻把同一个fd对应的buffer重新queue回去,这样才不会因为buffer池耗尽导致采集停滞。
struct v4l2_buffer buf; struct v4l2_plane planes[VIDEO_MAX_PLANES]; memset(&buf, 0, sizeof(buf)); memset(planes, 0, sizeof(planes)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; buf.memory = V4L2_MEMORY_MMAP; buf.m.planes = planes; buf.length = VIDEO_MAX_PLANES; ioctl(fd_video, VIDIOC_DQBUF, &buf); // 拿到 buf.index,用对应的 dma_fd 做 NPU 推理 ioctl(fd_video, VIDIOC_QBUF, &buf);这里有个非常重要的细节:不要在用完fd之后顺手close它。V4L2的dma-buf fd生命周期由你控制,但如果你在推理还没结束时就close,NPU那边拿到的是一份悬空的引用,轻则画面缺损重则内核panic。正确的做法是,每次dqueue拿到新的buffer后,确保该buffer对应的dma_fd已经duplicate一份给推理线程,或者使用栅栏同步保证推理线程完全用完再close。
3. NPU侧接手:用rknn_api直接消费DMA-BUF
RK3588的NPU在用户态通过librknnrt.so提供的rknn_api来访问,这套接口最棒的一点是原生支持“DMA-BUF输入”。也就是说,你可以直接构造一个rknn_input,里面放的不是CPU指针,而是dma-buf fd。NPU驱动会在内部通过IOMMU把这块内存映射到NPU的地址空间,推理时直接由硬件读取,CPU全程不碰数据。
3.1 初始化NPU上下文和模型
初始化这部分和其他rknn调用没有区别。先rknn_init加载模型,然后rknn_query获取输入输出的shape和size。唯一需要额外注意的是,你要确认模型本身的输入格式。比如经典YOLOv5的输入是RGB、640×640、NCHW排列。如果摄像头输出的是NV12,你先得明确中间要不要做RGA转换。如果用RGA,那RGA的输出buffer也要用dma-buf来承接,形成“摄像头 → RGA → NPU”的第二条零拷贝链路。
rknn_context ctx; rknn_init(&ctx, model_data, model_size, 0, NULL); rknn_input_output_num io_num; rknn_query(ctx, RKNN_QUERY_IN_OUT_NUM, &io_num, sizeof(io_num));3.2 把DMA-BUF fd作为输入传给NPU
给NPU传数据的时候,关键就是要设置buf_type。默认情况下rknn_input的buf_type是RKNN_INPUT_NORMAL,表示输入是一个普通CPU内存指针。我们改成RKNN_INPUT_DMA_BUF,然后把buf字段填成dma_fd的值。这个地方很多人第一次接触会非常迷惑:明明buf是void *类型,怎么塞一个int进去?这里其实是接口层面有意为之的常见做法,把文件描述符强转成指针传入,底层会重新解析成整数fd。
rknn_input in; memset(&in, 0, sizeof(in)); in.index = 0; in.type = RKNN_TENSOR_UINT8; in.fmt = RKNN_TENSOR_NCHW; in.size = 640 * 640 * 3; in.w = 640; in.h = 640; in.buf_type = RKNN_INPUT_DMA_BUF; in.buf = (void *)(intptr_t)dma_fd; in.pass_through = 0; rknn_inputs_set(ctx, 1, &in);传给DMA-BUF的fd不需要是CPU能直接访问的内存地址,所以这里也别去想“要不要先mmap一下”。NPU驱动和设备会自己搞定地址映射。如果你在dma_buf的fd上又做了一次mmap再传给NPU,反而可能引入cache一致性问题。
3.3 推理后的结果回收
rknn_output这一环通常走CPU内存就够了,因为模型输出的数据量很小,几个box加上类别置信度,拷贝开销可以忽略不计。如果你遇到的是分割模型,输出是一整张特征图,那就另说。分割模型的大输出确实可以继续走DMA-BUF回读,通过rknpu2的RKNN_OUTPUT_DMA_BUF方式让CPU只读取最终结果区域的少量像素,或者配合RGA做后处理。不过这个属于进阶话题,多数检测场景完全不需要把整张输出拷回CPU。
rknn_output outputs[1]; memset(outputs, 0, sizeof(outputs)); outputs[0].want_float = 1; rknn_outputs_get(ctx, 1, outputs, NULL); // 解析 outputs[0].buf 里的检测框 rknn_outputs_release(ctx, 1, outputs);4. 手把手串联主流程:从摄像头到NPU的一整条零拷贝链路
前面的准备工作都拆开讲了,现在把它们拼成一个完整可运行的流程。以下代码是精简过的伪代码和核心片段,实际工程里你还需要加上错误处理、线程同步和生命周期管理。先看整体架构:一个采集线程负责DQBUF拿到帧buffer,把对应的dma_fd扔给推理线程;推理线程用rknn_inputs_set做推理,完成后回调业务逻辑,再把buffer归还给采集队列。
4.1 采集线程与推理线程的fd交接
多线程下最核心的问题是如何安全地传递fd。推荐的做法是,在初始化摄像头时就把4个buffer对应的fd都先dup一份,后续通过环形缓冲或队列在采集线程和推理线程之间传递buffer index和fd映射,而不要每次dqueue的时候临时dup和close。因为fd的创建和销毁本身也有系统调用开销,在高帧率下会吃掉不少性能。
int fd_map[4]; for (int i = 0; i < req.count; i++) { struct v4l2_exportbuffer expbuf; memset(&expbuf, 0, sizeof(expbuf)); expbuf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; expbuf.index = i; expbuf.plane = 0; ioctl(fd_video, VIDIOC_EXPBUF, &expbuf); fd_map[i] = dup(expbuf.fd); close(expbuf.fd); }推理线程拿到的其实是fd_map[buf.index],这个fd在初始化时就已经固定好了。不过要注意,同一块内存在某个时刻只能在被NPU读取时,不能同时被摄像头硬件写入。所以一定要保证:某个buffer被dqueue出来后,你才把它交给NPU;等NPU推理完成后,这个buffer才能重新入队给摄像头。
4.2 主循环的完整代码骨架
下面是一段可以跑通的流程框架,我在关键位置加了注释。你可以把它改造成自己的项目代码。
// 摄像头初始化 int fd_video = open("/dev/video0", O_RDWR); set_format(fd_video, 1920, 1080, V4L2_PIX_FMT_NV12); request_buffers(fd_video, 4); // 把所有buffer入队 for (int i = 0; i < 4; i++) { queue_buffer(fd_video, i); } // NPU初始化 rknn_context ctx; rknn_init(&ctx, model_data, model_size, 0, NULL); while (running) { struct v4l2_buffer buf; struct v4l2_plane planes[VIDEO_MAX_PLANES]; // 取一帧 dequeue_buffer(fd_video, &buf, planes); int buffer_index = buf.index; int dma_fd = fd_map[buffer_index]; // 直接用dma_fd做NPU推理 rknn_input in; memset(&in, 0, sizeof(in)); in.index = 0; in.type = RKNN_TENSOR_UINT8; in.fmt = RKNN_TENSOR_NCHW; in.size = 640 * 640 * 3; in.w = 640; in.h = 640; in.buf_type = RKNN_INPUT_DMA_BUF; in.buf = (void *)(intptr_t)dma_fd; in.pass_through = 0; rknn_inputs_set(ctx, 1, &in); rknn_output outputs[1]; memset(outputs, 0, sizeof(outputs)); outputs[0].want_float = 1; rknn_outputs_get(ctx, 1, outputs, NULL); handle_inference_result(outputs[0].buf); // 业务处理 rknn_outputs_release(ctx, 1, outputs); // 推理完成,归还buffer给摄像头 queue_buffer(fd_video, buffer_index); }这段代码有几点需要强调:第一,dqueue和queue之间的时间就是这一帧的处理时间,处理时间越长,可用buffer越少,越容易出现采集线程等待。所以推理这部分尽量用单独的线程池来做,不要让采集线程阻塞太久。第二,如果你用的是rkisp的vid-capture节点,可能还涉及到V4L2的事件订阅和Media Controller的link设置,这些步骤建议照搬SDK里Camera模块的初始化逻辑。
4.3 如果模型输入是RGB怎么办
很多模型训练用的输入是RGB,但摄像头直接输出NV12。这时候你仍然可以保持零拷贝,做法是借助RGA硬件做格式转换。RGA的输出buffer也用dma-buf申请,让NPU直接消费RGA的产物。整体链路变成V4L2 → RGA → NPU,全程硬件搬运,不经过CPU。
RGA通过librga库操作,常用接口是rga_blit。要让它输出到dma-buf,你可以在RGA初始化时用dma_heap或ion申请一块buffer,把它的fd作为输出传给rga_blit,然后再把这个fd给NPU。在某些SDK版本里,RGA也支持直接传V4L2导出的dma-buf fd作为输入,这样连接起来非常顺手。不过要注意,RGA对内存对齐要求比较高,尤其是宽高和stride对齐,通常需要16字节对齐,条纹不对齐会出现绿边或者画面偏移。
如果你图省事,也可以在模型输入层前面加一个转换层,把模型改成RGB输入但实际喂NV12,然后让NPU驱动做隐式转换。但RK3588的NPU对NV12的直接支持有限,大多数模型还是乖乖走RGA转换比较稳。实测下来,RGA完成1080p NV12到640×640 RGB的缩放转换大概只要一两毫秒,比CPU转换快了一个数量级。
5. 性能实测与避坑实录
零拷贝链路搭好以后,性能提升是非常明显的。但实际工程里你还会遇到一堆奇奇怪怪的问题,这里我把踩过的坑和排查方法整理一下,按出现频率排个序。
5.1 实测数据:这套方案到底能省多少
我在一块RK3588开发板上做了个简单对比。同样跑YOLOv5s模型,输入640×640,摄像头1080p@30fps NV12输出。
传统readframe方式:CPU占用率在35%到45%之间,内存带宽占用明显,实际推理帧率只有18到22fps,而且偶尔出现因为拷贝导致的花屏和帧延迟抖动。
DMA-BUF零拷贝方式:CPU占用率降到15%以下,推理帧率稳定在30fps,整条链路的端到端延迟从40毫秒降低到25毫秒左右。这个项目还叠加了RTSP推流,推流模块单独走VPU硬编,整体系统负载很低。
如果只测V4L2到NPU这一段,不包含模型推理耗时,传统方式单帧搬运大约耗时3到5毫秒,零拷贝方式则不到0.5毫秒,省下来的CPU时间足够再做一路ISP或者跑一个轻量检测模型。这里我提醒一句:不同版本SDK、不同摄像头模组、不同内核配置下数字会有浮动,但量级趋势是一样的。
5.2 我踩过的5个坑
第一个坑:V4L2导出的fd在NPU推理时表现为画面花屏或者数据错位。这个问题大概率是缓存一致性问题。如果你的内核开了DMA-BUF的sync接口,某些情况下需要显式调用dma_buf_begin_cpu_access之类的接口,但嵌入式Linux中大部分驱动会在内部处理好,所以出现花屏先怀疑是不是同一个fd被多个设备同时访问了。我遇到过一次,原因是摄像头还在queue状态时,NPU已经拿到了同一个fd,两个硬件同时读写一块内存。解决办法是在入队前保证前一次推理彻底结束。
第二个坑:buffer数量不足导致的摄像头超时。V4L2申请的buffer只有4个,但推理线程处理慢的时候,DQBUF会阻塞等待,表现为摄像头突然不出帧。这时候需要增加buffer数,我一般建议6到8个。RK3588的ISP驱动对buffer数量限制比较宽松,但也不要无限增加,因为每个buffer都对应一块较大的物理内存。
第三个坑:多plane驱动的fd合并问题。有些ISP驱动把NV12的Y和UV各自放到一个dma-buf里,导致NPU需要两个fd才能描述一帧。这种情况下,我建议你优先检查SDK提供的dts配置,看是否能设置成单plane布局。如果实在不行,可以用一个dma_heap分配足够大的buffer,然后通过RGA或手动拷贝把两个plane合进去,但这就失去了完全零拷贝的意义。所以更推荐买模组或者改配置,让ISP输出你想要的单fd layout。
第四个坑:rknn_api版本和SDK内核版本不匹配。rknpu2的驱动在内核侧有一个rknn相关模块,用户态librknnrt库必须和内核版本配套。如果你从网上下了一个最新版librknnrt,但板子内核还是旧版,初始化rknn_init时会报错或者直接段错误。建议直接用SDK里的NPU驱动和librknnrt,不要混用。
第五个坑:fd生命周期管理失误导致系统卡死。如果我前面说的fd_map初始化时漏掉了dup,或者推理线程提前close了fd,轻则采集线程报EBADF,重则内核在释放dma-buf时与正在进行的DMA传输冲突,造成整板hang住。这个坑在长期稳定性测试中尤其明显。你可以在每个关键节点加一点日志,打印fd值,确保没有被重复close。
5.3 问题速查表
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 推理画面花屏 | cache一致性问题 | 检查同fd是否存在并发访问,增加同步 |
| DQBUF阻塞无帧 | buffer池不足 | 增加req.count到6或8 |
| NPU初始化失败 | 驱动与库版本不匹配 | 换回SDK配套rknpu2版本 |
| 采集线程EBADF | fd被提前close | 检查fd_map和close逻辑 |
| 画面绿边 | stride对齐问题 | 确认V4L2的bytesperline与模型输入宽一致 |
| 性能提升不明显 | 链路中仍有CPU拷贝 | 用perf或ftrace查memcpy调用点 |
6. 后续还能怎么扩展
这套V4L2到NPU的零拷贝链路搭起来之后,你手里的东西就可以玩出不少花样了。比如把同一个dma-buf fd同时送给NPU和RGA做显示,让视频采集的同一帧数据既能用于AI推理,又能直接走显示通路,整个系统不需要额外的帧拷贝。再比如接多路摄像头的时候,每一路都独立走这套机制,RK3588的ISP本身支持多路输入,配合DMA-BUF可以轻松做到多路并发检测。
还有一种思路是把NPU推理输出的结果放回dma-buf,让RGA直接在NPU输出的特征图上做可视化,把画框和缩放也丢给硬件,彻底解放CPU。这个方案在做多路实时分析时效果非常明显。我在实际项目里,最后把CPU占用压到了10%以内,同时开了两路1080p摄像头检测一路视频推流,板子温度都低了不少。
最后再分享一个经验:做这种底层优化,一定不要只盯着代码本身。RK3588的SDK版本之间差异不小,不同版本的内核dts里,DMA-BUF和摄像头链路的默认配置可能完全不同。拿到新板子,先花一小时确认好驱动版本和dts配置,再动手写代码,能省掉后面不少debug时间。这套方案的原理在瑞芯微其他芯片上也是通用的,把V4L2这端换成任何支持DMA-BUF导出的采集设备,NPU这端换成任何支持fd导入的设备,逻辑都是一样的。以后遇到新的芯片平台,这套思路可以直接迁移过去。