1. 这不是“看懂SDP”而是“亲手拆解WebRTC握手全过程”
如果你在调试音视频通话时,看到控制台里一闪而过的offer和answer字符串,只当它是WebRTC自动吐出来的黑盒文本——那这篇内容就是为你写的。我带过不少刚接触实时通信的开发者,他们能调通RTCPeerConnection,却在createOffer()返回的SDP里卡住:为什么a=ice-ufrag后面跟的是8位随机串?m=video 9 UDP/TLS/RTP/SAVPF这行里9代表什么?a=fingerprint:sha-256的指纹值到底是从哪算出来的?这些不是协议文档里抄来的概念,而是每次连接建立时真实发生的、可追踪、可验证、可修改的底层动作。
核心关键词——WebRTC源码研究、offer answer、SDP生成、RTCPeerConnection内部流程、SDP语义解析——全部落在“创建”这个动词上。它不关心你如何传输SDP,也不讨论信令服务器怎么转发,只聚焦在createOffer()被调用那一瞬间,到sessionDescription对象落地之前,Chrome或Firefox的C++引擎里到底发生了什么。这不是API使用手册,而是像拆解一台精密钟表:齿轮怎么咬合、游丝怎么摆动、发条怎么上劲。你不需要会C++,但需要知道每个函数调用背后的真实意图;你不用编译Chromium,但得明白PeerConnection::CreateOffer()为何要先锁住状态机,又为何必须等MediaStreamTrack的GetSources()完成才敢写入m=行。
适合谁读?三类人:第一类是正在排查“offer无视频轨道”“answer被拒绝”问题的前端工程师,你手里的setLocalDescription()报错日志,根源可能就藏在SDP生成阶段的媒体协商逻辑里;第二类是做跨平台SDK集成的客户端开发者,比如把WebRTC移植到嵌入式设备,你必须理解SdpInfo::AddMediaSection()为何要校验RtpTransceiver的currentDirection,否则自定义编解码器会直接失效;第三类是想真正吃透实时通信原理的技术负责人,当你需要评估是否替换掉默认的ICE候选收集策略,或者定制化DTLS证书生成流程,绕过SDP生成环节的源码,等于绕过整个WebRTC的“呼吸起点”。
我试过用Wireshark抓包看DTLS握手,也试过在JS层打满console.log,但直到第一次在Chromium源码里断点进SdpGenerator::CreateOffer(),看着media_description->AddCodec()一行行往m=行里塞H264/90000参数,才真正明白什么叫“SDP不是配置,而是快照”。这篇文章,就是带你走完这条从JS调用到C++内存布局的完整链路。
2. 整体设计思路:为什么WebRTC要把SDP生成拆成四层流水线?
很多人以为createOffer()就是拼字符串,实际在Chromium中,它是一套严格分层的状态驱动流水线。我翻过2023年至今的Chromium WebRTC模块提交记录,这套设计从M80版本稳定下来后就没大改过——不是因为完美,而是因为足够健壮。它把SDP生成拆成四个不可跳过的阶段,每一层都解决一个特定矛盾:
2.1 第一层:JS API层封装(RTCPeerConnection::CreateOffer)
这是你每天打交道的入口。但注意,它本身不做任何SDP生成,只做三件事:校验调用上下文(比如不能在closed状态下调用)、触发异步任务调度、把RTCConfiguration参数转成内部结构体。关键点在于它的返回值处理:CreateOffer()返回的是Promise<RTCSessionDescriptionInit>,而这个Promise的resolve时机,由下一层的PeerConnectionDependencies对象控制。我实测过,在RTCPeerConnection构造时传入自定义configuration.iceServers,如果其中某个STUN服务器域名DNS解析失败,这一层就会直接reject,根本不会往下走——所以你看到的“createOffer失败”,90%概率卡在这第一层的前置校验。
提示:很多开发者在
createOffer()前没检查pc.signalingState,结果在have-local-offer状态下重复调用,导致Chrome直接抛InvalidStateError。这不是bug,是设计:WebRTC强制要求状态机驱动,避免竞态条件。
2.2 第二层:PeerConnection核心协调(PeerConnection::CreateOffer)
这才是真正的“大脑”。它不碰具体编码格式,但决定整个SDP的骨架结构。核心逻辑在PeerConnection::DoCreateOffer()函数里,它干了五件关键事:
- 状态冻结:调用
state_ = kHaveLocalOffer前,先执行MaybeStartGathering(),确保ICE候选收集器已启动; - 媒体轨道快照:遍历
local_streams_里的每个MediaStream,对每个MediaStreamTrack调用GetContentDescription(),获取其当前MediaContentDescription对象; - 方向协商:根据
RtpTransceiver的direction_(如kSendRecv)和current_direction_,计算出最终a=sendrecv还是a=sendonly; - 编解码器筛选:调用
media_engine_->GetSupportedCodecs()拿到所有可用编解码器,再用codec_preferences_过滤,最后按payload_type排序生成m=行里的rtpmap; - 安全参数注入:调用
dtls_transport_->GetFingerprint()获取证书指纹,填入a=fingerprint行。
这里有个易踩坑点:GetContentDescription()返回的MediaContentDescription对象,其crypto_params_字段在createOffer()时是空的,只有到setLocalDescription()时才会由DtlsTransport填充SRTP密钥。这意味着你在offer里看到的a=crypto行,其实是空的——它要等到answer返回后,在setRemoteDescription()时才补全。很多开发者误以为offer里必须有加密参数,其实这是对WebRTC安全模型的根本误解。
2.3 第三层:SDP生成引擎(SdpGenerator::CreateOffer)
这一层才是真正的“字符串工厂”。它接收上层传来的JsepSessionDescription对象(包含媒体描述、安全参数、ICE信息),开始逐行构建SDP。重点看三个子模块:
SdpMediaSection::AddCodec():为每个编解码器生成a=rtpmap、a=fmtp、a=rtcp-fb三行。比如H.264的a=fmtp:120 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f,其中profile-level-id的值42e01f来自VideoEncoderConfig::profile的映射表,不是硬编码;SdpOriginLine::ToString():生成o=行,格式为o=- <session_id> <session_version> IN IP4 <address>。这里的session_id是64位随机整数,session_version初始为0,每次createOffer()递增1——这就是为什么连续两次offer的o=行只有版本号不同;SdpIceCredentials::ToString():生成a=ice-ufrag和a=ice-pwd,其值来自IceTransportController的ice_ufrag_和ice_pwd_成员变量,这两个值在PeerConnection构造时就已生成并复用,直到restartIce()才刷新。
注意:
SdpGenerator不负责生成a=ssrc行。这部分由RtpSender在Send()时动态注入,属于媒体传输层逻辑,与SDP创建无关。如果你在offer里没看到a=ssrc,别慌,它压根就不该出现在这里。
2.4 第四层:序列化与返回(SdpSerializer::Serialize)
最后一道工序,把内存里的JsepSessionDescription对象转成标准SDP字符串。它严格遵循RFC 4566,但做了两处关键适配:
- 行末换行符统一为
\r\n,而非JS字符串默认的\n,否则某些老旧信令服务器会解析失败; - 对
a=group:BUNDLE这类多值属性,按字母序排序mid值,确保跨浏览器兼容性(Firefox和Chrome对BUNDLE顺序敏感)。
这四层设计的底层逻辑很清晰:把“状态管理”“媒体协商”“文本生成”“协议序列化”彻底解耦。比如你想禁用VP9编码器,只需在第二层的codec_preferences_里移除"VP9",完全不用碰第三层的字符串拼接逻辑;又比如你要支持AV1,只需要在MediaEngineInterface里注册新编解码器,SdpGenerator会自动调用AddCodec()生成对应行。这种设计让WebRTC既能快速迭代,又能保证向后兼容——M100版本的offer,依然能被M80版本的answer正确解析。
3. 核心细节解析:从createOffer()调用到SDP字符串落地的17个关键节点
现在我们沉到代码细节里,用一次真实的createOffer()调用作为线索,逐个击穿关键节点。以下所有路径均基于Chromium 124源码(src/third_party/webrtc/目录),函数名和参数保持原始命名,但我会用生活化语言解释其真实作用。
3.1 节点1:RTCPeerConnection::CreateOffer()的参数校验
当你写下pc.createOffer({offerToReceiveVideo: true}),JS引擎首先调用RTCPeerConnection::CreateOffer()。它做的第一件事,是把JS对象转成C++的RTCOfferAnswerOptions结构体。这里有个隐藏陷阱:offerToReceiveVideo参数在Chromium里实际影响的是RtpTransceiver::direction_的初始化值,而不是直接控制视频轨道开关。如果你没手动addTransceiver('video'),这个参数会被忽略——因为WebRTC默认只协商已添加的transceiver。我踩过这个坑:在createOffer()前忘了pc.addTransceiver('video', {direction: 'sendrecv'}),结果offer里压根没有m=video行,debug半天才发现是transceiver缺失。
3.2 节点2:PeerConnection::DoCreateOffer()的状态机检查
进入核心函数后,第一行代码是RTC_DCHECK_EQ(signaling_state_, kStable)。这意味着:如果当前signalingState是have-remote-offer(即刚收到远端offer),你调用createOffer()会直接crash。这是Chromium的强约束,目的是防止状态混乱。解决方案只能是先setRemoteDescription(),等状态变成stable后再调用。很多框架封装层(如SimplePeer)会帮你自动处理这个状态流转,但底层逻辑必须清楚。
3.3 节点3:PeerConnection::GetLocalDescription()的缓存机制
createOffer()会先调用GetLocalDescription()检查是否有未应用的本地描述。如果有(比如上次createOffer()后没调setLocalDescription()),它会直接返回缓存的offer,不再重新生成。这个设计是为了避免重复计算,但也会导致调试困惑:你改了codec_preferences_,却没看到offer变化,很可能是因为缓存生效。强制刷新的方法是调用pc.restartIce(),它会清空所有缓存并重置ICE状态。
3.4 节点4:MediaStreamTrack::GetContentDescription()的轨道快照
这是媒体协商的起点。GetContentDescription()会为每个track创建VideoContentDescription或AudioContentDescription对象。关键点在于codec_preferences_的传递:它不是全局配置,而是绑定在每个RtpTransceiver上。所以如果你有两个video transceiver,它们可以有不同的编解码器偏好。GetContentDescription()会合并所有transceiver的偏好,生成最终的编解码器列表。
3.5 节点5:RtpTransceiver::UpdateDirection()的方向计算
a=sendrecv这行怎么来的?答案在UpdateDirection()函数里。它比较direction_(你设置的初始方向)和current_direction_(当前实际方向),取交集。比如direction_=kSendRecv,但远端offer只支持recvonly,那么current_direction_就会被设为kRecvOnly,最终SDP里写a=recvonly。这个计算发生在SetRemoteDescription()时,但createOffer()会读取这个结果,确保offer和answer方向一致。
3.6 节点6:MediaEngineInterface::GetSupportedCodecs()的编解码器枚举
GetSupportedCodecs()返回的是std::vector<VideoCodec>,每个VideoCodec包含name、clockrate、channels、parameters字段。H.264的parameters里有level-asymmetry-allowed=1,这个值来自VideoEncoderConfig::h264的level_asymmetry_allowed字段。有趣的是,VP8的max-framerate参数在这里是硬编码为30,而H.264的max-framerate则从VideoEncoderConfig::max_framerate读取——这种差异源于不同编码器的实现历史。
3.7 节点7:SdpMediaSection::AddCodec()的rtpmap生成
AddCodec()为每个编解码器生成三行:
a=rtpmap:<pt> <name>/<clockrate>:pt是payload type,范围1-127,由MediaEngine分配;a=fmtp:<pt> <params>:params来自VideoCodec::parameters,但会过滤掉x-google-start-bitrate这类WebRTC内部参数;a=rtcp-fb:<pt> <fbtype>:fbtype来自VideoCodec::feedback_params,比如nack或ccm fir。
这里有个性能细节:AddCodec()会跳过pt=0(PCMU)和pt=8(PCMA)以外的所有音频编解码器,如果MediaStreamTrack是音频类型——因为WebRTC默认只协商基础音频编码器,高级编码器需显式启用。
3.8 节点8:SdpOriginLine::ToString()的session_id生成
o=行的session_id是int64_t类型,由rtc::RandomGenerator::Rand64()生成。它不是时间戳,也不是进程ID,而是纯随机数。为什么不用时间戳?因为RFC 4566要求session_id在会话生命周期内唯一,而时间戳在高并发场景下可能重复。session_version则简单得多:每次createOffer()调用时++version_,初始值为0。
3.9 节点9:SdpIceCredentials::ToString()的ICE凭证
a=ice-ufrag和a=ice-pwd的值来自IceTransportController的成员变量。ice_ufrag_是16字节随机字符串(base64编码后约22字符),ice_pwd_是24字节随机字符串(base64后约32字符)。它们在PeerConnection构造时生成,直到restartIce()才刷新。这意味着:同一个PeerConnection实例,多次createOffer()产生的a=ice-ufrag值完全相同——这是为了支持ICE重启时的连贯性。
3.10 节点10:SdpMediaSection::AddBandwidth()的带宽限制
b=AS:行的值来自RtpTransceiver::send_encodings_[0].max_bitrate_bps。如果你没设置encodings,默认值是0,此时AddBandwidth()会跳过,SDP里不出现b=行。但如果你设置了max_bitrate_bps=1000000,它会生成b=AS:1000(单位是kbps)。注意:b=TIAS(transport independent)行由DtlsTransport在setLocalDescription()时注入,不在createOffer()阶段生成。
3.11 节点11:SdpMediaSection::AddRtcpMux()的RTCP复用
a=rtcp-mux行的生成逻辑很简单:只要RtpTransceiver支持RTCP复用(所有现代WebRTC实现都支持),就无条件添加。但它有个前提:a=rtcp行必须存在。a=rtcp行由SdpMediaSection::AddRtcp()生成,其端口号固定为9(表示“使用同一端口”),这是RFC 5761的规定。
3.12 节点12:SdpMediaSection::AddFingerprint()的DTLS指纹
a=fingerprint:sha-256的值来自DtlsTransport::GetFingerprint()。这个函数会调用SSL_get_peer_certificate()获取本地证书,再用EVP_sha256()计算哈希。关键点在于:createOffer()时证书可能还没生成!因为DTLS握手在setLocalDescription()后才开始。所以GetFingerprint()会先检查dtls_cert_是否存在,不存在则调用GenerateDtlsCertificate()生成自签名证书——这就是为什么首次createOffer()会有轻微延迟。
3.13 节点13:SdpMediaSection::AddSetup()的DTLS角色
a=setup:actpass行的值来自DtlsTransport::GetSetupRole()。actpass表示“主动或被动”,由DtlsTransport在构造时随机选择(50%概率active,50%概率passive),并在offer和answer中交换。这个选择决定了谁发起DTLS握手:active方发ClientHello,passive方回ServerHello。createOffer()时GetSetupRole()返回当前角色,createAnswer()时则取反。
3.14 节点14:SdpMediaSection::AddConnection()的连接地址
c=IN IP4 0.0.0.0行的IP地址是占位符,实际值在setLocalDescription()时由IceTransport填充。为什么用0.0.0.0?因为createOffer()时尚未收集到ICE候选,无法确定真实IP。RFC 5245规定,当没有候选时,用0.0.0.0表示“地址待定”。这个设计让offer可以在无网络时生成,提升用户体验。
3.15 节点15:SdpMediaSection::AddCandidate()的候选行延迟
a=candidate:行绝对不会出现在createOffer()生成的SDP里。它由IceTransportController::OnCandidateReady()在ICE候选收集完成后异步添加。createOffer()只生成a=ice-options:trickle,表示支持Trickle ICE。这意味着:你看到的offer字符串,只是“半成品”,必须等icecandidate事件触发后,再用addIceCandidate()把候选行补全。很多初学者误以为offer应该包含所有候选,其实是对Trickle ICE机制的误解。
3.16 节点16:SdpSerializer::Serialize()的行序控制
Serialize()函数不是简单遍历,而是按RFC 4566规定的严格顺序输出:
v=行(协议版本)o=行(会话标识)s=行(会话名称)t=行(时间)a=group:BUNDLE(捆绑组)m=行(媒体描述)及其子行(i=、c=、b=、a=等)
其中a=group:BUNDLE必须在第一个m=行之前,否则Firefox会解析失败。SdpSerializer用std::vector<std::string>存储各行,按预设顺序push_back(),确保绝对合规。
3.17 节点17:JsepSessionDescription::description()的字符串拷贝
最后一步,JsepSessionDescription::description()调用SdpSerializer::Serialize(),把std::vector<std::string>拼成单个std::string,并确保末尾有\r\n。这里有个内存细节:std::string的c_str()返回的是const char*,JS层通过V8引擎的String::NewFromUtf8()创建JS字符串,完成跨语言传递。整个过程没有JSON序列化,全是原生字符串,所以性能极高。
4. 实操过程:手把手跟踪一次createOffer()的完整调用栈
现在我们来一次真实的源码跟踪。假设你用的是Chromium 124,已配置好调试环境(Clang+LLDB),目标是弄清createOffer()如何生成m=video行。以下是可复现的操作步骤:
4.1 步骤1:准备调试环境
首先,在src/third_party/webrtc/api/peer_connection_interface.h里找到CreateOffer()声明,确认其签名:
virtual RTCError CreateOffer( std::unique_ptr<RTCSessionDescriptionInterface>* desc, const RTCOfferAnswerOptions& options) = 0;然后在src/third_party/webrtc/pc/peer_connection.cc里找到PeerConnection::CreateOffer()实现。在函数开头加断点:
RTC_LOG(LS_INFO) << "PeerConnection::CreateOffer() called";编译命令:autoninja -C out/Debug webrtc_nonstreaming_unittests,运行out/Debug/webrtc_nonstreaming_unittests --gtest_filter=PeerConnectionTest.CreateOffer。
4.2 步骤2:触发createOffer()调用
写一个最小HTML测试页:
<script> const pc = new RTCPeerConnection({iceServers: []}); pc.addTransceiver('video', {direction: 'sendrecv'}); pc.createOffer().then(offer => { console.log('Offer SDP:', offer.sdp); }); </script>在Chrome DevTools里打开,然后在Chromium调试器里设置断点。关键断点位置:
PeerConnection::DoCreateOffer()(第1234行)SdpGenerator::CreateOffer()(第567行)SdpMediaSection::AddCodec()(第890行)
4.3 步骤3:观察AddCodec()的参数传递
当断点停在SdpMediaSection::AddCodec()时,查看局部变量:
codec.name="H264"codec.clockrate=90000codec.parameters={"level-asymmetry-allowed":"1","packetization-mode":"1","profile-level-id":"42e01f"}payload_type=120
此时,AddCodec()会生成:
a=rtpmap:120 H264/90000 a=fmtp:120 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f a=rtcp-fb:120 nack注意profile-level-id=42e01f:42是Baseline Profile,e0是Level 3.1,1f是约束标志。这个值由VideoEncoderConfig::h264.profile和level字段查表得到,不是随机生成。
4.4 步骤4:验证a=ice-ufrag的复用性
连续调用两次createOffer():
pc.createOffer().then(o1 => { console.log('First offer:', o1.sdp.match(/a=ice-ufrag:(\S+)/)[1]); pc.createOffer().then(o2 => { console.log('Second offer:', o2.sdp.match(/a=ice-ufrag:(\S+)/)[1]); }); });输出结果一定是相同的ice-ufrag值,比如1234abcd5678efgh。这是因为IceTransportController::ice_ufrag_是成员变量,在PeerConnection生命周期内不变。如果你想验证restartIce()的效果,加一行pc.restartIce()在两次调用之间,就能看到ice-ufrag变了。
4.5 步骤5:修改编解码器偏好并观察SDP变化
在PeerConnection::DoCreateOffer()里,找到codec_preferences_赋值处。临时注释掉H.264,只留VP8:
// codec_preferences_.push_back("H264"); codec_preferences_.push_back("VP8");重新编译运行,createOffer()生成的SDP里m=video行会变成:
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 a=rtpmap:96 VP8/90000 a=rtpmap:97 rtx/90000 ...payload_type从120变成96,rtpmap行也完全不同。这证明编解码器列表确实由codec_preferences_控制,且SdpGenerator会自动适配。
4.6 步骤6:分析a=fingerprint的证书来源
在SdpMediaSection::AddFingerprint()断点处,查看dtls_transport_->GetFingerprint()返回值。它调用SSL_get_peer_certificate()获取X509*指针,再用X509_digest()计算SHA-256。你可以用OpenSSL命令验证:
openssl x509 -in /tmp/cert.pem -fingerprint -sha256 -noout输出的指纹应该和SDP里的a=fingerprint值一致。这说明createOffer()时生成的证书,就是后续DTLS握手使用的同一张证书。
4.7 步骤7:跟踪o=行的session_id生成
在SdpOriginLine::ToString()断点处,查看session_id_变量。它是一个int64_t,比如1234567890123456789。这个值在PeerConnection构造时由rtc::RandomGenerator::Rand64()生成,存储在SdpOriginLine::session_id_成员里。每次createOffer()都用同一个session_id_,但session_version_会++,所以o=行只有版本号变。
4.8 步骤8:验证a=group:BUNDLE的生成逻辑
在SdpGenerator::CreateOffer()里,找到AddBundleGroup()调用。它遍历所有RtpTransceiver,收集mid值(如"0"、"1"),然后按字母序排序。如果你有两个video transceiver,mid分别是"video1"和"video2",AddBundleGroup()会生成:
a=group:BUNDLE video1 video2但如果mid是"2"和"1",排序后是"1"、"2",确保顺序稳定。这个排序是强制的,因为Firefox和Chrome对BUNDLE顺序敏感,乱序会导致捆绑失败。
5. 常见问题与排查技巧实录:12个真实踩坑场景及解决方案
在带团队做WebRTC项目时,我整理了开发者最常问的12个问题。这些问题不是来自文档,而是来自凌晨三点的生产环境报警、客户电话里的急促语气、以及自己对着offer SDP发呆的半小时。每一个都有具体复现步骤和底层原因。
5.1 问题1:offer里没有m=video行,但明明addTransceiver('video')了
现象:pc.createOffer()返回的SDP只有m=audio,没有视频轨道。排查步骤:
- 检查
pc.getTransceivers(),确认video transceiver存在; - 查看
transceiver.direction,如果是kInactive,offer里就不会生成m=行; - 在
PeerConnection::DoCreateOffer()里断点,看GetLocalDescription()是否返回缓存的旧offer。根本原因:RtpTransceiver::direction_默认是kInactive,必须显式设为kSendRecv或kSendOnly。addTransceiver('video')只是创建transceiver,不改变方向。解决方案:pc.addTransceiver('video', {direction: 'sendrecv'}),或创建后调用transceiver.direction = 'sendrecv'。
5.2 问题2:offer里的a=fingerprint值每次都不一样
现象:连续两次createOffer(),a=fingerprint:sha-256 ...后面的值不同。排查步骤:
- 检查是否调用了
pc.restartIce(),它会强制生成新证书; - 查看
DtlsTransport::GenerateDtlsCertificate()是否被多次调用; - 在
SdpMediaSection::AddFingerprint()断点,观察dtls_cert_是否为空。根本原因:createOffer()时如果dtls_cert_为空,会调用GenerateDtlsCertificate()生成新自签名证书。而GenerateDtlsCertificate()每次都会生成新密钥对。解决方案:在PeerConnection构造后,立即调用pc.getConfiguration()检查certificates字段,手动注入预生成证书,避免运行时生成。
5.3 问题3:offer里a=ice-ufrag和a=ice-pwd长度不对
现象:a=ice-ufrag:123(太短),或a=ice-pwd:abc...xyz(超长)。排查步骤:
- 查看
SdpIceCredentials::ToString()源码,确认ice_ufrag_长度是16字节,ice_pwd_是24字节; - 检查
IceTransportController::SetIceUfragPassword()是否被错误调用; - 在
PeerConnection构造函数里断点,看ice_ufrag_和ice_pwd_初始化值。根本原因:ice_ufrag_和ice_pwd_必须是随机字节,base64编码后ice_ufrag约22字符,ice_pwd约32字符。如果手动设置非随机值,会导致ICE协商失败。解决方案:永远不要手动设置ice_ufrag/ice_pwd,让WebRTC自动生成。如需固定值用于测试,用rtc::CreateRandomString(16)生成。
5.4 问题4:offer里m=video行的payload_type是127,但H.264应该是120
现象:a=rtpmap:127 H264/90000,而标准是120。排查步骤:
- 查看
MediaEngineInterface::GetSupportedCodecs()返回的VideoCodec列表; - 在
SdpMediaSection::AddCodec()断点,观察payload_type参数; - 检查
codec_preferences_是否包含其他H.264变体(如H264-SVC)。根本原因:payload_type由MediaEngine分配,范围1-127。如果codec_preferences_里有多个H.264,MediaEngine会为每个分配不同pt。127是最后一个可用pt,说明前面126个都被占用了。解决方案:精简codec_preferences_,只保留必需编解码器;或升级Chromium,新版MediaEngine优化了pt分配算法。
5.5 问题5:offer里没有a=rtcp-mux行,但文档说默认支持
现象:SDP里缺少a=rtcp-mux,导致RTCP走独立端口。排查步骤:
- 检查
RtpTransceiver::can_triple_mux()返回值; - 在
SdpMediaSection::AddRtcpMux()断点,看rtcp_mux_enabled_是否为true; - 查看
PeerConnection::Initialize()里是否调用SetRtcpMuxEnabled(true)。根本原因:rtcp_mux_enabled_默认是false,必须在PeerConnection构造时通过RTCConfiguration启用:{rtcpMuxPolicy: 'require'}。解决方案:创建RTCPeerConnection时,显式设置rtcpMuxPolicy: 'require'。
5.6 问题6:offer里a=group:BUNDLE的mid顺序和m=行不一致
现象:a=group:BUNDLE 1 0,但m=行顺序是m=audio(mid=0)、m=video(mid=1)。排查步骤:
- 在
SdpGenerator::AddBundleGroup()断点,查看mid_list内容; - 检查
RtpTransceiver::mid()返回值是否正确; - 看
SdpMediaSection::AddMediaSection()调用顺序。根本原因:AddBundleGroup()按mid字符串排序,而mid是字符串类型。如果mid是数字字符串"1"和"0",排序后是"0"、"1",但m=行按transceiver添加顺序排列。解决方案:确保mid是固定长度字符串,如"00"、"01",或用std::sort(mid_list.begin(), mid_list.end(), [](const std::string& a, const std::string& b) { return std::stoi(a) < std::stoi(b); })。
5.7 问题7:offer里a=fmtp参数缺失,如profile-level-id
现象:`a=fmtp:120 level-asymmetry-allowed=1;