做音视频开发的朋友,对FFmpeg应该都不陌生。不管是命令行转码还是SDK二次开发,封装这一步基本绕不开av_write_frame()。它看起来只是一个“把packet写进输出文件”的简单函数,但真去翻源码会发现,里面藏着的检查逻辑、时间戳判断、muxer回调分发,比想象中要复杂得多。
这篇博文准备从源码层面把av_write_frame()拆开来看。我会讲清楚它在整个转码链路里的位置、它内部到底做了哪些事、调用前必须满足什么条件,以及我在实际项目中踩过的一些坑。适合已经用过FFmpeg API、想进一步理解mux内部机制的人;如果你刚接触FFmpeg,也可以把它当作理解封装流程的一个切入口。
1. av_write_frame()的定位与使用场景
1.1 从编码到写文件,它卡在哪一环
先看一个最典型的转码流程,以读取本地视频并重新编码为例:
AVFormatContext *in_fmt = NULL, *out_fmt = NULL; avformat_open_input(&in_fmt, input, NULL, NULL); avformat_find_stream_info(in_fmt, NULL); // ... 查找解码器、创建编码器 ... avformat_alloc_output_context2(&out_fmt, NULL, "mp4", output); while (av_read_frame(in_fmt, &in_pkt) >= 0) { // 解码得到frame,再编码得到out_pkt av_write_frame(out_fmt, out_pkt); av_packet_unref(&out_pkt); } av_write_trailer(out_fmt);在这段流程里,av_write_frame()是“编码器输出”和“文件字节流”之间的桥梁。编码器给出的AVPacket里装着压缩后的码流数据,但离最终落盘还差好几步:要写MP4就需要生成moov、mdat这类box结构,要写MKV就得按EBML规范组织Segment,要写TS还得按188字节的包去切。这些格式相关的逻辑都不在av_write_frame()主体里,而是被它转发给具体muxer。
所以av_write_frame()的本职工作是分发:负责把AVPacket交给当前输出格式对应的muxer,并保证这个packet是合法的。真正写字节的活,由每个格式的write_packet回调完成。用生活里的话打比方,编码器是生产素材的车间,av_write_frame()是核对完时间戳的调度员,它把素材递给“包装车间”的封装机,封装机再按MP4或MKV的规则打包。理解了这个定位,就不会再把它当成一个简单的fwrite封装了。
1.2 和av_interleaved_write_frame()的分工
在FFmpeg的API里,和av_write_frame()并肩的还有一个av_interleaved_write_frame()。两个函数名字很像,行为差异却不小。用了一段时间后,我对分工有个很直接的判断:
| 对比维度 | av_write_frame() | av_interleaved_write_frame() |
|---|---|---|
| 是否做交错(interleave) | 不做 | 做 |
| 对packet传入顺序的要求 | 必须按dts递增传入 | 允许乱序,内部排队重排 |
| 是否缓冲写 | 不缓冲,直接写底层I/O | 会缓冲若干帧,按dts顺序输出 |
| 适合场景 | 单路流、无B帧、快速写入 | 常规多路音视频、含B帧的H.264 |
| 内部开销 | 小 | 较大,多一层队列 |
简单说,如果你自己在外部已经保证了packet严格按dts递增排列,用av_write_frame()可以省掉内部排序;如果编码器输出了多个流,或者有B帧导致dts和pts次序不一致,老老实实用av_interleaved_write_frame()让FFmpeg帮你维护顺序,会稳妥得多。
这个区别在后面分析源码时会看得更清楚。av_write_frame()几乎不关心dts的相对顺序,拿到packet就直接写;av_interleaved_write_frame()则会把packet推进交错缓冲队列,达到一定条件后再按顺序写出去。
2. 源码怎么走:av_write_frame()的核心流程
2.1 函数入口与两个参数
av_write_frame()的实现位置在libavformat/mux.c,函数不长,但分支不少。先看声明:
int av_write_frame(AVFormatContext *s, AVPacket *pkt);- s:输出上下文,里面装着输出格式、输出流信息、底层I/O上下文
- pkt:编码器输出的一帧数据包
返回值是int,0表示成功,负值表示出错。实际调用中还有一个隐含约定:如果返回非负值,一般表示该packet已经被muxer接受并处理;某些特殊格式下返回正值有额外语义,但常规用法里我们统一按“非负即成功”来对待。
如果在命令行工具里追过调用链,会发现ffmpeg.c里的write_packet()其实就是套在av_write_frame()外面的一层薄壳,它的主要工作就是调用av_write_frame()并维护运行时统计信息。看懂这个函数,等于也看懂了命令行转码写文件这一侧。
2.2 四道检查,先说清楚
源码前几行都在做防御性检查。我按照执行顺序整理成以下几步:
检查s->oformat是否为NULL。oformat指向AVOutputFormat,它是整个封装格式的“说明书”。如果这个指针是空的,说明这个AVFormatContext根本不知道自己要写哪种格式,直接返回AVERROR(EINVAL)。
检查pkt是否带时间戳。对于绝大多数需要时间戳的封装格式,如果某个packet的dts等于AV_NOPTS_VALUE,函数会打出一条错误日志并返回EINVAL。这条日志很常见,就是“Timestamps are unset in a packet...”,后面我会专门讲。
检查输出格式的能力标志。有些格式不关心视频尺寸、不关心帧率,源码里会有针对AVFMT_NODIMENSIONS之类的跳过逻辑,避免对一些不该处理的字段做额外校验。
检查muxer的write_packet回调是否存在。如果s->oformat->write_packet为空,说明这个格式根本没有实现封装写入,也会直接失败。
把检查逻辑展开之后会发现,av_write_frame()把自己的职责收得很窄:检查完前置条件,剩下的全部交给回调。这也是FFmpeg设计上的习惯——把“格式无关”的公共逻辑放在mux.c,把“格式相关”的实现下沉到各个muxer文件里。
2.3 核心分发:write_packet回调
过了检查后,函数进入真正的写入分支。源码里会出现类似这样的逻辑:
if (s->oformat->write_packet) ret = s->oformat->write_packet(s, pkt); else ret = write_packet_common(s, pkt);这里要说明一点:新版本的FFmpeg里,几乎每个muxer都实现了write_packet,所以绝大多数场景走的是第一个分支。拿MP4来说,这个回调对应movenc.c里的mov_write_packet();拿MKV来说,对应matroskaenc.c里的实现。
如果某个格式没单独实现write_packet,就会退回公共的write_packet_common()。common函数里做的事情也很有意思:它会根据上下文维护通用统计信息,比如已写入的字节数、更新输出位置,必要时处理packet头部等。这些是所有格式都需要的通用记账逻辑。
从这个角度再看av_write_frame()的全部源码,能提炼出一个结论:它最大的存在价值不是自己写数据,而是统一入口加前置校验加回调分发。所有muxer格式都在这套框架下遵循相同的调用协议。
2.4 时间戳判断和duration兜底
文档里常见说法是:调用av_write_frame()之前,AVPacket里的pts和dts必须是有效值。但实际写代码时,从编码器拿到的packet不一定总带齐这两个字段,于是源码里就有一段“duration兜底”逻辑。
av_write_frame()内部会检查packet的duration是否为0,如果是,它会尝试用帧率、时间基去估算一个合理值。这种兜底对固定帧率视频有效,对VFR(可变帧率)则不一定准。源码里封装了类似guess_pkt_duration()的辅助函数专门做这件事。
所以如果你发现某些帧的duration输出后不对,可以先怀疑是不是走了兜底逻辑,而不是自己的时间基算错了。我的习惯是:只要编码器输出AVPacket,就先把rescale过的pts、dts、duration全部显式赋值,杜绝依赖兜底。兜底逻辑是给不会主动填值的调用者兜底的,不是给严谨应用省事的。
3. 调用前必须吃透的几个数据结构
3.1 AVFormatContext里藏着关键状态
av_write_frame()的第一个参数是AVFormatContext。这个结构体非常大,但真正影响写帧行为的字段值得单独列一下:
- oformat:决定封装格式,必须非空
- pb:AVIOContext,也就是底层I/O,决定数据写到文件、内存还是网络
- streams:AVStream指针数组,每个流对应一路编码数据
- nb_streams:流的数量,muxer用它对packet里的stream_index做校验
- flags:比如AVFMT_FLAG_IGNIDX、AVFMT_FLAG_BITEXACT等,影响写入细节
调用av_write_frame()时,muxer会根据pkt->stream_index取出对应的AVStream,然后按该流的时间基、编码参数去转换和写入数据。如果stream_index超出范围,muxer内部校验不过,会直接报错。
我在做流拷贝时踩过这个坑:手动构建AVPacket,只改了data和size,忘了把stream_index同步到目标流的索引,结果写入时各种怪异失败,最后定位就在这儿。从那以后,每次手动构造packet,我都会先把stream_index确认一遍。
3.2 AVPacket里的时间字段必须想清楚
AVPacket是编码器输出和muxer输入之间的标准载体。在av_write_frame()的视角里,它关心的关键字段有:
- data/size:压缩码流本身,muxer会原样写入容器
- pts:显示时间戳,单位是stream->time_base
- dts:解码时间戳,单位同样是stream->time_base
- duration:这一帧持续的时间
- stream_index:表明这个packet属于哪一路流
- flags:标记关键帧等
一个容易忽略的点是时间戳单位。av_write_frame()要求packet里的pts/dts单位与对应AVStream的time_base一致。很多时候编码后拿到的packet时间戳是编码器time_base的,必须用av_rescale_q_rnd()换算成stream的time_base,再交给av_write_frame()。
如果只rescale了pts而忘了dts,或者反过来,写出来的文件播放时会出现音画不同步、拖动卡顿,这号问题查起来特别费劲。我一般会在循环里打印pts、dts、rescale后的秒数,用实际时间去核验,而不是只看数值对不对。
3.3 AVOutputFormat:muxer能力的说明书
AVOutputFormat里定义了格式名、扩展名和一些标志位。av_write_frame()执行时读取oformat->flags来判断是否需要额外处理:
- AVFMT_NOTIMESTAMPS:该格式不在意时间戳,此时即便dts缺失也不会报错
- AVFMT_VARIABLE_FPS:支持可变帧率,duration兜底策略会不同
- AVFMT_NODIMENSIONS:不关心画面尺寸,跳过尺寸相关校验
源码里很多判断都是“oformat->flags & xxx”,所以遇到“为什么这个格式可以、那个格式不行”的差异时,先去看AVOutputFormat的flag定义,通常比盯着函数调用更直接。我第一次给裸H.264加容器时,就因为没搞懂AVFMT_NOTIMESTAMPS对数据流的影响,一直卡在时间戳报错上。
4. 实操中容易翻车的高频问题
4.1 “Timestamps are unset in a packet”怎么处理
这是av_write_frame()最经典的报错。出现这种情况,基本是packet的dts被设成了AV_NOPTS_VALUE,而输出格式又不是AVFMT_NOTIMESTAMPS。我遇到过类似这样的日志:
Timestamps are unset in a packet for stream 0. This is deprecated and will stop working in the future. Fix your code to set the timestamps properly这句话已经把解决方向说了一半,但很多人只看“deprecated”就以为升级版本就行,其实根源还是dts没填。排查步骤我一般这样走:
- 在调用av_write_frame()之前加断点或日志,打印pkt->dts和pkt->pts,确认是不是AV_NOPTS_VALUE。
- 如果编码器确实没给dts,比如某些编码器在非零bufsize下提前输出的packet没有时间戳,就在编码后的循环里手动赋值:用上一帧的dts加上duration来推导。
- 检查stream的time_base是否设置正确。即便手动赋值,时间基错了也可能导致数值溢出后看着像无效值。
还有一种特殊情况:从容器直接copy流而不重新编码,解封装读出来的packet一般都有完整时间戳,但如果输入格式本身不带时间戳,读出来就是AV_NOPTS_VALUE,这时需要根据帧序号或帧率重新推导。我的做法是在packet进入av_write_frame()之前统一做一次“时间戳补齐”,不指望muxer帮忙。
4.2 写完文件没有moov信息或者文件不可播放
这个问题经常出现在跳过头尾处理的场景。正确顺序是:先avformat_write_header()写文件头,再循环写帧,最后av_write_trailer()收尾。MP4这类格式,moov box的位置由写入过程动态决定,如果跳过write_header或者结尾没正确调write_trailer,文件虽然生成了,但播放器找不到关键索引,打开就失败。
我踩过更隐蔽的一次是程序中途崩溃没走write_trailer,导致MP4完全不完整。后来我在外层封装了统一的错误处理:任何异常路径都要走cleanup,把write_trailer和avio_close都执行到。别小看这个,因为实际项目里中断、磁盘满、缓冲问题随时会打断循环写帧。
另一个容易翻车的点:在调avformat_write_header()之前,必须先调avformat_alloc_output_context2()分配好上下文,并创建好AVStream、设置好编码参数。如果stream里的codecpar没填,很多muxer在write_header时就会因为拿不到编码格式信息返回EINVAL,而且错误日志还不明显,只能一步步排查。
4.3 音画不同步:先查时间基再查写入顺序
音画不同步这类问题,如果出现,八成不是av_write_frame()本身的问题,而是调用者没把时间基统一。举个例子,视频流time_base是1/90000,音频流time_base是1/48000,两个流的packet时间戳各自用各自的时间基没问题;但如果音频packet里误用了视频时间基,写出来就是明显的时长不对。
排查时我一般先把两个流的time_base打出来,再分别打印每个packet的pts/dts和对应的实际秒数,用表格形式的日志去对比音画播放位置,很快能定位哪一路流的时间戳在哪个阶段产生了偏差。这里有一个经验:不要只打印数值,要转成秒。因为90000这个基数下,差一个单位就是11微秒左右,肉眼看数值根本看不出问题,转成秒才直观。
4.4 什么时候该放弃av_write_frame()改用av_interleaved_write_frame()
如果你只写单路视频,且编码器不带B帧,av_write_frame()完全够用。但只要出现下面任何一种情况,我更推荐切到av_interleaved_write_frame():
- 输出包含音视频两路以上
- 编码器开了B帧,dts与pts次序不一致
- 多路流来自不同编码线程,packet到达顺序无法保证
- 输出目标不支持非交错写入,比如某些流式封装
原因在于:av_write_frame()不会在内部调整包顺序,一旦传入的dts顺序是乱的,最终文件里的帧顺序就会乱。而av_interleaved_write_frame()会用队列把packet按dts缓存排序,等到安全时机再交给muxer。代价是多一些延迟和内存占用,换来的是容器内交错正确,播放器可以边下边播。
5. 从源码里总结出的调用建议
5.1 自己动手跟踪一次调用链
对源码分析感兴趣的话,建议自己写个小demo,在调用链上打断点跟踪av_write_frame(),观察它走到了哪个回调。我一般会在三个位置打点:
- av_write_frame()入口,看pkt->dts/pts、stream_index
- s->oformat->write_packet回调入口,看进入哪个muxer文件
- write_packet内部,看它如何切分数据、写box和header
把这几个位置的状态日志拼起来,对一个packet从内存变到文件字节的整个过程会有非常直观的认识。跟踪过一次后,后面再遇到封装问题,排查思路会清晰很多,不会拿着各种方法瞎试。
5.2 调用顺序和生命周期管理
最后把av_write_frame()相关的标准调用流程再强调一遍,这是一个可复用的缩略版:
AVFormatContext *fmt_ctx = NULL; avformat_alloc_output_context2(&fmt_ctx, NULL, "mp4", out_filename); // 创建并填充stream,设置time_base和编码参数 avio_open(&fmt_ctx->pb, out_filename, AVIO_FLAG_WRITE); avformat_write_header(fmt_ctx, NULL); while (/* 有编码输出 */) { av_packet_unref(&out_pkt); // 别忘了先unref // 编码得到 out_pkt // rescale时间戳到 stream->time_base av_write_frame(fmt_ctx, &out_pkt); // 或 av_interleaved_write_frame } av_write_trailer(fmt_ctx); avio_close(fmt_ctx->pb); avformat_free_context(fmt_ctx);注意循环开头先调av_packet_unref()。这是因为avcodec_receive_packet拿到的packet内部有引用计数,不unref会造成内存持续增长。这是容易被忽略的坑,尤其在长循环里跑,内存缓缓上涨,很可能就是这里。
外层循环最好包一层错误判断:av_write_frame()返回负值时,立即跳出循环并走统一清理逻辑。不要试图吞掉错误继续写,否则很容易产出一半内容且索引损坏的废文件。
5.3 绕开兜底逻辑,自己掌控时间戳
源码里的duration兜底是为了“尽量让它工作”而设计的,但放在严谨工程里,最好自己把控所有时间字段。我给自己定的标准很简单:调用av_write_frame()之前,显式确保pkt->pts、pkt->dts都不是AV_NOPTS_VALUE,确保已经rescale到stream的time_base,确保duration为正且合理。这样不管底层muxer怎么变,行为都可预期。
我在项目里见过太多因为时间戳没设对而出现的诡异问题:有的表现是播放卡顿,有的是时长显示错误,有的干脆打不开。把时间戳字段管好,至少能消灭一半以上的封装问题。这也是av_write_frame()源码给我的最大提醒——它把责任分得很清楚,前置条件没满足,后面一切都白搭。
最后再分享一个小技巧:调试封装问题时,可以把输出从文件换成内存缓冲,用avio_open_dyn_buf或者自定义AVIOContext。这样每次写帧后都能直接检查缓冲里多出来的字节是否符合预期。很多容器字段写错的问题,都是靠这种“对着字节调代码”的方式快速定位的。希望这篇分析能帮你在用FFmpeg写文件的路上少踩几个坑。