ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

RK3588 WiFi摄像头推流实战:V4L2采集与硬编码

RK3588 WiFi摄像头推流实战:V4L2采集与硬编码 1. 为什么选 RK3588 做 WiFi 无线摄像头推流方案选型与硬件准备先聊点实在的。我这个项目最开始其实不是非 RK3588 不可当时手里同时有树莓派 4B 和一块全志 H6 的开发板都试过做 USB 摄像头采集推流。结果怎么说呢要么是编码性能拉胯1080p 推流 CPU 直接烧到 80% 以上要么是 WiFi 吞吐不稳定画面时不时卡成幻灯片。后来换了 RK3588整体体验完全不一样了。RK3588 这芯片在嵌入式开发圈里这两年热度一直很高8 核 ARM 架构4 个 Cortex-A76 大核加 4 个 Cortex-A55 小核算力相当充裕而且自带 NPU虽然这个项目暂时没用到 AI 推理但后续如果想在端侧跑个目标检测、人脸识别再做智能推流硬件底子是现成的。更重要的是RK3588 内置了 VPU视频编解码单元硬编 H.264/H.265 都是常规操作这样 CPU 就能从编码这种重活里解放出来哪怕 4K 分辨率的视频流推出去系统负载也能保持在一个很健康的水位。再一个原因就是接口资源。RK3588 的 MIPI-CSI 接口支持多路摄像头输入USB 3.0 Host 口也够多这意味着无论是接 MIPI 摄像头模组还是免驱 UVC 摄像头都非常灵活。我这套方案用的是一个普通的 USB 摄像头走的标准 V4L2 协议根本不需要额外写驱动插上就能识别对新手来说特别友好。硬件准备清单如下硬件/软件型号/版本说明开发板RK3588 开发板我用的是某国产厂商的核心板加底板方案8GB 或 16GB 内存都行存储建议 32GB 以上摄像头USB UVC 免驱摄像头支持 1080p选 YUYV/MJPEG 输出格式的兼容性好系统Ubuntu 22.04RK3588 移植版或者 Debian内核 5.10 以上自带 V4L2 框架WiFiUSB 无线网卡或板载 WiFi 模块支持 AP/STA 模式我用的板载 WiFi 6 模块实测吞吐稳定软件GStreamer、FFmpeg、VLC用于验证核心推流工具下面细说提示RK3588 的很多开发板出厂带的是 Android 系统做嵌入式 Linux 开发之前记得先刷成 Ubuntu 或 Debian 的固件网上针对各厂商板子的移植教程已经比较成熟了实在不行用系统自带的 SDK 自己编一个也行只是耗时比较长。这里想特别强调一下选 USB UVC 摄像头而不是 MIPI 摄像头的原因。MIPI-CSI 摄像头的画质上限确实更高但驱动适配是个大坑不同厂商的 sensor 芯片驱动差异很大很多 RK3588 的板子即使有 DTS 配置也要反复调。USB UVC 摄像头走的是标准协议Linux 内核里已经有现成的uvcvideo驱动插上就能枚举出/dev/video0对做应用层开发的人来说省了不止一个晚上的折腾时间。2. V4L2 摄像头采集链路从设备枚举到原始帧读取V4L2 的全称是 Video for Linux 2是 Linux 内核里一套标准化的视频设备驱动框架。你可以理解成摄像头在 Linux 系统里的普通话协议——不管底层是什么 sensor、什么接口上层应用只要按 V4L2 的规范操作/dev/videoX这个设备节点就能拿到图像数据。这种设计思路和 POSIX 文件操作很像一切都是文件一切都是标准接口。2.1 先用命令行确认摄像头能被系统正确识别动手写代码之前先跑几个命令确认摄像头工作正常。插上 USB 摄像头后在 RK3588 的终端里执行lsusb ls /dev/video* v4l2-ctl --list-devices正常情况下v4l2-ctl --list-devices会输出类似这样的内容USB Camera (1bcf:2c87): /dev/video0接着查看摄像头支持的像素格式和分辨率v4l2-ctl -d /dev/video0 --list-formats-ext输出里如果能看到YUYV或者MJPG就说明摄像头在 UVC 协议下工作正常。这里有个重要概念YUYV 是无压缩的裸数据格式一帧 1080p 图像在 YUYV 下大约是1920 * 1080 * 2字节算下来差不多 4MB如果以 30fps 采集每秒数据量是 120MB 以上这个数据量直接扔到网络上是不现实的。MJPG 是摄像头内部做了 JPEG 压缩后输出的格式同样一帧 1080p 可能只需要 100KB 到 300KB流量压力小很多但代价是画质有损、解码需要算力。所以这里要铺垫一个贯穿整个项目的判断摄像头采集端到底输出什么格式决定了后面推流链路的复杂度。我的建议是如果摄像头支持 MJPG优先用 MJPG 做网络传输如果只支持 YUYV那就老老实实在板子上做硬编码压缩别想着裸流直接推。2.2 V4L2 编程的基本套路打开设备、设置格式、申请缓冲区、启动采集命令行验证完下面进入正式的主题——用 C 语言写一个 V4L2 采集程序。这里先给出完整可运行的代码然后逐段拆解关键逻辑。#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include errno.h #include sys/ioctl.h #include sys/mman.h #include linux/videodev2.h #define DEVICE_PATH /dev/video0 #define WIDTH 1920 #define HEIGHT 1080 #define BUFFER_COUNT 4 struct buffer_info { void *start; size_t length; }; static struct buffer_info buffers[BUFFER_COUNT]; static int xioctl(int fd, unsigned long request, void *arg) { int r; do { r ioctl(fd, request, arg); } while (r -1 errno EINTR); return r; } int main(void) { int fd open(DEVICE_PATH, O_RDWR | O_NONBLOCK, 0); if (fd 0) { perror(open video device failed); return -1; } struct v4l2_format fmt; memset(fmt, 0, sizeof(fmt)); fmt.type V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width WIDTH; fmt.fmt.pix.height HEIGHT; fmt.fmt.pix.pixelformat V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field V4L2_FIELD_NONE; if (xioctl(fd, VIDIOC_S_FMT, fmt) 0) { perror(set format failed); close(fd); return -1; } struct v4l2_requestbuffers req; memset(req, 0, sizeof(req)); req.count BUFFER_COUNT; req.type V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory V4L2_MEMORY_MMAP; if (xioctl(fd, VIDIOC_REQBUFS, req) 0) { perror(request buffers failed); close(fd); return -1; } for (int i 0; i BUFFER_COUNT; i) { struct v4l2_buffer buf; memset(buf, 0, sizeof(buf)); buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; buf.index i; if (xioctl(fd, VIDIOC_QUERYBUF, buf) 0) { perror(query buffer failed); close(fd); return -1; } buffers[i].length buf.length; buffers[i].start mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset); if (buffers[i].start MAP_FAILED) { perror(mmap failed); close(fd); return -1; } } enum v4l2_buf_type type V4L2_BUF_TYPE_VIDEO_CAPTURE; for (int i 0; i BUFFER_COUNT; i) { struct v4l2_buffer buf; memset(buf, 0, sizeof(buf)); buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; buf.index i; if (xioctl(fd, VIDIOC_QBUF, buf) 0) { perror(queue buffer failed); return -1; } } if (xioctl(fd, VIDIOC_STREAMON, type) 0) { perror(stream on failed); close(fd); return -1; } for (int frame_count 0; frame_count 300; frame_count) { fd_set fds; FD_ZERO(fds); FD_SET(fd, fds); struct timeval tv; tv.tv_sec 2; tv.tv_usec 0; int sel select(fd 1, fds, NULL, NULL, tv); if (sel 0) { perror(select failed); break; } if (sel 0) { fprintf(stderr, select timeout, no frame\n); continue; } struct v4l2_buffer buf; memset(buf, 0, sizeof(buf)); buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; if (xioctl(fd, VIDIOC_DQBUF, buf) 0) { perror(dequeue buffer failed); break; } // 到这里buffers[buf.index].start 就是一帧图像数据 // 帧大小为 buf.bytesused 字节 printf(frame %d: size%d bytes\n, frame_count, buf.bytesused); // 实际项目中这里会把 buf 里的数据交给编码器/网络模块处理 if (xioctl(fd, VIDIOC_QBUF, buf) 0) { perror(requeue buffer failed); break; } } type V4L2_BUF_TYPE_VIDEO_CAPTURE; xioctl(fd, VIDIOC_STREAMOFF, type); for (int i 0; i BUFFER_COUNT; i) { munmap(buffers[i].start, buffers[i].length); } close(fd); return 0; }2.3 这段采集代码里几个值得琢磨的细节先看打开设备那一步。我这里用了O_NONBLOCK非阻塞模式然后靠select()来等待帧数据准备好。如果不用非阻塞模式VIDIOC_DQBUF会在没有帧数据时一直卡住主线程就被堵死了。用select()的好处是能设置超时时间一旦摄像头异常比如线松了、设备被占用程序不至于挂死可以及时报错处理。实际项目里我还会把超时后的重连逻辑加进去比如连续超时 5 次就自动重新打开设备这对长时间运行的监控设备特别重要。然后是缓冲区机制。V4L2 经典的REQBUFS - QUERYBUF - mmap - QBUF - DQBUF流程本质上是一个生产者消费者模型摄像头硬件是生产者往 DMA 缓冲区里写图像数据应用层是消费者从缓冲区里把数据取走。用多个缓冲区这里设了 4 个能避免一帧数据还没处理完下一帧就被覆盖的情况。缓冲区数量也不是越多越好多了占内存少了容易丢帧4 到 8 个是比较合理的区间。还有一个常被忽略的坑VIDIOC_S_FMT设置格式不保证一定成功特别是某些摄像头不支持你指定的分辨率和像素格式时驱动会静默改成自己支持的最接近值。所以设置完格式之后一定要再调用VIDIOC_G_FMT读回来确认一下实际生效的参数不然你按 1080p 分配缓冲区摄像头却输出 640x480后面全是内存越界问题排查起来非常酸爽。3. 推流方案的技术选型GStreamer 硬编码管线 vs FFmpeg 软编码采集到原始帧之后画面数据是有了但离WiFi 推流还有一道关键工序编码成 H.264/H.265 再封装成适合网络传输的流格式RTSP/RTP/FLV。市面上主流的选择就是 GStreamer 和 FFmpeg 这两个框架我这次两个方案都实测过下面分别说说优缺点和适用场景。3.1 GStreamer RK3588 硬件编码性能和效率的首选RK3588 的 VPU 在 GStreamer 里对应的插件是mpph264encMpp 是 Rockchip 媒体处理平台的简称。用 GStreamer 拼一条推流管线核心思路就是v4l2src 采集视频 - 像素格式转换 - mpph264enc 硬编码 - rtph264pay 打包 - udpsink 或 rtsp 服务端一条完整的命令行推流示例推 UDP 到局域网内的一台 PC 上用 VLC 接收gst-launch-1.0 v4l2src device/dev/video0 ! \ video/x-raw,width1920,height1080,framerate30/1 ! \ videoconvert ! \ video/x-raw,formatI420 ! \ mpph264enc bitrate2000000 ! \ h264parse ! \ rtph264pay config-interval1 pt96 ! \ udpsink host192.168.1.100 port5000这里解释一下每个插件的用途v4l2src从 V4L2 设备采集视频帧。注意如果你把摄像头格式设成了 MJPG这里还需要插入jpegdec解压成原始图像否则后面的编码器不认。常见的坑是视频格式没有协商好看到Internal data stream error多半就是格式协商不通过解决办法是在 Caps 里明确指定video/x-raw的格式和分辨率。videoconvert做像素格式转换。摄像头的输出是 YUYV而 H.264 编码器输入通常要求 I420这中间差一步转换。别小看这个插件它在 CPU 上做转换的时候也有开销如果能直接在采集端拿到 I420 格式会好一点但大部分 UVC 摄像头原生只给 YUYV 或 MJPEG。mpph264encRK3588 的硬编码器bitrate参数直接控制码率。我实测 2Mbps 下 1080p30 的画面在室内场景基本上画质能接受运动剧烈一点的场景建议提到 4Mbps。h264parsertph264pay把 H.264 裸流封装成 RTP 包这样才能走 UDP 推给播放器。udpsink把 RTP 包发到指定主机的指定端口。这种 GStreamer 管线的好处是完全不用写代码一条命令就能跑起来而且硬编码对 CPU 占用极低。我在 RK3588 上实测1080p30 硬编码推流时四个 A76 大核的 CPU 占用率加起来不超过 15%大部分时候维持在 5% 以内真的是杀鸡用牛刀级别的轻松。3.2 FFmpeg x264 软编码兼容性优先的备选方案如果你不想在板子上装一堆 GStreamer 插件或者你的摄像头输出的格式比较奇葩FFmpeg 是更大众的选择。在 RK3588 上FFmpeg 也可以调用 Rockchip 的硬件编码器但需要专门编译带--enable-rkmpp选项的版本很多发行版自带的 FFmpeg 默认不启用这个能力。不带硬件加速的话直接用软件 x264 编码也能跑命令如下ffmpeg -f v4l2 -input_format yuyv422 -video_size 1920x1080 -framerate 30 \ -i /dev/video0 -c:v libx264 -preset veryfast -b:v 2M \ -f rtsp rtsp://192.168.1.100:8554/live这里-preset veryfast是为了降低编码延迟x264 的编码速度直接决定实时性表现。我的实测结果是在 RK3588 上用veryfast档位编码 1080p30CPU 占用大约 30% 到 40%比硬编码高不少但还在可接受范围。如果你用的是性能更弱的板子这个方案可能就撑不住了。FFmpeg 方案最大的优势是协议支持全面。不管你要推 RTSP、RTMP 还是直接录成 MP4 文件FFmpeg 一个工具全搞定而且参数调起来非常直观适合快速验证。但它在低延迟场景下表现不如 GStreamer 的纯 RTP 管线灵活因为 FFmpeg 的 RTSP 服务模式是转发模式内部会有缓冲延迟通常会比 GStreamer 的 UDP 直推高一截。3.3 我最终的选择双路并行策略这个项目最终我采用了GStreamer 做主干推流FFmpeg 做辅助录制的组合方案。主干推流用 GStreamer mpph264enc 保证低延迟和低 CPU 占用同时在板子上定时用 FFmpeg 拉取本地的 RTSP 流做切片录制这样既满足了实时监看的延迟要求又保证了关键时刻有录像留存。这种一鱼两吃的做法在真实项目里非常常见因为运维监控场景既要看得见也要留得住。4. WiFi 推流的网络痛点为什么局域网延迟时好时坏以及如何优化摄像头数据采集和编码都搞定之后最大的不可控因素就剩 WiFi 了。我最初在局域网里用普通 2.4GHz WiFi 测试的时候延迟经常在 200ms 到 2 秒之间反复横跳画面时不时卡顿一下一度以为是自己代码写的太烂后来排查发现根因在网络这一层。4.1 WiFi 频段选择对推流质量的影响2.4GHz 频段的穿透能力确实好但干扰源也多——蓝牙、微波炉、邻居的路由器全挤在 2.4GHz 这不到 100MHz 的频谱里环境稍微复杂一点WiFi 吞吐量就会剧烈抖动。而视频流最怕的就是吞吐量抖动因为直播类应用对带宽是持续占满型的需求不像网页浏览那样有突发性容忍度。我实测下来的数据WiFi 环境1080p30 推流延迟卡顿情况2.4GHz普通路由器多设备共存300ms - 2s频繁轻微卡顿偶尔爆卡5GHz路由器较近3 米内100ms - 300ms基本流畅5GHz穿一堵墙300ms - 800ms轻微卡顿建议是做 WiFi 推流时板子和路由器之间尽量用 5GHz 频段并且缩短物理距离。如果只能走 2.4GHz那就降低推流码率和分辨率比如 720p 加 1Mbps 码率老老实实换体验。另外注意一个容易忽略的点RK3588 开发板上的无线网卡很多是同时支持 AP 和 STA 模式的也就是板和手机可以组成一个独立的 WiFi 热点网络。在户外没有路由器的时候可以让板子开一个 AP手机连上板的 WiFi 热点就能直接看视频流。这种无基础设施的玩法在无人机图传、车载监控等场景特别实用而且因为跳过了路由器转发数据路径更短延迟反而更低。4.2 TCP 还是 UDP这是个问题推流传输层协议的选择同样关键。GStreamer 里如果推 RTSP底层默认走 TCP可靠传输但重传机制会带来额外延迟如果要低延迟可以用 UDP 直推不可靠传输丢包就直接丢帧但不卡顿。TCP 和 UDP 在视频推流场景下的表现差异非常大。TCP 虽然不会丢包但一旦网络出现瞬时拥塞TCP 的拥塞控制算法会主动降低发送速率导致视频延迟节节攀升画面虽然不花屏但越来越卡顿其实是延迟越来越大。UDP 则不会做这种退避丢掉的包直接丢弃播放端画面虽然可能偶发花屏、马赛克但时间线上是实时的不会有延迟持续积累的问题。我的经验是内网可控环境用 UDP广域网环境用 TCP。内网丢包率极低UDP 完全够用公网上丢包不可避免TCP 重传至少保证画面完整延迟高一点也能接受。4.3 码率匹配策略别让编码输出和网络带宽打架很多人做推流时只关注编码参数忽略了网络带宽这个木桶短板。RK3588 的 mpph264enc 设置的固定码率在 2Mbps但如果 WiFi 的有效吞吐只有 1.5Mbps那画面必然卡顿。这里建议在应用层做一层自适应码率控制定期检测网络的往返时延RTT和发送队列的堆积情况如果 RTT 持续偏高就把编码器的bitrate参数动态调低例如从 2Mbps 降到 1.2Mbps让画面更流畅。在 GStreamer 里可以通过mpph264enc的bitrateproperty 动态设置gst-launch-1.0 v4l2src device/dev/video0 ! \ video/x-raw,width1280,height720,framerate30/1 ! \ videoconvert ! video/x-raw,formatI420 ! \ mpph264enc bitrate1200000 ! \ h264parse ! rtph264pay config-interval1 pt96 ! \ udpsink host192.168.1.100 port50004.4 网络缓冲区的隐藏雷区还有一个坑我从没见文档里写明过GStreamer 的udpsink插件默认有一个发送缓冲区如果网络拥堵导致数据堆积缓冲区会被填满然后udpsink会开始丢包。这种行为本身没问题但会导致画面突然出现大范围花屏。解决办法是通过udpsink的max-lateness和qos参数控制发送节奏让过期的帧直接丢弃而不是堆积等待。加入 QoS服务质量机制后播放端收到的帧是最新鲜的延迟会明显下降代价是网络差时帧率会掉。命令行加参数的方式udpsink host192.168.1.100 port5000 qostrue max-lateness10000000这里的max-lateness单位是纳秒10000000纳秒就是 10ms超过这个时间的帧直接丢。经过这样调整后WiFi 环境下的画面流畅度和延迟一致性提升很明显。5. 完整代码实现推流主程序的架构设计和关键模块拆解前面讲了不少概念和命令行方案下面写一个稍微体面的 C 程序把这些要素整合起来。这个程序不依赖 GStreamer 的命令行而是直接用 V4L2 采集 RKMPP 硬编码 RTP 打包可以理解成一个精简版的推流器。5.1 程序整体架构整个推流程序分四个线程采集线程V4L2 循环取帧放进帧队列。编码线程从帧队列取出原始帧交给 RKMPP 硬编码为 H.264放进编码帧队列。发送线程从编码帧队列取 H.264 数据封装成 RTP 包通过 UDP socket 发出。控制线程响应命令行输入如修改码率、打印状态实现简单的运行管理。用多线程而非单线程循环是因为采集、编码和网络发送三个环节的速度不恒等如果串行执行任何一环变慢都会拖垮整条链路。用队列解耦之后即使网络短暂拥塞编码线程也可以继续工作把数据积压在编码帧队列里一旦网络恢复发送线程能快速追上进度。5.2 关键结构体和队列封装#include pthread.h #include semaphore.h #include stdint.h #include rga/RgaApi.h #include rockchip/rk_mpi.h #define MAX_QUEUE_SIZE 8 typedef struct { void *data; size_t size; uint64_t pts; } frame_t; typedef struct { frame_t frames[MAX_QUEUE_SIZE]; int head; int tail; int count; pthread_mutex_t lock; sem_t empty_slots; sem_t full_slots; } frame_queue_t; void queue_init(frame_queue_t *q) { q-head 0; q-tail 0; q-count 0; pthread_mutex_init(q-lock, NULL); sem_init(q-empty_slots, 0, MAX_QUEUE_SIZE); sem_init(q-full_slots, 0, 0); } void queue_push(frame_queue_t *q, frame_t *frame) { sem_wait(q-empty_slots); pthread_mutex_lock(q-lock); q-frames[q-tail] *frame; q-tail (q-tail 1) % MAX_QUEUE_SIZE; q-count; pthread_mutex_unlock(q-lock); sem_post(q-full_slots); } int queue_pop(frame_queue_t *q, frame_t *frame) { sem_wait(q-full_slots); pthread_mutex_lock(q-lock); if (q-count 0) { pthread_mutex_unlock(q-lock); return -1; } *frame q-frames[q-head]; q-head (q-head 1) % MAX_QUEUE_SIZE; q-count--; pthread_mutex_unlock(q-lock); sem_post(q-empty_slots); return 0; }用信号量 互斥锁组合的方式实现了一个经典的有界阻塞队列。这里没有用无锁队列因为赛道上这几个线程的数据量并不算极端加锁完全可以接受。如果将来要做 4K 60fps 那种高吞吐场景再考虑无锁队列或者环形缓冲区也不迟。5.3 采集线程的实现采集线程就是把之前那段 V4L2 代码放到线程函数里然后每取到一帧塞进raw_frame_queue。void *capture_thread(void *arg) { // 参数设备路径帧队列指针 char *dev_path ((char **)arg)[0]; frame_queue_t *q (frame_queue_t *)(((void **)arg)[1]); int fd open(dev_path, O_RDWR | O_NONBLOCK, 0); if (fd 0) { perror(open device failed); return NULL; } // 设置 1080p YUYV 格式 struct v4l2_format fmt; memset(fmt, 0, sizeof(fmt)); fmt.type V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width 1920; fmt.fmt.pix.height 1080; fmt.fmt.pix.pixelformat V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field V4L2_FIELD_NONE; xioctl(fd, VIDIOC_S_FMT, fmt); // 读回实际格式并检查 struct v4l2_format actual; memset(actual, 0, sizeof(actual)); actual.type V4L2_BUF_TYPE_VIDEO_CAPTURE; xioctl(fd, VIDIOC_G_FMT, actual); if (actual.fmt.pix.width ! 1920 || actual.fmt.pix.height ! 1080) { fprintf(stderr, Warning: actual format is %dx%d\n, actual.fmt.pix.width, actual.fmt.pix.height); } // 申请缓冲区、mmap、QBUF 等同前文示例代码 // 采集循环 struct v4l2_buffer buf; for (;;) { fd_set fds; FD_ZERO(fds); FD_SET(fd, fds); struct timeval tv {2, 0}; select(fd 1, fds, NULL, NULL, tv); memset(buf, 0, sizeof(buf)); buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; if (xioctl(fd, VIDIOC_DQBUF, buf) 0) { continue; } frame_t frame; frame.data malloc(buf.bytesused); memcpy(frame.data, buffers[buf.index].start, buf.bytesused); frame.size buf.bytesused; frame.pts (uint64_t)((double)clock() / CLOCKS_PER_SEC * 1000); queue_push(q, frame); xioctl(fd, VIDIOC_QBUF, buf); } }这里注意我从 mmap 缓冲区里memcpy了一份数据到堆内存然后才入队。为什么不直接把 mmap 缓冲区地址传下去因为缓冲区在 DQBUF 之后再次 QBUF 时硬件可能立刻往里写下一帧如果编码线程还在读这块内存就会产生数据竞争。要么加锁保护要么拷贝一份我选择了性价比更高的拷贝方式——内存占用多一点但逻辑简单安全。5.4 编码线程调用 RKMPP 硬编码RKMPPRockchip Media Process Platform是 Rockchip 官方提供的多媒体编程接口。相比直接用 V4L2 的VIDIOC_ENCODER_CMD或者 GStreamer 插件RKMPP 的 API 更贴近底层适合做精细控制。编码线程的核心代码void *encode_thread(void *arg) { frame_queue_t *input_q (frame_queue_t *)(((void **)arg)[0]); frame_queue_t *output_q (frame_queue_t *)(((void **)arg)[1]); MppCtx ctx; MppApi *mpi; mpp_create(ctx, mpi); mpp_init(ctx, MPF_CTX_ENC, MPP_VIDEO_CodingAVC); MppEncPrepCfg prep_cfg; memset(prep_cfg, 0, sizeof(prep_cfg)); prep_cfg.width 1920; prep_cfg.height 1080; prep_cfg.format MPP_FMT_YUV420SP; prep_cfg.frame_rate 30; mpi-control(ctx, MPP_ENC_SET_PREP_CFG, prep_cfg); MppEncCodecCfg codec_cfg; memset(codec_cfg, 0, sizeof(codec_cfg)); codec_cfg.codec_type MPP_VIDEO_CodingAVC; codec_cfg.h264.profile 100; codec_cfg.h264.level 40; codec_cfg.h264.cabac_en 1; codec_cfg.h264.cabac_idc 0; codec_cfg.h264.trans_8x8 1; mpi-control(ctx, MPP_ENC_SET_CODEC_CFG, codec_cfg); MppEncRcCfg rc_cfg; memset(rc_cfg, 0, sizeof(rc_cfg)); rc_cfg.rc_mode MPP_RC_CBR; rc_cfg.bps 2000000; rc_cfg.bps_max 2500000; rc_cfg.bps_min 1500000; mpi-control(ctx, MPP_ENC_SET_RC_CFG, rc_cfg); // 帧数据搬运到 MPP 缓冲区并编码 frame_t raw_frame; while (!stop_flag) { if (queue_pop(input_q, raw_frame) 0) continue; // 将 YUYV 转为 NV12RKMPP 输入格式 // 这里也可以用 RGA 硬件加速下文会提到 uint8_t *nv12_data convert_yuyv_to_nv12( (uint8_t *)raw_frame.data, 1920, 1080); // 申请 MPP 编码使用的输入包 MppPacket packet NULL; mpp_packet_init(packet, nv12_data, 1920 * 1080 * 3 / 2); mpi-encode_put_packet(ctx, packet); MppPacket out_packet NULL; mpi-encode_get_packet(ctx, out_packet); if (out_packet) { frame_t enc_frame; enc_frame.data malloc(mpp_packet_get_size(out_packet)); memcpy(enc_frame.data, mpp_packet_get_data(out_packet), mpp_packet_get_size(out_packet)); enc_frame.size mpp_packet_get_size(out_packet); enc_frame.pts raw_frame.pts; queue_push(output_q, enc_frame); mpp_packet_deinit(out_packet); } free(nv12_data); free(raw_frame.data); mpp_packet_deinit(packet); } mpp_destroy(ctx); return NULL; }5.5 发送线程RTP 封包和 UDP 发送RTP 封包这部分H.264 的 NAL 单元如果大于 MTU通常 1500 字节就需要做分片FU-A否则播放端无法正确重组。这里实现一个简化的分包逻辑void send_h264_rtp(int sockfd, struct sockaddr_in *dst, uint8_t *nal_data, size_t nal_size, uint32_t *timestamp) { // 跳过 NAL 起始码 00 00 00 01 或 00 00 01 uint8_t *nal_start nal_data; while (nal_start nal_data nal_size - 4) { if (*(uint32_t *)nal_start 0x00000001 || *(uint32_t *)nal_start 0x00000100) { break; } nal_start; } size_t header_size (nal_start[2] 1) ? 3 : 4; uint8_t *payload nal_start header_size; size_t payload_size nal_size - (payload - nal_data); uint8_t nal_header payload[0]; uint8_t nal_type nal_header 0x1f; if (payload_size MAX_RTP_PAYLOAD) { // 单包封装 uint8_t rtp_buf[1500]; rtp_buf[0] 0x80; rtp_buf[1] 96; // payload type PT96 rtp_buf[2] (uint8_t)((*timestamp) 8); rtp_buf[3] (uint8_t)(*timestamp); rtp_buf[4] 0x00; rtp_buf[5] 0x00; rtp_buf[6] 0x00; rtp_buf[7] 0x01; // SSRC memcpy(rtp_buf 12, payload, payload_size); sendto(sockfd, rtp_buf, 12 payload_size, 0, (struct sockaddr *)dst, sizeof(*dst)); } else { // FU-A 分片封装 uint8_t fu_indicator (nal_header 0xe0) | 28; uint8_t fu_header; uint8_t rtp_buf[1500]; size_t offset 0; int first 1; while (offset payload_size) { size_t chunk_size payload_size - offset; if (chunk_size MAX_RTP_PAYLOAD - 2) chunk_size MAX_RTP_PAYLOAD - 2; rtp_buf[0] 0x80; rtp_buf[1] 96; rtp_buf[2] (uint8_t)((*timestamp) 8); rtp_buf[3] (uint8_t)(*timestamp); // ... 填充 RTP header fu_header (nal_type 0x1f); if (first) fu_header | 0x80; if (offset chunk_size payload_size) fu_header | 0x40; rtp_buf[12] fu_indicator; rtp_buf[13] fu_header; memcpy(rtp_buf 14, payload offset, chunk_size); sendto(sockfd, rtp_buf, 14 chunk_size, 0, (struct sockaddr *)dst, sizeof(*dst)); offset chunk_size; first 0; } } }5.6 完整示例代码仓库结构这个程序如果完整写完代码量在 500 行左右。一个合理的工程目录结构可以是rtsp_streamer/ ├── Makefile ├── src/ │ ├── main.c # 主函数、线程创建 │ ├── v4l2_capture.c # 采集模块 │ ├── encoder_mpp.c # RKMPP 编码模块 │ ├── rtp_sender.c # RTP 打包发送模块 │ └── queue.c # 帧队列 └── include/ └── streamer.h # 公共头文件6. 实测结果延迟、CPU 占用和画面质量表现写代码是一回事跑起来看到真实数据是另一回事。我在 RK3588 开发板上做了一轮完整的压力测试从推流延迟、CPU 占用、画面质量三个维度记录数据以下结果供你参考。测试环境RK3588 开发板8GB RAM板载 WiFi 6 模块连接 5GHz 路由器接收端为一台 PC 上的 VLC通过 WiFi 连接同一个路由器。摄像头为 1080p30 USB UVC 摄像头。6.1 延迟测试用秒表对着屏幕录制分别观察实际画面动作和 VLC 显示画面的时间差推流方式平均延迟最大延迟备注GStreamer UDP 硬编码180ms350ms流畅偶发小卡顿GStreamer TCP RTSP 硬编码450ms1200ms画面完整延迟波动大FFmpeg RTSP 软编码800ms2500ms延迟最高CPU 占用高GStreamer UDP 方案在延迟表现上一骑绝尘这也是为什么很多低延迟图传方案倾向于走 UDP RTP 的原因。6.2 CPU 占用测试用top和mpstat观察系统负载场景CPU 平均占用A76 核心负载A55 核心负载空闲仅系统2%低低GStreamer 硬编码推流12% - 18%中等低FFmpeg x264 软编码推流55% - 70%高中硬编码的优势非常明显RK3588 的 VPU 把最重的编码工作接管了A76 核心只需要跑 V4L2 采集、格式转换和 RTP 打包这些轻量任务。6.3 画面质量主观评价在 2Mbps 固定码率下1080p30 的画面在室内静止场景几乎无可见噪点文字边缘清晰。运动场景下比如快速挥手或者走动画面会有轻微马赛克但整体可接受。如果改成动态码率VBR码率上限放宽到 4Mbps运动场景的马赛克会显著减少代价是 WiFi 吞吐压力增大。7. 踩坑记录V4L2 采集和 RKMPP 编码对接时我遇到过的 5 个大坑这个项目看着代码量不大但中间踩的坑绝对够写一篇单独的排错笔记了。我把印象最深的几个问题列出来希望能帮你少走弯路。7.1 格式转换瓶颈YUYV 转 NV12 的 CPU 耗时过高采集端拿到的是 YUYV 格式RKMPP 编码器输入一般是 NV12 格式YUV420SP中间必须做一次转换。我最初用纯 C 写了一个转换函数实测 1080p 一帧在 RK3588 上要花 8 到 10ms虽然单看还行但叠加上采集、编码、发送之后整个链路延迟就上去了而且 CPU 占用率明显升高。后来改用 RK3588 的 RGA 硬件加速模块librga做格式转换耗时直接降到 1ms 以下CPU 占用几乎为零。如果你在 RK3588 上做视频应用记住能用硬件加速的转换和缩放别用 CPU 硬凑。RGA 的调用方式和 DMA 类似拷贝图像数据的同时指定源格式和目标格式即可。注意RGA 的驱动在不同内核版本上 API 略有差异编译前确认一下你的板子内核版本对应的 librga 版本不然编译报错或者运行时报RGA init failed会让人头大。7.2 mpph264enc 硬编码器在低码率下出现花屏这是一个让我排查了两天的问题码率设置到 1Mbps 以下时H.264 硬编码输出的画面会周期性出现横条纹花屏。最开始以为是 WiFi 丢包但本地录制也有这个问题。后来查文档和社区发现是 RKMPP 编码器对低码率场景下的参考帧管理策略有问题解决办法是调整 GOPGroup of Pictures大小把默认的 2 秒一个 I 帧改成 1 秒一个 I 帧gst-launch-1.0 v4l2src device/dev/video0 ! \ video/x-raw,width1280,height720,framerate30/1 ! \ videoconvert ! video/x-raw,formatI420 ! \ mpph264enc bitrate800000 gop30 ! \ h264parse ! rtph264pay config-interval1 pt96 ! \ udpsink host192.168.1.100 port5000gop30表示每 30 帧一个关键帧也就是 1 秒一个 I 帧。I 帧频率增加会略微降低压缩率但能显著改善低码率下的花屏现象。7.3 udev 权限问题非 root 用户打不开 /dev/video0开发板到手后我用普通用户运行推流程序一直报open video device failed: Permission denied。查了一圈发现默认 udev 规则没有给 video 设备添加 user 权限。解决方法是在/etc/udev/rules.d/下新建规则文件KERNELvideo*, SUBSYSTEMvideo4linux, MODE0666然后重启 udev 或重插摄像头。这个坑几乎每个做嵌入式视频开发的人都会遇到直接加sudo chmod 666 /dev/video0能临时解决但重启就失效用 udev 规则是正解。7.4 select 超时之后摄像头驱动挂死的恢复策略在我的采集程序里如果摄像头被意外拔出USB 接触不良select()会一直超时即使重新插上驱动状态也未必能自动恢复。我最后实现的策略是连续 10 次 select 超时后主动关闭 fd 并重新打开/dev/video0重新走一遍格式设置和缓冲申请流程。这个软重启逻辑听起来很粗暴但实测非常有效在长时间运行的设备上能避免 99% 的摄像头失联问题。7.5 RKMPP 输入帧尺寸对齐问题RKMPP 编码器对输入图像的宽高有对齐要求一般要求 16 像素对齐。如果摄像头输出的分辨率是 1920x1080对齐没问题但如果换成 640x480正好也对齐反而是一些分辨率为 1280x720 或者 2592x1944 这样的尺寸偶尔会触发对齐异常。程序在启动时最好先查一下编码器支持的对齐要求或者直接把输入裁剪/缩放到一个安全的对齐分辨率。RGA 硬件缩放这时候就派上用场了缩放的同时还能完成格式转换一举两得。8. 进阶玩法在 RK3588 上给视频流加上 AI 目标检测聊完基础推流最后说一个非常自然的扩展方向——RK3588 自带 NPU很多人拿到这块板子不仅是为了做普通摄像头推流还想在端侧做 AI 分析比如检测人体、车辆然后在检测到目标时才触发推流或者标记画面区域。RK3588 的 NPU 支持 RKNN 格式的模型常见的 YOLOv8 等检测模型可以转换成 RKNN 在 NPU 上运行。部署流程大概是在 PC 上训练或下载预训练模型如 YOLOv8n。用rknn-toolkit2把 PyTorch 或 ONNX 模型转换成 RKNN 模型。板子上调用 RKNN Runtime C/Python API 对视频帧做推理。在采集线程和编码线程之间插入一个AI 检测节点检测结果可以叠加到画面或者作为触发条件。将 AI 推理嵌入到 GStreamer 管线里可以自定义一个 GStreamer 插件也可以简单地在应用层做取一帧 → 推理 → 有目标才编码推流。后者简单直接适合快速验证前者性能更好、延迟更低适合正式产品。这里有一个设计经验推理频率不需要和帧率一致。比如视频是 30fps但检测可以每 5 帧做一次即 6fps 的检测帧率对大多数监控场景完全够用。这样可以大幅降低 NPU 功耗和发热对那些用电池供电的移动设备尤为重要。实测在 RK3588 上跑 YOLOv8n输入 640x640NPU 推理单帧大约 20ms 到 30ms按 6fps 的策略NPU 占用率不到 20%CPU 也完全不受影响推流和检测并行毫无压力。我在实际使用中发现把 AI 检测叠加到推流链路之后最值得优化的不是推理速度而是检测结果的时空平滑。直接对每一帧独立检测会导致目标框闪烁跳跃观感很差。最简单的平滑策略是用卡尔曼滤波或指数移动平均EMA对目标框的坐标做低通滤波。这个 trick 能让目标框贴合目标运动轨迹效果提升非常明显而计算成本几乎可以忽略。另一个体会是端侧 AI 检测的价值不只是识别更是决策。检测到目标之后可以联动其他动作比如有陌生人进入时立刻提高码率、切换到高清流并开始本地录像平时则用低码率流维持基本监控。这种按需分配资源的设计让有限的 WiFi 带宽和板端算力都花在刀刃上。最后再分享一个小技巧如果把推流程序和 AI 检测程序做成两个独立进程运行时的调试会轻松很多——检测模型版本升级不会影响推流稳定性推流异常也不至于让 AI 进程一起崩掉。进程间通信用共享内存传帧性能损失也很小。这种模块独立部署的思路和大系统里的微服务架构是一个意思只不过在嵌入式场景下我们要用更轻量的方式实现而已。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表