☰
FFmpeg转封装核心:avformat_write_header原理与实战排查指南
2026/9/30 4:59:19 网站建设 项目流程

我最早碰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=xxxav_dict_set(&ctx->metadata, ...)
-f flvavformat_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 验证,它能正常解析,说明配置没问题,剩下的问题基本都出在打包循环里。

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

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

立即咨询