第3章

RTP与RTCP流媒体协议








9min


RTP是针对Internet上多媒体数据流的一个传输协议,由IETF(Internet工程任务组)作为RFC 1889发布。RTP被定义为在一对一或一对多的传输情况下工作,其目的是提供时间信息和实现流同步。RTP的典型应用建立在UDP上,但也可以在TCP或ATM等其他协议之上工作。RTP本身只保证实时数据的传输,并不能为按顺序传送数据包提供可靠的传送机制,也不提供流量控制或拥塞控制,它依靠RTCP提供这些服务。

多媒体应用的一个显著特点是数据量大,并且许多应用对实时性要求比较高。传统的TCP 协议是一个面向连接的协议,它的重传机制和拥塞控制机制都不适用于实时多媒体传输。RTP 是一个应用型的传输层协议,它并不提供任何传输可靠性的保证和流量的拥塞控制机制。RTP 位于UDP(User Datagram Protocol) 之上。UDP 虽然没有TCP 那么可靠,并且无法保证实时业务的服务质量,需要RTCP 实时监控数据传输和服务质量,但是,由于UDP 的传输时延低于TCP,能与声频和视频很好地配合,因此在实际应用中RTP/ RTCP/ UDP 用于传输声频/视频媒体,而TCP 用于数据和控制信令的传输。目前支持流媒体传输的协议主要有实时传输协议(Realtime Transport Protocol,RTP)、实时传输控制协议(Realtime Transport Control Protocol,RTCP)和实时流协议(Real Time Streaming Protocol,RTSP)等。RTP标准可以参考RFC 3550,网址为https://datatracker.ietf.org/doc/html/rfc3550#section5.3.1。

3.1RTP

RTP用来为语音、图像、传真等多种需要实时传输的多媒体数据提供端到端的实时传输服务。RTP为Internet上端到端的实时传输提供时间信息和流同步,但并不保证服务质量,服务质量由RTCP来提供。RTP用来提供实时传输,因而可以看成传输层的一个子层。流媒体应用中的一个典型的协议体系结构,如图31所示。



图31流媒体体系结构的协议栈


RTP用于在单播或多播网络中传送实时数据,它们典型的应用场合有以下几个:

(1) 简单的多播声频会议。语音通信通过一个多播地址和一对端口实现。一个用于声频数据(RTP),另一个用于控制包(RTCP)。

(2) 声频和视频会议。如果在一次会议中同时使用了声频和视频会议,这两种媒体将分别在不同的RTP会话中传送,每个会话使用不同的传输地址(IP地址+端口)。如果一个用户同时使用了两个会话,则每个会话对应的RTCP包都使用规范化名字CNAME(Canonical Name)。与会者可以根据RTCP包中的CNAME获取相关联的声频和视频,然后根据RTCP包中的计时协议相关信息(Network Time Protocol)实现声频和视频的同步。

(3) 翻译器和混合器。翻译器和混合器都是RTP级的中继系统。翻译器用在通过IP多播不能直接到达的用户区,例如发送者和接收者之间存在防火墙。当与会者能接收的声频编码格式不一样,例如有一个与会者通过一条低速链路接入到高速会议,这时就要使用混合器。在进入声频数据格式需要变化的网络前,混合器将来自一个源或多个源的声频包进行重构,并把重构后的多个声频合并,采用另一种声频编码进行编码后,再转发这个新的RTP包。从一个混合器出来的所有数据包要用混合器作为它们的同步源(SSRC)来识别,可以通过贡献源列表(CSRC)确认谈话者。





RTP详细说明了在互联网上传递声频和视频的标准数据包格式。它一开始被设计为一个多播协议,但后来被用在很多单播应用中。RTP常用于流媒体系统(配合RTCP或者RTSP)。因为RTP自身具有时间戳(Timestamp),所以在FFmpeg 中被用作一种Format(封装格式)。

3.1.1RTP格式

RTP格式,如图32所示。



图32RTP格式


RTP的各个字段,解释如下。

(1) V: RTP的版本号,占2b,当前协议版本号为2。

(2) P: 填充标志,占1b,如果P=1,则在该报文的尾部填充一个或多个额外的八位组,它们不是有效载荷的一部分。

(3) X: 扩展标志,占1b,如果X=1,则在RTP报头后跟有一个扩展报头。

(4) CC: CSRC计数器,占4b,指示CSRC 标识符的个数。

(5) M: 标记,占1b,不同的有效载荷有不同的含义,对于视频,标记一帧的结束; 对于声频,标记会话的开始。

(6) PT: 有效荷载类型,占7b,用于说明RTP报文中有效载荷的类型,如GSM声频、JPEG图像等,在流媒体中大部分用来区分声频流和视频流,这样便于客户端进行解析。RTP的负载类型,如表31所示。

表31RTP负载类型



负载类型编 码 类 型A/V时钟频率声道数参 考 文 档

0PCMUA80001[RFC 3551]
1Reserved
2Reserved
3GSMA80001[RFC 3551]
4G723A80001[Vineet_Kumar][RFC 3551]
5DVI4A80001[RFC 3551]
6DVI4A160001[RFC 3551]
7LPCA80001[RFC 3551]
8PCMAA80001[RFC 3551]
9G722A80001[RFC 3551]10
L16A441002[RFC 3551]11
L16A441001[RFC 3551]12
QCELPA80001[RFC 3551]
13CNA80001[RFC 3389]
14MPAA90000[RFC 3551][RFC 2250]
15G728A80001[RFC 3551]16
DVI4A110251[Joseph_Di_Pol]
17DVI4A220501[Joseph_Di_Pol]
18G729A80001[RFC 3551]

续表


负载类型编 码 类 型A/V时钟频率声道数参 考 文 档

19ReservedA20
UnassignedA21
UnassignedA
22UnassignedA

23
Unassigned
A



24
Unassigned
V



25
CelB
V
90000

[RFC 2029]
26
JPEG
V
90000

[RFC 2435]
27
Unassigned
V



28
nv
V
90000

[RFC 3551]
29
Unassigned
V



30
Unassigned
V



31
H261
V
90000

[RFC 4587]
32
MPV
V
90000

[RFC 2250]
33
MP2T
AV
90000

[RFC 2250]
34
H263
V
90000

[Chunrong_Zhu]
3571
Unassigned
?



7276
Reserved for RTCP conflict avoidance



[RFC 3551]
7795
Unassigned
?



96127
dynamic
?


[RFC 3551]


注意: 基本的A/V列中的A表示声频,V表示视频,?表示未知。

(7) 序列号(Sequence Number): 占16b,用于标识发送者所发送的RTP报文的序列号,每发送一个报文,序列号增1。这个字段目前层的承载协议用UDP时,网络状况不好的时候可以用来检查丢包。同时在出现网络抖动的情况下可以用来对数据进行重新排序,序列号的初始值是随机的,同时声频包和视频包的sequence分别进行记数。

(8) 时间戳(Timestamp): 占32b,必须使用90kHz时钟频率。时间戳反映了该RTP报文的第1个八位组的采样时刻。接收者使用时间戳来计算延迟和延迟抖动,并进行同步控制。

(9) 同步信源(SSRC)标识符: 占32b,用于标识同步信源。该标识符是随机选择的,参加同一视频会议的两个同步信源不能有相同的SSRC。

(10) 贡献信源(CSRC)标识符: 每个CSRC标识符占32b,可以有0~15个。每个CSRC标识了包含在该RTP报文有效载荷中的所有贡献信源。

注意: 基本的RTP说明并不定义任何头扩展,如果遇到X=1,则需要特殊处理。

下面是一段示例码流,用十六进制显示如下: 



80 e0 00 1e 00 00 d2 f0 00 00 00 00 41 9b 6b 49 ?....??....A?kI

e1 0f 26 53 02 1a ff06 59 97 1d d2 2e 8c 50 01 ?.&S....Y?.?.?P.

cc 13 ec 52 77 4e e50e 7b fd 16 11 66 27 7c b4 ?.?RwN?.{?..f'|?

f6 e1 29 d5 d6 a4 ef3e 12 d8 fd 6c 97 51 e7 e9 ??)????>.??l?Q??

cfc7 5e c8 a9 51 f6 82 65 d6 48 5a 86 b0 e0 8c ??^??Q??e?HZ????





各字节的值及对应的字段,如表32所示。

表32RTP示例码流的值及对应字段



十 六 进 制对 应 字 段十 六 进 制对 应 字 段

80V、P、X、CC00 00 d2 f0Timestamp

e0M、PT00 00 00 00SSRC

00 1eSequenceNumber


把前两字节(0x80e0)换成二进制,即1000 0000 1110 0000,按顺序解释,如表33所示。

表33示例码流的前两字节的二进制及对应字段



前两字节的二进制对 应 字 段

10
V,即版本是2
0
P,填充标志,0表示没有
0
X,扩展标志,0表示没有
0000
CC,CSRC计数器
1
M,标记位; 对于视频,标记一帧的结束
110 0000
PT,有效荷载类型,这里的值为96,代表H.264


3.1.2RTP封装H.264

RTP荷载格式定义了3种不同的基本荷载结构,接收者可以通过RTP荷载的第1字节的后5位识别荷载结构,如图33所示。



图33RTP荷载第1字节的字段结构


(1) 单个H.264的NAL单元包: 荷载中只包含一个NAL单元。NAL头类型域等于原始 NAL单元类型,即在范围1到23之间。

(2) 聚合包: 本类型用于将多个NAL单元聚合到单个RTP荷载中。本包有4个版本,包括单时间聚合包类型A(STAPA)、单时间聚合包类型B(STAPB)、多时间聚合包类型(MTAP)16位位移(MTAP16)和多时间聚合包类型(MTAP)24位位移(MTAP24)。赋予STAPA、STAPB、MTAP16、MTAP24的NAL单元类型号分别是24、25、26、27。

(3) 分片单元: 用于将单个NAL单元分片到多个RTP包。现存两个版本FUA和FUB,分别用NAL单元类型28和29标识。

常用的打包时的分包规则是: 如果小于MTU,则采用单个NAL单元包,如果大于MTU,则采用分片方式(FUs)。因为常用的打包方式是单个NAL包和FUA方式,这里只解析这两种。

1. 单个NAL单元包

定义在此的NAL单元包必须只包含一个。这意味着聚合包和分片单元不可以用在单个NAL 单元包中,并且RTP序号必须符合NAL单元的解码顺序。NAL单元的第一字节和RTP荷载头的第1字节重合,如图34所示。打包H.264码流时,只需要在帧前面加上12字节的RTP头。



图34RTP荷载单个NAL单元


2. 分片单元(FUA)

分片只定义于单个NAL单元,不用于任何聚合包。NAL单元的一个分片由整数个连续NAL单元字节组成。每个NAL单元字节必须正好是该NAL单元一个分片的一部分。相同NAL单元的分片必须使用递增的RTP序号连续按顺序发送,即第一和最后分片之间没有其他的RTP包。类似地,NAL单元必须按照RTP顺序号的顺序装载。

当一个NAL单元被分片运送在分片单元(FUs)中时,被引用为分片NAL单元。STAPs和MTAPs不可以被分片。FUs不可以嵌套,即一个FU 不可以包含另一个FU。运送FU的RTP时戳被设置成分片NAL单元的NALU时刻。

FUA的RTP荷载格式,如图35所示。FUA由1字节的分片单元指示(FU indicator,如图33所示)、1字节的分片单元头(FU header,如图36所示)和分片单元荷载(FU payload)组成。



图35RTP荷载的分片单元格式(FUA)




图36RTP的分片单元头(FUA header)


RTP的分片单元头(FUA header)的各个字段(如图36所示),解释如下。

(1) S: 长度为1b,当设置成1时,开始位指示分片NAL单元的开始。当跟随的FU荷载不是分片NAL单元荷载的开始时,开始位设为0。

(2) E: 长度为1b,当设置成1时,结束位指示分片NAL单元的结束,即荷载的最后字节也是分片NAL单元的最后一字节。当跟随的FU荷载不是分片NAL单元的最后分片时,结束位设置为0。

(3) R: 长度为1b,保留位,必须设置为0,接收者必须忽略该位。

(4) Type: 长度为5b,打包时,原始的NAL头的前三位为FU indicator的前三位,原始的NAL头的后五位为FU header的后五位,即这里的Type字段,表示H.264的NALU类型。

3. RTP示例码流分析

下面是一段RTP示例码流,用十六进制显示如下: 


80 60 01 0f 00 0e 10 00 00 00 00 00 7c 85 88 82€`..........|???

00 0a 7f ca 94 05 3b 7f 3e 7f fe 14 2b 27 26 f8 ...??.;.>.?.+'&?

89 88 dd 85 62 e1 6d fc 33 01 38 1a 10 35 f2 14 ????b?m?3.8..5?.

84 6e 21 24 8f 72 62 f0 51 7e 10 5f 0d 42 71 12 ?n!$?rb?Q~._.Bq.

17 65 62 a1 f1 44 dc df 4b 4a 38 aa 96 b7 dd 24 .eb??D??KJ8????$ 





(1) 前12B是RTP Header,具体字段格式如图32所示。

(2) 7c是FU indicator。

(3) 85是FU header。

(4) FU indicator(0x7c)和FU header(0x85)换成二进制,即0111 1100 1000 0101,按顺序解释,如表34所示。

表34FU indicator和FU header的字段的二进制值及解释



二进制对应的值字 段 解 释

0F,即版本是2
11NRI,填充标志,0表示没有
11100FU Type,这里是28,即FUA分片模式
1S,Start,1表示是分片的第1个包。这里的值为1,说明是分片的第一包

续表


二进制对应的值字 段 解 释

0E,End,如果是分片的最后一包,设置为1。这里是0,表示不是分片的最后一个包
0R,Remain,保留位,总是0
00101NAL Type,这里是5,说明是关键帧


打包时,FU indicator的F、NRI是NAL header中的F、NRI,Type是28; FU header的S、E、R分别按照分片起始位置设置,Type是NAL header中的Type。

解包时,取FU indicator的前三位和FU header的后五位,即0110 0101(0x65)为H.264的NALU header(共8b)。

3.1.3RTP的会话过程

当应用程序建立一个RTP会话时,应用程序将确定一对目的地传输地址。目的地传输地址由一个网络地址和一对端口组成,有两个端口: 一个给RTP包,另一个给RTCP包,使RTP/RTCP数据能够正确发送。RTP数据发向偶数的UDP端口,而对应的控制信号RTCP数据发向相邻的奇数UDP端口(偶数的UDP端口+1),这样就构成一个UDP端口对。RTP的发送过程如下,接收过程则相反。

(1) RTP从上层接收流媒体信息码流(如H.264),封装成RTP数据包; RTCP从上层接收控制信息,封装成RTCP控制包。

(2) RTP将RTP 数据包发往UDP端口对中的偶数端口; RTCP将RTCP控制包发往UDP端口对中的接收端口。

3.1.4RTP的抓包分析

安装好Wireshark之后,双击“RTSP交互流程抓包分析(章节2.8.3).pcapng”(可从本书对应的代码资料中下载),弹出界面,可以看出包含声频流和视频流,如图37所示。



图37RTP的抓包流程截图


然后,双击序号为71的那一行,弹出界面如图38所示。



图38RTP包的详细字段解析


该RTP包的各个字段如下: 



10.. .... = Version: RFC 1889 Version (2)//版本号为2

..0. .... = Padding: False  //没有填充位

...0 .... = Extension: False  //没有扩展

.... 0000 = Contributing source identifiers count: 0 //CC

1... .... = Marker: True  //M,标记位

Payload type: DynamicRTP-Type-96 (96)  //PT:负载类型,这里为H.264码流

Sequence number: 5963	  //序列号

[Extended sequence number: 71499]

Timestamp: 14556300	  //时间戳

Synchronization Source identifier: 0x885130ec (2287022316)  //SSRC

Payload: 5c41114cca2d06fe257bff25d3c57264cf1220954fe23881… //负载





3.2RTCP

实时传输控制协议(Realtime Transport Control Protocol,RTCP)负责管理传输质量并在当前应用进程之间交换控制信息。在RTP会话期间,各参与者周期性地传送RTCP包,包中含有已发送的数据包的数量、丢失的数据包的数量等统计资料,因此,服务器可以利用这些信息动态地改变传输速率,甚至改变有效载荷类型。RTP和RTCP配合使用,能以有效的反馈和最小的开销使传输效率最佳化,故特别适合传送网上的实时数据。

当应用程序开始一个RTP会话时将使用两个端口: 一个给RTP,另一个给RTCP。RTP本身并不能为按顺序传送数据包提供可靠的传送机制,也不提供流量控制或拥塞控制,它依靠RTCP提供这些服务。在RTP的会话之间周期性地发放一些RTCP包以用来监听服务质量和交换会话用户信息等功能。RTCP包中含有已发送的数据包的数量、丢失的数据包的数量等统计资料,因此,服务器可以利用这些信息动态地改变传输速率,甚至改变有效载荷类型。RTP和RTCP配合使用,它们能以有效的反馈和最小的开销使传输效率最佳化,因而特别适合传送网上的实时数据。根据用户间的数据传输反馈信息,可以制定流量控制的策略,而会话用户信息的交互,可以制定会话控制的策略。

3.2.1RTCP的5种分组类型

RTP需要RTCP为其服务质量提供保证,RTCP的主要功能是服务质量的监视与反馈、媒体间的同步及多播组中成员的标识。在RTP会话期间,各参与者周期性地传送RTCP包。RTCP包中含有已发送的数据包的数量、丢失的数据包的数量等统计资料,因此,各参与者可以利用这些信息动态地改变传输速率,甚至改变有效载荷类型。RTP和RTCP配合使用,它们能以有效的反馈和最小的开销使传输效率最佳化,因而特别适合传送网上的实时数据。RTCP也是用UDP来传送的,但RTCP封装的仅仅是一些控制信息,因而分组很短,所以可以将多个RTCP分组后封装在一个UDP包中。在RTCP通信控制中,RTCP的功能是通过不同的RTCP数据报实现的,有5种分组类型,如表35所示。

表35RTCP的5种分组类型



类型缩写用途

200SR(Sender Report)发送端报告
201RR(Receiver Report)接收端报告
202SDES(Source DEScription)源描述
203BYE通知离开
204APP应用程序自定义


(1) SR: 发送端报告,所谓发送端是指发出RTP数据报的应用程序或者终端,发送端同时也可以是接收端。

(2) RR: 接收端报告,所谓接收端是指仅接收但不发送RTP数据报的应用程序或者终端。

(3) SDES: 源描述,主要功能是作为会话成员有关标识信息的载体,如用户名、邮件地址、电话号码等,此外还具有向会话成员传达会话控制信息的功能。

(4) BYE: 通知离开,主要功能是指示某一个或者几个源不再有效,即通知会话中的其他成员自己将退出会话。

(5) APP: 由应用程序自己定义,解决了RTCP的扩展性问题,并且为协议的实现者提供了很大的灵活性。

3.2.2RTCP包结构

上述5种分组的封装大同小异,下面只讲述SR类型,而其他类型可参考RFC 3550。发送端报告分组SR(Sender Report)用来使发送端以多播方式向所有接收端报告发送情况。SR分组的主要内容有相应的RTP流的SSRC、RTP流中最新产生的RTP分组的时间戳和NTP、RTP流包含的分组数,以及RTP流包含的字节数。SR包的封装结构,如图39所示。



图39RTCP的头格式


RTCP头部格式的各个字段解释如下。

(1) 版本(V): 同RTP包头域。

(2) 填充(P): 同RTP包头域。

(3) 接收报告计数器(RC): 5b,该SR包中的接收报告块的数目,可以为零。

(4) 包类型(PT): 8b,SR包是200。

(5) 长度域(Length): 16b,其中存放的是该SR包以32b为单位的总长度减一。

(6) 同步源(SSRC): SR包发送者的同步源标识符。与对应RTP包中的SSRC一样。

(7) NTP(Network Time Protocol)timestamp:  SR包发送时的绝对时间值。NTP的作用是同步不同的RTP媒体流。

(8) RTP timestamp: 与NTP时间戳对应,与RTP数据包中的RTP时间戳具有相同的单位和随机初始值。

(9) Sender’s packet count: 从开始发送包到产生这个SR包这段时间里,发送者发送的RTP数据包的总数。SSRC改变时,这个域清零。

(10) Sender’s octet count: 从开始发送包到产生这个SR包这段时间里,发送者发送的净荷数据的总字节数(不包括头部和填充)。发送者改变其SSRC时,这个域要清零。

(11) 同步源n的SSRC标识符: 该报告块中包含的是从该源接收的包的统计信息。

(12) 丢失率(Fraction Lost): 表明从上一个SR或RR包发出以来从同步源n(SSRC_n)来的RTP数据包的丢失率。

(13) 累计的包丢失数目: 从开始收到SSRC_n的包到发送SR,从SSRC_n传过来的RTP数据包的丢失总数。

(14) 收到的扩展最大序列号: 从SSRC_n收到的RTP数据包中最大的序列号。

(15) 接收抖动(Interarrival Jitter): RTP数据包接受时间的统计方差估计。

(16) 上次SR时间戳(Last SR,LSR): 取最近从SSRC_n收到的SR包中的NTP时间戳的中间32b。如果目前还没收到SR包,则该域清零。

(17) 上次SR以来的延时(Delay since Last SR,DLSR): 上次从SSRC_n收到SR包到发送本报告的延时。

3.2.3RTCP的注意事项

不同类型的RTCP信息包可堆叠,不需要插入任何分隔符就可以将多个RTCP包连接起来形成一个RTCP组合包,然后由低层协议用单一包发送出去。由于需要低层协议提供整体长度来决定组合包的结尾,在组合包中没有单个RTCP包的显式计数。

组合包中每个RTCP包可独立处理,而不需要按照包组合的先后顺序处理。在组合包中有以下几条强制约束: 

(1) 只要带宽允许,在SR包或RR包中的接收统计应该经常发送,因此每个周期发送的组合RTCP 包中应包含报告包。

(2) 每个组合包中都应该包含SDES CNAME,因为新接收者需要通过接收CNAME来识别源,并与媒体联系进行同步。

(3) 组合包前面是包类型数量,其增长应该受到限制。

所有RTCP包至少必须以两个包组合的形式发送,推荐格式如下: 

(1) 加密前缀(Encryption Prefix): 

(2) 仅当组合包被加密,才加上一个32位随机数用于每个组合包发送。

对于SR或RR,组合包中第1个RTCP包必须是一个报告包,以帮助分组头的确认。即使没有数据发送,也没有收到数据,也要发送一个空RR,哪怕组合包中RTCP包为BYE。对于附加RR,如报告统计源数目超过31,在初始报告包后应该有附加RR包。对于SDES,包含CNAME 项的SDES包必须包含在每个组合RTCP包中; SDES包可能包括其他源描述项,这要根据特别的应用需要,并同时考虑带宽限制。对于BYE或APP,除了BYE应作为最后一个包发送,其他RTCP包类型可以按任意顺序排列,包类型的出现次数可不止一次。

混合器从多个源组合单个RTCP包,如组合包整体长度超过网络路径最大传输单元,则可分成多个较短组合包并用低层协议以单个包形式发送。注意,每个组合包必须以SR或RR包开始。附加RTCP包类型可在Internet Assigned Numbers Authority (IANA)处注册,以获得合法的类型号。

1. RTCP传输间隔

由于RTP被设计成允许应用自动扩展,所以可从几个人的小规模系统扩展成上千人的大规模系统,而每个会话参与者可周期性地向所有其他参与者发送RTCP控制信息包,如每个参与者以固定速率发送接收报告,控制流量将随参与者的数量线性增长。由于网络资源有限,相应的数据包就要减少,直接影响用户关心的数据传输。为了限制控制信息的流量,RTCP控制信息包速率必须按比例下降。一旦确认加入RTP会话中,即使后来被标记成非活动站,地址的状态仍会被保留,地址应继续计入共享RTCP带宽地址的总数中,时间要保证能扫描典型网络分区,建议为30分钟。注意,这仍大于RTCP报告间隔最大值的五倍。

2. SR源报告包和RR接收者报告包

SR源报告包和RR接收者报告包用于提供接收质量反馈,除包类型代码外,SR与RR间唯一的差别是源报告包含一个20字节发送者信息段。RR针对每个信源都提供信息包丢失数、已收信息包最大序列号、到达时间抖动、接收最后一个SR的时间、接收最后一个SR的延迟等信息。SR不仅提供接收质量反馈信息(与RR相同),而且提供SSRC标识符、NTP时间戳、RTP时间戳、发送包数及发送字节数等。根据接收者是否为发送者来决定使用SR还是RR包,活动源在发出最后一个数据包之后或前一个数据包与下一个数据包间隔期间发送SR; 否则,就发送RR; SR和RR包都可没有接收报告块,也可以包括多个接收报告块,其发布报告表示的源不一定是在CSRC列表上的起作用的源,每个接收报告块提供从特殊源接收数据的统计。最大可有31个接收报告块嵌入在SR 或 RR包中,丢失包累计数差别给出间隔期间丢包的数量,而系列号的差别给出间隔期间希望发送的包数量,两者之比等于经过间隔期间包丢失百分比。从发送者信息,第三方监控器可计算载荷平均数据速率与没收到数据间隔的平均包速率,两者比值给出平均载荷大小。如假设包丢失与包大小无关,则特殊接收者收到的包数量给出此接收者收到的表观流量。

3. SDES源描述包

SDES源描述包提供了直观的文本信息,用来描述会话的参加者,包括CNAME、NAME、EMAIL、PHONE、LOC等源描述项,这些为接收方获取发送方的有关信息提供了方便。SDES 包由包头与数据块组成,数据块可以没有,也可有多个。包头由版本(V)、填充(P)、长度指示、包类型(PT)和源计数(SC)组成。PT占8位,用于识别RTCP的SDES包,SC占5位,用于指示包含在SDES包中的SSRC/CSRC块的数量,零值有效,但没有意义。数据块由源描述项组成,源描述项的内容如下: 

(1) CNAME用于规范终端标识SDES项,类似SSRC标识,RTCP为RTP连接中每个参加者赋予唯一一个CNAME标识。在发生冲突或重启程序时,由于随机分配的SSRC标识可能发生变化,CNAME项可以提供从SSRC标识到仍为常量的源标识的绑定。为了方便第三方监控,CNAME应适合程序或人员定位源。

(2) NAME是指用户名称SDES项,是用于描述源的真正的名称,如John Doe、Bit Recycler、Megacorp等,可以是用户想要的任意形式。由于采用文本信息来描述,对诸如会议应用,可以对参加者直接列表显示,NAME项是除CNAME项以外发送最频繁的项目。NAME值在一次RTP会话期间应该保持为常数,但它不该成为连接的所有参加者中的唯一依赖。

(3) EMAIL是指电子邮件地址SDES项,邮件地址格式由RFC 822规定,如John.Doe@megacorp.com。一次RTP会话期间,EMAIL项的内容应保持不变。

(4) PHONE是指电话号码SDES项,电话号码应带有加号,代替国际接入代码,如+1 908 555 1212为美国电话号码。

(5) LOC是指用户地理位置SDES项,根据应用,此项具有不同程度的细节。对会议应用,字符串(如Murray Hill、New Jersey)就足够了,然而,对活动标记系统,字符串(如Room 2A244, AT&T BL MH)就适用。细节留给实施者或用户,但格式和内容可用来设置指示。在一次RTP会话期间,除移动主机外,LOC值应保持不变。

(6) TOOL是指应用或工具名称SDES项,包含一个字符串,表示产生流的应用的名称与版本,如videotool 1.2。这部分信息对调试很有用,类似于邮件或邮件系统版本SMTP头。TOOL值在一次RTP会话期间应保持不变。

(7) NOTE是指通知/状态SDES项,旨在描述源当前状态的过渡信息,如on the phone、cant talk,或在讲座期间用于传送谈话的题目,它的语法可在设置中显式定义。NOTE项一般只用于携带例外信息,而不应包含在全部参加者中,因为这将降低接收报告和CNAME发送的速度,降低协议的性能。一般NOTE 项不作为用户设置文件的项目,也不会自动产生。由于NOTE项对显示很重要,当会话的参加者处于活动状态时,其他非CNAME项(如NAME)传输速率将会降低,结果使NOTE项占用RTCP部分带宽。若过渡信息不活跃,则NOTE项会继续以同样的速度重复发送几次,并以一个串长为零的字符串通知接收者。

(8) PRIV专用扩展SDES项,用于定义实验或应用特定的SDES扩展,它是由长字符串对组成的前缀、后跟填充该项其他部分和携带所需信息的字符串值组成。前缀长度段为8位。前缀字符串是定义PRIV项人员选择的名称,唯一对应应用接收的其他PRIV项。应用实现者可选择使用应用名称,如有必要,外加附加子类型标识。另外,推荐其他人根据其代表的实体选择名称,然后在实体内部协调名称的使用。注意,前缀应尽可能短。SDES的PRIV项前缀没在IANA处注册。如证实某些形式的PRIV项具有通用性,则IANA应给它分配一个正式的SDES项类型,这样就不再需要前缀,从而简化应用,并提高传输的效率。

4. BYE断开RTCP包

如混合器接收到一个BYE包,混合器转发BYE包,而不改变SSRC/CSRC 标识。如混合器关闭,在关闭之前它应该发出一个BYE包,列出混合器处理的所有源,而不只是自己的SSRC标识。作为可选项,BYE包可包括一个8位八进制计数,后跟文本信息,表示离开原因,如cameramalfunction或RTPloop detected。字符串的编码与在SDES 项中所描述的编码相同。如字符串信息至BYE包下32位边界结束处,字符串就不以空结尾; 否则,BYE包以空八进制填充。

5. APP特殊应用包

APP包用于开发新应用和新特征的实验,不要求注册包类型值。带有不可识别名称的APP包应被忽略。测试后,如确定应用广泛,推荐重新定义每个APP包,而不用向IANA注册子类型和名称段。

3.2.4RTCP的抓包分析

安装好Wireshark之后,双击“RTSP交互流程抓包分析(章节2.8.3).pcapng”(可从本书对应的代码资料中下载),弹出界面,可以看出包含声频流和视频流,如图310所示。



图310RTCP抓包流程截图


双击序号为702的那一行,弹出界面如图311所示。



图311RTCP包的详细字段


该RTCP包的各个字段(可参考“3.2.2 RTCP包结构”)如下: 



//chapter3/rtcp.pack.filelds.txt

#Real-time Transport Control Protocol (Receiver Report)

10.. .... = Version: RFC 1889 Version (2)








..0. .... = Padding: False

...0 0001 = Reception report count: 1

Packet type: Receiver Report (201)

Length: 7 (32 Bytes)

Sender SSRC: 0x31bdfdbf (834534847)

#Source 1

Identifier: 0xf42bd674 (4096513652)

SSRC contents

Extended highest sequence number received: 93323

Interarrival jitter: 109

Last SR timestamp: 0 (0x00000000)

Delay since last SR timestamp: 0 (0 milliseconds)



#Real-time Transport Control Protocol (Source description)

10.. .... = Version: RFC 1889 Version (2)

..0. .... = Padding: False

...0 0001 = Source count: 1

Packet type: Source description (202)

Length: 6 (28 Bytes)

#Chunk 1, SSRC/CSRC 0x31BDFDBF

Identifier: 0x31bdfdbf (834534847)

#SDES items

Type: CNAME (user and domain) (1)

Length: 15

Text: DESKTOP-NUCTJFU

Type: END (0)

负载





3.3RTP/RTCP与RTSP的关系

RTP与RTCP通常会结合在一起使用,RTP用于传输媒体流,而RTSP是媒体控协议。RTP/RTCP一般是基于UDP来传输数据的,但也可以基于TCP,因此可以将RTP划分到传输层。RTP、RTCP与RTSP的网络层级关系如图312所示。



图312RTP、RTCP、RTSP的网络层级关系


RTP是用于Internet上针对多媒体数据流的一种传输层协议,详细说明了在互联网上传递声频和视频的标准数据包格式。RTP协议常用于流媒体系统,并配合RTCP、视频会议和一键通系统(配合H.323或SIP),使它成为IP电话产业的技术基础。RTP和控制协议RTCP一起使用,通常建立在UDP上。RTP本身并没有提供按时发送机制或其他服务质量(QoS)保证,它依赖于低层服务去实现这一过程。RTP并不保证传送或防止无序传送,也不确定底层网络的可靠性。RTP实行有序传送,RTP中的序列号允许接收方重组发送方的包序列,同时序列号也能用于决定适当的包位置,例如在视频解码中就不需要按顺序解码。可以把广义的RTP理解为由两个紧密连接的部分组成,即RTP和RTCP。RTP用于传送具有实时属性的数据; RTCP用于监控服务质量并传送正在进行的会话参与者的相关信息。

RTCP是RTP的一个姐妹协议。RTCP为RTP媒体流提供信道外(outofband)控制。RTCP本身并不传输数据,但和RTP一起协作将多媒体数据打包和发送。RTCP定期在流多媒体会话参加者之间传输控制数据。RTCP的主要功能是为RTP所提供的服务质量(Quality of Service,QoS)提供反馈。RTCP收集相关媒体连接的统计信息,例如传输字节数、传输分组数、丢失分组数、jitter、单向和双向网络延迟等。网络应用程序可以利用RTCP所提供的信息试图提高服务质量,例如限制信息流量或改用压缩比较小的编解码器。RTCP本身不提供数据加密或身份认证。SRTCP可以用于此类用途。

安全实时传输协议(Secure Realtime Transport Protocol,SRTP)是在RTP的基础上定义的一个协议,旨在为单播和多播应用程序中的RTP的数据提供加密、消息认证、完整性保证和重放保护。它是由思科公司和爱立信公司开发的,并最早由IETF于2004年3月作为RFC 3711发布。由于RTP和可以被用来控制RTP会话的RTCP有着紧密的联系,SRTP同样也有一个伴生协议,它被称为安全实时传输控制协议(Secure RTCP,SRTCP)。SRTCP为RTCP提供类似的与安全有关的特性,就像SRTP为RTP提供的那些一样。在使用RTP或RTCP时,使不使用SRTP或SRTCP是可选的,但即使使用了SRTP或SRTCP,它们提供的所有特性(如加密和认证)也都是可选的,这些特性可以被独立地使用或禁用。唯一的例外是在使用SRTCP时,必须使用其消息认证特性。

RTSP是用来控制声音或影像的多媒体串流协议,并允许同时多个串流需求控制,传输时所用的网络通信协定并不在其定义的范围内,服务器端可以自行选择使用TCP或UDP来传送串流内容,它的语法和运作跟HTTP 1.1类似,但并不特别强调时间同步,所以比较能容忍网络延迟,而前面提到的允许同时多个串流需求控制(Multicast),除了可以降低服务器端的网络用量,更进而支持多方视频会议(Video Conference)。因为与HTTP 1.1的运作方式相似,所以代理服务器的缓存功能也同样适用于RTSP,并因RTSP具有重新导向功能,可根据实际负载情况来转换提供服务的服务器,以避免过大的负载集中于同一服务器而造成延迟。

RTP不像HTTP和FTP那样可完整地下载整个影视文件,它是以固定的数据率在网络上发送数据,客户端也是按照这种速度观看影视文件,当影视画面播放过后,就不可以再重复播放了,除非重新向服务器端请求数据。RTSP与RTP最大的区别在于: RTSP是一种双向实时数据传输协议,它允许客户端向服务器端发送请求,如回放、快进、倒退等操作。当然,RTSP可基于RTP来传送数据,还可以选择TCP、单播UDP或组播UDP等通道来发送数据,具有很好的扩展性。它是一种类似于HTTP的网络应用层协议。例如一个应用场景,服务器端实时采集、编码并发送两路视频,客户端接收并显示这两路视频。由于客户端不必对视频数据做任何回放、倒退等操作,所以可直接采用UDP+RTP+组播实现。RTP、RTCP与RTSP的关系与作用,如图313所示。



图313RTP、RTCP与RTSP的关系与作用


3.4开源库JRTPLIB简介

JRTPLIB是一个基于C++、面向对象的RTP封装库,目前的较新版本是3.x.x。为了与RFC 3550相兼容,3.x.x版本已被完全重写,现在它提供了一些非常有用的组件,这些组件为构建各种各样的RTP应用程序开发提供了有用的帮助。较旧的2.x.x版本依然可用,但是不兼容RFC 3550。JRTPLIB支持定义于RFC 3550中的RTP,它使发送和接收RTP报文变得非常简单,用户不用担心SSRC冲突,也不用考虑如何传输RTCP数据,因为RTCP功能完全在内部实现,不需用户手动操作。当发送RTP报文时,用户只需简单地给发送函数提供负载数据; 当接收数据时,JRTPLIB提供了访问传入的RTP和RTCP数据的接口。

目前为止,JRTPLIB支持的平台主要包括GNU/Linux、MSWindows和Solaris等,也可以运行于其他类UNIX环境。

JRTPLIB可以使用JTHREAD库在后台自动轮询传入的数据,所以安装JTHREAD库是个很好的选择。如果没有安装JTHREAD库,JRTPLIB也能正常工作,但是需要用户自己轮询传入的数据。3.x.x版本的JRTPLIB至少需要1.3.0版本的JTHREAD库。JRTPLIB官网网址是http://research.edm.uhasselt.be/jori/page/Main/HomePage.html,可以从官网上下载源码。

3.4.1Windows 10+VS 2015编译JRTPLIB

操作系统使用Windows 10,读者自己安装好CMake 3.12.0,以及Visual Studio 2015/2017/2019。由于JRTPLIB依赖于JTHREAD库,需要下载以下内容。

(1) jrtplib: http://research.edm.uhasselt.be/jori/jrtplib/jrtplib3.11.1.zip。

(2) jthread: http://research.edm.uhasselt.be/jori/jthread/jthread1.3.3.zip。

(3) cmake: https://cmake.org/files/v3.12/cmake3.12.0win64x64.msi。

注意: 如果下载网址无法使用,读者则可以自己百度,也可以从清华大学出版社网站上本书对应的课件资料中下载(jrtplib3.11.1.zip、jthread1.3.3.zip、cmake3.12.0win64x64.msi)。

详细的编译步骤如下:

(1) 安装cmakegui。

(2) 将下载的jrtplib和jthread压缩包进行解压缩,同时在同目录下创建build文件夹。

(3) 以下过程主要是编译jthread 并生成 jthread.lib和jthread_d.lib。

打开cmakegui,首先添加输入源(where..)和输出路径(where to…),单击Configure,目标选择VS 2015/2017/2019默认编译器,然后详细检查参数,如图314所示。确认无误后再单击configure,最后单击Generate,生成VS 2015/2017/2019工程文件,如图315所示。



图314CMake配置JTHREAD




图315CMake生成的JTHREAD工程文件


单击Open Project打开工程,如图316所示。



图316VS打开JTHREAD工程


编译的具体方法为选择解决方案资源管理器里的解决方案jthread,运行“重新生成解决方案”; 如果没有出现错误,再选择INSTALL项目,运行“生成”。debug和release各进行一次上述操作即可。如果编译成功(如图316所示),则会在对应目录的\jthread\include\jthread下生成头文件; 在lib文件夹下生成lib和cmake文件。

(4) 以下过程主要是编译jrtplib,生成 jrtplib.lib和jrtplib_d.lib。

大致的步骤与上述相同,但在编译和configure时需要添加一些配置,同样先输入源(where..)和输出路径(where to…),单击configure,目标选择VS 2015/2017/2019默认编译器,初始的配置结果如图317所示。



图317CMake配置JRTPLIB


注意这里需要添加几个路径,包括ADDITIONAL_DEBUG_LIBRARIES、ADDITIONAL_RELEASE_LIBRARIES和ADDITIONAL_INCLUDE_DIRS的路径。确认无误后再单击Configure,最后单击Generate,生成VS 2017工程文件,然后单击Open Project打开工程,如图318所示。



图318VS打开JRTPLIB工程


编译的具体方法为选择解决方案资源管理器里的解决方案jrtplib,运行“重新生成解决方案”; 如果没有出现错误,则选择INSTALL项目,运行“生成”。debug和release各进行一次上述操作即可。如果编译成功,则会在对应的目录\jrtplib\include\jthread生成头文件; 在lib文件夹下生成lib和cmake文件。

3.4.2Ubuntu 18编译JRTPLIB

Linux下编译JRTPLIB比较简单,遵循基本的三部曲,即configure、make、make install,这里以Ubuntu 18为例进行讲解。

(1) 源码下载,需要从JRTPLIB官网下载源码,这里需要下载两个库,包括JTHREAD和JRTPLIB,如图319所示。

注意: 官网网址http://research.edm.uhasselt.be/jori/page/Main/HomePage.html。



图319JRTPLIB官网


分别下载两个库JTHREAD和JRTPLIB,如图320所示。



图320JRTPLIB和JTHREAD下载


(2) 安装相关工具,JRTPLIB使用CMake构建,这里需要安装较新版本的CMake,可以使用以下命令安装:



sudo apt install cmake





(3) 编译并安装,先编译JTHREAD,使用下面的命令解压jthread1.3.3.tar.gz:



tar xvzf jthread-1.3.3.tar.gz





在源码文件外创建一个jthreadbuild文件夹,用于放编译文件,这样不会对源码有任何改动,命令如下:



mkdir jthread-build





进入jthreadbuild文件夹: 



cd jthread-build/





执行cmake命令生成makefile文件: 



cmake ../jthread-1.3.3 -DCMAKE_INSTALL_PREFIX=/usr





使用make命令进行源码的编译: 



make





使用make install命令进行安装: 



sudo make install





此时可以看到链接库安装在/usr/lib/目录下,头文件安装在/usr/include/jthread/目录下。

然后来编译JRTPLIB,使用如下命令解压jrtplib3.11.1.tar.gz: 



tar xvzf jrtplib-3.11.1.tar.gz





在源码文件外创建一个jrtplibbuild文件夹,用于放编译文件,这样不会对源码有任何改动: 



mkdir jrtplib-build





进入jrtplibbuild文件夹: 



cd jrtplib-build/





执行cmake命令生成makefile文件: 



cmake ../jrtplib-3.11.1 -DCMAKE_INSTALL_PREFIX=/usr





使用make命令进行源码的编译: 



make





使用make install命令进行安装: 



sudo make install





以上命令执行完后,库文件安装在/usr/lib/目录下,头文件在/usr/include/jrtplib3/目录下。

3.4.3使用VS 2015搭建JRTPLIB开发环境并收发包案例解析

上文介绍了jrtplib源码库的编译,下面介绍一个简单的JRTPLIB接收端和客户端。在使用JRTPLIB之前需要将其添加进工程,笔者以VS 2015(VS 2017/2019与此大同小异)作为IDE,编写一个VC程序,其他IDE参考VS即可,调用外部库通常有三点注意事项。

(1) 引用时需要的头文件.h/.hpp。

(2) 编译时需要的库文件.dll/.lib/.a。

(3) 运行时需要的动态库.dll/.so。

下面介绍详细的配置步骤。

1. 新建jrtplibdemo工程

使用VS 2015新建VC++空工程,移除创建的项目,然后添加sender和recver两个项目,如图321和图322所示。



图321VS 2015创建VC++新建项目




图322VS 2015添加两个新项目


为了调试方便,启用多个项目调试,即运行时可设置运行调试哪些项目,如图323所示。



图323VS 2015设置多项目启动


运行时,如图324所示。



图324VS 2015运行多项目


2. 引用JRTPLIB头文件和库文件

新建一个文件夹jthreaddevs,将JRTPLIB和JTHREAD的头文件和库文件整合到一起,如图325所示。



图325JTHREAD和JRTPLIB的头文件和库文件


为sender项目引入头文件和库文件目录(recver项目设置完全相同),如图326所示。其中,需要注意相对路径..\..\,需要将刚才创建的jthreaddevs文件夹放置到jrtplibdemoSenderRecver的同目录下,如图327所示。



图326为sender/recver项目设置目录和库目录




图327jthreaddevs的相对路径为..\..\


复制库文件,如果将jrtplib编译为静态库,则运行时不需要其他库; 如果将jrtplib编译为动态库,则运行时需要使用dll动态库,需要将DLL文件复制到exe文件输出目录下,如图328所示。

3. JRTPLIB示例代码实现发送和接收功能

发送端(参考sender项目下的sender.cpp文件)代码如下: 



图328动态编译则运行时需要单独的DLL文件





//chapter3/jrtplibDemoRecverSender/jrtplibdemoSenderRecver/sender/sender.cpp

#include <stdio.h>

#include <stdlib.h>



//RTP库依赖socket,必须在RTP库引入之前添加,否则会出现各种错误

#include <WinSock2.h>

#pragma comment(lib, "ws2_32.lib")



//RTP库引入

#include "jrtplib/rtpsession.h"

#include "jrtplib/rtpudpv4transmitter.h"

#include "jrtplib/rtpipv4address.h"

#include "jrtplib/rtpsessionparams.h"

#include "jrtplib/rtperrors.h"

//注意引入对应的lib文件

#pragma comment(lib, "jrtplib_d.lib")//jthread_d.lib;jrtplib_d.lib;

#pragma comment(lib, "jthread_d.lib")//jthread的lib库文件



using namespace jrtplib;   //引入jrtplib命名空间



int main(void)

{

//RTPSession 代表一个RTP会话

RTPSession  rtpSession;

//设置会话参数

RTPSessionParams rtpSessionParams;

RTPUDPv4TransmissionParams rtpUdpv4Transmissionparams;   //UDP地址



char buf[1024] = { 0x00 };

char ip[16] = { 0x00 };

int port = 0;   //端口号,需要运行时输入

int ret = 0;









//容易忽略,因为自写代码中没有调用socket,RTP有调用,但是没有初始化

WSADATA dat;

WSAStartup(MAKEWORD(2, 2), &dat);



printf("This is sender!!!\n");



//目的ip与port

printf("Input destination ip:");

scanf("%s", ip);

printf("Input destination port:");

scanf("%d", &port);

printf("Destination %s:%d\n", ip, port);



//设置RTP会话的参数

rtpSessionParams.SetOwnTimestampUnit(1.0 / 1);   //时间戳单元

rtpSessionParams.SetUsePollThread(true);   //线程轮询

rtpSessionParams.SetAcceptOwnPackets(false);是否接收自己的数据包

ret = rtpSession.Create(rtpSessionParams,&rtpUdpv4Transmissionparams);   //创建会话

if (ret < 0)

{

printf("Failed to RtpSession::Create, ret=%d\n", ret);

}



RTPIPv4Address addr(ntohl(inet_addr(ip)), port);

rtpSession.AddDestination(addr);   //将目的地址添加到RTP会话中



while (true)//开启循环,连续发送消息,输入exit退出循环

{

printf("Input message:");

scanf("%s", buf);

if (strcmp(buf, "exit") == 0)//输入exit退出循环

{

break;

}

//发送数据包,JRTPLIB会将原始数据封装为RTP包格式,自动添加RTP包头

ret = rtpSession.SendPacket((void *)buf, strlen(buf), 0, false, 1);

if (ret < 0)

{

printf("Failed to RtpSession::SendPacket, ret=%d\n", ret);

continue;

}

else {

printf("Succeed to RtpSession::SendPacket!!!\n");

}

RTPTime::Wait(RTPTime(0, 100));

}

return 0;

}





接收端(参考recver项目下的recver.cpp文件)代码如下:



//chapter3/jrtplibDemoRecverSender/jrtplibdemoSenderRecver/recver/recver.cpp

#include <stdio.h>

#include <stdlib.h>



//RTP库依赖socket,必须在RTP库引入之前添加,否则会出现各种错误

#include <WinSock2.h>

#pragma comment(lib, "ws2_32.lib")



//RTP库引入

#include "jrtplib/rtpsession.h"

#include "jrtplib/rtpudpv4transmitter.h"

#include "jrtplib/rtpipv4address.h"

#include "jrtplib/rtpsessionparams.h"

#include "jrtplib/rtperrors.h"

#include "jrtplib/rtppacket.h"

#pragma comment(lib, "jrtplib_d.lib")

#pragma comment(lib, "jthread_d.lib")



using namespace jrtplib;



int main(void)

{

RTPSession  rtpSession;   //RTP会话

RTPSessionParams rtpSessionParams;   //RTP会话参数

RTPUDPv4TransmissionParams rtpUdpv4Transmissionparams;



char ip[16] = "127.0.0.1";

int port = 0;

int ret = 0;

char buf[1024] = { 0x00 };



//容易忽略,因为自写代码中没有调用socket,RTP有调用,但是没有初始化

WSADATA dat;

WSAStartup(MAKEWORD(2, 2), &dat);



printf("This is recver!!!\n");



printf("Input local port:");

scanf("%d", &port);

printf("recv %s:%d\n", ip, port);



//设置RTP会话参数

rtpSessionParams.SetOwnTimestampUnit(1.0 / 1);

rtpSessionParams.SetUsePollThread(true);








rtpSessionParams.SetAcceptOwnPackets(true);

rtpUdpv4Transmissionparams.SetPortbase(port);

ret = rtpSession.Create(rtpSessionParams, &rtpUdpv4Transmissionparams);  //创建RTP会话

if (ret < 0)

{

printf("Failed to RtpSession::Create, ret=%d\n", ret);

}





RTPIPv4Address addr(ntohl(inet_addr(ip)), port);

#if 0

//组播

rtpSession.JoinMulticastGroup(addr);

#else

//本机接收,127.0.0.1

rtpSession.AddDestination(addr);

#endif



while (true)//开启循环,接收消息

{

rtpSession.BeginDataAccess();   //开始数据访问

if (rtpSession.GotoFirstSourceWithData())

{

do {

RTPPacket *packet;

//注意以下缩进是为了方便读者阅读,源码中建议使用Tab键对齐

//GetNextPacket用于接收下一个可用的数据包

while ((packet = rtpSession.GetNextPacket()) != NULL)

{

//RTP数据包的负载内容的长度,不包含包头

unsigned int recvSize = packet->GetPayloadLength();

//RTP负载的具体内容

unsigned char * recvData = (unsigned char *)packet->GetPayloadData();

memcpy(buf, recvData, recvSize);

buf[recvSize] = '\0';

printf("recv %d, message: %s\n", recvSize, buf);

rtpSession.DeletePacket(packet);   //用完后,需要删除数据包

}

} while (rtpSession.GotoNextSourceWithData());

}

rtpSession.EndDataAccess();   //结束数据访问

//时间间隔,真实项目中要根据帧率设置

RTPTime::Wait(RTPTime(0, 100));

}

return 0;

}





在VS中运行程序,效果演示,如图329所示。



图329recver和sender的收发效果演示


4. JRTPLIB的重要数据结构及用法

JRTPLIB的交互流程中涉及一些重要的数据结构及相关的API,包括RTPSession、RTPSessionParams、RTPUDPv4TransmissionParams、RTPIPv4Address等。

创建一个RTP会话需要以下几个步骤: 

(1) 使用RTPSession类创建一个会话对象sess/session。

(2) 通过RTP会话的参数类RTPSessionParams创建一个参数设置的对象sessparams/sessionparams。具体设置的属性有时间戳单元(SetOwnTimestampUnit)、是否允许接收自己的数据(SetAcceptOwnPackets)等。

(3) 传递参数类(RTPTransmissionParams)下面的有基于UDP和IPv4的传递参数的派生类(RTPUDPv4TransmissionParams),也有基于UDP和IPv6的派生类,一般使用第1个。用于设置本机的数据传递参数,主要是RTP数据包中的首部参数,本机要使用的端口号(注意是偶数)。

(4) 通过会话对象sess/session的Create方法调用步骤3和步骤4中的两个类完成对话的创建。

(5) 在IPv4目标地址类(RTPIPv4Address)创建的时候,完成目标端口号和目标IP地址。

(6) 通过会话对象sess/session的方法AddDestination将创建的目标地址对象添加到发送队列中,这里可以添加多个目标地址。

以上是使用jrtplib包进行TCP通信的通用步骤。端口号选择偶数是因为在JRTPLIB中偶数位用于RTP通信,然后自动将下一数字(奇数)用于RTCP通信。创建RTP会话依靠RTPSession类的方法,其他的3个类RTPSessionParams、RTPTransmissionParams、RTPIPv4Address作为RTPSession的参数分别为其提供RTP会话所需要的时间戳单元、本机的传递参数设置及目标主机的IP地址和端口号,样例中一般先创建前两个类,完成RTP对话的创建,最后使用第3个类添加目标主机的地址,也可以添加多个目标主机,即发往不同的主机上。

JRTPLIB发送数据是由重载函数实现的,代码如下: 



1.intjrtplib::RTPSession::SendPacket(const void* data,size_t len)

2.intjrtplib::RTPSession::SendPacket(const void* data,size_t len,uint8_t pt,bool mark,uint32_t timetamp inc)





第2个函数相对于第1个函数多了pt、M标志位及时间戳,这几个参数都是RTP首部包中需要的部分。如果需要使用第1个函数发送数据,则需要先将这3个参数设置成默认值。具体设置可通过RTP会话对象的3个属性完成,代码如下: 



session.SetDefaultPayloadType(96);

session.SetDefaultMark(false);

session.SetDefaultTimestampIncrement(160);





在创建一个TCP对话和发送数据中两次用到了与时间戳相关的参数,这里解释一下这两处分别代表什么意思,以及该如何设置。第一处是RTP会话的参数类RTPSessionParams,这里需要设置基本的时钟单元,例如,一个8000Hz的声音信号,接收端每接收一个采样信号的时间要增加1/8000s。这个是传递这个信号最基本的时钟单元,sessionparams.SetOwnTimestampUnit(1.0/8000.0)。再例如,一个20fps的图像,每传送一张图像过去,时钟信号需要增加1/20s。第二处是RTP会话对象的默认时间戳属性,假设每个RTP包中包含一个20ms的声音信号(对于声音而言,每字节是一个采样信号),则这一个包传送过去,时间需要增加0.02s/(1/8000s)=160。得到的160是以第一处设置的基本时钟单元为单位传送这20ms的声音信号所需要的时间。

JRTPLIB接收端首先要使用RTPSession的成员函数poll接收来自不同会话参与者的数据(不同的IP地址发来的数据,这里称为会话参与者)。在数据接收端,关于RTP会话参与者及数据包检索等信息,可以在对RTPSession类的成员函数RTPSession::BegainDataAccess和RTPSession::EndDataAceess的调用之间完成。这种方式是为了保证所使用的数据不会被其他隐藏线程所调用。使用RTPSession::GotoFirstSource和RTPSession::GotoNextSource两个成员函数的迭代找出所需要的会话参与者,当前被选中的会话参与者的数据包可以通过RTPSession::GetNextPacket函数获取,该函数返回一个RTPPacket类的对象。下面是接收端示例,代码如下: 



//chapter/jrtplib/recvdemo.txt

status = session.Poll();

check(status);



status = session.BeginDataAccess();









if (session.GotoFirstSource())

{

do

{

RTPPacket *packet;

while ((packet = session.GetNextPacket()) != 0)

{

std::cout <<"Got packet with extended sequence number "

<< packet->GetExtendedSequenceNumber() 

<<" from SSRC "<< packet->GetSSRC() 

<< std::endl;

session.DeletePacket(packet);

}

} while (session.GotoNextSource());

}

session.EndDataAccess();





有以下几个注意事项: 

(1) RTPSession::GetNextPacket函数用于获取当前参与者的数据包,而不是下一个。

(2) packet->GetPayloadData函数用于获取负载数据。

(3) 如果想要获取发送报告、接收报告、SDES等数据可以通过RTPSsion类的成员函数GetCurrentSourceInfo函数获取,返回的是一个RTPSourceData类。

(4) 会话参与者是指数据接收端接收不止一个IP主机发送过来的数据,不同的参与者指的是不同的IP主机。

另外,RTPSession提供了一个OnRTPPacket()虚函数,可以更加方便快捷地接收数据包,代码如下: 



virtual void OnRTPPacket(RTPPacket *pack,const RTPTime &receivetime, const RTPAddress *senderaddress)





使用OnRTPPacket函数时,需要注意几个问题: 

(1) 重载OnRTPPacket替代之前的接收方法,会出现内存泄漏,因为数据处理完后,无法调用DeletePacket。

(2) 即使能通过修改部分源码,释放内存,这种方式也不可取,因为OnRTPPacket收到的数据包是即时的,没经过排序。

JRTPLIB与时间相关的类是RTPTime,有两个构造函数,代码如下: 



//这个函数的单位不同,一个是s,一个是s+ms

jrtplib::RTPTime::RTPTime(double t)

jrtplib::RTPTime::RTPTime(int64_t seconds, uint32_t microseconds )





通过RTPTime构造一个延时类,例如RTPTimedelay(0.02),此时并没有延时,只是构造了一个需要延时0.02s的dealy对象。

RTPTime有两个成员函数,代码如下: 



RTPTime jrtplib::RTPTime::CurrentTime()

void jrtplib::RTPTime::Wait ( constRTPTime & delay)





(1) RTPTime jrtplib::RTPTime::CurrentTime() 函数用于获取当前的时间,这个时间是以秒为单位,从世界标准时间的1970年1月1日00:00:00开始算起为第0s,代码如下:



RTPTimestarttime =RTPTime::CurrentTime();

//构造一个RTPTime的对象,并且对其进行类的成员函数进行操作,等同于

RTPTime starttime; 

starttime.CurrentTime();





(2) jrtplib::RTPTime::Wait ( constRTPTime & delay) 函数用于等待延时,传入的是构造函数中的对象,例如RTPTime::Wait(delay)。

3.4.4RTP与H.264的相关结构体

使用RTP可以封装H.264码流的数据包,详细过程可以参考“3.1.2 RTP封装H.264”节。这里详细介绍几个重要的结构体,主要包括RTP_FIXED_HEADER、NALU_HEADER、FU_INDICATOR、FU_HEADER和NALU_t等,详细的解释可参考下面的代码。

关于H.264的拆包流程,按照FUA方式主要分为以下几个步骤。

(1) 第1个FUA包的FU indicator: F应该为当前NALU头的F,而NRI应该为当前NALU头的NRI,Type等于28,表明它是FUA包。FU header生成方法: S=1、E=0、R=0,而Type等于NALU头中的Type。

(2) 后续的N个FUA包的FU indicator和第1个是完全一样的,如果不是最后一个包,则FU header应该为S=0、E=0、R=0,Type等于NALU头中的Type。

(3) 最后一个FUA包FU header应该为S=0、E=1、R=0,Type等于NALU头中的Type。

RTP与H.264的NALU数据结构,代码如下: 



//chapter3/rtpnalu.txt

#define MAX_RTP_PKT_LENGTH1360 //包长度不要超过1400

#define H26496   //H.264的负载类型

typedef struct //RTP固定头

{

/* Byte 0 */

unsigned char csrc_len:4;	  /* 4位:csrn长度 */








unsigned char extension:1;  /* 1位:扩展 */

unsigned char padding:1;	  /* 1位:填充 */

unsigned char version:2;	  /* 2位:版本号 */

/* Byte 1 */

unsigned char payload:7;	  /* 7位:负载类型 */

unsigned char marker:1;	  /* 1位:标志位 */

/* Bytes 2, 3 */

unsigned short seq_no;	  //RTP序列号          

/* Bytes 4-7 */		  //RTP时间戳

unsigned  long timestamp;        

/* Bytes 8-11 */

unsigned long ssrc;	  /* ssrc */

} RTP_FIXED_HEADER;



/* 1 BYTES */

typedef struct { //NALU_HEADER

//Byte 0

unsigned char TYPE:5;  //类型

unsigned char NRI:2;  //优先级

unsigned char F:1;  //禁止位



} NALU_HEADER; 



typedef struct {

//Byte 0

unsigned char TYPE:5;

unsigned char NRI:2; 

unsigned char F:1;                             

} FU_INDICATOR; /**//* 1 BYTES */



typedef struct {//FU_HEADER

//Byte 0

unsigned char TYPE:5;

unsigned char R:1;  //保留位,必须设置为0,接收者必须忽略该位

unsigned char E:1;  //结束位指示分片NAL单元的结束

unsigned char S:1;  //开始位指示分片NAL单元的开始  

} FU_HEADER; /* 1 BYTES */



typedef struct

{

//4B for parameter sets and first slice in picture, 

//3B for everything else

//如果是SPS、PPS,则第1个slice为4B,其他的为3B	

int startcodeprefix_len;   //起始码的长度,3或4字节     



//! Length of the NAL unit (Excluding the start code, which does not belong to the NALU)









//NALU的字节数,不包括起始码

unsigned len;	

unsigned max_size;            //! Nal Unit Buffer size,NALU的缓冲区大小

int forbidden_bit;            //! should be always FALSE,禁止位

int nal_reference_idc;        //! NALU_PRIORITY_xxxx,优先级

int nal_unit_type;            //! NALU_TYPE_xxxx,NALU的类型



//! contains the first Byte followed by the EBSP

unsigned char *buf; //真正的帧数据缓冲区



//! true, if packet loss is detected,是否丢包

unsigned short lost_packets;  

} NALU_t;





3.4.5使用JRTPLIB发送H.264码流

使用JRTPLIB发送H.264码流,需要根据起始码获取NALU,自动封装为RTP格式,然后发送出去,核心代码及解释如下所示,完整的代码可以参考课件资料中的jrtplibdemoSendH264工程。重要的结构体及注释说明可参考“3.4.4 RTP与H.264的相关结构体”。



//chapter3/jrtplib/sendh264.cpp

#ifndef H264_H_

#define H264_H_

#include <stdio.h>

#include <stdlib.h>

#include <string.h>





#define PACKET_BUFFER_END      (unsigned int)0x00000000

#define MAX_RTP_PKT_LENGTH     1360

#define H264                      96





#define SSRC           100

#define DEST_IP_STR   "127.0.0.1"

#define DEST_PORT     12500

#define BASE_PORT     9400



//static bool flag = true;

static int info2=0, info3=0;

RTP_FIXED_HEADER*rtp_hdr;

FILE *bits = NULL;                //!< the bit stream file:位流文件

NALU_HEADER*nalu_hdr;









FU_INDICATOR*fu_ind;

FU_HEADER*fu_hdr;



//查找开始字符0x000001

static int FindStartCode2 (unsigned char *Buf)

{

if(Buf[0]!=0 || Buf[1]!=0 || Buf[2] !=1) return 0;//判断是否为0x000001。如果是,则返回1

else return 1;

}



//查找开始字符0x00000001

static int FindStartCode3 (unsigned char *Buf)

{

//判断是否为0x00000001,如果是,则返回1

if(Buf[0]!=0 || Buf[1]!=0 || Buf[2] !=0 || Buf[3] !=1) return 0;	else return 1;

}



//打开H.264裸流文件

void OpenBitstreamFile (char *fn)

{

if (NULL == (bits=fopen(fn, "rb"))) //操作二进制必须为 rb/wb

{

printf("open file error\n");

exit(0);

}

}





//为NALU_t结构体分配内存空间

NALU_t *AllocNALU(int buffersize)

{

NALU_t *n;



if ((n = (NALU_t*)calloc (1, sizeof (NALU_t))) == NULL)

{

printf("AllocNALU: n");

exit(0);

}



n->max_size=buffersize;

//默认80000字节的大小

if ((n->buf = (unsigned char*)calloc (buffersize, sizeof (char))) == NULL) 

{

free (n);

printf ("AllocNALU: n->buf");

exit(0);








}



return n;

}





//释放

void FreeNALU(NALU_t *n)

{

if (n)

{

if (n->buf)

{

free(n->buf);

n->buf=NULL;

}

free (n);

}

}





//这个函数的输入为一个NAL结构体

//得到一个完整的NALU并保存在NALU_t的buf中,获取它的长度,填充F、IDC、TYPE位

//并且返回两个开始字符之间间隔的字节数,即包含前缀的NALU的长度

int GetAnnexbNALU (NALU_t *nalu)

{

int pos = 0;

int StartCodeFound, rewind;

unsigned char *Buf;



if ((Buf = (unsigned char*)calloc (nalu->max_size , sizeof(char))) == NULL) 

printf ("GetAnnexbNALU: Could not allocate Buf memory\n");



nalu->startcodeprefix_len = 3;   //初始化码流序列的开始字符为3字节



if (3 != fread (Buf, 1, 3, bits))//从码流中读3字节

{

free(Buf);

return 0;

}

info2 = FindStartCode2 (Buf);   //判断是否为0x000001 

if(info2 != 1) 

{

//如果不是,则再读一字节

if(1 != fread(Buf+3, 1, 1, bits))//读一字节

{








free(Buf);

return 0;

}

info3 = FindStartCode3 (Buf);   //判断是否为0x00000001

if (info3 != 1)//如果不是,则返回-1

{ 

free(Buf);

return -1;

}

else 

{

//如果是0x00000001,则得到的开始前缀为4字节

pos = 4;

nalu->startcodeprefix_len = 4;

}

}

else

{

//如果是0x000001,则得到的开始前缀为3字节

nalu->startcodeprefix_len = 3;

pos = 3;

}

//查找下一个开始字符的标志位

StartCodeFound = 0;

info2 = 0;

info3 = 0;



while (!StartCodeFound)

{

if (feof (bits))//判断是否到了文件尾

{

nalu->len = (pos-1)-nalu->startcodeprefix_len;

memcpy (nalu->buf, &Buf[nalu->startcodeprefix_len], nalu->len);     

nalu->forbidden_bit = nalu->buf[0] & 0x80;      //1 bit

nalu->nal_reference_idc = nalu->buf[0] & 0x60;  //2 bit

nalu->nal_unit_type = (nalu->buf[0]) & 0x1f;    //5 bit

free(Buf);

return pos-1;

}

Buf[pos++] = fgetc (bits);   //将一字节读到Buf中

info3 = FindStartCode3(&Buf[pos-4]);   //判断是否为0x00000001

if(info3 != 1)

info2 = FindStartCode2(&Buf[pos-3]);   //判断是否为0x000001

StartCodeFound = (info2 == 1 || info3 == 1);

}








//Here, we have found another start code 

//(and read length of startcode Bytes more than we should

//have.  Hence, go back in the file

//已经发现了一个起始码

rewind = (info3 == 1)? -4 : -3;



//把文件指针指向前一个NALU的末尾

if (0 != fseek (bits, rewind, SEEK_CUR))

{

free(Buf);

printf("GetAnnexbNALU: Cannot fseek in the bit stream file");

}



//Here the Start code, the complete NALU, 

//and the next start code is in the Buf.  

//完整的NALU已经在缓冲区中了

//The size of Buf is pos

//pos+rewind are the number of Bytes excluding the next

//start code, and (pos+rewind)-startcodeprefix_len 

//is the size of the NALU excluding the start code



nalu->len = (pos+rewind)-nalu->startcodeprefix_len;

//复制一个完整NALU,不复制起始前缀0x000001或0x00000001

memcpy (nalu->buf, &Buf[nalu->startcodeprefix_len], nalu->len);

nalu->forbidden_bit = nalu->buf[0] & 0x80; //1 bit

nalu->nal_reference_idc = nalu->buf[0] & 0x60; //2 bit

nalu->nal_unit_type = (nalu->buf[0]) & 0x1f;   //5 bit

free(Buf);



//返回两个开始字符之间间隔的字节数,即包含前缀的NALU的长度

return (pos+rewind);}



#endif





可以使用VLC直接打开show.sdp来播放RTP封装的H.264码流。本地IP地址为127.0.0.1,端口号是12500,使用RTP封装格式,帧率为25,RTP负载类型是H.264,代码如下: 



m=video 12500 RTP/AVP 96

a=rtpmap:96 H264

a=framerate:25

c=IN IP4 127.0.0.1





使用JRTPLIB发送H.264码流,以及VLC播放的效果,如图330所示。



图330JRTPLIB发送H.264码流后用VLC播放


3.5RTP扩展头结构

RTP提供扩展机制允许实现个性化: 某些新的与负载格式独立的功能要求的附加信息在RTP 数据包头中传输。设计此方法可以使其他没有扩展的交互忽略此头扩展。

3.5.1RTP单扩展头

RTP扩展头跟在确定头后,如果有CSRC,就跟在CSRC后。RTP单扩展头的格式如图331所示。



图331RTP单扩展头结构


若RTP固定头中的可扩展比特位置为1,则一个长度可变的头扩展部分被加到RTP固定头之后。头扩展包含16b的长度域,指示扩展项中32b的个数,不包括4字节扩展头(因此零是有效值)。RTP固定头之后只允许有一个头扩展。为允许多个互操作独立生成不同的头扩展,或某种特定实现有多种不同的头扩展,扩展项的前16b用以识别标识符或参数。这16b的格式由具体实现的上层协议定义。基本的RTP说明并不定义任何头扩展本身。RTP标准可以参考RFC 3550,网址为https://datatracker.ietf.org/doc/html/rfc3550#section5.3.1。

3.5.2RTP多扩展头

上面介绍的这种形式只能够附加一个扩展头,为了支持多个扩展头,RFC 5285对defined by profile进行了扩展,可以参考网址https://datatracker.ietf.org/doc/html/rfc5285。

扩展头为OneByte Header的情况,如图332所示。RTP头后的第1个16b为固定的0XBEDE标志,意味着这是一个OneByte扩展,length=3说明后面有3个扩展头,每个扩展头首先以一字节开始,前4位是这个扩展头的ID,后4位是data的长度减1。例如L=0意味着后面有1B的data,同理第2个扩展头的L=1说明后面还有2B的data,但是注意,其后没有紧跟第3个扩展头,而是添加了2B大小的全0的data,这是为了使填充对齐,因为扩展头是以32b填充对齐的。



图332RTP扩展头OneByte Header结构


扩展头为TwoByte Header的情况,如图333所示。RTP头后的第1个16b为0x100+appbits,其中appbits可以用来填充应用层级别的数据。



图333RTP扩展头TwoByte Header结构


扩展头为TwoByte Header的一个案例,如图334所示。可以看到开头为0x100+0x0,接下来的length=3表示有3个头,然后是扩展头和数据,扩展头除了ID和L相对于OneByte Header从4b变成了8b之外,其余都一样。



图334RTP扩展头TwoByte Header案例