mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5
3116 字
8 分钟
FFmpeg播放链路的解析
2026-01-31
统计加载中...

FFmpegPlayer的播放链路#

一般来讲,基于FFmpeg播放器的播放过程是这样的

graph TD A["媒体源输入"] -->|"本地文件/网络流(RTMP/HTTP)/设备"| B["解封装(Demuxer)"] B -->|"解析封装格式(MP4/FLV/MKV等)"| C["分离音视频AVPacket"] C --> C1["视频AVPacket队列"] C --> C2["音频AVPacket队列"] %% 视频链路 C1 --> D["视频解码器(Video Decoder)"] D -->|"解码为原始视频帧"| E["视频AVFrame(YUV格式)"] E --> F["格式转换(Swscale)"] F -->|"YUV→RGB/适配分辨率"| G["视频渲染器(Video Renderer)"] G -->|"SDL/OpenGL/系统显示层"| H["屏幕输出"] %% 音频链路 C2 --> I["音频解码器(Audio Decoder)"] I -->|"解码为原始音频帧"| J["音频AVFrame(PCM格式)"] J --> K["音频重采样(Swresample)"] K -->|"统一采样率/声道数/位深"| L["音频渲染器(Audio Renderer)"] L -->|"SDL/AudioTrack/ALSA"| M["扬声器输出"] %% 同步机制 E --> N["音视频同步(AVSync)"] J --> N N -->|"基于PTS/DTS时间戳"| G N -->|"基于PTS/DTS时间戳"| L

看起来蛮复杂的,对吧。但是整体流程比较简单,我们这样来看

graph LR A["媒体源输入<br/>(本地/网络/设备)"] --> B["解封装<br/>(提取音视频裸数据)"] B --> C["音视频解码<br/>(转原始音视频帧)"] C --> D["音视频格式适配<br/>(转换/重采样)"] D --> E["音视频同步<br/>(PTS/DTS对齐)"] E --> F["音视频渲染输出<br/>(视频→屏幕 音频→扬声器)"]

我们来看第一部分

媒体源的分析#

1

一般来讲,音视频文件有很多种类型,有很多类型的容器

我们分成不同类型的音视频流来讨论

媒体源类型常见场景FFmpeg 底层协议开发优先级
本地文件MP4/MKV/FLV/MP3 等音视频文件file://最高
本地音视频设备摄像头、麦克风(采集播放)v4l2/alsa
网络流HTTP/HTTPS(点播)、RTMP/RTSP(直播)http/https/rtmp/rtsp
内存流内存中读取音视频数据(如 Qt 缓存、网络分片)自定义协议 /avio_alloc_context中(扩展用)

不同的封装格式具有不同的特点以及对应的适应场景#

封装格式核心特点典型适用场景FFmpeg 支持度Linux 播放器开发注意点
MP4 (M4V)兼容性最强(跨平台 / 设备)、基于 ISO-BMFF 标准、支持 H.264/AAC 主流编码本地文件点播、移动端视频、HTTP 点播完全支持(读写 + 解封装)1. 是优先级最高的适配格式;2. 注意处理 “fMP4”(分片 MP4,HLS/DASH 直播用)
MKV (Matroska)开源免费、支持几乎所有音视频编码(H.265/AV1/FLAC 等)、可封装多音轨 / 字幕本地高清视频、多语言视频文件完全支持Linux 下最常用的本地高清格式,需注意提取多音轨 / 字幕流索引
FLV轻量化、适配 RTMP 直播流、仅支持 H.264/AAC/MP3直播平台(如抖音 / 快手)、RTMP 流封装完全支持处理 RTMP 流时,FFmpeg 会自动解析 FLV 封装,需配置直播流的缓存参数
TS (MPEG-TS)容错性强、断流后可快速恢复、标准流媒体格式RTSP/HTTP 直播(IPTV、安防摄像头)、HLS 分片完全支持Linux 下处理 RTSP 流时,底层多为 TS 封装,需注意 PTS/DTS 时间戳对齐
AVI老旧格式、无流媒体适配、封装效率低早期本地视频文件完全支持(仅解封装)仅做兼容处理,无需优先优化,注意处理音视频同步问题
MOV苹果生态格式、基于 QuickTime、和 MP4 同源(ISO-BMFF)苹果设备录制的视频、专业视频编辑文件完全支持Linux 下需注意部分 MOV 文件的 “私有编码”,FFmpeg 需编译对应解码器
WebM开源、适配网页播放、主打 VP9/AV1 视频 + Opus 音频网页视频、开源平台视频

媒体源的 解封装(Demuxer) 与分流#

我们把音视频文件进行解封装 DEMUX ,基于不同的目标格式

extern "C" {
#include <libavformat/avformat.h>
#include <libavutil/error.h>
}
int demux_with_split(const char* url) {
AVFormatContext* fmt_ctx = nullptr;
AVPacket* pkt = av_packet_alloc();
int video_idx = -1, audio_idx = -1; // 音视频流索引
int ret = 0;
// 1. 打开源 + 提取流信息
if ((ret = avformat_open_input(&fmt_ctx, url, nullptr, nullptr)) < 0 ||
(ret = avformat_find_stream_info(fmt_ctx, nullptr)) < 0) {
char err[1024];
av_strerror(ret, err, sizeof(err));
return ret;
}
// 2. 查找音视频流索引(分流核心:通过索引区分音视频包)
video_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
audio_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
// 3. 读包 + 音视频分流
while (av_read_frame(fmt_ctx, pkt) >= 0) {
if (pkt->stream_index == video_idx) {
// 视频包处理逻辑
// 如:video_queue->push(pkt); 需注意深拷贝包
} else if (pkt->stream_index == audio_idx) {
// 音频包处理逻辑
// 如:audio_queue->push(pkt);
}
av_packet_unref(pkt); // 释放包引用
}
// 4. 资源释放
av_packet_free(&pkt);
avformat_close_input(&fmt_ctx);
return 0;
}

这是一个解封装的简单实例

其中,FFmpeg 本身就可以读取url的资源,FFmpeg调用 avformat_open_input(&fmt_ctx, url, nullptr, nullptr)) 格式上下文,URL,其他.... 来进行对url流的解析,创建并填充 AVFormatContextfmt_ctx,其中会填充媒体的封装格式,meta信息等等。

av_read_frame(fmt_ctx, pkt) 是这个流程中的重要一环。 我们已知fmt_ctx是对应的格式上下文,已被avformat_open_input所填充,AVPacket pkt在上文中 alloc 成功。 程序通过while来不断地从资源读AVPacket包裹。

AVPacket 像一个容器一样,承载着装一个Frame的作用,所以说在av_read_frame之后,被FFmpeg av_packet_unref掉,这部分需要资源释放。

在最后,调用 avformat_close_input(&fmt_ctx); 来结束这一环节。 这就是解封装的第一部分,最终存储在fmt_ctx里。

被解析的每个AVPacket里,都有一个字段叫stream_index,它代表了这个AVPacket的流的格式。 在这部分,根据这个字段的分辨来进行对应的入队操作。

音视频解码#

我们将入队后的AVPacket进行处理。

#include <thread>
#include <atomic>
#include <queue>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
}
std::atomic<bool> running{true};
AVFormatContext* fmt_ctx = nullptr;
int vidx = -1, aidx = -1;
std::queue<AVPacket*> video_queue;
std::queue<AVPacket*> audio_queue;
void demux_thread(const char* url) {
//DEMUX
}
void video_decode_thread() {
if (vidx < 0) return;
const AVCodec* codec = avcodec_find_decoder(fmt_ctx->streams[vidx]->codecpar->codec_id);
AVCodecContext* cctx = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(cctx, fmt_ctx->streams[vidx]->codecpar);
avcodec_open2(cctx, codec, nullptr);
while (running) {
if (video_queue.empty()) continue;
AVPacket* pkt = video_queue.front();
video_queue.pop();
AVFrame* frame = av_frame_alloc();
avcodec_send_packet(cctx, pkt);
avcodec_receive_frame(cctx, frame);
av_frame_free(&frame);
av_packet_free(&pkt);
}
avcodec_free_context(&cctx);
}
void audio_decode_thread() {
if (aidx < 0) return;
const AVCodec* codec = avcodec_find_decoder(fmt_ctx->streams[aidx]->codecpar->codec_id);
AVCodecContext* cctx = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(cctx, fmt_ctx->streams[aidx]->codecpar);
avcodec_open2(cctx, codec, nullptr);
while (running) {
if (audio_queue.empty()) continue;
AVPacket* pkt = audio_queue.front();
audio_queue.pop();
AVFrame* frame = av_frame_alloc();
avcodec_send_packet(cctx, pkt);
avcodec_receive_frame(cctx, frame);
av_frame_free(&frame);
av_packet_free(&pkt);
}
avcodec_free_context(&cctx);
}
int main() {
av_register_all();
avformat_network_init();
std::thread demux(demux_thread, "/test.mp4");
std::thread vdec(video_decode_thread);
std::thread adec(audio_decode_thread);
getchar();
running = false;
demux.join();
vdec.join();
adec.join();
avformat_close_input(&fmt_ctx);
avformat_network_deinit();
return 0;
}

这个部分看起来很多,但是两个线程的行为几乎是一样的。

我们手头有两个队列,分别是std::queue<AVPacket*> video_queue;std::queue<AVPacket*> audio_queue; 对于软件来说,解码部分不应该阻塞主线程(UI线程),所以我们采用多线程来实现。

在main函数中调用两个线程 Join,触发解码部分,但是这片代码不严谨,因为它没有考虑线程安全,正常开发情况下是要考虑解码过程的线程安全以对内存泄漏的注意。

我们注意到,两个线程中有avcodec_find_decoder(fmt_ctx->streams[aidx]->codecpar->codec_id),返回值是 const AVCodec*,这部分是做什么的呢? 顾名思义,avcodec_find_decoder,在fmt_ctx格式上下文中根据stream[aidx](a代表Audio) 对应流的AVCodecParameters,也就是codepar中获取对应的编码id,封装好AVCodec,它是解码器的说明书

AVCodecContext 是解码器的上下文,存储必要的参数,调用avcodec_parameters_to_context去打开解码器。 在while之后,解码循环开始了,循环体不断地将队列的AVPakcet弹出,送进对应的解码器里,在这同时,AVFrame承载解码后的帧,A/V帧代表了对应的解码结果,同时也需要队列去维护,再后面就是资源释放了。

音频重采样#

我们为什么需要去做这一部分? 因为解码后的AVFrame音频格式是不固定的,不同来源的音频参数可能差距巨大! 但是实际上播放设备只支持一些固定的格式,所以我们才有了重采样这一部分

参数说明常见取值
采样率每秒采集的音频样本数44100Hz、48000Hz(主流)
声道数音频声道数量单声道(1)、双声道(2)
采样格式每个样本的存储格式S16(16 位整型,最通用)、FLT(浮点型)

这个表是重采样的一些内容(参数)

// 1. 初始化重采样上下文(设置输入/输出参数)
SwrContext* swr_ctx = swr_alloc_set_opts(
nullptr,
av_get_default_channel_layout(2), // 输出:双声道
AV_SAMPLE_FMT_S16, // 输出:16位整型
44100, // 输出:44100Hz采样率
av_get_default_channel_layout(codec_ctx->channels), // 输入:解码帧声道
codec_ctx->sample_fmt, // 输入:解码帧采样格式
codec_ctx->sample_rate, // 输入:解码帧采样率
0, nullptr
);
swr_init(swr_ctx);
// 2. 执行重采样(将解码后的PCM帧转换为目标格式)
uint8_t* out_buf = nullptr;
int out_samples = swr_convert(
swr_ctx,
&out_buf, // 输出缓冲区
frame->nb_samples, // 输出样本数
(const uint8_t**)frame->data, // 输入PCM数据(解码帧)
frame->nb_samples // 输入样本数
);
// 3. 释放资源
swr_free(&swr_ctx);

通过前文的分析,整个的框架显而易见,操作都是初始化上下文为基础进行的。

我们在解码后可以获得codec_ctx->sample_fmtcodec_ctx->sample_rate,作为基础的音频信息。 对应的输出格式我们自定义,一般都是将AV_SAMPLE_FMT_FLTP转化为AV_SAMPLE_FMT_S16,浮点转化为整型非平面格式。

具体的格式我们就暂不讨论。

音视频同步#

这是播放核心最重要的一环,也是很难的一环,这个部分的实现成功代表着一个播放器基本合格

为什么需要同步?

因为音频解码线程,和视频解码线程,它们分开后的处理方式是独立的。这就导致它们的处理速度天然不一致,比如视频解码来说耗时更长一些,放任这样的话就会导致视频里口型对不上等等严重错误

DTS 和 PTS#

PTS/DTS 时间戳:是同步的 “标尺”,所有同步逻辑都基于这两个时间戳:

  • PTS Presentation Time Stamp:「显示 / 播放时间戳」,表示该帧应该被显示(视频)/ 播放(音频)的时间;

  • DTS Decoding Time Stamp:「解码时间戳」,仅用于视频帧解码顺序(如 B 帧),同步只看 PTS。

    (音频帧无 B 帧,PTS=DTS,因此音频时间戳更稳定)。

我们进行同步大多条件下应该以Audio pts为准,这是因为人耳对音频更敏感,视频连续播放的情况下丢掉几个帧,人眼是不容易发现的。

在这个基础上我们维护一个音频时钟。这个时钟听命于同步模式,如果我们是以音频为同步基础,那么这个视频就根据时钟对齐播放。

  • 我们维护一个差值,叫δ,也就是delta。
delta=Δsync=video_ptsaudio_clockdelta = \Delta_{sync} = video\_pts - audio\_clock
变量名含义
audio_clock音频基准时钟(核心):当前音频实际播放到的时间(由音频帧 PTS + 已播放时长计算)
video_pts视频帧的显示时间戳:该帧应该被渲染的时间(解码后从 AVFrame 中获取)
delta同步差值:delta = video_pts - audio_clock

delta > 0:视频帧 “偏慢”(视频该显示的时间还没到,音频已经播放到前面了); delta < 0:视频帧 “偏快”(视频该显示的时间已过,音频还没播放到,视频跑在前面); delta = 0:完美同步(理论值,实际几乎不存在)

Δsync 差值范围同步状态判断处理策略(仅调整视频,不修改音频)策略原因
−0.2s≤Δsync<−0.05s视频轻微 / 中度超前1. 丢弃当前 1 帧,直接解码下一帧;2. 禁用视频额外缓冲,渲染速度小幅提升(1.05~1.1 倍)单帧丢弃无感知,轻量追平音频
−0.05s≤Δsync≤0.05s完美同步(无感知)直接渲染当前帧,不做任何干预,维持正常帧率 / 缓冲优先保证播放流畅性
0.05s<Δsync≤0.2s视频轻微 / 中度滞后1. 延迟渲染:sleep (Δsync−0.005),预留 5ms 缓冲;2. 关闭视频硬解帧缓存,减少渲染延迟温和延迟,等待音频追上
Δsync>0.2s视频严重滞后1. 跳过延迟直接渲染当前帧;2. 丢弃历史缓存 1~2 帧,一次性追平音频时钟避免画面卡停,少量丢帧无感知
Δsync<−0.2s视频严重超前1. 一次性丢弃 2~3 帧(按帧率适配,如 25 帧 / 秒≈0.04s / 帧);2. 重置视频渲染时钟

我们经过音视频同步后处理的队列,就可以准备播放了

音视频播放#

以Qt平台为例,我们使用Qt的QAudioOutput

音频播放#

重采样后 PCM → 音频输出初始化 → 开辟播放缓冲 → 异步写入 PCM 播放 → 配合同步模块启停

  1. 初始化 QAudioOutput:传入 PCM 参数(采样率、声道、格式),创建音频输出设备;
  2. 打开音频输出流,创建循环缓冲(避免 PCM 数据断流,缓冲大小建议 200ms);
  3. 从音频解码线程获取重采样后的 PCM 数据,异步写入缓冲,QAudioOutput 自动从缓冲取数据送声卡;
  4. 联动同步模块:同步异常时(如 Δ_sync 超阈值),仅暂停 / 恢复缓冲写入,不直接停止音频播放(避免音频杂音)。
  • 采用回调 / 异步写入模式,避免阻塞解码线程;
  • 缓冲大小控制在100~200ms:过小易断流爆音,过大增加同步延迟;
  • 音频播放不做任何速度调整:前序同步仅靠视频适配,音频始终以固定速率播放(保证基准时钟稳定);
  • 异常处理:无 PCM 数据时,写入静音数据(全 0),避免声卡断流。

视频播放#

视频播放依赖解码后 YUV 帧(前序视频解码输出,如 YUV420P),Qt 无原生 YUV 渲染支持,需先通过 FFmpeg 的swscale库将 YUV 帧转为 Qt 支持的RGB/ARGB 格式,再通过 Qt 组件渲染,核心是格式转换 + 帧率控制 + 同步联动。 链路为: 解码 YUV 帧 → swscale 格式转换(YUV420P→ARGB32) → 渲染帧缓存 → 按同步策略刷新渲染 → 联动音频时钟控速

结语#

以上为基于FFmpeg的播放器的基本播放流程,它流程很简单,但是实际开发起来很复杂,如果深挖起来要说很久。这需要很大的耐心与毅力去参与到每个部分的调试当中去,但凡有一个路径的逻辑混乱了,输出将会是一片狼藉。

同时这也是我学习FFmpeg的过程中内心的感慨,自身的实力仍然需要打磨,还有很长的一条路要走。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

FFmpeg播放链路的解析
https://rinzemoon.top/posts/260131/avplay/
作者
泠时月
发布于
2026-01-31
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00