写点什么

醍醐灌顶学习 RTMP,从总体介绍到各个细节

用户头像
hanaper
关注
发布于: 1 小时前
醍醐灌顶学习RTMP,从总体介绍到各个细节

RTMP 协议是 Real Time Message Protocol(实时信息传输协议)的缩写,它是由 Adobe 公司提出的一种应用层的协议,用来解决多媒体数据传输流的多路复用(Multiplexing)和分包(packetizing)的问题。随着 VR 技术的发展,视频直播等领域逐渐活跃起来,RTMP 作为业内广泛使用的协议也重新被相关开发者重视起来。正好最近在从事这方面的工作,在此记录下自己对 RTMP 的理解,文章内容多翻译自英文版 RTMP 文档,按照本人的理解重新整理,希望可以帮助想要了解 RTMP 协议的朋友,也方面自己日后查阅。

1. 总体介绍:

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,从而实现信息的收发。

推荐一个音视频领域啥都有的 Github repo https://github.com/0voice/audio_video_streaming

2. 握手

要建立一个有效的 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----> |

3. 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=1。

Type = 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=140

3.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。

推荐一个音视频领域啥都有的 Github repo https://github.com/0voice/audio_video_streaming

用户头像

hanaper

关注

还未添加个人签名 2018.05.07 加入

还未添加个人简介

评论

发布
暂无评论
醍醐灌顶学习RTMP,从总体介绍到各个细节