FFmpeg音视频开发实战:从采集到播放的完整链路拆解
1. 音视频开发绕不开的FFmpeg:从采集到播放的完整链路拆解
做音视频开发这些年,被问得最多的问题就是“有没有一套能跑通的FFmpeg基础demo”。网上资料确实多,但要么是零散的命令行片段,要么是直接甩一个几千行的工程让人自己啃,中间缺了最关键的那层—— 为什么这么写、每一步在干什么、踩坑了怎么排查 。这篇内容就是把我自己从零搭这套demo的过程完整记录下来,涵盖音视频采集、播放、编码、解码、解封装五个核心环节,每个环节都配上可运行的代码骨架和参数说明。
FFmpeg在音视频领域的地位不用多讲,它本质上是一套跨平台的音视频处理框架,提供了注册、封装、编解码、滤镜、重采样等一整套API。你可以把它理解成一个“音视频领域的瑞士军刀”——不管你是要做播放器、推流工具、视频转码服务,还是做AI音视频预处理,底层几乎都绕不开它。这套demo适合谁看?刚入行音视频开发、能看懂C语言但没怎么接触过FFmpeg的工程师;或者做上层业务开发,想理解音视频处理底层逻辑的开发者。我尽量把每个API的调用意图和参数含义都讲清楚,让你看完能自己动手改、动手调。
在开始之前先明确一个概念: 容器格式和编码格式是两回事 。MP4是容器,里面装的视频可能是H.264编码,音频可能是AAC编码;MKV也是容器,里面可以装H.265。解封装就是把容器拆开,拿到里面的音视频压缩数据包;解码就是把压缩数据包还原成原始的音视频帧。这个区分不清楚,后面写代码很容易混淆。
注意:本文所有代码基于FFmpeg 6.x版本API编写,部分函数在4.x到6.x之间有变动,比如
avcodec_encode_video2已经被移除,统一用avcodec_send_frame和avcodec_receive_packet替代。如果你用的是老版本,需要对照官方迁移文档调整。
2. 环境搭建与工程结构设计
2.1 FFmpeg库的获取与编译要点
Windows下最省事的方式是直接下载官方预编译包,但要注意选对版本。
ffmpeg-master-latest-win64-gpl-shared.zip
这种带shared的包会同时提供dll和lib,适合动态链接;如果选static版本,编译出来的exe体积大但部署简单。我一般开发阶段用shared,方便调试和替换库文件。
Linux下建议自己编译,因为发行版自带的FFmpeg版本往往偏老,而且裁剪了很多编解码器。编译时几个关键配置项:
./configure --enable-shared --enable-gpl --enable-libx264 --enable-libx265 --enable-libfdk-aac --enable-nonfree --prefix=/usr/local/ffmpeg
--enable-gpl
是为了用x264/x265,
--enable-nonfree
是为了用fdk-aac,这两个选项会影响你后续能不能商用,自己评估。编译完之后记得把
/usr/local/ffmpeg/lib
加到
LD_LIBRARY_PATH
,否则运行时会报找不到动态库。
macOS下用Homebrew装最方便,
brew install ffmpeg
,但默认可能不带某些编码器,需要加
--with-x264
之类的选项(新版Homebrew已经改为formula选项)。
2.2 工程目录与依赖管理
我习惯把工程分成这几个模块:
project/
├── include/ # FFmpeg头文件
├── lib/ # FFmpeg库文件
├── src/
│ ├── capture.c # 采集模块
│ ├── decode.c # 解码模块
│ ├── encode.c # 编码模块
│ ├── demux.c # 解封装模块
│ ├── play.c # 播放模块
│ └── main.c # 入口
├── CMakeLists.txt
└── build/
用CMake管理依赖,核心配置:
find_package(PkgConfig REQUIRED)
pkg_check_modules(FFMPEG REQUIRED libavformat libavcodec libavutil libswscale libswresample)
include_directories(${FFMPEG_INCLUDE_DIRS})
target_link_libraries(${PROJECT_NAME} ${FFMPEG_LIBRARIES})
这样在Linux和macOS下都能自动找到FFmpeg。Windows下需要手动指定路径,或者用vcpkg。
实操心得:不要一上来就写完整工程,先写一个最小的“打开文件并打印流信息”的demo,确认库能正常链接、能正常读取文件,再往上叠功能。我见过太多人卡在链接错误上半天,结果发现是库版本和头文件版本不一致。
3. 解封装:把容器拆开拿到音视频流
3.1 解封装的核心流程与API调用
解封装是整个链路的第一步。你拿到一个MP4文件,FFmpeg需要先识别它的容器格式,然后读出里面有几条流、每条流是什么编码、时长多少、码率多少。核心API就几个:
AVFormatContext *fmt_ctx = NULL;
avformat_open_input(&fmt_ctx, filename, NULL, NULL);
avformat_find_stream_info(fmt_ctx, NULL);
for (int i = 0; i < fmt_ctx->nb_streams; i++) {
AVStream *stream = fmt_ctx->streams[i];
if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
printf("Video: %dx%d, codec_id=%d\n",
stream->codecpar->width,
stream->codecpar->height,
stream->codecpar->codec_id);
}
}
avformat_open_input
负责探测容器格式并打开文件,
avformat_find_stream_info
负责读取足够的数据来填充流的详细信息。后者在网络流场景下可能会阻塞,可以设置
fmt_ctx->probesize
和
fmt_ctx->max_analyze_duration
来控制探测的数据量和时长。
3.2 读取压缩数据包的正确姿势
拿到流信息之后,就可以循环读取数据包了:
AVPacket *pkt = av_packet_alloc();
while (av_read_frame(fmt_ctx, pkt) >= 0) {
if (pkt->stream_index == video_stream_index) {
// 处理视频包
}
av_packet_unref(pkt);
}
这里有个关键点:
av_read_frame
返回的packet需要调用
av_packet_unref
释放引用,否则会内存泄漏。很多人第一次写的时候忘了这一步,跑几分钟内存就爆了。
注意事项:
av_read_frame返回的packet可能包含多条流的数据交错,你需要根据stream_index判断属于哪条流。另外,packet里的数据是压缩数据,不能直接拿去显示,必须先解码。
3.3 时间戳与同步的基础概念
解封装阶段拿到的packet带有PTS(Presentation Timestamp)和DTS(Decoding Timestamp)。PTS是显示时间,DTS是解码时间。对于没有B帧的编码,PTS和DTS相等;有B帧时,DTS会早于PTS。做播放器的时候,音视频同步就是靠这两个时间戳来对齐的。
时间戳的单位是
AVStream->time_base
,比如
{1, 90000}
表示每个单位是1/90000秒。要转换成秒,就是
pts * time_base.num / time_base.den
。这个换算在后续做同步时非常关键,写错了就会出现音视频不同步或者播放速度异常。
4. 解码:从压缩数据到原始帧
4.1 解码器的初始化与打开
解码的第一步是找到对应的解码器并打开:
const AVCodec *codec = avcodec_find_decoder(stream->codecpar->codec_id);
AVCodecContext *codec_ctx = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(codec_ctx, stream->codecpar);
avcodec_open2(codec_ctx, codec, NULL);
avcodec_parameters_to_context
这一步很关键,它把流的编码参数(分辨率、像素格式、采样率等)拷贝到解码器上下文。如果跳过这一步,解码器可能因为缺少参数而初始化失败。
4.2 发送数据包与接收帧的循环
FFmpeg 6.x的解码API是“发送-接收”模式:
avcodec_send_packet(codec_ctx, pkt);
while (avcodec_receive_frame(codec_ctx, frame) == 0) {
// 处理解码后的frame
av_frame_unref(frame);
}
这个模式的好处是解码器内部可以缓冲多个packet,一次send可能对应多次receive。对于有B帧的视频,解码器需要缓存若干帧才能输出,所以第一次send之后可能receive不到frame,这是正常的。
常见坑:
avcodec_receive_frame返回AVERROR(EAGAIN)表示当前没有更多帧可输出,需要继续send packet;返回AVERROR_EOF表示解码结束。很多人只判断了返回值是否等于0,导致漏帧或者死循环。
4.3 像素格式转换与图像缩放
解码出来的frame可能是YUV420P、NV12等格式,如果要显示或者做图像处理,通常需要转成RGB。用libswscale:
struct SwsContext *sws_ctx = sws_getContext(
codec_ctx->width, codec_ctx->height, codec_ctx->pix_fmt,
codec_ctx->width, codec_ctx->height, AV_PIX_FMT_RGB24,
SWS_BILINEAR, NULL, NULL, NULL);
AVFrame *rgb_frame = av_frame_alloc();
rgb_frame->format = AV_PIX_FMT_RGB24;
rgb_frame->width = codec_ctx->width;
rgb_frame->height = codec_ctx->height;
av_frame_get_buffer(rgb_frame, 0);
sws_scale(sws_ctx, frame->data, frame->linesize, 0,
codec_ctx->height, rgb_frame->data, rgb_frame->linesize);
sws_getContext
的参数顺序是:源宽高和格式、目标宽高和格式、缩放算法。如果源和目标分辨率一样,缩放算法用
SWS_POINT
最快;如果要缩小,用
SWS_BILINEAR
或
SWS_BICUBIC
质量更好但更慢。
4.4 音频解码与重采样
音频解码的流程和视频类似,但多了一步重采样。因为解码出来的音频可能是48kHz、双声道、浮点格式,而播放设备可能要求44.1kHz、单声道、16位整数。用libswresample:
struct SwrContext *swr_ctx = swr_alloc_set_opts(NULL,
AV_CH_LAYOUT_STEREO, AV_SAMPLE_FMT_S16, 44100,
codec_ctx->channel_layout, codec_ctx->sample_fmt, codec_ctx->sample_rate,
0, NULL);
swr_init(swr_ctx);
uint8_t *out_buffer;
int out_samples = swr_convert(swr_ctx, &out_buffer, frame->nb_samples,
(const uint8_t **)frame->data, frame->nb_samples);
重采样的核心是
swr_alloc_set_opts
,参数顺序是:输出声道布局、输出采样格式、输出采样率、输入声道布局、输入采样格式、输入采样率。写反了会导致声音变调或者噪音。
5. 编码:把原始帧压缩成数据包
5.1 编码器参数配置与打开
编码是解码的逆过程,但参数配置更复杂。以H.264编码为例:
const AVCodec *codec = avcodec_find_encoder(AV_CODEC_ID_H264);
AVCodecContext *codec_ctx = avcodec_alloc_context3(codec);
codec_ctx->bit_rate = 2000000;
codec_ctx->width = 1280;
codec_ctx->height = 720;
codec_ctx->time_base = (AVRational){1, 25};
codec_ctx->framerate = (AVRational){25, 1};
codec_ctx->gop_size = 50;
codec_ctx->max_b_frames = 3;
codec_ctx->pix_fmt = AV_PIX_FMT_YUV420P;
avcodec_open2(codec_ctx, codec, NULL);
bit_rate
是目标码率,
gop_size
是关键帧间隔,
max_b_frames
是B帧数量。这几个参数直接影响画质和文件大小。码率给低了画面糊,给高了文件大;GOP大了随机访问慢,GOP小了压缩率低。
5.2 编码循环与码率控制
编码的调用方式和解码对称:
avcodec_send_frame(codec_ctx, frame);
while (avcodec_receive_packet(codec_ctx, pkt) == 0) {
// 写入文件或发送到网络
av_packet_unref(pkt);
}
如果要刷出编码器内部缓存的帧,需要发送NULL frame:
avcodec_send_frame(codec_ctx, NULL);
while (avcodec_receive_packet(codec_ctx, pkt) == 0) {
// 处理剩余的packet
}
实操心得:编码器的码率控制模式有CBR、VBR、CRF等。CBR适合直播,码率稳定;CRF适合点播,画质优先。设置
codec_ctx->rc_buffer_size和codec_ctx->rc_max_rate可以控制码率波动范围。我一般做点播用CRF=23,做直播用CBR+2秒缓冲。
5.3 封装:把编码后的数据写入容器
编码出来的packet需要写入容器文件:
AVFormatContext *out_fmt_ctx = NULL;
avformat_alloc_output_context2(&out_fmt_ctx, NULL, NULL, "output.mp4");
AVStream *out_stream = avformat_new_stream(out_fmt_ctx, NULL);
avcodec_parameters_from_context(out_stream->codecpar, codec_ctx);
avio_open(&out_fmt_ctx->pb, "output.mp4", AVIO_FLAG_WRITE);
avformat_write_header(out_fmt_ctx, NULL);
// 写入packet
av_interleaved_write_frame(out_fmt_ctx, pkt);
av_write_trailer(out_fmt_ctx);
av_interleaved_write_frame
会自动处理音视频交错,保证播放时不会出现音视频不同步。如果手动调用
av_write_frame
,需要自己控制写入顺序。
6. 采集与播放:打通输入输出两端
6.1 摄像头采集与麦克风采集
采集这块FFmpeg本身不直接提供摄像头API,需要依赖平台接口。Linux下用V4L2,Windows下用DirectShow或Media Foundation,macOS下用AVFoundation。FFmpeg提供了
libavdevice
来统一这些接口:
AVFormatContext *fmt_ctx = NULL;
AVInputFormat *input_fmt = av_find_input_format("v4l2");
avformat_open_input(&fmt_ctx, "/dev/video0", input_fmt, NULL);
Windows下把
v4l2
换成
dshow
,设备名换成
video=Integrated Camera
。macOS下用
avfoundation
,设备名是
0
或
0:0
(视频:音频)。
采集到的数据同样是packet,需要解码才能得到原始帧。如果要做实时处理,采集线程和解码线程要分开,用队列缓冲。
6.2 音频播放与视频渲染
播放这块FFmpeg也不直接提供输出API,需要依赖平台。Linux下用ALSA或PulseAudio,Windows下用WASAPI,macOS下用CoreAudio。视频渲染可以用SDL、OpenGL或者平台原生窗口。
以SDL为例,视频渲染的核心是创建纹理并更新:
SDL_Texture *texture = SDL_CreateTexture(renderer,
SDL_PIXELFORMAT_YV12, SDL_TEXTUREACCESS_STREAMING,
width, height);
SDL_UpdateYUVTexture(texture, NULL,
frame->data[0], frame->linesize[0],
frame->data[1], frame->linesize[1],
frame->data[2], frame->linesize[2]);
SDL_RenderClear(renderer);
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);
音频播放用SDL的音频回调:
void audio_callback(void *userdata, Uint8 *stream, int len) {
// 从音频队列取数据填充stream
}
注意事项:音视频同步是播放器最复杂的部分。简单做法是以音频时钟为主时钟,视频帧根据PTS和音频时钟的差值决定是等待还是丢弃。差值大于阈值就丢帧,小于阈值就等待。阈值一般设40ms左右,太小会频繁丢帧,太大会不同步。
6.3 完整链路的串联与线程模型
一个完整的采集-编码-封装链路,至少需要三个线程:采集线程、编码线程、写入线程。采集线程负责从设备读packet,编码线程负责解码-处理-编码,写入线程负责封装写文件。线程之间用环形队列传递数据,队列满了就丢帧或者阻塞。
播放链路反过来:解封装线程、解码线程、渲染线程。解封装线程读packet,解码线程解码成frame,渲染线程按时间戳显示。
线程模型的设计直接影响延迟和流畅度。队列长度设太小容易卡顿,设太大延迟高。我一般设视频队列5帧、音频队列200ms,实测下来比较平衡。
7. 常见问题排查与避坑指南
7.1 链接错误与运行时崩溃
最常见的链接错误是
undefined reference to av_xxx
,原因通常是库路径没配对或者链接顺序不对。FFmpeg的库有依赖关系,链接顺序应该是
avformat
在前,
avcodec
在后,
avutil
最后。用CMake的
target_link_libraries
一般能自动处理,手动写Makefile就要注意。
运行时崩溃最常见的是空指针,比如
avcodec_find_decoder
返回NULL还继续用。这种情况一般是编码器没编译进去,或者codec_id传错了。加个判空就能定位。
7.2 解码花屏与音画不同步
解码花屏通常是像素格式转换的问题。比如解码出来是NV12,你按YUV420P去读,UV顺序就错了。用
av_pix_fmt_desc_get
查一下格式描述,确认plane数量和排列方式。
音画不同步分两种情况:如果是固定偏移,检查时间戳换算有没有写错;如果是逐渐漂移,检查音频重采样有没有丢样本或者多采样。音频时钟的精度直接影响同步效果,用
av_gettime_relative
获取高精度时间。
7.3 内存泄漏与性能瓶颈
FFmpeg的内存管理遵循“谁分配谁释放”原则。
av_frame_alloc
配
av_frame_free
,
av_packet_alloc
配
av_packet_free
,
avcodec_alloc_context3
配
avcodec_free_context
。循环里用的frame和packet用
av_frame_unref
和
av_packet_unref
重置,不要反复alloc/free。
性能瓶颈一般在解码和缩放。如果CPU占用高,检查是不是用了软件解码。有GPU的话可以用硬件解码,比如
h264_cuvid
、
h264_qsv
、
h264_videotoolbox
。硬件解码的frame在GPU内存里,转成CPU可访问的格式需要额外拷贝,这个开销要算进去。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 链接报undefined reference | 库路径错误或链接顺序不对 | 检查CMake配置,调整链接顺序 |
| 运行时崩溃 | 空指针或参数错误 | 加判空,打印返回值 |
| 解码花屏 | 像素格式不匹配 | 打印pix_fmt,确认plane布局 |
| 音画不同步 | 时间戳换算错误或时钟精度不够 | 打印PTS/DTS,检查time_base |
| 内存持续增长 | 忘记unref或free | 用valgrind或ASan检测 |
| CPU占用过高 | 软件解码或缩放算法太重 | 启用硬件解码,换快速缩放算法 |
独家避坑:调试FFmpeg的时候,把
av_log_set_level(AV_LOG_DEBUG)打开,能看到详细的内部日志。但生产环境记得调回AV_LOG_WARNING,否则日志量巨大。另外,av_log_set_callback可以自定义日志输出,方便集成到自己的日志系统。
8. 从Demo到生产:还需要补哪些课
这套demo跑通之后,你基本理解了FFmpeg的核心API和音视频处理的主干流程。但要放到生产环境用,还有几块要补:
第一是 错误处理和恢复 。网络流断了要重连,文件读完了要正确处理EOF,解码器遇到坏帧要能跳过继续。这些在demo里往往被简化了,实际项目里必须处理。
第二是 性能优化 。零拷贝、内存池、硬件加速、多线程解码,这些都能显著降低CPU和内存占用。特别是4K视频,不做硬件加速基本跑不动。
第三是 格式兼容性 。不同容器、不同编码、不同封装参数的组合千奇百怪,要有一套健壮的探测和回退机制。比如某些MP4的moov在文件末尾,需要先seek再读;某些流的time_base是0,需要特殊处理。
第四是 同步精度 。demo里的同步是毫秒级,专业播放器要做到微秒级。这需要更精细的时钟管理和帧调度策略。
我个人的体会是,FFmpeg的API设计是“给你所有工具,但不告诉你什么时候用哪个”。官方文档只讲函数签名,不讲使用场景。真正要掌握,还是得自己动手写、动手调、动手踩坑。这套demo的价值不在于代码本身,而在于它帮你把整条链路串起来了,后面遇到问题你知道该去哪个环节找原因。
最后分享一个调试技巧:用
ffprobe
命令先分析文件,确认流的编码格式、分辨率、帧率、时间戳基准,再对照代码里的参数。很多时候问题不在代码,而在你对输入文件的理解有偏差。
更多推荐

所有评论(0)