用RUST寫流媒體伺服器實戰——rtmp chunk 踩坑記錄

HarlanC發表於2021-04-26

最近幾個月斷更了,把精力放在了新的開源專案上,一個用rust寫的流媒體服務xiu
實現過程中踩了不少坑,今天說下rtmp中的chunk。

RTMP協議確實複雜,在做這個專案之前,看過很多帖子,看過官方文件,但總是感覺不能徹底的理解清楚,在實現過一遍此協議之後,感覺清楚了不少。

目前做的測試還不夠多,倒是發現了一些問題。chunk這個東西看了很久可能很多人還是不明白,說明一下,RTMP 協議除了3次握手資料,其它的,包括信令和媒體資料(音視訊相關的資料),都會被封裝成chunk塊。

handshake的殘留資料

TCP傳送資料不是按照協議信令,一次只傳送一個信令,有時候會傳送多個,rtmp握手階段從TCP流中讀一次資料,握手結束後,會留下一部分資料,這部分要填到chunk解析緩衝資料中。

chunk size

初始化的chunk size要設定成128。

我的測試和排查過程記錄如下:
我一開始的chunk size設定成了4096,用ffplay播放流,傳送connect信令的時候,總是會多出一個byte,導致amf解析失敗,用wireshark抓包,這個byte是沒有的,一開始認為wireshark是不會出錯的,以為tokio網路庫,於是換成了tcp基礎庫,這個byte還是存在,想了個笨方法,找到一個開源的rtmp伺服器,也列印出此信令,剛收到tcp資料的時候,這個byte也有,但是amf解析卻成功了,接下來就是把每一步的資料都列印出來,從解析chunk到解析amf. 看看這個byte究竟是在哪個步驟消失的,最後發現,這個byte是chunk的第一個byte,fmt+csid,初始化的chunk size不對。。

狀態保留

解釋狀態保留之前說一下chunk的各部分組成,按照官方的文件,chunk由四部分組成:

  • basic header
  • message header
  • extended timestamp
  • payload

前三部分是都可以壓縮的。

basic header

 /******************************************************************
 * 5.3.1.1. Chunk Basic Header
 * The Chunk Basic Header encodes the chunk stream ID and the chunk
 * type(represented by fmt field in the figure below). Chunk type
 * determines the format of the encoded message header. Chunk Basic
 * Header field may be 1, 2, or 3 bytes, depending on the chunk stream
 * ID.
 *
 * The bits 0-5 (least significant) in the chunk basic header represent
 * the chunk stream ID.
 *
 * Chunk stream IDs 2-63 can be encoded in the 1-byte version of this
 * field.
 *    0 1 2 3 4 5 6 7
 *   +-+-+-+-+-+-+-+-+
 *   |fmt|   cs id   |
 *   +-+-+-+-+-+-+-+-+
 *   Figure 6 Chunk basic header 1
 *
 * Chunk stream IDs 64-319 can be encoded in the 2-byte version of this
 * field. ID is computed as (the second byte + 64).
 *   0                   1
 *   0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
 *   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 *   |fmt|    0      | cs id - 64    |
 *   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 *   Figure 7 Chunk basic header 2
 *
 * Chunk stream IDs 64-65599 can be encoded in the 3-byte version of
 * this field. ID is computed as ((the third byte)*256 + the second byte
 * + 64).
 *    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
 *   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 *   |fmt|     1     |         cs id - 64            |
 *   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 *   Figure 8 Chunk basic header 3
 *
 * cs id: 6 bits
 * fmt: 2 bits
 * cs id - 64: 8 or 16 bits
 *
 * Chunk stream IDs with values 64-319 could be represented by both 2-
 * byte version and 3-byte version of this field.
 ***********************************************************************/

第一個byte的前兩個bit是format,有0,1,2,3四個值,這個四個值的作用是壓縮message header,詳細的會在下面說,後6個bit是chunk stream ID, 簡稱csid(關於這個欄位有坑,下面會解釋),6個bit的取值範圍為[0,63] ,0和1有特殊用途,2到63表示真正的csid,關於特殊值0和1:

  • 0 表示csid用 6+ 8個bit表示

  • 1 表示csid用 6 + 16個bit表示

    解析程式碼如下:

     let mut csid = (byte & 0b00111111) as u32;
     match csid {
      0 => {
          if self.reader.len() < 1 {
              return Ok(UnpackResult::NotEnoughBytes);
          }
          csid = 64;
          csid += self.reader.read_u8()? as u32;
      }
      1 => {
          if self.reader.len() < 1 {
              return Ok(UnpackResult::NotEnoughBytes);
          }
          csid = 64;
          csid += self.reader.read_u8()? as u32;
          csid += self.reader.read_u8()? as u32 * 256;
      }
      _ => {}

    }

message header

下面說下message header, 這部分比較複雜,有四種型別,對應著basic header裡面的format欄位的0~3。

type 0

/*****************************************************************/
/*      5.3.1.2.1. Type 0                                        */
/*****************************************************************
 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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                timestamp(3bytes)              |message length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| message length (cont)(3bytes) |message type id| msg stream id |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|       message stream id (cont) (4bytes)       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
*****************************************************************/

任何欄位都不省略。

type 1

/*****************************************************************/
/*      5.3.1.2.2. Type 1                                        */
/*****************************************************************
 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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                timestamp(3bytes)              |message length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| message length (cont)(3bytes) |message type id|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
*****************************************************************/

省略了message stream id,使用上一個chunk的資料。

type 2

 /************************************************/
 /*      5.3.1.2.3. Type 2                       */
 /************************************************
  0                   1                   2
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |                timestamp(3bytes)              |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 ***************************************************/

更絕了,省略了message stream id、message length和 message type id,這個也從前邊的chunk讀。

type 3

3 啥都沒有,全從前邊拿。

extended timestamp

這個欄位是可選的,佔用4個byte,如果message header裡面的timestamp欄位大於0xFFFFFF,則讀取這個欄位。

payload

最後是payload,payload的長度由 message header裡面的message length決定。

chunk塊的整個讀取流程如下,一開始我的實現流程是這樣的(有問題)

  1. 讀取一個chunk的第一個byte,解析 format和chunk stream ID。

  2. 根據format解析message header:

    • 如果是0 則每個欄位都要從TCP流裡面解析出來。
    • 如果是1 則使用上一個chunk塊的message stream ID。
    • 如果是2 則使用上一個chunk塊的message stream id、message length和 message type id。
    • 如果是3 則使用上一個chunk塊的message stream id、message length、message type id以及timestamp。
  3. 根據timestamp值來決定是否讀取4個bytes的extendtimestamp。

  4. 根據message length讀取payload值,這裡有種情況比較特殊,有可能一塊payload資料被分成了2個或者多個chunk塊,在這一步裡面就需要將這些分割的payload 資料合成一個完整的chunk資料再返回。也就是說如果讀完payload資料後發現message length 不等於payload的長度,要回到步驟1從下一個chunk塊裡面繼續讀剩餘的payload資料,直到讀完為止。

    好了,整個流程基本上介紹清楚了。大標題裡面的狀態保留我這裡有兩個意思,第一個意思是要說明一下我上面表述的問題。我說的是『從上一個chunk塊』拿省略的欄位,這裡是不對的,因為有下面這種情況存在:

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

注意:message header裡面的欄位複用是針對chunk stream ID的。

因此上面的情況,chunk2 可以複用 chunk1的message header,但是chunk 4不能複用chunk 3的,所以,在程式碼裡面要特殊處理,每個csid的message header都需要儲存一份,每解析一個chunk,讀完basic header之後,需要把這個csid的上一個message header先恢復出來。

第二種情況也是我寫程式碼時不曾想到的:

tcp資料包可以在任何地方拆分。

也就是說,可能一個chunk還沒讀完,這次的tcp資料就用完了,需要等下一次的資料,這種情況就要保留讀取各個欄位的狀態了。每一個讀取操作就應該設定一個標記,因此寫了下面的四個大狀態,message header裡面有4個小的狀態。

#[derive(Copy, Clone)]
enum ChunkReadState {
    ReadBasicHeader = 1,
    ReadMessageHeader = 2,
    ReadExtendedTimestamp = 3,
    ReadMessagePayload = 4,
    Finish = 5,
}

#[derive(Copy, Clone)]
enum MessageHeaderReadState {x'x
    ReadTimeStamp = 1,
    ReadMsgLength = 2,
    ReadMsgTypeID = 3,
    ReadMsgStreamID = 4,
}

例如: ReadExtendedTimestamp佔用4個bytes,但是讀到這裡的時候就還剩下2個bytes,就要保留這個狀態,下次從TCP裡面讀出新資料的時候從這個狀態開始,再把兩外兩個bytes讀出來。

最後rtmp chunk解析的rust完整實現在這裡

最後,歡迎star。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章