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组成
- 尾部(可选)——通常没有
我画了一张图,帮你快速建立整体认知:
看到这个结构了吗?每个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封装中最容易出错的地方。我总结了几条经验:
- 单位是毫秒——所有时间戳都以毫秒为单位
- 从0开始——第一个Tag的时间戳为0
- 单调递增——时间戳必须单调递增,不能回退
- 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出来,和规范逐字节对比,这样排查问题最快。