RTMP Chunk Stream 深度解析:从协议细节到工程实践 1. 从一个被忽略的协议细节说起做音视频开发的人绕不开 RTMP。不管是直播推流、低延迟互动还是老旧的流媒体服务RTMP 至今仍然活跃在很多生产环境里。但大多数人用 RTMP 的方式是直接调库——比如用 FFmpeg 推流、用 SRS 或 Nginx-rtmp 做服务端很少有人真正翻开 RTMP 规范去读一读它底层是怎么工作的。我最初也是这样。直到有一次排查一个推流端和服务端之间的诡异问题推流端明明发送了数据服务端也收到了但解析出来的音视频帧就是不对时间戳错乱、数据包边界混乱。抓包看了半天最后发现问题出在Chunk Stream这一层——也就是 RTMP 规范里编号为 05 的那个章节。RTMP Chunk Stream中文一般叫块流或分块流是 RTMP 协议最核心的传输机制。它解决的问题很朴素RTMP 底层跑在 TCP 上TCP 是字节流没有消息边界的概念而 RTMP 需要传输的是有结构的消息Message。Chunk Stream 就是在这两者之间架了一层把 Message 拆成 Chunk 来传输同时通过头部压缩、多路复用等机制让协议既高效又灵活。这篇文章我想把 RTMP Chunk Stream 从头到尾拆一遍。不是翻译规范而是站在一个实际写过 RTMP 解析器、调过推流链路的人的角度讲清楚它为什么这么设计、每个字段到底在干什么、实际实现时哪些地方最容易踩坑。如果你正在做直播推流、流媒体服务端、或者需要自己实现一个 RTMP 客户端/服务端这篇内容应该能帮你省下不少抓包和调试的时间。适合的读者有基本网络编程概念、了解 TCP、对音视频传输感兴趣的同学。完全没接触过 RTMP 也没关系我会从最基础的概念讲起。2. Chunk Stream 到底在解决什么问题2.1 先搞清楚 Message 和 Chunk 的关系在 RTMP 的世界里有两个层级的数据单位需要区分清楚Message消息这是 RTMP 的逻辑数据单元。比如一条音频消息、一条视频消息、一条控制消息。每个 Message 有类型音频/视频/命令等、有长度、有时间戳、有流 ID。Chunk块这是 RTMP 的实际传输单元。一个 Message 在网络上传送时会被拆成一个或多个 Chunk。为什么要拆因为 RTMP 需要多路复用。想象一下你在推一路直播流同时有音频、视频、还有控制信令比如握手后的 connect、publish 命令。如果每个 Message 都必须完整发完才能发下一个那音频就得等视频发完视频就得等命令发完延迟会非常糟糕。更严重的是如果一个大视频帧比如关键帧可能有几百 KB独占连接音频数据就会被阻塞导致声音卡顿。Chunk Stream 的做法是把每个 Message 切成固定大小的 Chunk默认 128 字节可协商然后不同流的 Chunk 可以交替发送。这样音频、视频、控制消息就能穿插着走谁也不用等谁。提示这里的流Stream和 Chunk Stream 里的流是两个概念。Message 层面的 Stream ID 标识的是逻辑流比如一路直播而 Chunk Stream IDcsid标识的是 Chunk 层面的复用通道。一个逻辑流的数据可以走多个 csid多个逻辑流也可以共享 csid 规则。初学的时候特别容易混淆这两个概念。2.2 为什么不用简单的长度前缀你可能会想TCP 上没有消息边界那我给每个 Message 加个长度前缀不就行了读的时候先读长度再读内容多简单。这个方案在单路、小数据量场景下确实够用。但 RTMP 面对的场景要复杂得多第一头部开销。如果每个 Message 都带完整的头部类型、长度、时间戳、流 ID一个 128 字节的音频包可能头部就占了几十字节有效载荷比例太低。RTMP 通过 Chunk 头部的压缩机制让连续发送的同类 Chunk 可以用极短的头部甚至 1 字节来表示。第二多路复用。长度前缀方案下你要么串行发送要么自己再设计一套复用机制。Chunk Stream 直接把这个能力内建了。第三时间戳同步。音视频同步依赖时间戳Chunk 头部携带时间戳信息并且支持增量压缩让时间戳传输也很高效。所以 Chunk Stream 不是多此一举而是针对流媒体场景专门设计的传输层。理解了这一点后面看它的头部设计就不会觉得奇怪了。2.3 Chunk 的基本结构长什么样一个 Chunk 由三部分组成Basic Header基本头部1 到 3 字节包含 csidChunk Stream ID和 fmtChunk 类型。Message Header消息头部0、3、7 或 11 字节取决于 fmt包含时间戳、消息长度、消息类型、消息流 ID 等信息。Extended Timestamp扩展时间戳0 或 4 字节当时间戳字段不够用时才出现。Chunk Data块数据实际载荷最大长度为协商的 chunk size。这个结构看起来简单但每个字段的设计都有讲究。下面逐个拆。3. 逐字段拆解 Chunk 头部设计3.1 Basic Headercsid 和 fmt 的编码艺术Basic Header 是每个 Chunk 必带的它的核心任务是编码两个信息csid和fmt。csid 的取值范围是 2 到 65599。为什么从 2 开始因为 0 和 1 有特殊用途后面讲。fmt 取值 0 到 3表示这个 Chunk 的 Message Header 用哪种格式。Basic Header 的长度是可变的取决于 csid 的大小csid 在 2~63 之间Basic Header 占 1 字节。低 6 位存 csid高 2 位存 fmt。csid 在 64~319 之间Basic Header 占 2 字节。第一字节低 6 位为 0第二字节存 csid 减去 64 的值。csid 在 320~65599 之间Basic Header 占 3 字节。第一字节低 6 位为 1后两字节存 csid 减去 64 的值。用位运算表示就是// 1 字节形式 byte0 (fmt 6) | csid; // 2 字节形式 byte0 (fmt 6) | 0; byte1 csid - 64; // 3 字节形式 byte0 (fmt 6) | 1; byte1 (csid - 64) 8; byte2 (csid - 64) 0xFF;这里有个细节值得注意csid 的编码方式决定了协议能支持的并发 Chunk Stream 数量。1 字节形式支持 62 个 csid2~632 字节形式扩展到 3193 字节形式到 65599。实际使用中大部分 csid 都在 2~63 范围内因为协议预定义了一些常用 csidcsid用途2协议控制消息Protocol Control Messages3命令消息Command Messages如 connect、publish4音频/视频等用户消息部分实现5~63用户自定义或动态分配注意csid 0 和 1 不是用来标识 Chunk Stream 的。csid 0 表示这个 Chunk 的 Basic Header 是 2 字节形式csid 1 表示3 字节形式。这是编码上的技巧读的时候要先判断低 6 位是不是 0 或 1再决定读几个字节。3.2 fmt四种消息头部格式的取舍fmt 决定了 Message Header 的长度和内容这是 Chunk Stream 压缩机制的核心。四种格式分别是fmt 011 字节 Message Header包含完整信息时间戳3 字节、消息长度3 字节、消息类型 ID1 字节、消息流 ID4 字节小端。这是最重的格式通常在一个新的 Chunk Stream 开始时使用或者时间戳出现回退时使用。fmt 17 字节 Message Header包含时间戳增量3 字节、消息长度3 字节、消息类型 ID1 字节。省略了消息流 ID表示和上一个 Chunk 使用相同的流 ID。时间戳是增量值要加上上一个 Chunk 的时间戳才是真实时间戳。fmt 23 字节 Message Header只包含时间戳增量3 字节。消息长度、类型、流 ID 都沿用上一个 Chunk 的值。适合连续发送同类型、同长度的消息比如固定码率的音频帧。fmt 30 字节 Message Header什么都不带。所有字段都沿用上一个 Chunk。这是最省的形式适合连续发送完全同质的 Chunk。这个设计的逻辑很清晰用得越频繁、越规律的数据头部越短。比如音频流通常码率稳定、帧长固定就可以大量使用 fmt2 甚至 fmt3。而视频流因为关键帧和普通帧大小差异大fmt 会频繁切换。实操心得很多自己实现 RTMP 的开发者会图省事所有 Chunk 都用 fmt0。这样确实不会出错但带宽浪费严重。我实测过一个 1080p 推流场景全用 fmt0 比正常压缩头部多消耗大约 3%~5% 的带宽。在移动网络或大规模并发场景下这个差距很可观。3.3 时间戳与扩展时间戳32 位不够用怎么办时间戳字段在 Message Header 里是 3 字节最大值是 0xFFFFFF也就是 16777215 毫秒约 4.66 小时。对于大多数直播场景够用了但如果是长时间运行的点播或监控流就可能溢出。RTMP 的处理方式是扩展时间戳Extended Timestamp当 3 字节时间戳字段的值为 0xFFFFFF 时说明真实时间戳在后面的 4 字节扩展字段里。这个扩展字段紧跟在 Message Header 之后。这里有个容易踩的坑扩展时间戳的触发条件。规范里说的是当时间戳或时间戳增量大于等于 0xFFFFFF 时把 3 字节字段填 0xFFFFFF然后在扩展字段写真实值。但实际实现中有些实现是大于才触发有些是大于等于就触发导致互操作性出问题。我建议严格按照大于等于 0xFFFFFF 就启用扩展来实现兼容性最好。还有一个更隐蔽的坑fmt3 的扩展时间戳。当 fmt3 时Message Header 是 0 字节没有时间戳字段。但如果上一个 Chunk 用了扩展时间戳那么 fmt3 的 Chunk 也需要带扩展时间戳字段。这个规则在规范里写得比较隐晦很多实现会漏掉导致解析错位。// 判断是否需要读扩展时间戳的逻辑 if (fmt 0) { timestamp read_u24(); if (timestamp 0xFFFFFF) { timestamp read_u32(); has_extended true; } } else if (fmt 1 || fmt 2) { delta read_u24(); if (delta 0xFFFFFF) { delta read_u32(); has_extended true; } timestamp last_timestamp delta; } else if (fmt 3) { // 关键如果上一个 chunk 有扩展时间戳这里也要读 if (last_has_extended) { read_u32(); // 读掉扩展时间戳 } timestamp last_timestamp; }3.4 消息长度、类型与流 ID消息长度Message Length是 3 字节表示这个 Message 的总长度不是当前 Chunk 的长度。接收方根据这个值来判断一个 Message 什么时候收完。最大长度 0xFFFFFF约 16MB对音视频帧来说足够了。消息类型 IDMessage Type ID是 1 字节标识消息的类别。常见的类型有类型 ID名称说明1Set Chunk Size设置 Chunk 大小2Abort Message中止消息3Acknowledgement确认4User Control Message用户控制消息5Window Acknowledgement Size窗口确认大小6Set Peer Bandwidth设置对端带宽8Audio音频数据9Video视频数据15/18Data Message (AMF3/AMF0)元数据17/20Command Message (AMF3/AMF0)命令消息流 IDMessage Stream ID是 4 字节小端序。注意这里和 Basic Header 里的 csid 不是一回事。Message Stream ID 标识的是逻辑流比如一路直播的音频和视频可能共享同一个 Message Stream ID但走不同的 csid。注意Message Stream ID 是小端序而 RTMP 协议里其他多字节字段大多是大端序。这个不一致是历史遗留问题实现时特别容易搞错。我见过不止一个项目在这里翻车抓包看数据全对就是流 ID 解析出来是个天文数字。3.5 Chunk Data 与 Chunk Size 协商Chunk Data 就是实际载荷。一个 Message 被切成多个 Chunk 时除了最后一个 Chunk前面的 Chunk Data 都是满的等于 chunk size。默认 chunk size 是 128 字节。这个值可以通过Set Chunk Size控制消息来修改范围是 1 到 0xFFFFFF。实际使用中推流端通常会把它调大比如 4096 或 60000以减少 Chunk 数量、降低头部开销。为什么默认是 128因为 RTMP 设计于带宽匮乏的年代小 chunk 能让低优先级数据更快插入降低延迟。但在现代高带宽环境下128 太小了一个 4KB 的视频帧要切成 32 个 Chunk头部开销和解析开销都不划算。调大 chunk size 的代价是延迟会增加。因为一个 Chunk 必须完整收到才能处理chunk 越大等待一个完整 Chunk 的时间越长。所以这里有个权衡低延迟场景互动直播chunk size 可以保持较小比如 1024~4096。高吞吐场景点播、大并发chunk size 可以调大比如 16384~60000。实操心得Set Chunk Size 这个消息本身必须用 chunk size 为 128 来发送因为对方还没收到新的 chunk size。而且这个消息的 Message Stream ID 必须是 0csid 通常是 2。这些细节如果搞错对方可能直接解析失败。4. 消息分块与重组的完整流程4.1 发送端一个 Message 如何变成多个 Chunk假设我们要发送一个 1000 字节的视频消息当前 chunk size 是 128csid 是 4Message Stream ID 是 1时间戳是 1000。发送端的处理流程判断这个 Message 需要几个 Chunk1000 / 128 7.8向上取整是 8 个 Chunk。第一个 Chunk 用 fmt0因为这是新的消息需要完整头部写入时间戳 1000、消息长度 1000、类型 9视频、流 ID 1。后续 Chunk 用 fmt3因为长度、类型、流 ID 都没变时间戳也没变Message Header 为 0 字节。每个 Chunk 的 Chunk Data 最多 128 字节最后一个 Chunk 是 1000 - 7*128 104 字节。这里有个关键点同一个 Message 的所有 Chunk 必须使用相同的 csid。接收方靠 csid 来区分不同 Message 的 Chunk。如果交错发送接收方需要为每个 csid 维护一个正在接收的 Message的状态。4.2 接收端如何知道一个 Message 收完了接收端为每个 csid 维护一个状态机记录当前正在接收的 Message 的已接收字节数当前 Message 的总长度当前 Message 的时间戳、类型、流 ID上一个 Chunk 的头部信息用于 fmt1/2/3 的增量解析每收到一个 Chunk就把 Chunk Data 追加到当前 Message 的缓冲区并累加已接收字节数。当已接收字节数等于消息长度时一个完整的 Message 就收完了可以交给上层处理然后重置状态准备接收下一个 Message。这个状态机看起来简单但实际实现时有个容易忽略的点消息长度可能为 0。比如某些控制消息长度为 0这时候收到一个 Chunk 就应该立即完成不需要等数据。4.3 多路复用的实际表现多路复用是 Chunk Stream 的精髓。假设同时有音频csid4、视频csid6、控制消息csid2在发送网络上的 Chunk 序列可能是这样的[csid2, fmt0, 控制消息] [csid4, fmt0, 音频消息第1块] [csid6, fmt0, 视频消息第1块] [csid4, fmt3, 音频消息第2块] [csid6, fmt3, 视频消息第2块] [csid6, fmt3, 视频消息第3块] ...接收端根据 csid 把 Chunk 分发到不同的重组缓冲区互不干扰。这样即使视频消息很大音频 Chunk 也能及时插入保证音频不卡顿。提示实际实现中发送端如何决定 Chunk 的发送顺序规范没有强制规定。但通常的策略是控制消息优先音频优先于视频同一消息的 Chunk 尽量连续发送减少接收端状态切换。有些实现会做更复杂的调度比如按时间戳排序、按优先级队列发送。4.4 分块大小的动态调整Set Chunk Size 消息可以在连接建立后的任何时候发送发送后立即生效。这意味着发送端可以在发送过程中动态调整 chunk size。但这里有个陷阱调整 chunk size 的时机。如果发送端在发送一个 Message 的中途改变 chunk size接收端可能还在用旧的 chunk size 解析。规范建议在发送完一个完整 Message 后再调整并且调整消息本身要用旧的 chunk size 发送。实际实现中大多数推流端会在握手完成后、发送音视频数据之前先发一个 Set Chunk Size 把大小调到 4096 或更大之后就固定不变。这样最简单也最不容易出错。5. 协议控制消息与 Chunk Stream 的配合5.1 Set Chunk Size 的细节Set Chunk Size 是类型 1 的协议控制消息它的载荷是 4 字节最高位必须为 0保留位低 31 位是新的 chunk size。// 构造 Set Chunk Size 消息 uint8_t payload[4]; uint32_t new_size 4096; payload[0] (new_size 24) 0x7F; // 最高位清零 payload[1] (new_size 16) 0xFF; payload[2] (new_size 8) 0xFF; payload[3] new_size 0xFF;这个消息的 Message Stream ID 必须是 0csid 通常是 2。消息长度是 4。注意有些实现会把 chunk size 设成 0 或超过 0xFFFFFF这都是非法的。接收方收到非法值应该报错或忽略。我见过一个案例某推流端把 chunk size 设成了 0xFFFFFFFF结果服务端直接崩溃——因为服务端用这个值去分配缓冲区整数溢出导致内存越界。5.2 Window Acknowledgement Size 与 Acknowledgement这两个消息和 Chunk Stream 的流控相关。Window Acknowledgement Size 告诉对方你每收到我发的 N 字节数据就给我回一个 Acknowledgement。Acknowledgement 则是实际的确认消息携带已接收的字节数。这个机制的目的是让发送端知道接收端的进度避免发送端发得太快把接收端缓冲区撑爆。在 Chunk Stream 层面它影响的是发送端的发送节奏。实际使用中很多推流端会忽略 Acknowledgement因为 TCP 本身有流控。但在一些对延迟敏感的场景合理设置 Window Acknowledgement Size 可以帮助更快发现网络问题。5.3 Set Peer BandwidthSet Peer Bandwidth 用来告诉对方我的带宽是这么多你发送时参考一下。载荷 5 字节4 字节带宽值 1 字节限制类型。限制类型有三种0Hard对方必须按这个带宽发送。1Soft对方可以参考但可以超过。2Dynamic如果之前的限制是 Hard则降级为 Soft否则忽略。这个机制在实际中用得不多因为现代网络带宽变化快静态设置意义有限。但服务端在握手后通常会发一个推流端可以选择忽略。5.4 用户控制消息用户控制消息类型 4用于传递一些流控制事件比如Stream Begin通知对方流已开始。Stream EOF通知对方流已结束。Set Buffer Length设置缓冲区长度。Ping Request / Ping Response心跳检测。这些消息的 Message Stream ID 通常是 0csid 是 2。它们和 Chunk Stream 的关系是它们也走 Chunk 传输但因为通常很短小于 chunk size所以一个 Chunk 就能装下。6. 实际实现中最容易踩的坑6.1 字节序问题RTMP 协议里字节序不统一这是最容易出错的地方。总结一下字段字节序Basic Header 中的 csid大端位运算Message Header 中的时间戳大端Message Header 中的消息长度大端Message Header 中的消息流 ID小端Set Chunk Size 载荷大端AMF0 数据大端Message Stream ID 是唯一的小端字段。我建议在代码里明确标注每个字段的字节序不要靠记忆。6.2 fmt3 的隐式状态继承fmt3 的 Chunk 不带任何 Message Header所有信息都继承自上一个 Chunk。这意味着接收端必须为每个 csid 维护完整的上一个 Chunk 状态包括时间戳消息长度消息类型消息流 ID是否有扩展时间戳如果状态维护不完整fmt3 的 Chunk 就会解析错误。特别是扩展时间戳的状态很多实现会漏掉。6.3 跨 Message 的 fmt 继承这里有个更隐蔽的问题fmt1/2/3 继承的是上一个 Chunk的状态而不是上一个 Message的状态。如果两个 Message 之间没有其他 Chunk 插入那么第二个 Message 的第一个 Chunk 可以用 fmt1/2/3 来继承第一个 Message 的头部信息。但实际中因为多路复用两个 Message 之间通常会有其他 csid 的 Chunk 插入。所以每个 Message 的第一个 Chunk 通常还是用 fmt0。只有在同一个 csid 上连续发送多个 Message 时才可能用到跨 Message 的继承。实操心得我在实现接收端时曾经因为把上一个 Chunk的状态和上一个 Message的状态混在一起导致解析错位。后来改成每个 csid 独立维护状态问题就解决了。建议你也这样做虽然会多占一点内存但逻辑清晰得多。6.4 Chunk 边界与消息边界不一致一个 Chunk 可能包含多个 Message 的数据吗不会。一个 Chunk 只属于一个 Message。但一个 Message 的最后一个 Chunk 可能不满 chunk size下一个 Chunk 就是新 Message 的开始。接收端在处理时不能假设每个 Chunk 都是满的也不能假设一个 Chunk 就是一个 Message。必须严格按照消息长度来切分。6.5 时间戳回退的处理正常情况下时间戳是递增的。但如果时间戳出现回退比如编码器重置fmt1/2 的增量计算会出问题。规范建议当时间戳回退时发送端应该用 fmt0 发送完整时间戳接收端检测到回退后重置状态。实际实现中有些编码器在重置时不会主动切换 fmt导致接收端解析出负的增量。我建议接收端做防御性处理如果计算出的时间戳小于上一个时间戳就认为发生了回退直接采用新时间戳。6.6 常见问题速查表现象可能原因排查方向解析出的消息长度异常大字节序错误检查消息长度字段是否按大端解析流 ID 是天文数字字节序错误Message Stream ID 是小端音视频不同步时间戳解析错误检查扩展时间戳和增量计算数据错位、花屏Chunk 边界处理错误检查消息长度累加逻辑连接一段时间后断开时间戳溢出检查扩展时间戳触发条件服务端收不到数据Set Chunk Size 未生效检查控制消息的 csid 和流 IDfmt3 解析失败状态继承不完整检查每个 csid 的状态维护7. 自己动手实现一个最小 Chunk Stream 解析器7.1 整体架构设计如果要自己实现一个 RTMP Chunk Stream 解析器我建议分成三层字节读取层负责从 TCP 流里读取指定长度的字节处理粘包/半包。Chunk 解析层解析 Basic Header、Message Header、扩展时间戳提取 Chunk Data。Message 重组层按 csid 维护状态把 Chunk Data 拼成完整 Message。这三层分开的好处是字节读取层可以复用Chunk 解析层可以单独测试Message 重组层的状态管理逻辑清晰。7.2 核心数据结构// 每个 csid 的状态 typedef struct { uint32_t timestamp; // 上一个 Chunk 的时间戳 uint32_t timestamp_delta; // 上一个 Chunk 的时间戳增量 uint32_t message_length; // 当前 Message 的总长度 uint8_t message_type_id; // 消息类型 uint32_t message_stream_id;// 消息流 ID uint8_t has_extended; // 是否有扩展时间戳 uint8_t is_complete; // 当前 Message 是否收完 // 重组缓冲区 uint8_t *buffer; uint32_t buffer_size; uint32_t bytes_received; } chunk_stream_state_t; // 全局状态 typedef struct { uint32_t chunk_size; // 当前 chunk size默认 128 chunk_stream_state_t streams[65599]; // 按 csid 索引 } rtmp_chunk_parser_t;7.3 解析 Basic Headerint parse_basic_header(rtmp_chunk_parser_t *parser, const uint8_t *data, uint32_t *offset, uint8_t *fmt, uint32_t *csid) { uint8_t byte0 data[(*offset)]; *fmt (byte0 6) 0x03; uint8_t csid_low byte0 0x3F; if (csid_low 0) { // 2 字节形式 uint8_t byte1 data[(*offset)]; *csid byte1 64; } else if (csid_low 1) { // 3 字节形式 uint8_t byte1 data[(*offset)]; uint8_t byte2 data[(*offset)]; *csid (byte1 8) byte2 64; } else { *csid csid_low; } return 0; }7.4 解析 Message Headerint parse_message_header(rtmp_chunk_parser_t *parser, const uint8_t *data, uint32_t *offset, uint8_t fmt, uint32_t csid) { chunk_stream_state_t *state parser-streams[csid]; uint32_t timestamp 0; if (fmt 0) { timestamp read_u24(data, offset); state-message_length read_u24(data, offset); state-message_type_id data[(*offset)]; state-message_stream_id read_u32_le(data, offset); state-has_extended 0; if (timestamp 0xFFFFFF) { timestamp read_u32(data, offset); state-has_extended 1; } state-timestamp timestamp; state-timestamp_delta 0; } else if (fmt 1) { uint32_t delta read_u24(data, offset); state-message_length read_u24(data, offset); state-message_type_id data[(*offset)]; state-has_extended 0; if (delta 0xFFFFFF) { delta read_u32(data, offset); state-has_extended 1; } state-timestamp_delta delta; state-timestamp delta; } else if (fmt 2) { uint32_t delta read_u24(data, offset); state-has_extended 0; if (delta 0xFFFFFF) { delta read_u32(data, offset); state-has_extended 1; } state-timestamp_delta delta; state-timestamp delta; } else if (fmt 3) { // 关键如果上一个 Chunk 有扩展时间戳这里也要读 if (state-has_extended) { read_u32(data, offset); } // 时间戳不变 } return 0; }7.5 重组 Messageint process_chunk(rtmp_chunk_parser_t *parser, const uint8_t *data, uint32_t data_len) { uint32_t offset 0; uint8_t fmt; uint32_t csid; parse_basic_header(parser, data, offset, fmt, csid); parse_message_header(parser, data, offset, fmt, csid); chunk_stream_state_t *state parser-streams[csid]; // 如果是新 Message初始化缓冲区 if (state-bytes_received 0) { state-buffer malloc(state-message_length); state-buffer_size state-message_length; } // 计算本次 Chunk 的数据长度 uint32_t remaining state-message_length - state-bytes_received; uint32_t chunk_data_len (remaining parser-chunk_size) ? remaining : parser-chunk_size; // 拷贝数据 memcpy(state-buffer state-bytes_received, data offset, chunk_data_len); state-bytes_received chunk_data_len; // 判断 Message 是否收完 if (state-bytes_received state-message_length) { // 完整 Message交给上层处理 handle_complete_message(state); state-bytes_received 0; free(state-buffer); state-buffer NULL; } return 0; }7.6 测试与验证实现完之后怎么验证正确性我的做法是单元测试构造各种 fmt 的 Chunk验证解析结果。抓包对比用 Wireshark 抓一个真实的 RTMP 流把解析结果和 Wireshark 的解析对比。端到端测试自己实现一个简单的推流端推到一个成熟的服务端比如开源的流媒体服务器看能否正常播放。实操心得Wireshark 对 RTMP 的解析非常完善可以显示每个 Chunk 的 fmt、csid、时间戳等信息。调试时把 Wireshark 的解析结果和自己的解析结果并排对比能快速定位问题。另外Wireshark 的 Follow TCP Stream 功能可以看到原始字节配合自己的解析日志排查字节序问题特别有效。8. 性能优化与工程实践8.1 减少内存拷贝上面的示例代码里每个 Chunk 都要 memcpy 到 Message 缓冲区。在高并发场景下这个拷贝开销不小。优化的思路对于小 Message小于 chunk size直接引用原始缓冲区不拷贝。对于大 Message使用环形缓冲区或链式缓冲区避免连续内存分配。使用内存池预分配缓冲区减少 malloc/free 次数。8.2 批量解析从 TCP 读到的数据可能包含多个 Chunk。与其一个一个解析不如一次性解析出所有完整的 Chunk批量处理。这样可以减少函数调用开销也方便做批量内存操作。8.3 状态表的优化csid 范围是 2~65599如果直接开一个 65599 项的数组内存占用不小每项几十字节的话总共几 MB。实际使用中大部分 csid 不会用到。可以用哈希表或动态数组来优化。8.4 与上层协议的衔接Chunk Stream 解析出来的 Message需要交给上层处理。上层通常是协议控制消息直接处理比如 Set Chunk Size 要更新 parser 的 chunk_size。命令消息解析 AMF0/AMF3处理 connect、publish、play 等命令。音视频消息交给解码器或转发模块。这个衔接层的设计要考虑消息类型的分发效率。可以用函数指针表或 switch-case前者更灵活后者更快。8.5 实际部署中的注意事项在生产环境部署时有几个点需要特别注意chunk size 协商推流端和服务端要能正确处理 Set Chunk Size并且双方的值可以不同各自维护自己的发送 chunk size 和接收 chunk size。超时处理如果某个 csid 的 Message 长时间没收完要有超时机制避免内存泄漏。异常输入对非法的 csid、超大的消息长度、非法的 fmt 要有防御性处理避免崩溃。日志与监控记录关键指标比如每秒处理的 Chunk 数、Message 数、平均 Message 大小便于排查性能问题。我在实际项目中遇到过一个问题某个推流端在弱网环境下会重发数据导致时间戳回退。接收端没有处理回退时间戳计算出负数音视频同步直接崩了。后来加了时间戳回退检测问题解决。这个案例说明协议实现不仅要考虑正常流程还要考虑各种异常情况。9. 一些容易被忽略的边界情况9.1 空消息消息长度为 0 的 Message 是合法的。比如某些控制消息可能没有载荷。接收端收到长度为 0 的消息时应该立即完成重组不需要等待数据。9.2 超大消息消息长度最大 0xFFFFFF约 16MB。如果收到超过这个值的长度字段说明数据有问题应该报错。实际中单个音视频帧很少超过 16MB但如果遇到可能需要分片传输或调整编码参数。9.3 csid 复用同一个 csid 可以先后用于不同的 Message Stream。比如 csid4 先传音频流 A 的数据传完后可以传音频流 B 的数据。接收端靠 Message Stream ID 来区分。但要注意切换流时fmt 的继承状态要重置第一个 Chunk 应该用 fmt0。9.4 交错发送的极端情况理论上发送端可以在一个 Message 的 Chunk 之间插入任意多个其他 csid 的 Chunk。接收端必须能正确处理这种交错。实际中发送端通常会尽量连续发送同一 Message 的 Chunk减少接收端的状态切换但接收端不能假设这一点。9.5 握手与 Chunk Stream 的关系RTMP 握手Handshake是在 Chunk Stream 之前进行的握手数据不走 Chunk 格式。握手完成后才进入 Chunk Stream 阶段。实现时要注意区分这两个阶段不要把握手数据当成 Chunk 解析。握手本身也有不少细节比如 C0/C1/C2、S0/S1/S2 的交互以及简单握手和复杂握手的区别。这部分内容可以单独写一篇这里不展开。10. 我个人在实际操作中的几点体会写了这么多最后分享几个我在实际项目中总结的经验都是踩过坑之后才明白的。第一不要相信任何一方的实现是完美的。RTMP 规范虽然写得比较详细但不同实现之间总有差异。推流端和服务端对接时最好先用 Wireshark 抓包对比确认双方的 Chunk 解析一致。我遇到过一个案例某推流端的 fmt3 扩展时间戳处理有 bug但和某些服务端配合时恰好不出问题换一个服务端就崩了。第二chunk size 的选择要结合场景。不要盲目调大也不要一直用默认的 128。我的经验是互动直播用 1024~4096普通推流用 4096~8192大文件传输用 16384~60000。具体值可以通过压测来确定观察延迟和吞吐的平衡点。第三状态管理是 Chunk Stream 实现的核心。每个 csid 的状态必须独立维护包括时间戳、消息长度、类型、流 ID、扩展时间戳标志。任何一个字段漏掉都可能导致解析错位。建议用结构体把这些状态打包代码可读性和可维护性都会好很多。第四测试要覆盖异常路径。正常流程的测试很容易通过但异常情况才是真正考验实现的地方。比如时间戳回退、消息长度异常、csid 非法、chunk size 为 0 等。我建议专门写一组异常测试用例确保解析器不会崩溃或死循环。第五性能优化要基于数据。不要一上来就做复杂的优化先用简单的实现跑通然后用 profiler 找出瓶颈。大多数情况下瓶颈在内存分配和拷贝而不是解析逻辑本身。用内存池和零拷贝技术通常能带来最大的收益。RTMP 虽然是个老协议但它的 Chunk Stream 设计思路——分块、复用、头部压缩——在现代流媒体协议里仍然能看到影子。理解它不仅对维护现有 RTMP 系统有帮助对理解其他流媒体协议的设计也有启发。如果你正在做音视频相关的工作花点时间把这一层搞透绝对值得。