4. 信令协议适配

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),然后双方按牌面打。

流程很简单:

  1. 发起方创建 Offer,包含自己所有的媒体能力。
  2. 接收方收到 Offer,匹配自己的能力,生成 Answer。
  3. 双方各自设置远端描述,开始传输。

注意:Offer 和 Answer 不是随便写的。Offer 可以列出多种编码,但 Answer 只能从 Offer 的列表里选。Answer 不能引入 Offer 里没有的编码。这是 RFC 3264 的硬性规定,违反的话对端会直接拒绝。

4.3.1 协商的匹配逻辑

我画了一张图,帮你理解 Offer/Answer 的匹配过程:

Offer/Answer 编解码协商流程 Offer 端(发起方) m=video 5004 RTP/AVP 98 99 100 a=rtpmap:98 H264/90000 a=rtpmap:99 VP8/90000 a=rtpmap:100 VP9/90000 优先级:H264 > VP8 > VP9 Answer 端(接收方) 支持:H264, VP8 不支持:VP9 选择 H264(优先级最高) Answer 只能从 Offer 列表里选 Offer 发送 Answer 回复 协商结果 双方使用 H264 编码,profile-level-id 取交集 如果交集为空,协商失败,媒体流无法建立 媒体流开始传输

你看,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 协商的命脉。