☰
SIP客户端集成MSRP:从SDP协商到文件分片传输的完整指南
2026/10/1 15:20:48 网站建设 项目流程

简介:一个包含SIP客户端中MSRP协议C/C++实现源码与工程文件的资源包,面向网络语音、即时通信及多媒体会话开发人员,用于解决在SIP会话中传输图片、文件、富文本等长消息的问题。包内共四十六个文件,其中包含二十三个头文件、十六个源文件,另附构建脚本、工程配置及辅助文件等,压缩包整体约56KB,可支撑不同环境下编译与阅读。已有一百三十五人浏览学习。通过研读源码可掌握MSRP的地址参数、报文头部与事务标识设计,了解SIP邀请消息中携带MSRP能力协商、建立传输通道、发送消息以及结束清理会话等核心流程;同时源码按模块划分了连接管理、监听器、请求响应、路径与地址解析等组件,便于理解SIP控制面与MSRP传输面的协作关系。对研究即时消息传输、文件发送或网络语音富媒体扩展的开发者,是一份具备较高参考价值的协议实现样本。

1. 拿到 MSRP.zip 之后要解决的真问题:SIP 谈条件,MSRP 传内容

第一次拿到 MSRP.zip 的时候,我以为是某个 SIP 软电话的安装包,解压完才意识到,名字里的 msrp 是一个和 sip 协议绑得很紧的独立协议。做 SIP Client 的人迟早会遇到这一类包:sip 协议栈负责注册、呼叫、媒体协商,而真正要传的聊天消息、文件碎片,走的是 MSRP(Message Session Relay Protocol)。它解决的问题非常具体——SIP MESSAGE 发大消息会被 413 或 488 弹回来,UDP 传输也扛不住几百 KB 的负载;MSRP 则在 TCP 上建立一条独立的“消息会话”,把大消息和文件切成块发过去再组装。这个方向适合正在做或打算做 SIP 软电话(包括安卓版消息功能)、需要文件传输和大消息能力的人,也适合所有被 SIP MESSAGE 的字节上限卡过的开发者。

2. SIP 协议只负责牵线,MSRP 才负责传话:SDP 里怎么描述一路消息会话

2.1 为什么大消息要绕过 SIP MESSAGE:三个硬边界

SIP 消息里其实已经有一个发文本的方式,就是 SIP MESSAGE,大多数第一次做聊天功能的开发者会先用它。但它在真实项目里撑不住大消息,这不是某个库的缺陷,是协议设计决定的。第一个边界是传输层:SIP 默认跑在 UDP 上,单个报文一旦超过底层 MTU 就要分片,中间代理和 NAT 设备对分片报文的处理态度完全不同,很多网络环境下超过十几 KB 的 SIP MESSAGE 就直接沉默了。第二个边界是语义:SIP MESSAGE 是一个独立事务,没有会话概念,没有流控状态,也没有断点续传。你发一个 500 KB 的文件,如果中途断了,整个事务重来,对端还分不清这是新消息还是重传。第三个边界是业务确认:SIP 的 200 OK 只表示事务被信令层接受了,不表示业务内容送达,更不能表达“已读”和“第几段没收到”这种状态。

MSRP 的做法是把“传内容”这件事从信令里拆出来,单独成为一条 TCP 会话。SIP 仍然负责会话生命周期,也就是 INVITE、200 OK、ACK、BYE 这一套;MSRP 负责在这条会话里传字节。所以这个 MSRP.zip 的包名里“SIP Client MSRP”不是并列的两样东西,而是 SIP Client 领域里一个不可拆分的组合。你在项目里做选型的时候可以这样判断:消息体基本在几百字节以内,数量不大,SIP MESSAGE 够用;一旦要发文件、要开聊天室、要支持大消息或需要明确到“第几段已收到”的进度,就应走 MSRP。这个判断标准我在多个项目里验证过,比看功能列表靠谱。

2.2 SDP 协商:m=message 之后的六个关键参数

MSRP 会话是像媒体流一样,通过 SIP 的 SDP offer/answer 协商出来的。也就是说,SIP INVITE 里带着一段特殊的 SDP,描述“我这里有一条 MSRP 消息通道,你接不接”。下面是一段最小可用的 SDP 描述,风格取自 RFC 4975,也是大多数 SIP Client 实现里能直接照抄的形状:

v=0 o=alice 2890844526 2890844526 IN IP4 192.0.2.1 s=MSRP Session c=IN IP4 192.0.2.1 t=0 0 m=message 2855 TCP/MSRP * a=accept-types:text/plain message/cpim a=path:msrp://192.0.2.1:2855/798a;tcp a=setup:active

这段 SDP 的读法是:c=行给出本端可达的 IP,m=行声明这是一个 message 类型媒体流,走 TCP,端口 2855,格式不限;a=accept-types 声明接收方愿意处理的内容类型;a=path 给出本端 MSRP 的完整 URI,这个 URI 会在后面的 MSRP 报文中作为 From-Path 或 To-Path 出现;a=setup:active 表示本端会主动发起 TCP 连接。对端回复 200 OK 时,会带上自己的 SDP,通常 a=setup 是 passive,两端各写各的地址和端口,然后由 active 方主动去连 passive 方。

这里有一个关键点:a=path 里的地址和 c= 行里地址必须一致可达,否则协商出来一个对端永远连不上的 URI。MSRP 默认 TCP 端口是 2855,端口冲突时可以在 m=行里改,但改了之后 a=path 里的 URI 端口也要跟着改。下面这个表是每次对接我都会逐一核对的参数,建议直接复制到你的检查清单里:

SDP 参数作用典型值容易踩的坑
m=message声明 MSRP 媒体流m=message 2855 TCP/MSRP *写成TCP/RTP/AVP或漏掉端口
a=path本端 MSRP URI,报文的 From/To-Path 来源msrp://host:port/会话id;tcp端口与 m=行不一致
a=accept-types接收的内容类型text/plain message/cpim写死 text/plain 后对端发文件直接失败
a=setupTCP 连接角色active / passive / actpass两端都 active 会双方向同时连接,造成会话分裂
c=行本端 IP 地址c=IN IP4 1.2.3.4填入内网地址后公网对端连不上
msrp vs msrps是否走 TLSmsrp://或msrps://TLS 环境忘记改 scheme,连接被拒

参数里需要多说一句的是 accept-types。如果对端发来的是一个 message/cpim 包装的消息,而你的 a=accept-types 里没有这一项,对端会终止 SEND 并返回错误。很多互操作问题的根源不在解析,而在这一行没写全。交互上还有一个容易被忽略的点:SIP 流程是先 INVITE,200 OK,ACK,然后才是 TCP 建链和 SEND。不要在收到 100 Trying 就去建 MSRP 连接,那是白费劲。用 SIP 软电话测试的时候,先看 SDP 里有没有 m=message,再决定后面调什么,这是最快的定位方法。

3. 把 MSRP.zip 里的 SIP Client 跑起来:解压、编译与最小配置

3.1 解压前先看产物类型:源码包还是预编译包

拿到一个以 zip 分发的 SIP Client 相关代码包,第一件事不是急着解压,而是确认它到底是什么形态。常见做法是,先用 unzip -l 看一眼清单,再决定走哪条落地路径。很多人在这一步翻车:看到一个 CMakeLists.txt 就当源码包编译,结果缺依赖编译了半小时;看到一个 bin 目录就当成品直接用,结果运行环境缺动态库,一启动就报错。

# 1. 先列清单,不要直接解压 unzip -l MSRP.zip | head -30 # 2. 如果看到 configure / CMakeLists.txt,走源码编译 # 有 configure 就 ./configure && make -j$(nproc) # 3. 如果看到 bin/ 或 *.so / *.dll,走预编译,把可执行文件路径加进 PATH file bin/* 2>/dev/null | head

这段命令的逻辑是:unzip -l 只读中央目录,不做解压,能快速判断包内是源码结构还是二进制产物,同时验证这个 zip 文件本身是否完整。第二步里,configure 存在说明包遵守 autotools 习惯,也可以先跑./configure --help看有哪些开关。第三步的 file 命令用来确认二进制架构,x86_64 的程序在 ARM 机器上跑不起来,这是预编译包最常见的坑。

这一阶段还有两个和 zip 本身有关的坑要提前排掉。一个是解压时提示输入密码,而发布页没有提供任何密码,这多半是 zip 伪加密,也就是本地文件头里的加密标志被强行置位,数据本身并没有加密,用 7-Zip 解压通常能直接通过。另一个是解压后文件数比 unzip -l 列出的少,说明包被截断或二次打包过,别浪费时间修复,回到发布源重新取包。源码包落地时还要注意路径不要有中文,MSRP 客户端如果带资源文件路径解析逻辑,出中文路径问题很难查,属于玄学里的稳定复现项。

3.2 配置 SIP 账号与 MSRP 边界:七个必填参数缺一不可

编译通过只是第一步,能不能跑通取决于配置。我见过太多人把 MSRP 客户端当成普通 IM 聊天软件,只填一个服务器地址和用户名就去点登录,然后发现消息根本发不出去。不管这个 zip 包里的配置文件叫什么名字,落到协议层,这七个参数是必填的,缺一个都会在特定场景下暴露问题:

[sip] server=192.0.2.10 ; SIP 注册服务器 port=5060 user=1001 ; 账号 password=secret ; 认证密码 local_ip=192.0.2.1 ; 本端 IP,要和你 SDP 里 a=path 使用的一致 local_port=5060 [msrp] local_uri=msrp://192.0.2.1:2855/abc123;tcp ; 本端 MSRP URI transport=tcp ; tcp 或 tls,tls 时 scheme 要改成 msrps setup=active ; active/passive/actpass,一般主叫 active accept_types=text/plain,message/cpim chunk_size=4096 ; 单条 SEND 的消息体字节上限

这里的逻辑是:SIP 段负责让本端能注册上服务器、能被对端呼叫;MSRP 段负责让会话协商完成后,双方知道往哪个 TCP 端口连、以什么角色建链、能收什么类型的内容。local_uri 是最容易被忽略的,它同时出现在 SDP 的 a=path 和对端收到的 From-Path 里,如果你填的 host 在别的网络不可达,协商成功但连接永远建不起来。

setup 角色建议先固定成主叫 active、被叫 passive,这是最简单的组合。actpass 虽然灵活,但需要双方按 RFC 4145 的角色判定逻辑决策,实作偏差大,第一轮联调不要用它。accept_types 里带上 message/cpim 是为了兼容对端用 CPIM 包装的消息,很多开源软电话默认会包一层,你不声明就收不到。如果你做的是安卓版 SIP 软电话的 MSRP 消息功能,还要额外注意 Android 后台对 TCP 长连接的限制,MSRP 连接被系统回收后要能检测到并重建,这个逻辑必须在客户端实现,配置里没有开关能解决。

4. 从 INVITE 到 MSRP SEND:完整会话时序与分片上报逻辑

4.1 用 Python 模拟一次 MSRP SEND:请求行、To-Path 与 200 OK 的对应

跑通 SIP 协商之后,MSRP 层的行为可以单独验证。完整时序是这样的:主叫发 INVITE,对端回 200 OK 带自己的 SDP,主叫 ACK,随后主叫作为 active 方发起 TCP 连接到对端 SDP 里给出的地址和端口,连接建立后发送 MSRP SEND。这里我先给一个绕过 SIP 直接测试 MSRP 报文的 Python 脚本,用来验证你对端透传逻辑是否正确。这是在联调时最常用的手段,比猜对端有没有收到消息快得多。

import socket def send_msrp(peer_host, peer_port, from_path, to_path, message_id, body, finished=True): # peer_host/peer_port 从对端 SDP 的 c= 行和 m= 行解析 sock = socket.create_connection((peer_host, peer_port), timeout=5) headers = [ f"MSRP {message_id} SEND", f"To-Path: {to_path}", f"From-Path: {from_path}", f"Message-ID: {message_id}", f"Byte-Range: 1-{len(body)}/{len(body)}", "Content-Type: text/plain", "" ] req = "\r\n".join(headers) + "\r\n" + body # 每片事务都有自己的定界符,以 $ 结尾表示整条消息结束 term = "-------" + message_id + ("$" if finished else "+") sock.sendall(req.encode("utf-8") + b"\r\n" + term.encode("utf-8") + b"\r\n") resp = sock.recv(4096).decode("utf-8") sock.close() if "200 OK" in resp: print("MSRP 200 OK:", resp.splitlines()[0]) else: print("响应异常:", resp.splitlines()[0]) if __name__ == "__main__": # 这里的地址是文档示例,实际以对端 SDP 为准 send_msrp( peer_host="192.0.2.2", peer_port=2855, from_path="msrp://192.0.2.1:2855/abc123;tcp", to_path="msrp://192.0.2.2:2855/9di4ea;tcp", message_id="d93kswow", body="Hello MSRP", )

这段脚本里最关键的两行是 To-Path 和 From-Path。To-Path 必须是对端 SDP 里 a=path 给出的完整 URI,From-Path 必须是本端 SDP 里 a=path 的完整 URI,两个 URI 里的 host、port、会话 id、分号后的 tcp 都不能丢。丢了会话 id,对端可能把消息关联到错误的会话;丢了 ;tcp,对端解析 URI scheme 时可能走默认端口。Message-ID 同时用作报文标识和事务标识,但它不等于 SDP 里的会话 id,两套 id 各管各的。脚本里最后的定界符必须以-------开头,和请求行里的 transaction id 相同,否则对端会一直等后续分片直到超时。

收到的 200 OK 里,请求行是MSRP d93kswow 200 OK,它关联的是事务 id 而不是 Message-ID。所以客户端在同一连接上并发发多条消息时,必须按事务 id 匹配响应,不能按消息序匹配。我在实际对接中用这个脚本验证过三个不同厂家的 MSRP 网关,全部通过,说明报文格式只要按 RFC 4975 写,互操作没有想象中那么难。

4.2 大消息分片与断点续传:Range 语义怎么对上

单条 SEND 能承载的字节数没有硬性限制,但工程上会把消息体控制在 4 KB 左右。过大的 SEND 会让接收缓冲和解析逻辑变复杂,一个包卡住整条 TCP 连接;过小则会产生大量重复的 To-Path/From-Path 头,带宽浪费严重。发送端把文件拆成多个 SEND,每一片是一个独立事务,但 Message-ID 必须相同,接收端按照 Byte-Range 来对齐顺序。下面是一个分片发送的骨架:

def send_chunks(sock, from_path, to_path, msg_id, data, chunk_size=4096): total = len(data) pos = 0 index = 1 while pos < total: chunk = data[pos:pos + chunk_size] start = pos + 1 pos += len(chunk) tid = f"{msg_id}-{index}" # 每片的 transaction id 不同 headers = [ f"MSRP {tid} SEND", f"To-Path: {to_path}", f"From-Path: {from_path}", f"Message-ID: {msg_id}", # Message-ID 全片相同 f"Byte-Range: {start}-{pos}/{total}", "Content-Type: application/octet-stream", "" ] last = pos >= total term = "-------" + tid + ("$" if last else "+") sock.sendall( ("\r\n".join(headers) + "\r\n").encode("utf-8") + chunk + b"\r\n" + term.encode("utf-8") + b"\r\n" ) index += 1

这段代码的逻辑要点在 Byte-Range 的格式:它是“起始字节-结束字节/总字节数”,起始从 1 开始,不是从 0 开始,也不是从当前片的序号开始。比如总长 10000 的文件,第一片是1-4096/10000,第二片是4097-8192/10000,最后一片是8193-10000/10000。接收方就是靠这三个数字判断是否连续、是否有缺片。定界符尾部$表示这是最后一片,+表示后面还有,接收方靠这个标记判断整条消息何时组装完成,而不是靠收到 200 OK 的数量。

断点续传的机制也落在 Range 语义上。发送方如果收到对端返回的 200 OK,里面带有Range: 1-4096/10000,意思是这一片已收;发送方下一片从 4097 开始发。但要注意,这不是文件级确认,是报文级确认。如果应用层要求“文件 100% 已到达”才能算完成,光靠 MSRP 的 200 OK 是不够的,因为整个文件拆成 10 片,每一片都会收到 200 OK,但没有任何一个报文告诉你“10 片已经拼完整”。我一般会在文件传输业务里加一层应用确认:最后一个 SEND 发完后,接收方主动回一条 application/json 或 message/cpim 包装的业务消息,带上文件哈希,发送方收到后才把状态置为完成。这个设计能绕开 REPORT 机制的各种兼容性问题,后面避坑章节会展开说。

5. MSRP 会话落地避坑:5 个让互操作翻车的真实问题

5.1 NAT 环境里 a=path 给的是内网地址,对端连不过来

现象:SIP 注册和呼叫都正常,INVITE、200 OK、ACK 全部走通,但 TCP 连接就是建不起来,对端日志显示 Connection refused。抓包看,SIP 层报文到了,MSRP 的 SYN 没有响应。

原因:SDP 和 a=path 里填的是 192.168.x.x 内网地址,对端在公网上回连这个地址,直接被路由丢弃或拒绝。SIP 信令能通是因为注册服务器做了中转,MSRP 是端到端 TCP 直连,没有中转就暴露了私网地址。

解决:最常见的做法是把 MSRP 连接交给网关或 B2BUA 做 relay,也就是让服务器介入消息会话,两端的 MSRP 连接都只连到服务器,不直接互连。另一个做法是给 MSRP 媒体流配合 STUN 探测公网映射地址,把探测到的地址写入 a=path。实作时至少要做到:发送 SDP 之前判断本端 IP 是否私有,如果是私网地址且没有 relay 能力,直接拒绝发起 MSRP 会话,而不是等对端超时。这个问题在所有 NAT 场景下都会发生,属于必现问题,不是概率问题。

5.2 SIP 200 OK 和 MSRP 200 OK 混为一谈

现象:客户端把消息放进缓冲后,SIP 层收到 200 OK,UI 立刻显示已送达,但接收方实际一个字节都没收到。重试机制不生效,发送缓冲被清空,消息丢失。

原因:两个协议都有 200 OK,语义完全不同。SIP 的 200 OK 只表示 INVITE 协商成功、会话建立;MSRP 的 200 OK 才表示某个 SEND 事务被对端接收。开发者往往只接了 SIP 事务回调,就以为 MSRP 报文也被确认了。

解决:发送链路上把两个回调分开。MSRP SEND 的确认必须在 TCP 流里收到事务 id 匹配的 MSRP 200 OK,才能把这条报文标记为已发送;在这之前,任何 SIP 层事件都不能触发 UI 的送达状态变更。排查这类问题时不要盯着业务日志,直接 tcpdump 看 2855 端口上有没有 MSRP 200 OK 回包,这一眼就能定位。

5.3 对端不回 REPORT,文件传输进度卡在 99%

现象:文件分片全部发完,所有 SEND 都收到 200 OK,接收方文件也完整落盘了,但发送方进度条停在那里不动,业务状态永远不是“完成”。

原因:REPORT 是 MSRP 里接收方向发送方回报状态的机制,但默认情况下接收方没有义务回 REPORT,除非发送方在 SEND 头里显式要求。很多实现根本不实现 REPORT,或者只在出错时回。发送方如果把它当作必达的最终确认,就会永远等下去。

解决:第一,检查发送方的 SEND 报文里是否带了 Report-Success 或 Report-Failure 头,如果对方的协议栈不支持 REPORT,这些头也是白搭;第二,不要依赖 REPORT 做文件级完成判定,改用应用层确认。文件发完后接收方返一个业务消息,里面带文件哈希或行数,发送方收到这个才算完成。我的经验是,和第三方网关对接时,默认对方不支持 REPORT,能省掉一半互操作问题。

5.4 MSRP.zip 解压提示要密码,输入什么都是错的

现象:解压时 unzip 提示输入密码,输入发布文档里的密码也报错,甚至按回车直接报错。

原因:zip 伪加密,也就是 zip 文件头里的加密标志位被置位,但数据区实际上没有加密或加密方式不标准。这类文件在论坛和网盘传播的包里很常见,可能是打包工具残留,也可能是二次打包的人故意改的标志位。中央目录和本地文件头的标志位不一致,导致不同解压工具表现完全不同。

解决:先用zipinfo -v MSRP.zip | head -30看 General Purpose Flag 的 bit 0,再用 7-Zip 直接解压,7-Zip 对伪加密的容错比 unzip 好。如果解压出来的文件数量或大小和发布页描述不一致,直接放弃这个包,回到原始源重新取。这里提醒一句:只靠 zip 包判断不出来,一定要拿 unzip -l 的输出和发布页的文件清单核对。

5.5 TCP 粘包和分片定界符把报文边界搞丢

现象:同一 TCP 连接上连发多个 SEND,接收方解析时看到两条请求连在一起,To-Path 和上一片的定界符混在同一行;二进制文件内容里恰好出现以-------开头的字节序列,导致解析器提前截断。

原因:MSRP 是文本协议但没有固定长度,报文边界靠事务定界符决定,而 TCP 是流协议,没有报文边界。很多实现按行读,遇到消息体是二进制时逐行拆分必然出错。

解决:接收循环不能按行读,要用固定缓冲拼接,每次以\r\n-------<事务id>为边界扫描整条报文,匹配到$或+标记后再交给上层解析。事务 id 要在会话建立时加上本端随机前缀,确保同一连接上两端生成的事务 id 不会撞车。定位这类问题,用抓包工具在 2855 端口上看 ASCII 输出最直观:

tcpdump -A -i any -s 0 'tcp port 2855'

用 -A 参数把包内容以文本形式打印,重点检查每个 SEND 的收尾标记是$还是+,以及下一条 SEND 的请求行是不是从新的一行开始。多数粘包问题在抓包里一眼就能看出来。

6. 把 MSRP 会话验证做扎实:抓包判据与回归脚本

6.1 三条抓包判据,先于业务逻辑验证

MSRP 联调最容易出现的状态是“双方都认为自己在正常工作”,为了不被假象带偏,我现在接任何一个 MSRP 相关项目,先跑一组不依赖业务的验证。判据只有三条:INVITE 的 SDP 里必须出现m=message,并且有对应 a=path;200 OK 返回的 SDP 里,a=path 的地址能从本端 TCP 直连;第一笔 MSRP SEND 的 From-Path 和本端 SDP 的 a=path 完全一致。三条全过,再开始写业务逻辑;任何一条不过,后面的问题都不需要报给我。

def test_msrp_sdp(invite_sdp: str) -> None: lines = invite_sdp.strip().splitlines() assert any(l.startswith("m=message ") for l in lines), "缺少 m=message 媒体行" assert any(l.startswith("a=path:msrp://") for l in lines), "缺少 a=path" assert any(l.startswith("a=setup:") for l in lines), "缺少 a=setup 角色声明"

这个脚本可以做成 CI 里的固定用例,也可以放进本地回归。它验证的是“协商出的内容确实是 MSRP 会话,而不是退回了 SIP MESSAGE”。在我对接过的项目里,有三次所谓“MSRP 传大文件失败”的问题,最终根因都是 SDP 里根本没有协商出 m=message,SIP 层直接回了 488 或静默丢弃。回归脚本挡住的正是这一类问题。

MSRP 这个方向值不值得做,我的回答取决于你的具体诉求:只发短文本,SIP MESSAGE 更省事;要传真实文件、要做聊天室、要端到端大消息,MSRP 是当前标准化的唯一可靠路径。我现在的习惯是,任何新对接方先用上面三条判据跑通,再谈功能,这帮我挡掉了大量后续互操作翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询