音视频开发这个方向很多人卡在第一步就不是因为算法难而是因为“环境搭不起来、API 记不住、数据流看不懂”。我当初从纯业务后端转过来的时候第一个能跑通且真正理解的小项目就是一个极简的解封装器。它不涉及编解码不涉及渲染只做一件事把容器里的音视频流拆出来拿到原始的压缩数据包。别看它简单这一步是后面做播放器、转码、推流、剪辑的地基。这篇文章就把我基于 FFmpeg 4.1 实现这个解封装器的完整思路、踩过的坑和可复现的代码拆开讲清楚适合刚接触音视频、想找一个能跑通的最小闭环的开发者也适合已经会调 API 但没搞明白数据流向的人。1. 解封装到底在做什么为什么值得单独拿出来做1.1 从“容器”和“流”这两个概念说起很多人一上来就混淆了“文件格式”和“编码格式”。一个 mp4 文件它本身只是一个盒子也就是容器Container里面装着视频流、音频流可能还有字幕流。而每条流里面的数据才是真正被压缩过的音视频数据比如 H.264 编码的视频帧、AAC 编码的音频帧。解封装Demuxing做的事情就是把这个盒子打开把里面一条条流的数据包Packet按顺序取出来。你可以把它想象成拆快递容器是纸箱流是箱子里的不同物品Packet 就是一件件拿出来的东西。至于这些东西怎么用、要不要拆开包装解码那是下一步的事。解封装器只负责“拆箱”和“取件”不负责“使用”。为什么值得单独做因为在实际工程里解封装是最先接触数据的地方。你要做播放器得先解封装拿到 Packet 再送去解码你要做转码得先解封装再重新编码你要做剪辑也得先解封装定位关键帧。把这一步吃透后面所有环节的数据流向都会变得清晰。1.2 为什么选 FFmpeg 4.1 这个版本FFmpeg 的版本迭代很快4.1 属于比较经典的一个稳定版本API 相对成熟网上资料也多。更重要的是4.1 里avformat这一层的接口已经非常稳定av_read_frame、avformat_open_input这些核心函数在后续版本里基本没变。用它来学习不会因为版本差异导致代码跑不起来。另一个现实原因是很多嵌入式设备和老项目仍然停留在 4.x 系列你如果直接上手 6.x 或 7.x反而可能遇到一些 API 废弃和迁移的坑。先用 4.1 把基础逻辑跑通后面升级只是查一下变更日志的事。提示如果你本地已经装了更高版本的 FFmpeg也不用特意降级。本文用到的核心 API 在 4.x 到 6.x 之间基本兼容只有个别函数签名有细微差别我会在涉及的地方标注出来。1.3 这个解封装器最终能输出什么做完之后你的程序能做到输入一个 mp4 或 flv 文件打印出它有几条流、每条流的编码格式、分辨率、采样率等信息然后循环读取每一个 Packet打印出它属于哪条流、大小是多少、是不是关键帧。如果你愿意还可以把 Packet 直接写进文件得到裸的 H.264 或 AAC 数据。这个输出看起来很简单但它就是后面所有音视频处理的原型。你把这个循环里的printf换成解码器就是播放器的雏形换成编码器就是转码器的雏形。2. 环境准备与工程骨架搭建2.1 FFmpeg 开发环境的安装要点在 Linux 上最省事的方式是通过包管理器安装开发库。以常见的发行版为例你需要的是libavformat-dev、libavcodec-dev、libavutil-dev这几个包。安装完之后头文件通常在/usr/include/x86_64-linux-gnu/或/usr/local/include/下库文件在/usr/lib/x86_64-linux-gnu/或/usr/local/lib/。在 Windows 上建议直接下载官方提供的 dev 和 shared 包解压后把include和lib目录配置到你的 IDE 里。注意要同时下载对应版本的 DLL否则运行时会报找不到库。macOS 上用 Homebrew 安装ffmpeg即可头文件和库会自动放到标准路径下。安装完成后用一个小命令验证一下pkg-config --modversion libavformat如果输出了版本号比如58.29.100说明开发环境就绪。这个版本号对应 FFmpeg 4.1 的 libavformat 版本不同小版本可能略有差异不影响使用。2.2 编译命令与链接顺序的坑编译的时候链接顺序非常关键。FFmpeg 的库之间有依赖关系libavformat依赖libavcodeclibavcodec依赖libavutil。所以链接顺序必须是gcc demuxer.c -o demuxer -lavformat -lavcodec -lavutil如果你把-lavutil写在最前面链接器会报一堆 undefined reference。这是新手最容易踩的坑之一。原因是链接器从左到右解析遇到libavformat里的符号时如果后面的库还没被扫描到就找不到定义。注意有些系统还需要额外链接-lz和-lm因为 FFmpeg 内部用到了压缩和数学库。如果报错提示缺少inflate或log之类的符号把这两个加上即可。2.3 最小工程的文件结构我习惯把代码拆成三个部分一个头文件放公共声明一个源文件放主逻辑一个 Makefile 管理编译。但对于这个最小解封装器其实一个.c文件就够了。结构清晰比文件数量重要。整个程序的骨架大概是#include libavformat/avformat.h #include libavutil/avutil.h int main(int argc, char *argv[]) { // 1. 打开输入 // 2. 获取流信息 // 3. 打印流信息 // 4. 循环读取 Packet // 5. 清理资源 return 0; }别小看这五步每一步都有细节。下面我逐个拆开讲。3. 核心 API 逐个拆解与实操要点3.1 avformat_open_input打开输入的第一道门这个函数的作用是打开输入文件并初始化AVFormatContext。它的签名在 4.1 里是这样的int avformat_open_input(AVFormatContext **ps, const char *url, AVInputFormat *fmt, AVDictionary **options);第一个参数是二级指针因为函数内部会为你分配一个AVFormatContext。第二个参数是文件路径或 URL。第三个参数通常传NULL让 FFmpeg 自动探测格式。第四个参数是选项字典一般也用不上。调用方式AVFormatContext *fmt_ctx NULL; int ret avformat_open_input(fmt_ctx, argv[1], NULL, NULL); if (ret 0) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, 打开输入失败: %s\n, errbuf); return -1; }这里有个细节av_strerror能把错误码转成可读字符串比直接打印数字强太多。我见过很多人调试时只打印ret结果看到-2完全不知道什么意思。养成用av_strerror的习惯能省一半排查时间。注意avformat_open_input失败时fmt_ctx可能已经被分配了一部分内存。虽然 FFmpeg 内部会尽量清理但保险起见失败后不要再手动 free直接返回即可。3.2 avformat_find_stream_info补全流的元信息打开输入之后很多流的参数还没填全比如视频的宽高、音频的采样率。avformat_find_stream_info会预读一部分数据把这些信息探测出来。ret avformat_find_stream_info(fmt_ctx, NULL); if (ret 0) { fprintf(stderr, 获取流信息失败\n); return -1; }这个函数会消耗一定时间因为它要读一段数据来分析。对于网络流这个时间可能比较明显。如果你只关心流的基本信息可以跳过它但那样拿到的宽高可能是 0。所以除非有特殊性能要求一般都要调用。调用之后fmt_ctx-nb_streams就是流的数量fmt_ctx-streams[i]是每一条流。3.3 遍历流并打印参数理解 AVStream 和 AVCodecParameters每一条流用一个AVStream结构体表示。在 4.1 里编码参数放在AVStream-codecpar里这是一个AVCodecParameters指针。注意老版本里用的是AVStream-codec那是个AVCodecContext在 4.x 里已经废弃了。如果你看到老教程用codec要改成codecpar。打印视频流信息的代码for (unsigned int i 0; i fmt_ctx-nb_streams; i) { AVStream *stream fmt_ctx-streams[i]; AVCodecParameters *par stream-codecpar; if (par-codec_type AVMEDIA_TYPE_VIDEO) { printf(视频流 #%u: 编码%s, 分辨率%dx%d\n, i, avcodec_get_name(par-codec_id), par-width, par-height); } else if (par-codec_type AVMEDIA_TYPE_AUDIO) { printf(音频流 #%u: 编码%s, 采样率%d, 声道数%d\n, i, avcodec_get_name(par-codec_id), par-sample_rate, par-channels); } }avcodec_get_name能把AVCodecID转成字符串比如h264、aac。这个函数在libavcodec里别忘了包含头文件。提示par-channels在更新的 FFmpeg 版本里被标记为废弃推荐用par-ch_layout.nb_channels。但在 4.1 里channels还是标准用法直接用没问题。3.4 av_read_frame循环读取 Packet 的核心这是整个解封装器的心脏。每次调用av_read_frame它会从容器里取出一个 Packet填充到AVPacket结构体里。AVPacket *pkt av_packet_alloc(); while (av_read_frame(fmt_ctx, pkt) 0) { printf(Packet: 流索引%d, 大小%d, 关键帧%d\n, pkt-stream_index, pkt-size, (pkt-flags AV_PKT_FLAG_KEY) ? 1 : 0); av_packet_unref(pkt); }这里有几个关键点第一av_packet_alloc分配的是一个空的 Packet每次av_read_frame会往里填数据。读完一个 Packet 后必须调用av_packet_unref释放它内部的数据缓冲区否则内存会一直涨。注意是unref不是freefree会把 Packet 结构体本身也释放掉下次循环就没法用了。第二av_read_frame返回 0 表示成功返回负值表示出错或到达文件末尾。到达末尾时返回的是AVERROR_EOF所以循环条件写成 0是安全的。第三pkt-stream_index告诉你这个 Packet 属于哪条流。你后面要解码视频就判断它是不是视频流的索引。第四pkt-flags AV_PKT_FLAG_KEY判断是不是关键帧。关键帧对做 seek 和剪辑非常重要解封装阶段就能拿到这个信息。3.5 资源清理别让内存泄漏毁掉你的程序程序结束前必须按顺序清理av_packet_free(pkt); avformat_close_input(fmt_ctx);avformat_close_input会关闭输入并释放AVFormatContext。注意传的是fmt_ctx因为它内部会把指针置空。如果你先手动avformat_free_context再关闭可能会双重释放。注意av_packet_free和av_packet_unref的区别要记牢。unref只释放 Packet 内部的数据Packet 结构体还能复用free释放整个 Packet包括结构体本身。循环里用unref最后用free。4. 完整代码实现与逐行注释4.1 可直接编译运行的完整源码把上面的片段拼起来就是一个完整的解封装器#include stdio.h #include libavformat/avformat.h #include libavcodec/avcodec.h #include libavutil/avutil.h #include libavutil/error.h int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s 输入文件\n, argv[0]); return -1; } AVFormatContext *fmt_ctx NULL; int ret; // 打开输入 ret avformat_open_input(fmt_ctx, argv[1], NULL, NULL); if (ret 0) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, 打开输入失败: %s\n, errbuf); return -1; } // 获取流信息 ret avformat_find_stream_info(fmt_ctx, NULL); if (ret 0) { fprintf(stderr, 获取流信息失败\n); avformat_close_input(fmt_ctx); return -1; } // 打印容器信息 printf(容器格式: %s\n, fmt_ctx-iformat-name); printf(流数量: %u\n, fmt_ctx-nb_streams); printf(时长: %.2f 秒\n, fmt_ctx-duration / (double)AV_TIME_BASE); // 遍历流 for (unsigned int i 0; i fmt_ctx-nb_streams; i) { AVStream *stream fmt_ctx-streams[i]; AVCodecParameters *par stream-codecpar; if (par-codec_type AVMEDIA_TYPE_VIDEO) { printf(视频流 #%u: 编码%s, 分辨率%dx%d\n, i, avcodec_get_name(par-codec_id), par-width, par-height); } else if (par-codec_type AVMEDIA_TYPE_AUDIO) { printf(音频流 #%u: 编码%s, 采样率%d, 声道数%d\n, i, avcodec_get_name(par-codec_id), par-sample_rate, par-channels); } } // 循环读取 Packet AVPacket *pkt av_packet_alloc(); int video_count 0, audio_count 0; while (av_read_frame(fmt_ctx, pkt) 0) { if (pkt-stream_index (int)fmt_ctx-nb_streams) { AVMediaType type fmt_ctx-streams[pkt-stream_index]-codecpar-codec_type; if (type AVMEDIA_TYPE_VIDEO) video_count; else if (type AVMEDIA_TYPE_AUDIO) audio_count; } av_packet_unref(pkt); } printf(读取完成: 视频 Packet %d 个, 音频 Packet %d 个\n, video_count, audio_count); // 清理 av_packet_free(pkt); avformat_close_input(fmt_ctx); return 0; }4.2 编译与运行的实际记录保存为demuxer.c用下面的命令编译gcc demuxer.c -o demuxer -lavformat -lavcodec -lavutil找一个测试文件比如自己用手机录一段视频或者用 FFmpeg 命令行生成一个ffmpeg -f lavfi -i testsrcduration5:size640x480:rate30 \ -f lavfi -i sinefrequency1000:duration5 \ -c:v libx264 -c:a aac test.mp4这条命令会生成一个 5 秒的测试视频包含视频流和音频流。然后运行./demuxer test.mp4输出大概是这样容器格式: mov,mp4,m4a,3gp,3g2,mj2 流数量: 2 时长: 5.00 秒 视频流 #0: 编码h264, 分辨率640x480 音频流 #1: 编码aac, 采样率44100, 声道数1 读取完成: 视频 Packet 150 个, 音频 Packet 216 个视频 Packet 150 个因为 30 帧每秒乘以 5 秒就是 150 帧。音频 Packet 216 个这个数字和 AAC 的帧长有关每个 AAC 帧通常包含 1024 个采样点44100 除以 1024 约等于 43 帧每秒乘以 5 秒约等于 215和 216 很接近。看到这个数字对得上说明解封装逻辑是正确的。4.3 把 Packet 写成裸流文件如果你想验证拿到的数据确实能用可以把视频 Packet 写进一个.h264文件FILE *fp fopen(output.h264, wb); // 在循环里 if (type AVMEDIA_TYPE_VIDEO) { fwrite(pkt-data, 1, pkt-size, fp); } // 循环结束后 fclose(fp);然后用播放器打开这个.h264文件如果能播放说明你拿到的就是标准的 H.264 裸流。这一步能给你很强的信心因为它证明你确实把数据从容器里完整地拆出来了。注意这样写出来的裸流没有起始码的分隔保证某些播放器可能识别不了。更严谨的做法是每个 Packet 前手动加00 00 00 01起始码但那是封装格式的细节不影响解封装本身的正确性。5. 常见问题与排查技巧实录5.1 打开文件失败先看错误码再看路径最常见的错误是avformat_open_input返回负值。这时候第一件事是用av_strerror看具体原因。常见的有错误码含义排查方向-2文件不存在检查路径拼写、相对路径基准-13权限不足检查文件读权限-1094995529格式不支持检查文件是否损坏、扩展名是否误导-5I/O 错误检查磁盘、网络流地址是否可达我遇到过一次路径里有个中文空格shell 传参时被截断了程序一直报文件不存在。后来用引号包起来就好了。这种问题不看错误码根本想不到。5.2 流信息里宽高为 0别忘了 find_stream_info如果你跳过了avformat_find_stream_info打印出来的宽高和采样率很可能是 0。因为avformat_open_input只读了文件头很多参数还没解析。加上这个调用就好了。但要注意对于某些特殊容器即使调用了find_stream_info某些字段仍然可能是 0。这时候可以尝试读取几个 Packet 后再打印或者直接看codecpar-extradata里的 SPS/PPS 信息。5.3 内存持续增长unref 和 free 用错了这是新手最常犯的错误。在循环里用av_packet_free而不是av_packet_unref会导致每次循环都释放 Packet 结构体下一次av_read_frame写入时就是野指针程序直接崩溃。反过来循环结束后忘记av_packet_free会泄漏一个 Packet 结构体虽然量不大但习惯不好。正确的模式是循环内unref循环外free。记住这个口诀就不会错。5.4 读取到一半返回错误区分 EOF 和其他错误av_read_frame返回负值时不一定是文件结束。可能是网络中断、磁盘读取错误等。严谨的做法是判断返回值while (1) { ret av_read_frame(fmt_ctx, pkt); if (ret AVERROR_EOF) { printf(正常到达文件末尾\n); break; } else if (ret 0) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, 读取错误: %s\n, errbuf); break; } // 处理 Packet av_packet_unref(pkt); }这样能把正常结束和异常错误区分开排查问题时心里有数。5.5 时间戳混乱理解 PTS 和 DTSPacket 里的pts和dts是时间戳单位是AVStream-time_base。很多人直接拿这两个值当秒数用结果完全对不上。正确的换算方式是double pts_seconds pkt-pts * av_q2d(stream-time_base);av_q2d把AVRational转成 double。视频流和音频流的time_base可能不同所以必须用各自流的time_base来换算。这个细节在做音视频同步时至关重要解封装阶段先把时间戳打印出来看看能帮你建立直观感受。提示有些 Packet 的pts是AV_NOPTS_VALUE表示没有时间戳。这时候不能直接换算要先判断。6. 从解封装延伸出去的三条实用路线6.1 路线一接上解码器做播放器解封装拿到 Packet 后下一步就是送进解码器。你需要为每条流创建一个AVCodecContext用avcodec_parameters_to_context把codecpar的参数复制进去然后avcodec_open2打开解码器。之后每个 Packet 送avcodec_send_packet再avcodec_receive_frame拿解码后的帧。这条路线是播放器的核心。你可以在解封装器的基础上加一个简单的 SDL 窗口把解码后的 YUV 数据渲染出来。虽然离完整播放器还有距离但数据流已经通了。6.2 路线二接上编码器做转码如果你想做格式转换比如把 H.264 转成 H.265流程是解封装拿到 Packet解码成帧再送进 H.265 编码器最后封装成新容器。解封装器是这条流水线的起点。转码比播放多了一个封装环节你需要用avformat_alloc_output_context2创建输出上下文用avformat_write_header写头每个编码后的 Packet 用av_interleaved_write_frame写入。时间戳的重新计算是难点但解封装阶段拿到的原始时间戳是基础。6.3 路线三做流媒体分析工具解封装器稍加改造就能变成一个流分析工具。比如统计每个 Packet 的大小分布、关键帧间隔、码率波动。这些数据对排查卡顿、优化编码参数很有用。我做过一个简单的分析脚本把每个视频 Packet 的大小打印出来画成折线图一眼就能看出码率是否平稳。关键帧间隔也能从 Packet 的 flags 里统计出来如果间隔忽大忽小说明编码器的 GOP 设置可能有问题。6.4 三条路线的共同基础不管走哪条路线解封装都是第一步。把这个最小闭环跑通你对AVFormatContext、AVStream、AVPacket这三个核心结构体的理解就会从“知道名字”变成“知道它们怎么协作”。这种理解是看多少篇文档都换不来的。我在实际带新人的时候都会让他们先把这个解封装器手敲一遍不许复制粘贴。敲的过程中会遇到各种编译错误、链接错误、运行时错误每解决一个对 FFmpeg 的熟悉度就增加一分。等这个跑通了再去看播放器或转码的代码就不会觉得无从下手了。最后分享一个小技巧调试的时候把av_read_frame返回的每个 Packet 的stream_index、size、pts、dts、flags都打印出来重定向到一个日志文件。然后用文本工具搜索关键帧的位置你就能直观地看到关键帧的分布规律。这个习惯帮我快速定位过好几次时间戳异常的问题。
阅读完成 · 觉得有帮助?