1. 从单帧抓取到连续取流为什么这一步绕不过去如果你正在香橙派 RK3588 上跑 yolov5s而且已经能把摄像头打开、抓一帧、送进模型推理那么恭喜你最基础的通路已经打通了。但接下来你一定会遇到一个很现实的问题抓一帧只能做一次检测画面是静止的模型跑完这一帧就结束了。想让它像真正的监控或者实时检测系统那样持续工作就必须把“抓一帧”改成“循环连续取流”。这个改动听起来只是加一个while循环但实际动手之后你会发现事情远没有这么简单。摄像头取流涉及 V4L2 的缓冲区管理、帧率控制、内存拷贝、NPU 推理耗时和显示刷新之间的配合。如果只是简单地在外面套一个循环很容易出现帧率骤降、画面延迟越来越大、内存泄漏甚至程序直接卡死的情况。我在香橙派 5 上做这套流程的时候前前后后调了差不多一周才把连续取流的稳定性做到可以接受的程度。这篇文章就把我从“抓一帧”到“稳定循环取流”的完整过程拆开来讲包括 V4L2 的取流机制、yolov5s 推理和取流的线程配合、RK3588 上 NPU 的调用节奏以及那些文档里不会写的坑。无论你是刚接触 RK3588 和 yolov5s 的新手还是已经跑通单帧推理想进一步做实时检测的开发者这篇内容都能直接拿去参考。2. 先搞清楚 V4L2 取流到底在做什么2.1 从打开设备到拿到第一帧的完整链路很多人用 OpenCV 的cv2.VideoCapture习惯了觉得取流就是read()一下的事。但在 RK3588 这种嵌入式平台上尤其是你要把取流和 NPU 推理串起来的时候理解底层 V4L2 的流程非常有必要。因为一旦出现掉帧或者延迟你至少知道该从哪里查。V4L2 取流的核心流程大致是这样的首先打开/dev/videoX设备节点然后通过VIDIOC_QUERYCAP查询设备能力确认它支持视频采集。接着用VIDIOC_S_FMT设置像素格式和分辨率这一步很关键因为 RK3588 的 MIPI CSI 接口对格式是有要求的常见的 YUYV、NV12、MJPEG 支持情况不一样。设置完格式之后用VIDIOC_REQBUFS申请缓冲区通常申请 4 个左右。然后通过VIDIOC_QUERYBUF获取每个缓冲区的信息再用mmap把内核空间的缓冲区映射到用户空间。最后用VIDIOC_QBUF把缓冲区全部入队调用VIDIOC_STREAMON开始流传输。这一套流程走完之后你就可以通过VIDIOC_DQBUF从队列里取出一帧已经填充好数据的缓冲区处理完之后再用VIDIOC_QBUF把它放回队列。所谓“循环连续取流”本质上就是不断地重复DQBUF和QBUF这两个动作。2.2 为什么不能简单地在外面套一个 while如果你已经写好了单帧抓取的代码最直觉的做法就是在外面加一个while(true)每次循环里做一次DQBUF、处理、QBUF。这个思路方向是对的但直接这么写会碰到几个问题。第一个问题是帧率不匹配。摄像头可能以 30fps 的速度在产出帧但 yolov5s 在 RK3588 的 NPU 上跑一帧可能需要 30 到 50 毫秒也就是说推理速度大概在 20 到 30fps 之间。如果你的循环是“取一帧、推理一帧、再取下一帧”那么当推理比取流慢的时候V4L2 的缓冲区队列会被填满新来的帧没有空缓冲区可用驱动层就会开始丢帧。表现出来就是画面卡顿但程序不报错。第二个问题是内存拷贝。如果你在每次循环里都把帧数据从 mmap 的缓冲区拷贝到一个新的cv::Mat然后再做格式转换这个拷贝开销在 1080p 分辨率下是相当可观的。RK3588 的 CPU 虽然不弱但频繁的大块内存拷贝会挤占本来就不宽裕的带宽。第三个问题是线程阻塞。如果你把取流、推理、显示都放在同一个线程里串行执行那么推理的时候就没法取流显示的时候也没法推理。整个系统的吞吐量会被最慢的那个环节拖死。2.3 连续取流真正要解决的三件事把上面这些问题归纳一下连续取流真正要解决的是三件事第一保证 V4L2 的缓冲区队列始终有空位让驱动可以持续写入新帧第二尽量减少不必要的数据拷贝和格式转换第三让取流和推理解耦各自按照自己的节奏运行。理解了这三点后面的方案设计就有了明确的目标。我接下来会按照“先跑通、再优化”的思路先把一个能连续取流的基本版本搭起来然后再逐步解决帧率、延迟和资源占用的问题。3. 把单帧代码改造成循环取流的最小改动3.1 单帧抓取的代码结构回顾假设你之前的单帧抓取代码大概是这个结构打开设备、设置格式、申请缓冲区、入队、开流、DQBUF拿一帧、把数据取出来、QBUF还回去、关流、关闭设备。这个流程本身没有问题问题在于它只执行了一次。要改成连续取流最直接的方式是把DQBUF到QBUF之间的这段逻辑放进一个循环里。但这里有一个细节需要注意VIDIOC_STREAMON只需要调用一次不需要每次循环都开流。同样缓冲区的申请和映射也只需要做一次。循环里只保留DQBUF、数据处理、QBUF这三个动作。下面是一个最小改动的示例结构用 C 伪代码来表示// 初始化和开流只做一次 int fd open(/dev/video0, O_RDWR); // ... 设置格式、申请缓冲区、mmap、入队 ... ioctl(fd, VIDIOC_STREAMON, type); // 循环取流 while (running) { struct v4l2_buffer buf; memset(buf, 0, sizeof(buf)); buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; // 从队列取一帧 if (ioctl(fd, VIDIOC_DQBUF, buf) 0) { // 处理错误可能是 EAGAIN continue; } // 处理这一帧比如送进 yolov5s 推理 process_frame(buffers[buf.index].start, buf.bytesused); // 把缓冲区还回队列 ioctl(fd, VIDIOC_QBUF, buf); } // 循环结束后关流和清理 ioctl(fd, VIDIOC_STREAMOFF, type);这个结构已经能跑起来了但它是最基础的版本性能和稳定性都还有很大的优化空间。我建议你先用这个版本验证一下摄像头能不能持续出图确认硬件和驱动层面没有问题然后再往下做优化。3.2 缓冲区数量对连续取流的影响在申请缓冲区的时候VIDIOC_REQBUFS里的count参数决定了队列里有多少个缓冲区。这个值设得太小比如只申请 2 个那么当你在处理一个缓冲区的时候驱动只有一个空缓冲区可以写稍微有一点延迟就会丢帧。设得太大比如 8 个以上虽然不容易丢帧但会引入额外的延迟因为帧在队列里排队的时间变长了。我在 RK3588 上实测下来4 个缓冲区是一个比较平衡的选择。既能容忍一定程度的处理延迟又不会让画面延迟太明显。如果你发现推理速度特别慢可以适当增加到 5 到 6 个但一般不建议超过 8 个。还有一个细节是VIDIOC_DQBUF默认是阻塞模式也就是说如果队列里没有已经填充好的帧它会一直等在那里。这在单线程场景下没问题但如果你后面要做多线程可能需要把它设成非阻塞模式通过O_NONBLOCK标志打开设备然后在DQBUF返回EAGAIN的时候做其他事情。3.3 格式选择MJPEG 还是 NV12RK3588 的 MIPI CSI 接口支持的像素格式比较多常见的有 MJPEG、YUYV、NV12、NV16 等。选择哪种格式直接影响到后续的数据处理流程和 CPU 占用。MJPEG 的好处是带宽占用低同样分辨率下数据量比原始格式小很多适合 USB 摄像头或者带宽受限的场景。但缺点是每一帧都需要解码解码本身要消耗 CPU。如果你用的是 MIPI 摄像头一般不建议用 MJPEG因为 MIPI 的带宽足够直接传原始格式。NV12 是很多嵌入式平台推荐的格式因为它是 YUV 4:2:0 的半平面格式数据量比 RGB 小而且 RK3588 的硬件编解码器和 NPU 对 NV12 的支持都比较好。如果你后面要把帧送进 NPU 做推理NV12 通常是最省事的因为 RKNN 的输入预处理可以直接接受 NV12 数据省掉一次颜色空间转换。YUYV 是 YUV 4:2:2 的打包格式数据量比 NV12 大但兼容性最好几乎所有的 UVC 摄像头都支持。如果你用的是 USB 摄像头可能只能选 YUYV 或者 MJPEG。我的建议是如果是 MIPI 摄像头优先选 NV12如果是 USB 摄像头优先选 MJPEG然后在解码之后转成 NV12 或者直接送进推理。具体选哪个可以用v4l2-ctl --list-formats-ext看一下设备支持哪些格式再做决定。4. 取流和推理的线程配合别让 NPU 等摄像头4.1 单线程串行执行的瓶颈在哪里用上面那个最小改动的版本跑起来之后你会发现帧率大概只有十几帧而且画面有明显的卡顿感。原因很简单取一帧、推理一帧、还一帧这三个动作是串行的。假设摄像头出帧是 30fps每帧间隔 33 毫秒而 yolov5s 推理一帧需要 40 毫秒那么整个循环的周期就是 40 毫秒加上取帧和还帧的开销实际帧率可能只有 20fps 左右。更糟糕的是当推理在进行的时候V4L2 的缓冲区队列里可能已经堆积了好几帧等你推理完再去取的时候取到的是旧帧延迟就这样累积起来了。要打破这个瓶颈就必须把取流和推理放到不同的线程里。取流线程只负责不断地DQBUF和QBUF把拿到的帧放进一个队列推理线程从队列里取帧送进 NPU拿到结果后再做后续处理。这样取流和推理各自按照自己的节奏运行互不阻塞。4.2 双线程模型的具体实现双线程模型的核心是一个线程安全的帧队列。取流线程在DQBUF拿到一帧之后不直接处理而是把帧数据拷贝或者引用到一个队列节点里然后立刻QBUF把缓冲区还回去。推理线程从队列的另一端取帧做推理。这里有一个关键决策队列里存的是帧的拷贝还是帧的引用。如果存引用也就是直接指向 mmap 缓冲区的指针那么你必须保证在推理线程用完这一帧之前取流线程不会把同一个缓冲区QBUF回去并被驱动重新填充。这需要额外的同步机制实现起来比较复杂。如果存拷贝也就是把帧数据复制一份放到队列里那么取流线程可以立刻QBUF不用担心缓冲区被覆盖但代价是每帧都要做一次内存拷贝。在 RK3588 上1080p 的 NV12 帧大概是 3MB 左右拷贝一次大概需要几毫秒。如果你的推理时间是 40 毫秒那么几毫秒的拷贝开销是可以接受的。而且拷贝之后取流线程可以更快地QBUF减少丢帧的概率。所以我的建议是先用拷贝的方式把双线程模型跑通如果后面发现拷贝开销太大再考虑用零拷贝或者缓冲区池的方式来优化。队列的实现可以用std::queue加上std::mutex和std::condition_variable。取流线程在队列为空的时候等待推理线程在队列有数据的时候等待。队列的最大长度建议设为 2 到 3太大了会引入延迟太小了又容易丢帧。4.3 线程优先级和 CPU 亲和性设置RK3588 是八核处理器有四个 A76 大核和四个 A55 小核。取流线程和推理线程如果被调度到同一个小核上性能会受影响。你可以用pthread_setaffinity_np把取流线程绑定到一个 A76 核心把推理线程绑定到另一个 A76 核心这样它们可以并行执行减少相互干扰。另外取流线程的优先级可以设得稍微高一点因为如果取流不及时V4L2 的缓冲区队列会满导致丢帧。推理线程的优先级可以稍微低一点因为它晚几毫秒处理一帧影响没有取流那么大。在 Linux 下可以用pthread_setschedparam来设置线程的调度策略和优先级不过需要 root 权限。还有一个细节是如果你用的是 USB 摄像头取流线程可能会因为 USB 带宽或者驱动的原因出现周期性的阻塞。这种情况下可以考虑把 USB 摄像头的数据先读到内存里再用内存队列的方式送给推理线程避免 USB 的抖动影响到整个流水线。5. RK3588 上 yolov5s 推理的节奏控制5.1 NPU 推理耗时和帧率的匹配yolov5s 在 RK3588 的 NPU 上跑一帧 640x640 的输入耗时大概在 30 到 50 毫秒之间具体取决于模型是否量化、输入分辨率以及 NPU 的频率设置。如果你的摄像头是 30fps那么每 33 毫秒就有一帧新数据。推理速度如果跟不上取流速度队列就会越来越长延迟越来越大。解决这个问题的思路有两种一种是降低取流帧率让摄像头以 15fps 或者 20fps 出帧这样推理压力小一些另一种是跳帧处理也就是取流线程照常取流但推理线程只处理队列里最新的那一帧把旧的帧丢掉。跳帧的好处是保证画面实时性你看到的永远是最新的画面代价是有些帧没有被检测到。在实际项目中我通常会用跳帧的策略。具体做法是推理线程每次从队列里取帧的时候不取最早的那一帧而是把队列里所有帧都取出来只保留最后一帧其他的直接丢弃。这样虽然会丢掉一些帧但保证了检测结果的实时性。对于监控或者实时检测场景来说实时性比完整性更重要。5.2 输入预处理的耗时优化yolov5s 的输入是 640x640 的 RGB 图像而摄像头输出的是 1080p 的 NV12。这中间需要做缩放和颜色空间转换。如果你用 OpenCV 的cv::resize和cv::cvtColor在 CPU 上做这些操作大概需要 10 到 20 毫秒这个开销甚至比推理本身还大。RK3588 的 RGARaster Graphic Acceleration模块可以硬件加速缩放和颜色空间转换。你可以用 RGA 把 1080p 的 NV12 直接缩放到 640x640 的 RGB耗时可以降到几毫秒。RKNN 的 SDK 里通常有 RGA 的接口封装或者你可以用librga这个库来调用。如果你不想折腾 RGA也可以考虑把摄像头的输出分辨率直接设成 640x640让摄像头或者 V4L2 驱动来做缩放这样取到的帧直接就是模型需要的尺寸省掉一次缩放。不过把摄像头设成 640x640 有一个问题很多摄像头的传感器原生分辨率是 1080p 或者更高设成 640x640 可能是通过裁剪或者缩放实现的视野会变窄。如果你的应用对视野有要求还是建议用 1080p 取流然后用 RGA 做缩放。5.3 推理结果的后处理和显示推理完成之后你会得到一组检测框。如果要把检测框画到画面上显示还需要把原始帧和检测结果合成。这一步如果放在 CPU 上做又是一次全画面的操作开销不小。一个常见的做法是显示的时候不直接显示原始帧而是显示一个缩小的预览图比如 640x360这样绘制检测框的开销会小很多。如果你不需要实时显示只是把检测结果通过串口或者网络发出去那么后处理的开销可以忽略不计。这种情况下整个流水线的瓶颈就只在取流和推理上帧率可以做得更高。还有一个细节是RK3588 的 NPU 在连续推理的时候会发热如果散热不好NPU 可能会降频推理时间会变长。我在香橙派 5 上加了一个小风扇之后连续跑一个小时推理时间基本稳定在 35 毫秒左右。如果不加风扇跑十几分钟之后推理时间会涨到 50 毫秒以上。所以如果你要做长时间连续取流和推理散热一定要做好。6. 实测中遇到的坑和排查过程6.1 画面延迟越来越大是怎么回事我第一次把循环取流跑起来的时候发现一个很奇怪的现象刚开始画面还挺流畅但跑了大概一两分钟之后画面延迟越来越大最后能延迟好几秒。用v4l2-ctl看缓冲区状态发现队列里一直有 3 到 4 个缓冲区是满的说明取流线程取帧的速度跟不上摄像头出帧的速度。排查下来发现问题出在推理线程的处理速度上。我的推理线程每处理完一帧会做一次比较耗时的后处理包括画框、写日志、发网络请求。这些操作加起来要 20 多毫秒导致推理线程的整体周期变成了 60 多毫秒比摄像头的 33 毫秒慢了一倍。队列里的帧越积越多延迟就越来越大。解决办法是把后处理也拆出去推理线程只负责推理推理结果放进另一个队列由第三个线程来做后处理和显示。这样推理线程的周期就只包含预处理和推理大概 40 毫秒左右虽然还是比 33 毫秒慢一点但通过跳帧策略延迟可以控制在 100 毫秒以内。6.2 DQBUF 返回 EAGAIN 该怎么处理如果你把设备设成了非阻塞模式VIDIOC_DQBUF在队列为空的时候会返回 -1并且errno被设成EAGAIN。这个不是错误只是说明当前没有可用的帧。很多人在第一次遇到这个的时候会以为是出错了然后直接退出循环结果程序跑了几帧就停了。正确的处理方式是遇到EAGAIN的时候不要退出而是短暂休眠一下比如usleep(1000)然后继续下一次DQBUF。休眠时间不要太长否则会错过帧也不要太短否则会空转浪费 CPU。1 毫秒左右是一个比较合适的值。还有一种情况是DQBUF返回EIO这个通常说明摄像头出现了硬件层面的错误比如 USB 断开或者 MIPI 信号丢失。这种情况下需要重新初始化摄像头或者至少要把流停掉再重新开。我在调试的时候遇到过 USB 摄像头因为供电不足导致EIO换了一个带独立供电的 USB Hub 之后就稳定了。6.3 内存泄漏的排查连续取流跑久了之后如果发现内存占用一直在涨那多半是有内存泄漏。常见的原因有几个一是每次循环都new了一个cv::Mat或者缓冲区但没有delete二是队列里的帧数据没有被正确释放三是 V4L2 的缓冲区在程序退出时没有munmap。排查内存泄漏可以用valgrind或者heaptrack不过在嵌入式平台上跑这些工具比较慢。一个更简单的方法是在每次循环里打印一下当前的内存占用观察它的变化趋势。如果内存占用在几分钟内从几十兆涨到几百兆那肯定是有泄漏。我在自己的代码里遇到过一次泄漏原因是推理线程从队列里取帧之后如果队列为空会直接continue但忘记释放上一帧的数据。后来改成用std::shared_ptr来管理帧数据让引用计数自动处理释放问题就解决了。6.4 摄像头掉线后的自动恢复在实际部署中摄像头可能会因为各种原因掉线比如线缆松动、供电波动、驱动异常。如果你的程序没有处理这种情况一旦掉线整个流水线就停了。要做一个健壮的系统必须加上自动恢复的逻辑。我的做法是在取流线程里监测DQBUF的返回值如果连续多次返回错误就认为摄像头掉线了。然后关闭当前设备重新打开、重新初始化、重新开流。这个过程可能需要几秒钟但至少程序不会彻底死掉。重新开流之后队列里的旧帧要全部清空避免把掉线前的旧画面送进推理。还有一个细节是重新打开设备的时候/dev/videoX的编号可能会变。如果你用的是 USB 摄像头拔插之后可能会从video0变成video1。可以用udev规则给摄像头绑定一个固定的符号链接比如/dev/camera0这样程序里始终打开这个符号链接就不用担心编号变化了。7. 性能调优的几个实用手段7.1 用 RGA 替代 OpenCV 做缩放和转换前面提到过OpenCV 的resize和cvtColor在 CPU 上做比较慢。RK3588 的 RGA 是专门做这个的硬件模块用起来也不复杂。librga提供了imresize和cvtcolor的接口可以直接在 NV12 和 RGB 之间转换同时做缩放。我实测下来用 RGA 把 1080p NV12 转成 640x640 RGB耗时大概 3 到 5 毫秒比 OpenCV 的 15 到 20 毫秒快了好几倍。而且 RGA 是独立于 CPU 和 NPU 的硬件模块用它做预处理可以和 NPU 推理并行进一步缩短整体周期。不过 RGA 的使用有一些限制比如输入输出的对齐要求、支持的分辨率范围等。如果你的分辨率比较特殊可能需要先做一次对齐处理。具体的使用方法可以参考 RK3588 的 RGA 文档和librga的示例代码。7.2 零拷贝的尝试和取舍零拷贝是指取流线程拿到帧之后不拷贝数据直接把缓冲区的文件描述符或者物理地址传给推理线程让 NPU 直接从这块内存里读数据。这样可以省掉每帧几毫秒的拷贝开销对于高帧率场景很有吸引力。但在实际实现中零拷贝的复杂度比较高。首先你需要保证在 NPU 读完这块内存之前V4L2 的缓冲区不会被QBUF回去并被驱动覆盖。这需要一种机制来跟踪每个缓冲区的使用状态。其次RKNN 的输入接口是否支持直接传入 dmabuf 或者物理地址取决于你用的 SDK 版本和接口。有些版本支持有些不支持。我的建议是如果你的帧率要求不高比如 15fps 以下拷贝的开销完全可以接受没必要折腾零拷贝。如果你的帧率要求很高比如 30fps 以上而且 CPU 占用已经很高了那么可以考虑零拷贝但要准备好花时间调试。7.3 调整 NPU 频率和电源模式RK3588 的 NPU 频率是可以调整的。默认情况下NPU 可能运行在较低的频率上以节省功耗。如果你需要更高的推理速度可以通过sysfs或者rknn_server的接口把 NPU 频率设到最高。具体的操作方式因系统版本而异。在 Ubuntu 20.04 上你可以查看/sys/class/devfreq/fdab0000.npu/下面的governor和cur_freq文件。把governor设成performance可以让 NPU 一直跑在最高频率。不过这样功耗和发热都会增加需要配合散热措施。另外RK3588 的 CPU 也有类似的频率调节机制。如果你发现取流线程偶尔会被调度延迟可以把 CPU 的 governor 也设成performance减少频率切换带来的抖动。8. 从能跑到好用稳定性收尾工作8.1 日志和状态监控一个能连续跑几个小时甚至几天的系统必须有足够的日志和状态监控。我在代码里加了几个关键的统计指标取流帧率、推理帧率、队列长度、平均推理耗时、丢帧数。这些指标每隔几秒打印一次通过观察它们的变化可以快速判断系统是否健康。比如如果取流帧率是 30fps但推理帧率只有 15fps队列长度一直在涨那就说明推理跟不上需要考虑跳帧或者降低分辨率。如果平均推理耗时突然从 35 毫秒涨到 60 毫秒那可能是 NPU 降频了需要检查散热。如果丢帧数在短时间内快速增长那可能是 USB 带宽不够或者 MIPI 信号有问题。这些日志不需要很复杂用printf或者std::cout输出到终端或者文件就行。关键是要有而且要及时看。8.2 优雅退出和资源释放连续取流的程序通常是长期运行的但总会有需要停止的时候比如更新模型、重启设备或者调试。如果程序退出的时候没有正确释放资源可能会导致摄像头设备被占用下次启动的时候打不开。正确的退出流程是先设置一个running标志为false让取流线程和推理线程退出循环然后等待线程结束接着调用VIDIOC_STREAMOFF停流再munmap释放缓冲区最后close设备文件描述符。如果有队列里的帧数据没有处理完也要在退出前释放掉。我见过有人直接按 CtrlC 结束程序结果摄像头设备一直处于 busy 状态必须重启系统才能恢复。所以如果你的程序要长期运行一定要处理好信号比如捕获SIGINT在信号处理函数里设置退出标志让主循环优雅退出。8.3 长时间运行的散热和稳定性最后再强调一下散热。RK3588 性能很强但发热也不小尤其是 NPU 和 CPU 同时高负载运行的时候。香橙派 5 的板子如果没有散热片或者风扇连续跑十几分钟之后就会因为过热而降频推理速度明显下降甚至可能出现不稳定的情况。我的配置是一个小的铝制散热片加上一个 5V 的小风扇风扇直接从板子的 GPIO 取电。这样连续跑几个小时温度能控制在 60 度以下推理速度基本稳定。如果你要做产品级的应用散热设计一定要提前考虑不要等到出了问题再补。另外电源也很关键。RK3588 在 NPU 满载的时候电流需求比较大如果电源适配器的功率不够可能会导致电压跌落引起系统重启或者摄像头掉线。建议用 5V 3A 以上的电源而且线材不要太细。这套从单帧抓取到循环连续取流的改造核心思路就是解耦取流和推理用队列做缓冲用跳帧保实时用硬件加速降开销。每一步的改动都不复杂但组合起来就能让整个系统从“能跑”变成“好用”。我在香橙派 5 上跑这套流程1080p 取流加 yolov5s 推理稳定在 20fps 左右延迟控制在 100 毫秒以内连续跑一整天没有出现崩溃或者内存泄漏。如果你也在做类似的项目希望这些经验能帮你少走一些弯路。
