4. SDP协议详解:深入理解SDP的格式、媒体描述、编解码协商、以及Offer/Answer模型
SDP,全称 Session Description Protocol,会话描述协议。很多人第一次看到 SDP 会觉得它像配置文件,其实它就是个文本格式的「媒体清单」。WebRTC 用它来告诉对方:我能发什么、想收什么、怎么加密。
我刚开始接触 WebRTC 时,总觉得 SDP 又臭又长,一行一行看过去头都大。后来被一个线上问题折磨了两天——两端明明都支持 H.264,就是连不上。最后发现是 SDP 里的 profile-level-id 没对齐。从那以后,我再也不敢跳过 SDP 的细节了。
核心认知:SDP 不是传输协议,它只负责描述。真正的媒体流走的是 SRTP/SCTP,SDP 只是帮双方在握手阶段把「能力清单」对齐。
4.1 SDP 的基本结构
SDP 由多行文本组成,每行一个键值对,格式是 <type>=<value>。类型是单个字符,大小写敏感。我习惯把 SDP 分成三个层级:会话级、时间级、媒体级。
| 层级 | 典型行 | 作用 |
|---|---|---|
| 会话级 | v=, o=, s=, c= |
描述整个会话的全局信息 |
| 时间级 | t=, r= |
描述会话的起止时间 |
| 媒体级 | m=, a=, c= |
描述每个媒体流的具体参数 |
一个典型的 SDP 长这样:
v=0
o=- 1234567890 2 IN IP4 192.168.1.100
s=-
t=0 0
m=audio 5004 RTP/AVP 96 97
c=IN IP4 192.168.1.100
a=rtpmap:96 OPUS/48000/2
a=rtpmap:97 PCMU/8000/1
a=fmtp:96 minptime=10; useinbandfec=1
m=video 5006 RTP/AVP 98 99
c=IN IP4 192.168.1.100
a=rtpmap:98 H264/90000
a=fmtp:98 profile-level-id=42e01f; packetization-mode=1
a=rtpmap:99 VP8/90000
嗯,这里要注意:o= 行的会话 ID 必须唯一,否则某些 SFU 会拒绝连接。我踩过这个坑,当时用固定 ID 做测试,结果每次重连都失败。
4.2 媒体描述与编解码协商
媒体描述以 m= 开头,格式是:
m=<媒体类型> <端口> <传输协议> <格式列表>
举个例子:m=video 5006 RTP/AVP 98 99,意思是视频流走端口 5006,用 RTP 传输,支持编号 98 和 99 的编码格式。
那 98 和 99 到底是什么编码?这就靠 a=rtpmap 来映射了:
a=rtpmap:98 H264/90000
a=rtpmap:99 VP8/90000
说白了,m= 行里的数字只是占位符,真正的编码名称在 a=rtpmap 里定义。90000 是时钟频率,视频通常用这个值,音频则根据编码不同而变化。
我的习惯:写 SDP 解析器时,永远先读 m= 行拿到格式列表,再遍历 a=rtpmap 建立映射表。顺序很重要——m= 行里的顺序就是优先级顺序,排第一的编码是发送方最想用的。
4.2.1 fmtp 参数的秘密
a=fmtp 是格式参数的扩展行。不同编码有不同的参数,我挑几个常见的说说:
- OPUS:
minptime=10表示最小打包时长 10ms,useinbandfec=1开启带内 FEC(前向纠错)。 - H.264:
profile-level-id=42e01f指定档次和级别,packetization-mode=1表示支持非交错模式(也就是单 NAL 单元模式)。 - VP8/VP9:参数相对少,但注意
max-fs和max-fr可能影响分辨率上限。
我曾经遇到一个兼容性问题:Chrome 发的 H.264 SDP 里 profile-level-id 是 42e01f,但某款 Android 手机只认 42e00d。两边都支持 H.264,但参数不匹配,协商失败。解决方案是在 Offer 里同时列出多个 profile-level-id,或者让 Answer 端做降级处理。
4.3 Offer/Answer 模型
Offer/Answer 是 WebRTC 协商的核心机制。说白了就是:一方先出牌(Offer),另一方接牌(Answer),然后双方按牌面打。
流程很简单:
- 发起方创建 Offer,包含自己所有的媒体能力。
- 接收方收到 Offer,匹配自己的能力,生成 Answer。
- 双方各自设置远端描述,开始传输。
注意:Offer 和 Answer 不是随便写的。Offer 可以列出多种编码,但 Answer 只能从 Offer 的列表里选。Answer 不能引入 Offer 里没有的编码。这是 RFC 3264 的硬性规定,违反的话对端会直接拒绝。
4.3.1 协商的匹配逻辑
我画了一张图,帮你理解 Offer/Answer 的匹配过程:
你看,Offer 端列出了三种编码,优先级 H264 最高。Answer 端只支持 H264 和 VP8,所以它选了 H264。如果 Answer 端一种都不支持,那这个媒体流就协商失败了。
4.3.2 重协商与 ICE 重启
Offer/Answer 不是一次性的。WebRTC 允许在会话中进行重协商,比如切换编码、添加流、改变带宽限制。触发重协商的场景包括:
- 添加新流:比如从纯音频升级到音视频通话。
- 编码切换:网络变差时从 H264 切到 VP8。
- ICE 重启:网络切换导致候选地址变化。
避坑指南:我曾经在重协商时忘记更新 o= 行的版本号,导致对端认为 SDP 没变化而忽略。每次重协商,o= 行的版本号必须递增,否则对端可能不处理。
4.4 SDP 中的 ICE 与 DTLS 信息
SDP 不只是描述编码,它还携带了 ICE 和 DTLS 的协商信息。这部分我单独拎出来说,因为很多人只关注编码,忽略了这些关键字段。
| 属性行 | 作用 | 示例 |
|---|---|---|
a=ice-ufrag |
ICE 用户名片段 | a=ice-ufrag:E1c2 |
a=ice-pwd |
ICE 密码 | a=ice-pwd:Y3O2 |
a=fingerprint |
DTLS 证书指纹 | a=fingerprint:sha-256 AA:BB:CC... |
a=setup |
DTLS 角色 | a=setup:actpass |
a=candidate |
ICE 候选地址 | a=candidate:1 1 UDP 2122252543 192.168.1.100 5004 typ host |
这里有个细节:a=setup 的值可以是 active、passive、actpass。Offer 端通常设 actpass,表示自己两种角色都行。Answer 端根据自身情况选 active 或 passive。如果两端都设 passive,DTLS 握手就卡死了——没人主动发起连接。
我曾经犯过的错:在自定义信令服务器里,我把 Answer 的 a=setup 写成了 passive,但 Offer 端也是 passive。结果 DTLS 握手一直超时,排查了整整一天才发现是角色冲突。记住:至少有一方必须是 active。
4.5 实际项目中的 SDP 处理技巧
说了这么多理论,最后分享几个我在项目中用到的实用技巧:
4.5.1 SDP 的裁剪与注入
有时候你不想要 Offer 里的某些编码,比如 VP9 太耗性能,想强制只用 H264。可以在收到 Offer 后、生成 Answer 前,手动修改 SDP:
// 伪代码:移除 VP9
function removeCodec(sdp, codecName) {
const lines = sdp.split('\n');
const filtered = [];
let inVideoSection = false;
let codecPayloads = [];
for (const line of lines) {
if (line.startsWith('m=video')) {
inVideoSection = true;
// 解析 payload 列表,移除目标编码
const parts = line.split(' ');
codecPayloads = parts.slice(3);
// 这里需要配合 rtpmap 行做映射
}
// ... 过滤逻辑
}
return filtered.join('\n');
}
嗯,这只是示意。实际项目中我推荐用成熟的 SDP 库,比如 sdp-transform 或 wrtc 自带的解析器,手写解析容易漏掉边界情况。
4.5.2 调试 SDP 的常用手段
- 打印原始 SDP:在
createOffer和setLocalDescription之间加日志,看看浏览器生成了什么。 - 对比 Offer 和 Answer:用 diff 工具对比两端 SDP,重点关注
m=、a=rtpmap、a=fmtp的差异。 - 检查 ICE 候选:如果媒体流建立失败,先看
a=candidate行是否为空。空候选意味着 ICE 没收集到可用地址。
一句话总结:SDP 是 WebRTC 的「媒人」,它不负责传输,但负责让双方知道对方想要什么、能给什么。搞懂 SDP,你就掌握了 WebRTC 协商的命脉。