认识rtmp
rtmp是Adobe公司出品的流媒体传输协议,它的全称是Real Time Messaging Protocol,是一个实时消息传输协议,学习RTMP一定要抓住 一个关键点:消息。
rtmp协议的原文可以在Adobe官网下载,内容十分精简,建议读一读原文。
rtmp的核心是消息交换,是一个基于TCP的协议,消息被分成消息块(chunk)使用TCP传输。每个chunk都携带一个id,称为chunk id,接收端根据chunk id将分块重新组装成完整的消息。所有chunk id相同的分块构成一条虚拟的chunk stream(块流),是一条逻辑流。同时每个消息也有一个message stream id,所有message stream id相同的消息构成一条消息流,这是第二条逻辑流。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsIyZuBnL4EDN5EjMjVDOxEDMykTNilTN2QjYkFTZ0IWN4UmN4M2Lc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
message stream和chunk stream之间并不存在一一对应的关系。一条message stream可以通过多条chunk stream传输,不同的message stream也可以复用同一条chunk stream。消息有很多种类型,消息类型和消息流也不是一一对应的关系,一条消息流可以传输不同类型的消息,但一般每一种消息都会独占一条chunk stream。
rtmp协议的流程也是从握手开始。握手之后就全部是消息交换。
工欲善其事 必先利其器
rtmp协议的目的是流媒体传输,为了验证效果,需要用到
ffmpeg
和
ffplay
,这两个程序都可以在ffmpeg官网上找到,直接下载压缩包解压就可以使用。当然,为了使用方便,可以添加到path。有其他的推拉流工具也可以,作为开发,这两个命令就足够了。
推流命令:
ffmpeg -re -stream_loop -1 -i trailer.mp4 -codec copy -f flv rtmp://localhost/live/test
播放命令:
ffplay -autoexit rtmp://localhost/live/test
这两个命令比较长,可以使用make帮助我们简化工作。
push: trailer.mp4
@ffmpeg -re -stream_loop -1 -i trailer.mp4 -codec copy -f flv rtmp://localhost/live/test
pull:
@ffplay -autoexit rtmp://localhost/live/test
协议使用golang开发,具体的代码实现详见Github.
握手
rtmp协议从握手开始,由客户端发起。客户端和服务端分别需要发送3个数据块,客户端发送的称为C0、C1和C2;服务端发送的称为S0、S1和S2。
C0和S0有相同的结构,C1和S1有相同的结构,C2和S2有相同的结构。
握手过程如下:
- 客户端发送C0和C1
- 服务端收到C0(或C1)后,发送S0和S1
- 客户端收到S1后发送C2
- 服务端收到C1后,发送S2
- 客户端收到S2且服务端收到C2,握手完成
数据格式
C0和S0都只有一个字节,内容为协议版本号。
C0中的是客户端要求的RTMP版本号,S0中是服务端选择的版本号,目前版本号为3。0-2是早期版本,已废弃,4-31是未来版本,32-255不允许使用,因为在ASSIC码中他们是可打印字符,其他协议常会用一个可打印字符作为版本号,RTMP协议为了做出区分,不使用可打印字符作为版本号。服务端无法识别客户端版本号时,响应3,客户端要么降到版本3,要么放弃握手。
C1和S1都是1536字节,格式如下:
time
字段用来协调消息的时间起点,因为rtmp的每个消息都是带有时间戳的。rtmp的主要目的是传输音视频数据,而音视频都是时间相关的信息。
zero
字段必须是全零。
random bytes
是随机数,可以是任意字节内容。
C2和S2也是1536字节,格式如下:
C2的
time
来自于S1的
time
字段,S2的
time
来自于C1的
time
字段。
C2的
time2
来自于C1的
time
字段,S2的
time2
来自于S1的
time
字段。
C2的
random echo
来自于S1的
random bytes
字段,S2的
random echo
来自于C1的
random bytes
字段。
一个完整的握手流程如下:
- Unintialized(未初始化):在此阶段发送协议版本。
- Version Sent(版本已发送):发送C0和S0后分别进入此状态,客户端等待S1,服务端等待C1。
- Ack Sent(确认已发送):发送C2和S2后进入此状态。
- Handshake Done(握手结束):收到C2和S2进入此状态。
复杂握手
上面是rtmp协议中描述的握手过程,被称为简单握手。现在还有一种称为复杂握手的握手方式,没有公开的官方说明,只有网络上流传着它的传说。
如果要实现一个可用的rtmp服务就需要实现复杂握手,因为有些客户端已经采用了复杂握手,并且拒绝简单握手,这其中就有ffplay。
复杂握手和简单握手的区别在于复杂握手的
random bytes
不只是单纯的随机字节,而是带有校验信息的。C1和S1结构对比如下图所示。
复杂握手的C1和S1有
scheme 0
和
scheme 1
两种结构,它们的区别仅仅是
key
和
digest
的摆放顺序不同。不管是哪种结构,
key
和
digest
的结构都是相同的。另外以前的
zero
字段现在变成了
version
,注意和C0、S0的version区分开。在简单握手中,
zero
字段是全零,而在复杂握手中,
zero
不是零,我们就是以此来区分要进行简单握手还是复杂握手的。
key
和
digest
中
offset
字段并不是直接编码的偏移量,计算时需要将各个字节相加来计算。
- key offset:
(offset[0] + offset[1] + offset[2] + offset[3]) % 632
- digest offset:
(offset[0] + offset[1] + offset[2] + offset[3]) % 728
对于
key
来说,
key
和
offset
要占去132字节,因此偏移量最大是764-132=632字节;而对于
digest
,
digest
和
offset
要占36字节,最大偏移量是764-36=728字节。
服务端需要对C1进行校验,校验的方式是先找到C1中32字节的
digest
,然后去掉它,对剩余的部分做sha256哈希,最后将哈希结果与
digest
进行比较。
这里我们并不知道如何区分
scheme 0
和
scheme 1
,网上几乎都是说先选一种scheme结构去做校验,如果失败就换另一种scheme去校验,如果成功,说明就是这种scheme。虽然能工作,但是怎么看都不是靠谱的样子,现在的
version
字段不要求为0,4个字节肯定是会编码一些信息的,将scheme编码到
version
字段中的确是可行的方案,不过由于没找到关于
version
字段含义的说明,也只能作为一种猜测,还需要进一步验证。关于C1中128字节的
key
也没有找到相关的用途说明。
对于S1,服务端需要以相同的方式生成digest供客户端校验。S2的生成要复杂一些,首先要将C1的
digest
哈希得到一个
key
,然后用这个
key
哈希S2的前1504字节得到
sign
,最后将
sign
放到S2的最后组成完整的S2。
以上就是复杂握手的过程,关于哈希用到的key和具体代码实现可用参考
handshake.go
。
chunk
rtmp协议将消息分块后进行传输,分块的目的有两个:
- 避免大而不重要的消息阻塞小而重要的消息。
- 减少重复发送相同的消息头部。
chunk是rtmp的基本单位,每个chunk必须完整发送,也就是说发送完一个chunk之前,不能发送另一个chunk。
chunk有4个部分组成,分为
Chunk Header
和
Chunk Data
,如下图所示:
基本头部
基本头部中包含两个信息:消息头部的格式(
fmt
)和chunk stream id(csid)。
fmt
指示了消息头部的格式,消息头部一共4种类型,需要两个比特来编码;csid标识了该chunk属于哪一路chunk流,接收端需要根据它来组装消息。
本着能省则省的原则,基本头部的长度有1字节,2字节和3字节三种,根据chunk stream id的大小而定。
第一种情况是csid在2到63之间,用一个字节编码。
第二种情况是csid在64到319之间,使用2字节编码。
第三种情况是csid在320到65599之间,使用3字节编码。
注意,此情况下csid的计算方式是
第三个字节 × 256 + 第二个字节 + 64
,换句话说,csid是以小端序编码的。
csid的范围是2到65599,0和1保留,0表示基本头使用2字节编码,1表示基本头使用3字节编码。2也是一个特殊的csid,专用于协议控制消息和用户控制消息,普通消息的csid都是从3开始。
消息头部
消息头部中记录了消息的相关信息,包括消息的时间戳,长度,类型和所属的消息流id。消息头部有4种类型,由基本头部中的
fmt
指定。
Type 0
0类型消息头部共11字节,包含完整的头部信息。
根据能省则省的原则,消息头部中的
timestamp
只有3字节,如果时间戳超过了
0xFFFFFF
,需要将它设置为
0xFFFFFF
,然后将真正的时间戳写入
Chunk Header
的
Externed Timestamp
中。
message length
也只有3个字节,所以消息的最大长度不能超过
0xFFFFFF
。
message type id
(mtid)表示消息的类型,不同类型的消息携带的负载也不同,这个后面再说。
message stream id
(msid)表示消息所属的消息流,是这些字段中唯一以小端序编码的字段。0是一个特殊的msid,专用于协议控制消息和用户控制消息。
Type 1
1类型的消息头部共7字节,相比于类型0,缺少了
message stream id
。
如果消息属于同一个消息流,那么后面的消息就不用重复发送消息流id了。注意这里的头三个字节不是时间戳了,而是时间戳增量。有些rtmp实现始终将头三个字节解释为时间戳其实是不对的,之所以也能正常工作是因为大部分时候消息时间戳都是从零开始的。如果时间戳增量超过了
0xFFFFFF
,也需要编码到
Externed Timestamp
中。
Type 2
2类型的消息头只剩3个字节,用来设置时间戳增量。如果超过了
0xFFFFFF
,也需要编码到
Externed Timestamp
中。
Type 3
3类型消息头部是0字节,这下全都省了。对于固定长度,时间戳成等差数列的消息,第一个分块发送一个0类型消息头,第二个分块发送一个2类型的消息头,之后的消息就可以发送3类型的消息头了,比如音频数据。此外,如果一个大消息被分成多个chunk发送,除了第一个chunk,后面的chunk也可以发送3类型的消息头,比如视频数据。
在读取消息时,对时间戳的处理要格外小心。因为对于音视频消息,时间戳时非常重要的,会影响到播放,如果时间戳错误可能会导致音画不同步。在笔者的实现中,就曾犯过这样的错误。特别是对时间戳增量的处理,如果处理的不对,音画不同步的现象会随着播放的进行逐渐累积。
扩展时间戳
扩展时间戳是一个可选项,只有当消息头部中的时间戳(时间戳增量)大于
0xFFFFFF
时,才存在扩展时间戳。
分块负载
chunk的负载长度(chunk size)也不是固定的,但是不能小于128字节。在
Chunk Header
中并没有指定负载长度,它是客户端/服务端的一个状态,默认是128字节,可以通过协议控制消息来修改,并且读和写的chunk size可以单独设置。
消息
rtmp有很多类型的消息,不同类型的消息有不同的格式和作用。
协议控制消息
协议控制消息的message stream id必须是0,chunk stream id是2,主要用于设置chunk stream的相关状态。协议控制消息的时间戳都是0,必须立即生效。
Set Chunk Size
mtid=1,用来设置分块的负载大小。确切的说是对方的读chunk size,自己的写chunk size,因为读写可以设置不同的chunk size。该消息负载4字节,有效位只有31比特,也就是说chunk的最大负载是
0x7FFFFFFF
字节。
Abort Message
mtid=2,大小4字节,内容是csid,用来告诉对方放弃读取所指定chunk stream中的消息。比如某个消息发送了一半不想发送了,就可以使用这个消息来取消。
Acknowledgement
mtid=3,rtmp也提供了窗口机制,当接收端接收到窗口大小的字节数后,需要发送一个确认消息。注意确认消息中的内容是截至目前为止已接收到的字节数。
Window Acknowledgement Size
mtid=5,用来设置窗口大小。
Set Peer Bandwidth
mtid=6,除了设置窗口大小,还会设置带宽模式,共3种:
- 0:严格,将窗口大小设置为该消息指定的大小。
- 1:宽松,可以使用该窗口大小,如果之前的窗口更小,也可以继续使用之前。
- 2:动态,如果之前设置了严格模式,把该消息当作严格模式,否则忽略该消息。
用户控制消息
用户控制消息的message stream id也必须是0,chunk stream id是2,主要用于设置message stream的相关状态。
用户控制消息的消息类型为4,内容包括Event Type和Event Data,共7种类型。
Stream Begin
一般在连接或创建流之后由服务端发给客户端。
命令消息
消息类型17和20都表示命令消息,区别是编码格式不同,17是采用AMF3编码,而20是采用AMF0编码。命令消息主要是控制流媒体的相关状态。
AMF格式与解码参见【Go】FLV文件解析(二)。
命令消息分成两大类:NetConnection Command和NetStream Command。
无论是推流还是拉流,客户端都会先发送
connect
命令。接下来对于推流端会发送
publish
命令,可能还有
FCPublish
命令,取决于客户端;而对于拉流端,会发送
play
命令。
所有命令的前两项都是
CommandName
和
TransactionID
,之后的结构因命令而异。对于这些命令的具体结构,我的建议是把他们保存到文件里,用二进制查看器亲自看一看,vscode就不错。
connect
connect
命令消息结构如下。
其中Command Name为
connect
,Transaction ID为1,User Argument是可选项。
服务端收到
connect
命令后需要发送一个响应,响应也是command消息,结构是一样的,其中Command Name为
_result
或
_error
,Transaction ID固定为1。
publish
publish
命令用来发布流,会携带流名称和流类型两个信息,结构如下。
Publishing Type有以下三种:
- live:不将数据写入文件,直播使用此类型。
- record:将数据写入文件,如果文件已存在,覆盖原文件。
- append:将数据追加到文件,如果文件不存在则创建。
play
play
命令用来播放流,结构如下。
对于直播来说,重要的是Stream Name,点播会用到Start。
音视频消息
传输音视频数据是我们使用rtmp协议的主要目的,音频消息的消息类型是8,视频消息的消息类型是9。如果你熟悉FLV文件的结构,会发现这些数字很眼熟,都是Adobe出品的,所以定义是一样的。此外还有类型为18的视频元数据Tag,在rtmp中对应的是消息类型为18和15的消息,18是AMF0编码,15是AMF3编码。
对于音视频消息,其负载是FLV文件的Tag Data部分的内容,如果是做直播应用,直接缓存它,然后发送给播放端就可以了,如果是做点播应用,需要提取出Tag Data发送给客户端。
FLV文件中有三个特殊的Tag:Script Tag,Video Tag 0和Audio Tag 0。播放时,必须先将这三个Tag按顺序发送给播放端。
关于FLV文件的格式与解析参见【Go】FLV文件解析(一)。
交互流程
无论是推流还是拉流,都是从
connect
和
createStream
命令开始的。注意,这里的
connect
命令并不是连接到服务器,而是连接到应用。这里需要说明一下rtmp的地址结构,如下图。
rtmp的地址由4个部分组成,
connect
命令会携带
application
信息,至于
streamName
则由
publish
和
play
命令携带,可以简单的字符串,也可以是带参数的路径,如
stream_name?secret=xxx&key=xxx
,取决于服务端的实现。
createStream
命令创建的是message stream,之后的音视频消息都会在这条message stream上传输。与它对应的另一个命令是
deleteStream
,用来删除一条message stream。
上面是
connect
和
createStream
的流程示意,上面的流程并不是强制的,有时候你会发现精简一下,去掉几个过程也能正常工作,但
connect
和
createStream
的response是必不可少的。
推流
推流使用
publish
命令,有些客户端还会发动
FCPublish
命令,一般我们会忽略掉后者。推流的大致流程如下。
这个过程也不是十分严格的,除了
publish result
是必须的,实际的过程可能有出入,收到音视频数据后如何缓存它们已经超出了协议本身的内容,可以有不同的实现方案。
作为直播的服务端,从抽象的角度来说,流缓存器应该是一个无限长的队列,发布者向队尾写入数据。队列上有一些入口,播放端从入口开始读取数据,但是不删除。注意,入口不一定是队头,这些入口对应的应该是关键帧所在位置。当播放端读到队尾时,需要等待发布者写入数据。
然而实际中我们不可能实现一个无限长的队列,不过我们可以使用环形队列来替代。想象在三维空间中的一个螺旋上升的弹簧,在二维空间就是一个圆。还要注意读写的时候不能加锁,因为不能让读阻塞写,也就是拉流端不能影响推流端。当拉流端读的太慢时,启动丢帧机制。
播放
播放使用
play
命令。播放流程如下所示。
以上流程也不是严格的,比如直播就可以不用发送
StreamIsRecorded
消息,如果
play
命令没有带
reset
标志,服务端也不需要发送
reset
响应。注意在发送音视频消息之前要先发送
Metadata
,第一个视频消息必须时video tag 0,其中包含了解码视频需要的SPS和PPS,第二个视频帧要是一个关键帧,否则解码会失败。
示例程序
rtmp协议本身的内容不多,实现起来也不难,我希望向使用http服务那样使用rtmp服务,下面是我实现的一个直播示例。前两个
HandleCommand
分别处理
FCUpublish
和
play
命令,
HandleData
用来处理音视频数据。
本文只介绍了使用rtmp实现推拉流所涉及的内容,完整的rtmp协议可以阅读协议原文。
package main
import (
"fmt"
"log"
"github.com/chenyj/rtmp"
"github.com/chenyj/rtmp/encoding/av"
)
func main() {
// 流缓存器
streams := map[string]rtmp.Streamer{}
// 处理unpublished命令
rtmp.HandleCommand(rtmp.CMD_FCUNPUBLISH, func(w rtmp.MessageWriter, r *rtmp.Request) error {
s, ok := streams[r.StreamPath]
if !ok {
return nil
}
s.Write(nil)
return nil
})
// 处理play命令
rtmp.HandleCommand(rtmp.CMD_PLAY, func(w rtmp.MessageWriter, r *rtmp.Request) error {
s, ok := streams[r.StreamPath]
if !ok {
return rtmp.ResponsePlay(w, false, "stream not found")
}
err := rtmp.ResponsePlay(w, true, "")
if err != nil {
return err
}
go func(it rtmp.Iterator) {
for {
p, err := it.Next()
if err != nil {
break
}
err = w.WriteMessage(rtmp.NewMessage(p))
if err != nil {
break
}
}
fmt.Println("播放结束")
}(s.Iterator())
return nil
})
// 处理音视频数据
rtmp.HandleData(func(app, path string, p *av.Packet) error {
s, ok := streams[path]
if !ok {
s = rtmp.NewStream(3000)
streams[path] = s
}
s.Write(p)
return nil
})
// 使用默认端口启动rtmp服务
err := rtmp.ListenAndServe("", nil)
log.Fatal(err)
}