我最早碰avformat_write_header这个函数,是在给公司的转封装服务加 MP4 录制功能的时候。代码写得很顺,avformat_alloc_output_context2、avio_open、av_interleaved_write_frame一路下来,最后文件也生成了,但拿播放器一打开就报"文件损坏"。排查了很久,最后发现是漏了avformat_write_header。那之后我对这个函数的定位彻底改变了——它不是一个"可选的初始化步骤",而是输出管道的"生死线"。这篇就把我从踩坑到理解、再到能熟练排查问题过程中积累的东西完整写下来,围绕avformat_write_header的函数原理、调用时机、参数细节、错误处理,以及它在文件录制和网络推流中的真实行为展开,给正在从命令行转向 FFmpeg API 开发的读者一份可以直接参考的实战笔记。
1. 为什么转封装管道里这行代码能决定文件"生"或"死":函数定位与底层角色
1.1 一次"文件打不开"的翻车现场
当时我接手的模块,输入是 RTSP 摄像头的 H.264 裸流,输出要落成 MP4 文件。由于源数据已经是 H.264,同事最初觉得"转封装不需要重新编码,只要把包写进去就行",于是写代码时把avformat_write_header整行注释掉了,理由听上去还挺合理:"我们又不改编码,写 header 有什么用?"
结果产出的文件大小始终是 0 字节,偶尔有几个字节,播放器全部打不开。后来翻 FFmpeg 源码和文档才明白,MP4 这类容器不是简单的字节流拼接,它的文件结构要求在开头写入ftypbox 和moovbox,播放器要靠这些 box 才能知道文件里有几条轨道、视频编码是什么、时间基准是多少。没有这一层"目录索引",后面写再多的包数据也没有意义。你可以在avio_open之后随便写几千字节的垃圾数据,文件可能也能打开,但播放器读到的轨迹完全是错的。
这个教训让我后来养成一个习惯:凡是自己写 FFmpeg API 程序,第一步先把avformat_write_header和av_write_trailer这两个函数的调用位置确定下来,再动手写中间的打包循环。很多"文件损坏""花屏""播放器无法定位时长"的问题,根因都不是编码器参数,而是这一头一尾没处理好。
1.2 write_header、open_input、io_open的分工边界
要理解avformat_write_header,必须先把它和周边几个 API 的分工划清楚。很多人一开始会把"打开输入文件"、"打开输出文件"、"写封装头"混在一起,导致上下文一混乱就报错。
avformat_open_input:负责读路径。它打开输入文件或流,通过探测数据识别容器格式,填充输入的AVFormatContext。avio_open:负责底层 I/O 通路。不管是本地磁盘文件、内存缓冲区,还是 RTMP 网络连接,AVIOContext都是统一的数据出口。avformat_write_header:负责输出路径上的封装层初始化。它拿到你已经配置好的AVFormatContext,把它交给ctx->oformat对应的封装器回调,让封装器在真正写媒体数据之前把容器自身的状态建立起来。
这里的"容器自身的状态",每个格式都不一样。写 MP4,回调要生成ftyp和moov;写 FLV,回调要生成 FLV header 和onMetaDatascript tag;写 HLS,回调要初始化 m3u8 索引,并准备第一个分片。这也是为什么我建议新手直接去读源码里libavformat/mux.c的avformat_write_header实现,它做的事情比大多数人以为的多得多:
int avformat_write_header(AVFormatContext *s, AVDictionary **options)函数的核心逻辑是先检查上下文状态和流参数,再把options里的键值对按协议层、封装层分别分发,最后调用s->oformat->write_header。如果封装器的write_header返回错误,整个输出流程会直接中止,后面你再写包,封装器也不会接受。
所以你可以把avformat_write_header理解成一个"构造函数":它负责把输出管道初始化到"可以接收媒体包"的状态。没调用它,输出管道就是一根没有插头的电线,电压再高也点不亮灯泡。
2. 铺垫工作做不对,header永远写不出去:调用前的AVFormatContext与AVStream配置
2.1 从函数签名看使用约束
avformat_write_header的声明非常简洁:
int avformat_write_header(AVFormatContext *s, AVDictionary **options);只有两个参数,但很多人在第二个参数上栽过跟头。options的类型是AVDictionary **,不是AVDictionary *,它设计成这个类型,是因为函数内部会把"当前格式不认识的选项"留在字典里返回给你,方便你调用结束后检查有没有拼错的参数名。这一点后面还会细说,先看第一个参数:调用前它必须满足三个前提。
第一,s->oformat必须非空。通常通过avformat_alloc_output_context2创建上下文时就已经绑定好了。如果你用avformat_alloc_context手动分配,再忘记赋值ctx->oformat,avformat_write_header会直接返回AVERROR(EINVAL)。
第二,s->pb必须已经通过avio_open打开。avio_open的第二个参数是输出 URL,第三个参数要传AVIO_FLAG_WRITE。如果打开失败,函数内部会在写头时报 I/O 错误,日志里往往能看到Unable to open URL之类的信息。
第三,ctx->nb_streams必须大于 0。封装器需要知道至少一条流的信息才能构造轨道描述。即使是纯音频或者纯视频,也必须通过avformat_new_stream创建出流对象。
2.2 AVStream必须携带哪些信息才能"过关"
比上下文更关键的是流信息。avformat_write_header内部会读取每个AVStream的codecpar、time_base、avg_frame_rate、sample_aspect_ratio等字段,用来生成轨道信息。其中最容易踩坑的,是time_base。
从命令行切到 API 的开发者经常会发现一个问题:用ffmpeg -i input.mp4 -c copy output.mkv命令时一切正常,换成自己写代码后,输出的文件时长不对,或者在写包时狂刷Application provided invalid, non monotonically increasing dts to muxer。这个问题的直接原因就是输出流的time_base没有设置成和 packet 时间戳一致的单位。
举个例子:你的编码器输出帧率是 25fps,packet 里的 pts 按 1000 为单位递增,也就是每帧 40。但你没有设置out_stream->time_base,它默认是{0, 0}或某个残留值。封装器计算 DTS 时就会得到一团乱码,写进容器的时间轴自然错了。
我一般会在创建输出流后立刻做三件事:
AVStream *out_stream = avformat_new_stream(ctx, NULL); avcodec_parameters_copy(out_stream->codecpar, codec_ctx->codecpar); out_stream->time_base = (AVRational){1, 1000};注意第三行,这是我个人的默认习惯。实际用{1, 1000}还是{1, 90000},取决于封装格式和业务需求。MP4 用{1, 1000}通常没问题;MPEG-TS 和部分直播场景用{1, 90000}更稳妥。关键是不要留成默认值。
除了time_base,codecpar里的extradata也必须保证正确。H.264 的 SPS/PPS、H.265 的 VPS/SPS/PPS,在封装成 MP4 时要写进avcC/hvcCbox,在 FLV 里要写进AVCDecoderConfigurationRecord。avformat_write_header不会替你去解码器里取这些信息,它只负责把你准备好的 extradata 转换格式。如果你的输入是裸流,必须先通过avcodec_parameters_from_context把编码器上下文的参数抄到codecpar上,再拷给out_stream->codecpar。漏掉这一步,封装器虽然也能写头,但生成的流在某些播放器里会黑屏或绿屏。
2.3 附加信息:metadata、chapter 和 attached_pic 的写入时机
avformat_write_header不只是写轨道,它还会把ctx->metadata、ctx->chapters以及附带封面图一并写入封装层。这个特性容易被忽略,但它对文件的可读性影响非常大。
命令行里的-metadata title="xxx",在 API 里的实现方式就是在调用avformat_write_header之前,通过av_dict_set把键值对挂到对应对象的 metadata 字典上。MP4 封装器会在udta/metabox 里写入标题、语言、创建时间;FLV 封装器会在onMetaDatascript tag 里带上这些信息。如果你不调av_dict_set,affprobe 查出来的文件标题栏就是空的,这不是封装器的问题,而是源头上没设。
举个例子:
av_dict_set(&ctx->metadata, "title", "My Video", 0); av_dict_set(&stream->metadata, "language", "eng", 0);这两个调用要在avformat_write_header之前完成,因为封装器只在写头阶段读取 metadata。如果你在写完头之后才设置字典,这些信息不会被写入文件。
章节信息和封面图也是同理。ctx->chapters需要在你调用avformat_write_header前构建好;封面图则是在某个AVStream上通过attached_pic字段挂载,里面存放图片编码后的AVPacket。我做过几次带封面的 MP4,最大的感受是:封面图的codecpar必须是 JPEG 或 PNG,封装器对附加图片的编码格式有严格要求,否则写头阶段会直接报错。
3. 写头不是"随手一写":调用位置、返回值与错误处理的完整细节
3.1 正确的调用位置:从分配上下文到写trailer的完整流程
先给出一段可以抄作业的核心流程。这里省略了编码器初始化和 packet 生成逻辑,只展示与avformat_write_header强相关的骨架:
AVFormatContext *ctx = NULL; avformat_alloc_output_context2(&ctx, NULL, NULL, "output.mp4"); if (!ctx) { // 处理失败 } if (avio_open(&ctx->pb, "output.mp4", AVIO_FLAG_WRITE) < 0) { // 处理失败 } AVStream *out_stream = avformat_new_stream(ctx, NULL); avcodec_parameters_copy(out_stream->codecpar, codec_ctx->codecpar); out_stream->time_base = (AVRational){1, 1000}; AVDictionary *opts = NULL; av_dict_set(&opts, "movflags", "faststart", 0); int ret = avformat_write_header(ctx, &opts); if (ret < 0) { char errbuf[AV_ERROR_MAX_STRING_SIZE] = {0}; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, "write header failed: %s\n", errbuf); return -1; } // 循环写入 packet while (/* 有 packet */) { av_interleaved_write_frame(ctx, pkt); } av_write_trailer(ctx); avio_closep(&ctx->pb); avformat_free_context(ctx);这段代码里的位置顺序非常重要。avformat_new_stream必须在avformat_write_header之前,因为封装器需要遍历所有流。avio_open也必须在 write header 之前,因为 FLV 和 MP4 的头部数据要写到ctx->pb指向的 IO 上。如果你把avio_open放在avformat_write_header之后,函数会因为没有可写的 IO 返回错误。
还有一个经常被忽略的点:av_write_trailer也必须调用。MP4 封装器在 write header 阶段会预留一部分字节,等到av_write_trailer时才把真正的时长、文件大小回填到moov里。如果只调 write header 不调 trailer,文件头尾信息就会对不上,播放器显示异常时长甚至无法播放。
3.2 返回值对照:遇到AVERROR(EINVAL)、AVERROR_INVALIDDATA怎么办
avformat_write_header返回 0 表示成功,负数表示失败。我在几个项目里遇到过的错误返回值,整理成了一张表:
| 返回值 | 实际原因 | 排查方向 |
|---|---|---|
| AVERROR(EINVAL) | 上下文为空、oformat缺失、pb未打开、编码参数不合法 | 优先检查ctx->oformat和ctx->pb是否有效 |
| AVERROR_INVALIDDATA | 流参数与封装格式不匹配、没有可用流、extradata缺失 | 检查codecpar和extradata,确认流的编码id是否被容器支持 |
| AVERROR(ENOMEM) | 内部申请内存失败 | 看系统内存,检查是否有内存泄漏 |
| AVERROR(EIO) | 底层写入失败 | 检查磁盘空间、文件权限、网络连接状态 |
| AVERROR(EPIPE) | RTMP 等网络协议中断 | 检查服务端是否断连,握手是否完成 |
遇到AVERROR(EINVAL),我的排查顺序是固定的:先看ctx->oformat是否为 NULL,再看ctx->pb是否已经打开,最后才怀疑流参数。这三个问题里,第二个最隐蔽,因为avio_open失败时你可能只打了日志,但没有及时返回,导致后面拿着一个空的pb继续走流程。
遇到AVERROR_INVALIDDATA,优先检查codecpar从哪里拷来的。我见过一个很典型的错误:开发者从输入文件的AVStream->codecpar拷贝参数到输出流,但输入文件的编码格式在输出容器里不被支持,比如把 MPEG-2 视频写进 FLV。FLV 封装器对 CodecID 有严格限制,只支持 H.264、HEVC、VP6 等少数几种,不支持的编码会导致 write header 阶段返回数据无效错误。
3.3 options参数到底传了什么:AVDictionary与封装器私有选项
第二个参数options是avformat_write_header最灵活的地方。很多人只知道传 NULL,实际上你可以通过它传递封装器的私有选项,影响写入行为。
AVDictionary本质上是一个键值对容器。调用时,你可以先把想设置的选项放进去:
AVDictionary *opts = NULL; av_dict_set(&opts, "hls_time", "5", 0); av_dict_set(&opts, "hls_list_size", "20", 0); avformat_write_header(ctx, &opts);avformat_write_header内部会遍历这个字典,把当前封装格式能识别的键取走并生效。处理完剩下的键会留在字典里返回给你,所以调用后检查一下av_dict_count(opts)是否大于 0,是判断参数名是否拼写正确的一个有效手段。这个技巧在命令行里对应的就是-hls_time 5 -hls_list_size 20,只是命令行工具替你完成了字典的构造和传递。
要注意,options不仅会影响封装层,还会影响协议层。比如对 RTMP 输出,你可以通过它传rtmp_buffer、rtmp_live等协议参数。在使用网络输出时,这个通道非常关键。我曾经在推流到 SRS 时遇到反复断连,后来发现是 RTMP 协议层的 buffer 设置太小,通过在avformat_write_header前设置rtmp_buffer解决了问题。
4. 文件录制和网络推流的头信息差异:从FLV/RTMP到HLS的行为观察
4.1 本地mp4和网络FLV在write_header那一刻做的事完全不同
同一个avformat_write_header,面对不同封装格式时,内部行为差异大到会让你怀疑是不是调了两个函数。我把文件录制和网络推流最典型的两个场景分别拆开看。
本地 MP4 场景,avformat_write_header做的事情是:生成ftypbox,生成moovbox,把每个流的编码信息、时间基、语言元数据写进去。moovbox 里还包含每个 track 的 sample table 的起始偏移位置。这些信息在写包阶段会被不断更新,最终在av_write_trailer时回填。
网络 FLV/RTMP 场景,avformat_write_header做的事情则完全不同:它先把 9 字节的 FLV header 写进 RTMP 流,紧接着写一个onMetaDatascript tag,里面包含音频编码、视频编码、宽度、高度、帧率、码率等信息。这些信息是播放器解析流的第一份数据,如果时序不对,服务端和播放端都会出问题。
HLS 场景又不一样。avformat_write_header会初始化 m3u8 播放列表,决定是否生成#EXT-X-MEDIA-SEQUENCE,并准备好第一个 TS 分片。HLS 的write_header行为还和hls_time、hls_list_size这些私有选项强相关,所以通过 options 传入的参数会在这一步直接生效。
理解了这些差异,调试时才不会犯"本地文件正常,网络推流失败"时一脸懵的错。本地文件写坏了可以用 ffprobe 看文件头,网络流只能靠协议分析和服务端日志,定位难度高一个量级。
4.2 推流到SRS有延迟:write_header是不是该背锅
搜索 FFmpeg 相关问题时常能看到"推流到 SRS 存在延迟"的讨论。这里把话说透:延迟问题和avformat_write_header的关系不大,更多来自编码参数和拉流播放端的缓冲策略。
avformat_write_header对延迟唯一可能产生影响的,是元数据里duration和filesize字段的取值。对于直播流,FLV 封装器默认会在onMetaData里写出duration=0、filesize=0,表示这是一个不定长的实时流。如果你之前设置过flvflags或某些私有选项,导致这两个字段被填成了具体数值,某些播放器可能会按这个值做缓冲判断,出现延迟增大。
我自己的做法是,在推流版项目里这样设置:
AVDictionary *opts = NULL; av_dict_set(&opts, "flvflags", "no_duration_filesize", 0); avformat_write_header(ctx, &opts);这能让 FLV 封装器不输出 duration 和 filesize,播放器就不会错误地按文件模式缓冲。但注意,这只是辅助手段。真正影响延迟的核心,是编码端的 GOP 大小、tune=zerolatency有没有设置、B 帧是否关闭、播放端的buffer_time配置。所以如果推流延迟高,不要一上来怀疑 write_header,先看编码器和播放端参数。
5. 实测中write_header失败的四个经典场景与完整排查思路
5.1 time_base未设置导致的"Header writing failed"
前面提到过 time_base 的重要性,这里展开讲一个真实的排查链路。某个项目的输出是 MPEG-TS 文件,程序从解码器拿到帧,直接打包写入输出流。运行后日志里不断出现:
Application provided invalid, non monotonically increasing dts to muxer最开始我以为是编码器的时间戳跳变,后来发现是因为out_stream->time_base没有设置,而 packet 的 pts/dts 是按{1, 90000}递增的。封装器拿到了一组它无法理解的时间值,自然写不出正常的头信息。
排查思路是这样的:先打印out_stream->time_base和第一个 packet 的 dts、pts,发现数值完全对不上。再把输出流的time_base改成和 packet 一致的{1, 90000},问题立即消失。整个过程只花了 20 分钟,但之前没有思路时卡了小半天。
建议你在创建输出流后,打印一行日志确认time_base:
av_log(ctx, AV_LOG_INFO, "out stream time_base: %d/%d\n", out_stream->time_base.num, out_stream->time_base.den);5.2 空流和单流文件:write_header对流数量的强制要求
绝大多数封装器都要求ctx->nb_streams > 0。遇到只有一个音频或只有一个视频的文件,只要流数量不为零就没问题。但如果你的程序做了过滤逻辑,把音频流全滤掉了,或者编码失败导致流没有有效数据,write header 阶段就会暴露问题。
我遇到过一个案例:批量转码脚本里,用户上传了一个空视频轨的素材,程序里音频拷贝正常,视频编码失败导致视频流没有有效 packet。avformat_write_header返回的错误信息是stream number is zero,具体含义是视频轨虽然存在,但没有可用的编码参数。解决方法是编码前先检查codecpar->codec_id是否合法,编码后检查codec_ctx->frame_number是否大于 0,不满足条件就不创建输出流。
5.3 非基本流数据:attachment和subtitle需要特别处理
如果输出 MP4/MKV,除了音视频,还有封面图、字幕轨道等附加数据。封面图在 AVStream 上通过attached_pic表示,这个结构里的data字段存有图片编码后的数据。如果你不处理它,write_header 会因为封面流缺少有效编码数据而报错。
字幕流也是类似的坑。ASS 字幕的codecpar->extradata里存放的是脚本头信息,比如字体声明、分辨率、事件格式。如果这个字段是空的,封装器虽然能写出轨道,但播放器渲染字幕时会乱码或无法显示。正确做法是在 write_header 前,把字幕解码器上下文里的参数完整拷到out_stream->codecpar。
5.4 复用同一个AVFormatContext反复write_header的隐患
不要在同一个 AVFormatContext 上调用两次avformat_write_header。这是我在做循环录制功能时被坑出来的经验。
最初为了减少 CPU 开销,我想复用上下文,于是在每个录制周期开始前重新avio_open,然后再次调用avformat_write_header。结果第二次调用时,MP4 封装器直接返回AVERROR(EINVAL)。查了源码才知道,MP4 的mov_write_header里有状态判断,moov 一旦写过就不会允许再写。后来的做法是每个录制周期都释放旧上下文、重新分配新上下文,问题才消失。
如果你也有类似的循环录制需求,建议直接写成"每次循环都从avformat_alloc_output_context2开始",而不是试图复用。虽然多了一点分配开销,但远比排查状态残留问题省时间。
6. 从命令行反推API行为:用ffmpeg CLI和ffprobe验证头信息
6.1 ffmpeg命令行内部如何调用avformat_write_header
如果你用过ffmpeg -i input.mp4 -c:v libx264 -f flv rtmp://...,那么实际上你已经接触过avformat_write_header了。命令行工具内部的处理流程和 API 程序基本一致:为输出文件创建 AVFormatContext,创建输出流,拷贝或转换编码参数,然后调用 write header。
很多命令行参数都能在 API 里找到对应关系。我整理了一个速查表:
| 命令行参数 | API 对应方式 |
|---|---|
-movflags faststart | 在 options 里设置movflags=faststart |
-flvflags no_duration_filesize | 在 options 里设置flvflags=no_duration_filesize |
-hls_time 5 | 在 options 里设置hls_time=5 |
-metadata title=xxx | av_dict_set(&ctx->metadata, ...) |
-f flv | avformat_alloc_output_context2的 format 参数 |
这个对应关系对排查问题非常有用。比如你发现命令行输出正常,但 API 输出文件无法播放,先对比两者的参数设置,往往能快速找出差异。
6.2 用ffprobe检查write_header写出的内容是否合理
写完文件后,用 ffprobe 检查头信息是否正确,是必不可少的一步。
ffprobe -v error -show_format -show_streams output.mp4重点看几个字段:format_name、duration、nb_streams,以及每个 stream 的codec_name、time_base、extradata。如果duration等于 0,很可能是 write header 前 time_base 设置不正确;如果某个 stream 的extradata为空,那 H.264 的 SPS/PPS 就没有写进容器。
还有一个更专业的检查方式:用ffprobe -show_packets看第一个关键帧的位置。MP4 和 HLS 都会依赖头部索引来定位关键帧,如果头部索引和实际数据偏移不一致,播放器会出现"能拖进度条但画面卡住"的问题。
6.3 我的调试习惯:日志观察、strace和二分法
如果通过 ffprobe 还是定位不了问题,我的调试三板斧是:打开 FFmpeg 日志、用系统调用跟踪、做二分法对比。
打开日志的方式很简单:
av_log_set_level(AV_LOG_DEBUG); av_log_set_callback(av_log_default_callback);在调用avformat_write_header前后各打一行日志,观察日志里 muxer 有没有打印具体的错误信息。FFmpeg 在 DEBUG 级别会把很多封装器内部的诊断信息打印出来,包括某个字段缺失、某个参数不合法。这些信息比返回值本身有用得多。
如果怀疑是底层 I/O 问题,我会用系统调用跟踪工具看实际写入了多少字节。文件权限、磁盘空间、网络连接状态,都可能让avio_open表面成功但实际写入失败。比如网络推流时,服务端可能在握手阶段就断开了连接,write header 阶段虽然返回 0,但后面写包时全部失败。
二分法对比就更直接:先改成写本地文件,如果本地文件正常,问题大概率在网络协议或服务端配置;如果本地文件也不正常,继续把输出从 MP4 换成 AVI,逐步缩小问题范围。这个方法看起来笨,但面对封装器报的模糊错误时,几乎是效率最高的定位手段。
个人体验下来,avformat_write_header这个函数最大的特点是"表面简单,实际复杂"。参数就两个,但埋着上下文、流参数、I/O 状态、格式私有选项四层依赖。一开始我把它当作随手一行 API,踩了两次坑之后才真正重视起来。建议所有做 FFmpeg API 开发的人,都把自己项目的封装流程画成一张时间线,标明每个 API 的调用位置,尤其是 write header 和 write trailer 这两端,画清楚之后,很多错误其实一眼就能看出来。最后再分享一个小技巧:如果怀疑自己的流参数或 metadata 配置有问题,可以在写完一个仅有头尾、没有媒体 packet 的"空壳文件"后用 ffprobe 验证,它能正常解析,说明配置没问题,剩下的问题基本都出在打包循环里。