资讯动态

FFmpeg源码阅读指南:以转码流水线拆解核心架构

发布时间:2026/9/7 20:27:55 来源:尧图企业网站定制
先说个很直白的结论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.c和h2645.c注意 FFmpeg 命名规则里有dec结尾的是解码器enc结尾的是编码器一目了然。1.2 插件式架构一个靠“注册表”运转的系统FFmpeg 最让我觉得精彩的设计是它本质上跑的是一个“插件式架构”。你在libavcodec/allcodecs.c里能看到一张极其庞大的列表里面罗列了所有编解码器。每个解码器不是被 if-else 硬编码调用的而是通过一个叫做AVCodec的结构体来对外暴露自己的能力。每个AVCodec实例就像一张“名片”上面写着我叫什么名字、我的 id 是什么、我支持什么像素格式、我的init函数是谁、我的decode函数是谁。框架层只需要根据输入的编码 ID 去这张“名片簿”里查找匹配项找到之后调用对应的函数指针就能完成解码。这就是为什么 FFmpeg 能轻松支持几十种格式——新增一个解码器本质上就是往这张表里多注册一个AVCodec实例。libavformat也一样AVInputFormat和AVOutputFormat就是封装层“名片”。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存的是压缩后的数据也就是编码器吐出来的、或者解码器吃进去的东西。它里面有pts、dts、duration、data、size这些字段。注意AVPacket里的pts和dts的单位不是秒而是“以对应流的时间基为单位”。这个时间基就藏在AVStream-time_base里换算关系用av_rescale_q这个函数来处理。AVFrame存的是解码后的原始数据视频就是 YUV 或 RGB 的像素数据音频就是 PCM 采样点。它里面有data像素平面指针数组、linesize每行字节数、pts、format、width、height等字段。从AVPacket到AVFrame的过程就是解码从AVFrame到AVPacket的过程就是编码。这两个结构体的关系可以拿快递来类比AVPacket是“打包好的包裹”里面是压缩过的货物编码数据贴了标签pts/dtsAVFrame是“拆开包装后的实物”可以直接用了像素/采样点。转码流水线里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)函数把数值a从bq时间基换算到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数组里的每个AVStream的codecpar字段中。AVStream里最关键的就是codecpar它是个AVCodecParameters结构体存了编码类型、宽高、比特率、采样率、声道数这些“参数”而不涉及具体的编解码器实例。这个阶段的“输出”是一个装满了AVStream的AVFormatContext以及每个流对应的编码参数。注意到这里为止还没有真正打开解码器只是“知道这个文件里面有什么”。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同样要提前配好需要设置width、height、pix_fmt、time_base、bit_rate、gop_size、max_b_frames等参数然后avcodec_open2()打开编码器。拿到编码后的AVPacket之后流向复用处。要先avio_open()打开输出文件得到输出AVFormatContext的pb指针然后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-dts是AV_NOPTS_VALUE还是正确值必要时手动用av_rescale_q修正到目标流的时间基。4. 滤镜与重采样被忽略的“隐形流水线”转码并不总是“解码→编码”一条直线。中途往往还要调整画面大小、帧率、色彩空间、音量、采样率。这些操作在 FFmpeg 里由libavfilter和libswscale/libswresample承担。4.1 滤镜图buffer 进buffersink 出源码里滤镜的核心是“buffer 源滤镜 处理链 buffersink 汇滤镜”。视频处理最常用的三个滤镜是scale、fps、format。一个典型的滤镜图构建流程是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 scale1280:720,fps25会被解析成一个一个的过滤器描述符然后动态生成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 muxermovenc.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_threads和thread_count多线程解码。AVCodecContext-thread_count如果设成 0 或 1 是单线程设大则解码器内部会创建线程池把多个 slice 或帧交给不同线程并行处理。这个机制对开发者是透明的你只管send_packet和receive_frame就行。但问题在于多线程解码时AVFrame从receive_frame拿出来的顺序未必跟 packet 送进去的顺序完全一致尤其是在 B 帧场景。如果你以为“先送的 packet 一定先出帧”那你就等着花屏吧。解决办法有两个要么用thread_count1强制单线程性能损失明显要么依赖帧的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_flags和hw_frames_ctx来协商显存帧池但前提是你得在代码层把回退逻辑写扎实。6.4 裁剪编译configure 是你的手术刀嵌入式场景下FFmpeg 全量编译的体积动辄几十 MB根本塞不进小 Flash。这时候要用 configure 来做裁剪。核心选项包括--disable-everything然后手动--enable-decoderh264、--enable-parserh264、--enable-demuxermov一点一点把需要的组件加回来。这里推荐一个组合套路./configure --disable-everything \ --enable-decoderh264 --enable-decoderaac \ --enable-encoderh264 --enable-encoderaac \ --enable-parserh264 --enable-parseraac \ --enable-demuxermov --enable-demuxerflv \ --enable-muxermp4 --enable-muxerflv \ --enable-protocolfile \ --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-hwaccelh264_vaapi这类选项。改完 configure 之后编译前最好make clean一次否则增量编译经常把一些不该编进来的旧 .o 文件带进去体积根本不减。这个坑我踩过说出来都是泪。写在最后的建议我给想啃源码的人一个比较现实的路线先别碰ffmpeg.c先用这套流程走通“从命令行到源码函数”的映射——随便写一条转码命令然后用gdb在av_read_frame、avcodec_send_packet、avcodec_receive_frame、av_interleaved_write_frame这几个核心函数上打断点用 bt 看调用栈你会发现整个库的内部调用关系被一步步摊开了比对着代码死记硬背高效得多。读完主体链路之后再回头去抠某个具体模块的细节比如 H.264 解码器的h264dec.c里如何处理 SPS/PPS或者mov.c里如何解析moovbox。你会发现有了骨架之后这些细节其实都是“在某条流水线上某个工位的具体操作”读起来不再零散。如果你是基于这套源码做二次开发我的最后一句忠告是多写日志但别在核心热路径上打日志。FFmpeg 的快速转码离不开 SIMD 和缓存友好型的数据布局你在decode/receive循环里插一句无脑 printf性能就能掉下去 20%。要打日志请使用 FFmpeg 自带的av_log()机制它也支持级别过滤和回调重定向线上排障时调整日志级别就行不用重新编译。这算是踩过版本发布事故之后换来的经验希望能给你省点时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价