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 命令先分析文件,确认流的编码格式、分辨率、帧率、时间戳基准,再对照代码里的参数。很多时候问题不在代码,而在你对输入文件的理解有偏差。

Logo

邀请您加入社区

更多推荐