3、核心数据结构(上):AVFormatContext、AVStream、AVPacket 的作用与关系

做推流开发,说白了就是跟几个核心结构体打交道。我刚开始接触 FFmpeg 时,看着那几十个结构体一头雾水,后来才发现真正绕不开的其实就三四个。今天咱们先把最关键的三个讲透——AVFormatContext、AVStream 和 AVPacket。

你想想看,推流本质上干的事是什么?从文件或设备读数据,拆成包,再按某种协议发出去。这三个结构体正好对应了「容器管理」「流描述」「数据载体」三个角色。搞懂它们,推流的骨架就有了。

3.1 AVFormatContext:一切容器的总管家

AVFormatContext 是整个 FFmpeg 里最「大」的结构体。它代表一个多媒体文件或流的容器上下文。无论是读文件(解封装)还是写文件(封装),你都得先创建它。

核心职责:管理输入/输出的全局信息,包括封装格式、所有流、元数据、I/O 上下文等。

我个人习惯把 AVFormatContext 想象成一个「集装箱调度中心」。它不直接装数据,但它知道集装箱(流)有几个、每个箱子装什么类型的东西、以及整个运输流程怎么走。

看一下它的关键字段:

字段名 类型 作用
iformat AVInputFormat* 输入封装格式(读文件时用)
oformat AVOutputFormat* 输出封装格式(写文件/推流时用)
nb_streams unsigned int 流的总数量
streams AVStream** 指向所有流的指针数组
pb AVIOContext* I/O 上下文(读写数据的底层接口)
duration int64_t 总时长(微秒单位)
bit_rate int64_t 总码率
metadata AVDictionary* 元数据(标题、作者等)

创建和销毁的典型代码:

// 分配上下文
AVFormatContext *fmt_ctx = avformat_alloc_context();

// 打开输入文件(内部会填充格式信息)
if (avformat_open_input(&fmt_ctx, "input.mp4", NULL, NULL) < 0) {
    // 错误处理
}

// ... 使用 ...

// 关闭并释放
avformat_close_input(&fmt_ctx);

注意:avformat_alloc_context 和 avformat_open_input 的关系容易搞混。前者只分配空壳,后者才会真正探测格式、填充字段。我曾经在项目里只调了 alloc 就以为能读流信息,结果拿到的 nb_streams 永远是 0,排查了半天才发现忘了 open_input。

3.2 AVStream:流的身份证

AVStream 描述的是容器里的一条流。一条流可以是一路视频、一路音频,或者字幕、数据等。每个 AVFormatContext 里都有一个 AVStream 数组,索引从 0 开始。

嗯,这里要注意:AVStream 本身不存原始音视频数据,它存的是描述信息——编码参数、时间基、帧率、元数据等等。真正装数据的是后面要讲的 AVPacket。

关键字段一览:

字段名 类型 作用
index int 流在容器中的索引号
codecpar AVCodecParameters* 编码参数(编码器类型、分辨率、采样率等)
time_base AVRational 时间基(用于时间戳换算)
start_time int64_t 流的起始时间戳
duration int64_t 流的时长
nb_frames int64_t 总帧数(不一定准确)
avg_frame_rate AVRational 平均帧率
r_frame_rate AVRational 实时帧率

获取流信息的典型做法:

// 遍历所有流
for (unsigned int i = 0; i < fmt_ctx->nb_streams; i++) {
    AVStream *stream = fmt_ctx->streams[i];
    
    // 通过 codecpar 判断流类型
    if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
        printf("视频流 #%d: %dx%d\n", i,
               stream->codecpar->width,
               stream->codecpar->height);
    } else if (stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
        printf("音频流 #%d: %d Hz, %d 通道\n", i,
               stream->codecpar->sample_rate,
               stream->codecpar->ch_layout.nb_channels);
    }
}

经验之谈:time_base 是 AVStream 里最容易踩坑的字段。推流时时间戳换算全靠它。我建议你拿到流之后第一时间打印 time_base 的值,确认它是多少。常见的是 1/90000(TS 流)或 1/1000(部分封装格式)。

3.3 AVPacket:数据搬运工

AVPacket 是 FFmpeg 里最「忙」的结构体。它承载的是编码后的数据包——也就是压缩过的视频帧或音频帧。解封装时从流里读出来的是 AVPacket,封装时写进去的也是 AVPacket。

说白了,AVPacket 就是数据在容器和编解码器之间流转的载体。

核心字段:

字段名 类型 作用
data uint8_t* 压缩数据缓冲区
size int 数据大小(字节)
pts int64_t 显示时间戳(Presentation Timestamp)
dts int64_t 解码时间戳(Decoding Timestamp)
stream_index int 所属流的索引
duration int64_t 该包的时长(以流的时间基为单位)
flags int 标志位(如关键帧标记 AV_PKT_FLAG_KEY)

读写 AVPacket 的标准流程:

// 分配一个 packet
AVPacket *pkt = av_packet_alloc();

// 从输入流读取一个包
while (av_read_frame(fmt_ctx, pkt) >= 0) {
    // pkt->stream_index 告诉我们这个包属于哪条流
    AVStream *stream = fmt_ctx->streams[pkt->stream_index];
    
    // 处理包(比如推流或解码)
    process_packet(pkt);
    
    // 必须解引用,否则内存泄漏
    av_packet_unref(pkt);
}

av_packet_free(&pkt);

避坑指南:av_packet_unref 和 av_packet_free 的区别一定要搞清楚。unref 只是释放内部数据缓冲区,packet 对象本身还能复用;free 则是连对象一起销毁。我曾经在循环里用了 free 而不是 unref,结果第二次循环时 packet 已经没了,程序直接崩溃。记住:循环读包用 unref,彻底不用了才用 free。

3.4 三者的关系:一张图说清楚

这三个结构体是怎么配合的?我画了一张图帮你理解:

AVFormatContext、AVStream、AVPacket 关系图 AVFormatContext(容器上下文) nb_streams = 2 | duration | bit_rate | metadata AVStream[0](视频流) index = 0 codecpar: H.264, 1920x1080 time_base: 1/90000 AVStream[1](音频流) index = 1 codecpar: AAC, 44100Hz time_base: 1/44100 AVPacket(数据包) pkt[0] stream_index=0 pkt[1] stream_index=1 pkt[2] stream_index=0 pkt[3] stream_index=1 AVPacket 通过 stream_index 关联到对应的 AVStream,AVStream 通过 streams[] 数组归属于 AVFormatContext

从图上可以清楚看到:

  • AVFormatContext 是顶层容器,持有所有 AVStream 的指针数组
  • AVStream 描述每条流的编码参数和时间信息,不存数据
  • AVPacket 是实际的数据载体,通过 stream_index 指向它所属的流

3.5 推流场景下的协作流程

假设我们要把一个 MP4 文件推成 RTMP 流,这三个结构体的协作顺序大致是这样的:

  1. 创建输入 AVFormatContext:avformat_open_input 打开 MP4 文件,自动探测格式并填充 streams 数组
  2. 创建输出 AVFormatContext:avformat_alloc_output_context2 分配输出上下文,指定 RTMP 格式
  3. 复制流信息:遍历输入的所有 AVStream,用 avformat_new_stream 在输出上下文中创建对应的流,并复制 codecpar 等参数
  4. 循环读取 AVPacket:av_read_frame 从输入上下文读包,每个包携带 stream_index
  5. 时间基转换:用 av_rescale_q_rnd 把包的 pts/dts 从输入流的时间基转换到输出流的时间基
  6. 写入输出:av_interleaved_write_frame 把包写入输出上下文,底层通过 RTMP 协议发出去

个人经验:第 5 步时间基转换是推流时最容易出问题的地方。我见过不少新手直接拿原始 pts 往输出写,结果播放器端要么花屏要么音画不同步。记住一个原则:包的 pts/dts 必须用目标流的时间基重新计算。

3.6 小结

这三个结构体是 FFmpeg 推流开发的基石。AVFormatContext 管全局,AVStream 管描述,AVPacket 管数据。搞懂它们各自管什么、怎么配合,后面学 muxer、demuxer、协议封装都会轻松很多。