ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FFmpeg音视频同步实战:从PTS/DTS到播放器核心实现

FFmpeg音视频同步实战:从PTS/DTS到播放器核心实现 1. 音视频同步到底在解决什么问题做FFmpeg音视频开发绕不开一个让人又爱又恨的话题音视频同步。我接触过的不少初学者写完解码、渲染看到画面能出、声音能响就觉得大功告成结果一播放口型和声音差了十万八千里或者看两三分钟视频画面越来越卡。这不是他们的解码代码写错了而是没有真正理解音视频同步的含义。先说个直观的场景。你拿手机拍一段视频摄像头采集的画面和你说话的声音在录制时其实是两条独立的数据流。播放时解码器把视频帧和音频帧分别吐出来视频按帧率走音频按采样率走。问题是这两个“节奏”天然就不一致视频可能掉帧、解码慢音频可能在网络传输中被延迟或缓冲GPU渲染一抖画面就慢半拍。如果没有一个机制去校正两条流根本没法在时间线上对齐。音视频同步干的就是这件事让音频和视频在播放过程中的任意时刻尽量保持在同一个时间点上保证用户看到的画面和听到的声音是一致的、连贯的没有可感知的延迟或超前。这套机制在几乎所有音视频场景里都是地基本地播放器、直播推流、视频剪辑预览、监控系统回放、视频通话。只要涉及“同时存在画面和声音”的解码渲染就一定绕不开它。尤其是做播放器、直播客户端或者想深入FFmpeg底层做二次开发的朋友理解同步原理是必须迈过去的一道坎。这篇文章我按“原理 → 环境准备 → 解码链路 → 缓冲队列 → 同步策略 → 问题排查”的顺序从零带你写一个带有完整音视频同步能力的播放器核心模块。代码不需要多复杂关键是把每条同步策略背后的“为什么”讲透让你拿到代码后能自己扩展、修改而不是机械复制。2. 同步原理从DTS/PTS到三种主流策略2.1 先搞懂时间戳PTS、DTS和AV_TIME_BASE同步的核心基础是时间戳。FFmpeg的解码器在输出每一帧数据时会附带两个关键时间信息PTSPresentation Time Stamp显示时间戳和DTSDecoding Time Stamp解码时间戳。PTS表示这一帧应该在什么时刻被显示出来DTS表示这一帧应该什么时候被解码。对于大部分没有B帧的流PTS和DTS是相同的一旦视频编码里用了B帧双向预测帧解码顺序和显示顺序就会错开这时候必须分别对待。FFmpeg还有一个全局时间基AV_TIME_BASE值固定为1,000,000代表一秒被分成一百万份。几乎所有需要计算“真实时间点”的地方都会用到它。解码出来的帧其PTS/DTS所在的单位是流自己的时间基time_base直接拿来做播放调度是不行的要先通过av_rescale_q或av_q2d转换统一换算成毫秒或微秒级的时间轴。这一步漏掉后面所有同步逻辑全部是错的。// 把AVPacket的时间戳从流时间基转换为毫秒 int64_t pts_ms av_rescale_q(pkt-pts, stream-time_base, av_make_q(1, 1000));2.2 三种主流同步策略音频为主、视频为主、外部时钟我用一个生活中特别常见的类比来帮你理解同步策略。假设你在看一场演唱会直播左边是舞台画面右边是音频调音台。如果以画面为主那调音师就要不断盯着画面去调整声音延迟保证声音跟着画面走如果以声音为主那导播就要根据耳机里听到的声音去微调画面输出。音频为主时钟Audio Master Clock以音频播放进度为基准视频不断去对齐它。因为人对声音延迟更敏感音频的播放节奏天然稳定而且音频设备的时钟基准通常比视频渲染更可靠所以这是本地播放器用得最多的策略。实现时维护一个记录当前已播放音频时间的时间轴视频渲染时算出自己的PTS和这个轴之间的差值大了就丢帧小了就等待。视频为主时钟Video Master Clock反过来以视频渲染进度为基准让音频去对齐。这种方式在某些特殊场景有用比如只有视频没有音频的监控画面。但风险是视频一掉帧整个时间轴就飘音频会跟着忽快忽慢。外部时钟External Clock不依赖音视频任何一方而是用系统的绝对时间比如av_gettime_relative()作为统一时钟。解码、渲染、音频播放都以这个时钟为参考点。直播场景常用这种方式因为网络传输的延迟波动很大直接用音频或视频做基准都可能被对方带偏不如统一对齐到墙钟时间。在后面的代码实现里我会采用“以音频为主、外部时钟兜底”的混合方案。本地文件播放时走音频时钟遇到纯视频或音频解码异常时自动切换到外部时钟。这也是很多开源播放器的成熟做法实测稳定性和适应性都比较好。3. 动手前的准备FFmpeg环境与流程盘点3.1 环境搭建和版本选择这篇的代码基于FFmpeg 6.x编写但核心逻辑在4.x到6.x这些主流版本上都通用。如果你不知道怎么安装我按操作系统的差异给你三个方向的参考。Ubuntu/Debian系可以直接用官方源或ffmpeg-release包sudo apt update sudo apt install ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-devmacOS用户建议用Homebrewbrew install ffmpegWindows用户可以直接到ffmpeg官网下载编译好的development build版本带dev头文件和库用MSVC或MinGW配置工程。不太建议自己在Windows上编译FFmpeg尤其是刚入门的时候工具链问题会消耗掉你大量时间。注意这里我有个实操建议——自己用源码编译FFmpeg固然能学到很多东西也可以按需要裁剪模块但新手阶段建议先用官方编译好的二进制把精力放在理解同步逻辑本身。等跑通demo再去研究编译配置比如--disable-everything配合--enable-decoder...做裁剪你会发现事半功倍。3.2 解封装和解码链路的基本流程音视频同步不是从解码才开始的而是从解封装阶段就要打好地基。我习惯把整个播放器核心分成三个模块解封装模块把MP4、MKV、TS封装格式中的音频流和视频流拆出来、解码模块把H.264、H.265、AAC这些压缩数据解码成原始帧、播放渲染模块视频帧送GPU/屏幕音频帧送音频设备接口同时做同步调度。解封装阶段的关键动作是找到音视频流各自的索引并且拿到它们的时间基和时间戳信息。用FFmpeg做这件事非常成熟核心就几步avformat_open_input打开文件、avformat_find_stream_info探测流信息、av_find_best_stream分别拿到音频流和视频流的索引。// 找到输入文件中的音频流和视频流索引 AVFormatContext* fmt_ctx NULL; if (avformat_open_input(fmt_ctx, filepath, NULL, NULL) 0) { // 处理失败 } if (avformat_find_stream_info(fmt_ctx, NULL) 0) { // 处理失败 } int video_stream_idx av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0); int audio_stream_idx av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);这里有个很多人会踩的坑av_find_best_stream是在已知封装格式的情况下根据流类型做智能匹配。但如果文件里存在参考帧类型不标准的情况它依然可能返回错误的流。比较好的做法是拿到索引后再做一次完整性校验确认返回的流确实是想要的编码类型比如视频流编码是不是AV_CODEC_ID_H264。解码器的初始化同样有一套成熟的固定流程avcodec_find_decoder找到解码器、avcodec_alloc_context3分配上下文、avcodec_parameters_to_context把流参数拷贝进上下文、avcodec_open2打开解码器。AVCodec* decodec avcodec_find_decoder(stream-codecpar-codec_id); AVCodecContext* codec_ctx avcodec_alloc_context3(decodec); avcodec_parameters_to_context(codec_ctx, stream-codecpar); if (avcodec_open2(codec_ctx, decodec, NULL) 0) { // 处理失败 }这时候整个链路的地基就算铺好了。解码器已经打开、流信息已经就位下一步就是让数据跑起来。不过在实际写解码循环之前我得先说清楚一个最容易被忽视的环节缓冲队列的设计。没有它同步逻辑根本无从谈起。4. 核心实现打通解码与同步的完整链路4.1 先搭一个解码线程和数据缓冲队列同步调度和网络缓冲不一样本地播放的同步调度做得好不好很大程度上取决于数据流的组织方式。实际播放过程中解封装线程读包的速度往往比解码线程快解码线程比渲染线程快。如果不加缓冲很容易出现渲染线程等着解码解码线程等着读包CPU空转、卡顿不断的状况。我的做法是设计一个简单的环形缓冲队列里面存放解码前的AVPacket。解封装线程负责往里丢包解码线程负责取包解码。关键的参数是队列上限我一般按缓存时长来控制比如视频帧数超过200帧或者音频样本字节数超过某个阈值就暂停读包避免内存无限制增长。typedef struct PacketQueue { AVPacketList* first_pkt; AVPacketList* last_pkt; int nb_packets; int size; SDL_mutex* mutex; SDL_cond* cond; } PacketQueue; int packet_queue_put(PacketQueue* q, AVPacket* pkt) { AVPacketList* pkt_list av_malloc(sizeof(AVPacketList)); if (!pkt_list) { return -1; } // av_packet_ref增加引用计数av_packet_move_ref转移所有权 av_packet_move_ref(pkt_list-pkt, pkt); pkt_list-next NULL; SDL_LockMutex(q-mutex); if (!q-last_pkt) { q-first_pkt pkt_list; } else { q-last_pkt-next pkt_list; } q-last_pkt pkt_list; q-nb_packets; q-size pkt_list-pkt.size; SDL_CondSignal(q-cond); SDL_UnlockMutex(q-mutex); return 0; }提示这里使用SDL的互斥锁和条件变量来做线程同步。如果你不想依赖SDL完全可以用C11的stdatomic配合pthread替换但SDL的接口跨平台性好而且后面章节音频播放也要用SDL所以提前统一更省事。解码线程从队列里取包后调用avcodec_send_packet和avcodec_receive_frame两步完成解码。必须注意FFmpeg新版本API要求send和receive成对调用而且一次avcodec_send_packet可能对应多次avcodec_receive_frame特别是遇到H.264多slice的情况代码里一定要用while循环尽量取帧。// 解码线程的骨架 while (1) { AVPacket pkt; // 从队列取一个包 if (packet_queue_get(audio_q, pkt, 1) 0) { continue; } int ret avcodec_send_packet(audio_codec_ctx, pkt); if (ret AVERROR(EAGAIN)) { // 解码器内部缓冲满了需要取走帧 AVFrame* frame av_frame_alloc(); while (avcodec_receive_frame(audio_codec_ctx, frame) 0) { // 这里每收到一帧就去播放 audio_play(frame); } av_frame_free(frame); } av_packet_unref(pkt); }解码之后的音频帧和视频帧我分别再开两个“解码后帧队列”因为视频帧和音频帧的播放节奏不同解耦后同步调度更灵活。视频帧队列的容量通常只留1~3帧就够了因为视频就是要及时显示缓冲太多反而增加延迟。音频帧队列可以稍微多留一点但也不能无脑堆音频缓冲太大声音会明显滞后于画面。4.2 音频播放与音频时钟的建立做“音频为主时钟”策略前提是得有一个可靠的音频时钟。这个时钟怎么建立不是简单记录“解码了多少音频”而是要结合音频设备真实的播放进度来算。用SDL打开音频设备后SDL会以一个固定的频率回调把缓存的音频数据写入设备。每次回调我们根据当前解码出的音频帧所带的PTS以及这个音频帧被消费了多少就能推算出当前正在从扬声器出来的声音对应的时间点。这个时间点就是音频时钟的“当前值”。我用的方法比较轻量读取SDL音频设备的队列长度然后通过SDL_GetQueuedAudioSize知道还有多少数据没播放完用总数据量减去剩余量就可以估算出当前已播放位置对应的PTS。// 音频时钟更新函数 double get_audio_clock() { int bytes_played audio_hw_buf_size - SDL_GetQueuedAudioSize(audio_dev); double pts audio_clock; // 上一帧音频的初始PTS pts (double)bytes_played / audio_bytes_per_sec; return pts; }注意这里的audio_bytes_per_sec要根据音频的采样率、声道数和采样格式计算。比如声道数2、采样率44100Hz、样本格式16bit2字节每秒字节数就是 44100乘以2再乘以2 176400字节。这一算错音频时钟偏一点点视频同步就会跟着歪。音频播放的回调里除了把数据喂给设备还要及时更新当前音频时钟。FFmpeg解码出来的AVFrame里有best_effort_timestamp这是解码器综合各种信息得出的最优时间戳比直接拿pts字段更可靠尤其是处理B帧和转码流时。我把音频播放回调里每一次拿到帧就用这个时间戳更新audio_clock。// SDL音频回调 void audio_callback(void* userdata, Uint8* stream, int len) { // 从音频帧队列里取数据填充到stream // 每次填入后更新audio_clock audio_clock av_frame_get_best_effort_timestamp(audio_frame) * av_q2d(time_base); }4.3 视频渲染与同步调度的核心算法现在到了最核心的部分视频线程拿到一帧视频后到底怎么决定“显示”还是“不显示”这里的本质是算一个差值diff video_frame_pts - audio_clock如果diff大于0说明视频帧比音频时钟早到了也就是画面比声音超前了需要等一下。具体等一下多久就是diff的时长。如果diff小于0说明这帧视频来得太晚声音已经跑到前面去了画面帧过期了直接丢弃避免积压导致延迟越来越大。“等一下”这个动作用SDL的时钟或者标准sleep都能做到但直接sleep不精确。我一般用SDL的延时机制先确保睡眠一小段时间然后到了预计的显示时刻再配合时钟校正。void video_refresh_timer(void* userdata) { // 从视频帧队列取一帧 // 计算当前PTS和音频时钟差值 double diff video_frame_pts - get_audio_clock(); double delay video_frame_duration; // 下一帧的预计显示时长按帧率估算 if (diff AV_SYNC_THRESHOLD) { // 视频超前了多等一会 delay diff; } else if (diff -AV_SYNC_THRESHOLD) { // 视频落后太多丢掉这帧 frame_queue_drop(); return; } else { // 在阈值范围内按正常帧率走就行 } // 调度下一次刷新 SDL_AddTimer(delay * 1000, video_refresh_timer, NULL); }这里的AV_SYNC_THRESHOLD是关键参数。设置得太小比如小于50毫秒一帧的微小抖动就会触发同步逻辑视频帧不断被丢或者不断等待画面反而更卡设置得太大超过200毫秒用户会察觉音画不同步。我的经验值是100毫秒也就是允许音画之间存在100毫秒以内的偏差超过才做校正这样既能消除明显卡顿感也能保证绝大部分内容音画对齐。视频帧的时长video_frame_duration怎么算最准确的方式是用同一帧的PTS减上一帧的PTS但如果你正在丢帧或者第一帧没有参考值可以退化到用帧率估算1除以fps再乘以1000得到毫秒数。很多本地播放器在跳转seek后会短暂用帧率估算时间等积累几帧后再切回精确PTS差我从实践经验看这种混合方案最稳。4.4 完整代码从打开文件到音画同步渲染的主循环把上面的模块串起来就是一个精简但功能完整的播放器核心。我把主体逻辑整理成一个可编译的demo结构清晰方便你在此基础上加字幕、截图、倍速等业务功能。#include SDL.h #include libavcodec/avcodec.h #include libavformat/avformat.h #include libavutil/imgutils.h #include libswscale/swscale.h // 全局变量音频时钟、解码上下文等 static double audio_clock 0.0; static AVFormatContext* fmt_ctx NULL; static AVCodecContext* video_ctx NULL; static AVCodecContext* audio_ctx NULL; static int video_stream_idx -1; static int audio_stream_idx -1; static SDL_mutex* que_mutex NULL; static SDL_cond* que_cond NULL; static int quit 0; // 省略PacketQueue相关实现put/get/free // 省略音频回调实现SDL_OpenAudioDevice的回调函数 static int decode_and_play_audio() { AVPacket pkt; AVFrame* frame av_frame_alloc(); if (!frame) return -1; // packet_queue_get等待队列中有数据 while (!quit) { if (packet_queue_get(audio_q, pkt, 1) 0) { continue; } int ret avcodec_send_packet(audio_ctx, pkt); av_packet_unref(pkt); if (ret 0) { continue; } while (avcodec_receive_frame(audio_ctx, frame) 0) { // 用SDL_QueueAudio把frame数据送到输出队列 SDL_QueueAudio(audio_dev, frame-data[0], frame-linesize[0]); // 更新audio_clock if (frame-best_effort_timestamp ! AV_NOPTS_VALUE) { audio_clock av_q2d(audio_stream_timebase) * frame-best_effort_timestamp; } } } av_frame_free(frame); return 0; } static int decode_and_play_video() { // 类似音频线程但取出视频帧后调用video_refresh_timer做同步显示 AVPacket pkt; AVFrame* frame av_frame_alloc(); if (!frame) return -1; while (!quit) { if (packet_queue_get(video_q, pkt, 1) 0) { continue; } int ret avcodec_send_packet(video_ctx, pkt); av_packet_unref(pkt); if (ret 0) continue; while (avcodec_receive_frame(video_ctx, frame) 0) { // 计算同步延迟后用SDL_RenderCopy显示 // frame-pts与audio_clock做比较决定等待或丢弃 // 实际显示逻辑可以放到video_refresh_timer中 } } av_frame_free(frame); return 0; } int main(int argc, char* argv[]) { if (argc 2) { fprintf(stderr, Usage: %s input_file\n, argv[0]); return -1; } avformat_network_init(); if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER)) { return -1; } if (avformat_open_input(fmt_ctx, argv[1], NULL, NULL) 0) { return -1; } if (avformat_find_stream_info(fmt_ctx, NULL) 0) { return -1; } // 初始化音视频解码器略参照前文avcodec_find_decoder流程 // 初始化SDL窗口和渲染器略 // 初始化SDL音频设备略 SDL_Thread* audio_thread SDL_CreateThread(decode_and_play_audio, audio, NULL); SDL_Thread* video_thread SDL_CreateThread(decode_and_play_video, video, NULL); // 主循环只负责处理SDL事件和调度刷新 SDL_Event event; while (!quit) { SDL_PollEvent(event); switch (event.type) { case SDL_QUIT: quit 1; break; case SDL_KEYDOWN: if (event.key.keysym.sym SDLK_ESCAPE) { quit 1; } break; } } // 等待线程结束释放资源 SDL_WaitThread(audio_thread, NULL); SDL_WaitThread(video_thread, NULL); // 释放上下文、队列、SDL资源 return 0; }注意我这里为了可读性省略了一些代码但核心框架是全的。你在实际跑的时候要补上SDL窗口创建、音频设备打开、PacketQueue的具体实现、解码器的完整初始化以及渲染时的纹理拷贝逻辑。这些都不难难的地方恰好是这里强调的音频线程和视频线程通过audio_clock这个共享变量协作而video_refresh_timer则是整个播放器的“总调度器”。视频帧的PTS和audio_clock的差值决定了显示还是丢弃这就是整套同步机制跳动的心脏。5. 常见问题与排查技巧实录5.1 画面和声音对不齐但一会儿又能自动恢复这是最常见的情况几乎每个做过播放器的人都会遇到。原因大概率是时间戳换算问题特别是你用av_rescale_q做单位转换时一定要确认清楚两个参数的时间基。我见过不少人在视频流上用了音频流的时间基或者反过来结果换算出来的PTS完全乱了套。排查方法很直接打印出音频时钟和视频PTS的时间戳对照日志看偏差是不是固定的。如果偏差恒定那大概率是转换因子的问题如果偏差是逐渐增大的那可能是时钟基准漂移要检查是不是用了系统实时时钟而没有做补偿。5.2 音画同步正常但播放到某些视频时卡顿明显遇到卡顿先别着急怀疑同步逻辑。很多情况下是视频帧的显示时长估算错了或者帧队列满了导致解码线程长期阻塞。我之前遇到一个H.264视频帧率标注的是30fps实际码流里的PTS间隔却很均匀地把时间平摊给了每帧。如果直接用帧率估算就会误差越来越大。更隐蔽的情况是B帧导致的乱序。如果视频流有B帧解码器输出帧的PTS顺序不是单调递增的。送进帧队列前要么你自己排序要么用av_frame_get_best_effort_timestamp带上解码器的推荐值。我一般直接把视频帧丢进队列后在渲染前做一次单调校验发现乱序就按PTS顺序重新排一下。实操心得不要相信任何“看起来合理”的帧率估算。只要流的时间戳是有效的尽量用相邻帧的PTS差值作为实际显示时长。5.3 音频时钟获取不到可靠值在处理直播流或者一些本地损坏文件时音频帧的PTS可能是AV_NOPTS_VALUE。如果你不加判断直接换算audio_clock就会变成一个垃圾值同步逻辑瞬间崩溃。解决办法是给音频时钟加一个“有效标志位”只有拿到合法PTS时才更新audio_clock否则就退回外部时钟模式用av_gettime_relative()作为兜底。此外音频设备如果没有打开成功比如驱动不支持当前采样率你拿不到任何音频时钟。场景上我建议直接退化到纯视频播放模式不做音画同步推断只保证画面流畅输出。不要试图在没声音的情况下用“假装音频时钟”硬对齐那样反而会让画面忽快忽慢。5.4 常见问题速查表现象可能原因排查手段解决方案音画始终偏一个固定差值时间戳单位换算错误打印原始PTS和换算后PTS对比检查av_rescale_q参数播放越久偏差越大用帧率估算显示时长打印相邻帧PTS差值改用真实PTS差值偶发卡顿并伴随画面跳变帧队列容量过小查看队列占用率适当增加队列上限音频正常视频巨卡视频帧同步阈值过小增大AV_SYNC_THRESHOLD调整到100ms左右视频快速播放丢帧逻辑过于激进检查sync阈值判断放宽diff阈值无声且画面极卡音频时钟不可用检查PTS有效性加入外部时钟兜底这张表是我在实际开发中遇到的高频问题缩影。你会发现绝大多数问题都集中在“时间戳换算”和“同步阈值”两个地方。所以真正稳定的播放器核心往往不是代码结构多花哨而是对这两个地方做了足够的防御性处理。6. 我的几点经验总结写音视频同步模块这几年我最大的感受是看似简单边界情况多到让人头皮发麻。文件格式的差异、编码参数的差异、设备驱动的差异都会让同一套代码在不同输入面前表现完全不一样。一条特别值得的实践经验是在开发阶段一定要做一个“时间戳可视化工具”把每帧的PTS、音频时钟、实际渲染时间点输出到日志文件甚至可以用Python脚本画成折线图。没有这个工具排查同步问题基本靠猜效率极低。有了一目了然的时间线你一眼就能看出是哪一方的时钟出了问题。另外开始写业务逻辑之前先把同步模块单独抽出来做单元测试。我习惯用一段时长十几秒、音画位移明显的测试素材比如有字幕的MTV片段作为基准素材在每次代码改动后跑一遍看是否出现可感知的音画偏差。不要等到整个播放器都写完了再回头调同步那时候问题已经被层层代码包裹定位成本高得让人绝望。最后的建议是把同步逻辑和具体的渲染API解耦。比如视频渲染部分无论是SDL、OpenGL还是Direct3D同步判断的核心代码都不要被某一个渲染接口污染。这样未来如果你想给播放器接不同的输出后端或者改造VR/投屏场景音频时钟和视频调度部分可以原封不动地复用。架构上多留一层薄薄的封装省下的全是未来的时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进