1. 从一个被忽视的抓包现象说起
如果你做过流媒体相关的开发,大概率遇到过这样的场景:推流端显示一切正常,播放端却黑屏或者卡在第一帧;用抓包工具看数据,TCP连接建立成功,握手也完成了,但后续的数据包解析出来全是乱码。我第一次碰到这个问题时,花了整整一个下午才定位到根源——不是网络问题,也不是编码参数配错了,而是对RTMP协议中Message(消息)这一层的格式理解有偏差。
RTMP(Real-Time Messaging Protocol)是流媒体领域最经典的传输协议之一,虽然现在HTTP-FLV、WebRTC、SRT等方案层出不穷,但大量直播平台、安防监控、在线教育系统仍然在用它。很多人学RTMP时把注意力全放在“握手”和“Chunk”上,却忽略了Message Formats这一层——而恰恰是这一层,决定了你能否正确地把音视频帧、元数据、控制指令组装成对端能识别的数据包。
这篇内容就是围绕RTMP Message Formats展开的。我会从Message和Chunk的关系讲起,把Message Header的字段逐个拆开,解释每种Message Type ID的用途和实际场景,再结合我踩过的坑,给出可复现的解析代码和排查思路。不管你是刚接触流媒体协议的新手,还是已经能跑通推拉流但想深入理解底层的老手,应该都能从中找到有用的东西。
2. Message与Chunk:先搞清楚这两个概念的区别
2.1 为什么RTMP要设计两层结构
刚看RTMP规范的人最容易混淆的就是Message和Chunk。我当初也是,看到“Message Header”和“Chunk Header”两个词就头大,觉得这不是重复设计吗?后来才明白,这是RTMP为了兼顾多路复用和传输效率做出的取舍。
打个比方:Message就像是你寄快递时的一个完整包裹,里面有你要送的东西(音视频数据、控制指令等);Chunk则是这个包裹被拆分成的一个个小纸箱。为什么要拆?因为RTMP底层跑在TCP上,如果一个大视频帧(比如一个I帧可能有几十KB甚至上百KB)直接发过去,会长时间占用连接,其他路流(比如音频、控制消息)就得干等着,实时性没法保证。拆成Chunk之后,不同Message的Chunk可以交替发送,实现多路复用。
关键点在于:Message是逻辑单位,Chunk是传输单位。一个Message可以被拆成多个Chunk,一个Chunk也可以承载多个Message的数据(通过Chunk Header中的msg length和type来判断边界)。
2.2 Message的基本结构长什么样
一个完整的RTMP Message由两部分组成:Message Header和Message Payload。Message Header本身又包含以下几个字段:
| 字段 | 长度 | 说明 |
|---|---|---|
| Timestamp | 3字节 | 消息的时间戳,用于音视频同步 |
| Message Length | 3字节 | Payload的长度,不包含Message Header |
| Message Type ID | 1字节 | 标识消息类型,决定Payload的解析方式 |
| Message Stream ID | 4字节 | 标识消息属于哪一路流 |
这里有个容易踩的坑:Message Stream ID在Chunk Header中是小端序(Little-Endian)存储的,而其他字段都是大端序。我第一次手写解析代码时没注意这一点,导致Stream ID解析出来完全不对,排查了很久。规范里明确写了这一点,但很多人看文档时会漏掉。
Message Header总共11字节(3+3+1+4),这个大小是固定的。Payload的长度由Message Length字段决定,可以是0到16777215字节(3字节能表示的最大值)。实际使用中,音视频数据的Payload通常比较大,而控制消息的Payload往往很小甚至为0。
2.3 Chunk Header如何承载Message Header
理解了Message之后,再看Chunk Header就清晰多了。RTMP定义了4种Chunk Header格式(Type 0到Type 3),它们的区别在于携带了多少Message Header的信息:
- Type 0(11字节):完整的Message Header,包含Timestamp、Message Length、Message Type ID、Message Stream ID。通常在一个Message的第一个Chunk使用。
- Type 1(7字节):省略Message Stream ID,包含Timestamp Delta、Message Length、Message Type ID。适用于同一个流中后续的Message。
- Type 2(3字节):只包含Timestamp Delta。适用于Message Length和Type ID都与前一个Message相同的情况。
- Type 3(0字节):完全不包含Message Header信息,全部沿用前一个Chunk的值。适用于一个Message被拆成多个Chunk时,后续Chunk的发送。
这种设计的目的是压缩头部开销。比如一个视频帧被拆成10个Chunk,第一个Chunk用Type 0带完整头信息,后面9个用Type 3,每个Chunk只需要1字节的Basic Header,传输效率大幅提升。
注意:Type 1/2/3中的Timestamp是Delta值(相对于前一个Message的时间戳差值),而不是绝对时间戳。解析时需要累加才能得到真实时间戳。这一点在实现时间戳同步逻辑时非常关键。
3. Message Type ID逐个拆解:每种消息到底干什么用
3.1 音视频数据消息(Type 8和Type 9)
Type 8是音频消息(Audio Message),Type 9是视频消息(Video Message)。这两个是流媒体传输中最核心的消息类型,Payload里装的就是编码后的音频帧和视频帧。
音频消息的Payload第一个字节是Audio Header,包含音频编码格式(4位)和采样率、位深、声道数等信息。比如AAC编码的音频,第一个字节通常是0xAF(AAC + 44kHz + 16bit + 立体声)。视频消息的Payload第一个字节是Video Header,包含帧类型(关键帧/非关键帧)和视频编码格式。比如H.264关键帧的第一个字节是0x17,非关键帧是0x27。
这里有个实际开发中经常遇到的问题:AAC音频的配置信息(AudioSpecificConfig)和H.264的SPS/PPS是在第一个音频/视频消息中发送的,而不是在握手阶段。如果你在解析时跳过了第一个消息,解码器就拿不到这些配置信息,导致无法解码。我见过不少人在做流媒体转发时,只转发后续的媒体数据,把第一个带配置的消息丢了,结果播放端一直黑屏。
3.2 控制消息(Type 1到Type 6)
控制消息是RTMP协议自身用来管理连接和流状态的消息,Payload通常很小,但作用很大:
- Type 1(Set Chunk Size):设置Chunk Size,默认是128字节。发送大视频帧之前,通常会把Chunk Size调大(比如4096或更大),减少拆包开销。Payload是4字节,最高位必须为0。
- Type 2(Abort Message):通知对端放弃当前正在传输的Message,Payload是4字节的Chunk Stream ID。
- Type 3(Acknowledgement):确认收到多少字节的数据,Payload是4字节的序列号。用于流量控制。
- Type 4(User Control Message):用户控制消息,包括Stream Begin、Stream EOF、Stream Dry、Set Buffer Length、Ping等事件。Payload前2字节是事件类型,后面是事件数据。
- Type 5(Window Acknowledgement Size):设置窗口确认大小,Payload是4字节。告诉对端每收到多少字节需要发一次Acknowledgement。
- Type 6(Set Peer Bandwidth):设置对端带宽,Payload是5字节(4字节带宽值+1字节限制类型)。
这些控制消息在推流和拉流过程中都会出现,如果你在做协议解析,必须正确处理它们,否则连接可能会异常断开。比如Set Chunk Size如果没处理好,后续的Chunk解析就会全部错位。
3.3 命令消息(Type 17和Type 20)
Type 17是AMF3编码的命令消息,Type 20是AMF0编码的命令消息。目前绝大多数RTMP实现都用AMF0,AMF3用得很少。命令消息的Payload是AMF编码的,包含命令名称、事务ID、命令对象等。
常见的命令包括:
- connect:客户端请求连接到服务器
- releaseStream/FCPublish:推流前的准备命令
- createStream:创建流
- publish:开始推流
- play:开始播放
- onStatus:服务器返回的状态信息
命令消息是RTMP交互的“语言”,推拉流的整个生命周期都靠这些命令来驱动。如果你要自己实现一个RTMP服务器或客户端,这部分是必须完整实现的。
3.4 其他消息类型
除了上面几类,还有Type 15(AMF3 Data Message)、Type 18(AMF0 Data Message)、Type 22(Aggregate Message)等。Data Message通常用来传递元数据,比如onMetaData,里面包含视频宽高、帧率、码率等信息。Aggregate Message是把多个Message打包成一个,用得不多,但解析时需要注意。
4. 手写一个Message解析器的完整过程
4.1 解析流程的整体设计
理解了Message的格式之后,写一个解析器其实不难。整体流程是:先从Chunk Header中提取出Message Header信息,然后根据Message Length和已接收的字节数判断Message是否完整,完整之后根据Message Type ID分发到不同的处理函数。
我用Python写过一个简化版的解析器,核心逻辑大概是这样:
class RTMPMessageParser: def __init__(self): self.chunk_size = 128 self.message_buffer = {} self.timestamp = {} def parse_chunk(self, data): # 解析Basic Header fmt = (data[0] >> 6) & 0x03 csid = data[0] & 0x3F # ... 根据fmt解析不同长度的Message Header # ... 将Chunk Payload追加到对应Message的缓冲区 # ... 判断Message是否完整 pass def on_message_complete(self, msg_type, payload, timestamp, stream_id): if msg_type == 8: self.handle_audio(payload, timestamp) elif msg_type == 9: self.handle_video(payload, timestamp) elif msg_type == 20: self.handle_command(payload) # ... 其他类型这里的关键是维护每个Chunk Stream的状态。因为Chunk是交替发送的,你不能假设同一个Message的所有Chunk是连续到达的。需要为每个Chunk Stream ID维护一个缓冲区,记录已接收的字节数和当前Message的Header信息。
4.2 时间戳回绕的处理
RTMP的时间戳是3字节的,最大值是16777215毫秒,约4.66小时。超过这个值就会回绕(从0重新开始)。如果你的流持续时间超过4.66小时,就必须处理时间戳回绕的问题。
处理方法是:记录上一次的时间戳,如果当前时间戳比上一次小很多(比如差值超过阈值),就认为发生了回绕,需要加上一个偏移量。这个逻辑在长时间运行的直播场景中非常重要,我见过因为没处理回绕导致音视频不同步的案例。
4.3 Chunk Size变更时的边界处理
Set Chunk Size消息本身也是通过Chunk传输的,而且它改变的是后续Chunk的大小。这里有个微妙的边界:Set Chunk Size消息的Payload是4字节,如果当前Chunk Size是128,它会在一个Chunk内传完;但如果当前Chunk Size小于4(虽然实际不会这么小),就需要拆包。更常见的问题是:解析器在处理Set Chunk Size时,必须确保这个Chunk本身已经完整接收,然后再更新chunk_size变量,否则后续的Chunk解析会错位。
我的做法是:在解析完一个完整的Chunk之后,检查这个Chunk是否属于Set Chunk Size消息,如果是且消息完整,再更新chunk_size。这样能保证不会在解析当前Chunk的过程中改变解析规则。
5. 那些年我踩过的Message格式坑
5.1 Message Length为0的消息
有些控制消息的Message Length是0,比如某些User Control Message。解析时如果没处理好,可能会陷入死循环或者错误地等待更多数据。正确的做法是:当Message Length为0时,直接认为消息完整,不需要等待Payload。
5.2 扩展时间戳的陷阱
当Timestamp或Timestamp Delta超过0xFFFFFF时,Chunk Header中会使用扩展时间戳(Extended Timestamp),即在Chunk Header后面额外跟4字节的时间戳。这个规则在Type 3的Chunk中也有特殊情况:如果前一个Chunk使用了扩展时间戳,Type 3 Chunk也需要带扩展时间戳。这个细节很容易被忽略,导致时间戳解析错误。
5.3 AMF0编码的坑
命令消息的Payload是AMF0编码的,AMF0是一种二进制编码格式,支持Number、Boolean、String、Object、Null、Undefined等类型。解析AMF0时最容易出错的是Object的结束标记(0x00 0x00 0x09)和ECMA Array的处理。我当初写解析器时,因为没正确处理Object的嵌套,导致onMetaData解析出来是空的,排查了很久才发现是AMF0解析逻辑有问题。
5.4 多路流的Stream ID混淆
Message Stream ID是用来区分不同流的,但在实际推流场景中,通常只有一路流,Stream ID往往是1。有些实现会忽略这个字段,直接假设Stream ID为1。但如果你的服务器需要支持多路流,就必须正确处理Stream ID,否则不同流的数据会混在一起。
6. 从Message Formats看RTMP的设计哲学
RTMP的Message Formats设计体现了一种“分层解耦”的思想:Message层负责逻辑上的数据组织,Chunk层负责物理上的传输。这种设计让RTMP既能高效传输大块音视频数据,又能灵活地插入控制消息和命令消息。
虽然RTMP在现代流媒体场景中已经不再是唯一选择,但它的设计思路仍然值得学习。比如它的Chunk Header压缩机制、多路复用机制、时间戳同步机制,在很多自定义协议中都能看到类似的影子。理解Message Formats,不仅能帮你解决实际开发中的问题,还能让你在设计自己的传输协议时少走弯路。
如果你正在做RTMP相关的开发,我的建议是:不要只停留在调用现成库的层面,花点时间把Message Formats这一层搞清楚。当你遇到那些“莫名其妙”的问题时,对底层格式的理解往往就是破局的关键。