FFmpeg源码阅读指南:以转码流水线拆解核心架构
2026/9/7 20:27:40 网站建设 项目流程

先说个很直白的结论:FFmpeg 这套源码,绝不是靠“从头到尾读一遍”能啃下来的。体量在那里摆着,核心库加起来几十万行,如果抱着看小说的心态去读,大概率在 libavcodec 的编解码器注册表里就打转了。我自己的经验是,必须带着一条主线去“逆推”——这条主线就是转码流水线。你盯着一个 MP4 文件从打开到输出另一个格式,这中间数据是怎么一步步流过去的,每一步调用的是哪个模块的哪个函数,把这条链路走通了,整个源码的骨架自然就印在脑子里了。这篇内容就是按这个思路来写的,主要服务两类人:一是想从“会敲 ffmpeg 命令”进阶到“能改 ffmpeg 源码”的开发者,二是做嵌入式、播放器、音视频 SDK 需要裁剪定制 FFmpeg 的小伙伴,希望看完能少走点弯路。

这次拆解我会省略掉那些源码树里层层叠叠的宏定义细节,重点讲清楚“模块怎么分、数据怎么流、关键函数怎么找”,同时在最后放几个我在实际读码和二次开发中踩过的坑,方便你对照自查。

1. FFmpeg 源码的结构认识:先读懂目录,再说读懂代码

刚开始接触 FFmpeg 源码的人,很容易一头扎进ffmpeg.c这个命令行入口文件,结果被几千行参数解析和 filter graph 构建逻辑直接劝退。这个方向其实是反的。命令行工具ffmpeg.c只是一层很薄的“调度壳”,真正的精华在它下面的几个libav*库里面。所以第一步应该是把源码目录结构和模块职责对应起来。

1.1 顶层目录到底在告诉我们什么

你 clone 下源码之后,第一眼扫过去会看到大量libav*前缀的目录。这些目录就是 FFmpeg 的命根子,我建议你把它们按职责分成三组来记。

第一组是“干活的库”:libavcodec负责编解码,H.264、HEVC、AAC、MP3 全在这里面;libavformat负责封装和解封装,也就是解析 MP4、MKV、FLV、TS 这些容器,以及把编码后的数据写回容器;libavfilter负责滤镜,缩放、裁剪、字幕叠加、音频重采样这些操作都由它完成;libswscale专门做图像像素格式转换和缩放;libswresample专门做音频重采样和声道布局转换。这一组是整个转码流水线的核心执行者。

第二组是“打基础的库”:libavutil是公共基础工具库,内存分配、数学运算、日志系统、时间基换算全在这里,几乎所有其他库都依赖它。libavdevice是设备输入输出库,比如读取摄像头、麦克风,或者推流到某些采集设备,它就是靠这一层对接的。

第三组是“自己人用的库”:libavfilter里其实会用到libavcodec的某些结构,而libavcodec又离不开libavutil。这个依赖方向是单向的,从 util 往上到 codec/format,再到 filter,再到命令行工具,整个依赖层次非常清晰。

把这三组对应关系记住之后,你读源码就不会迷路。比如你想找“某个格式是如何被解析的”,直接去libavformat/下找对应名字的文件,比如mov.c就是 MP4/MOV 的解析器,flvdec.c是 FLV 解封装,mpegts.c是 TS 流处理。想找“H.264 解码器在哪”,就去libavcodec/下找h264dec.ch2645.c,注意 FFmpeg 命名规则里有dec结尾的是解码器,enc结尾的是编码器,一目了然。

1.2 插件式架构:一个靠“注册表”运转的系统

FFmpeg 最让我觉得精彩的设计,是它本质上跑的是一个“插件式架构”。你在libavcodec/allcodecs.c里能看到一张极其庞大的列表,里面罗列了所有编解码器。每个解码器不是被 if-else 硬编码调用的,而是通过一个叫做AVCodec的结构体来对外暴露自己的能力。

每个AVCodec实例就像一张“名片”,上面写着我叫什么名字、我的 id 是什么、我支持什么像素格式、我的init函数是谁、我的decode函数是谁。框架层只需要根据输入的编码 ID 去这张“名片簿”里查找匹配项,找到之后调用对应的函数指针就能完成解码。这就是为什么 FFmpeg 能轻松支持几十种格式——新增一个解码器,本质上就是往这张表里多注册一个AVCodec实例。

libavformat也一样,AVInputFormatAVOutputFormat就是封装层“名片”。mov.c里的ff_mov_demuxer是一个AVInputFormatff_mov_muxer是一个AVOutputFormat。打开文件时,FFmpeg 靠“探测”机制去匹配这些格式,所以解封装这个动作其实是一个“根据扩展名和内容特征,找出最合适的 demuxer 并调用”的过程。

理解了这套插件机制,你就明白了为什么 FFmpeg 裁剪起来很容易:不需要的东西,直接不编译对应的.o文件即可,框架层毫无感知。这也是它在嵌入式领域流行的根本原因。

2. 核心数据结构:转码流水线的“血液”

如果说模块是流水线上的工位,那数据结构就是工位之间传递的“工件”。FFmpeg 的转码全程就是一组结构体在不停流转:AVFormatContext管文件、AVStream管流、AVCodecContext管编解码器实例、AVPacket管压缩数据、AVFrame管原始数据。这几个结构体你搞不清楚,后面的源代码读起来一定是一头雾水。

2.1 AVPacket 和 AVFrame:压缩态和原始态的区分

AVPacket存的是压缩后的数据,也就是编码器吐出来的、或者解码器吃进去的东西。它里面有ptsdtsdurationdatasize这些字段。注意,AVPacket里的ptsdts的单位不是秒,而是“以对应流的时间基为单位”。这个时间基就藏在AVStream->time_base里,换算关系用av_rescale_q这个函数来处理。

AVFrame存的是解码后的原始数据,视频就是 YUV 或 RGB 的像素数据,音频就是 PCM 采样点。它里面有data(像素平面指针数组)、linesize(每行字节数)、ptsformatwidthheight等字段。从AVPacketAVFrame的过程就是解码,从AVFrameAVPacket的过程就是编码。

这两个结构体的关系,可以拿快递来类比:AVPacket是“打包好的包裹”,里面是压缩过的货物(编码数据),贴了标签(pts/dts);AVFrame是“拆开包装后的实物”,可以直接用了(像素/采样点)。转码流水线里,AVPacket进解码器,出来一堆AVFrameAVFrame经过滤镜处理,再进编码器,出来一堆新的AVPacket;最后这些新AVPacket被复用器打包成目标容器格式。

2.2 时间基体系:代码里最容易翻车的部分

FFmpeg 里的时间处理经常让初学者头疼,因为它不是用统一的“秒”为单位,而是用“有理数时间基”。AVRational就是“分子/分母”这样的一个分数结构,time_base就表示“一个 tick 代表多少秒”。比如time_base{1, 90000},就代表每个 tick 是 1/90000 秒,这是 TS 流里最常见的 MPEG 时间基。

整个项目还有一个全局的基准时间基AV_TIME_BASE,值是 1000000,也就是微秒。AV_TIME_BASE_Q则是{1, 1000000}这个AVRational。为什么搞这么多时间基?因为视频是 90000 赫兹的 tick,音频是采样率(比如 48000)的 tick,帧率是 25 或 30 的 tick,如果不做换算,直接拿数字加减就是灾难。

FFmpeg 提供了av_rescale_q(a, bq, cq)函数,把数值abq时间基换算到cq时间基。读源码时你会在转码链路里反复看到这个调用,尤其是ffmpeg.c里音频重采样、视频帧率转换的视频时间戳重写,全是靠它来对齐的。我自己的经验是,看到时间相关代码时,第一件事就是确认当前操作的time_base到底是哪个流的,是流的还是 codec 的,这个搞错哪怕 1 个单位,视频音画不同步就找上门了。

3. 转码流水线全景走读:从打开文件到写出文件

现在开始动真格的了。我们以一个最常见的场景为例:输入一个 MP4 文件,转成 H.264 + AAC 编码的 MKV 文件。我尽量把每一步对应到源码层面的关键函数,让你知道该去哪个文件里看什么。

3.1 解封装阶段:AVFormatContext 的诞生与流探测

打开文件的入口是avformat_open_input(),它位于libavformat/utils.c(新版移到了demux.c)里。这个函数做两件事:第一,创建并初始化AVFormatContext;第二,通过av_probe_input_format2()等探测函数,扫描文件头部的若干字节,匹配出对应的AVInputFormat,比如 MP4 就匹配到ff_mov_demuxer

紧接着要调avformat_find_stream_info(),这个函数会尝试读取一部分数据包并解码(或者部分解码)来获取流的编码参数、帧率、时长等信息,填充到AVFormatContext->streams数组里的每个AVStreamcodecpar字段中。AVStream里最关键的就是codecpar,它是个AVCodecParameters结构体,存了编码类型、宽高、比特率、采样率、声道数这些“参数”,而不涉及具体的编解码器实例。

这个阶段的“输出”是:一个装满了AVStreamAVFormatContext,以及每个流对应的编码参数。注意,到这里为止还没有真正打开解码器,只是“知道这个文件里面有什么”。

3.2 解码准备:从参数到 CodecContext

有了codecpar,下一步就是根据参数找到合适的解码器并初始化。关键函数是avcodec_find_decoder()avcodec_open2()

avcodec_find_decoder()做的事情很直白:拿着AVCodecID(比如AV_CODEC_ID_H264)去遍历前面说的AVCodec注册表,找到 id 匹配的那个解码器。找到之后,我们要为它创建一个独立的AVCodecContext,这个 ctx 是每次解码会话的“现场环境”,里面保存着解码器的私有选项、线程数、像素格式协商结果、延迟帧等状态。然后调用avcodec_open2(ctx, codec, options),这一步会真正调用解码器的init回调,分配解码器内部需要的内存和句柄。

这里有个很多新手容易踩的坑:AVCodecContext必须通过avcodec_alloc_context3()来创建,不要自己直接malloc一个结构体出来,因为很多内部字段的初始值要靠avcodec_get_context_defaults3()来填充,你自己 malloc 的就是一块没初始化的野内存。当初我犯过这个错,解码出来的画面全是绿屏还找不到原因,后来对比了官方示例才发现连初始化都做错了。

3.3 数据流主循环:read_frame 与 send/receive 模型

编码器和解码器之间的数据流转,现在官方推荐的是“送入/取出”模型,也就是avcodec_send_packet()配合avcodec_receive_frame()。旧的avcodec_decode_video2()这种一次性调用接口已经废弃了,建议新代码都按新模型来写,它天然支持 B 帧延迟输出和帧多包、包多帧的场景。

整个主循环大致是下面这个样子:

while (av_read_frame(ifmt_ctx, &pkt) >= 0) { if (pkt.stream_index == video_stream_index) { avcodec_send_packet(dec_ctx, &pkt); while (avcodec_receive_frame(dec_ctx, frame) >= 0) { // 这里拿到一个解码后的 AVFrame // 可以做滤镜处理、缩放、编码 } } av_packet_unref(&pkt); }

av_read_frame()是解封装层的核心输出函数,它从文件/网络流里读出一个AVPacket,并把stream_index指向对应的流。注意它返回的 packet 只是“当前这一个”,用完必须av_packet_unref()释放引用,否则内存泄漏会很难看。

解码器内部其实维护了一个缓冲队列。avcodec_send_packet()把压缩数据喂进去,avcodec_receive_frame()从解码器内部缓冲区里取出一个乃至多个已经解码好的帧。对于 B 帧较多的编码流,解码器不会立刻输出所有帧,而是等后续帧送来之后才按显示顺序输出,这就是为什么receive端要用while循环取帧,直到返回EAGAIN才说明当前没有可输出的帧了。

3.4 编码与复用:反向的流水线

解码拿到原始AVFrame后,如果要做滤镜处理,就送入 filter graph;如果直接编码,则把AVFrame送入avcodec_send_frame(),然后通过avcodec_receive_packet()取出编码后的AVPacket。编码器的AVCodecContext同样要提前配好,需要设置widthheightpix_fmttime_basebit_rategop_sizemax_b_frames等参数,然后avcodec_open2()打开编码器。

拿到编码后的AVPacket之后,流向复用处。要先avio_open()打开输出文件,得到输出AVFormatContextpb指针;然后avformat_write_header()写文件头;再把每个 packet 送入av_interleaved_write_frame()(这个函数会负责交错写入,让音频视频数据块的排列合理);最后av_write_trailer()写文件尾,完成整个封装过程。

这里有个细节值得注意:写入端如果用的是av_interleaved_write_frame(),FFmpeg 内部会对 packet 做时间戳排序和缓冲,你需要确保送进来的 packet 的时间戳是“流时间基”下的正确值。很多自研封装时遇到的 muxer 报错,比如Application provided invalid, non monotonically increasing dts,基本都发生在这一层,原因是发送到 muxer 的 dts 没有单调递增。解决思路是检查编码器输出的packet->dtsAV_NOPTS_VALUE还是正确值,必要时手动用av_rescale_q修正到目标流的时间基。

4. 滤镜与重采样:被忽略的“隐形流水线”

转码并不总是“解码→编码”一条直线。中途往往还要调整画面大小、帧率、色彩空间、音量、采样率。这些操作在 FFmpeg 里由libavfilterlibswscale/libswresample承担。

4.1 滤镜图:buffer 进,buffersink 出

源码里滤镜的核心是“buffer 源滤镜 + 处理链 + buffersink 汇滤镜”。视频处理最常用的三个滤镜是scalefpsformat。一个典型的滤镜图构建流程是:

avfilter_graph_create_filter(&buffer_src, avfilter_get_by_name("buffer"), "in", args, NULL, graph); avfilter_graph_create_filter(&buffer_sink, avfilter_get_by_name("buffersink"), "out", NULL, NULL, graph); // 用 avfilter_link 把 filter 依次串联 // 然后用 avfilter_graph_config 验证并配置整个图

构建完成后,数据流是:解码出的AVFrame通过av_buffersrc_add_frame_flags()送进 buffer 源,滤镜图内部经过 scale/fps/format 等处理后,用av_buffersink_get_frame()从 buffersink 汇滤镜取出结果。拿出来的帧就是“滤镜后的最终帧”,可以直接交给编码器。

一个让我印象深刻的点:在ffmpeg.c的命令行工具里,滤镜图不是死的,而是根据命令行参数动态拼接的。你在命令行写的-vf scale=1280:720,fps=25,会被解析成一个一个的过滤器描述符,然后动态生成filter_graph。这就是为什么 FFmpeg 命令那么灵活——它本质上是一个动态语法解析 + 图构建系统。

4.2 为什么我劝你别绕过 libswscale

很多做嵌入式裁剪的朋友会嫌libswscale太大,想自己在代码里用memcpy做像素格式拷贝。如果你只做同格式拷贝那还好说,一旦涉及 YUV420P 转 NV12 这种平面与半平面的转换,或者色彩空间从 BT.601 到 BT.709 的转换,就绝不是简单的字节复制能搞定的,还牵扯到色度样本位置、量化范围、色域矩阵这些量,稍不留神就会输出偏色、绿边、暗部发雾的画面。

libswscale的入口函数是sws_scale(),在使用前通过sws_getContext()初始化好转换上下文。它可以处理格式转换、缩放、色彩空间转换三件事,用起来很简单,内部 SIMD 优化也很完善。我的建议是,除非你确定不需要颜色转换和缩放,否则保留这个库,它自己带有 runtime dispatch 优化,在 x86 和 ARM 上都能跑满流水线,比自己写的朴素 C 代码快一个数量级不夸张。

5. 实操:从源码里“看着代码”理解一条命令行

热词里反复出现 ffmpeg 命令、m4s 转 mp4、合并 ts、推流这类需求。其实这些操作对应的源码路径都是同一条流水线,无非是换了 demuxer、muxer 和时间参数。这一节我把几个高频命令串到前面的源码流程里,方便你对照命令去回想内部发生了什么。

5.1 m4s 转 mp4:其实只是“换了个壳”

m4s 是 DASH 或 fMP4 体系下的媒体分片文件,它本身就是 MP4 的片段(styp + moof + mdat),所以从 m4s 转 mp4 的本质是“解封装 + 重新封装”,并不需要重新编码。

ffmpeg -i input.m4s -c copy -movflags faststart output.mp4

这里的-c copy意味着不解码不编码,直接把AVPacket从 demuxer 手里接过,原封不动交给 muxer。这种模式就是“转封装”,CPU 占用极低,速度极快。对应的源码路径是:avformat_open_input探测到 m4s/fMP4 的 demuxer,然后主循环里av_read_frame读出 packet,由于开了copy,不会走解码编码,而是经过时间基换算后直接av_interleaved_write_frame写进 MP4 muxer(movenc.c)。-movflags faststart的作用是让 muxer 在写完文件之后把moovbox 挪到文件头,这样在网页播放器上可以快速开始播放。

5.2 合并多个 ts 文件:concat demuxer 的功劳

把多个 TS 文件合并成一个 MP4,最常见做法是:

ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4

在源码层面,-f concat指定的是libavformat/concatdec.c这个 demuxer。它本身不解析具体的 TS 流,而是把 list.txt 里列出的文件当成一系列输入段,内部依次切换读取。也就是说,concat demuxer 是一个“虚拟的、多文件拼接读取器”,对上层来说它仍然表现为“一个输入流”,读出来的 packet 依次来自不同的文件。正因为如此,源 TS 文件的编码参数必须一致,否则生成的 MP4 时间戳会错乱,甚至播放花屏。

如果不用 concat demuxer,用ffmpeg -i "concat:file1.ts|file2.ts|file3.ts" -c copy out.mp4,走的是另一种基于文件协议拼接的方式。后者要求所有 TS 流的编码参数相同,拼接处容易产生时间戳跳变。我的建议是优先用 concat demuxer,因为它对每个文件的时间戳做了归一化处理,出来的文件更干净。

5.3 推流:-re 选项背后的调度逻辑

用 FFmpeg 推流时,大家都会写:

ffmpeg -re -i input.mp4 -c copy -f flv rtmp://server/live/stream

-re的意思是“按原始帧率读取输入”,也就是让读包速度匹配视频帧率,否则 FFmpeg 会以最大速度把所有包瞬间读完,推流端还没发送完呢,文件已经见底了。源码层面,-re会设置AVFormatContext->ctx_flags里的AVFMT_FLAG_NONBLOCK相关逻辑,并在输入端的read_packet回调里做“按时间戳 sleep”的节流。理解这个点以后,你就知道为什么推本地流要加-re,而推摄像头采集的实时流不加也没事——采集本身是实时的,不需要节流。

反过来,如果-re的节流逻辑出问题,最常见症状就是推流端 CPU 占用很低但下游黑屏、延迟越来越大,因为包之间的时间间隔被压缩了,播放端跟不上。

6. 二次开发避坑指南:源码读完不等于能上手改造

读完流水线是一回事,真正动手改代码是另一回事。下面这几条是我在基于 FFmpeg 做播放器、转码服务、嵌入式抽帧工具时踩出来的,分享出来希望你少走点弯路。

6.1 线程模型:解码器内部有自己的线程池

FFmpeg 解码器默认支持frame_threadsthread_count多线程解码。AVCodecContext->thread_count如果设成 0 或 1 是单线程,设大则解码器内部会创建线程池,把多个 slice 或帧交给不同线程并行处理。这个机制对开发者是透明的,你只管send_packetreceive_frame就行。

但问题在于:多线程解码时,AVFramereceive_frame拿出来的顺序未必跟 packet 送进去的顺序完全一致,尤其是在 B 帧场景。如果你以为“先送的 packet 一定先出帧”,那你就等着花屏吧。解决办法有两个:要么用thread_count=1强制单线程(性能损失明显);要么依赖帧的pts排序,不要依赖输出顺序。正确做法永远是把AVFrame->pts当成最终排序依据,因为这才是显示顺序。

6.2 AVPacket 的生命周期:不 unref 就是内存炸弹

在长时间转码服务里,AVPacket用完后不调用av_packet_unref()是最隐蔽的内存泄漏来源。因为AVPacket里的data指向的内存是引用计数的,av_packet_unref()才会真的把 ref count 减到零并释放内存。如果你只是把AVPacket结构体从栈上清掉,而不调 unref,底层 buffer 就一直不释放,时间长了内存曲线必然飞升。

我排查过一次内存泄漏,服务跑了两周内存涨到 8GB,最后定位到就是某个 error 分支里 packet 没有 unref,直接 return 了。修复就一行,排查花了整整一天。所以我的写码规范是:AVPacket拿到的瞬间,就约定好它在哪个作用域、什么时候 unref,绝不允许“跨函数传递后忘记释放”。

6.3 硬解不是拿来就能用:需要协商和 fallback

如果你要基于 FFmpeg 做硬解(VAAPI、CUDA、VideoToolbox、MediaCodec),要注意avcodec_open2()之前必须设置AVCodecContext->hw_device_ctx,然后解码器内部会尝试硬件解码。但硬解不是必然成功,可能是驱动不支持、格式不支持、显存不够。所以健壮的播放器/转码服务必须写 fallback 逻辑:检测到avcodec_open2失败或者receive_frame连续报错,就关掉硬解,用avcodec_find_decoder重新找软解解码器重新打开。

我自己见过太多“播放器在别人机器上好好的,到我这片黑屏”的案例,十有八九是硬解失败没有回退。FFmpeg 的hwaccel机制本身设计得挺好的,它提供了AVCodecContext->hwaccel_flagshw_frames_ctx来协商显存帧池,但前提是你得在代码层把回退逻辑写扎实。

6.4 裁剪编译:configure 是你的手术刀

嵌入式场景下,FFmpeg 全量编译的体积动辄几十 MB,根本塞不进小 Flash。这时候要用 configure 来做裁剪。核心选项包括--disable-everything然后手动--enable-decoder=h264--enable-parser=h264--enable-demuxer=mov,一点一点把需要的组件加回来。这里推荐一个组合套路:

./configure --disable-everything \ --enable-decoder=h264 --enable-decoder=aac \ --enable-encoder=h264 --enable-encoder=aac \ --enable-parser=h264 --enable-parser=aac \ --enable-demuxer=mov --enable-demuxer=flv \ --enable-muxer=mp4 --enable-muxer=flv \ --enable-protocol=file \ --disable-doc --disable-debug \ --enable-small --disable-avdevice --disable-avfilter

这里--disable-everything先把所有组件关掉,再手动开需要的,是裁剪的精髓。配合--enable-small优化体积,--disable-avdevice干掉设备库,--disable-avfilter干掉滤镜库(如果你确实不需要的话),最终体积能压到 2MB 左右。注意,--disable-avfilter不是随便关的,如果命令行里写了-vf-af就会挂,所以裁剪前一定要理清楚自己的功能需求。

关于libavcodec里面庞大的解码器表,我建议嵌入式场景优先考虑只保留软解需要的 parser、decoder;硬解场景要额外保留hwaccel相关文件,并在 configure 里开对应的--enable-hwaccel=h264_vaapi这类选项。改完 configure 之后,编译前最好make clean一次,否则增量编译经常把一些不该编进来的旧 .o 文件带进去,体积根本不减。这个坑我踩过,说出来都是泪。

写在最后的建议

我给想啃源码的人一个比较现实的路线:先别碰ffmpeg.c,先用这套流程走通“从命令行到源码函数”的映射——随便写一条转码命令,然后用gdbav_read_frameavcodec_send_packetavcodec_receive_frameav_interleaved_write_frame这几个核心函数上打断点,用 bt 看调用栈,你会发现整个库的内部调用关系被一步步摊开了,比对着代码死记硬背高效得多。

读完主体链路之后,再回头去抠某个具体模块的细节,比如 H.264 解码器的h264dec.c里如何处理 SPS/PPS,或者mov.c里如何解析moovbox。你会发现有了骨架之后,这些细节其实都是“在某条流水线上某个工位的具体操作”,读起来不再零散。

如果你是基于这套源码做二次开发,我的最后一句忠告是:多写日志,但别在核心热路径上打日志。FFmpeg 的快速转码离不开 SIMD 和缓存友好型的数据布局,你在decode/receive循环里插一句无脑 printf,性能就能掉下去 20%。要打日志,请使用 FFmpeg 自带的av_log()机制,它也支持级别过滤和回调重定向,线上排障时调整日志级别就行,不用重新编译。这算是踩过版本发布事故之后换来的经验,希望能给你省点时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询