首页 / 资讯中心 / 文章详情

RTMP Chunk Stream编解码原理与实现:多路复用、头部压缩与边界处理

RTMP Chunk Stream编解码原理与实现:多路复用、头部压缩与边界处理 ★ FEATURED ARTICLE
1. 从一个被忽略的抓包现象说起如果你做过流媒体协议分析大概率遇到过这样一个场景用抓包工具抓取一段RTMP推流数据打开一看满屏都是十六进制的字节流你能看到握手阶段的C0、C1、C2也能看到连接阶段的connect命令但再往下翻数据就变成了一堆看起来毫无规律的二进制块。你隐约知道这些块跟音视频数据有关但具体怎么组织的、为什么有的块大有的块小、时间戳是怎么对齐的一时半会儿说不清楚。这些“看起来毫无规律的二进制块”就是RTMP Chunk Stream要解决的问题。RTMP协议本身是建立在TCP之上的TCP是字节流协议没有消息边界的概念。而RTMP需要传输的是有结构的消息——命令消息、音频消息、视频消息、元数据消息等等。如果直接把整条消息丢进TCP接收端根本不知道一条消息从哪里开始、到哪里结束。更麻烦的是RTMP还需要支持多路复用在同一条TCP连接上同时传输音频、视频、控制命令等多种数据流而且不同流的优先级还不一样。如果一条大消息比如一个关键帧把TCP连接堵住了后面的控制命令就得干等这在实时互动场景里是不可接受的。Chunk Stream机制就是RTMP给出的答案。它把消息拆成小块每块带上自己的标识信息接收端根据这些标识把块重新组装成完整的消息。这个设计思路跟HTTP/2的帧层、QUIC的流机制有异曲同工之妙但RTMP早在2002年左右就定型了算是流媒体领域里非常经典的一套多路复用方案。这篇文章面向的是已经对RTMP协议有基本了解、但想深入理解Chunk Stream层实现细节的开发者。我会从Chunk的基本结构讲起逐步拆解分块规则、头部压缩机制、时间戳处理、多路复用原理最后给出一个可落地的实现思路和调试方法。文章里涉及的具体参数和计算过程我会尽量给出推导逻辑让你不仅知道“是什么”还知道“为什么是这样”。提示本文假设读者已经了解RTMP握手和基本消息类型的概念。如果你对RTMP完全陌生建议先补充一下RTMP协议的基础知识再来看Chunk Stream层的内容。2. Chunk的基本结构与头部字段拆解2.1 为什么不能直接传消息要理解Chunk的设计先得理解它要解决的核心矛盾。RTMP消息Message是逻辑上的完整数据单元比如一条音频消息可能包含1024个AAC帧一条视频消息可能包含一个完整的H.264关键帧。这些消息的长度从几十字节到几兆字节不等。如果直接把消息写入TCP流接收端面临两个问题第一无法区分消息边界除非每条消息都带长度前缀但这样又增加了额外开销第二无法实现多路复用一条大消息会阻塞后续所有消息。Chunk机制的做法是把每条消息拆成固定大小的块默认最大128字节每个块带一个Chunk Header接收端根据Header里的信息把块拼回消息。这样做的代价是每个块都有额外的头部开销但换来的是多路复用能力和更好的传输控制。2.2 Chunk Header的四种格式Chunk Header不是固定长度的它根据上下文信息做了压缩。RTMP定义了四种Header格式用第一个字节的高2位来标识Header类型第一个字节高2位头部长度包含字段Type 00011字节timestamp(3) message length(3) message type id(1) message stream id(4)Type 1017字节timestamp delta(3) message length(3) message type id(1)Type 2103字节timestamp delta(3)Type 3110字节无第一个字节的低6位是Chunk Stream ID简称CSID范围是2到63。0和1有特殊用途CSID为0表示后面还有一个字节参与CSID计算CSID为1表示后面还有两个字节参与计算。这样CSID的实际范围可以扩展到3到65599。Type 0是完整的头部包含绝对时间戳、消息长度、消息类型ID和消息流ID。Type 1省略了消息流ID因为同一个Chunk Stream上的消息流ID通常不变。Type 2进一步省略了消息长度和消息类型ID适用于同一流上连续发送相同类型、相同长度的消息。Type 3最极端什么字段都不带完全复用上一个块的信息。这种设计本质上是一种差分编码如果当前块和上一个块的信息相同或可推导就不重复传输。对于音频流这种消息长度固定、类型固定的场景大部分块都可以用Type 3头部开销几乎为零。2.3 Message Stream ID与Chunk Stream ID的区别这两个概念容易混淆但它们的职责完全不同。Message Stream ID标识的是逻辑上的消息流比如一个RTMP连接里可能有一个音频流和一个视频流它们有不同的Message Stream ID。Chunk Stream ID标识的是Chunk层的复用通道同一个Message Stream ID的消息可以分散在不同的Chunk Stream上传输。实际实现中通常一个Message Stream ID对应一个Chunk Stream ID但协议并不强制要求这样。发送端可以根据需要把同一条消息的不同块分配到不同的Chunk Stream上接收端根据CSID来区分不同通道的块再根据Message Stream ID来组装消息。注意CSID是Chunk层的概念Message Stream ID是消息层的概念。在实现时接收端需要维护一个CSID到“当前正在接收的消息状态”的映射表每个CSID独立维护自己的时间戳、消息长度、消息类型等状态。2.4 一个具体的Chunk解析示例假设我们收到一段字节流第一个字节是0x03。高2位是00说明是Type 0头部。低6位是0x03即CSID为3。接下来读取11个字节时间戳3字节、消息长度3字节、消息类型ID 1字节、消息流ID 4字节小端序。假设解析出来时间戳为0消息长度为200消息类型ID为8音频消息流ID为1。那么我们就知道CSID 3上开始了一条新的音频消息总长度200字节当前时间戳为0。接下来读取Chunk Data长度取min(200, 128)即128字节。这128字节是消息的前128字节。然后继续读取下一个Chunk Header。如果下一个字节是0xC3高2位是11即Type 3CSID为3。Type 3没有头部字段直接读取Chunk Data。此时接收端知道CSID 3上还有72字节没接收完所以继续读取min(72, 128)即72字节。拼上前面的128字节一条完整的200字节音频消息就组装完成了。这个过程中第二个块用Type 3头部只用了1个字节的头部开销效率非常高。如果每个块都用Type 0头部开销就是11字节对于128字节的块来说开销接近9%这在低码率音频场景下是不可忽视的。3. 分块规则与消息重组的状态机3.1 分块大小的协商机制RTMP默认的Chunk Data最大长度是128字节。这个值可以通过Set Chunk Size控制消息来修改。发送端发送一条Set Chunk Size消息告诉接收端“我接下来发送的块数据部分最大可以是N字节”。接收端收到后更新自己的解析状态。为什么要有这个协商机制128字节对于视频关键帧来说太小了。一个1080p的关键帧可能有几十KB按128字节分块需要几百个块每个块至少1字节头部总开销就是几百字节。如果把块大小提高到4096或8192块数量减少头部开销占比就降下来了。但块大小也不是越大越好。块越大单个块的传输时间越长多路复用的粒度就越粗。如果一个视频块是8192字节在低带宽下可能需要几十毫秒才能传完这期间其他流的数据就得等待。所以块大小的选择需要在开销和延迟之间做权衡。实际工程中音频流通常保持128字节或稍大视频流会根据码率和网络状况调整到4096左右。提示Set Chunk Size消息本身也是用Chunk传输的但它的解析不依赖于新的块大小。接收端在收到这条消息后从下一条消息开始使用新的块大小。这个细节在实现时容易出错需要特别注意。3.2 消息分块的完整流程发送端分块的逻辑可以用以下步骤描述从消息队列中取出一条消息获取其时间戳、消息长度、消息类型ID、消息流ID。根据消息流ID找到对应的Chunk Stream如果不存在则创建。判断当前块是否是该消息的第一个块。如果是根据与上一个块的差异选择Header类型如果不是通常使用Type 3。计算本次要发送的数据长度min(剩余字节数, 当前块大小)。写入Chunk Header和Chunk Data。更新剩余字节数如果还有剩余回到步骤3继续发送下一个块。接收端的重组逻辑更复杂一些因为它需要处理乱序、丢包虽然RTMP基于TCP理论上不会丢包但实现上仍需考虑异常情况和多个CSID的并发状态。每个CSID需要维护以下状态时间戳当前消息的绝对时间戳或时间戳增量消息长度当前消息的总长度消息类型ID消息流ID已接收字节数消息缓冲区当收到一个块时接收端根据CSID找到对应的状态解析Header更新状态把Chunk Data追加到缓冲区。如果已接收字节数等于消息长度说明消息接收完成把完整消息交给上层处理然后重置该CSID的状态但保留时间戳基准等信息用于后续块的差分解析。3.3 时间戳的两种模式绝对与增量Type 0头部携带的是绝对时间戳Type 1和Type 2携带的是时间戳增量delta。接收端需要根据这两种模式计算出当前块的实际时间戳。对于Type 0当前时间戳 头部中的时间戳值。 对于Type 1和Type 2当前时间戳 上一个块的时间戳 头部中的时间戳增量。 对于Type 3当前时间戳 上一个块的时间戳增量视为0但有一种特殊情况后面会讲。这里有一个容易踩坑的地方时间戳增量是有符号还是无符号协议里没有明确说但实际实现中通常按无符号处理。如果增量超过0xFFFFFF3字节最大值需要用到扩展时间戳字段。3.4 扩展时间戳的处理3字节的时间戳最大能表示16777215毫秒约4.66小时。对于大多数直播场景够用了但如果是长时间推流或者时间戳基准很大的场景就可能溢出。RTMP的解决方案是当时间戳或时间戳增量等于0xFFFFFF时在Chunk Header后面额外跟4个字节的扩展时间戳。这个规则对Type 0、Type 1、Type 2都适用。Type 3比较特殊如果上一个块使用了扩展时间戳那么Type 3块也需要读取4字节扩展时间戳。这个细节在实现时很容易漏掉导致解析错位。我在实际调试中遇到过一个问题某推流端在时间戳接近0xFFFFFF时没有正确发送扩展时间戳导致接收端解析出来的时间戳突然跳变音视频不同步。排查了很久才发现是发送端的时间戳溢出处理有bug。所以如果你在实现发送端一定要在时间戳达到0xFFFFFF之前就切换到扩展时间戳模式不要等到溢出才处理。4. 多路复用与优先级控制的实现细节4.1 多路复用的本质CSID作为虚拟通道Chunk Stream的多路复用能力来自于CSID。每个CSID相当于一条虚拟通道不同通道的块可以交错发送。接收端根据CSID把块分发到不同的重组缓冲区互不干扰。这种设计的好处是一条大消息的传输不会阻塞其他通道的消息。比如视频关键帧正在分块传输中间可以插入音频块和控制命令块。接收端先收到完整的音频消息就可以先解码播放不用等视频关键帧传完。但这里有一个隐含的问题如果发送端不加控制地把所有通道的块混在一起发接收端的处理顺序可能和发送顺序不一致。比如视频块先发了一半然后发了音频块再发视频块的剩余部分。接收端收到音频块时视频消息还没组装完但音频消息已经完整了。上层应用需要决定是立即处理音频消息还是等视频消息也完整了再一起处理。这涉及到播放器的缓冲策略不在Chunk层的职责范围内。4.2 块大小对多路复用的影响块大小直接决定了多路复用的粒度。块越小交错越频繁延迟越低但头部开销越大。块越大头部开销越小但一条大消息占用通道的时间越长其他通道的等待时间就越长。假设视频码率是2Mbps音频码率是128kbps。如果块大小是128字节一个视频关键帧50KB需要约400个块每个块头部至少1字节总开销400字节占比约0.8%。如果块大小是4096字节需要约13个块头部开销约13字节占比几乎可以忽略。但4096字节的块在2Mbps带宽下需要约16毫秒传输这16毫秒内音频块无法插入音频延迟会增加。实际工程中常见的做法是音频流用较小的块128或256字节视频流用较大的块4096或8192字节控制命令用默认的128字节。这样在开销和延迟之间取得平衡。4.3 发送端的调度策略发送端如何决定下一个发送哪个CSID的块最简单的策略是轮询每个CSID轮流发送一个块。但这样对高优先级的控制命令不公平控制命令可能被大量视频块淹没。更好的策略是优先级队列控制命令最高优先级音频次之视频最低。每次从最高优先级的非空队列中取一个块发送。但这样又可能导致视频块长时间得不到发送因为音频块持续产生。实际实现中通常采用加权轮询或令牌桶算法给每个流分配一定的发送配额。还有一种策略是基于消息的尽量把一条消息的块连续发送完减少交错。这样接收端可以更快地组装出完整消息但会增加其他流的延迟。对于实时互动场景通常采用更细粒度的交错对于点播场景可以接受更大的交错粒度。注意RTMP协议本身没有规定发送端的调度策略这完全由实现者决定。不同的推流软件如OBS、FFmpeg在调度策略上可能有差异这也是为什么有时候换一个推流工具延迟和流畅度会有明显变化。4.4 接收端的并发重组接收端需要同时维护多个CSID的重组状态。每个CSID的状态包括当前消息的剩余字节数、已接收的字节数、消息缓冲区、时间戳基准、消息长度、消息类型ID、消息流ID。当收到一个块时接收端先解析CSID找到对应的状态。如果状态不存在新的CSID则创建一个新的状态。然后根据Header类型更新状态字段把Chunk Data追加到缓冲区。如果缓冲区满了已接收字节数等于消息长度则把消息交给上层并重置状态。这里有一个性能优化点消息缓冲区的大小应该根据消息长度动态分配而不是固定分配一个大缓冲区。对于音频消息通常只有几百字节对于视频关键帧可能有几十KB。如果每个CSID都预分配一个大缓冲区内存浪费会很严重。实际实现中可以用内存池或动态扩容的缓冲区。5. 协议交互中的边界情况与异常处理5.1 块大小变更时的解析陷阱前面提到Set Chunk Size消息本身是用旧的块大小传输的收到后从下一条消息开始使用新的块大小。但这里有一个细节如果Set Chunk Size消息本身被分成了多个块那么这些块都应该用旧的块大小解析。只有在完整接收到这条消息后才更新块大小。我在实现接收端时曾经犯过一个错误在解析到Set Chunk Size消息的第一个块时就把块大小更新了导致后续块的解析长度错误。正确的做法是把Set Chunk Size当作普通消息处理等消息完整接收后再更新块大小。5.2 时间戳回退与乱序RTMP基于TCP理论上不会出现乱序。但时间戳回退是可能发生的比如推流端的时间戳基准重置或者某些实现的时间戳计算有bug。接收端如果发现当前块的时间戳小于上一个块的时间戳应该怎么处理协议没有明确规定。实际实现中通常有两种做法一是直接接受让上层去处理时间戳跳变二是检测到回退后重置时间戳基准。我倾向于第一种做法因为Chunk层不应该对时间戳做过多假设把判断逻辑留给上层更灵活。5.3 消息长度为零的情况RTMP允许消息长度为0吗协议里没有明确禁止但实际中很少见。如果收到消息长度为0的块接收端应该立即认为消息接收完成把空消息交给上层。但有些实现可能会陷入死循环因为剩余字节数始终为0块数据长度为0看起来像是一直在接收空块。所以实现时要特别处理长度为0的消息。5.4 CSID为0和1的扩展解析CSID为0时需要再读1个字节实际CSID 64 第二个字节的值。CSID为1时需要再读2个字节实际CSID 64 第一个字节的值 第二个字节的值 * 256。这个计算方式有点反直觉因为第二个字节是高位。我在第一次实现时把高低位搞反了导致CSID解析错误调试了很久。提示CSID为0时扩展字节的范围是0到255所以实际CSID范围是64到319。CSID为1时两个扩展字节组合起来范围是0到65535实际CSID范围是64到65599。注意CSID 2到63是直接编码的不需要扩展。5.5 块数据长度不足的异常如果接收端发现Chunk Data的实际长度小于预期比如TCP连接中断应该把该CSID的状态标记为异常等待连接恢复或重置。不要试图用不完整的数据组装消息否则上层会收到损坏的数据。6. 从零实现一个Chunk Stream编解码器6.1 数据结构设计实现Chunk Stream编解码器核心是设计好两个数据结构发送端的Chunk Stream状态和接收端的Chunk Stream状态。发送端每个CSID需要维护上一个块的时间戳、消息长度、消息类型ID、消息流ID。这些信息用于判断下一个块应该用哪种Header类型。接收端每个CSID需要维护当前消息的剩余字节数、已接收字节数、消息缓冲区、时间戳基准、消息长度、消息类型ID、消息流ID、上一个块是否使用了扩展时间戳。用C语言风格的结构体表示大概是这样的typedef struct { uint32_t csid; uint32_t timestamp; uint32_t message_length; uint8_t message_type_id; uint32_t message_stream_id; uint32_t received_bytes; uint8_t *buffer; bool extended_timestamp; } chunk_stream_state_t;发送端和接收端可以共用这个结构体只是使用的字段不同。6.2 发送端编码流程发送端编码一条消息的伪代码def send_message(csid, timestamp, message_type_id, message_stream_id, data): state get_or_create_state(csid) offset 0 first_chunk True while offset len(data): remaining len(data) - offset chunk_data_size min(remaining, chunk_size) if first_chunk: header_type decide_header_type(state, timestamp, len(data), message_type_id, message_stream_id) write_chunk_header(csid, header_type, timestamp, len(data), message_type_id, message_stream_id) first_chunk False else: write_chunk_header(csid, TYPE_3, 0, 0, 0, 0) write_chunk_data(data[offset:offsetchunk_data_size]) offset chunk_data_size update_state(state, timestamp, len(data), message_type_id, message_stream_id)decide_header_type的逻辑是如果消息流ID变了用Type 0如果消息长度或类型ID变了用Type 1如果只有时间戳变了用Type 2如果什么都没变用Type 3。6.3 接收端解码流程接收端解码的伪代码def on_data_received(data): while len(data) 0: csid, header_type, header_fields parse_chunk_header(data) state get_or_create_state(csid) update_state_from_header(state, header_type, header_fields) chunk_data_size min(state.message_length - state.received_bytes, chunk_size) chunk_data read_bytes(data, chunk_data_size) state.buffer.extend(chunk_data) state.received_bytes chunk_data_size if state.received_bytes state.message_length: message assemble_message(state) deliver_to_upper_layer(message) reset_state(state)解析Chunk Header时先读第一个字节取高2位判断Header类型取低6位判断CSID。如果CSID为0或1继续读取扩展字节。然后根据Header类型读取相应长度的字段。6.4 性能优化建议Chunk Stream编解码是RTMP协议栈里最频繁调用的部分性能优化很重要。几个实用的优化点第一避免频繁的内存分配。接收端的消息缓冲区可以预分配一个较大的内存池每个CSID从池中分配。消息处理完后归还到池中而不是每次都malloc/free。第二批量解析。如果一次收到多个块的数据尽量在一个循环里解析完减少函数调用开销。第三使用查表法解析Header类型。第一个字节的高2位只有4种取值可以预先建一个表直接查出Header长度和字段布局。第四对于Type 3块由于没有Header字段解析非常快可以单独优化这条路径。6.5 调试与验证方法实现完编解码器后怎么验证正确性我通常用以下几种方法方法一用已知的RTMP流做回放测试。抓取一段真实的RTMP推流数据用你的解码器解析看能否正确还原出音视频消息。可以跟FFmpeg的解析结果做对比。方法二构造边界测试用例。比如时间戳刚好等于0xFFFFFF、消息长度刚好是块大小的整数倍、CSID为0和1的情况、连续多个Type 3块等。方法三压力测试。模拟高码率推流看解码器的CPU占用和内存占用是否稳定。方法四用Wireshark的RTMP解析器做对比。Wireshark内置了RTMP协议解析可以把你解析出来的消息跟Wireshark的结果对比快速定位差异。提示Wireshark解析RTMP时如果遇到不认识的块可能会显示为“Unknown”或解析错误。这时候可以对照你的解析结果看是谁的问题。有时候是Wireshark的解析器不够完善不一定是你的实现有bug。7. 几个实际项目中踩过的坑第一个坑是关于块大小协商的。某次对接一个推流端对方在发送Set Chunk Size消息后紧接着就用新的块大小发送了后续数据。但我们的接收端是在完整接收Set Chunk Size消息后才更新块大小的导致后续数据解析错位。后来发现是对方实现有误Set Chunk Size消息本身应该用旧的块大小发送而且更新时机应该是在消息完整接收之后。这个问题的排查花了很长时间因为抓包看起来一切正常只是解析出来的数据对不上。第二个坑是关于扩展时间戳的。某次测试长时间推流大约4个多小时后音视频开始不同步。排查发现是时间戳超过了0xFFFFFF但推流端没有正确发送扩展时间戳。接收端把0xFFFFFF当成了实际时间戳导致后续时间戳计算全部错误。这个问题的教训是在实现发送端时一定要在时间戳达到0xFFFFFF之前就切换到扩展时间戳模式不要等到溢出才处理。第三个坑是关于CSID扩展的。前面提到过CSID为1时两个扩展字节的组合方式是64 byte1 byte2 * 256第二个字节是高位。我一开始按64 byte1 * 256 byte2计算结果CSID解析错误导致消息重组混乱。这个问题的根源是协议文档里对这个计算方式的描述不够直观容易看错。第四个坑是关于Type 3块的时间戳的。Type 3块通常表示时间戳增量为0但如果上一个块使用了扩展时间戳Type 3块也需要读取4字节扩展时间戳。这个细节在协议文档里没有特别强调我在实现时漏掉了导致解析错位。后来是在对比Wireshark解析结果时发现的。第五个坑是关于消息长度为0的。某次测试中推流端发送了一条长度为0的控制消息我们的接收端陷入了死循环因为剩余字节数始终为0块数据长度也为0看起来像是一直在接收空块。后来加了一个判断如果消息长度为0直接认为消息接收完成不再进入块解析循环。这些坑的共同特点是协议文档里都有提及但描述不够详细或者容易忽略。实际实现时最好的办法是边实现边用真实数据验证不要等到全部写完再测试。每实现一个功能点就用抓包数据验证一下这样问题定位会快很多。8. 关于Chunk Stream层的一些个人体会Chunk Stream层是RTMP协议里设计得最精巧的部分之一。它用相对简单的机制解决了多路复用、消息边界、头部压缩三个问题而且留出了足够的扩展空间。虽然现在有QUIC、WebTransport等更现代的协议但RTMP的Chunk Stream设计思路仍然值得学习。从实现角度看Chunk Stream编解码器的难点不在于单个功能的实现而在于边界情况的处理。时间戳溢出、块大小变更、CSID扩展、Type 3的扩展时间戳这些细节任何一个处理不当都会导致解析错误。而且这些错误往往不会立即暴露而是在特定条件下才出现排查起来比较困难。我的建议是实现时尽量参考成熟的实现比如FFmpeg的rtmpproto.c或者srs的RTMP模块。这些实现经过了大量实际场景的验证边界情况处理得比较完善。但也不要盲目照搬要理解每一行代码的意图这样才能在遇到问题时快速定位。另外调试RTMP协议时Wireshark是非常有用的工具。它内置了RTMP解析器可以直观地看到每个Chunk的Header类型、CSID、时间戳等信息。虽然Wireshark的解析器也有不完善的地方但作为参考对比已经足够了。最后说一个实际经验在实现接收端时建议加一个详细的日志系统记录每个CSID的状态变化和每个块的解析结果。这样当出现问题时可以通过日志快速定位是哪个CSID、哪个块、哪个字段出了问题。日志级别可以动态调整平时只记录关键信息调试时打开详细日志。这个习惯帮我节省了大量排查时间。
阅读完成 · 觉得有帮助?
咨询建站