☰
FFmpeg AVPacket 全解析:从生命周期到踩坑实战
2026/10/16 8:00:03 网站建设 项目流程

做音视频开发的人,多多少少都绕不开 FFmpeg。刚开始我是照着一套示例代码直接跑:avformat_open_input 打开文件,av_read_frame 拿数据,avcodec_send_packet 喂给解码器,最后 avcodec_receive_frame 出帧。流程是通的,画面也出来了,但一旦涉及音视频同步、多路流切换、内存持续上涨这类问题,就彻底抓瞎。回头排查了一圈,根子全在同一个地方——AVPacket 的使用姿势不对。

这篇博文想把 FFmpeg API 里的 AVPacket 整个拆开讲透。它适合刚接触 FFmpeg 开发、正在为“怎么拿一包数据”“怎么释放一个 packet”纠结的新手,也适合已经写过几个 demo、但一遇到时间戳错乱或内存泄漏就头疼的中级开发者。看完之后,你应该能清楚一个 packet 从解封装到解码、从编码器到封装器,中间每一步该怎么处理,以及为什么必须这么做。

1. AVPacket 在数据流管线里的两个“搬运”阶段

1.1 解封装到解码:packet 是你手里第一个“成品”

FFmpeg 处理一个媒体文件的起点,并不是解码,而是解封装。一个 MP4、FLV、TS 文件里,音频和视频是按某种容器规则交错存放的。解复用器(demuxer)负责把文件里的数据块读出来,每读出一块,就封装成一个 AVPacket 交给你。

拿快递打比方:AVPacket 就是一个密封的快递盒,盒面贴着标签(pts、dts、stream_index),里面装着你实际上需要的压缩数据(data 指针指向的内存区域)。这个阶段你拿到的还不是能直接播放的原始像素或 PCM,而是编码器吐出来的压缩数据,可能是 H.264、AAC 或者其他编码格式。

有个细节容易忽略:一个 AVPacket 和视频帧、音频帧的关系并非一对一。视频一般是“一个 packet 对应一帧”,但音频经常是“一个 packet 里装了好几帧”。你只有在解码之后,才知道里面到底包含了多少 PCM 样本或多少视频帧。所以,不要试图从压缩数据本身去猜“这一包该播放多久”,那是容器和 packet 字段的职责。

1.2 编码到封装:packet 是转码链路的最终产物

如果做的是单纯解码播放,AVPacket 走到解码器就结束了。但你要是做转码、录屏、推流这类工作,AVPacket 还会再次出现——这次它是编码器的输出。

流程是这样的:解码器把 AVPacket 变成 AVFrame,你对该帧做缩放、滤镜、叠加等处理,然后送入编码器。编码器再吐出一个 AVPacket,这个 packet 里的内容就是编码后的新数据,最后交给复用器(muxer)写入文件或推流。

这个方向的 packet 和读文件拿到的 packet 有个重要差异:你读取时拿到的 packet,容器已经帮你填好了 stream_index 和流的时间基准;编码器吐出来的 packet,虽然也有 pts、dts、duration,但它的时间基来自编码器上下文。在交给 muxer 之前,你需要做时间基准换算,并把 packet 挂到正确的输出流上。这一步漏了,就会出现输出文件时长不对、播放倍速异常、音画不同步之类的问题。

1.3 与 AVFrame 的分工差异

很多人把 AVPacket 和 AVFrame 混在一起,觉得都是“一帧数据”。实际上两者的分工非常清晰:

对比项AVPacketAVFrame
承载内容压缩后的编码数据解码后的原始数据(YUV/RGB、PCM)
数据布局连续 buffer,data + size多平面数据,有 linesize 和 channel 布局等
参与环节解封装→解码,编码→封装解码→滤镜/处理→编码
内存特征靠引用计数管理,unref 释放同样需要 ref/unref,但字段更多更复杂
时间戳语义pts/dts 是编码顺序相关,需要换算pts 通常是显示顺序,单位与流相关

一个直观的理解方式:AVPacket 是压缩世界的“快递单号”,AVFrame 是像素世界的“工作台”。你在工作台上处理数据之前,必须先签收快递——也就是解码。

2. pts、dts、duration、stream_index 这些字段的真相

2.1 字段速查表,以及 AVPacket 没有 time_base 这件事

先把 AVPacket 常用的字段摆出来,每个字段我都标了实际使用中的关键提醒。

字段含义使用注意
data压缩数据指针不一定以 \0 结尾,永远用 size 判断长度
size数据字节数解封装后可能为 0,这种空包有特殊用途
pts显示时间戳单位是所在流的时间基准,可能是 AV_NOPTS_VALUE
dts解码时间戳与 pts 不同步时代表解码顺序
duration该包持续时长单位同样由流的时间基准决定
stream_index属于第几条流多路流时必须根据它分发数据
flags标志位关键帧、可丢弃等,转封装时很关键
side_data附加数据数组存储回放增益、调色板等元信息
side_data_elemsside_data 数量遍历时需要用到
buf内部引用计数 buffer通常不直接操作,由 API 管理

这里有个新手最常踩的误区:AVPacket 里并没有一个叫 time_base 的字段。它的 pts、dts、duration 统统是相对于所属流 AVStream.time_base 的。FLV 里视频流时间基准一般是 1/1000,所以 duration = 40 意味着 40 毫秒;但 MPEG-TS 里视频流时间基准是 1/90000,duration = 3600 才意味着 40 毫秒。直接把两个容器的 packet 拿来比较时间戳,结果一定是一团糟。

2.2 pts 与 dts 不一致的场景:B 帧和音频 delay

pts 是显示时间戳,表示这一帧内容什么时候该显示;dts 是解码时间戳,表示这一帧数据什么时候该进入解码器并完成解码。当编码器用了 B 帧之后,两者就会出现差异。

举个最直观的例子:一段视频,显示顺序是 I、B、B、P。解码器在解第一个 B 帧之前,必须先把 P 帧解出来,因为 B 帧可能参考了它。于是解码器实际拿到的包顺序可能是 I、P、B、B,这时候 dts 就是 0、1、2、3,而 pts 可能是 0、2、3、1。如果你在代码里直接用 dts 驱动渲染,视频就会花屏或顺序颠倒。

音频由于没有 B 帧这种参考机制,绝大多数情况下 pts 等于 dts。但某些编码格式会有 encoder delay,表现为第一个包的 pts 不是 0,通常需要配合 side_data 里的延迟信息纠正。所以,不能认为音频的 pts 一定从 0 开始。

处理时还有一点必须养成的习惯:使用 pts、dts 之前先判断是否为 AV_NOPTS_VALUE。有些封装格式根本不写时间戳,这个值是约等于 -9223372036854775808 的特殊值,直接参与加减乘除会把整个时间轴算崩。

2.3 duration 的取值与估算方法

duration 表示这个包在 stream 时间基准下的持续时长。它在做精准裁剪、字幕对齐、进度条控制时有价值。

视频的 duration 通常是 1 帧的时长。比如帧率 25fps、time_base = 1/90000 时,一帧时长约为 3600。音频的 duration 取决于一个 packet 里装了多少样本。AAC 一个 packet 通常是 1024 个样本,假设采样率 44100、time_base = 1/44100,那么 duration 就是 1024。

问题是不少封装格式并不提供 duration 字段,很多 packet 拿到的 duration 就是 0。这种时候如果你确实需要它,可以自行估算:视频用帧率倒数换到流时间基准,音频用样本数除以采样率再换到流时间基准。不能直接在程序里硬编码“一包 20ms”,因为不同容器、不同编码格式的实际情况差很多。

2.4 stream_index 决定数据去向

多路流的文件里,demuxer 会交替吐出不同流的 packet。假设某个文件有 1 路视频、2 路音频,你从 av_read_frame 拿到的 packet,stream_index 可能是 0、1、2 中的任何一个。

很多初学的代码写成这样:

if (pkt->stream_index == 0) { // 当成视频处理 }

这是赌 stream_index 0 一定是视频,但文件结构稍微一变就完蛋。正确做法是从 AVFormatContext 里先遍历 streams,根据 codecpar->codec_type 判断出 video_stream_index 和 audio_stream_index,再用这个索引去比对 packet->stream_index。多路音频字幕的场景,还要维护一个数组做映射,而不是只认死一个数字。

2.5 flags 与 side_data:容易被忽略的“标签”

AVPacket.flags 是一个位掩码,最常用的是AV_PKT_FLAG_KEY,表示这个包是关键帧。做视频剪切、seek 定位、流媒体切片时都要靠它。另一个是AV_PKT_FLAG_DISCARD,表示解码器可以跳过这个包;还有AV_PKT_FLAG_CORRUPT,提示包数据可能损坏。

side_data 是挂在 packet 上的一堆附加信息,形式上类似“快递盒外面额外贴的标签”。常见类型包括调色板数据、回放增益、跳过的样本数、S12M 时间码等。普通播放场景基本不用管它,但做专业转码或设备对接时,如果发现输出文件出现“公认的奇怪现象”,比如音频延迟对不上、画面颜色不对,可以检查一下 side_data 是否在转封装过程中丢掉了。

查询 side_data 推荐用新版本封装好的接口,不要自己从头遍历 side_data_elems 再猜数据类型。

3. 引用计数与释放策略:别再手动 malloc 了

3.1 av_packet_alloc 和 av_packet_free 的正确使用

我在早期项目里见过不少代码,直接用 av_malloc 分配一个 AVPacket 结构体,然后 memset 清零,再手动填字段。这种写法在旧版 FFmpeg 里有人这么干,但它绕开了内部 buf 指针和 side_data 的所有权管理,稍不留神就是 double free 或者内存泄漏。从新版本 API 开始,官方思路已经非常明确:用 av_packet_alloc 分配,用 av_packet_free 释放。

AVPacket *pkt = av_packet_alloc(); if (!pkt) { // 分配失败,处理错误 } // ... 使用 pkt ... av_packet_free(&pkt);

av_packet_alloc 只负责分配结构体本身,不分配数据 buffer,所以刚返回时 data 为 NULL、size 为 0。真正的数据 buffer 是在 av_read_frame、avcodec_receive_packet 等函数往里填包时才分配的。av_packet_free 会先自动清理内部持有的数据,再把结构体指针置为 NULL,避免你手上残留悬空指针。

有些教程还会出现 av_init_packet,这个函数在较新版本里已经被弃用。如果你看到的示例还在用它,建议直接切到 av_packet_alloc 这套新 API。

3.2 ref / move_ref / unref 三者有什么区别

AVPacket 内部的数据采用引用计数机制。你可以把它想象成多个快递单号指向同一个仓库里的包裹,大家通过一份“引用记录”确认包裹什么时候能销毁。

  • av_packet_ref(dst, src):让 dst 共享 src 的数据 buffer,内部引用计数加一,然后把 src 的字段复制到 dst。之后 dst 和 src 指向同一份数据,但彼此是独立的结构体。
  • av_packet_move_ref(dst, src):把 src 持有的数据所有权整体转移给 dst,src 被清空。这个操作不需要额外增加引用计数,效率更高。适合把临时的 packet“上交”给队列或另一个模块,交完之后 src 就彻底废了。
  • av_packet_unref(pkt):让当前 packet 不再持有它引用的数据,引用计数减一,并且把结构体内部字段重置为空状态。注意它不会释放结构体本身,所以你还能继续复用它。

特别强调一点:不要用memcpy(dst, src, sizeof(AVPacket))去复制 packet。这么做会让两个结构体同时持有一个数据引用,却没有正确增加引用计数,最终大概率导致 double free 或悬空访问。需要复制时就用 av_packet_ref;需要把数据本身完整拷贝一份而不是共享时,要先确保源是引用计数管理的,再考虑其他深拷贝方案。

3.3 一个标准解码循环的生命周期拆解

把上面的理论落到最常见的解码循环里,大概长这样:

AVPacket *pkt = av_packet_alloc(); if (!pkt) return -1; while (av_read_frame(fmt_ctx, pkt) >= 0) { if (pkt->stream_index == video_stream_idx) { int ret = avcodec_send_packet(dec_ctx, pkt); while (ret >= 0) { AVFrame *frame = av_frame_alloc(); ret = avcodec_receive_frame(dec_ctx, frame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { av_frame_free(&frame); break; } // 处理 frame... av_frame_free(&frame); } } av_packet_unref(pkt); // 关键一步:每次用完后释放引用 } avcodec_send_packet(dec_ctx, NULL); // flush 解码器 av_packet_free(&pkt);

我在实际项目中发现一个很典型的错误:avcodec_send_packet 之后以为 packet 被解码器“拿走”了,后面就不去 unref 了。实际上,解码器内部会自己增加一个引用,调用者这边的引用必须自己负责清理。少了这行 av_packet_unref,每读一包就泄漏一批数据。文件又长又复杂的时候,内存增长几乎是肉眼可见的。

4. 踩坑实录:时间戳跳动、seek 后花屏、flush 丢尾帧、内存持续上涨

4.1 时间戳跳动:都是 time_base 没有换算

有一次做 FLV 转 MP4 的转封装,输入文件播放正常,输出文件放到播放器里却变成“开头正常,播几秒后倍速乱跳”。排查到最后,发现输出流的 time_base 已经被 muxer 调整了,但 packet 的时间戳还是按输入流的 time_base 在传。FLV 里 video 流时间基准是 1/1000,MP4 里可能是 1/12800 或者别的值,不换算就直接写,播放器的时钟换算必然错乱。

正确做法是每次写入前做一次时间基准换算:

av_packet_rescale_ts(pkt, in_stream->time_base, out_stream->time_base);

这个函数的三个参数分别是目标 packet、源时间基准、目标时间基准。它内部会同时换算 pts、dts 和 duration,省得你手动对每个字段单独乘除。还有一种常见需求是把时间戳换算成微秒:

int64_t usec = av_rescale_q(pkt->pts, in_stream->time_base, AV_TIME_BASE_Q);

这个得到的结果,才是 FFmpeg 内部统一时间基准下的微秒值,适合用来做跨流的比较或 UI 展示。

4.2 seek 后花屏:关键帧检查不到位

做视频播放器时,用户拖动进度条,程序先 av_seek_frame 定位,然后继续读 packet 送解码器。前面的画面经常是花屏或黑屏,要过一会儿才正常。

原因在于 seek 落点不一定正好是关键帧。解码器从一个非关键帧开始解,没有参考帧就无法还原出完整画面。解决思路有两种:

  • 在读取循环里检查pkt->flags & AV_PKT_FLAG_KEY,跳到最近的关键帧位置再给用户反馈。
  • 不管 seek 到哪儿,解码端都做一次 flush:先 avcodec_send_packet(dec_ctx, NULL) 把解码器里的残留帧排空,再继续正常喂包。

另外还需要注意,seek 是多路流同时动作的。只 seek 了视频流,音频流还在原来的时间位置,音画就会错位。实际项目里要根据目标时间点对每一路流做定位,并且核对 packet 的 pts 是否落在目标区间附近。

4.3 flush 空包:丢失最后几帧的隐藏原因

编码器因为 B 帧的原因,可能已经接收了最后 N 帧输入,但迟迟没有把对应的输出 packet 吐出来。如果你以为“编码器没输出就是结束了”,最后几帧会永远丢在编码器内部缓存里。

标准做法是发送一个空包触发 flush:

avcodec_send_packet(enc_ctx, NULL); while (avcodec_receive_packet(enc_ctx, enc_pkt) >= 0) { // 把最后一个编码包写出去 }

解码器也有同样的 flush 操作。之前我遇到“视频结尾黑了几帧,少了一段”的问题,就是在文件读完后忘了 flush 编码器。加上这段代码,问题立刻消失。

需要留意的是,flush 和解码器内部的 avcodec_flush_buffers 是两个动作。前者把缓存中的帧推出来,后者清空解码器的内部状态以便 seek 后重新开始。别混淆,也别在 flush 之后不清状态就继续读新位置的数据。

4.4 内存持续上涨:queue 里的 packet 没 unref

做多线程音视频处理时,我通常会把读到的 packet 按 stream_index 分发到不同的队列,交给后台线程处理。队列本身存的是 AVPacket 结构体,看起来一切正常,但内存却随着播放时长稳步上涨。

后来逐一排查,发现是消费者线程从队列里取出 packet 后只 memcpy 走了字段,没有在读完数据后调用 av_packet_unref。队列里每个 packet 持有的数据 buffer 从不归还,自然越积越多。正常做法是在入队时用 av_packet_move_ref 或 av_packet_ref,出队处理完成后立即 unref。

这里还想分享一个排查技巧:内存上涨问题往往不是一下子就能在代码里看出来的。可以先用谷歌手动方法主观猜,再用 AddressSanitizer 这类工具跑一遍长视频,报错信息通常会精确指向某个未释放的 buf 来自哪一行。比肉眼盯代码可靠得多。

5. 一个最小可跑的转封装循环与演进路径

5.1 只做转封装的最简循环

理解 AVPacket 最快的项目不是转码,而是转封装(remux)。转封装只操作 packet,不做编解码,省去了 AVFrame 的大量细节,非常适合当作“packet 生命周期练习场”。

一个最小转封装循环核心逻辑如下:

AVPacket *pkt = av_packet_alloc(); if (!pkt) return -1; int ret; while ((ret = av_read_frame(ifmt_ctx, pkt)) >= 0) { AVStream *in_stream = ifmt_ctx->streams[pkt->stream_index]; AVStream *out_stream = ofmt_ctx->streams[pkt->stream_index]; // 假设流顺序一一对应 av_packet_rescale_ts(pkt, in_stream->time_base, out_stream->time_base); pkt->stream_index = pkt->stream_index; ret = av_interleaved_write_frame(ofmt_ctx, pkt); if (ret < 0) { // 写文件失败处理 } av_packet_unref(pkt); } av_packet_free(&pkt);

有人会问,为什么用了 av_interleaved_write_frame 之后还要自己 unref?因为写函数内部可能异步保留 packet 引用,它处理的是自己的那一份引用,调用者手里这份还是要自己清理。这个习惯和解码循环里 send 之后 unref 是一回事。

5.2 什么时候需要从 packet 升级到 frame 级处理

转封装只动了 AVPacket,说明整个流程里没有改变音视频内容的需求。如果你开始做滤镜、缩放、裁剪、混音,就必须把 packet 解码成 AVFrame 再编码。这时候你会同时接触 decode 侧和 encode 侧的 packet,两个方向的时间基准处理要分开看。

一个小建议是:先把纯转封装跑通,并且确保整个循环零泄漏,再往里面加解码器。因为转封装流程简洁,更容易判断泄漏来自 packet 还是 frame。一旦出现内存问题,可以立刻关掉编码器,只保留 read + decode + receive 流程,把范围继续缩小。

我在实际项目中最受益的一步,就是彻底放弃旧示例里手动 malloc 的方式,所有 packet 统一走 av_packet_alloc、av_packet_unref、av_packet_free。那之后内存问题少了一大半,时间戳问题也更容易顺着 packet 的字段追根溯源。这篇文章要能帮你少踩一点 AVPacket 的坑,就算值了。

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

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

立即咨询