7、RTMP数据封装:FLV封装格式详解

说到RTMP传输,就绕不开FLV封装。很多刚入行的朋友会问:为什么RTMP要用FLV?直接用MP4不行吗?

其实答案很简单——FLV足够轻量。我在做直播推流SDK的时候,对这一点体会特别深。MP4的moov box在文件头部,你得先读完整个文件才能知道时长和关键帧位置。但直播是实时流,你不可能等全部数据到了再封装。FLV就不一样,它是流式友好的结构,来一帧数据就能立刻封装发送。

嗯,今天我们就来把FLV的底裤扒干净。

7.1 FLV文件结构概览

FLV文件的结构其实不复杂。说白了就三部分:

  • 文件头(FLV Header)——告诉解析器这是个FLV文件
  • 文件体(FLV Body)——由一串Tag组成
  • 尾部(可选)——通常没有

我画了一张图,帮你快速建立整体认知:

FLV Header (9字节) Signature "FLV" + Version 1 + Flags + DataOffset PreviousTagSize0 (4字节) = 0 Tag 1 (Audio / Video / Script) Tag Header (11字节) + Tag Data (N字节) PreviousTagSize1 (4字节) = 上一个Tag的总大小 Tag 2 (Audio / Video / Script) Tag Header (11字节) + Tag Data (N字节) ...... Tag N

看到这个结构了吗?每个Tag后面紧跟着一个PreviousTagSize,记录上一个Tag的字节数。这个设计是为了方便解析器做随机访问——你从任意位置开始读,都能通过PreviousTagSize往前回溯。

7.2 FLV Header详解

FLV Header固定9个字节。我直接给你看代码:

// FLV Header结构
typedef struct {
    uint8_t signature[3];   // "FLV" (0x46 0x4C 0x56)
    uint8_t version;        // 版本号,目前为0x01
    uint8_t flags;          // 音频/视频标记位
    uint32_t dataOffset;    // 文件头长度,固定为9
} FLVHeader;

这里有个细节要注意——flags字段。它的bit0表示是否有音频,bit2表示是否有视频。比如0x05(二进制00000101)表示既有音频又有视频。我在项目中遇到过有人把这个字段写错,结果播放器死活不认。

重要:dataOffset虽然固定为9,但解析时一定要读这个字段,不要硬编码。有些工具会在Header后面加扩展信息,硬编码9会出问题。

7.3 FLV Tag结构

每个Tag由两部分组成:11字节的Tag Header + 可变长度的Tag Data。

Tag Header的结构如下:

typedef struct {
    uint8_t tagType;        // 8:音频 9:视频 18:脚本
    uint24_t dataSize;      // Tag Data的长度(3字节,大端)
    uint24_t timestamp;     // 时间戳(3字节,大端)
    uint8_t timestampExtended; // 时间戳扩展字节
    uint24_t streamID;      // 流ID,总是0
} FLVTagHeader;

你可能会问:为什么时间戳是3字节?24位最大只能表示约4.6小时。对于直播来说,一场直播可能持续十几个小时,3字节根本不够用。

所以FLV用了时间戳扩展机制:低24位放在timestamp字段,高8位放在timestampExtended字段。组合起来就是32位时间戳,单位毫秒,可以表示约49.7天。够用了吧?

我的经验:在构造时间戳时,一定要把timestamp和timestampExtended分开赋值。我见过有人直接把32位时间戳强转成3字节,结果高位被截断,直播超过4.6小时后时间戳就乱了。

7.4 Audio Tag

Audio Tag的Data部分,第一个字节是音频信息,后面跟着音频数据。

// 音频信息字节结构
// bit7-4: 音频编码格式
//   0 = Linear PCM, 平台字节序
//   1 = ADPCM
//   2 = MP3
//   3 = Linear PCM, 小端字节序
//   4 = Nellymoser 16kHz 单声道
//   5 = Nellymoser 8kHz 单声道
//   6 = Nellymoser
//   7 = G.711 A-law
//   8 = G.711 mu-law
//   9 = 保留
//   10 = AAC
//   11 = Speex
//   14 = MP3 8kHz
//   15 = 设备特定声音
//
// bit3-2: 采样率
//   0 = 5.5kHz
//   1 = 11kHz
//   2 = 22kHz
//   3 = 44kHz
//
// bit1: 采样精度
//   0 = 8位
//   1 = 16位
//
// bit0: 声道数
//   0 = 单声道
//   1 = 立体声

以AAC为例,第一个字节通常是0xAF(10101111),表示AAC编码、44kHz、16位、立体声。AAC的音频数据又分为两种:

  • AAC Sequence Header(type=0):包含AudioSpecificConfig,用于初始化解码器
  • AAC Raw Data(type=1):实际的音频帧数据

我记得第一次做AAC推流时,忘了发Sequence Header,结果播放器一直没声音。排查了半天才发现是这个问题。

7.5 Video Tag

Video Tag的Data部分,第一个字节是视频信息,后面跟着视频数据。

// 视频信息字节结构
// bit7-4: 帧类型
//   1 = 关键帧
//   2 = 非关键帧
//   3 = 可丢弃帧(仅H.263)
//   4 = 服务器生成的关键帧
//
// bit3-0: 编码格式
//   1 = JPEG
//   2 = Sorenson H.263
//   3 = Screen Video
//   4 = On2 VP6
//   5 = On2 VP6 with alpha
//   6 = Screen Video v2
//   7 = AVC (H.264)
//   12 = HEVC (H.265)

对于H.264/AVC,视频数据又分为:

  • AVC Sequence Header(type=0):包含SPS和PPS
  • AVC NALU(type=1):实际的视频帧数据
  • AVC End of Sequence(type=2):序列结束

避坑指南:我曾经在推流时,把关键帧和非关键帧的帧类型写反了。结果播放器在seek时找不到关键帧,画面一直花屏。关键帧的帧类型必须是1,非关键帧是2,千万别搞混。

7.6 Script Tag

Script Tag也叫Data Tag,用于传输元数据。它使用AMF(Action Message Format)编码。

最常见的Script Tag是"onMetaData",包含以下信息:

属性名 类型 说明
duration Number 时长(直播时为0)
width Number 视频宽度
height Number 视频高度
videocodecid Number 视频编码ID
audiocodecid Number 音频编码ID
framerate Number 帧率
audiosamplerate Number 音频采样率
audiosamplesize Number 音频采样精度
stereo Boolean 是否立体声

构造Script Tag的代码示例:

// 构造onMetaData Script Tag
void buildScriptTag(uint8_t* buffer, int* size) {
    int pos = 0;
    
    // Tag Header
    buffer[pos++] = 0x12;  // Script Tag类型
    // dataSize稍后填充
    // timestamp = 0
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x00;  // timestampExtended
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x00;  // streamID
    
    // Tag Data - AMF编码
    // 字符串 "onMetaData"
    buffer[pos++] = 0x02;  // AMF String类型
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x0A;  // 字符串长度10
    memcpy(buffer + pos, "onMetaData", 10);
    pos += 10;
    
    // ECMA Array (关联数组)
    buffer[pos++] = 0x08;  // AMF ECMA Array类型
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x05;  // 5个属性
    
    // 添加属性...
    // width: 1920
    // height: 1080
    // videocodecid: 7 (AVC)
    // audiocodecid: 10 (AAC)
    // framerate: 30
    
    // 结束标记
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x00;
    buffer[pos++] = 0x09;  // Object End
    
    // 填充dataSize
    int dataSize = pos - 11;  // 减去Tag Header
    buffer[1] = (dataSize >> 16) & 0xFF;
    buffer[2] = (dataSize >> 8) & 0xFF;
    buffer[3] = dataSize & 0xFF;
    
    *size = pos;
}

7.7 时间戳处理

时间戳是FLV封装中最容易出错的地方。我总结了几条经验:

  1. 单位是毫秒——所有时间戳都以毫秒为单位
  2. 从0开始——第一个Tag的时间戳为0
  3. 单调递增——时间戳必须单调递增,不能回退
  4. 32位扩展——超过0xFFFFFF时,必须使用timestampExtended
// 时间戳编码函数
void encodeTimestamp(uint32_t ts, uint8_t* timestamp, uint8_t* extended) {
    timestamp[0] = (ts >> 16) & 0xFF;
    timestamp[1] = (ts >> 8) & 0xFF;
    timestamp[2] = ts & 0xFF;
    *extended = (ts >> 24) & 0xFF;
}

我的建议:在推流时,最好用系统时钟的差值作为时间戳,而不是用帧率去推算。因为网络抖动、编码延迟都会导致实际帧率不稳定,推算出来的时间戳会越来越不准。

7.8 实战:构造一个完整的FLV文件

最后,我们把这些知识串起来,构造一个最简单的FLV文件:

// 构造FLV文件
void buildFLV(FILE* fp) {
    // 1. 写FLV Header
    uint8_t header[] = {
        0x46, 0x4C, 0x56,  // "FLV"
        0x01,              // Version 1
        0x05,              // 有音频有视频
        0x00, 0x00, 0x00, 0x09  // DataOffset = 9
    };
    fwrite(header, 1, 9, fp);
    
    // 2. 写PreviousTagSize0 = 0
    uint32_t prevSize = 0;
    fwrite(&prevSize, 4, 1, fp);
    
    // 3. 写Script Tag (onMetaData)
    // ... 省略具体实现
    
    // 4. 写Audio Tag (AAC Sequence Header)
    // ... 省略具体实现
    
    // 5. 写Video Tag (AVC Sequence Header)
    // ... 省略具体实现
    
    // 6. 循环写音视频数据
    while (hasMoreData()) {
        // 写Audio Tag
        // 写PreviousTagSize
        // 写Video Tag
        // 写PreviousTagSize
    }
}

嗯,到这里FLV封装的核心内容就讲完了。你可能会觉得细节很多,但说白了就是Header + Tag + PreviousTagSize的循环。只要把Tag Header的11个字节填对,把时间戳处理好,剩下的就是往Data里塞编码数据了。

我在做第一个RTMP推流项目时,光调试FLV封装就花了两天。后来把每个字节都打印出来对照规范看,才发现是时间戳扩展位写错了。所以建议你写代码时,也把每个Tag的十六进制dump出来,和规范逐字节对比,这样排查问题最快。

公众号:蓝海资料掘金营,微信deep3321