音视频技术傻瓜版解析:带你解锁RTMP

37次阅读

共计 11005 个字符,预计需要花费 28 分钟才能阅读完成。

RTMP 协议是 Real Time Message Protocol(实时信息传输协议)的缩写,它是由 Adobe 公司提出的一种应用层的协议,用来解决多媒体数据传输流的多路复用(Multiplexing)和分包(packetizing)的问题。随着 VR 技术的发展,视频直播等领域逐渐活跃起来,RTMP 作为业内广泛使用的协议也重新被相关开发者重视起来。

总体介绍 RTMP 协议是应用层协议,是要靠底层可靠的传输层协议(通常是 TCP)来保证信息传输的可靠性的。在基于传输层协议的链接建立完成后,RTMP 协议也要客户端和服务器通过“握手”来建立基于传输层链接之上的 RTMP Connection 链接,在 Connection 链接上会传输一些控制信息,如 SetChunkSize,SetACKWindowSize。其中 CreateStream 命令会创建一个 Stream 链接,用于传输具体的音视频数据和控制这些信息传输的命令信息。RTMP 协议传输时会对数据做自己的格式化,这种格式的消息我们称之为 RTMP Message,而实际传输的时候为了更好地实现多路复用、分包和信息的公平性,发送端会把 Message 划分为带有 Message ID 的 Chunk,每个 Chunk 可能是一个单独的 Message,也可能是 Message 的一部分,在接受端会根据 chunk 中包含的 data 的长度,message id 和 message 的长度把 chunk 还原成完整的 Message,从而实现信息的收发。
握手

要建立一个有效的 RTMP Connection 链接,首先要“握手”: 客户端要向服务器发送 C0,C1,C2(按序)三个 chunk,服务器向客户端发送 S0,S1,S2(按序)三个 chunk,然后才能进行有效的信息传输。RTMP 协议本身并没有规定这 6 个 Message 的具体传输顺序,但 RTMP 协议的实现者需要保证这几点:
客户端要等收到 S1 之后才能发送 C2 客户端要等收到 S2 之后才能发送其他信息(控制信息和真实音视频等数据)服务端要等到收到 C0 之后发送 S1 服务端必须等到收到 C1 之后才能发送 S2 服务端必须等到收到 C2 之后才能发送其他信息(控制信息和真实音视频等数据)如果每次发送一个握手 chunk 的话握手顺序会是这样:
理论上来讲只要满足以上条件,如何安排 6 个 Message 的顺序都是可以的,但实际实现中为了在保证握手的身份验证功能的基础上尽量减少通信的次数,一般的发送顺序是这样的,这一点可以通过 wireshark 抓 ffmpeg 推流包进行验证:|client|Server | |---C0+C1—->| |<--S0+S1+S2– | |---C2----> |
RTMP Chunk Stream
Chunk Stream 是对传输 RTMP Chunk 的流的逻辑上的抽象,客户端和服务器之间有关 RTMP 的信息都在这个流上通信。这个流上的操作也是我们关注 RTMP 协议的重点。3.1 Message(消息)这里的 Message 是指满足该协议格式的、可以切分成 Chunk 发送的消息,消息包含的字段如下:Timestamp(时间戳):消息的时间戳(但不一定是当前时间,后面会介绍),4 个字节 Length(长度):是指 Message Payload(消息负载)即音视频等信息的数据的长度,3 个字节 TypeId(类型 Id):消息的类型 Id,1 个字节 Message Stream ID(消息的流 ID):每个消息的唯一标识,划分成 Chunk 和还原 Chunk 为 Message 的时候都是根据这个 ID 来辨识是否是同一个消息的 Chunk 的,4 个字节,并且以小端格式存储 3.2 Chunking(Message 分块)RTMP 在收发数据的时候并不是以 Message 为单位的,而是把 Message 拆分成 Chunk 发送,而且必须在一个 Chunk 发送完成之后才能开始发送下一个 Chunk。每个 Chunk 中带有 MessageID 代表属于哪个 Message,接受端也会按照这个 id 来将 chunk 组装成 Message。为什么 RTMP 要将 Message 拆分成不同的 Chunk 呢?通过拆分,数据量较大的 Message 可以被拆分成较小的“Message”,这样就可以避免优先级低的消息持续发送阻塞优先级高的数据,比如在视频的传输过程中,会包括视频帧,音频帧和 RTMP 控制信息,如果持续发送音频数据或者控制数据的话可能就会造成视频帧的阻塞,然后就会造成看视频时最烦人的卡顿现象。同时对于数据量较小的 Message,可以通过对 Chunk Header 的字段来压缩信息,从而减少信息的传输量。Chunk 的默认大小是 128 字节,在传输过程中,通过一个叫做 Set Chunk Size 的控制信息可以设置 Chunk 数据量的最大值,在发送端和接受端会各自维护一个 Chunk Size,可以分别设置这个值来改变自己这一方发送的 Chunk 的最大大小。大一点的 Chunk 减少了计算每个 chunk 的时间从而减少了 CPU 的占用率,但是它会占用更多的时间在发送上,尤其是在低带宽的网络情况下,很可能会阻塞后面更重要信息的传输。小一点的 Chunk 可以减少这种阻塞问题,但小的 Chunk 会引入过多额外的信息(Chunk 中的 Header),少量多次的传输也可能会造成发送的间断导致不能充分利用高带宽的优势,因此并不适合在高比特率的流中传输。在实际发送时应对要发送的数据用不同的 Chunk Size 去尝试,通过抓包分析等手段得出合适的 Chunk 大小,并且在传输过程中可以根据当前的带宽信息和实际信息的大小动态调整 Chunk 的大小,从而尽量提高 CPU 的利用率并减少信息的阻塞机率。3.3 Chunk Format(块格式)
3.3.1 Basic Header(基本的头信息)包含了 chunk stream ID(流通道 Id)和 chunk type(chunk 的类型),chunk stream id 一般被简写为 CSID,用来唯一标识一个特定的流通道,chunk type 决定了后面 Message Header 的格式。Basic Header 的长度可能是 1,2,或 3 个字节,其中 chunk type 的长度是固定的(占 2 位,注意单位是位,bit),Basic Header 的长度取决于 CSID 的大小, 在足够存储这两个字段的前提下最好用尽量少的字节从而减少由于引入 Header 增加的数据量。RTMP 协议支持用户自定义[3,65599]之间的 CSID,0,1,2 由协议保留表示特殊信息。0 代表 Basic Header 总共要占用 2 个字节,CSID 在[64,319]之间,1 代表占用 3 个字节,CSID 在[64,65599]之间,2 代表该 chunk 是控制信息和一些命令信息,后面会有详细的介绍。chunk type 的长度固定为 2 位,因此 CSID 的长度是(6=8-2)、(14=16-2)、(22=24-2)中的一个。当 Basic Header 为 1 个字节时,CSID 占 6 位,6 位最多可以表示 64 个数,因此这种情况下 CSID 在[0,63]之间,其中用户可自定义的范围为[3,63]。
当 Basic Header 为 2 个字节时,CSID 占 14 位,此时协议将与 chunk type 所在字节的其他位都置为 0,剩下的一个字节来表示 CSID-64,这样共有 8 个字节来存储 CSID,8 位可以表示[0,255]共 256 个数,因此这种情况下 CSID 在[64,319],其中 319=255+64。
当 Basic Header 为 3 个字节时,CSID 占 22 位,此时协议将[2,8]字节置为 1,余下的 16 个字节表示 CSID-64,这样共有 16 个位来存储 CSID,16 位可以表示[0,65535]共 65536 个数,因此这种情况下 CSID 在[64,65599],其中 65599=65535+64,需要注意的是,Basic Header 是采用小端存储的方式,越往后的字节数量级越高,因此通过这 3 个字节每一位的值来计算 CSID 时,应该是:< 第三个字节的值 >x256+< 第二个字节的值 >+64
可以看到 2 个字节和 3 个字节的 Basic Header 所能表示的 CSID 是有交集的[64,319],但实际实现时还是应该秉着最少字节的原则使用 2 个字节的表示方式来表示[64,319]的 CSID。3.3.2 Message Header(消息的头信息)
包含了要发送的实际信息(可能是完整的,也可能是一部分)的描述信息。Message Header 的格式和长度取决于 Basic Header 的 chunk type,共有 4 种不同的格式,由上面所提到的 Basic Header 中的 fmt 字段控制。其中第一种格式可以表示其他三种表示的所有数据,但由于其他三种格式是基于对之前 chunk 的差量化的表示,因此可以更简洁地表示相同的数据,实际使用的时候还是应该采用尽量少的字节表示相同意义的数据。
以下按照字节数从多到少的顺序分别介绍这 4 种格式的 Message Header。Type=0:
type= 0 时 Message Header 占用 11 个字节,其他三种能表示的数据它都能表示,但在 chunk stream 的开始的第一个 chunk 和头信息中的时间戳后退(即值与上一个 chunk 相比减小,通常在回退播放的时候会出现这种情况)的时候必须采用这种格式。timestamp(时间戳):占用 3 个字节,因此它最多能表示到 16777215=0xFFFFFF=2 24-1, 当它的值超过这个最大值时,这三个字节都置为 1,这样实际的 timestamp 会转存到 Extended Timestamp 字段中,接受端在判断 timestamp 字段 24 个位都为 1 时就会去 Extended timestamp 中解析实际的时间戳。message length(消息数据的长度):占用 3 个字节,表示实际发送的消息的数据如音频帧、视频帧等数据的长度,单位是字节。注意这里是 Message 的长度,也就是 chunk 属于的 Message 的总数据长度,而不是 chunk 本身 Data 的数据的长度。message type id(消息的类型 id):占用 1 个字节,表示实际发送的数据的类型,如 8 代表音频数据、9 代表视频数据。msg stream id(消息的流 id):占用 4 个字节,表示该 chunk 所在的流的 ID,和 Basic Header 的 CSID 一样,它采用小端存储的方式,Type = 1:
type= 1 时 Message Header 占用 7 个字节,省去了表示 msg stream id 的 4 个字节,表示此 chunk 和上一次发的 chunk 所在的流相同,如果在发送端只和对端有一个流链接的时候可以尽量去采取这种格式。timestamp delta:占用 3 个字节,注意这里和 type=0 时不同,存储的是和上一个 chunk 的时间差。类似上面提到的 timestamp,当它的值超过 3 个字节所能表示的最大值时,三个字节都置为 1,实际的时间戳差值就会转存到 Extended Timestamp 字段中,接受端在判断 timestamp delta 字段 24 个位都为 1 时就会去 Extended timestamp 中解析时机的与上次时间戳的差值。Type = 2:
type= 2 时 Message Header 占用 3 个字节,相对于 type=1 格式又省去了表示消息长度的 3 个字节和表示消息类型的 1 个字节,表示此 chunk 和上一次发送的 chunk 所在的流、消息的长度和消息的类型都相同。余下的这三个字节表示 timestamp delta,使用同 type=1Type = 3 0 字节!!!好吧,它表示这个 chunk 的 Message Header 和上一个是完全相同的,自然就不用再传输一遍了。当它跟在 Type=0 的 chunk 后面时,表示和前一个 chunk 的时间戳都是相同的。什么时候连时间戳都相同呢?就是一个 Message 拆分成了多个 chunk,这个 chunk 和上一个 chunk 同属于一个 Message。而当它跟在 Type=1 或者 Type=2 的 chunk 后面时,表示和前一个 chunk 的时间戳的差是相同的。比如第一个 chunk 的 Type=0,timestamp=100,第二个 chunk 的 Type=2,timestamp delta=20,表示时间戳为 100+20=120,第三个 chunk 的 Type=3,表示 timestamp delta=20,时间戳为 120+20=1403.3.3 Extended Timestamp(扩展时间戳)上面我们提到在 chunk 中会有时间戳 timestamp 和时间戳差 timestamp delta,并且它们不会同时存在,只有这两者之一大于 3 个字节能表示的最大数值 0xFFFFFF=16777215 时,才会用这个字段来表示真正的时间戳,否则这个字段为 0。扩展时间戳占 4 个字节,能表示的最大数值就是 0xFFFFFFFF=4294967295。当扩展时间戳启用时,timestamp 字段或者 timestamp delta 要全置为 1,表示应该去扩展时间戳字段来提取真正的时间戳或者时间戳差。注意扩展时间戳存储的是完整值,而不是减去时间戳或者时间戳差的值。
3.3.4 Chunk Data(块数据)
用户层面上真正想要发送的与协议无关的数据,长度在 (0,chunkSize] 之间。
3.3.5 chunk 表示例 1
首先包含第一个 Message 的 chunk 的 Chunk Type 为 0,因为它没有前面可参考的 chunk,timestamp 为 1000,表示时间戳。type 为 0 的 header 占用 11 个字节,假定 chunkstreamId 为 3 <127,因此 Basic Header 占用 1 个字节,再加上 Data 的 32 个字节,因此第一个 chunk 共 44=11+1+32 个字节。第二个 chunk 和第一个 chunk 的 CSID,TypeId,Data 的长度都相同,因此采用 Chunk Type=2,timestamp delta=1020-1000=20,因此第二个 chunk 占用 36=3+1+32 个字节。第三个 chunk 和第二个 chunk 的 CSID,TypeId,Data 的长度和时间戳差都相同,因此采用 Chunk Type=3 省去全部 Message Header 的信息,占用 33=1+32 个字节。第四个 chunk 和第三个 chunk 情况相同,也占用 33=1+32 个字节。最后实际发送的 chunk 如下:
3.3.6 chunk 表示例 2
注意到 Data 的 Length=307>128, 因此这个 Message 要切分成几个 chunk 发送,第一个 chunk 的 Type=0,Timestamp=1000,承担 128 个字节的 Data,因此共占用 140=11+1+128 个字节。第二个 chunk 也要发送 128 个字节,其他字段也同第一个 chunk,因此采用 Chunk Type=3,此时时间戳也为 1000,共占用 129=1+128 个字节。第三个 chunk 要发送的 Data 的长度为 307-128-128=51 个字节,还是采用 Type=3,共占用 1 +51=52 个字节。最后实际发送的 chunk 如下:
3.4 协议控制消息(Protocol Control Message)在 RTMP 的 chunk 流会用一些特殊的值来代表协议的控制消息,它们的 Message Stream ID 必须为 0(代表控制流信息),CSID 必须为 2,Message Type ID 可以为 1,2,3,5,6,具体代表的消息会在下面依次说明。控制消息的接受端会忽略掉 chunk 中的时间戳,收到后立即生效。
Set Chunk Size(Message Type ID=1): 设置 chunk 中 Data 字段所能承载的最大字节数,默认为 128B,通信过程中可以通过发送该消息来设置 chunk Size 的大小(不得小于 128B),而且通信双方会各自维护一个 chunkSize,两端的 chunkSize 是独立的。比如当 A 想向 B 发送一个 200B 的 Message,但默认的 chunkSize 是 128B,因此就要将该消息拆分为 Data 分别为 128B 和 72B 的两个 chunk 发送,如果此时先发送一个设置 chunkSize 为 256B 的消息,再发送 Data 为 200B 的 chunk,本地不再划分 Message,B 接受到 Set Chunk Size 的协议控制消息时会调整的接受的 chunk 的 Data 的大小,也不用再将两个 chunk 组成为一个 Message。以下为代表 Set Chunk Size 消息的 chunk 的 Data:
其中第一位必须为 0,chunk Size 占 31 个位,最大可代表 2147483647=0x7FFFFFFF=231-1,但实际上所有大于 16777215=0xFFFFFF 的值都用不上,因为 chunk size 不能大于 Message 的长度,表示 Message 的长度字段是用 3 个字节表示的,最大只能为 0xFFFFFF。Abort Message(Message Type ID=2): 当一个 Message 被切分为多个 chunk,接受端只接收到了部分 chunk 时,发送该控制消息表示发送端不再传输同 Message 的 chunk,接受端接收到这个消息后要丢弃这些不完整的 chunk。Data 数据中只需要一个 CSID,表示丢弃该 CSID 的所有已接收到的 chunk。
Acknowledgement(Message Type ID=3): 当收到对端的消息大小等于窗口大小(Window Size)时接受端要回馈一个 ACK 给发送端告知对方可以继续发送数据。窗口大小就是指收到接受端返回的 ACK 前最多可以发送的字节数量,返回的 ACK 中会带有从发送上一个 ACK 后接收到的字节数。
Window Acknowledgement Size(Message Type ID=5): 发送端在接收到接受端返回的两个 ACK 间最多可以发送的字节数。
Set Peer Bandwidth(Message Type ID=6): 限制对端的输出带宽。接受端接收到该消息后会通过设置消息中的 Window ACK Size 来限制已发送但未接受到反馈的消息的大小来限制发送端的发送带宽。如果消息中的 Window ACK Size 与上一次发送给发送端的 size 不同的话要回馈一个 Window Acknowledgement Size 的控制消息。
Hard(Limit Type=0): 接受端应该将 Window Ack Size 设置为消息中的值 Soft(Limit Type=1): 接受端可以讲 Window Ack Size 设为消息中的值,也可以保存原来的值(前提是原来的 Size 小与该控制消息中的 Window Ack Size)Dynamic(Limit Type=2): 如果上次的 Set Peer Bandwidth 消息中的 Limit Type 为 0,本次也按 Hard 处理,否则忽略本消息,不去设置 Window Ack Size。
不同类型的 RTMP Message – Command Message(命令消息,Message Type ID=17 或 20)
表示在客户端盒服务器间传递的在对端执行某些操作的命令消息,如 connect 表示连接对端,对端如果同意连接的话会记录发送端信息并返回连接成功消息,publish 表示开始向对方推流,接受端接到命令后准备好接受对端发送的流信息,后面会对比较常见的 Command Message 具体介绍。当信息使用 AMF0 编码时,Message Type ID=20,AMF3 编码时 Message Type ID=17. – Data Message(数据消息,Message Type ID=15 或 18):传递一些元数据(MetaData,比如视频名,分辨率等等)或者用户自定义的一些消息。当信息使用 AMF0 编码时,Message Type ID=18,AMF3 编码时 Message Type ID=15. – Shared Object Message(共享消息,Message Type ID=16 或 19):表示一个 Flash 类型的对象,由键值对的集合组成,用于多客户端,多实例时使用。当信息使用 AMF0 编码时,Message Type ID=19,AMF3 编码时 Message Type ID=16. – Audio Message(音频信息,Message Type ID=8):音频数据。– Video Message(视频信息,Message Type ID=9):视频数据。– Aggregate Message (聚集信息,Message Type ID=22):多个 RTMP 子消息的集合 – User Control Message Events(用户控制消息,Message Type ID=4): 告知对方执行该信息中包含的用户控制事件,比如 Stream Begin 事件告知对方流信息开始传输。和前面提到的协议控制信息(Protocol Control Message)不同,这是在 RTMP 协议层的,而不是在 RTMP chunk 流协议层的,这个很容易弄混。该信息在 chunk 流中发送时,Message Stream ID=0,Chunk Stream Id=2,Message Type Id=4。
———下面对以上 7 种信息具体介绍———-
4.1 Command Message(命令消息,Message Type ID=17 或 20) 发送端发送时会带有命令的名字,如 connect,TransactionID 表示此次命令的标识,Command Object 表示相关参数。接受端收到命令后,会返回以下三种消息中的一种:_result 消息表示接受该命令,对端可以继续往下执行流程,_error 消息代表拒绝该命令要执行的操作,method name 消息代表要在之前命令的发送端执行的函数名称。这三种回应的消息都要带有收到的命令消息中的 TransactionId 来表示本次的回应作用于哪个命令。可以认为发送命令消息的对象有两种,一种是 NetConnection,表示双端的上层连接,一种是 NetStream,表示流信息的传输通道,控制流信息的状态,如 Play 播放流,Pause 暂停。
4.1.1 NetConnection Commands(连接层的命令)
用来管理双端之间的连接状态,同时也提供了异步远程方法调用(RPC)在对端执行某方法,以下是常见的连接层的命令:
4.1.1.1 connect: 用于客户端向服务器发送连接请求,消息的结构如下:
第三个字段中的 Command Object 中会涉及到很多键值对,这里不再一一列出,使用时可以参考协议的官方文档。消息的回应有两种,_result 表示接受连接,_error 表示连接失败 4.1.1.2 Call: 用于在对端执行某函数,即常说的 RPC:远程进程调用,消息的结构如下:
如果消息中的 TransactionID 不为 0 的话,对端需要对该命令做出响应,响应的消息结构如下:
4.1.1.3 Create Stream:创建传递具体信息的通道,从而可以在这个流中传递具体信息,传输信息单元为 Chunk。
4.1.2 NetStream Commands(流连接上的命令)Netstream 建立在 NetConnection 之上,通过 NetConnection 的 createStream 命令创建,用于传输具体的音频、视频等信息。在传输层协议之上只能连接一个 NetConnection,但一个 NetConnection 可以建立多个 NetStream 来建立不同的流通道传输数据。以下会列出一些常用的 NetStream Commands,服务端收到命令后会通过 onStatus 的命令来响应客户端,表示当前 NetStream 的状态。onStatus 命令的消息结构如下:
用于客户端向服务器发送连接请求,消息的结构如下:如果 Pause 为 true 即表示客户端请求暂停的话,服务端暂停对应的流会返回 NetStream.Pause.Notify 的 onStatus 命令来告知客户端当前流处于暂停的状态,当 Pause 为 false 时,服务端会返回 NetStream.Unpause.Notify 的命令来告知客户端当前流恢复。如果服务端对该命令响应失败,返回_error 信息。4.1.2.1 play(播放): 由客户端向服务器发起请求从服务器端接受数据(如果传输的信息是视频的话就是请求开始播流),可以多次调用,这样本地就会形成一组数据流的接收者。注意其中有一个 reset 字段,表示是覆盖之前的播流(设为 true)还是重新开始一路播放(设为 false)。4.1.2.2 play2(播放):和上面的 play 命令不同的是,play2 命令可以将当前正在播放的流切换到同样数据但不同比特率的流上,服务器端会维护多种比特率的文件来供客户端使用 play2 命令来切换。
4.1.2.3 deleteStream(删除流):用于客户端告知服务器端本地的某个流对象已被删除,不需要再传输此路流。
4.1.2.4 receiveAudio(接收音频):通知服务器端该客户端是否要发送音频 receiveAudio 命令结构如下:
4.1.2.5 receiveVideo(接收视频):通知服务器端该客户端是否要发送视频 receiveVideo 命令结构如下:
4.1.2.6 publish(推送数据):由客户端向服务器发起请求推流到服务器。publish 命令结构如下:
4.1.2.7 seek(定位流的位置):定位到视频或音频的某个位置,以毫秒为单位。seek 命令的结构如下:
4.1.2.8 pause(暂停):客户端告知服务端停止或恢复播放。pause 命令的结构如下:
如果 Pause 为 true 即表示客户端请求暂停的话,服务端暂停对应的流会返回 NetStream.Pause.Notify 的 onStatus 命令来告知客户端当前流处于暂停的状态,当 Pause 为 false 时,服务端会返回 NetStream.Unpause.Notify 的命令来告知客户端当前流恢复。如果服务端对该命令响应失败,返回_error 信息。
代表流程
5.1 推流流程
5.2 播流流程
新手建议
如果仔细读完了上面讲的 RTMP 协议,想必会觉得 RTMP 协议非常繁琐,事实也确实是这样,RTMP 协议中充斥着很多冗余的字段,比如三次握手中的时间戳的校对,还有一些特殊的命令,如 FCPublish、UnFCPublish 等,但在实际实现中为了保证更大兼容性通常还是要处理这些看似多余的命令。加上 Adobe 对 RTMP 协议的实现细节有些并没有按照协议来或者协议中没有写清楚自己搞了一套实现,其他应用开发时还要兼容 Adobe 错误的实现,从而使的 RTMP 也一直为开发者所诟病。但不管怎样,RTMP 确实提供了一种能够全面并且实现简单的协议来保证流信息的传输,这方面暂时还没有一种更完善更简洁的协议能够取代它在视频流开发中的地位。新人一开始接触 RTMP 的时候肯定会觉得头大,这也是 RTMP 协议不简洁的后果。建议读者在学习时先过一遍协议理解大概的概念和流程,然后对照 wireshark 抓的包,和协议进行比对,这样将理论和实践结合,应该会理解的更快一点。
——————— 本文来自 会敲代码的咩 的 CSDN 博客
网易云信,你身边的即时通讯和音视频技术专家,了解我们,请戳网易云信官网想要阅读更多行业洞察和技术干货,请关注网易云信博客

正文完
 0