在嵌入式视觉项目里,经常会遇到这样一个需求:
相机通过 RTSP 输出 H.265,RK3588 负责实时预览,同时还要把原始视频保存成 MP4,并且每 10 分钟自动切一个文件。
看起来并不复杂,但真正放到 RK3588 + Qt5 环境里,往往会碰到不少问题:
- Qt 自带播放器播放不稳定;
- H.265 硬解链路不好控制;
- 预览一旦卡顿,录像也跟着受到影响;
- MP4 分段后出现文件无法播放;
- 程序退出时最后一个 MP4 损坏;
- U 盘拔出后文件变成 0KB;
- RTSP 网络抖动以后,整个 Pipeline 卡死。
这篇文章不打算把 GStreamer 每个插件都讲一遍,而是直接围绕一个实际工程问题来看:
RK3588 上,如何搭一套“低延迟预览 + 无二次编码 MP4 分段录像”的视频链路。
一、先明确我们的目标
假设现在有一台相机:
Camera
│
│ RTSP / H.265
▼
RK3588
│
├── 实时预览 → Qt5
│
└── 视频录像 → MP4
我们希望最终做到:
- RTSP 视频低延迟显示;
- H.265 使用 RK3588 硬件解码;
- 预览和录像互不影响;
- 录像尽可能不重新编码;
- 每 10 分钟生成一个 MP4;
- 程序正常退出后,MP4 可以正常播放;
- 网络出现短暂抖动时能够恢复。
其中有一个非常关键的原则:
能不编码,就不要编码。
相机已经输出 H.265 了,如果 RK3588 再把 H.265 解码成 YUV,然后重新编码成 H.265 再保存,那么 CPU/GPU/NPU 之外还会增加编码器负担。
而我们真正需要的只是:
H.265
├──→ 解码 → 预览
│
└──→ 直接封装 → MP4
也就是说:
预览需要解码,录像不需要解码。
二、先看整个 Pipeline
一个比较典型的方案是:
┌──→ MPP硬解 → videoconvert → appsink → Qt
│
RTSP → H265 Depay → Parse → Tee
│
└──→ Queue → H265 → MP4Mux → SplitMuxSink
对应 GStreamer Pipeline:
rtspsrc location=rtsp://... latency=100 protocols=tcp !
rtph265depay !
h265parse config-interval=1 !
tee name=t
t. ! queue !
mppvideodec !
videoconvert !
appsink name=video_sink
emit-signals=true
sync=false
max-buffers=2
drop=true
t. ! queue !
video/x-h265,stream-format=hvc1 !
splitmuxsink
location=record/%05d.mp4
max-size-time=600000000000
muxer-factory=mp4mux
这里实际上有两条完全不同的链路。
预览链路
RTSP
↓
H265
↓
MPP硬解
↓
YUV
↓
appsink
↓
Qt
录像链路
RTSP
↓
H265
↓
h265parse
↓
splitmuxsink
↓
mp4mux
↓
MP4
这套设计最大的好处,就是:
录像完全绕过解码器。
三、为什么不用 Qt5 自己播放?
最开始很多项目都会想到:
QMediaPlayer
毕竟 Qt 自己已经提供了播放器。
但在嵌入式 Linux 上,这条路经常没有 PC 上那么顺利。
尤其是 RK3588 这种平台,真正的视频解码能力往往依赖:
Rockchip MPP
而 Qt Multimedia 是否能够正确使用对应的硬解链路,还取决于具体 Qt 版本、GStreamer 后端、系统镜像以及插件配置。
最终很容易出现:
Qt
↓
QMediaPlayer
↓
GStreamer
↓
不知道走了什么解码器
↓
CPU占用升高 / 延迟增加 / 播放异常
所以在工程项目里,与其把所有控制权交给 QMediaPlayer,不如直接控制 GStreamer Pipeline。
Qt 负责:
UI
窗口
用户操作
状态显示
GStreamer 负责:
RTSP
解包
解析
解码
缓存
录像
两者通过:
appsink
连接起来。
这样整个架构会清晰很多。
四、预览:为什么使用 appsink?
预览 Pipeline 最核心的一段:
mppvideodec !
videoconvert !
appsink
这里的 appsink 可以理解成:
把 GStreamer 里的视频帧交给 Qt/C++ 程序自己处理。
例如:
GStreamer
│
│ GstSample
▼
appsink
│
▼
C++ / Qt
│
▼
QImage / QVideoFrame
│
▼
QWidget / QOpenGLWidget
这样我们就不会被某个特定的 GStreamer Video Sink 绑死。
五、低延迟的关键:不要让帧排队
实时预览最怕的事情其实不是丢帧。
而是:
延迟越来越大。
比如摄像头现在拍到:
14:00:00.000
结果 UI 显示的是:
13:59:55.000
虽然画面还在动,但已经落后 5 秒。
这对于监控、吊舱、机器人来说基本不可接受。
因此 appsink 可以设置:
max-buffers=2
drop=true
意思大致就是:
我只允许你这里积压很少的帧,如果消费者处理不过来,就丢掉旧帧。
例如:
Camera
↓
Frame 100
Frame 101
Frame 102
Frame 103
Frame 104
↓
Qt处理比较慢
如果没有限制:
100 → 101 → 102 → 103 → 104 → ...
队列不断积累,延迟越来越高。
而设置:
max-buffers=2
drop=true
之后更接近:
100
101
↓
丢弃旧帧
↓
104
我们真正关心的是:
现在这一帧。
而不是:
几秒钟以前那一帧。
六、Qt 侧最好采用“最新帧”思路
如果 UI 线程处理能力有限,也不要设计成:
每来一帧
↓
必须处理
↓
处理完再处理下一帧
更适合实时视频的方式是:
GStreamer线程
│
▼
最新帧
│
│ 覆盖
▼
最新帧
│
▼
Qt定时器
│
▼
显示
这其实就是一种“黑板模式”。
例如:
GStreamer:
frame 100
frame 101
frame 102
frame 103
↓
latestFrame
Qt:
每 30ms
↓
取 latestFrame
↓
显示
如果 Qt 某一次处理慢了:
frame 100
frame 101
frame 102
frame 103
frame 104
不需要把 100、101、102、103 全部显示一遍。
直接显示:
frame 104
这才是真正意义上的低延迟。
七、录像为什么可以不解码?
这是整个方案里面最值得注意的地方。
预览需要:
H.265 → YUV
但 MP4 本身可以存储 H.265。
所以录像完全可以:
H.265
↓
h265parse
↓
mp4mux
↓
MP4
也就是说:
┌──→ 解码 → Qt预览
│
H.265 → Parse → Tee
│
└──→ MP4封装 → 文件
没有:
H265 → Decode → Encode → H265
自然就省掉了一次编码。
对于 RK3588 这种嵌入式设备来说,这一点非常重要。
八、为什么使用 tee?
因为我们需要“一份数据,两条路”。
GStreamer 的:
tee
就是干这个事情的。
例如:
tee name=t
之后:
t. ! queue ! preview
t. ! queue ! record
于是:
┌── preview
│
H265 → Parse → Tee ──┤
│
└── record
这里有一个非常重要的细节:
tee 后面一定要考虑 queue。
不要简单写成:
tee
├── preview
└── record
更推荐:
tee
├── queue → preview
└── queue → record
因为两个分支的处理速度完全可能不同。
例如:
预览:正常
录像:磁盘突然变慢
如果没有 queue,录像分支的阻塞可能影响整个 Pipeline。
加上 queue 后,可以把两个分支进行一定程度的解耦。
九、splitmuxsink 是干什么的?
录像部分:
splitmuxsink
是这套方案里非常关键的插件。
它解决的问题就是:
如何持续写 MP4,同时按照时间自动切成多个文件。
例如:
record/
├── 00000.mp4
├── 00001.mp4
├── 00002.mp4
├── 00003.mp4
└── ...
每个文件 10 分钟。
对应:
max-size-time=600000000000
注意:
GStreamer 的时间单位是纳秒。
10 分钟:
10 × 60 × 1,000,000,000
=
600,000,000,000 ns
所以:
max-size-time=600000000000
就是:
10分钟切一个文件
十、为什么不用 multifilesink?
这是实际项目中非常容易踩的坑。
很多人第一反应是:
H265
↓
multifilesink
↓
1.h265
2.h265
3.h265
这个确实可以保存数据。
但是我们最终需要的是:
MP4
而 MP4 并不是简单地把 H.265 数据写进去就结束了。
MP4 是一个完整的容器格式,需要维护:
metadata
sample table
duration
offset
moov
mdat
等信息。
所以:
multifilesink
更适合做:
裸流分片
图片序列
简单buffer输出
而对于:
MP4分段录像
更合适的是:
splitmuxsink + mp4mux
十一、一个比较完整的录像 Pipeline
可以写成:
rtspsrc location=rtsp://admin:password@192.168.1.100:554/stream \
latency=100 \
protocols=tcp !
rtph265depay !
h265parse config-interval=1 !
tee name=t
t. ! queue !
video/x-h265,stream-format=hvc1 !
splitmuxsink
location=record/%05d.mp4
max-size-time=600000000000
muxer-factory=mp4mux
这里:
rtspsrc
负责 RTSP。
rtph265depay
负责 RTP H.265 解包。
h265parse
负责 H.265 码流解析。
tee
负责分流。
queue
负责两个分支解耦。
mp4mux
负责 MP4 封装。
splitmuxsink
负责自动分段和文件管理。
十二、为什么 h265parse 很重要?
有些人会觉得:
rtph265depay
出来已经是 H.265 了,为什么还需要:
h265parse
?
因为后面的 MP4 封装以及解码器通常需要更明确、规范的码流信息。
因此更推荐:
rtph265depay
↓
h265parse
↓
后续处理
而不是:
rtph265depay
↓
直接写文件
另外:
config-interval=1
对于网络视频流也比较实用,可以周期性地提供编码配置参数,使得后续处理在断流、重新同步等场景下更加容易恢复。
十三、MP4 最后一个文件为什么容易出问题?
这是工程上非常常见的问题。
例如程序运行:
10:00
↓
10:10
↓
10:20
↓
10:27
于是:
00000.mp4 10分钟
00001.mp4 10分钟
00002.mp4 7分钟
前两个文件通常没问题。
但是如果程序在:
10:27
直接:
kill -9
或者设备突然断电:
掉电
最后一个 MP4 就可能出现:
文件存在
文件大小也不小
但是播放器打不开
原因就在于:
容器没有得到完整结束。
所以正常退出时,不应该简单地:
gst_element_set_state(pipeline, GST_STATE_NULL);
然后马上结束进程。
更合理的是让 Pipeline 正常处理:
EOS
让 muxer 有机会完成最后的容器信息写入。
当然,EOS 并不能解决突然断电问题。
如果项目对录像完整性要求非常高,还需要考虑:
- 文件系统;
- fsync;
- 电源异常;
- 存储介质;
- 分段策略;
- 恢复机制。
十四、U 盘录像还有一个坑
实际项目中经常会出现这样的情况:
录像结束
↓
文件生成
↓
用户拔U盘
↓
电脑打开
↓
文件0KB / 文件损坏
程序看到:
close()
并不意味着数据已经真正落到物理存储介质。
尤其是 Linux 的缓存机制,会让:
write()
和:
真正写入磁盘
之间存在时间差。
因此对于需要用户立即拔盘的场景,应该设计:
停止录像
↓
EOS
↓
等待文件关闭
↓
sync / fsync
↓
提示“可以拔出设备”
而不是:
停止录像
↓
立刻提示用户拔U盘
这在无人机、吊舱、机器人等设备上尤其重要。
十五、低延迟和录像稳定性其实是两个目标
这里有一个很容易产生误区的地方。
很多人会认为:
我要低延迟,所以整个 Pipeline 都应该把缓存降到最低。
实际上并不是。
预览和录像的需求不同。
预览
目标:
越低延迟越好
所以可以:
max-buffers=2
drop=true
sync=false
录像
目标:
不能丢数据
所以录像分支反而不能简单粗暴地:
drop=true
否则可能出现:
预览正常
但是录像丢帧
因此一个比较合理的思路就是:
┌──→ 低延迟预览
│
RTSP → Tee ──┤
│
└──→ 稳定录像
两条链路的缓存策略应该分别设计。
十六、RTSP 的 latency 也不能乱设
例如:
latency=100
表示 GStreamer 的 RTP 接收端允许一定的缓冲时间。
一般来说:
latency 越小
→ 延迟越低
→ 抗网络抖动能力越弱
反过来:
latency 越大
→ 延迟越高
→ 抗网络抖动能力更强
所以:
100ms
并不是一个绝对正确的数字。
实际项目需要根据:
网络质量
RTSP服务器
相机编码延迟
码率
帧率
无线/有线网络
进行调整。
如果是有线局域网,可以尝试比较低的值。
如果是无线链路或者网络质量不稳定,就需要适当增加。
十七、TCP 还是 UDP?
例如:
protocols=tcp
它的优点是稳定。
尤其是:
RTSP
+
H.265
+
无人机/吊舱
+
复杂网络环境
UDP 丢包以后,H.265 码流可能出现明显的花屏或者等待恢复。
TCP 虽然可能带来一定的延迟,但通常更容易获得稳定的视频流。
所以工程上经常会看到:
rtspsrc
protocols=tcp
latency=100
当然,如果你的网络环境非常稳定,并且对延迟极度敏感,也可以根据实际情况评估 UDP。
十八、RK3588 上为什么优先考虑 MPP 硬解?
RK3588 最大的优势之一,就是平台本身提供了专用的视频编解码硬件。
所以我们不希望变成:
RTSP
↓
H265
↓
CPU软件解码
↓
CPU占用很高
↓
Qt显示
而是:
RTSP
↓
H265
↓
Rockchip MPP
↓
硬件解码
↓
YUV
GStreamer 侧具体插件名称和可用属性,需要根据你的 Rockchip BSP、GStreamer 版本以及插件实现确认。
例如环境中可能看到:
mppvideodec
可以先检查:
gst-inspect-1.0 mppvideodec
不要直接认为网上某个 Pipeline 在你的系统上一定能跑。
嵌入式 Linux 最大的特点就是:
同样叫 RK3588,不同 BSP 上的软件环境可能完全不一样。
十九、真正工程化以后,还要解决断流重连
Demo 最简单的逻辑是:
启动Pipeline
↓
一直播放
但产品不能这么设计。
真实环境可能出现:
Camera重启
网络断开
交换机重启
RTSP服务异常
网线拔出
无线网络抖动
所以应该考虑:
┌──────────────┐
│ RTSP Pipeline│
└──────┬───────┘
│
正常?
/ \
YES NO
│ │
│ 重建
│ │
└────────┘
不要指望一个 GStreamer Pipeline 在所有异常情况下都能自动恢复。
工程上更稳妥的方案往往是:
监控 Pipeline 状态
↓
Bus 消息
↓
ERROR / EOS / STATE异常
↓
停止当前Pipeline
↓
释放资源
↓
重新创建
↓
重新连接RTSP
二十、最终推荐的架构
如果把整个方案整理一下,我比较推荐:
RK3588
│
RTSP H.265
│
▼
┌───────────┐
│ rtspsrc │
└─────┬─────┘
│
rtph265depay
│
h265parse
│
▼
┌───────┐
│ tee │
└───┬───┘
│
┌───────────┴───────────┐
│ │
queue queue
│ │
▼ ▼
mppvideodec H.265
│ │
videoconvert mp4mux
│ │
appsink splitmuxsink
│ │
▼ ▼
Qt 00000.mp4
UI 00001.mp4
00002.mp4
这个架构最大的特点就是:
预览和录像从 Pipeline 层面解耦。
预览慢了:
丢预览帧
而不是:
录像停止
磁盘慢了:
录像分支自己处理
而不是:
Qt画面一起卡住
二十一、最终的几个核心结论
如果只记住这几个东西就够了。
1. RTSP/H.265 预览
rtspsrc
→ rtph265depay
→ h265parse
→ mppvideodec
→ videoconvert
→ appsink
RK3588 上优先使用硬件解码。
2. MP4 录像
H265
→ h265parse
→ mp4mux
→ splitmuxsink
尽量不要二次编码。
3. 预览和录像必须分支
tee
├── queue → preview
└── queue → record
不要让 UI 和磁盘互相拖累。
4. 低延迟预览
appsink
max-buffers=2
drop=true
sync=false
核心思想不是“每一帧都不能丢”,而是:
宁可丢旧帧,也不要让延迟越来越大。
5. MP4 分段
splitmuxsink
+
mp4mux
比简单使用 multifilesink 更适合 MP4 分段录像。
6. 产品级还要考虑
断流
重连
EOS
文件完整性
fsync
U盘拔出
磁盘空间
异常退出
突然断电
这些东西,往往比 Pipeline 本身更容易成为真正的坑。
写在最后
GStreamer 真正难的地方,其实不是记住:
rtspsrc
rtph265depay
h265parse
mppvideodec
appsink
splitmuxsink
这些插件名称。
真正难的是理解每个环节应该承担什么职责。
对于 RK3588 上的相机应用,一个比较清晰的思路就是:
网络 → GStreamer
解码 → MPP
预览 → appsink + Qt
录像 → 原始H.265 + MP4封装
分段 → splitmuxsink
异常 → 状态监控 + 自动重连
这样设计之后,GStreamer 不再只是一个“能播放视频的工具”,而是整个视频数据链路的基础设施。
而 Qt,只需要专注于它最擅长的事情:
把最新的一帧稳定、低延迟地显示出来。