rtmp 协议

参考:

1. 概述

本篇文章主要根据学习的相关文档和对 rtmp 的具体实现,记录自己的相关理解。

2. 握手

rtmp 握手消息分为普通握手(simple handshake) 和摘要握手(complex/digest handshake)。
rtmp 是基于 tcp 的应用层协议,rtmp handshake 之前,需要先建立 tcp 连接。
rtmp 握手过程如下:

+-------------+                           +-------------+
|    Client   |       TCP/IP Network      |    Server   |
+-------------+            |              +-------------+
      |                    |                     |
 Uninitialized             |               Uninitialized
      |          C0        |                     |
      |------------------->|         C0          |
      |                    |-------------------->|
      |          C1        |                     |
      |------------------->|         S0          |
      |                    |<--------------------|
      |                    |         S1          |
 Version sent              |<--------------------|
      |          S0        |                     |
      |<-------------------|                     |
      |          S1        |                     |
      |<-------------------|                Version sent
      |                    |         C1          |
      |                    |-------------------->|
      |          C2        |                     |
      |------------------->|         S2          |
      |                    |<--------------------|
   Ack sent                |                  Ack Sent
      |          S2        |                     |
      |<-------------------|                     |
      |                    |         C2          |
      |                    |-------------------->|
 Handshake Done            |               Handshake Done
      |                    |                     |

握手由 client 开始,client 会发送 C0、C1、C2 消息,server 会发送 S0、S1、S2 消息。
握手分为 4 个阶段:

  • Uninitialized 阶段,这时 tcp 连接已经建立,但是 client 还没有发起握手
  • Version sent 阶段,对于 client 来说,发出了 C0、C1;对于 server 来说,发出了 S0、S1
  • Ack sent 阶段,对于 client 来说,发出了 C2;对于 server 来说,发出了 S2
  • Handshake Done 阶段,所有握手包都成功接收

这几个阶段中的握手包,有如下时序限制:

  • 客户端通过发送 C0 和 C1 消息来启动握手过程
  • 客户端必须接收到 S1 消息,然后发送 C2 消息
  • 客户端必须接收到 S2 消息,然后发送其他数据
  • 服务端必须接收到 C0 或者 C1 消息,然后发送 S0 和 S1 消息
  • 服务端必须接收到 C2 消息,然后发送其他数据

2.1 simple handshake

普通握手可以看作是随机数据的简单回传(echo)

2.1.1 C0 和 S0 格式

C0 和 S0 由一个字节组成:

 0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+
|    version    |
+-+-+-+-+-+-+-+-+
  • version(1 bytes),目前固定为 3。

2.1.2 C1 和 S1 格式

C1 和 S1 长度为 1536 字节:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        time (4 bytes)                         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        zero (4 bytes)                         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         random bytes                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         random bytes                          |
|                           (cont)                              |
|                             ...                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • time(4 bytes),可以为任意值
  • zero(4 bytes),必须置 0
  • random bytes(1528 bytes),随机值

2.1.3 C2 和 S2 格式

C2 和 S2 长度为 1536 字节,作为 C1 和 S1 的回应:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        time (4 bytes)                         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        time2 (4 bytes)                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          random echo                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          random echo                          |
|                            (cont)                             |
|                              ...                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • time(4 bytes),对于 C2,复制自 S1 的 time 字段;对于 S2,复制自 C1 的 time 字段
  • time2(4 bytes),对于 C2,复制自 C1 的 time 字段;对于 S2,复制自 S1 的 time 字段
  • random echo(1528 bytes),对于 C2,复制自 S1 的 random 字段;对于 S2,复制自 C1 的 random 字段

2.2 摘要握手

simple handshake 在 rtmp 标准文档中有详细描述(https://rtmp.veriskope.com/docs/spec),而摘要握手较为复杂,也没有找到任何标准文档对其进行描述。
rtmp 有描述 rtmpe(参考 https://www.cs.cmu.edu/~dst/Adobe/Gallery/RTMPE.txt),rtmpe 主要是为了对称加密服务的,即通过 Diffie-Hellman 算法协商出对称加密密钥,rtmpe 不需要证书验证。
但是 rtmpe 的过程和握手消息格式与这里的摘要握手有很大的不同。

2.2.1 challenge-response

在 http digest 认证过程中,client 通过 challenge 响应,将用户密码 hash 后传递给 server。因为用户密码只有 clent 和 server 端知道,所以 server 认证过程是安全可靠的:

  client                              server
                http request
    -------------------------------------> 
       http 401 Unauthorized (with nonce)
    <-------------------------------------
      http request (digest with password)
    -------------------------------------> 
                http 200ok
    <-------------------------------------

摘要握手中,其中 C1S1 可以称为 challenge,C2S2 称为 response。
在 rtmp 摘要握手中,可以看作是模拟挑战认证的过程,且不同于 http,双方都需要进行认证:

  • 其中 C1S1 携带的 digest 可以看作 nonce
  • C2S2 携带的 digest 可以看作 response
  • client 端的认证密码为固定公开的密码:
u_int8_t FPKey[] = {
    0x47, 0x65, 0x6E, 0x75, 0x69, 0x6E, 0x65, 0x20,
    0x41, 0x64, 0x6F, 0x62, 0x65, 0x20, 0x46, 0x6C,
    0x61, 0x73, 0x68, 0x20, 0x50, 0x6C, 0x61, 0x79,
    0x65, 0x72, 0x20, 0x30, 0x30, 0x31, // Genuine Adobe Flash Player 001
    0xF0, 0xEE, 0xC2, 0x4A, 0x80, 0x68, 0xBE, 0xE8,
    0x2E, 0x00, 0xD0, 0xD1, 0x02, 0x9E, 0x7E, 0x57,
    0x6E, 0xEC, 0x5D, 0x2D, 0x29, 0x80, 0x6F, 0xAB,
    0x93, 0xB8, 0xE6, 0x36, 0xCF, 0xEB, 0x31, 0xAE
}; // 62
  • server 端的认证密码为固定公开的密码:
u_int8_t FMSKey[] = {
    0x47, 0x65, 0x6e, 0x75, 0x69, 0x6e, 0x65, 0x20,
    0x41, 0x64, 0x6f, 0x62, 0x65, 0x20, 0x46, 0x6c,
    0x61, 0x73, 0x68, 0x20, 0x4d, 0x65, 0x64, 0x69,
    0x61, 0x20, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
    0x20, 0x30, 0x30, 0x31, // Genuine Adobe Flash Media Server 001
    0xf0, 0xee, 0xc2, 0x4a, 0x80, 0x68, 0xbe, 0xe8,
    0x2e, 0x00, 0xd0, 0xd1, 0x02, 0x9e, 0x7e, 0x57,
    0x6e, 0xec, 0x5d, 0x2d, 0x29, 0x80, 0x6f, 0xab,
    0x93, 0xb8, 0xe6, 0x36, 0xcf, 0xeb, 0x31, 0xae
}; // 68

注意到两端的认证密码都是固定公开的密码,此密码不用做鉴权功能(如果需要鉴权功能,可以在业务侧通过 rtmp url 携带自定义的 key-value 实现)。

2.2.2 C0 和 S0 格式

C0 和 S0 由一个字节组成,与 simple handshake 一样:

 0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+
|    version    |
+-+-+-+-+-+-+-+-+
  • version(1 bytes),目前固定为 3。

2.2.3 C1 和 S1 格式

C1 和 S1 长度为 1536 字节:

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                        time (4 bytes)                         |
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                     version (4 bytes)                         |
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                         key (764 bytes)                       |
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                      digest (764 bytes)                       |
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • time(4 bytes),可以为任意值
  • version(4 bytes),此 version 表示 flash 版本,例如,0x09 0x00 0x7c 0x02 表示 9.0.124.2 版本
  • key(764 bytes),生成 digest 的密钥,注意,key 可以与下面的 digest 交换位置
  • digest(764 bytes),通过 key 生成的摘要,注意,digest 可以与上面的 key 交换位置

key 占用 764 字节,其中真正的密钥占用 128 字节,剩下为 random data + offer,结构如下:

  • random-data: (offset) bytes
  • key-data: 128 bytes,实际上此字段可以为随机值,digest 校验时没有用到此值
  • random-data: (764 - offset - 128 - 4) bytes
  • offset: 4 bytes

digest 占用 764 字节,其中真正的摘要占用 32 字节,剩下为 offer + random data,结构如下:

  • offset: 4 bytes
  • random-data: (offset) bytes
  • digest-data: 32 bytes
  • random-data: (764 - 4 - offset - 32) bytes

2.2.4 C1 生成 digest

在 C1 1536 bytes 的长度中,根据 32 bytes 的 digest,将 C1 分为:

| n Bytes part-1 | 32 Bytes digest | (1536-part1-digest) Bytes part-2 |

这里将除开 digest 后的部分称为 C1-joined:

C1-joined = bytes_join(C1-part1, C1-part2)

那么生成 C1 digest 的方法为:

digest-data = HMACsha256(C1-joined, FPKey)

server 端在收到 C1 后,需要依次尝试 key 和 digest 的两种相对顺序进行解析,得到 key-data 和 digest-data。
key-data 没有用处,digest-data 需要按照 client 生成 C1 digest 的方法,也生成一个进行比较,如果比较失败,则尝试使用 simple handshake。
srs 中关于 C1 digest 的生成可以参考文档:https://blog.csdn.net/win_lin/article/details/13006803

2.2.5 C2 生成 digest

生成方法与 C1 一致,只是 hmac 的 key 变成 FMSKey。
在 srs 中关于 C2 digest 的生成文档中(https://blog.csdn.net/win_lin/article/details/13006803),key-data 使用了 Diffie-Hellman 算法生成,而不是一个随机值。实际上个人理解,使用随机值也是可以的,因为 key-data 没有被用到过。

2.2.6 C2 和 S2 格式

C2 和 S2 长度为 1536 字节,作为 C1 和 S1 的回应:

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                   random-data (1504 bytes)                    |
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                   digest-data (32 bytes)                      |
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • random-data(1504 bytes),随机值
  • digest-data(32 bytes),摘要

2.2.7 C2 生成 digest

C2 生成 digest 需要先解析 S1 的 digest,srs 中介绍如下(https://blog.csdn.net/win_lin/article/details/13006803):

temp-key = HMACsha256(s1-digest, FPKey)
c2-digest-data = HMACsha256(c2-random-data, temp-key)

server 收到 C2 后需要用同样的 hmac 计算方式进行 digest 校验。

2.2.7 S2 生成 digest

与 C2 生成 digest 相同。

3. chunk stream

rtmp 通过 chunk 技术将上层的 rtmp message 分割成多个 chunks,在网络状况较差的时候,可以优先传递优先级高的 chunk message,而不是等待前一个 message 全部传递完成。
chunk 的另一个好处是,不同 rtmp message 的头部格式是同一的,便于程序的识别和解析。
但是在实际实现中,一般都是一个 rtmp message chunk 化后一次性发完,没有根据优先级选择性发送的设计。

3.1 chunk format

如下:

+--------------+----------------+--------------------+--------------+
| Basic Header | Message Header | Extended Timestamp |  Chunk Data  |
+--------------+----------------+--------------------+--------------+
|                                                    |
|<------------------- Chunk Header ----------------->|

其中,Basic Header(1-3 Bytes),Message Header(0,3,7,11 Bytes),Extended Timestamp(0,4 Bytes),Chunk Data(可变大小)。

3.2 basic header

basic header 包含:

  • chunk type(fmt),取值为 0,1,2,3。会决定后面 message header 的格式
  • chunk stream ID(csid),取值在 [0, 65599] 之间,其中,0,1,2 是特定数值,其余可以自由取值。

csid 主要有两个作用:

  • 用于标识一个 message 的不同 chunks
  • 当 chunk fmt!=0 时,解码 chunk header 依赖于前面 fmt=0 的 chunk,那么即通过相同的 csid 来找到参考的 fmt=0 的 chunk

basic header 根据 csid 的长度也有不同格式。

3.2.1 1 byte

csid 可表示范围 [0, 63]

     0 1 2 3 4 5 6 7
    +-+-+-+-+-+-+-+-+
    |fmt|   cs id   |
    +-+-+-+-+-+-+-+-+

3.2.2 2 byte

csid 可表示范围 [64, 319]

     0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |fmt|    0      |  cs id - 64   |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

3.2.3 3 byte

csid 可表示范围 [64,65599]

     0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |fmt|    1      |          cs id - 64           |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

3.3 message header

根据前面 basic header 中 fmt 取值的不同,message header 分为 4 种格式。

3.3.1 fmt=0

message header 占用 11 Bytes。

     0               1               2               3
     0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |                    timestamp                  |message length |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |    message length (coutinue)  |message type id| msg stream id |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |                  msg stream id                |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • timestamp(3 Bytes),最大表示 0xFFFFFF-1,当时间戳大于此值时,此字段置 0xFFFFFF,真正的时间戳由 message header 后面的 Extended Timestamp 字段来表示
  • message length(3 Bytes),本 chunk 所在的 message 长度(注意不是本 chunk data 的长度),一个 message 的长度等于多个 chunks body 的长度和
  • message type id(1 Bytes),消息类型
  • msg stream id(4 Bytes),消息所在的 stream id

fmt=0 的 message header 格式是 full format,即 message header 的所有信息都能表示出来。

3.3.2 fmt=1

message header 占用 7 Bytes。

     0               1               2               3
     0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |               timestamp delta                 |message length |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |    message length (coutinue)  |message type id|
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

这里相比于 fmt=0,省去了 msg stream id,即表示此 chunk 与上一个关联的 chunk 所在同一路流。

  • timestamp delta(3 Bytes),这里和 fmt=0 时不同,存储的是和上一个关联的 chunk 的时间差

3.3.3 fmt=2

message header 占用 3 Bytes。

     0               1               2               
     0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |               timestamp delta                 |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

这里相比于 fmt=1,省去了 msg length 和 message type id。

3.3.4 fmt=3

message header 占用 0 Bytes。

4. message decode(消息解码)

除开 handshake message,其余消息都是 chunk 化的,解码需要根据 chunk 的格式进行解码。

4.1 不拆分 message 的 chunk

有 4 个 audio message 如下(https://rtmp.veriskope.com/docs/spec):

+---------+-----------------+-----------------+-----------------+
|         |Message Stream ID| Message TYpe ID | Time  | Length  |
+---------+-----------------+-----------------+-------+---------+
| Msg # 1 |    12345        |         8       | 1000  |   32    |
+---------+-----------------+-----------------+-------+---------+
| Msg # 2 |    12345        |         8       | 1020  |   32    |
+---------+-----------------+-----------------+-------+---------+
| Msg # 3 |    12345        |         8       | 1040  |   32    |
+---------+-----------------+-----------------+-------+---------+
| Msg # 4 |    12345        |         8       | 1060  |   32    |
+---------+-----------------+-----------------+-------+---------+
Sample audio messages to be made into chunks

这 4 个 audio message 所在的 stream id 相同,类型都是 audio,且时间戳是固定增量,可以进行如下 chunk 化:

+--------+---------+-----+------------+-----------+------------+
|        | Chunk   |Chunk|Header Data |No.of Bytes|Total No.of |
|        |Stream ID|Type |            |  After    |Bytes in the|
|        |         |     |            |Header     |Chunk       |
+--------+---------+-----+------------+-----------+------------+
|Chunk#1 |    3    |  0  | ts: 1000,  |   32      |    44      |
|        |         |     | length: 32,|           |            |
|        |         |     | type: 8,   |           |            |
|        |         |     | stream ID: |           |            |
|        |         |     | 12345      |           |            |
|        |         |     | (11bytes)  |           |            |
+--------+---------+-----+------------+-----------+------------+
|Chunk#2 |    3    |  2  | delta: 20  |   32      |    36      |
|        |         |     | (3bytes)   |           |            |
+--------+---------+-----+------------+-----------+------------+
|Chunk#3 |    3    |  3  | none       |   32      |    33      |
|        |         |     | (0bytes)   |           |            |
+--------+---------+-----+------------+-----------+------------+
|Chunk#4 |    3    |  3  | none       |   32      |    33      |
|        |         |     | (0bytes)   |           |            |
+--------+---------+-----+------------+-----------+------------+
Format of each of the chunks of audio messages

其中:

  • Chunk#1 表示的 Msg#1,使用的是 fmt=0 的格式,即 full format,后面的 chunk 以此为基础进行 message header 还原
  • Chunk#2 表示的 Msg#2,使用的是 fmt=2 的格式。本 message 通过上一个 message(Chunk#1) 还原出除开 ts 的所有 message header。同时根据指定的 ts delta,还原出 ts
  • Chunk#3 表示的 Msg#3,使用的是 fmt=3 的格式,本 message 通过上一个 message(Chunk#2) 还原出所有 message header(ts = previous ts + ts delta)
  • Chunk#4 表示的 Msg#4,使用的是 fmt=3 的格式,本 message 通过上一个 message(Chunk#3) 还原出所有 message header(ts = previous ts + ts delta)

4.2 拆分 message 的 chunk

有 1 个 video message 如下(https://rtmp.veriskope.com/docs/spec):

+-----------+-------------------+-----------------+-----------------+
|           | Message Stream ID | Message TYpe ID | Time  | Length  |
+-----------+-------------------+-----------------+-----------------+
| Msg # 1   |       12346       |    9 (video)    | 1000  |   307   |
+-----------+-------------------+-----------------+-----------------+

假设本端的 chunk size 被设置为 128 Bytes,那么此 message 将会被如下 chunk 化:

+-------+------+-----+-------------+-----------+------------+
|       |Chunk |Chunk|Header       |No. of     |Total No. of|
|       |Stream| Type|Data         |Bytes after| bytes in   |
|       | ID   |     |             | Header    | the chunk  |
+-------+------+-----+-------------+-----------+------------+
|Chunk#1|  4   |  0  | delta: 1000 |  128      |   140      |
|       |      |     | length: 307 |           |            |
|       |      |     | type: 9,    |           |            |
|       |      |     | stream ID:  |           |            |
|       |      |     | 12346 (11   |           |            |
|       |      |     | bytes)      |           |            |       
+-------+------+-----+-------------+-----------+------------+
|Chunk#2|  4   |  3  | none (0     |  128      |   129      |
|       |      |     | bytes)      |           |            |
+-------+------+-----+-------------+-----------+------------+
|Chunk#3|  4   |  3  | none (0     |  51       |   52       |
|       |      |     | bytes)      |           |            |
+-------+------+-----+-------------+-----------+------------+
Format of each of the chunks

其中:

  • Chunk#1 表示的 Msg#1 第一个 chunk,使用的是 fmt=0 的格式,即 full format,后面的 chunk 以此为基础进行 message header 还原
  • 通过 Chunk#1 的 length 字段,继续从 Chunk#2 和 Chunk#3 读取 min(128, 307-i*128) 个字节,既可以解码出整个 rtmp message

4.3 message 还原

rtmp message 的还原主要依赖两个参数,一个是 chunk csid,另一个是对端的 rtmp chunk size。

  • csid,用于 rtmp header 还原:
    当 chunk fmt=0 时,可以称作 fresh chunk,对于所有后续 fmt>0 的,且 message stream id 和 chunk type 与 fresh chunk 相同的 chunk,其解码实际上都依赖于 fresh chunk。
    后续 chunk 如何找到参考的 fresh chunk 呢?答案是根据 csid。即将 fmt=0 时的 csid 与 chunk 建立映射关系,后续 chunk 就可以根据 csid 找到参考的 fresh chunk 了。
    参考上面的两则示例,不同 rtmp message 的 csid 都是相同的。实际上对于同一个 stream 和同一 type 的消息(例如 video、audio),csid 必须是相同的,不然后续 chunk 就会参考到别的 fresh chunk。
    可以看到,csid 本意是为了标识一个 rtmp message 被拆分成的多个 chunks,但是由于有不同 fmt 格式的存在,csid 也用作了一系列 rtmp message header 的还原。

  • rtmp chunk size,用于 rtmp message 还原:
    由于 chunk 的 message length 字段表示的并不是 chunk 的大小,所以从 tcp 中读取完整的 chunk 就依赖于 rtmp chunk size,rtmp chunk size 实际上扮演了表示 chunk 大小的角色。
    如果 rtmp message 没有被拆分为 chunks,那么 chunk 的 message length 必定小于或等于 rtmp chunk size。
    如果 rtmp message 有被拆分为 chunks,那么 chunk 的 message length 也必定等于或等于 rtmp chunk size(最后一个 chunk 小于 rtmp chunk size)。
    所以读取的时候,一次读取 min(chunk message length, rtmp chunk size) 字节大小数据,一定能读取完一个 chunk。
    然后如果累计读取数据等于 chunk message length,即表明读取完了一个完整的 rtmp message。

伪代码如下(参考 srs::srs_rtmp_stack.c):

struct RtmpChunk {
  RtmpHeader header;
  // rtmp message 对象
  RtmpMessage* msg = nullptr;
};
// chunk 缓存, 用于解析 fmt > 0 的 chunk header
map<int, RtmpChunk> csid_cache;
// 本端 chunk size, chunk body 最大大小
int rtmp_chunk_size = 128;

void on_chunk(char* data) {
  int fmt = get_fmt(data);
  int csid = get_csid(data);

  //
  // 得到/新建 chunk 缓存
  //
  RtmpChunk* chunk = nullptr;
  if (!csid_cache.count(csid)) {
    chunk = new RtmpChunk();
    csid_cache[csid] = chunk;
  } else {
    chunk = csid_cache[csid];
  }

  //
  // 解析得到每个 RtmpChunk::header(不论这个 chunk 是否是 rtmp message 的一部分)
  //
  if (fmt == 0) {
    // fresh chunk 应该是一个 rtmp message 的起始 chunk
    if (chunk->msg != nullptr) {
      // return error
    }
    // 创建一个新的 rtmp message
    if (chunk->msg) {
      chunk->msg = new RtmpMessage;
    }

    // 解析 fresh chunk, 填充 RtmpChunk::header
    decode_fresh_chunk_header(data, chunk);
  } else {
    // 根据上一个 RtmpChunk::header 还原本 chunk::header
    restore_chunk_header(fmt, data, chunk);
  }

  // 一次最多从网络读取 read_len 字节的数据
  int read_len = std::min(chunk->header.size, rtmp_chunk_size);
  char* body = socket_read(read_len);

  // 将 message body 追加到 RtmpMessage 对象
  append_message(chunk->msg, body, read_len);

  // 接收到了一个完整的 rtmp message
  if (chunk->msg.size() == chunk->header.size) {
    // return chunk->msg
    // 复位 msg, 准备接收下一个 message
    chunk->msg = nullptr;
  }
}

5. rtmp 消息流程

5.1 消息类型

rtmp 消息看起来类型很复杂,种类很多,格式也不一样,而且多种消息类型会交叉组合成一组来完成某次请求与响应,不同消息类型对于 csid 和 stream id 的规定也不一样,有的消息有响应,有的没有,容易让人迷惑。
下面对几类消息进行简单讨论。

5.1.1 handshake message

此类消息不能 chunk 化(其它消息都能 chunk 化),整体看起来类似 magic number,具体已在上文描述。

5.1.2 protocol control message

此类消息能 chunk 化,message type 为 1、2、3、5、6 其中之一,chunk stream id 必须为 2,message stream id 必须为 0,时间戳被忽略。
此类消息常与 command message 组合使用。
此类消息不存在事务,没有事务响应。

5.1.3 user control message

主要用于信息通知相关,message type 为 4,chunk stream id 必须为 2,message stream id 必须为 0,时间戳被忽略。
此类消息常与 command message 组合使用。
此类消息不存在事务,没有事务响应。

5.1.4 command message

主要用于音视频播放相关,message type 为 20(AMF0 编码) 或 17(AMF3 编码)。chunk stream id 不固定,message stream id 一般为 0。
command message 有两个大类,一个是 NetConnection 消息族,另一个是 NetStream 消息族。

  • NetConnection 消息族:
    有 connect、call、close、createStream 这几种具体消息类型。
    NetConnection 消息族消息是携带事务的,即拥有 transcation id,必须拥有响应,响应时 transcation id 相同
  • NetStream 消息族:
    有 play、play2、deleteStream、closeStream、receiveAudio、receiveVideo、publish、seek、pause 等这几种具体消息类型。
    NetStream 消息族也是携带 transcation id 的,但是其事务 id 规定为 0,理论上不用回复事务响应(但是实际上有些消息流程事务 id 并不为 0,同时 server 也会给不用回复事务响应的消息回复事务响应)。
    有一些消息需要回复一个 onStatus command 响应(onStatus command 也属于 NetStream 消息族)。

关于 command message 的具体流程,参见后文的消息流程(主要参考 srs 的处理)。

5.1.5 audio message

传递音频消息,message type 为 8。chunk stream id 不固定,message stream id 一般为 1

5.1.6 video message

传递音频消息,message type 为 9。chunk stream id 不固定,message stream id 一般为 1

5.1.7 data message

5.1.8 shared object message

5.2 connect 与 stream 的关系

rtmp 中 connect 与 stream 是一个抽象概念,但是建立连接的过程实际上就是在 client 和 server 端创建 NetConnection 和 NetStream 对象的过程。
rtmp 连接流程深入细节会比较复杂,但是我们可以从 flash 中 rtmp 建立连接的 js 代码中看到整体大概过程(https://github.com/ossrs/srs/wiki/v4_CN_RtmpUrlVhost):

// how to play url: rtmp://demo.srs.com/live/livestream
conn = new NetConnection();
conn.connect("rtmp://demo.srs.com/live");

stream = new NetStream(conn);
stream.play("livestream");

可以看到,首先是创建 NetConnection,然后是创建 NetStream,创建 NetStream 会在 connect 上创建一个后续会话的媒体通道。
同时,NetConnection 创建过程也可以看作对应了 rtmp url 中的 host 和 app 字段,NetStream 创建过程可以看作对应了 url 中的 appInstance 字段。
理论上一个 connect 对应多个 stream,但是实际上现在的 rtmp 实现都是一一对应的。

5.3 connect 流程

connect command 属于 NetConnection 消息族,connect 命令可以看作是在 server 端建立一个 NetConnection 对象。
rtmp handshake 完成后,后续无论是推流还是拉流,都要先建立一个连接。图示如下(https://rtmp.veriskope.com/docs/spec):

+--------------+                              +-------------+
|    Client    |             |                |    Server   |
+------+-------+             |                +------+------+
       |              Handshaking done               |         
       |                     |                       |         
       |                     |                       |         
       |                     |                       |         
       |                     |                       |
       |----------- Command Message(connect) ------->|
       |                                             |              
       |<------- Window Acknowledgement Size --------|
       |                                             |              
       |<----------- Set Peer Bandwidth -------------|
       |                                             |              
       |-------- Window Acknowledgement Size ------->|
       |                                             |              
       |<------ User Control Message(StreamBegin) ---|
       |                                             |              
       |<------------ Command Message ---------------|              
       |       (_result- connect response)           |
       |                                             |
Message flow in the connect command

其中,connect command 命令定义如下(https://rtmp.veriskope.com/docs/spec):

+----------------+---------+---------------------------------------+
|  Field Name    |  Type   |           Description                 |
+--------------- +---------+---------------------------------------+
| Command Name   | String  | Name of the command. Set to "connect".|
+----------------+---------+---------------------------------------+
| Transaction ID | Number  | Always set to 1.                      |
+----------------+---------+---------------------------------------+
| Command Object | Object  | Command information object which has  |
|                |         | the name-value pairs.                 |
+----------------+---------+---------------------------------------+
| Optional User  | Object  | Any optional information              |
| Arguments      |         |                                       |
+----------------+---------+---------------------------------------+

可见,事务 id 固定为 1。
其中,Command Object 有如下可选字段(https://rtmp.veriskope.com/docs/spec):

+-----------+--------+-----------------------------+----------------+
| Property  |  Type  |        Description          | Example Value  |
+-----------+--------+-----------------------------+----------------+
|   app     | String | The Server application name |    testapp     |
|           |        | the client is connected to. |                |
+-----------+--------+-----------------------------+----------------+
| flashver  | String | Flash Player version. It is |    FMSc/1.0    |
|           |        | the same string as returned |                |
|           |        | by the ApplicationScript    |                |
|           |        | getversion () function.     |                |
+-----------+--------+-----------------------------+----------------+
|  swfUrl   | String | URL of the source SWF file  | file://C:/     |
|           |        | making the connection.      | FlvPlayer.swf  |
+-----------+--------+-----------------------------+----------------+
|  tcUrl    | String | URL of the Server.          | rtmp://local   |
|           |        | It has the following format.| host:1935/test |
|           |        | protocol://servername:port/ | app/instance1  |
|           |        | appName/appInstance         |                |
+-----------+--------+-----------------------------+----------------+
|  fpad     | Boolean| True if proxy is being used.| true or false  |
+-----------+--------+-----------------------------+----------------+
|audioCodecs| Number | Indicates what audio codecs | SUPPORT_SND    |
|           |        | the client supports.        | _MP3           |
+-----------+--------+-----------------------------+----------------+
|videoCodecs| Number | Indicates what video codecs | SUPPORT_VID    |
|           |        | are supported.              | _SORENSON      |
+-----------+--------+-----------------------------+----------------+
|videoFunct-| Number | Indicates what special video| SUPPORT_VID    |
|ion        |        | functions are supported.    | _CLIENT_SEEK   |
+-----------+--------+-----------------------------+----------------+
|  pageUrl  | String | URL of the web page from    | http://        |
|           |        | where the SWF file was      | somehost/      |
|           |        | loaded.                     | sample.html    |
+-----------+--------+-----------------------------+----------------+
| object    | Number | AMF encoding method.        |     AMF3       |
| Encoding  |        |                             |                |
+-----------+--------+-----------------------------+----------------+

注意,这里文档描述的 tcUrl 可能有点问题,因为现在的多种 rtmp 实现,tcUrl 字段都是不携带 appInstance 的。
抓包示例如下:

其中,虽然 Command Object 字段很多,但是一般用到的字段不多,抓包示例如下:

最重要的就是 appName 和 tcUrl 这两个字段。
server 在收到 connect command 后,会依次发送多个 protocol control message 后,再回应 connect response,其中 connect response 格式如下(https://rtmp.veriskope.com/docs/spec):

+--------------+----------+----------------------------------------+
| Field Name   |   Type   |             Description                |
+--------------+----------+----------------------------------------+
| Command Name |  String  | _result or _error; indicates whether   |
|              |          | the response is result or error.       |
+--------------+----------+----------------------------------------+
| Transaction  |  Number  | Transaction ID is 1 for connect        |
| ID           |          | responses                              |
|              |          |                                        |
+--------------+----------+----------------------------------------+
| Properties   |  Object  | Name-value pairs that describe the     |
|              |          | properties(fmsver etc.) of the         |
|              |          | connection.                            |
+--------------+----------+----------------------------------------+
| Information  |  Object  | Name-value pairs that describe the     |
|              |          | response from|the server. ’code’,      |
|              |          | ’level’, ’description’ are names of few|
|              |          | among such information.                |
+--------------+----------+----------------------------------------+

5.4 createStream 流程

createStream command 属于 NetConnection 消息族。createStream 命令主要是在已经建立连接的基础上,创建一个新的 stream。
创建新的 stream,反应在会话协议上,就是改变了 message header 字段中的 stream id。
在 createStream 之前的会话协议,client message header 字段的 stream id 都为 0,在 client 创建新的 stream 之后,后续会话协议 message header 字段的 stream id 就是 server command response 中指定的 id。
createStream 命令定义如下(https://rtmp.veriskope.com/docs/spec):

+--------------+----------+----------------------------------------+
| Field Name   |   Type   |             Description                |
+--------------+----------+----------------------------------------+
| Command Name |  String  | Name of the command. Set to            |
|              |          | "createStream".                        |
+--------------+----------+----------------------------------------+
| Transaction  |  Number  | Transaction ID of the command.         |
| ID           |          |                                        |
+--------------+----------+----------------------------------------+
| Command      |  Object  | If there exists any command info this  |
| Object       |          | is set, else this is set to null type. |
+--------------+----------+----------------------------------------+

command resposne 定义如下:

+--------------+----------+----------------------------------------+
| Field Name   |   Type   |             Description                |
+--------------+----------+----------------------------------------+
| Command Name |  String  | _result or _error; indicates whether   |
|              |          | the response is result or error.       |
+--------------+----------+----------------------------------------+
| Transaction  |  Number  | ID of the command that response belongs|
| ID           |          | to.                                    |
+--------------+----------+----------------------------------------+
| Command      |  Object  | If there exists any command info this  |
| Object       |          | is set, else this is set to null type. |
+--------------+----------+----------------------------------------+
| Stream       |  Number  | The return value is either a stream ID |
| ID           |          | or an error information object.        |
+--------------+----------+----------------------------------------+

其中 Stream ID 字段指定了后续会话的 message header stream id 值,只要是 client 在这个 stream 上发的消息,都必须使用这里指定的 id 值。
Stream ID 字段一般为 1。
命令流程如下:

  client                              server
            createStream command
    -------------------------------------> 
              command response
    <-------------------------------------

createStream command 中 Command Object 一般为 null。
需要注意的是,不同客户端,createStream command 的顺序并不一定接在 connect command 后面,具体在下面 publish 和 play 流程中会描述与 createStream 的相对顺序。这一块逻辑可以参考 srs/srs_rtmp_stack.cpp::SrsRtmpServer::identify_client() 函数。
另外需要注意的是,这里 createStream response 指定的 stream id 是给客户端用的,服务端的后续发给客户端的消息可以继续使用原来的 stream id(即一般为0)。

5.5 publish 流程

publish command 属于 NetStream 消息族。所以理论上 publish 没有 transcation response,事务 id 也为 0。
在 createStream command 成功后,client 可以进行推流。
publish 命令定义如下(https://rtmp.veriskope.com/docs/spec):

+--------------+----------+----------------------------------------+
| Field Name   |   Type   |             Description                |
+--------------+----------+----------------------------------------+
| Command Name |  String  | Name of the command, set to "publish". |
+--------------+----------+----------------------------------------+
| Transaction  |  Number  | Transaction ID set to 0.               |
| ID           |          |                                        |
+--------------+----------+----------------------------------------+
| Command      |  Null    | Command information object does not    |
| Object       |          | exist. Set to null type.               |
+--------------+----------+----------------------------------------+
| Publishing   |  String  | Name with which the stream is          |
| Name         |          | published.                             |
+--------------+----------+----------------------------------------+
| Publishing   |  String  | Type of publishing. Set to "live",     |
| Type         |          | "record", or "append".                 |
|              |          | record: The stream is published and the|
|              |          | data is recorded to a new file.The file|
|              |          | is stored on the server in a           |
|              |          | subdirectory within the directory that |
|              |          | contains the server application. If the|
|              |          | file already exists, it is overwritten.|
|              |          | append: The stream is published and the|
|              |          | data is appended to a file. If no file |
|              |          | is found, it is created.               |
|              |          | live: Live data is published without   |
|              |          | recording it in a file.                |
+--------------+----------+----------------------------------------+

在 Publishing Type 字段中,支持 live、record、append 3 种推流类型,但是 srs 中只支持 live 类型,且忽略了 Publishing Type 字段。
publish 在不同客户端有着不同的命令流程,参考 srs/srs_rtmp_stack.cpp::SrsRtmpServer::identify_client() 函数,具体可以分为 FMLE、HAIVISION、flash player 3 种。

  • flash player 命令流程
    这是标准文档描述的命令流程,命令流程如下(特意增加 createStream 以示意相对顺序):
  client                              server
            createStream command
    -------------------------------------> 
              command response
    <-------------------------------------
              publish command
    -------------------------------------> 
      onStatus(NetStream.Publish.Start)
    <-------------------------------------
  • FMLE 命令流程
    命令流程如下(参考 srs/srs_rtmp_stack.cpp::SrsRtmpServer::start_fmle_publish() 函数):
  client                              server
            releaseStream command
    -------------------------------------> 
              command response
    <-------------------------------------
             FCPublish command
    -------------------------------------> 
              command response
    <-------------------------------------
            createStream command
    -------------------------------------> 
              command response
    <-------------------------------------
              publish command
    -------------------------------------> 
      FCPublish(NetStream.Publish.Start)
    <-------------------------------------
      onStatus(NetStream.Publish.Start)
    <-------------------------------------

注意这里 releaseStream、FCPublish command 可以看作 NetConnection 命令族,有 transcation response。

  • HAIVISION 命令流程
    命令流程如下(参考 srs/srs_rtmp_stack.cpp::SrsRtmpServer::start_haivision_publish() 函数):
  client                              server
            createStream command
    -------------------------------------> 
              command response
    <-------------------------------------
              publish command
    -------------------------------------> 
      FCPublish(NetStream.Publish.Start)
    <-------------------------------------
      onStatus(NetStream.Publish.Start)
    <-------------------------------------

但是在有的客户端-服务端连接流程种,client publish 事务 id 不为 0,server 也会对 publish 进行事务回应。

5.5.1 重新推流流程

由于某些原因,推流端可能会发起重新推流,由于 rtmp 标准文档并没有定义 unpublish command,所以各个客户端的实现可能不一样。
参考 srs/srs_app_rtmp_conn.cpp::SrsRtmpConn::handle_publish_message()。

  • 普通 flash 客户端:
    在 srs 中的判断为只要在同一个 tcp 连接上再次接收到 publish command,就算重新推流。
  • fmle 客户端:
    fmle 定义了 FCUnpublish command。
  client                              server
            FCUnpublish command
    -------------------------------------> 
  onFCUnpublish(NetStream.unpublish.Success)
    <-------------------------------------
            FCUnpublish response
    <-------------------------------------
      onStatus(NetStream.Unpublish.Start)
    <-------------------------------------

5.5.2 停止推流流程

各个客户端的实现可能不一样,最通用的做法就是断开 tcp 连接。

另外需要注意,在 srs 的实现中,对推流端有超时设计(参考 srs/srs_app_rtmp_conn.cpp::SrsRtmpConn::do_publishing()):

  • 首帧超时,即信令完成后,等待首帧音视频数据的超时时间
  • 数据超时,即已经接收到音视频数据之后,突然一段时间没有收到任何流的超时时间

超时后,srs 会主动直接断开 tcp 连接。

5.6 play 流程

play command 属于 NetStream 消息族。所以理论上 play 没有 transcation response,事务 id 也为 0。
在 createStream command 成功后,client 可以进行拉流。
play 命令定义如下(https://rtmp.veriskope.com/docs/spec):

+--------------+----------+-----------------------------------------+
| Field Name   |   Type   |             Description                 |
+--------------+----------+-----------------------------------------+
| Command Name |  String  | Name of the command. Set to "play".     |
+--------------+----------+-----------------------------------------+
| Transaction  |  Number  | Transaction ID set to 0.                |
| ID           |          |                                         |
+--------------+----------+-----------------------------------------+
| Command      |   Null   | Command information does not exist.     |
| Object       |          | Set to null type.                       |
+--------------+----------+-----------------------------------------+
| Stream Name  |  String  | Name of the stream to play.             |
|              |          | To play video (FLV) files, specify the  |
|              |          | name of the stream without a file       |
|              |          | extension (for example, "sample"). To   |
|              |          | play back MP3 or ID3 tags, you must     |
|              |          | precede the stream name with mp3:       |
|              |          | (for example, "mp3:sample"). To play    |
|              |          | H.264/AAC files, you must precede the   |
|              |          | stream name with mp4: and specify the   |
|              |          | file extension. For example, to play the|
|              |          | file sample.m4v, specify                |
|              |          | "mp4:sample.m4v".                       |
|              |          |                                         |
+--------------+----------+-----------------------------------------+
| Start        |  Number  | An optional parameter that specifies    |
|              |          | the start time in seconds. The default  |
|              |          | value is -2, which means the subscriber |
|              |          | first tries to play the live stream     |
|              |          | specified in the Stream Name field. If a|
|              |          | live stream of that name is not found,  |
|              |          | it plays the recorded stream of the same|
|              |          | name. If there is no recorded stream    |
|              |          | with that name, the subscriber waits for|
|              |          | a new live stream with that name and    |
|              |          | plays it when available. If you pass -1 |
|              |          | in the Start field, only the live stream|
|              |          | specified in the Stream Name field is   |
|              |          | played. If you pass 0 or a positive     |
|              |          | number in the Start field, a recorded   |
|              |          | stream specified in the Stream Name     |
|              |          | field is played beginning from the time |
|              |          | specified in the Start field. If no     |
|              |          | recorded stream is found, the next item |
|              |          | in the playlist is played.              |
+--------------+----------+-----------------------------------------+
| Duration     |  Number  | An optional parameter that specifies the|
|              |          | duration of playback in seconds. The    |
|              |          | default value is -1. The -1 value means |
|              |          | a live stream is played until it is no  |
|              |          | longer available or a recorded stream is|
|              |          | played until it ends. If you pass 0, it |
|              |          | plays the single frame since the time   |
|              |          | specified in the Start field from the   |
|              |          | beginning of a recorded stream. It is   |
|              |          | assumed that the value specified in     |
|              |          | the Start field is equal to or greater  |
|              |          | than 0. If you pass a positive number,  |
|              |          | it plays a live stream for              |
|              |          | the time period specified in the        |
|              |          | Duration field. After that it becomes   |
|              |          | available or plays a recorded stream    |
|              |          | for the time specified in the Duration  |
|              |          | field. (If a stream ends before the     |
|              |          | time specified in the Duration field,   |
|              |          | playback ends when the stream ends.)    |
|              |          | If you pass a negative number other     |
|              |          | than -1 in the Duration field, it       |
|              |          | interprets the value as if it were -1.  |
+--------------+----------+-----------------------------------------+
| Reset        | Boolean  | An optional Boolean value or number     |
|              |          | that specifies whether to flush any     |
|              |          | previous playlist.                      |
+--------------+----------+-----------------------------------------+

在 Start 字段中,本意是支持直播和文件的定位播放,但是 srs 中只支持直播,所以忽略此字段。
在 Duration 字段中,本意是支持直播时长和文件播放时长(Duration=0,可以单帧播放),但是由于 srs 只支持直播,所以这里 Duration 字段只运用到了直播中(Duration>0),即可以控制直播播放时长。
在 Reset 字段中,本意是为了支持播放列表相关,srs 中被忽略。
play 命令流程如下(参考 srs/srs_rtmp_stack.cpp::SrsRtmpServer::start_play() 函数):

  client                              server
                play command
    -------------------------------------> 
          user control(stream begin)
    <-------------------------------------
        onStatus(NetStream.play.Reset)
    <-------------------------------------
        onStatus(NetStream.play.Start)
    <-------------------------------------
              |RtmpSampleAccess
    <-------------------------------------
        onStatus(NetStream.Data.Start)
    <-------------------------------------

|RtmpSampleAccess command 属于 NetStream 命令族,srs 这里是为了做兼容处理,关于此命令,参考 https://help.adobe.com/en_US/FlashPlatform/reference/actionscript/3/flash/net/NetStream.html#videoSampleAccess。

5.6.1 停止拉流流程

参考 srs/srs_app_rtmp_conn.cpp::SrsRtmpConn::process_play_control_msg() 函数和 https://my.oschina.net/u/2326611/blog/1358555。
各个客户端的实现可能不一样,有的发送 closeStream,有的发送 deleteStream,最通用的做法就是直接断开 tcp 连接。
注意,在 srs 中,即使拉流的对应源没有数据了,或者客户端拉流时源本身就不存在,srs 也不会主动断开与客户端的连接,而是等待客户端主动断开连接,这与针对推流客户端的处理不一样。当然,不同 rtmp server 的实现不一样。

5.7 play pause 流程

pause 命令定义如下:

+--------------+----------+----------------------------------------+
| Field Name   |   Type   |             Description                |
+--------------+----------+----------------------------------------+
| Command Name |  String  | Name of the command, set to "pause".   |
+--------------+----------+----------------------------------------+
| Transaction  |  Number  | There is no transaction ID for this    |
| ID           |          | command. Set to 0.                     |
+--------------+----------+----------------------------------------+
| Command      |  Null    | Command information object does not    |
| Object       |          | exist. Set to null type.               |
+--------------+----------+----------------------------------------+
|Pause/Unpause |  Boolean | true or false, to indicate pausing or  |
| Flag         |          | resuming play                          |
+--------------+----------+----------------------------------------+
| milliSeconds |  Number  | Number of milliseconds at which the    |
|              |          | the stream is paused or play resumed.  |
|              |          | This is the current stream time at the |
|              |          | Client when stream was paused. When the|
|              |          | playback is resumed, the server will   |
|              |          | only send messages with timestamps     |
|              |          | greater than this value.               |
+--------------+----------+----------------------------------------+

命令流程如下(代码可以参考 srs::srs_rtmp_stack.cpp::SrsRtmpServer::on_play_client_pause()):

  client                              server
            pause command(pause)
    -------------------------------------> 
       onStatus(NetStream.pause.Notify)
    <-------------------------------------
          user control(stream EOF)
    <-------------------------------------

                .............

           pause command(unpause)
    -------------------------------------> 
       onStatus(NetStream.Unpause.Notify)
    <-------------------------------------
          user control(stream begin)
    <-------------------------------------

6. 时间戳

6.1 不同 fmt 格式下的时间戳含义

对于不同 fmt 格式的 chunk 包,message header 的 timestamp 字段含义是不同的:

  • 对于 fmt=0 格式的 chunk 包,timestamp 字段即表示实际的时间戳(也可以称为绝对时间戳)
  • 对于 fmt=1 和 ftm=2 格式的 chunk 包,timestamp 字段表示的是 timestamp delta(时间戳增量,也可以称为相对时间戳),实际的时间戳需要根据前面 fmt=0 的chunk 包计算出来
  • 对于 fmt=3 格式的 chunk 包,当然已经没有了 timestamp 字段

6.2 extended timestamp

rtmp 时间戳本身是 32bit 大小的,但是在 rtmp message header 中,timestamp 只占用 24bit 大小。
rtmp 的策略是,在 timestamp >= 0xFFFFFF 时,将 timestamp 全部置于 0xFFFFFF,并启用 extended timestamp 字段,此字段是完整的 32bit 大小。
当启用 extended timestamp 字段时,message header 中的 timestamp 字段就变成了标识符作用。
当 extended timestamp 字段都表示不了时间戳时,会发生时间戳回绕。
需要注意的是:

  • srs 中,无论 fmt 取值为什么,都将 extended timestamp 看作绝对时间戳
  • 在 rtmp 标准文档中,实际上 fmt=1 与 fmt=2 时 extended timestamp 是相对时间戳
  • 关于 extended timestamp 字段处理为绝对时间戳,也可以参考另一个项目的实现:https://github.com/q191201771/lal/issues/7。

6.3 rtmp message 拆分下 extended timestamp 的问题

对于 timestamp < 0xFFFFFF 的时间戳,由于没有启用 extended timestamp 字段,不同发流端和接收端协议解析是没有问题的。
当启用 extended timestamp 字段时,这时就会出现问题:

  • 如果一个 rtmp message 没有被分为不同的 chunk,那么不同发流端和接收端协议解析是没有问题的,
  • 如果一个 rtmp message 被分为不同的 chunks,第一个 chunk 一定会携带 extended timestamp 字段,但是后续的属于同一个 rtmp message 的 chunk 带不带 extended timestamp 字段,不同发流端实现不一样,这样就导致收流端读取数据出现错误的情况

一般来说,一个 rtmp message 被分为不同的 chunks,后续 chunk 都是 fmt=3 类型的,在 srs 中为了兼容不同的推流端,如果检测到了 extended timestamp 字段被启用,那么会尝试读取 extended timestamp 字段(参考 srs::srs_rtmp_stack.cpp::SrsProtocol::read_message_header()):

        /**
         * about the is_first_chunk_of_msg.
         * @remark, for the first chunk of message, always use the extended timestamp.
         */
        if (!is_first_chunk_of_msg && chunk_timestamp > 0 && chunk_timestamp != timestamp) {
            mh_size -= 4;
            in_buffer->skip(-4);
        } else {
            chunk->header.timestamp = timestamp;
        }

如上:

  • 如果是 rtmp message 第一个 chunk,则确实是存在 extended timestamp 字段
  • 如果不是 rtmp message 第一个 chunk,则检查上一个 chunk(同一个 rtmp message) 的时间戳是否与这里读取的 extended timestamp 字段相同:
    • 如果相同,说明确实存在 extended timestamp 字段
    • 如果不同,说明不存在 extended timestamp 字段,解析 chunk 回退 4 字节

srs 在发送 chunk 时,只会发送 fmt=0 和 fmt=3 两种类型的 chunk,并且只要 extended timestamp 被启用,每个包都会携带 extended timestamp 字段。

6.4 负数的 timestamp delta

rtmp header timestamp 一般都是从 0 开始,且最好不要出现负数时间戳(无论是绝对时间戳还是相对时间戳),否则 rtmp 接收端可能解析失败或者丢掉负数时间戳的包。

6.5 timestamp 的单调性

  • 考虑如下场景:
    audio encoder 和 video encoder 独立编码音视频 frame,得到音视频 packet,然后将音视频 packet 打包为 rtmp message,打包的时候的时候为其打上时间戳,然后由发送逻辑将 rtmp message 发送到网络中。
    这时单独看 audio rtmp message 或 video rtmp message 流的时间戳都是单调递增的。
    但是如果发送的时候由于某些本地策略的原因,时间戳大的 rtmp message 先发送,那么从整个 rtmp message 流的视角下看接收端前后收到的两个 rtmp message 就会出现后一个包时间戳比前一个包时间戳小的情况。
    如果出现这种情况,一些 rtmp 接收端会认为时间戳出现了错误,可能会丢弃时间戳乱序的包,引发问题。

在 srs 中,不会因为时间戳不是混合递增的就会丢包或者时间戳解析失败,但是需要注意的是,能正确解析 rtmp message 并不意味着后续不存在问题:

  • 如果直接将 rtmp message 转发出去,其它接收端可能会因为整个流时间戳单调递增而出现播放问题
  • 如果音视频流时间戳相差太大,在进行 ts 切片时,按时间切片会出现混乱

在 srs 中,设计了一个混合单增方法来使收到的 message 能通过时间戳递增的顺序再发送出去:

  • 即使用一个 std::multimap<timestamp, packet> 的变量来接收 message 包,经过时间戳排序后,再取出时间戳最小的发送出去,这样就能使得 message 发送出去的时候一定是有序的了(前提是时间戳相差不大)

7. 重定向

参考 srs https://github.com/ossrs/srs/issues/369 实现。

posted @ 2022-04-10 13:27  小夕nike  阅读(645)  评论(0编辑  收藏  举报