
如果你在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导出成fdNPU驱动拿到同一个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驱动要支持EXPBUFNPU驱动要支持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与DQBUFFV4L2采集的循环模式是“把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生命周期由你控制但如果你在推理还没结束时就closeNPU那边拿到的是一份悬空的引用轻则画面缺损重则内核panic。正确的做法是每次dqueue拿到新的buffer后确保该buffer对应的dma_fd已经duplicate一份给推理线程或者使用栅栏同步保证推理线程完全用完再close。3. NPU侧接手用rknn_api直接消费DMA-BUFRK3588的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摄像头1080p30fps 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或8NPU初始化失败驱动与库版本不匹配换回SDK配套rknpu2版本采集线程EBADFfd被提前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导入的设备逻辑都是一样的。以后遇到新的芯片平台这套思路可以直接迁移过去。