ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RK3588零拷贝视觉链路:从V4L2到NPU的DMA-BUF直通实战

2026/10/5 6:10:41 拓冰建站 浏览量
RK3588零拷贝视觉链路:从V4L2到NPU的DMA-BUF直通实战 做嵌入式视觉的兄弟应该都有过这个体验摄像头采集流畅得很NPU 推理看起来也挺快但把两者串起来之后CPU 占用突然飙到 30%~40%帧率还往下掉怎么调都不得劲。扒开瓶颈一看问题往往不在算法也不在算子而是每一帧图像被 memcpy 拷贝了太多趟。RK3588 这台 SoC 算力不弱但要真正吃满它就得把 V4L2 采集、DMA-BUF 共享、NPU 推理这条链路打通。这篇文章我把从摄像头到 NPU 的零拷贝数据流完整拆开从内核机制讲到用户态代码手把手带你绕开 CPU 这个中间搬运工。文章适合已经在用 Linux 和 C 做嵌入式开发、准备在 RK3588 上跑视觉推理的工程师读整条路径跑通之后你会对“设备间直接传内存”这句话有完全不一样的体感。1. 不解决拷贝问题再好的 NPU 也会被“饿死”1.1 一帧图像在传统链路里要被搬运多少趟假设摄像头输出 1080P 的 NV12 图像一帧尺寸是 1920×1080×1.5约 3.1MB。传统做法通常是这样的V4L2 驱动在内核里分配 DMA 缓冲区摄像头 DMA 把数据直接写进这片内存应用层通过mmap把缓冲区映射到用户态这一步本身不拷贝但当你准备把画面交给 NPU 时为了满足模型输入的对齐和格式要求十有八九会调一次memcpy把数据从 V4L2 缓冲区搬到另一块专门给 NPU 用的内存里。如果中间还经过 RGA、图像预处理库或者 GStreamer 插件拷贝次数还会再加。按 30fps 来算每帧 3.1MB单次拷贝就是每秒 93MB 的内存搬移量如果这条链路上出现两到三次拷贝就是每秒 200~300MB。可能有人觉得这数字不大毕竟 DDR 标称带宽有好几个 GB。但别忽略RK3588 的 DDR 带宽是 CPU、GPU、NPU、ISP、VPU、显示控制器一起共享的系统里只要还在跑 UI、编解码、网络收发内存带宽早就不是一份清爽的空闲资源。更麻烦的是memcpy会在 ARM 的 Cortex-A76/A55 上产生大量 cache 写入和失效一来一回把 CPU 流水线打到七零八落最终表现出来就是“NPU 满负荷在等数据CPU 满负荷在搬数据”。1.2 真正能绕开 CPU 的路径长什么样零拷贝的思路很直接既然摄像头 DMA 已经把数据写进了 DDRNPU 读数据也是从 DDR 读为什么非要让 CPU 在中间做一次人肉中转正确做法是把“这一帧内存的句柄”直接在设备之间传递。摄像头驱动持有这块内存NPU 驱动也拿到同一段物理内存的引用两边各自通过 DMA 引擎读写CPU 只负责流程调度不碰像素数据。这套机制在 Linux 上就是 DMA-BUF。它把一块内存封装成一个文件描述符fd由一个 exporter 驱动导出其他 importer 驱动通过这个 fd 拿到同一块内存的映射或 scatter-gather 列表再接进自己的 IOMMU 或 DMA 引擎。数据本体不会因为“换了个使用者”就被复制一遍。RK3588 的 ISP、RGA、VPU、NPU 以及显示控制器在内核驱动里都对 DMA-BUF 有成熟支持所以这条路不光是理论可行而是芯片厂商本身就在系统里预埋好的主流用法。1.3 这套方案适合谁、解决什么问题如果你正在做下面这几类事情DMA-BUF 值得尽早加入方案设计而不是等性能出问题再临时优化摄像头实时画面直接送进 NPU 做检测、识别、分割ISP 输出需要经过 RGA 缩放、裁剪、转格式之后再接 NPU且希望每一步都不落 CPU有多个视频流或高分辨率输入并发DDR 带宽已经很紧张需要在高帧率下把采集和推理解耦成不同线程甚至不同进程。当然如果是刚接触 V4L2 或 RKNN先跑通mmap copy也完全正常毕竟先把功能跑起来最重要。但真正做产品时内存带宽会被多路视频、图形叠加、通信协议栈同时挤压到那时再回头做零拷贝就得改不少业务代码。不如一开始就把“fd 传递”当成数据流的主干来设计后面加功能也只是往链路上挂新节点而已。2. DMA-BUF 的工作机制与 RK3588 链路拆解2.1 DMA-BUF 是 Linux 共享设备内存的“通用语言”DMA-BUF 在内核里有三个关键角色exporter、importer 和 umporter。exporter 通常是某个驱动的 buffer 分配器比如 V4L2 的 vb2 框架负责把一块 DMA 内存导出为 fdimporter 是另一个驱动它拿到 fd 后调用dma_buf_attach()把自己挂上去再通过dma_buf_map_attachment()拿到可供 DMA 使用的 sg_tablescatter-gather 表从而把内存映射进自己的地址空间或 IOMMU。这里面有几个决定了它“零拷贝”本质的细节。第一dma_buf_attach()不会复制数据只是建立一份引用关系第二importer 拿到的是同一块底层物理内存而不是一份快照第三fd 是带引用计数的只要还有设备在用底层内存就不会被释放。你可以把它理解成一本借书卡exporter 把书登记在册importer 凭卡借阅书本身始终只有一本。用户空间同样能参与实验。拿到了 DMA-BUF fd你也能用常规的mmap()把它映射到进程地址空间甚至用read()读一部分内容。不过强调一下映射成 CPU 可访问只是为了调试或预处理如果每次都通过 CPU 去读写这层映射零拷贝的意义就打了折扣。真正高效的数据流动是设备 DMA 引擎直接读写CPU 只做状态机切换。2.2 在 RK3588 上数据流可以串成“设备到设备”RK3588 内部集成了多个媒体处理 IP而且这些 IP 在 Linux 驱动里大多天然支持 DMA-BUF这就给设备到设备的直通提供了便利。拿最常见的 MIPI CSI 摄像头采集来做路径拆解ISP或 CSI 控制器把 sensor 传来的 RAW 数据处理成 NV12 或 RGB 格式并写进驱动分配的 DMA-BUF驱动把这个 buffer 封装成 fd 返回给用户空间用户空间把这个 fd 交给 NPU 运行时RKNPU 驱动 attach 到这个 fd通过设备内部的 IOMMU/SMMU 拿到同一块物理内存NPU 的 DMA 引擎直接读取该内存进行推理。整条链路中V4L2 的 vb2 buffer 既是摄像头 DMA 的“目的地”又是 NPU DMA 的“源地址”两个硬件 IP 都只接触 DDRCPU 从头到尾没有碰过像素数据。这也解释了为什么 DMA-BUF 在 RK3588 上会成为媒体通路的标准做法——SoC 把 ISP、RGA、NPU 这些外设做成了一套可以互相传 fd 的流水线驱动框架早就铺好了路关键是我们得在用户态把这段数据流编排出来。2.3 为什么不选 mmap memcpy也不选老的 ION可能有人会问既然 V4L2 本身就支持mmap我 mmap 出来再通过共享内存传给 NPU 行不行答案是“部分行”但会破功。mmap解决的问题是“用户态进程能直接读写内核缓冲区”它解决的是 CPU 访问 DMA 内存的问题而不是设备间共享的问题。你把 V4L2 缓冲区 mmap 到进程地址空间后如果 NPU 要使用这块内存你还需要把 fd 传给 NPU 驱动如果中间用内存拷贝来适配 NPU 输入那就回到了 CPU 搬运的路子。更麻烦的是直接 mmap 出来的地址在进程里可能是随机的NPU 驱动需要的是 sg_table 或物理地址而不是一个进程虚拟地址。所以跨设备时标准答案始终是“用 fd 传内存”而不是“用指针传内存”。老内核里还有个 ION 分配器曾经是 Android 平台共享内存的主流方案。问题是 ION 和 DMA-BUF 的定位有重叠而且 ION 的实现偏 Android 定制社区化程度不如 DMA-BUF。TS 从 mainline 演进的角度看ION 后来逐步被 DMA-BUF 取代RK3588 的新内核与 Rockchip SDK 也更倾向于直接使用 DMA-BUF。做新项目时没必要再走 IONV4L2 的 EXPBUF RKNN 的 fd import 就是最干净的一条线。路径数据拷贝次数CPU 参与跨 IP 支持推荐度mmap memcpy多高一般调试用ION少低老平台不推荐V4L2 EXPBUF DMA-BUF0低好推荐3. V4L2 侧实战把摄像头帧变成 dma-buf fd3.1 先确认设备和驱动能力动手之前先确认你的设备确实支持导出 DMA-BUF。对 V4L2 来说核心就是VIDIOC_EXPBUF这个 ioctl简单说它能把已经分配好的 MMAP buffer 导出一个新 fd。大部分 mainline 驱动和 Rockchip SDK 里的摄像头驱动都支持但少数老平台或奇葩 USB 设备不一定。第一步打开设备并查询V4L2_CAP_STREAMING能力int fd open(/dev/video0, O_RDWR | O_NONBLOCK); struct v4l2_capability cap; if (ioctl(fd, VIDIOC_QUERYCAP, cap) 0) { perror(QUERYCAP); return -1; } if (!(cap.capabilities V4L2_CAP_STREAMING)) { fprintf(stderr, device doesnt support streaming\n); return -1; }接着设置采集格式这里用一个简洁的S_FMT逻辑。注意fmt.fmt.pix.field通常设为V4L2_FIELD_NONE表示逐行扫描。对 MIPI CSI 摄像头我们尽量选 NV12 这种 YUV 格式很多 ISP 数据通路直接输出 NV12这对后续 NPU 处理最友好。struct v4l2_format fmt {0}; fmt.type V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width 1920; fmt.fmt.pix.height 1080; fmt.fmt.pix.pixelformat V4L2_PIX_FMT_NV12; fmt.fmt.pix.field V4L2_FIELD_NONE; if (ioctl(fd, VIDIOC_S_FMT, fmt) 0) { perror(S_FMT); return -1; }这里有个经验设置完S_FMT之后驱动有可能根据 sensor 和 ISP 的实际能力微调分辨率和格式所以最好把设置后的fmt再读回来确认避免后面 buffer 参数对不上。3.2 分配缓冲池REQBUFS 和 QUERYBUF接下来要向驱动申请一块缓冲池。使用V4L2_MEMORY_MMAP模式因为我们要让驱动负责分配 DMA 内存并希望这块内存能够被导出成 fd。struct v4l2_requestbuffers req {0}; req.count 4; req.type V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_REQBUFS, req) 0) { perror(REQBUFS); return -1; }申请成功之后驱动会为每个 buffer 分配一块 DMA 内存并把它加入 vb2 管理队列。此时我们要用VIDIOC_QUERYBUF获取每个 buffer 的信息特别是length和offset。传统用法里我们还要用mmap映射这些缓冲区到用户空间这是为了在调试时能直接查看画面内容。但在纯零拷贝链路里映射不是必须的不过为了后续能确认图像内容我通常还是会把缓冲区映射出来留着应急看。struct v4l2_buffer buf {0}; buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; buf.index i; if (ioctl(fd, VIDIOC_QUERYBUF, buf) 0) { perror(QUERYBUF); return -1; } void *user_ptr mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset);需要说明的是mmap本身不会导出 DMA-BUF 给其他设备它的主要作用是让 CPU 也能访问这块内存用于调试和验证。真正要传给 NPU 的是下一节的VIDIOC_EXPBUF导出的 fd。3.3 用 VIDIOC_EXPBUF 导出 DMA-BUF fd这一步是整个 V4L2 侧的核心。对于每个已经通过QUERYBUF拿到的 buffer我们调用VIDIOC_EXPBUF让驱动把这块内存包装成 DMA-BUF 并返回一个 fd。struct v4l2_exportbuffer expbuf {0}; expbuf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; expbuf.index i; expbuf.flags O_CLOEXEC; int dma_fd -1; if (ioctl(fd, VIDIOC_EXPBUF, expbuf) 0) { perror(EXPBUF); return -1; } dma_fd expbuf.fd;flags这里设置O_CLOEXEC是为了防止这个 fd 在后续fork()或exec()时泄漏尤其是在跑多进程 AI 推理框架时这个习惯能省掉不少排查时间。注意expbuf.fd是一个全新的文件描述符它的生命周期独立于 V4L2 设备节点也就是说即使你关掉/dev/video0只要这个 fd 还开着底层的 DMA-BUF 内存就还活着。把每个 buffer 都导出一遍保存到一个数组里。后面在采集循环里拿到哪个 index 的 buffer就直接使用对应的 dma_fd 去喂给 NPU而不再把数据整体拷贝出来。3.4 一个可工作的采集循环骨架缓冲池准备完之后把所有 buffer 入队然后开启视频流进入主循环。主循环逻辑是从DQBUF拿到一帧把这帧对应的 dma_fd 交给下游下游推理完成之后把这帧重新入队给摄像头让摄像头继续填新数据。for (int i 0; i req.count; i) { struct v4l2_buffer buf {0}; buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; buf.index i; if (ioctl(fd, VIDIOC_QBUF, buf) 0) { perror(QBUF initial); return -1; } } enum v4l2_buf_type type V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(fd, VIDIOC_STREAMON, type); while (running) { struct v4l2_buffer buf {0}; buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_DQBUF, buf) 0) { if (errno EAGAIN) { usleep(1000); continue; } break; } int frame_index buf.index; // 把 dma_fd[frame_index] 交给 NPU做推理 infer_with_dmabuf(dma_fd[frame_index], frame_index); // NPU 使用完成后重新入队 if (ioctl(fd, VIDIOC_QBUF, buf) 0) { perror(QBUF return); break; } }这里有个工程细节非常关键在DQBUF和下一次QBUF之间这个 buffer 是处于“用户空间占用”状态的摄像头不会往里面写数据。所以下游推理必须在这个窗口内完成或者你用多个 buffer 形成流水线让摄像头在“填充下一块缓冲”的同时NPU 在“推理当前这块缓冲”。缓冲数量太少会导致采集和推理互相等数量太多又会增加内存占用和延迟实际项目里一般先从 3~4 块开始调。4. RKNN 侧实战把 DMA-BUF fd 注册成 NPU 的零拷贝输入4.1 初始化模型并确认输入规格V4L2 侧导出了 fd剩下的工作就是让 NPU 通过这个 fd 读取同一块内存。RKNN 的运行时 API 在librknnrt.so里提供我们需要做的不是把数据“传进” NPU而是把 fd 关联到模型输入张量上。rknn_context ctx; rknn_init(ctx, model_path, 0, 0, NULL); rknn_input_output_num io_num; rknn_query(ctx, RKNN_QUERY_IN_OUT_NUM, io_num, sizeof(io_num)); rknn_tensor_attr input_attr {0}; input_attr.index 0; rknn_query(ctx, RKNN_QUERY_INPUT_ATTR, input_attr, sizeof(input_attr));拿到输入属性之后对比一下 V4L2 那边实际输出的分辨率和像素格式确认是否匹配。最舒服的情况是模型输入在转换时设置了对应的pre_compile或image_config能够直接接受 NV12 格式的输入如果模型输入要求 RGB 且分辨率不同就需要在中间增加 RGA 处理。这部分我在第五节会展开。4.2 基于 fd 创建 tensor memory 并绑定RKNN 运行时提供了从 fd 创建零拷贝内存的接口。这类 API 在 RKNPU2 的 SDK 里通常形如rknn_create_mem_from_fd不同小版本函数名和参数会有差异但核心语义一致传入 DMA-BUF fd让 RKNPU 驱动在内核侧 attach 这块内存并返回一个可供rknn_set_io_mem使用的 tensor memory 对象。int dma_fd dma_fd[frame_index]; // 来自 V4L2 EXPBUF rknn_tensor_mem *input_mem; int ret rknn_create_mem_from_fd(ctx, dma_fd, 0, input_mem); if (ret 0) { printf(rknn_create_mem_from_fd failed: %d\n, ret); return ret; }这里的 0 一般是 flag 位。创建成功后你可以通过rknn_query(ctx, RKNN_QUERY_MEM_SIZE, ...)查询这块内存的信息确认对齐和大小是否符合模型输入要求。然后把 tensor memory 绑定到对应的输入索引上rknn_tensor_attr fake_attr input_attr; fake_attr.size width * height * 1.5; // NV12 字节数 fake_attr.pass_through 1; rknn_set_io_mem(ctx, input_mem, fake_attr);pass_through的作用是让运行时跳过它自己默认的数据变换。按通常理解pass_through1表示“数据格式我已经按你要求的准备好了运行时不要再做预处理直接作为原始输入送进网络”。这一点对零拷贝很关键因为如果我们没有做格式转换数据本身是什么布局NPU 就应该按什么去读。4.3 推理循环与 fd 的时序配合每拿到一帧 V4L2 buffer我们就把新的 dma_fd 重新创建一个 memory 对象或者复用已经创建好的对象再更新绑定。调用一次rknn_run执行推理推理完成后可以立即把 buffer 还给 V4L2rknn_input inputs[1]; inputs[0].index 0; inputs[0].type RKNN_TENSOR_UINT8; inputs[0].fmt RKNN_TENSOR_NHWC; // 注意pass_through 模式下很多字段会被忽略 // 关键是保证底层内存布局与网络输入一致 inputs[0].buf input_mem-virt_addr; inputs[0].size input_mem-size; inputs[0].pass_through 1; rknn_run(ctx, NULL); rknn_output outputs[1]; outputs[0].want_float 1; rknn_outputs_get(ctx, 1, outputs, NULL); // 推理完成处理输出 rknn_outputs_release(ctx, 1, outputs); // 此时可以把 frame_index 对应的 V4L2 buffer 重新 QBUF真实项目中我更倾向于把“创建 fd 对应 memory”和“rknn_run”拆开变成两张表采集线程维护 DQBUF/EXP_BUF/QBUF 循环推理线程维护 RKNN 的 memory 对象和输出队列。这样采集和推理可以重叠执行避免DQBUF等待时间直接吃掉推理时间。缓冲区数量足够时一帧的采集和下一帧的推理在时间上交错开整体帧率就能明显上升。还有一个细节要和内核驱动对齐从 fd 创建的 memory 对象在推理完成之后记得调用对应的rknn_destroy_mem释放。它释放的是 RKNPU 驱动对该 DMA-BUF 的 attach 引用不是释放摄像头那块 buffer摄像头 buffer 的生命周期仍由 V4L2 侧的QBUF/DQBUF控制。不要在摄像头还没有再次QBUF之前就把 fd 关掉否则驱动会在访问时拿不到有效内存。4.4 格式不匹配时的标准变招让 RGA 来接上面讲的“直接把 V4L2 输出喂给 NPU”最理想但现实里模型往往吃的是 640×640 或 416×416 的 RGB 图摄像头输出的是 1920×1080 的 NV12两者之间天然差了一道预处理。如果这道预处理用 CPU 做零拷贝的成效会大打折扣所以正确做法是在链路上挂 RGA。RGA 是 Rockchip 的 2D 图形加速引擎也支持 DMA-BUF。你可以把 V4L2 导出的 fd 作为 RGA 的输入源把 RGA 输出的 fd 作为 NPU 的输入源整个过程仍然是设备到设备。用户态只需要调用librga的rk_rga_set或直接走RGA_IMPORT的 ioctl指定源 fd、目标 fd、尺寸和像素格式RGA 驱动会自己完成 DMA-BUF attach、缩放、格式转换和写回。模型输入分辨率如果不是特别大可以用 RGA 一次搞定缩放和色彩空间转换。这个链路我在实际项目里用过多次处理 1080P NV12 到 640×640 RGB 的速度非常快CPU 占用几乎可以忽略。关键点是 RGA 输出的 buffer 本身也要是一个 DMA-BUF fd而不是 malloc 出来的普通内存否则链路过一段就断了。RGA 的 API 在不同 SDK 版本里也有差异但原理一致把源 fd 和目标 fd 交给 RGA让硬件去搬。5. 流水线工程化与性能实测5.1 缓冲数量和排队节奏别拍脑袋决定做零拷贝最怕“buffer 数量不够导致隐性串行”。假设摄像头 30fps采集一帧 33msNPU 推理一次 20ms。如果只有两块 buffer流程很可能是摄像头填完 buffer ADQBUF 拿到 A推理 20ms 完成QBUF A此时摄像头可能已经在写 buffer B而 A 在被重新使用前只能等待。两块 buffer 时DQBUF的等待时间和空档经常叠加最终达不到 30fps。我的习惯是开 4 块 buffer。多出来的两块充当“蓄水池”让采集和推理可以错开 100ms 左右即使某一次推理出现轻微抖动也不会打断摄像头的填充节奏。buffer 太多当然会占 DDR 带宽和内存但在 1080P NV12 的场景下每块 3MB4 块也就是 12MB完全可接受。采集线程负责填充和出入队推理线程负责从队列中拿到 dma_fd 并执行rknn_run两者之间用一个定长环形队列传递 index 或 fd。注意V4L2 的 buffer 索引在DQBUF/QBUF之间是独占的所以同一块 buffer 的 index 不能同时在采集线程和推理线程中使用。这个约束写注释都赶不上系统乱飞最好在代码里做一个标志位标记每个 index 当前所处的状态采集、推理、待回收。5.2 cache 一致性和 IOMMU 里的“零拷贝”边界零拷贝不等于零同步。这里最容易误解的地方是 DMA-BUF 的 cache 一致性。当两个设备都通过 IOMMU 或 DMA 访问 DDR 时如果内核或硬件已经做了足够的同步维护用户态通常不用关心 cache 刷新但如果哪一端有 CPU 介入过比如你在中间用mmap直接改过这个 buffer 的内容那就必须小心 cache 脏数据的问题。实际经验是不要在零拷贝链路上随意通过 CPU 写同一块 buffer 的内容尤其是不要只改几个字节。就算调试需要也要通过 memory barrier 和dma_buf_begin_cpu_access/dma_buf_end_cpu_access这类接口来保证顺序。在 RK3588 上很少看到用户态手写 cache flush 代码因为驱动层已经处理了大部分但一旦你发现画面出现“隔几帧花一下”就要回头检查是不是有 CPU 和 DMA 同时写了同一块 buffer。另外一个边界是 IOMMU。RKNPU 和 RGA 都挂了 SMMU理论上可以处理散落的内存页但 V4L2 驱动导出 buffer 时可能要求物理连续尤其是 ISP 输出路径。如果驱动报错或者出现奇怪的故障试试看减少 buffer 数量、或者检查内核对 CMA 区域的配置看是不是连续内存不够了这通常比改业务代码更管用。5.3 在 RK3588 上的实测观察我在 RK3588 板子上跑过一条比较典型的链路MIPI CSI 摄像头直接输出 1080P NV12 → V4L2 EXPBUF → DMA-BUF → NPU 推理 YOLOv5s。用top和perf stat做了对比。普通mmap memcpy路径下CPU 占用几乎恒定在 30% 以上其中相当一部分被 memcpy 和 cache 失效吃掉了瓶颈在搬运推理反而经常处于等待输入的空闲态。切到 DMA-BUF 零拷贝后CPU 占用掉到个位数NPU 的利用率明显提升从传感器数据到达 NPU 输出结果的端到端延迟也稳定了不少。这个效果不是某一次运气而是每次测都能复现的规律。当然具体数字受固件、摄像头驱动、NPU 模型参数和分辨率影响很大所以不建议把别人的实测值当作自己的性能指标。但有一个方向是确定的只要链路里还带着多余的memcpy瓶颈就永远会重新出现只是时间早晚的问题。6. 踩坑清单V4L2、DMA-BUF、RKNN 三者衔接的常见问题6.1 VIDIOC_EXPBUF 返回 EINVAL 或 ENOTTY最常见的原因是驱动没有实现VIDIOC_EXPBUF或者内核编译时没开CONFIG_DMA_SHARED_BUFFER。先查内核配置再看驱动源码里 vb2 操作集是否提供了.buf_dmabuf相关支持。还有个容易忽略的场景部分 USB 摄像头驱动uvc虽然支持 MMAP但导出的 DMA-BUF 在实际 DMA 时可能是通过 SWIOTRL bounce buffer 走的性能和“真零拷贝”有差距。判断办法是打开调试节点看驱动是否真正 attach 到了 dma-buf或者干脆换 MIPI 摄像头测试。6.2 推理结果花屏、错位花屏大概率是格式不一致。V4L2 输出的 NV12 有 Y plane 和 UV plane 两部分NPU 模型如果要求 RGB 或要求严格的 WxH 对齐而你直接用pass_through塞进去结果必然不对。排查顺序先确认模型输入格式能不能兼容 YUV不能兼容就接 RGA再确认分辨率和 stride 是否一致很多 ISP 输出有行对齐实际bytesperline可能大于width * bpp/8NPU 那边必须按这个 stride 去解析。这个坑特别隐蔽因为画面整体不会完全黑只是左右错位或者颜色偏绿偏紫。6.3 fd 泄漏导致内存耗尽DMA-BUF 的 fd 也是 fd是 fd 就会漏。常见漏法有两种一种是每帧都调用rknn_create_mem_from_fd但忘了rknn_destroy_mem另一种是VIDIOC_DQBUF出错或提前退出后没有把当前未还回的 buffer 清理干净。排查手段很粗暴但有效跑一段时间后看/proc/self/fd数量是不是在涨再用cat /proc/self/fdinfo/fd看 dma-buf 的引用计数和大小。设计上最好统一一个“buffer 状态机”每个 dma_fd 在创建、传给 NPU、归还给 V4L2 的过程中都走同一套函数。6.4 USB 摄像头和 MIPI 摄像头差异USB 摄像头走 UVC 驱动链路里绕不开 USB Host 控制器DMA 路径更长而且很多 USB 摄像头输出的直接是 MJPEG 或 YUYV不是 NV12。MJPEG 要解码YUYV 到 NV12 要转格式这两步要么走 CPU 解码要么走 VPU/RGA。想在 USB 摄像头上获得完整零拷贝体验现实一点的做法是让 MJPEG 解码任务交给 VPU或者把 USB 输出的 YUYV 先导成 fd再喂给 RGA 做转换RGA 输出的 fd 再给 NPU。链路比 MIPI 长但至少关键路径上不出现 CPU 拷贝。如果你手里同时有 MIPI 摄像头和 USB 摄像头优先用 MIPI 验证 DMA-BUF 通路的正确性通了之后再扩展去处理 USB 的格式转换。在我自己做过的一版视觉方案里整条链路后来还接了编码器NPU 输出检测结果RGA 负责画框VPU 再把画好的画面编码成 H.264 流推给上位机。整条流水线上每个环节都靠 DMA-BUF fd 衔接CPU 几乎不参与像素搬运系统稳定性比早期mmap copy版本强出一截。做这套东西最大的体会是不要迷信“先跑通再优化”这句话DMA-BUF 这种跨设备的内存传递方式越早进入架构设计后面铺开的每一条数据通路都会轻松很多。如果你正在 RK3588 或类似平台上做视觉落地找一块带 MIPI 摄像头的板子花一个下午把 EXPBUF、RKNN import、双缓冲循环这段代码写出来会比看十篇文章都管用。