
前言最近在学习ffmpeg的rtsp拉流实时播放遇到一些问题。旧的实现方式是通过网络拉取一帧就显示一帧实时播放但是如果网络延迟隔了几百毫秒甚至几千毫秒才拿到下一帧画面那么期间就会卡主导致整体播放流程一卡一卡的。所以使用播放时钟约定播放很有必要。有一些必要的了解实现方式是在一个线程函数内持续循环读取帧来显示voidYViewTask::Main(){while(is_running_){AVFrame*framelist_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}std::cout* ;yview_-DrawFrame(frame);av_frame_free(frame);}}YViewTask::Main()是C线程执行函数。list_ 是一个链表用于缓存已经读取到的AVFrame帧其结构如下AvObjListAVFrame list_;// 通用队列templatetypenameTclassAvObjList{public:explicitAvObjList(size_t max100):max_(max){}~AvObjList(){Clear();}// 禁止拷贝因为含有mutexAvObjList(constAvObjList)delete;AvObjListoperator(constAvObjList)delete;// 生产者慢则丢弃最旧的boolAdd(T*obj){std::unique_lockstd::mutexlock(mux_);if(!obj){LOGWARNING(pkt is null!);returnfalse;}// 要么被终止了(abort_ true) || 要么还有空位not_full_.wait(lock,[this]{returnabort_||list_.size()max_;});if(abort_){AVDeleterT::Free(obj);returnfalse;}list_.push_back(obj);not_empty_.notify_one();// 通知链表有元素returntrue;}// 消费者T*Pop(){std::unique_lockstd::mutexlock(mux_);// 要么被终止了(abort_ true) || 要么链表不为空not_empty_.wait(lock,[this]{returnabort_||!list_.empty();});if(list_.empty()){returnnullptr;// 只有被 Abort 叫醒时才会走到这里}T*objlist_.front();list_.pop_front();not_full_.notify_one();// 通知链表腾出位置了returnobj;}voidClear(){std::unique_lockstd::mutexlock(mux_);for(T*obj:list_){AVDeleterT::Free(obj);}list_.clear();not_full_.notify_all();}size_tSize(){std::unique_lockstd::mutexlock(mux_);returnlist_.size();}voidAbort(){std::unique_lockstd::mutexlock(mux_);abort_true;not_empty_.notify_all();not_full_.notify_all();}private:std::mutex mux_;size_t max_;std::listT*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_tpts0AV_NOPTS_VALUE;int64_tlast_ptsAV_NOPTS_VALUE;doublewall00.0;doubleframe_ms100.0;while(is_running_){AVFrame*framelist_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}if(0.0ms_per_tick_AV_NOPTS_VALUE!frame-pts){constdoublecur_ms(double)now_ms();// 判断首帧if(AV_NOPTS_VALUEpts0){pts0frame-pts;wall0cur_msprebuffer_ms_;}else{constdoubled(double)(frame-pts-last_pts)*ms_per_tick_;if(0.0d1000.0d){frame_msd;// 记录真实真间隔备用}elseif(0.0d||1000.0d){// 时间戳倒退或大跳重连 / RTSP 时间戳跳变→ 重锚// 不重锚的话 due 会落到很远的过去狂丢帧或未来画面冻住pts0frame-pts;wall0cur_ms;}}last_ptsframe-pts;// 算这一帧该几点出现constdoubleduewall0(double)(frame-pts-pts0)*ms_per_tick_;// 计算最晚迟到时间constdoublelate_tol(prebuffer_ms_0.0)?prebuffer_ms_:frame_ms;// 晚到了if(cur_ms-duelate_tol){if(cur_ms-due1000.0){// 落后 1 秒以上不是晚一帧是起播堆积或断流后恢复// → 重锚把现在当新起跑线从这一帧起重新排表pts0frame-pts;wall0cur_ms;std::coutX((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_caststd::chrono::milliseconds(std::chrono::steady_clock::now().time_since_epoch()).count();}inlinevoidMSleep(int64_tms){if(ms0)return;int64_tdeadlinenow_ms()ms;while(now_ms()deadline){std::this_thread::sleep_for(std::chrono::milliseconds(1));}}测试rtsp拉流地址rtsp://stream.strba.sk:1935/strba/VYHLAD_JAZERO.streamYViewTask::Main() 逐行讲解零、一句话总纲这段代码就干一件事给每一张画片算一个该上台的墙上时刻到点就画没到点就盯着钟等。旧做法网上一来帧就画 → 网络快就快放网络慢就卡住 像公交站站停忽快忽慢 新做法先看钟钟说该第 5 张了才去拿第 5 张 像地铁按时刻表发车一、先认识 6 个道具代码里的名字用比喻说真实含义frame-pts画片右上角印的号码这一帧的显示时间戳单位是流自己的刻度ms_per_tick_换算尺1 个刻度 多少毫秒av_q2d(time_base) × 1000这路流是1/90000所以尺子上 1 刻度 0.0111 mspts0起跑号码拿哪一张当第 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_tpts0AV_NOPTS_VALUE;// 起跑号码还没定用无效值占位int64_tlast_ptsAV_NOPTS_VALUE;// 上一张的号码用来量间隔doublewall00.0;// 起跑线的墙上时刻doubleframe_ms100.0;// 真实帧间隔先随便给个 100 占位大白话这四个变量要在整个循环里一直记着所以必须写在while外面。写在里面的话每次循环都被清零永远记不住上一帧是谁。为什么pts0用AV_NOPTS_VALUE当占位因为 pts 可能是 0 甚至是负数用 0 当还没定的标志会误判。FFmpeg 给了个专门的无效值常量用它最安全。块 2 · 拿一张画片这里会睡着AVFrame*framelist_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}大白话Pop()内部是筐空了就睡谁来塞货就醒。所以它不会因为暂时没货就返回空手。返回nullptr只有一种可能有人喊了收工list_.Abort()。既然是收工那就break走人。块 3 · 有号码才排时间表if(0.0ms_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_VALUEpts0){pts0frame-pts;wall0cur_msprebuffer_ms_;// ← 注意这个 300}大白话起跑号码 第一张的号码。起跑线 现在再往后推 300ms。为什么推 300ms因为第一帧刚到手时筐里是空的。马上画的话第二帧还没到就又得等 —— 画面一开始就一顿。所以先等 300ms 让网络把货攒起来这叫起播预缓冲。这就是那个 prebuffer_ms_的唯一含义。它只在首帧出现一次。3.3 非首帧量一下这一张和上一张隔了多久constdoubled(double)(frame-pts-last_pts)*ms_per_tick_;if(0.0d1000.0d){frame_msd;// 正常间隔 → 记下来当真间隔}elseif(0.0d||1000.0d){pts0frame-pts;// 号码倒退 或 跳得太远 → 这张当新的起跑线wall0cur_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_ptsframe-pts;constdoubleduewall0(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-duelate_tol){// 已经晚了if(cur_ms-due1000.0){// 而且晚得离谱超过 1 秒pts0frame-pts;// 重新起跑wall0cur_ms;std::coutX((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.9ms11fpsprebuffer_ms_ 300。#情形cur_msframe-ptspts0wall0算出duecur−due结果1首帧10009000900013001300−300等 300ms 到 1300 才画预缓冲2正常109117182900013001390.9−300d90.9记入frame_ms等 0.9ms 画3正常139125364900013001481.8−91等 91ms 画4晚 500ms198033546900013001572.7407超 300 但没超 1000 →不丢直接画5晚 1.5 秒307041728900013001663.61406超 1000 → 打印X(1406)重锚pts041728 / wall03070立刻画6紧跟一帧3161499104172830703160.90.1时间表已重置恢复均匀7断流 3 秒600029736441728307011475.9−5475但d 3000 1000→ 在 3.3 步就重锚了 →due6000立刻画第 5、7 行是同一件事的两种入口5 是表算对了但我人迟了7 是号码跳了表就该重排。两条路都会把pts0/wall0重置到现在。五、最容易搞混的两个 10001000.0 d3.3 步cur_ms - due 10003.5 步量的是什么帧间距这张和上张隔多久落后量我们落后时间表多久触发条件源那边号码跳了断流/重连我们自己播慢了起播堆积、卡顿恢复动作重锚打印X 重锚典型场景源停了 3 秒下一帧号码直接大 3 秒首帧堆了 2 秒的货才开始播动作一样都是重锚但原因完全不同。前者是原料有问题后者是我们干活慢了。