FFmpeg RTSP播放时钟实现揭秘
2026/9/24 22:22:29 网站建设 项目流程

前言

最近在学习ffmpeg的rtsp拉流实时播放;遇到一些问题。

旧的实现方式是,通过网络拉取一帧,就显示一帧,实时播放;但是,如果网络延迟,隔了几百毫秒甚至几千毫秒才拿到下一帧画面,那么期间就会卡主,导致整体播放流程一卡一卡的。

所以,使用播放时钟约定播放很有必要。

有一些必要的了解:

实现方式是在一个线程函数内,持续循环读取帧来显示:

voidYViewTask::Main(){while(is_running_){AVFrame*frame=list_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}std::cout<<"* ";yview_->DrawFrame(frame);av_frame_free(&frame);}}

YViewTask::Main()是C++线程执行函数。

list_ 是一个链表,用于缓存已经读取到的AVFrame帧,其结构如下:
AvObjList<AVFrame> list_;

// 通用队列template<typenameT>classAvObjList{public:explicitAvObjList(size_t max=100):max_(max){}~AvObjList(){Clear();}// 禁止拷贝,因为含有mutexAvObjList(constAvObjList&)=delete;AvObjList&operator=(constAvObjList&)=delete;// 生产者,慢则丢弃最旧的boolAdd(T*obj){std::unique_lock<std::mutex>lock(mux_);if(!obj){LOGWARNING("pkt is null!");returnfalse;}// 要么被终止了(abort_ = true) || 要么还有空位not_full_.wait(lock,[this]{returnabort_||list_.size()<max_;});if(abort_){AVDeleter<T>::Free(obj);returnfalse;}list_.push_back(obj);not_empty_.notify_one();// 通知,链表有元素returntrue;}// 消费者T*Pop(){std::unique_lock<std::mutex>lock(mux_);// 要么被终止了(abort_ = true) || 要么链表不为空not_empty_.wait(lock,[this]{returnabort_||!list_.empty();});if(list_.empty()){returnnullptr;// 只有被 Abort 叫醒时才会走到这里}T*obj=list_.front();list_.pop_front();not_full_.notify_one();// 通知,链表腾出位置了returnobj;}voidClear(){std::unique_lock<std::mutex>lock(mux_);for(T*obj:list_){AVDeleter<T>::Free(obj);}list_.clear();not_full_.notify_all();}size_tSize(){std::unique_lock<std::mutex>lock(mux_);returnlist_.size();}voidAbort(){std::unique_lock<std::mutex>lock(mux_);abort_=true;not_empty_.notify_all();not_full_.notify_all();}private:std::mutex mux_;size_t max_;std::list<T*>list_;boolabort_=false;std::condition_variable not_empty_;// 链表有元素了std::condition_variable not_full_;// 链表有空位了};

yview_->DrawFrame(frame);内部通过SDL渲染显示。

下方两个变量是线程中常用的,必须说明一下:

doublems_per_tick_=av_q2d(v_time_base)*1000.0;// 时间刻度,v_time_base是解复用视频流的timebase,计算出毫秒doubleprebuffer_ms_=300.0;// 缓存时间和最大迟到时间

如上内容知道即可,对下方学习理解有帮助。

最终加上“播放时钟”代码如下:

voidYViewTask::Main(){int64_tpts0=AV_NOPTS_VALUE;int64_tlast_pts=AV_NOPTS_VALUE;doublewall0=0.0;doubleframe_ms=100.0;while(is_running_){AVFrame*frame=list_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}if(0.0<ms_per_tick_&&AV_NOPTS_VALUE!=frame->pts){constdoublecur_ms=(double)now_ms();// 判断首帧if(AV_NOPTS_VALUE==pts0){pts0=frame->pts;wall0=cur_ms+prebuffer_ms_;}else{constdoubled=(double)(frame->pts-last_pts)*ms_per_tick_;if(0.0<d&&1000.0>d){frame_ms=d;// 记录真实真间隔,备用}elseif(0.0>d||1000.0<d){// 时间戳倒退或大跳(重连 / RTSP 时间戳跳变)→ 重锚,// 不重锚的话 due 会落到很远的过去(狂丢帧)或未来(画面冻住)pts0=frame->pts;wall0=cur_ms;}}last_pts=frame->pts;// 算这一帧该几点出现;constdoubledue=wall0+(double)(frame->pts-pts0)*ms_per_tick_;// 计算最晚迟到时间constdoublelate_tol=(prebuffer_ms_>0.0)?prebuffer_ms_:frame_ms;// 晚到了if(cur_ms-due>late_tol){if(cur_ms-due>1000.0){// 落后 1 秒以上不是"晚一帧",是起播堆积或断流后恢复// → 重锚:把"现在"当新起跑线,从这一帧起重新排表pts0=frame->pts;wall0=cur_ms;std::cout<<"X("<<(cur_ms-due)<<") ";}// 300~1000ms 这一段:不丢,往下走,直接显示}// 还没到该显示的时刻,就等 —— 这一句才是"播放时钟"while(is_running_&&(double)now_ms()<due){MSleep(1);}}std::cout<<"* ";yview_->DrawFrame(frame);av_frame_free(&frame);}}

当前时间和睡眠函数:

inlineint64_tnow_ms(){returnstd::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();}inlinevoidMSleep(int64_tms){if(ms<=0)return;int64_tdeadline=now_ms()+ms;while(now_ms()<deadline){std::this_thread::sleep_for(std::chrono::milliseconds(1));}}

测试rtsp拉流地址:rtsp://stream.strba.sk:1935/strba/VYHLAD_JAZERO.stream


YViewTask::Main() 逐行讲解

零、一句话总纲

这段代码就干一件事:给每一张画片算一个"该上台的墙上时刻",到点就画,没到点就盯着钟等。

旧做法:网上一来帧就画 → 网络快就快放,网络慢就卡住 (像公交,站站停,忽快忽慢) 新做法:先看钟,钟说该第 5 张了,才去拿第 5 张 (像地铁,按时刻表发车)


一、先认识 6 个道具

代码里的名字用比喻说真实含义
frame->pts画片右上角印的号码这一帧的显示时间戳,单位是"流自己的刻度"
ms_per_tick_换算尺:1 个刻度 = 多少毫秒av_q2d(time_base) × 1000,这路流是1/90000,所以尺子上 1 刻度 = 0.0111 ms
pts0起跑号码:拿哪一张当第 0 张起跑线的参考帧号码
wall0起跑线:它该在墙上几点出现参考帧的应播时刻(毫秒)
cur_ms现在墙上几点now_ms(),单调时钟
due这一张该上台的时刻wall0 + (pts - pts0) × ms_per_tick_

还有两个配角:

名字干什么的
prebuffer_ms_起播先攒 300ms 的货,同时也当"容忍迟到量"
frame_ms最近观测到的真实帧间隔,prebuffer_ms_为 0 时拿它当容忍量

二、骨架(把细节全删掉的样子)

while(还在营业){从筐里拿一张画片// 没货就睡在这,被叫"收工"就下班if(这张有号码){定好或校准 起跑线// 首帧定,异常时重定算出它该上台的时刻 dueif(已经晚得离谱)重排时间表if(还没到点)盯着钟等// ← 这一等,才是"播放时钟"}上台(画出来) 扔掉}

下面逐块拆。


三、逐行拆解

块 1 · 开场四行:准备四个记账本

int64_tpts0=AV_NOPTS_VALUE;// 起跑号码,还没定,用"无效值"占位int64_tlast_pts=AV_NOPTS_VALUE;// 上一张的号码,用来量间隔doublewall0=0.0;// 起跑线的墙上时刻doubleframe_ms=100.0;// 真实帧间隔,先随便给个 100 占位

大白话:这四个变量要在整个循环里一直记着,所以必须写在while外面。写在里面的话每次循环都被清零,永远记不住上一帧是谁。

为什么pts0AV_NOPTS_VALUE当占位?因为 pts 可能是 0 甚至是负数,用 0 当"还没定"的标志会误判。FFmpeg 给了个专门的"无效值"常量,用它最安全。


块 2 · 拿一张画片:这里会睡着

AVFrame*frame=list_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}

大白话Pop()内部是"筐空了就睡,谁来塞货就醒"。所以它不会因为"暂时没货"就返回空手。

返回nullptr只有一种可能:有人喊了"收工"list_.Abort())。既然是收工,那就break走人。


块 3 · 有号码才排时间表

if(0.0<ms_per_tick_&&AV_NOPTS_VALUE!=frame->pts){

大白话:只有"尺子有效"且"这张有印号码"时,才能排时间表。两个条件缺一个,就直接跳过排表、马上画(当作不能定时的流处理)。

3.1 先看现在几点
constdoublecur_ms=(double)now_ms();

必须用steady_clocknow_ms()里封装的)。它只会往前走,用户改系统时间、NTP 对时都不会让它跳。如果用system_clock,某一刻系统时间被拨回 1 分钟,所有帧都会突然"迟到 60 秒",画面直接乱掉。

3.2 首帧:把第一张定成起跑线
if(AV_NOPTS_VALUE==pts0){pts0=frame->pts;wall0=cur_ms+prebuffer_ms_;// ← 注意这个 + 300}

大白话

  • 起跑号码 = 第一张的号码。
  • 起跑线 ="现在"再往后推 300ms

为什么推 300ms?因为第一帧刚到手时,筐里是空的。马上画的话,第二帧还没到就又得等 —— 画面一开始就一顿。所以先等 300ms 让网络把货攒起来,这叫起播预缓冲

这就是那个+ prebuffer_ms_的唯一含义。它只在首帧出现一次。

3.3 非首帧:量一下这一张和上一张隔了多久
constdoubled=(double)(frame->pts-last_pts)*ms_per_tick_;if(0.0<d&&1000.0>d){frame_ms=d;// 正常间隔 → 记下来当真间隔}elseif(0.0>d||1000.0<d){pts0=frame->pts;// 号码倒退 或 跳得太远 → 这张当新的起跑线wall0=cur_ms;}

大白话d= 两张画片之间隔了多少毫秒。分三种情况:

d 的取值意思怎么办
0 < d < 1000ms正常的两帧间隔记进frame_ms(把这两帧之间的真间隔存下来备用)
d ≥ 1000ms号码往前跳了一大截重锚:把这张当新的起点,起跑线挪到"现在"
d < 0号码倒退了同上,重锚

为什么跳得远就必须重锚?假设 3 秒没来画面,下一张的号码直接比上一张大 3 秒:

  • 不重锚的话,due = wall0 + (pts - pts0) × 尺子会算出一个"3 秒前就该播"的时刻;
  • 于是这一帧一进来就已经"迟到 3 秒",后面每一帧都永远追不上,画面彻底垮掉。

所以这里把这张当成新的第 0 张,时间表从它重排。

顺带说一个陷阱:d > 1000和后面那句cur_ms - due > 1000里的两个 1000,是完全无关的两个量

  • 前者比较的是"这张和上一张之间隔多久"(帧间距);
  • 后者比较的是"我们落后时间表多久"(跑步落后量)。

一个量的是步长,一个量的是差距,别混。

3.4 记住这张的号码,然后算它该几点上台
last_pts=frame->pts;constdoubledue=wall0+(double)(frame->pts-pts0)*ms_per_tick_;

大白话last_pts必须先更新,因为下一轮要拿它量间隔。

due的公式就一句话:

该上台的时刻 = 起跑线 + (这张号码 - 起跑号码) × 换算尺
  • (pts - pts0)是"这张比起点晚几个刻度",是个整数;
  • 乘上ms_per_tick_就变成毫秒;
  • 加上起跑线,就是墙上时刻。

这和"号码 × 单价 = 价钱"是一样的道理,尺子就是那个单价。

3.5 算容忍量,然后判断是不是已经晚了
constdoublelate_tol=(prebuffer_ms_>0.0)?prebuffer_ms_:frame_ms;

大白话:允许它迟到多少。默认用prebuffer_ms_(300ms)—— 既然起播都敢攒 300ms,那晚 300ms 内都算正常波动。如果调用方把prebuffer_ms_传成 0(表示不预缓冲),就退而用frame_ms(真实的帧间隔),至少有一帧的余量。

if(cur_ms-due>late_tol){// 已经晚了if(cur_ms-due>1000.0){// 而且晚得离谱(超过 1 秒)pts0=frame->pts;// 重新起跑wall0=cur_ms;std::cout<<"X("<<(cur_ms-due)<<") ";}// 300~1000ms 这一段:不丢,往下走,直接显示}

大白话:这里把"晚了"分成两档:

迟到量判断做法
≤ 300ms正常抖动什么都不做,按原时间表走
300ms ~ 1秒小落后不丢帧,直接往下走,马上显示
> 1秒大落后(起播堆积 / 断流恢复)重锚到"现在",并且马上显示,同时打印一个X(落后毫秒数)

为什么超过 1 秒要重锚?因为落后 1 秒以上不可能是"这一帧来晚了",一定是整条时间表已经失效了(断流、重连、时间戳跳变)。这时候继续按老表走,只会让后面每一帧都判成"迟到",一帧都追不回来。把"现在"当新起跑线,从这一帧起重新排表,才是唯一出路。

为什么 300~1000ms 不丢帧,直接画?因为之前实测过:丢帧并不能让画面更顺。源最大积压也就 0~1 帧,把帧扔掉只会让画面缺一块(出现空洞),而不会让播放追上时间。既然丢也追不上,不如把它画出来。


块 4 · 盯着钟等(真正的"播放时钟")

while(is_running_&&(double)now_ms()<due){MSleep(1);}

大白话:这一帧还没到上台的时候,就在这儿原地转,每 1ms 看一眼钟。钟一到due,立刻跳出、画出去。


块 5 · 上台,然后扔掉

std::cout<<"* ";yview_->DrawFrame(frame);av_frame_free(&frame);

大白话:画一帧,打印一个*(方便数到底画了多少帧),然后把这一帧的内存还回去。

av_frame_free不能省:每一帧都是av_frame_alloc出来的独立内存,用完不还就一路泄漏,跑久了内存爆掉。


四、带数字走一遍

假设:流是1/90000的刻度,ms_per_tick_ = 0.01111,帧间隔约 90.9ms(11fps),prebuffer_ms_ = 300

#情形cur_msframe->ptspts0wall0算出duecur−due结果
1首帧10009000900013001300−300等 300ms 到 1300 才画(预缓冲)
2正常109117182900013001390.9−300d=90.9记入frame_ms,等 0.9ms 画
3正常139125364900013001481.8−91等 91ms 画
4晚 500ms198033546900013001572.7+407超 300 但没超 1000 →不丢,直接画
5晚 1.5 秒307041728900013001663.6+1406超 1000 → 打印X(1406),重锚pts0=41728 / wall0=3070立刻画
6紧跟一帧3161499104172830703160.90.1时间表已重置,恢复均匀
7断流 3 秒600029736441728307011475.9−5475d = 3000 > 1000→ 在 3.3 步就重锚了 →due=6000,立刻画

第 5、7 行是同一件事的两种入口:5 是"表算对了但我人迟了",7 是"号码跳了表就该重排"。两条路都会把pts0/wall0重置到"现在"。


五、最容易搞混的两个 1000

1000.0 < d(3.3 步)cur_ms - due > 1000(3.5 步)
量的是什么帧间距:这张和上张隔多久落后量:我们落后时间表多久
触发条件源那边号码跳了(断流/重连)我们自己播慢了(起播堆积、卡顿恢复)
动作重锚打印X+ 重锚
典型场景源停了 3 秒,下一帧号码直接大 3 秒首帧堆了 2 秒的货才开始播

动作一样(都是重锚),但原因完全不同。前者是"原料有问题",后者是"我们干活慢了"。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询