SRT协议这几年在直播、远程制作、无界传输这些场景里越来越常见,很多团队从RTMP转过来之后,第一个遇到的拦路虎就是握手。老实说SRT的握手和RTMP那种一次HTTP式的交互完全不同,它是在UDP上自己造了一套逻辑连接,如果你不把握手控制包的结构和时序搞清楚,后面无论是调延迟、开加密还是排查"连不上"的问题,都会非常痛苦。这篇文章我就以一次完整的SRT建连过程为线索,从控制包头讲到握手扩展,再从抓包实战讲到踩坑排查,争取让你看完之后能独立分析一次SRT握手。
我尽量把每个字段为什么存在、每个阶段为什么这样设计讲透,而不只是罗列结构。适合正在接入SRT的流媒体开发者、运维、以及想深入理解SRT协议的测试同学。
1. 先弄清SRT握手到底在解决什么问题
1.1 UDP上的"连接"是如何凭空变出来的
SRT的一个核心前提是使用UDP传输。UDP本身没有连接概念,两个进程之间发数据包就是发数据包,不存在"建立连接"这个动作。但SRT要实现可靠传输、流控、加密、丢包重传,就必须先让通信双方知道"我正在和谁通信",这就需要一个逻辑连接。
这个逻辑连接的标识就是Socket ID。SRT每个数据包和控制包头里都带了一个Destination Socket ID,接收方看到这个ID就能判定这个包属于哪条会话。Socket ID不是在UDP层自动产生的,而是双方在握手阶段互相告知的:先由发起方临时生成一个ID告诉接收方,接收方再生成自己的ID回传。之后双方发的所有包,都要把对方的Socket ID填到目标字段里。
用生活里的例子类比,UDP像是寄明信片,地址写错也能寄出去,但寄件人和收件人之间没有约定暗号。SRT的握手就是两个人第一次见面时互相确认:"我叫A,你记住我的编号,下次写信给我记得写这个编号",然后两人各掏出一本通讯录,把对方的编号记进去。没有这一步,后面所有包都无从归属。
1.2 一次握手要完成的四件大事
很多人误以为SRT握手就是"打个招呼",实际上它要在两个阶段内完成四件事:
第一,建立逻辑连接并同步Socket ID。这是最基础的目标,上面已经说了。
第二,用SynCookie机制抵御UDP洪水攻击。因为UDP可以伪造源地址,如果服务端收到一个握手包就立刻分配资源并回复,攻击者用伪造IP就能打爆服务端内存。SRT借鉴了TCP的SynCookie思路:服务端第一次收到握手包时不分配任何资源,只根据客户端的IP、端口、时间等信息计算出一个4字节的Cookie,在响应包里带回去。客户端必须在下一次握手中原样带回这个Cookie,服务端验证通过后才真正创建会话。这样一来,伪造源地址的攻击者收不到响应,也就无法完成第二次握手,资源自然消耗不掉。
第三,协商传输参数。完整SRT连接需要先确定数据包最大长度(MSS)、流控窗口大小、起始时间基准、SRT延迟、传输类型、方向角色等。这些参数如果各持己见,后面收包解包就会对不上。比如MSS不一致会导致分片重组错乱,延迟参数不匹配会导致TSBPD时间计算混乱,接收端丢包或者播放卡顿。
第四,确认加密密码并协商密钥材料。SRT的一大卖点是端到端加密。在握手阶段,发起方会把密码的散列值(pw_hash)、密码长度(pw_length)以及HMAC密钥直接带在握手包里,接收方用自己配置的passphrase去比对。如果discovery不匹配,直接拒绝对端。握手通过后,双方再走KMREQ/KMRSP两条扩展消息交换真正的AES加密密钥,之后数据面全部走加密。
所以你看,一次握手表面上只有4个包,实际上承载了身份确认、防攻击、参数对齐、安全认证四重任务。这也是为什么SRT握手比RTMP、WebSocket这些基于TCP的握手复杂得多。
1.3 两阶段握手:为什么要拆成两步
SRT握手拆成了诱导握手(Induction)和完成握手(Completion)两个阶段,对应M1/M2和M3/M4四步。这样设计主要是出于安全考虑。
诱导阶段主要做两件事:一是防DoS,服务端通过SynCookie验证客户端是否真实可达;二是做一个版本降级的握手通道。为什么SRT的诱导握手要特意使用一个固定的"较老版本号"(比如0x0102)?因为握手本身也有兼容性问题,新老版本实现必须能先在一个双方都认识的最小公共版本上对话,后续进入完成阶段后再升级到真实配置。有点像两个人第一次见面先说普通话,确认彼此都会说普通话之后,再切换到各自的方言加普通话混合模式。
完成阶段才是真正的参数交换。此时客户端发出M3,里面带真实SRT版本(比如0x0104)、完整参数、SynCookie回传,以及一系列扩展块。服务端验证Cookie和密码散列后,回M4确认。到这一步,双方才真正具备发送数据包的条件。
弄清楚了握手在解决什么问题,我们再来解剖控制包本身,看看每个字节是怎么组织起来的。
2. 控制包解剖:从第一个字节看起
2.1 控制包头:如何区分数据包和控制包
SRT在UDP上的包有两种:数据包和控制包。它们的包头前16字节结构不同,靠第一个bit来区分。数据包的第一个bit是0,控制包的第一个bit是1。
控制包的16字节包头具体长这样:
| 位域 | 长度 | 说明 |
|---|---|---|
| F标志 | 1 bit | 固定为1,表示这是控制包 |
| 控制包类型 | 15 bit | 握手=0,Keepalive=1,ACK=2,NAK=3,拥塞控制=4,对端错误=5,EXT=6 |
| 子类型 | 8 bit | 仅当控制类型为EXT(6)时有效,用于区分具体扩展控制类型 |
| 类型特定信息 | 8 bit | 不同控制类型有的用之,握手包里通常为0 |
| 时间戳 | 32 bit | 发送方在起始时间基准上的相对时间戳 |
| 目标Socket ID | 32 bit | 该控制包所对应的逻辑连接对端ID |
这里有一个关键点:控制包里带的时间戳不是绝对时间,而是相对于握手阶段协商出来的起始时间基准的偏移。所以握手包里的起始时间戳(srt_start_time)非常重要,两边后续所有时间戳都以它为零点来计算。
在Wireshark里,一个SRT握手包通常显示为协议列SRT、Info列类似"Handshake, INDUCTION"的内容。它实际上就是在UDP负载最前面压了一个16字节的控制包头,后边跟着握手消息本体。
2.2 握手消息固定区:56字节的核心字段
控制包头之后是握手消息。握手消息有一个固定区,不管什么阶段、什么版本,这块结构都是固定的。它的字段顺序如下:
| 偏移 | 字段 | 长度 | 核心作用 |
|---|---|---|---|
| 0 | UDT/SRT版本 | 4字节 | 协议版本号,如0x0102、0x0103、0x0104 |
| 4 | 包索引/握手类型 | 4字节 | 低16位表示握手类型,诱导/诱导确认/完成/完成确认 |
| 8 | 起始时间戳 | 4字节 | 发送方基准时间,用于TSBPD时间换算 |
| 12 | 流控窗口 | 4字节 | 接收方通告的可用接收窗口大小 |
| 16 | 最大段大小MSS | 4字节 | 单个数据分片的最大字节数 |
| 20 | 传输类型 | 2字节 | 流媒体直播LIVE / 文件传输FILE等 |
| 22 | 方向 | 2字节 | Caller(主动发起方)或Listener(监听方) |
| 24 | 对端Socket ID | 4字节 | 握手发起方声明的Socket ID |
| 28 | 对端配置 | 4字节 | 携带对端相关配置位 |
| 32 | 密码长度 | 4字节 | 0表示不加密,非0表示使用passphrase |
| 36 | 密码哈希 | 4字节 | passphrase散列结果的前4字节 |
| 40 | HMAC密钥 | 16字节 | 用于后续握手消息完整性校验的密钥 |
注意这里的"对端配置"在各版本里含义有差异,但总体上它是对端在SrtConfig里声明的配置集合,比如是否启用特定过滤器、是否存在分组协商等。实际调试中我更关注版本号、MSS、流控窗口和加密相关字段。
还要强调一个容易踩的坑:握手消息固定区里面,Version字段在不同阶段承载的含义不一样。在诱导阶段M1/M2里,Version字段不一定代表真实协议版本,M2的Version字段实际上装的是SynCookie值。只有在完成阶段M3/M4里,Version字段才是真的协议版本。所以别拿M2里的版本号去判断对端SRT版本,那是会误判的。
2.3 从抓包角度快速定位握手包
如果你在Wireshark里看到一坨SRT包,最快的定位握手包方法是看两点:一个是控制包类型字段等于0,另一个是Info列里有"Handshake"字样。过滤表达式可以写成:
srt.hs && srt.hs.type == 0其中srt.hs.type是握手阶段的类型字段,0对应INDUCTION,1对应INDUCTION_ACK,2对应COMPLETION,3对应COMPLETION_ACK。这样你就能直接从一堆UDP包里把四个握手包全部拉出来,按时间排序看它们的交互顺序。
如果Wireshark没有自动识别出SRT协议,需要手动让某个UDP端口按SRT解析。做法是在Wireshark里右键某个UDP包,选择"Decode As",把对应端口指定为SRT。这一步在抓广播流量或者使用非标准端口时特别常用,我建议所有测试SRT的人先把这步记住。
3. 握手扩展与加密协商
3.1 扩展字段的TLV机制
固定区只是握手消息的地基,真正传递现代SRT参数的是扩展区。扩展区紧跟在固定区后面,采用类似TLV的格式逐块排列:
| 字段 | 长度 | 说明 |
|---|---|---|
| 扩展类型 | 2字节 | 例如1=HSREQ,2=KMREQ,3=KMRSP,4=SRT,5=TSBPDSND,6=TSBPDRCV,7=TRIM |
| 扩展长度 | 2字节 | 后面的数据体字节数 |
| 扩展数据 | 变长 | 具体内容随类型变化 |
扩展可以出现多个,依次拼接。接收方按类型逐个解析,遇到不认识或不需要的扩展类型可以跳过,这就保证了前向兼容。老版本实现即使不认识新扩展,也能通过跳过的方式继续完成握手。
这种TLV设计很务实。SRT从1.2到1.4演进过程中加了很多东西:TSBPD、加密、分组传输、过滤器,但核心固定区一直没变,全靠扩展区做增量。如果当初把所有参数都塞进固定区,现在恐怕已经乱成一锅粥了。
3.2 HS_EXT_SRT与直播关键参数
在所有扩展块里,HS_EXT_SRT(扩展类型4)是完成阶段最重要的一个。它携带了双方真正关心的SRT业务参数,主要包括:
- SRT版本(2字节):对端运行的SRT版本号,比如0x0104表示1.4.x系列。
- SRT标志(2字节):一个位掩码,里面包含加密类型、是否启用NAKREPORT、是否启用REXMIT、过滤器类型等信息。
- 对端类型(2字节):LIVE(实时直播)或FILE(文件传输),不同模式拥塞控制行为差异很大。
- 对端延迟(4字节):latency,单位是毫秒,这是TSBPD的核心参数,直接影响接收端的抖动容忍度。
TSBPD(TimeStamp-Based Packet Delivery)简单说就是接收端不拿到包就立刻丢给上层,而是根据包时间戳和预设延迟,算好"这个包该什么时刻被上层使用",到点再投递。这样可以吸收网络抖动,让播放器看到的是平滑的码流。但延迟设大了会增加端到端时延,设小了又吸收不了抖动。所以HS_EXT_SRT里协商出来的latency,往往要结合网络状况反复调。
另一组扩展是HS_EXT_TSBPDSND和HS_EXT_TSBPDRCV,分别对应发送方向和接收方向。它们主要告诉对端自己如何配置TSBPD的发送/接收行为,比如是否启用基于时间的发送调度、期望对端如何配合。搞懂这些扩展之间的配合,你就能理解为什么SRT调延迟不是只改一边参数就行:接收端延迟和发送端调度必须对齐,否则实际效果会和自己想的不一样。
3.3 加密协商与密钥材料交换
SRT加密有两层。第一层是握手消息里的passphrase校验。配置了passphrase的caller在M3里会带上密码哈希(pw_hash)和密码长度(pw_length)。listener收到后,用自己的passphrase计算同样的散列,对比前4字节。不一致就直接拒绝或标记错误。
这里有个细节:pw_hash只取SHA-1散列结果的前4字节,本质上是个指纹。它的设计目的不是防暴力破解,而是快速排除"根本不是一个密码"的情况。真正的安全性来自第二层:握手通过后,双方通过KMREQ/KMRSP交换密钥材料(Keying Material),用它生成后续数据加密用的AES对称密钥。密钥材料本身在握手消息的HMAC密钥保护之下,所以即使有人抓到包,也很难在不知道passphrase的情况下还原密钥。
实际使用中我建议:如果排查加密问题,先关掉加密用明文握手跑一遍,通了再开加密。这样做能快速区分问题是出在基本握手链路还是出在密码/密钥协商环节,省掉大量冤枉时间。
4. 抓包实战:完整解析一次SRT握手
4.1 本地环境搭建与抓包命令
为了讲得具体,我在本地搭一个最小环境:一台机器上用ffmpeg起一个SRT listener,另一侧用ffmpeg以caller模式推流,中间用Wireshark抓回环或物理网卡上的UDP包。
listener端:
ffmpeg -i "srt://127.0.0.1:9000?mode=listener&latency=2000" -f null -caller端:
ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 -c:v libx264 -preset ultrafast -tune zerolatency -f mpegts "srt://127.0.0.1:9000?mode=caller&latency=2000"Wireshark里直接抓loopback接口,过滤udp.port == 9000。这样抓到的包很干净,主要就是握手、Keepalive、ACK/NAK、数据包。我习惯再加一个!srt.data过滤,先只看控制面,把握手时序看得清清楚楚。
如果手头没有ffmpeg,也可以用srt-live-transmit这类专用工具,它带的日志比ffmpeg更详细,能看到更多握手内部状态。不过抓包分析角度,ffmpeg + Wireshark已经足够。
4.2 逐包解读M1到M4
抓包后,按时间顺序把四个握手包拉出来,逐个看关键字段。
M1(Caller发起诱导握手)
这个包从caller临时端口出发,目标端口9000。控制包头类型=0,后面的握手消息里:
- 版本:0x0102(诱导阶段故意用低版本)
- 包索引/握手类型:0(INDUCTION)
- 起始时间戳:一个随机整数,作为通话时间基准
- 流控窗口:比如8192
- MSS:1500
- 方向:0(Caller)
- 对端Socket ID:0,因为此时还没有对端ID
- 密码长度/哈希:如果配置了passphrase,这里会带,否则为0
M1的作用是投石问路,不需要携带真正的业务配置。关键是让listener能识别这是一个SRT握手,并根据源地址生成SynCookie。
M2(Listener回复诱导确认)
listener收到M1后,不分配资源,根据IP、端口、时间计算SynCookie,在M2里返回。这时候抓包看到的字段是:
- 版本字段里放的其实是SynCookie(一个看似随机的4字节值)
- 包索引/握手类型:1(INDUCTION_ACK)
- 对端Socket ID:listener自己的Socket ID
- 时间戳:listener的起始基准时间
注意M2里没有携带业务参数。这个包的目的只有一个:向caller证明"我能收到你的包,同时也让你知道你在我这边是可以被放行的"。
M3(Caller发起完成握手)
caller收到M2后,校验SynCookie,然后发送真正的完成握手。M3是SRT握手信息量最大的一个包:
- 版本:0x0104(真实版本)
- 包索引/握手类型:2(COMPLETION)
- SynCookie值:原样放在对应字段里带回
- 固定区里的流控窗口、MSS等参数更新为真实值
- 扩展区开始出现HS_EXT_SRT,里面带着SRT版本、标志、对端类型、latency
- 如果启用了加密,密码长度和密码哈希也会带上
抓包时重点看M3里的扩展区。listener就是在这里做最终决策的:Cookie对不对、密码对不对、参数能不能接受。任何一个不满足,listener都可能不回M4,或者回错误信息。
M4(Listener确认完成握手)
M4是ack包,类型为COMPLETION_ACK。listener告诉caller:"我认可你的参数,连接已建立。" 到这一步,双方的Socket ID、MSS、流控窗口、时间基准、延迟、密钥材料都已经对齐,可以开始发数据了。
如果你开启了加密,接下来还会看到KMREQ和KMRSP两条扩展控制消息往返,之后数据包内容才是密文。抓包时密文数据包负载看起来是随机的,不要误以为是乱码。
4.3 握手成功后的参数对照
握手结束后,我习惯把Wireshark里看到的M3/M4参数整理成一张对照表:
| 参数 | Caller声明的值 | Listener确认的值 | 影响 |
|---|---|---|---|
| 协议版本 | 1.4.x | 1.4.x | 决定功能集 |
| MSS | 1500 | 1500 | 分片大小 |
| 流控窗口 | 8192 | 8192 | 接收窗口 |
| 传输类型 | LIVE | LIVE | 拥塞控制模式 |
| 方向 | Caller | Listener | 角色确认 |
| 延迟latency | 2000ms | 2000ms | TSBPD缓冲 |
| 加密类型 | AES-128 | AES-128 | 数据面加密 |
如果某一栏双方对不上,比如caller声明MSS=1500、listener回应MSS=1200,那后续就可能出现分片重组错乱。Wireshark里直接对比这两个包里的字段是最快的排查手段。
5. 握手失败排查与经验技巧
5.1 常见问题速查表
我把实际项目中遇到过的SRT握手问题整理成了一张速查表,方便你对照排查:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 握手超时,收不到M2 | UDP被防火墙丢弃、NAT未映射、listener未启动 | Wireshark双向抓包确认UDP是否到达;检查listener进程 |
| 收到M2但M3后无响应 | SynCookie校验失败 | 抓包对比M2里的Cookie和M3里带回的Cookie是否一致 |
| 收到M2但M3后收到错误 | 密码不一致或加密类型不匹配 | 比对M3里pw_hash与listener本地配置;关掉加密做对照测试 |
| 连接建立后立即断开 | 参数协商冲突,比如延迟为0或MSS异常 | 看M3和M4里扩展参数是否一致 |
| 握手成功但推流卡顿 | latency设置偏小 | 调大latency观察TSBPD表现 |
大多数握手问题都集中在"UDP不通"和"加密配置不一致"两类。建议一上来先用明文、无加密的配置做最小验证,确认基本链路通了,再加加密和复杂参数。
5.2 抓包排查的几个实用技巧
Wireshark里专门做SRT握手分析时,有几个人人能用上的技巧。
第一,强制解码端口。如果你用了一个不常见的端口,Wireshark可能不识别SRT。右键任意UDP包,Decode As,选择SRT,立马就出来完整解析。
第二,用过滤表达式只看握手。srt.hs能筛出所有SRT握手控制包,srt.hs.type == 2能只看完成握手。分析时序时再叠加tcpdump在外面抓,效果更可控。
第三,看握手耗时。我通常统计从M1到M4的时间戳差,一般局域网内这个值在1毫秒以内,公网也不应该超过几十毫秒。如果这个值很大,说明中间链路质量差或服务端处理慢。
第四,抓包时用-s 200限制抓包长度就够了。握手包本身不大,限制快照长度可以减小抓包文件体积,方便回传分析。
sudo tcpdump -i any -s 200 -w srt_handshake.pcap udp port 90005.3 个人踩坑记录
最后分享几个我自己印象深刻的坑。
第一个坑是NAT环境下的SynCookie失败。SRT服务端在公网,客户端在内网,中间经过了好几层NAT。M2到了客户端再回M3时,服务端看到的源IP和M1不一样,导致SynCookie校验失败。这个问题排查起来极其隐蔽,因为Wireshark里看起来M1、M2、M3都正常,最后就是连不上。解决办法是在边界设备上做好端口映射,保证同一会话的UDP源地址稳定。
第二个坑是passphrase里多了不可见字符。有一次客户反馈两边都配了密码但握手失败,抓包一对比,M3里的pw_hash和listener本地计算的hash不一致。后来查出来是配置文件里行尾多了一个空格,肉眼完全看不出来。从那以后我调试加密问题时,都会用十六进制去对比配置文件的每个字节。
第三个坑是latency参数两边不一致导致播放端延迟异常。caller设了2000ms,listener设了4000ms,协商时没有报错,连接也建立了,但播放端实际缓冲明显偏大。后来翻源码和抓包对比才理解,TSBPD接收行为以listener的latency为准,发送调度又受caller参数影响,两边不一致就会出现这种"表面成功、实际怪异"的现象。现在我的习惯是:所有SRT端点的latency从同一份配置管理,避免各写各的。
我个人体会是,SRT握手的协议栈结构虽然比传统UDP协议复杂,但只要你把控制包头、握手消息固定区、扩展区这三层拆开理解,再把诱导和完成两个阶段在抓包工具里完整走一遍,绝大部分问题都能肉眼定位。尤其是当你需要给生产环境排查加密或NAT问题时,能熟练指认M3里的SynCookie和pw_hash字段,效率会高出一大截。