news 2026/10/15 1:35:46

FFmpeg解封装入门:从容器拆包到音视频流分离实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FFmpeg解封装入门:从容器拆包到音视频流分离实战

1. 为什么我建议从"解封装"入手音视频

很多人学音视频的第一反应是去啃编解码,抱着H.264白皮书或者AAC标准硬看,结果两周之后放弃。我自己也走过这条路,后来发现方向选错了。音视频这条链路其实可以拆成四层:协议层、封装层、编码层、渲染层。对初学者最友好的切入点不是编码,而是封装层里的"解封装"。

原因很直接。解封装做的事情本质上是"拆盒子"——把MP4、MKV、FLV、TS这些容器文件拆开,取出里面的音频流和视频流,顺便把时间戳、时长、码率这些元信息读出来。它不涉及复杂的数学变换,不需要你理解DCT、运动补偿、熵编码,只要搞清楚容器格式的组织方式和FFmpeg提供的API调用顺序,就能跑出一个能用的东西。成就感来得快,而且这套代码是后面做转码、推流、播放器的地基。

这篇笔记记录的是我用FFmpeg 4.1写的一个最简解封装器。说"最简"是因为它只做三件事:打开输入、循环读包、按流类型把包写到对应的输出文件。没有解码,没有滤镜,没有重采样。但就是这么个两百行不到的小工具,把FFmpeg解封装的核心API全部串了一遍:avformat_open_input、avformat_find_stream_info、av_read_frame、av_packet_*、av_interleaved_write_frame。把这几个函数吃透,后面再看avcodec_send_packet那套解码API会顺很多。

适合谁看?如果你已经会写C,能看懂指针和结构体,对音视频完全没概念,那这篇正好。如果你已经在用ffmpeg -i input.mp4 -c copy output.ts这种命令行,但不知道背后发生了什么,这篇也能帮你把黑盒打开。我下面会把每一步的"为什么"讲清楚,而不只是贴代码。

2. 解封装到底在拆什么:容器、流、包的三层关系

2.1 容器格式与编码格式是两码事

新手最容易混淆的一点,是把MP4当成一种"视频格式"。其实MP4只是容器,它规定了音视频数据怎么排列、索引放在哪、时间戳怎么记,但完全不关心里面装的是H.264还是H.265,是AAC还是MP3。同一个MP4文件,视频轨可以是H.264,也可以是AV1;音频轨可以是AAC,也可以是PCM。

我用一个生活化的类比:容器就像快递纸箱,编码格式是箱子里的商品。纸箱的规格(多大、几层瓦楞、封口方式)和商品是什么没有必然关系。解封装器干的就是"拆纸箱"的活,它把箱子打开,把商品一件件拿出来,同时读一下箱子外面的运单(元信息)。至于商品本身怎么用,那是解码器的事。

FFmpeg里对应这个概念的结构体是AVFormatContext,它代表一个容器上下文。打开文件之后,这个结构体里就装满了关于容器的所有信息:有几个流、每个流是什么类型、时长多少、比特率多少。

2.2 流(Stream)是容器里的独立轨道

一个容器里通常有至少一条流。视频文件一般是一条视频流加一条音频流,有些还带字幕流。每条流在FFmpeg里用AVStream表示,它挂在AVFormatContext的streams数组里。

AVStream里最关键的两个字段是codecpar和time_base。codecpar是AVCodecParameters类型,描述了这条流的编码参数——编码ID、宽高、采样率、声道数、像素格式等等。注意在FFmpeg 4.x里,编码参数从老的AVStream->codec迁移到了AVStream->codecpar,这是个重要的版本变化,很多老教程还在用codec,在新版本里会报废弃警告甚至编译不过。

time_base是这条流的时间基准,是个分数,比如1/90000表示一个时间单位是1/90000秒。所有这条流里的时间戳都是以这个基准为单位的整数。不同流的time_base可能不一样,视频流常见1/90000或1/1000,音频流常见1/44100或1/48000。这个细节在做音视频同步的时候是绕不开的坑,后面会细说。

2.3 包(Packet)是流里的数据切片

AVPacket是解封装器直接产出的东西。一个包通常对应一帧视频或者若干帧音频的压缩数据,里面装着实际要送去解码的字节。包上还带着时间戳:pts(显示时间戳)和dts(解码时间戳)。

为什么会有两个时间戳?因为视频编码里有B帧,B帧要参考后面的帧才能解码,所以解码顺序和显示顺序不一致。dts是解码顺序,pts是显示顺序。对于没有B帧的流,两者相等。音频一般没有这个问题,pts和dts基本一致。

理解这三层关系之后,解封装的流程就非常清晰了:打开容器拿到AVFormatContext,遍历里面的AVStream搞清楚有几条流,然后循环调用av_read_frame从容器里读出一个又一个AVPacket,根据包属于哪条流,决定把它送到哪里去。

3. 环境准备:FFmpeg 4.1的编译与链接细节

3.1 为什么特意选4.1这个版本

FFmpeg的API在3.x到4.x之间有一次比较大的调整,主要是AVStream->codec被废弃,改用codecpar。4.1是4.x系列里比较稳定的一个版本,API已经定型,网上资料也够多。如果你用更新的5.x或6.x,有些函数签名又变了,比如avcodec_parameters_to_context的用法、AVChannelLayout的引入等,对新手反而增加干扰。

我建议初学阶段就锁定4.1,把核心流程跑通,等理解了再迁移到新版本。版本差异本质上只是字段搬家,逻辑没变。

3.2 编译安装的实操命令

从源码编译是最稳的方式,因为发行版仓库里的FFmpeg版本和头文件经常对不上。下面是我在Linux下用的配置,关掉了一堆用不到的东西,编译快很多:

./configure --prefix=/usr/local/ffmpeg41 \ --disable-doc \ --disable-htmlpages \ --disable-manpages \ --disable-podpages \ --disable-txtpages \ --enable-shared \ --disable-static \ --disable-programs \ --disable-avdevice \ --disable-postproc \ --disable-swresample \ --disable-swscale

这里几个关键点解释一下。--disable-programs是不编译ffmpeg、ffplay这些命令行工具,我们只用库,省时间。--disable-avdevice关掉设备采集模块,解封装用不到。--disable-swresample和--disable-swscale分别关掉音频重采样和视频缩放,这两个是解码后处理才需要的,解封装阶段完全用不上。--enable-shared生成动态库,方便链接。

编译完记得把库路径加到系统里,否则运行时找不到.so:

export LD_LIBRARY_PATH=/usr/local/ffmpeg41/lib:$LD_LIBRARY_PATH

编译自己的程序时,头文件和库路径要指对:

gcc demuxer.c -o demuxer \ -I/usr/local/ffmpeg41/include \ -L/usr/local/ffmpeg41/lib \ -lavformat -lavcodec -lavutil

注意链接顺序,lavformat依赖lavcodec,lavcodec依赖lavutil,所以-lavformat要写在前面。这个顺序搞反了会报一堆undefined reference,我第一次踩这个坑查了半天。

提示:如果你在macOS上,用brew install ffmpeg@4装4.x版本,然后pkg-config --cflags --libs libavformat拿到编译参数,比手动指定路径省事。

4. 核心API的调用顺序与每一步的意图

4.1 打开输入:avformat_open_input

第一步是把文件打开,拿到AVFormatContext:

AVFormatContext *fmt_ctx = NULL; int ret = avformat_open_input(&fmt_ctx, input_path, NULL, NULL); if (ret < 0) { fprintf(stderr, "open input failed: %s\n", av_err2str(ret)); return -1; }

这里第三个参数是AVInputFormat,传NULL表示让FFmpeg自己探测格式。第四个参数是AVDictionary,用来传一些打开选项,比如超时、缓冲区大小,简单场景传NULL就行。

av_err2str是个宏,把错误码转成可读字符串,调试的时候非常有用。注意这个宏在某些编译器下因为返回临时字符串会有警告,如果介意可以自己写个函数用av_strerror。

这一步做完,fmt_ctx里已经有了基本的容器信息,但流的详细信息可能还不全,尤其是网络流或者某些特殊容器。

4.2 探测流信息:avformat_find_stream_info

ret = avformat_find_stream_info(fmt_ctx, NULL); if (ret < 0) { fprintf(stderr, "find stream info failed: %s\n", av_err2str(ret)); avformat_close_input(&fmt_ctx); return -1; }

这个函数的作用是"读一段数据,把流的参数补全"。为什么需要它?因为有些容器格式的头部信息不完整,比如某些MPEG-TS流,编码参数是分散在数据里的,必须实际读几个包才能确定。这个函数会预读一部分数据,填充AVStream->codecpar。

代价是它会消耗一些时间,对于大文件或者网络流,这一步可能明显变慢。如果确定容器头部信息完整(比如标准的MP4),可以跳过,但为了通用性,建议保留。

4.3 遍历流,找到音视频轨

int video_idx = -1, audio_idx = -1; for (unsigned i = 0; i < fmt_ctx->nb_streams; i++) { AVStream *st = fmt_ctx->streams[i]; if (st->codecpar->codec_type == AVMEDIA_TYPE_VIDEO && video_idx < 0) video_idx = i; else if (st->codecpar->codec_type == AVMEDIA_TYPE_AUDIO && audio_idx < 0) audio_idx = i; }

这里用codec_type判断流的类型。注意我只取第一条视频流和第一条音频流,因为一个文件可能有多条(比如多语言音轨、多视角视频)。实际项目里可能需要根据语言、码率等条件筛选,但最简版本取第一条就够了。

codecpar里还能读到很多有用信息,比如视频的width、height、format,音频的sample_rate、channels。这些在打印文件信息的时候很有用。

4.4 循环读包:av_read_frame

这是整个解封装的核心循环:

AVPacket *pkt = av_packet_alloc(); while (av_read_frame(fmt_ctx, pkt) >= 0) { if (pkt->stream_index == video_idx) { // 处理视频包 } else if (pkt->stream_index == audio_idx) { // 处理音频包 } av_packet_unref(pkt); }

av_read_frame每次从容器里读出一个包,返回0或正数表示成功,返回AVERROR_EOF表示读到文件尾,其他负值表示出错。读出来的包放在pkt里,pkt->stream_index告诉你这个包属于哪条流。

关键点:每次循环结束必须调用av_packet_unref。这个函数会释放包内部引用的数据缓冲区,把包重置成初始状态,供下一次读取复用。如果不调用,内存会持续增长,读大文件的时候很快就爆了。这是新手最常见的错误之一。

av_packet_alloc和av_packet_free配对使用,前者分配包结构体,后者释放。注意av_packet_unref只释放包内部的数据,不释放包结构体本身,两者职责不同。

4.5 写出:av_interleaved_write_frame

如果只是分析,读到包打印一下信息就够了。但如果要把音视频分离成独立文件,就需要写出去。这里有两种做法:一是用fwrite直接写裸数据,二是用FFmpeg的AVFormatContext输出上下文,走av_interleaved_write_frame。

后者更规范,因为它会帮你处理容器封装、时间戳转换、交织写入。流程是:先avformat_alloc_output_context2创建输出上下文,再为每条要输出的流avformat_new_stream创建流,把输入流的codecpar用avcodec_parameters_copy拷过去,然后avformat_write_header写头,循环里av_interleaved_write_frame写包,最后av_write_trailer写尾。

AVFormatContext *out_ctx = NULL; avformat_alloc_output_context2(&out_ctx, NULL, NULL, output_path); // ... 创建流、拷贝参数 ... avformat_write_header(out_ctx, NULL); // 循环里: av_interleaved_write_frame(out_ctx, pkt); // 结束后: av_write_trailer(out_ctx);

av_interleaved_write_frame和av_write_frame的区别在于前者会缓冲包,按时间戳顺序交织写入,保证输出文件的音视频交错正确。对于分离成单独文件(一个纯视频文件、一个纯音频文件)的场景,其实用av_write_frame也行,但用interleaved更保险。

5. 时间戳换算:那个绕不过去的time_base

5.1 为什么时间戳不能直接用

前面提到每条流有自己的time_base。假设视频流的time_base是1/90000,某个包的pts是90000,那它代表的实际时间是90000 * (1/90000) = 1秒。音频流的time_base可能是1/48000,pts是48000,实际时间也是1秒。两个包的pts数值差了将近一倍,但实际时间一样。

如果你直接把两个流的pts拿来比较或者相减,得到的数字毫无意义。必须先把它们换算到同一个时间基准上,通常换算成秒或者毫秒。

5.2 换算公式与代码

换算成秒的公式是:

时间(秒) = pts * av_q2d(stream->time_base)

av_q2d把AVRational(分子分母结构)转成double。反过来,从秒换算成某个流的pts:

pts = 时间(秒) / av_q2d(stream->time_base)

在写输出文件的时候,如果输入流和输出流的time_base不一致,必须做转换。FFmpeg提供了av_packet_rescale_ts一步到位:

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

这个函数会同时处理pts、dts和duration。忘了调用它,输出文件的时间戳就会错乱,播放器要么快进要么卡顿。

5.3 一个实际踩过的坑

我曾经写过一个把MP4转成TS的小工具,输出文件播放时音视频不同步,视频比音频快了大概半秒。查了很久才发现,我在创建输出流的时候,把输出流的time_base设成了1/1000,但写包的时候忘了av_packet_rescale_ts,导致时间戳被当成1/90000基准解释,实际时间被放大了90倍。

这个坑的教训是:只要输入输出流的time_base不同,就必须rescale。最稳妥的做法是让输出流的time_base和输入流保持一致,这样rescale是恒等变换,不容易出错。但有些容器格式对time_base有要求,比如MP4通常用1/1000或1/90000,TS用1/90000,这时候就必须老老实实转换。

6. 实测中的几个意外情况与处理

6.1 读到的包stream_index越界

正常情况下pkt->stream_index应该在0到nb_streams-1之间。但我在处理某些损坏文件时遇到过越界的情况,直接用它去索引streams数组会段错误。防御性写法是先判断范围:

if (pkt->stream_index < 0 || pkt->stream_index >= (int)fmt_ctx->nb_streams) { av_packet_unref(pkt); continue; }

这种文件通常是下载不完整或者传输过程中损坏的,FFmpeg能打开但读包时会给出异常数据。生产环境里必须做这个检查。

6.2 某些流的codecpar为空

avformat_find_stream_info之后,绝大多数流的codecpar都会被填充。但我遇到过一些特殊容器,某些数据流(比如时间码轨、章节信息轨)的codecpar是空的,codec_type是AVMEDIA_TYPE_DATA。遍历流的时候如果直接访问codecpar->width,会空指针崩溃。

处理方式是先判断codecpar非空,再判断类型:

if (st->codecpar && st->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { // 安全访问 }

6.3 大文件读取的内存控制

av_read_frame读出的包大小不一,视频关键帧可能几百KB,音频包可能几KB。如果每读一个包就malloc一块内存存起来,读一个几GB的文件内存会爆。正确做法是读完就处理、处理完就av_packet_unref,不要累积。

如果确实需要缓存(比如做seek),要用环形缓冲区或者限制缓存包的数量。我一般会设一个上限,比如缓存最多1000个包,超过就丢弃最老的。这个策略在实时处理场景里很常用。

6.4 网络流的超时设置

解封装本地文件和网络流有个重要区别:网络流可能卡住。av_read_frame在等数据的时候会阻塞,如果没有超时设置,程序可能永远挂在那里。解决办法是通过AVDictionary设置超时:

AVDictionary *opts = NULL; av_dict_set(&opts, "timeout", "5000000", 0); // 5秒,单位微秒 avformat_open_input(&fmt_ctx, url, NULL, &opts); av_dict_free(&opts);

注意不同协议的选项名可能不同,timeout对TCP有效,rw_timeout是更通用的选项。具体用哪个要查对应协议的文档。这个细节在写健壮的解封装器时很重要,但很多教程都不提。

7. 从解封装到下一步:这套代码能延伸出什么

把解封装跑通之后,你会发现它是个很好的脚手架。往解码方向走,就是在读包之后加一步avcodec_send_packet把包送进解码器,再avcodec_receive_frame拿解码后的帧。往转码方向走,就是在解码后加滤镜和重采样,再编码回去。往推流方向走,就是把读出的包直接av_interleaved_write_frame到一个网络输出上下文。

我自己的习惯是把这个最简解封装器保存成一个模板工程,每次开新项目就从它复制一份,改改输出逻辑就行。因为打开、探测、读包、清理这套流程是固定的,没必要每次重写。

还有个小技巧:调试的时候在循环里加一行打印,把每个包的pts、dts、size、stream_index打出来,能快速看出流的规律。比如视频包通常是音频包的几倍大,关键帧的size明显大于普通帧,音频包的pts间隔很均匀。这些观察对理解容器结构帮助很大。

最后说个我自己的体会。音视频学习最容易犯的错是好高骛远,一上来就想做播放器、做直播。其实把解封装这一层吃透,你就已经掌握了整条链路里最基础也最通用的部分。剩下的解码、渲染、同步,都是在解封装产出的包和帧的基础上做文章。这个最简解封装器虽然只有两百行,但它把FFmpeg最核心的几个API串起来了,值得反复写几遍,写到不用查文档就能默写出来的程度。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 1:35:44

Hexo静态博客搭建与GitHub Pages部署实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:35:15

基于51单片机的GSM智能快递柜设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:35:12

FT245BL USB FIFO驱动安装与高速通信实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:35:10

Verilog Task与Function全解析:语法差异、系统任务与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:35:05

深度学习主机配置指南:从负载画像到硬件选型与避坑实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:34:34

Java ArrayList详解:动态数组原理、扩容机制与学生管理系统实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华