
做音视频开发的人迟早都会碰到 FLV 这个封装格式。别看它年纪不小在直播、点播、低延迟传输这些场景里FLV 依然是绕不开的“硬通货”。特别是配合 FFmpeg 来做编码和封装几乎成了行业标配。这篇文章我就把 FLV 编码操作封装这件事掰开揉碎了讲从格式原理到代码实现再到我踩过的坑一次性说清楚。适合刚接触 FFmpeg 的开发者也适合那些已经在用 FFmpeg 但想深入理解 FLV 封装细节的朋友。1. 内容整体设计与思路拆解1.1 FLV 为什么至今没被淘汰很多人一看到 FLV 就觉得是上古产物毕竟 Flash 早就退场了。但实际上FLV 这个“容器”和 Flash 播放器是两码事。FLV 之所以能活到今天核心优势就三条结构极其简单解析成本低适合在弱网环境下做流式传输。支持在音视频数据前附加“脚本元数据”比如总时长、分辨率、码率播放器不需要读完整个文件就能拿到这些信息。能和 HTTP 协议完美配合实现伪流媒体渐进式播放和真正的流媒体分发比如 HTTP-FLV。CDN 厂商和直播平台至今仍大量使用 HTTP-FLV 作为直播分发协议原因就是延迟低、兼容性好。所以学习 FFmpeg 对 FLV 的编码与封装不是学一门“过气技术”而是在打底子。1.2 FFmpeg 在 FLV 封装链路中的角色FFmpeg 把“编码”和“封装”分得很清楚。编码是把原始图像或音频转成压缩数据比如 H.264、AAC封装是把这些压缩数据按某种格式的规则写入文件或网络流比如 FLV。我们要做的“FLV 编码操作封装”本质上就是一条流水线原始数据 → 编码器H.264/AAC → 封装器FLV Muxer → 文件或网络流FFmpeg 的 libavformat 库内置了 FLV Muxer它会把编码器输出的 AVPacket 按照 FLV 的 TAG 格式拆解、加头、排序最终输出带 FLV 头文件的合法流。我们可以直接用命令行做这件事也可以基于 FFmpeg API 在 C/C 工程里把这条链路封装成自己的功能模块。我个人的建议是先吃透命令行再动手写封装。命令行是验证思路最快的方式比如ffmpeg -i input.mp4 -c:v libx264 -c:a aac -f flv output.flv这一条命令就能完成转码封装。但如果你要做的是嵌入式、直播推流、或者需要动态控制编码参数那就必须走 API 封装路线了。1.3 封装方案选型命令行还是 API以实际项目为例。某次我接到一个需求把摄像头采集的 RTSP 流转成 FLV 推给流媒体服务器。这个场景下命令行很难做到按需控制关键帧间隔、动态码率、以及自定义 SEI 信息所以必须用 FFmpeg API。最终方案是用 libavformat 打开 RTSP 输入。用 libavcodec 做解码或直接转封装。用自定义的 FLV 封装模块管理输出缓冲、时间戳换算、TAG 写入。内部封装成FlvMuxer类对外只暴露 3 个接口打开、写视频帧、写音频帧、关闭。这样的优点是底层 FFmpeg 的复杂机制被隐藏上层业务只需要关心“帧数据进来FLV 数据出去”。下面我的整个讲解都会围绕这套思路展开。2. 核心细节解析与实操要点2.1 FLV 文件格式的精髓Header BodyFLV 文件结构可以用一句话概括FLV Header加上一串TAG。每一个 TAG 里要么是音频、要么是视频、要么是脚本数据。理解 TAG 的字节布局是封装工作的第一课。FLV Header 固定 9 个字节前 3 字节是 “FLV” 签名。第 4 字节是版本通常为 1。第 5 字节是标志位表示是否存在音频/视频 TAG。第 6~9 字节是 Header 长度固定为 9。然后是PreviousTagSize表示前一个 TAG 的大小第一个 TAG 之前这个值写 0。每个 TAG 的结构是TagType1 字节8 表示音频9 表示视频18 表示脚本。DataSize3 字节表示数据区长度。Timestamp3 字节 1 字节扩展位共 4 字节单位毫秒。StreamID3 字节通常为 0。DataN 字节。如果你要手写 FLV 封装器照着这个格式塞数据就行。但实际项目里直接用 FFmpeg 的 AVStream 和 AVFormatContext 会更省事这些字节层面的东西 FFmpeg 已经帮你处理好了。但你要懂尤其是排查问题的时候比如“为什么播放器显示不了时长”、“为什么音画不同步”最后都会回到对这个字节结构的理解上。2.2 编码器选择与参数设置FLV 容器里最常见的编码格式就是 H.264 视频 AAC 音频。所以做 FLV 封装前编码器必须选对。视频编码器首选libx264音视频同步的基准是音频采样率和视频帧率。几个关键参数profile直播场景选baseline或main就够兼容性最好。high profile虽然画质好但低端播放器可能不支持。presetveryfast到medium之间适合直播编码延迟低。tune直播推荐zerolatency这能避免编码器为了画质而主动引入 B 帧延迟。gop_size也就是关键帧间隔。直播场景一般设为帧率的 2 倍比如 25 帧就设 50。max_b_frames如果要求低延迟直接设为 0。音频方面AAC 编码器的profile建议用aac_low采样率 44100 或 48000声道数 2。FLV 中音频 TAG 的 SoundRate 字段只支持 5.5k、11k、22k、44k所以 48k 采样率其实会以 44k 的标识写入播放器再按 44.1k 处理这里容易造成时间戳偏差后面会讲到。2.3 时间戳FLV 封装的命门做 FLV 封装最容易翻车的点就是时间戳。FLV 的 TAG 时间戳是以毫秒为单位的整数而且视频和音频各自独立累加。如果你直接拿编码器输出的 PTS 用很可能会因为起始值不为 0、或者存在 B 帧导致 PTS 回退最终封装出来的 FLV 播放时快进快退全部错乱。正确的做法是在封装模块里维护相对起始时间的差值。具体来说记录第一个视频帧的 PTS 和第一个音频帧的 PTS。之后每一帧写入 TAG 的时间戳都减去对应的起始 PTS。确保视频帧时间戳单调递增如果遇到回退就强制钳制到上一帧的值。FFmpeg 自身的mux.c里也做类似的事情我们有AVFormatContext时它会在write_header之前调用avformat_write_header内部会统一处理。但如果你是自己把 AVPacket 拆出来手动写 TAG就必须自己处理这个逻辑。2.4 FLV 中 H.264 的“神秘”序列参数H.264 在 FLV 里不是直接把裸流塞进去的。FLV 的视频 TAG 数据区开头有一个VideoTagHeader其中FrameType和CodecID占了第一个字节紧接着AVCPacketType占一个字节然后是CompositionTime占 3 个字节。这里最关键的是AVCPacketType0 表示 AVCDecoderConfigurationRecord也就是包含 SPS/PPS 的关键配置信息。1 表示 AVC NALU也就是真正的编码帧数据。2 表示 AVC 序列结束。播放器拿到 FLV 流时必须先收到AVCPacketType 0的那个 TAG才能初始化解码器。所以封装模块必须在输出第一个视频帧之前先写入一个包含 SPS/PPS 的配置 TAG。FFmpeg 的extradata里有这个数据可以直接用av_stream-codecpar-extradata取到。实践中我还遇到过某些编码器送出来的extradata里没有 SPS/PPS或者只在第一帧前送一次但是 FLV 播放器中途开始播放时就无法解码。解决办法是在封装模块里缓存 SPS/PPS并且可以在关键帧前重复写入配置 TAG牺牲一点点带宽换兼容性。3. 实操过程与核心环节实现3.1 环境准备与 FFmpeg 编译选项在开始写代码之前先搞定环境。最省事的方式是用官方编译好的 FFmpeg 动态库但如果你要自己裁剪记得在 configure 时保留--enable-libx264 --enable-libfdk-aac --enable-demuxerflv --enable-muxerflv。FLV 的 muxer 和 demuxer 默认是开启的但 libx264 和 AAC 编码器需要额外开。我自己习惯下载 FFmpeg 源码在 Linux 下编一份静态库这样部署到服务器时不用带一堆 .so 文件。编译参数大致如下./configure --prefix/usr/local/ffmpeg \ --enable-gpl \ --enable-libx264 \ --enable-libfdk-aac \ --enable-version3 \ --disable-doc \ --disable-debug make -j8 make install编译过程中最容易踩的坑是 libfdk-aac 和 FFmpeg 的版本兼容问题如果你只做 FLV 播放那路的 AAC 封装直接用内置的aac编码器就行不用非得用 fdk。内置 aac 编码质量稍差一点但封装流程完全一样。3.2 自定义 FlvMuxer 类设计这是我项目里一个简化版的设计你完全可以照着扩展。类的核心职责是接收编码后的帧输出标准的 FLV 流。class FlvMuxer { public: FlvMuxer(); ~FlvMuxer(); // 初始化输出config 里包含分辨率、帧率、码率、音频参数 int Open(const FlvMuxerConfig config); // 写入一帧视频数据data 为 H.264 裸流 NALUpts 单位毫秒 int WriteVideoFrame(const uint8_t* data, size_t size, int64_t pts, bool is_keyframe); // 写入一帧音频数据data 为 AAC 裸流pts 单位毫秒 int WriteAudioFrame(const uint8_t* data, size_t size, int64_t pts); // 关闭并输出尾部信息如果有必要 int Close(); private: int WriteFlvHeader(); int WriteTag(uint8_t type, const uint8_t* data, size_t size, int64_t timestamp); int WriteVideoTag(const uint8_t* data, size_t size, int64_t pts, bool is_keyframe); int WriteAudioTag(const uint8_t* data, size_t size, int64_t pts); int WriteScriptTag(); std::FILE* out_file_; int64_t start_time_video_; int64_t start_time_audio_; bool has_written_video_config_; bool has_written_audio_config_; };核心是 WriteTag它要完成 FLV TAG 头的封装以及时间戳的处理。写出 TAG 头时timestamp 需要拆成低 24 位和高 8 位int FlvMuxer::WriteTag(uint8_t type, const uint8_t* data, size_t size, int64_t timestamp) { uint8_t tag_header[11]; tag_header[0] type; tag_header[1] (size 16) 0xFF; tag_header[2] (size 8) 0xFF; tag_header[3] size 0xFF; tag_header[4] (timestamp 16) 0xFF; tag_header[5] (timestamp 8) 0xFF; tag_header[6] timestamp 0xFF; tag_header[7] (timestamp 24) 0xFF; tag_header[8] 0; tag_header[9] 0; tag_header[10] 0; fwrite(tag_header, 1, 11, out_file_); fwrite(data, 1, size, out_file_); uint32_t previous_tag_size 11 size; fwrite(previous_tag_size, 1, 4, out_file_); return 0; }注意 fwrite 的 previous_tag_size 要按照大端序写入所以在嵌入式环境里记得用字节序转换函数。3.3 用 FFmpeg API 完成 FlvMuxer 的另一种实现说实话上面手写 TAG 的方式适合学习但在实际工程项目里我更推荐直接基于 FFmpeg 的 muxer API。这样你不需要关心 PreviousTagSize、扩展时间戳这些细节FFmpeg 全部处理掉了。核心流程如下AVFormatContext* fmt_ctx nullptr; avformat_alloc_output_context2(fmt_ctx, nullptr, flv, output_path); AVStream* video_stream avformat_new_stream(fmt_ctx, nullptr); video_stream-codecpar-codec_type AVMEDIA_TYPE_VIDEO; video_stream-codecpar-codec_id AV_CODEC_ID_H264; video_stream-codecpar-width 1920; video_stream-codecpar-height 1080; // 从编码器拿到 extradata 后设置 video_stream-codecpar-extradata_size encoder_extradata_size; video_stream-codecpar-extradata (uint8_t*)av_mallocz(encoder_extradata_size AV_INPUT_BUFFER_PADDING_SIZE); memcpy(video_stream-codecpar-extradata, encoder_extradata, encoder_extradata_size); if (avio_open(fmt_ctx-pb, output_path, AVIO_FLAG_WRITE) 0) { // 错误处理 } AVDictionary* opts nullptr; av_dict_set(opts, flvflags, no_duration_filesize, 0); avformat_write_header(fmt_ctx, opts);这里有一个坑AVFormatContext的pb如果不指定默认是文件输出。如果你要推网络流也可以把pb绑定到自定义的 AVIO 上这样 FLV 数据就能直接推给服务器。很多自研推流 SDK 就是这个思路。写完头部之后每一帧就是一个AVPacket。你要做的是把packet-pts和packet-dts按 AVStream 的时间基换算成毫秒然后调用av_interleaved_write_frame(fmt_ctx, packet)。FFmpeg 会自动完成写入排序和 TAG 封装。3.4 FLV 头部和脚本 TAG 的生成细节封装 FLV 时必须先写 Header然后是脚本 TAG。脚本 TAG 的 Data 区是一个 AMF0 编码的数组里面可以放 duration、width、height、videocodecid、audiocodecid 等元数据。手写 AMF0 非常繁琐我的做法是直接在代码里构造一个固定格式的字节数组或者用 FFmpeg 的avformat_write_header后它会根据流的参数自动生成 onMetaData 标签。如果你完全手写 FLV 封装器脚本 TAG 可以这样生成写入 2 字节的 AMF0 类型标记0x12 表示 ECMA array。写入 4 字节的数组元素个数。遍历所有元数据项每个项写入键名长度 字符串和值类型标记 值。实际项目里脚本 TAG 即使不写也能播放但写了会更友好。比如你要做直播服务器需要用 onMetaData 来获取音视频信息这个 TAG 就不能省。3.5 从 RTSP 到 FLV 的完整实操案例以前我做过一个网关程序把海康摄像头 RTSP 流转成 FLV 推给 CDN。当时使用的流程类似这样用 libavformat 打开 RTSP拿到 H.264/AAC 流。因为 RTSP 过来的 H.264 是 Annex-B 格式而 FLV 里需要的是 AVCC 格式的 extradata所以必须用av_bitstream_filter或 FFmpeg 的bsf模块做转换。在封装 FLV 之前我从解码器参数中复制 extradata 给输出流。时间戳做了同步处理因为 RTSP 流的时间基准是 90kHz转成毫秒时要除以 90。实际操作中RTSP 输入流的 DTS/PTS 可能从非零值开始所以我先记录第一帧的 PTS后续所有 PTS 减去该值确保 FLV 从 0 开始。4. 常见问题与排查技巧实录4.1 FLV 文件播放不了或黑屏这个问题的首要嫌疑是 extradata 没写对。播放器在黑屏之前其实有一个初始化阶段它需要从AVCDecoderConfigurationRecord里解析出 SPS/PPS如果解析失败视频就黑屏。排查方法用十六进制查看器打开 FLV 文件找视频 TAG 的AVCPacketType0。确认 SPS/PPS 的长度是否和 extradata 中描述一致。对比源 H.264 流是不是 Annex-B 格式如果是记得用 h264_mp4toannexb 的逆过程处理实际上封装到 FLV 时用 h264_annexb_to_mp4。我遇到过一种情况编码器输出的 extradata 里 SPS/PPS 是带 start code 的而 FLV 里的 AVCDecoderConfigurationRecord 要求去掉 start code只保留 NALU 长度前缀。直接用带 start code 的数据会导致播放器解析失败。解决办法用 FFmpeg 的av_bitstream_filter_filter做一次转换或者手动去掉 start code。4.2 音画不同步音画不同步多见于音频采样率不是 FLV 标准采样率的情况。FLV 的 SoundRate 字段只能表示 44.1kHz如果你用 48kHz大多数播放器能猜出来但有些播放器会按 44.1kHz 计算时长导致音视频累积误差。解决方法是要么把音频重采样到 44100Hz要么在封装时给音频时间戳做一个基于真实采样率的修正。更稳妥的方案是统一以毫秒为单位并且以音频帧的真实 pts 为准不依赖 FLV 头里的采样率字段。另外视频的 CompositionTime 必须正确。对于有 B 帧的 H.264DTS 和 PTS 不一致FLV 里用 CTS 补偿。FFmpeg 在写 FLV 时会自动计算 CTS但如果你手写 TAG一定要把pts - dts的值填入 CompositionTime 那 3 个字节。4.3 FLV 时间戳过大或超过 24 位FLV 的 tag 头时间戳低 3 字节是 24 位最多表示约 4.6 小时。超过这个时间的流必须使用扩展时间戳也就是第 4 字节。FFmpeg 的 muxer 会自动处理扩展位但手写时容易忽略。我的经验是持续推流超过 4 小时的直播应用一定要检查扩展时间戳是否设置正确。否则播放器会在 4.6 小时后出现时间回跳或卡死。4.4 常见问题速查表现象可能原因排查与解决播放器提示无法解析extradata 缺失或错误检查 SPS/PPS 是否完整确保 extradata 为 AVCC 格式视频花屏关键帧前收到非关键帧确认首帧必须是关键帧只有声音没有画面视频 TAG 的 CodecID 错误检查 CodecID 是否为 7H.264只有画面没有声音音频 TAG 的 SoundFormat 错误AAC 对应 10并确保音频配置 TAG 已写入播放进度条拖不动没有写入 onMetaData 或 duration 不正确在脚本 TAG 中正确写入 duration直播流延迟越来越高编码器产生 B 帧设置max_b_frames0和tunezerolatency服务器拒绝推流时间戳起始不为 0 或跳变将第一帧 PTS 归零后续帧做偏移4.5 避坑技巧缓存 SPS/PPS 并定期重复注入这个技巧是在做多路直播时总结出来的。有些 CDN 边缘节点在用户中途进入直播时会从最近的关键帧开始拉流但它不知道前面的 SPS/PPS。如果 FLV 流只在开头放一次 SPS/PPS中途进入的用户可能一直黑屏。因此很多推流端会在每个关键帧前面带上配置 TAG也就是每隔几秒重复写入一次 SPS/PPS。代价是带宽多了一点点但播放兼容性提升非常明显。我自己实现时是在关键帧写入前判断一下是否距上次配置 TAG 超过 2 秒如果是就补一个。5. 工具选型与代码集成经验5.1 纯手写封装 vs FFmpeg Muxer 的取舍看了前面的内容你会发现手写 FLV 封装并不难但细节不少。那么问题来了项目里到底该用哪种如果平台是 Linux / Windows / macOS直接用 FFmpeg 的libavformat是最稳的维护成本最低。如果是 MCU、嵌入式 Linux 等资源有限的环境且只需要输出 FLV那么自己实现一个精简 muxer 反而可控避免引入 FFmpeg 整个库的重量。如果是 iOS/Android可以考虑用 FFmpeg 的移动端编译版本也可以自己用 MediaCodec 手写 FLV muxer前者开发快后者性能好。我在一个低功耗设备上做过一个纯 C 的手写 FLV muxer整个模块只占 2KB RAM效果非常好。但前提是编码器输出的 H.264 已经是 AVCC 格式避免再去处理 start code。5.2 FFmpeg 版本与库的 API 兼容性FFmpeg 的 API 一直在演进网上很多老教程写的AVCodecContext用法在新版本已经变了。以 4.x 和 5.x 为例codecpar替代了codec的大部分职责。建议锁定一个长期支持版本比如 4.4 或 6.x然后统一封装自己的抽象层避免业务代码和 FFmpeg 版本强耦合。我自己写的一个项目中所有 FFmpeg 调用都集中在一个模块里对外暴露纯 C 结构体。这样即使 FFmpeg 升级我只需要改这一个模块。这个习惯帮我省了很多事。5.3 将 FLV 推流与网络模块结合FLV 封装出来的数据要推给服务器可以直接交给 socket。FFmpeg 的AVIOContext可以自定义回调把数据写到内存或直接 send 出去。举例int write_callback(void* opaque, uint8_t* buf, int buf_size) { return send(socket_fd, buf, buf_size, 0); }然后把这个回调传给avio_alloc_context再通过avformat_alloc_output_context2把pb设置成这个自定义 AVIO。这样写的代码既能输出到文件也能输出到网络对上层完全一致。5.4 直播场景下的关键编码参数推荐表参数推荐值说明视频编码器libx264通用性最好像素格式yuv420p兼容所有播放器GOP 大小帧率 × 2比如 25fps 设 50帧率15 / 25 / 30根据网络带宽调节视频码率1.5Mbps ~ 4Mbps1080p 建议 3Mbps音频编码器AAC-LC采样率 44100 或 48000音频码率128kbps双声道足够音频声道2立体声5.5 代码集成中的几个“潜规则”调用avformat_write_header之前先确保所有流的codecpar都设置完整。不设置 extradata 的情况下FFmpeg 会尝试从编码器上下文拷贝但如果你用的是外部编码器必须要手动拷贝。av_interleaved_write_frame和av_write_frame的区别也要搞清楚。前者会把帧排序后交织写入适合 FLV后者直接写入可能造成字节顺序不对。在 FLV 封装里一定用av_interleaved_write_frame。关闭资源时要先调用av_write_trailer再avio_close最后avformat_free_context。顺序错了可能会有内存泄漏甚至文件损坏。6. 扩展应用除了文件还能做什么FLV 编码操作封装的范围并不局限于输出.flv文件。在直播协议里HTTP-FLV 就是把 FLV 当作流媒体传输载体。也就是说你封装出来的 FLV 数据可以直接放进 HTTP 响应体中以http chunked或content-length的方式发送给播放器。很多自研播放器就是这么做的。此外FLV 还可以承载在 RTMP 协议上实际上 RTMP 的消息体内就是 FLV TAG 格式。这也是为什么学过 FLV 封装后再去看 RTMP 推流会很简单——核心的 TAG 结构一模一样。做直播服务的同学如果能把 FLV 封装吃透再学 SRT、WebRTC 这类协议会轻松很多。还有一点我在实际项目中也常用 FFmpeg 把 FLV 转成 MP4这个过程反而比 FLV 封装简单因为 MP4 不需要前后排序问题。但理解了 FLV 的时间戳和 TAG 规则转 MP4 时你就能轻松处理好stts、ctts这些采样表。7. 实操经验总结与个人心得讲了这么多最后聊点个人感受。FFmpeg 这个东西看文档一百遍不如动手写一遍。FLV 封装看起来简单但真正把它做稳需要你对“容器格式”有敬畏心。时间戳、SPS/PPS、字节序、AMF0每一个细节都能让你在线上事故里学到教训。我印象最深的一次线上推流突然在某个用户手机上黑屏查了半天最后发现是那个用户的播放器不支持 FLV 中带 SEI 的 H.264 流。解法很意外——在编码器参数里关掉 SEI 注入或者把 SEI 从帧数据里剥离掉。这个经验后来被我写进封装模块的一个开关里默认剥离 SEI只在需要时开启。还有一次为了兼容一个老旧的播放器我不得不把 FLV 的 Header 里音频标志位改成 1即使当时没有音频流。这个操作确实有点“hack”但播放器就能正常加载了。这类经验很难从官方文档里获得只能在实战中积累。因此我建议你先自己实现一个手写 FLV muxer哪怕只是把 H.264 裸流封装成可播放的 FLV。这会逼着你弄懂 TAG 头、时间戳、AVCC 和 Annex-B 的差别。等你全走通了再回到 FFmpeg API 就会有一种“透视”的感觉——你知道每一个函数背后在做什么遇到问题能直接定位到字节级别。最后分享一个调试小技巧用 FFmpeg 生成的 FLV 作对比把你的输出和它输出做二进制 diff你就能快速找到自己封装错在哪里。用ffprobe也能直观看到每个 TAG 和流的信息。千万别偷懒这一步能省掉你大量排查时间。