9、SDP深度解析:SDP结构分析、Offer/Answer模型、自定义SDP参数

聊到WebRTC,绕不开SDP。很多人觉得SDP就是一堆又臭又长的文本,看一眼就头疼。我刚开始接触的时候也这么想,甚至一度想跳过这部分。后来在调试一个跨网络通话的bug时,被SDP里的一个参数坑了整整两天……嗯,从那以后,我老老实实把SDP啃了一遍。

说白了,SDP就是通话双方的“自我介绍”。它告诉对方:我能支持什么编码、我的网络地址在哪、我的音视频参数是什么。没有它,两边根本没法建立连接。今天我们就把它拆开揉碎了讲清楚。

核心要点:SDP不是协议,而是一种描述格式。WebRTC用它来协商媒体能力。

9.1 SDP结构分析:一行一行拆给你看

一个典型的SDP文本,大概长这样。我截取了一段真实项目中的例子,去掉了敏感信息:

v=0
o=- 1234567890 2 IN IP4 192.168.1.100
s=-
t=0 0
m=audio 5004 RTP/AVP 111 103 104
a=rtpmap:111 opus/48000/2
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
a=fmtp:111 minptime=10;useinbandfec=1
c=IN IP4 192.168.1.100
m=video 5006 RTP/AVP 96 98
a=rtpmap:96 H264/90000
a=rtpmap:98 VP8/90000
a=fmtp:96 packetization-mode=1;profile-level-id=42e01f

每一行都有固定的格式:<类型>=<值>。类型是一个单字母,含义是固定的。我习惯把SDP分成三个层次来理解:

  • 会话层:以 v=o=s=t= 开头,描述整个会话的全局信息。
  • 媒体层:以 m= 开头,描述一个媒体流(音频或视频)。后面跟着端口、传输协议、编码格式列表。
  • 属性层:以 a= 开头,给媒体或会话附加参数。这是最灵活的部分,也是我们自定义参数的地方。

举个例子,m=audio 5004 RTP/AVP 111 103 104 这行,意思是:音频流,端口5004,用RTP/AVP协议,支持三种编码,对应的编号是111、103、104。至于111是什么编码?往下看 a=rtpmap:111 opus/48000/2 就知道了——opus编码,48kHz采样率,双声道。

我的习惯:调试时先看 m= 行,确定有几路流。再看 a=rtpmap,确认编码是否对齐。大部分连接失败,都是编码不匹配导致的。

9.2 Offer/Answer模型:你递过来,我改回去

WebRTC的SDP交换,遵循的是RFC 3264定义的Offer/Answer模型。流程很简单:

  1. 发起方(Offerer)生成一个SDP,描述自己希望怎么通信。
  2. 接收方(Answerer)收到后,根据自己的能力修改SDP,再发回去。
  3. 双方最终达成一致,开始传数据。

你想想看,这就像两个人约饭。A说:“我吃川菜、粤菜、日料都行。”B说:“我只会吃粤菜,那就粤菜吧。”Offer/Answer就是干这个的。

在实际代码中,我们通常这样操作:

// 创建Offer
const pc = new RTCPeerConnection();
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 通过信令服务器发送offer给对方

// 对方收到后,创建Answer
const pc2 = new RTCPeerConnection();
await pc2.setRemoteDescription(offer);
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
// 把answer发回给发起方

这里有个坑,我曾经踩过:setLocalDescription 和 setRemoteDescription 的顺序不能乱。必须先设置本地描述,再设置远端描述,或者反过来?其实规则是:Offer方先设本地,再设远端;Answer方先设远端,再设本地。搞反了,浏览器会直接抛异常。

注意:createOffer/createAnswer 返回的是未设置的SDP。你必须调用 setLocalDescription 之后,SDP中的ICE候选信息才会被填充完整。如果你在 setLocalDescription 之前就把SDP发出去,对方拿到的可能是个半成品。

9.3 自定义SDP参数:让浏览器听你的

默认生成的SDP,浏览器会选它认为最优的参数。但实际项目中,我们经常需要微调。比如强制使用H264编码,或者限制音频码率。

怎么做?直接修改SDP文本。我常用的方法有两种:

  • 在 createOffer 之后,setLocalDescription 之前,修改SDP字符串。
  • 通过 RTCRtpSender 的参数对象来控制。(更推荐,但有些参数只能改SDP)

举个例子,强制只使用H264:

const offer = await pc.createOffer();
let sdp = offer.sdp;
// 删除所有非H264的视频编码行
sdp = sdp.replace(/m=video.*\r\n/, 'm=video 0 RTP/AVP 96\r\n');
sdp = sdp.replace(/a=rtpmap:98.*\r\n/, '');
sdp = sdp.replace(/a=fmtp:98.*\r\n/, '');
offer.sdp = sdp;
await pc.setLocalDescription(offer);

这段代码很粗暴,但有效。我在一个嵌入式项目中就这么干过——因为对方的VP8解码器有bug,只能硬解H264。不改SDP,通话直接花屏。

另一个常见需求是限制音频码率。opus编码默认码率很高,在弱网下会卡顿。我们可以加一行属性:

a=fmtp:111 maxaveragebitrate=16000;maxplaybackrate=16000

这行加在音频媒体块里,告诉对方:opus码率限制在16kbps。效果立竿见影,通话流畅度提升明显。

重要提醒:修改SDP前,一定要先检查原始SDP中是否已经存在同类型的 a=fmtp 行。如果存在,要替换而不是追加,否则浏览器可能忽略你的设置。

9.4 一张图看懂SDP协商流程

下面这张SVG图,是我自己总结的SDP协商全过程。从创建PeerConnection到最终媒体流建立,每一步都标出来了。你保存下来,以后调试时对照着看,会清晰很多。

SDP Offer/Answer 协商流程 Offerer (发起方) Answerer (应答方) 1. createOffer() 2. setLocalDescription(offer) SDP Offer (信令通道) 3. setRemoteDescription(offer) 4. createAnswer() 5. setLocalDescription(answer) SDP Answer (信令通道) 6. setRemoteDescription(answer) ✅ 协商完成,媒体开始传输 ICE候选交换通常在步骤2-6之间并行进行,图中省略了ICE细节

9.5 避坑指南:我踩过的三个SDP坑

最后分享几个实战中容易翻车的地方。都是我曾经的血泪史:

  • 坑一:SDP中IP地址是内网地址,导致连接失败。 浏览器生成的SDP默认填的是本机IP,如果双方不在同一个局域网,需要ICE穿透。但有时候STUN/TURN配置不对,SDP里的IP一直没被替换。解决办法是检查ICE候选事件,确保有公网IP或relay地址。
  • 坑二:修改SDP后,编码参数不生效。 我试过在SDP里追加了 a=fmtp 行,但浏览器根本不认。后来发现原始SDP里已经有一行 a=fmtp,我直接追加导致重复。浏览器取第一个还是最后一个?不同浏览器行为不一样。稳妥做法是替换,不是追加。
  • 坑三:Offer和Answer的SSRC冲突。 有一次做SFU转发,我手动改了SDP中的SSRC,结果视频流一直黑屏。排查半天发现SSRC重复了。记住:SSRC在同一个会话中必须唯一,如果你手动修改,一定要确保不冲突。

我的建议:除非你非常清楚自己在做什么,否则尽量少动SDP。能用RTCRtpEncodingParameters控制的,就别去改字符串。改SDP是最后的手段,但也是最强的手段。

好了,SDP这块就讲到这里。内容不少,但都是干货。你把它理解成“媒体能力的自我介绍信”,就简单多了。下一节我们会聊更底层的传输细节,到时候你会回来感谢今天啃SDP的自己。


公众号:蓝海资料掘金营,微信deep3321