SDP(Session Description Protocol)是 WebRTC 里最容易被忽视、也最容易出问题的一环。它看起来只是一段文本,实际却同时承担了媒体描述、编解码能力声明、ICE 凭证传递、DTLS 指纹交换四项职责。很多人把 createOffer 的结果当成不透明的字符串直接转发,一旦协商失败就束手无策。
本文的目标是让你能读懂一条 SDP 的每一行,理解 Offer/Answer 状态机如何约束操作顺序,并掌握编解码协商与再协商的工程细节。读完你应该能在浏览器控制台里定位绝大多数协商类故障。
一、SDP 是什么
1.1 一段纯文本的会话描述
SDP 最早由 RFC 4566 定义,是一种纯文本的键值描述格式。每一行形如 <字符>=<值>,其中字符表示行类型。WebRTC 使用的 SDP 是 RFC 8829(JSEP)规定的子集,并扩展了 ICE、DTLS 相关属性。
常见行类型如下:
| 前缀 | 名称 | 含义 |
|---|---|---|
| v= | version | 协议版本,固定为 0 |
| o= | origin | 会话标识与版本号 |
| s= | session name | 会话名,WebRTC 中固定为 - |
| t= | timing | 时间描述,WebRTC 中为 0 0 |
| m= | media | 媒体行,描述一条媒体流 |
| c= | connection | 连接信息,通常为 IN IP4 0.0.0.0 |
| a= | attribute | 属性行,数量最多、变化最大 |
| b= | bandwidth | 带宽提示 |
其中 m= 与 a= 是理解 WebRTC 协商的关键,其余行在 WebRTC 中几乎恒定不变。
1.2 SDP 不是配置,而是能力提议
一个常见的误解是认为 SDP 是「我要用这个编码」的指令。实际上它是一份能力提议:Offer 方列出自己支持的全部编解码与参数,Answer 方从中挑选交集并回填。这种「提议-应答」模型决定了 SDP 必须成对出现,单方面修改往往导致协商失败。
二、一条真实 SDP 逐行解读
下面是一条典型的音视频 Offer(已省略部分无关行):
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
a=msid-semantic: WMS stream0
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 110
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:8hhY
a=ice-pwd:asd88fgpdd777uzjYhagZg
a=ice-options:trickle
a=fingerprint:sha-256 6B:8B:5F:...:2A
a=setup:actpass
a=mid:0
a=sendrecv
a=rtcp-mux
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:63 red/48000/2
a=rtpmap:9 G722/8000
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:110 telephone-event/48000
a=ssrc:1234567 cname:abc
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
c=IN IP4 0.0.0.0
a=mid:1
a=sendrecv
a=rtcp-mux
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=rtcp-fb:96 goog-remb
a=rtcp-fb:96 transport-cc
a=rtpmap:96 VP8/90000
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
a=rtpmap:98 H264/90000
a=fmtp:98 profile-level-id=42e01f;packetization-mode=1
2.1 会话级属性
a=group:BUNDLE 0 1 表示把 mid 为 0 和 1 的两条媒体复用同一条传输通道(同一个五元组)。BUNDLE 能显著减少候选与端口的数量,现代实现默认开启。a=msid-semantic: WMS 声明媒体流语义。
2.2 媒体级属性
以音频 m-line 为例,几个关键属性值得单独说明:
a=ice-ufrag与a=ice-pwd:ICE 凭证,用于连通性检查的完整性校验。每次 ICE 重启都会变化。a=fingerprint:sha-256 ...:DTLS 证书指纹,用于在不依赖 PKI 的情况下验证对端身份,是 DTLS-SRTP 的信任根。a=setup:actpass:DTLS 角色协商,actpass表示「我既能主动也能被动」,由 Answer 方决定最终角色。a=mid:0:媒体标识,BUNDLE 与候选配对都依赖它。a=sendrecv:方向属性,决定这条流是收发还是单向。
三、m-line 与媒体描述
3.1 m-line 的七个字段
一条 m-line 形如 m=<media> <port> <proto> <fmt> ...,字段含义如下:
| 位置 | 示例 | 含义 |
|---|---|---|
| media | audio / video | 媒体类型 |
| port | 9 | 端口,WebRTC 中为占位符 9 |
| proto | UDP/TLS/RTP/SAVPF | 传输协议,SAVPF 表示带反馈的 SRTP |
| fmt | 111 63 9 … | 载荷类型列表,按优先级排序 |
UDP/TLS/RTP/SAVPF 这一串几乎可以作为 WebRTC 的指纹:UDP 传输、TLS(即 DTLS)握手、RTP 承载、SAVPF 表示安全音频视频与反馈。
3.2 载荷类型与 rtpmap
m-line 里出现的数字是动态载荷类型(Payload Type),必须通过 a=rtpmap 映射到具体编解码:
a=rtpmap:111 opus/48000/2
这行表示载荷类型 111 对应 Opus,采样率 48000 Hz,双声道。数字本身没有固定含义,只有结合 rtpmap 才能确定编解码。
四、Offer/Answer 状态机
4.1 signalingState 取值
RTCPeerConnection.signalingState 约束了合法操作的顺序,取值如下:
| 状态 | 含义 | 允许的操作 |
|---|---|---|
| stable | 无进行中的协商 | createOffer / createAnswer |
| have-local-offer | 已设置本地 Offer | setRemoteDescription(answer) |
| have-remote-offer | 已设置远端 Offer | createAnswer / setLocalDescription |
| have-local-pranswer | 已设置本地临时应答 | setRemoteDescription(answer) |
| have-remote-pranswer | 已设置远端临时应答 | setLocalDescription(answer) |
| closed | 连接已关闭 | 无 |
最常见的新手错误是在 have-local-offer 状态下再次调用 createOffer,或在未设置远端描述时调用 createAnswer,都会抛出 InvalidStateError。
4.2 一次完整的协商
// 发起方
const pcA = new RTCPeerConnection();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
stream.getTracks().forEach((t) => pcA.addTrack(t, stream));
const offer = await pcA.createOffer();
console.log("协商前状态:", pcA.signalingState); // stable
await pcA.setLocalDescription(offer);
console.log("设置本地描述后:", pcA.signalingState); // have-local-offer
// 通过信令发送 offer.sdp 给对端
signaling.send({ type: "offer", sdp: pcA.localDescription.sdp });
// 接收方
const pcB = new RTCPeerConnection();
pcB.ontrack = (e) => (document.querySelector("#remote").srcObject = e.streams[0]);
async function handleOffer(sdp) {
await pcB.setRemoteDescription({ type: "offer", sdp });
console.log("接收方状态:", pcB.signalingState); // have-remote-offer
const answer = await pcB.createAnswer();
await pcB.setLocalDescription(answer);
signaling.send({ type: "answer", sdp: pcB.localDescription.sdp });
}
// 发起方收到 answer
async function handleAnswer(sdp) {
await pcA.setRemoteDescription({ type: "answer", sdp });
console.log("回到稳定态:", pcA.signalingState); // stable
}
4.3 临时应答 PRANSWER
状态机里还有 have-local-pranswer 与 have-remote-pranswer 两个状态,对应临时应答(provisional answer)。它允许接收方在正式 Answer 之前先回一个临时的 SDP,例如先接受音频再慢慢协商视频。这在 PSTN 网关与呼叫中心场景里有实际用途,但普通 WebRTC 应用极少使用,多数开发者甚至不会遇到。若排障时看到这两个状态,说明链路里存在 SIP 网关一类的中间设备。
五、编解码协商
5.1 交集与优先级
Answer 方不是简单接受 Offer 的全部编解码,而是计算双方支持的交集,并按自己的偏好排序后回填到 Answer 的 m-line 中。因此在 WebRTC 中,最终使用哪个编解码由 Answer 方决定,Offer 方只能通过调整 m-line 中载荷类型的顺序来施加影响。
| 编解码 | 典型载荷类型 | 用途 | 关键 fmtp 参数 |
|---|---|---|---|
| Opus | 111(动态) | 音频 | useinbandfec=1、minptime |
| PCMU / PCMA | 0 / 8(静态) | 音频兜底 | 无 |
| VP8 | 96(动态) | 视频 | 无 |
| H264 | 98(动态) | 视频 | profile-level-id、packetization-mode |
| rtx | 97(动态) | 重传 | apt=96 指向被重传的编码 |
5.2 反馈机制与 RTCP
a=rtcp-fb 行声明了本端支持的反馈类型,它直接决定了拥塞控制与抗丢包能力:
a=rtcp-fb:96 nack // 支持 NACK,可按包请求重传
a=rtcp-fb:96 nack pli // 支持 PLI,可请求关键帧
a=rtcp-fb:96 goog-remb // 支持 REMB 带宽估计
a=rtcp-fb:96 transport-cc // 支持 TWCC 传输层拥塞控制
如果某条 SDP 里缺少 transport-cc,那么基于 TWCC 的带宽估计就无从谈起,弱网表现会明显变差。这也是很多「同一套代码在某个浏览器上更卡」的根因之一。
六、方向属性与再协商
6.1 四种方向
| 方向 | 语义 |
|---|---|
| sendrecv | 双向收发 |
| sendonly | 只发送 |
| recvonly | 只接收 |
| inactive | 暂停 |
方向是相对的:一方的 sendonly 对应另一方的 recvonly。修改方向不需要重建连接,通过再协商即可完成,这也是实现「静音」「暂停视频」的优雅方式。
6.2 再协商的触发
新增或移除轨道会自动触发 negotiationneeded,从而开启一轮新的 Offer/Answer:
pc.onnegotiationneeded = async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: "offer", sdp: pc.localDescription.sdp });
};
// 后续新增屏幕共享轨道,会自动触发再协商
const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
pc.addTrack(screen.getVideoTracks()[0], screen);
再协商会复用已建立的 ICE 与 DTLS 通道,只更新媒体描述,因此开销远小于首次建连。但要注意,某些实现会在再协商时重排 m-line 顺序,如果信令层按位置而非 mid 关联媒体,就会错位。
七、SDP 改写的风险
生产系统里常见「拦截并改写 SDP」的需求,例如强制某个编解码、删除某些候选、插入自定义属性。改写本身可行,但风险很高:
- 改写
m=行的载荷类型顺序会改变协商偏好,但若改得与rtpmap不一致会导致解析失败。 - 删除
a=fingerprint或a=ice-ufrag会直接破坏加密与连通性。 - 修改
a=setup角色可能与对端冲突,导致 DTLS 握手死锁。 - 不同浏览器生成的 SDP 顺序与属性集差异很大,正则替换极易误伤。
更稳妥的做法是优先使用浏览器提供的 API(如 setCodecPreferences)而非字符串改写:
const transceiver = pc.getTransceivers()[0];
const capabilities = RTCRtpSender.getCapabilities("video").codecs;
const preferred = capabilities.filter((c) => c.mimeType === "video/VP8");
transceiver.setCodecPreferences(preferred);
八、SFU 场景下的 SDP 差异
8.1 服务端也会改写 SDP
在多人会议中,客户端往往不直接与对端交换 SDP,而是与 SFU 交换。此时 SFU 会对 SDP 做一次「翻译」:把客户端的 Offer 转成自己作为对端的 Answer,再为每个订阅者生成新的 Offer。这个过程叫 SDP 归一化(normalization)。
这意味着客户端看到的 SDP 与对端真实能力并不完全一致。例如 SFU 可能把多个发布者的轨道合并进同一条 m-line,也可能把某个编解码统一转码。理解这一点,才能解释「为什么两端明明都支持 VP9,实际却在用 VP8」——因为 SFU 中间做了取舍。
8.2 Simulcast 在 SDP 中的表达
Simulcast 让发送方同时编码多个分辨率层,SFU 按订阅者网络状况转发不同层。它在 SDP 中通过 a=simulcast 与 a=rid 表达:
a=rid:0 send pt=96;max-width=1280;max-height=720
a=rid:1 send pt=96;max-width=640;max-height=360
a=rid:2 send pt=96;max-width=320;max-height=180
a=simulcast:send 0;1;2
浏览器侧通过 sendEncodings 声明这些层:
const sender = pc.addTrack(track, stream);
const params = sender.getParameters();
params.encodings = [
{ rid: "0", maxBitrate: 1500000, scaleResolutionDownBy: 1 },
{ rid: "1", maxBitrate: 500000, scaleResolutionDownBy: 2 },
{ rid: "2", maxBitrate: 150000, scaleResolutionDownBy: 4 },
];
await sender.setParameters(params);
注意 setParameters 必须在 sender 已有轨道后调用,且在部分浏览器上首次调用需要先 getParameters 拿到当前值再修改,不能凭空构造。
8.3 从控制台观察协商结果
排障时最直接的手段是把 pc.localDescription.sdp 与 pc.remoteDescription.sdp 打印出来对比,重点看三处:m-line 的数量与顺序、每条 m-line 的载荷类型列表、a=rtcp-fb 行是否包含 transport-cc。这三处能覆盖绝大多数协商异常。
九、常见坑清单
- 把 SDP 当作不可变字符串缓存,导致 ICE 重启后使用了过期的
ice-ufrag。 - 在
have-local-offer状态下重复createOffer,抛出InvalidStateError。 - 用
sdpMLineIndex关联媒体,但再协商后 m-line 顺序变化,关联错乱。 - 忽略
a=rtcp-fb中的transport-cc,导致拥塞控制退化。 - Answer 方未回填
a=setup:active/passive,DTLS 角色协商失败。 - 手工拼接 SDP 时丢失
a=mid,BUNDLE 复用失败。 - 忘记
a=msid,导致对端ontrack拿不到streams,只能拿到裸轨道。 - 在移动端浏览器上强行指定
H264 profile-level-id,与硬件编码器不匹配。 - 把
a=rtcp-mux去掉,导致 RTP 与 RTCP 使用不同端口,BUNDLE 复用失败。 - 未设置
a=ice-options:trickle却使用 Trickle ICE,候选无法及时发送。 - 把
a=setup两端都写成active,DTLS 角色冲突导致握手停滞。
小结
SDP 是 WebRTC 的「合同文本」,它把编解码、ICE 凭证、DTLS 指纹与方向语义都写在同一份描述里。读懂 m-line 与 a= 属性,理解 Offer/Answer 状态机对操作顺序的约束,是排查协商问题的前提。生产代码应尽量避免字符串改写 SDP,优先使用 setCodecPreferences 等标准 API。编解码协商的结果会直接影响拥塞控制能力,这部分细节与 音视频编解码与拥塞控制
紧密相关。SDP 只是描述,真正把路径打通的是 ICE / STUN / TURN 与 NAT 穿透原理
里讲的候选与检查。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。