C++ MSRP协议栈解析:从SIP信令到富媒体文件传输的完整实践
2026/9/23 12:02:49 网站建设 项目流程

简介:一份基于C/C++实现的SIP客户端MSRP协议扩展源码包,面向VoIP开发者及对SIP多媒体会话感兴趣的进阶学习者。MSRP用于在SIP会话中高效传递图片、文件及富文本消息,资源完整展示了协议报文的构造与解析、URI处理、TCP/TLS连接管理以及和SIP信令的交互流程。压缩包共46个文件,以C++头文件(.h)和实现文件(.cpp)为主,覆盖消息会话中继协议的核心模块,同时附带Makefile与Visual Studio工程文件,便于跨平台编译与二次开发。整个资源包仅56KB,代码精简,适合研读和移植。已有135人学习/下载,是理解SIP与MSRP集成机制的实用参考资料。源码目录结构清晰,包含会话控制、连接监听、报文解析、地址解析、状态处理等模块,并配套说明文档辅助理解协议背景。读者通过研读源码可快速掌握MSRP消息封装、连接建立和异常处理思路,为自研SIP客户端增加富媒体传输能力提供直接可参考的实现框架。

1. 从 SIP 信令到 MSRP 通道:这个源码包到底解决了什么问题

做 VoIP 客户端的人多半会遇到这么个坎:SIP 信令把通话建起来了,但你想在会话里传一张图片、一份 PDF,SIP 自己干不了。MSRP 就是为这个补位的——在 SIP 建立的会话框架里开一条 TCP 通道,专门跑富媒体内容。这个 MSRP.zip 里的源码包,是一套完整的 C++ MSRP 协议栈,从报文解析、分片重组到 TCP 连接管理都齐了。我拆包后看到 MsrpRoar.cpp、ByteWrangler.cxx、TcpListener.h 这些文件,第一反应是这不是玩具 demo,而是能直接嵌进 SIP 客户端的协议层。它适合手里已有 SIP 呼叫控制、想给客户端加文件传输或即时消息扩展的 C++ 开发;只想看协议流程的话,里面的 IncomingMessage 和 OutgoingMessage 也能当活教材。

2. 理解 MSRP 报文与事务:SEND、SETUP、TEARDOWN 是怎么串起来的

2.1 报文骨架:Request Line、Status Line 与 ByteRange 的对应关系

MSRP 的报文格式比 SIP 简单,但细节坑不少。一个典型的 SEND 请求帧长这样:

MSRP a1b2 SEND To-Path: msrp://alice@example.com:9000/abc;tcp From-Path: msrp://bob@example.com:9000/xyz;tcp Message-ID: msg-10001 Byte-Range: 1-1000/2048 Content-Type: image/jpeg <binary data...> -------a1b2$

注意首行第一个字段是固定字符串MSRP,第二个字段是事务 ID(也叫 message ID),第三个是方法名。SIP 里方法是 INVITE/BYE,MSRP 里是 SEND、SETUP、TEARDOWN、REPORT 四个。包里的 MsrpRequestLine.cpp 就是在做这个首行拆解:它会把MSRP、事务 ID、方法名分别填到结构体里。对应地,MsrpStatusLine.cpp 处理响应行,格式是MSRP 200 OK,状态码和原因短语分开解析。

这里有个容易混淆的点:Message-ID头部和事务 ID 是两个不同概念。事务 ID 是单帧级别的,一帧一变;Message-ID 是整个消息的标识,分片传输时所有分片共享同一个 Message-ID。ByteRange.cpp 只关心字节范围,它负责把1-1000/2048这类字符串拆成 start、end、total 三个整数。total 为 0 表示长度未知,这在某些实现里会出现,解析时必须容忍。

除了 SEND,SETUP 和 TEARDOWN 的请求行结构完全一样,只是方法名不同。SETUP 用于在 SIP 会话建立后协商 MSRP 传输参数,TEARDOWN 用于关连接。注意并不是所有实现都用 SETUP,很多是 SIP INVITE 里带 msrp URI,连接建立后直接 SEND。这个包保留了 SETUP 分支,说明作者是从完整协议演进的角度写的。

2.2 事务状态机:从 IncomingMessage 到 ReportSuccess 的完整路径

数据从网络到达后,不是直接变成 IncomingMessage。先经过 ByteWrangler.cxx 这个字节处理器的分帧。它的职责是把 TCP 流切成一个个完整的 MSRP 帧,切分依据是 Content-Length 和结尾的-------<事务ID>$标记。切好一帧,ByteWrangler 才把它交给 IncomingMessage 做头部解析和 body 装载。

IncomingMessage.h 定义了解析后的消息对象。它内部有头部表、body 缓冲、事务 ID 和方法名。收到一帧 SEND 后,代码大致走这样一个流程:

if (msg.isRequest() && msg.getMethod() == "SEND") { const ByteRange& range = msg.getByteRange(); if (range.total == 0 || range.end == range.total) { // 最后一帧或单帧消息,通知上层完整消息到达 session->onCompleteMessage(msg); } else { // 分片未结束,按 Message-ID 缓存 fragmentCache[msg.getMessageId()].append(msg.getBody(), range.start); } }

逻辑说明:先判断是不是请求、是不是 SEND;然后查 Byte-Range。如果 total 为 0,说明对端没有按分片发送,或这是单帧消息,直接走完整回调。如果 end 等于 total,说明这是最后一个分片。否则把 body 按 start 偏移塞进缓存,等待后续分片。参数说明:range.start是当前分片在完整消息中的起始字节,通常从 0 开始;range.end是结束字节;range.total是完整消息总字节数。缓存用 Message-ID 作 key,才能把同一消息的不同分片归到一起。

一个完整的成功事务是这样的:接收方收到 SEND,处理完 body,回一个MSRP 200 OK响应。如果发送方在报头里带了Success-Report: yes,那接收方还要额外发一个 REPORT 请求,里面带状态信息。这也是 ReportSuccess.cpp 和 ReportFailure.cpp 存在的意义——它们不是直接回 200,而是构造 REPORT 请求。区分这两个文件很关键:ReportFailure 是接收方告诉发送方“我没收到完整数据”,ReportSuccess 是“我成功组装了整条消息”。

发送方向的代码在 OutgoingMessage.cpp。它负责把业务层传下来的二进制数据切成分片,每片生成一个单独帧。切分大小由链路 MTU 或者应用层策略决定,常见默认是 2048 字节一帧。这个包没有把 2048 硬编码死,而是允许在构造 OutgoingMessage 时传入块大小,具体在后面的集成代码里会看到。

到这里,整个协议路径就串起来了:TcpConnection 收字节流 → ByteWrangler 分帧 → IncomingMessage 解析 → Session 分发 → 分片缓存/完整回调 → ReportSuccess/ReportFailure 回执。把这条链吃透,剩下的事情都只是往 Session 回调里填你自己的业务逻辑。

2.3 连接管理与 TLS:TcpListener 与 Address 的协作

TcpListener 的职责看着简单——bind、listen、accept——但 MSRP 场景下有特殊要求。标准 MSRP 允许一条 TCP 连接上复用一个会话里的多个消息,也允许收发分用两条连接。TcpListener 必须维护一个连接池,而不是 accept 一个就立刻 close。这个包里的 TcpListener.h 暴露了 bind、start、stop 和 onAccept 回调,回调里会创建 TcpConnection 对象并交给 TransportGroup 管理。

Address.cpp 解决的是 host 到 IP 的解析。它在 DnsResolver.cpp 之上封装了一层,支持 IPv4 和 IPv6。这里有个细节:MSRP URI 里的 host 可能是域名,也可能是 IP。如果对端在 NAT 后面,SDP 里给的 host 是内网地址,Address 解析失败时,应该把错误冒泡到 Session 层,而不是抛异常导致整个进程崩溃。我在做集成时,会专门写一个回调接收 DNS 失败事件,然后走 SIP 层的 NAT 穿透逻辑。

TLS 支持通常通过 TcpConnection 的加密开关打开。代码里会看到 SSL_CTX 相关字段,但具体证书文件路径不一定在构造函数里。你需要在创建 Listener 前设置证书上下文,否则 TLS 握手会失败。测试阶段可以用自签证书,但要注意对端会拒绝,除非它关闭了证书校验。这一点在对接真实设备时尤为重要,我在第五节避坑里没有展开,因为它是 TLS 特有的。

连接管理还有一个容易忽略的点:空闲连接的超时。MSRP 会话可能长时间没有消息,但 TCP 连接还在。这个包里如果没有主动心跳,建议在应用层定时发送一个小的 SEND 或者 REPORT 来探测对端存活,否则对端崩溃后你这边要等 TCP 超时才能发现。一般把超时定为 90 秒,比 SIP 的注册刷新略短。

为了方便对照,下面列出我在集成时最关注的几个文件:

文件职责集成时关注点
TcpListener.h监听端口、accept 连接bind 端口冲突处理
TcpConnection.h封装一条 TCP/TLS 连接粘包分帧入口
Address.h域名/IP 解析NAT 下地址选择
Session.h协议会话状态机业务回调接口
OutgoingMessage.cpp构造发送帧、分片chunk size 调整
IncomingMessage.h解析接收帧分片缓存

这张表不用背,在你改代码前扫一眼,能省不少定位时间。

3. 把源码包编译成可用库:Makefile 与 VC 工程的双路径

3.1 在 Linux 下用 Makefile.in 生成 Makefile

解压 MSRP.zip 后,第一眼看到的是大量 .h/.cpp 文件,外加一个 Makefile.in 和 msrp.vcproj。Makefile.in 是 autotools 的标准输入文件,它不是最终 Makefile,需要 configure 脚本才能生成。但这个包里没有 configure,也没有 configure.ac。我的处理习惯是装好 autoconf 后,在目录里补一个最基本的 configure.ac,然后执行:

# 解压源码包,注意用 unzip 而不是图形工具,避免文件名编码问题 unzip MSRP.zip -d msrp-src cd msrp-src # 生成 configure 脚本 autoreconf -i ./configure --prefix=/usr/local/msrp make

逻辑说明:autoreconf 会扫描 configure.ac 并生成 configure 和 Makefile.in 的处理链。这个包没带 configure.ac,所以严格来说直接用 autoreconf 会报错,需要自己补。我一般会手动建一个最小的 configure.ac,里面只声明 AC_INIT 和 AC_PROG_CXX。参数说明:--prefix 指定安装路径,如果只是临时测试可以留默认,但建议单独目录,方便后面卸载。

还有一种更省事的做法是跳过 autotools,直接手动编译,因为依赖并不多。核心依赖只有 C++ 标准库和 socket 库:

g++ -c ByteWrangler.cxx -o ByteWrangler.o g++ -c Connection.cpp -o Connection.o g++ -c OutgoingMessage.cpp -o OutgoingMessage.o g++ -c IncomingMessage.cpp -o IncomingMessage.o # 其余 .cpp 同理 g++ -o msrp_test MsrpRoar.cpp *.o -lpthread

逻辑说明:这里没有一步到位用通配符编译所有文件,是因为个别文件有 #ifdef 分支,比如在 Windows 下会包含 winsock2.h,Linux 下则是 sys/socket.h。分开编译能让你在第一个编译错误出现时立刻定位是哪个文件的问题。参数说明:-lpthread 必须有,Session 和 Listener 里用了线程;如果出现undefined reference to pthread_create,就是漏了它。

编译时常见的输出是警告,不是错误。MsrpRoar.cpp 里可能有未使用的变量,可以直接忽略。但如果看到 ByteRange 相关的重定义错误,多半是你之前装过另一个 MSRP 库,头文件冲突了,把 /usr/local/include 里旧的 msrp 头文件挪走即可。

3.2 在 Windows 下用 msrp.vcproj 构建

msrp.vcproj 是 Visual Studio 2008/2010 时代的工程文件。新版 VS(2015 以后)打开它会提示“安全警告”和一个版本升级对话框,选择“是”即可,VS 会自动转换成当前格式。但有一个项目配置必须手动改:字符集和预处理器。

打开工程属性,找到 C/C++ → 预处理器,确认定义了WIN32_WINSOCKAPI_。没有后者的话,winsock2.h 和 windows.h 的顺序问题会导致一堆重定义报错。链接器那里,在附加依赖项里加ws2_32.lib。这个包里的 Makefile.in 没有 Windows 分支,所以 vcproj 才是 Windows 构建的唯一入口。

如果你不用 VS 而是 CMake 用户,可以用 vcproj 生成一个静态库项目,也可以自己写一个 CMakeLists.txt 替代。我一般保留 vcproj,因为它自带的文件列表是最完整的——哪些源文件需要参与编译,哪些是辅助工具,看工程文件里的<File>节点就一目了然。手动重组 CMake 时,照着 vcproj 里的文件清单列就行,不要自己凭目录猜测。

构建完成后,输出通常是一个 .lib 或 .exe。MsrpRoar.cpp 这个文件是测试入口,它会创建 Listener、启动 TCP 服务,然后从命令行读取要发送的文件。Windows 下直接运行可能会闪退,因为控制台程序没有暂停,建议在 VS 里按 F5 跑,或者自己在 main 末尾加 getchar()。这不是 bug,是测试程序的控制台习惯问题。

3.3 集成到 SIP 客户端的挂载点:Session 与 Connection 的职责边界

当你把这个协议栈嵌进现有 SIP 客户端时,千万不要把 SIP 的 Dialog 和 MSRP 的 Session 混成一个类。SIP 的 INVITE Dialog 负责呼叫状态,MSRP Session 负责数据通道。两者通过会话 ID 或者 Call-ID 关联,但生命周期不同。包里的 Session.h 就是一个纯 MSRP 会话抽象,它不知道 SIP 的事。

我一般会做一个桥接类,把 SIP 栈的 onDialogEvent 转到 MSRP Session:

#include "Session.h" #include "OutgoingMessage.h" #include "TcpListener.h" class MsrpBridge : public SessionObserver { public: // 当 SIP INVITE/200 OK 协商出 MSRP URI 时调用 void startMsrpSession(const std::string& remoteUri) { MsrpUrl url(remoteUri); // 解析 msrp://host:port/gr;tcp TcpListener listener; listener.bind(0); // 0 = 端口由系统分配 mySession = new Session(listener, url.getHost(), url.getPort()); mySession->setObserver(this); mySession->connect(); } void sendFile(const char* data, size_t len) override { OutgoingMessage msg(data, len); msg.setChunkSize(2048); // 每帧最大字节数 msg.addToPath("msrp://relay.example.com:9000/abc;tcp"); msg.addFromPath("msrp://myhost.example.com:9000/xyz;tcp"); mySession->sendMessage(msg); } private: Session* mySession; };

逻辑说明:这个桥接类把 SIP 协商出的远端 MSRP URI 解析成 host/port,然后用 Listener 建立 TCP 连接。sendFile 里构造 OutgoingMessage 并设置分片大小。参数说明:setChunkSize(2048)不是越大越好,太大的分片在弱网下重传代价高;太小则头部开销占比大。局域网测试 2048 够用,公网建议 1024。addToPath 的地址要和 SIP INVITE 协商结果一致,不能随便填,否则对端会拒绝。

连接建立后,对端发来的数据通过 SessionObserver 回调返回。你只需要在 onMessage 里处理完整消息,不需要关心这是第几个分片。这就是把协议栈做成库的好处。如果你希望发送方收到接收方的确认,需要在 OutgoingMessage 里加Success-Report: yes头,这个包的原生接口可能不直接暴露,需要改 OutgoingMessage.cpp 加一个成员变量。改动很小,但能让你观察到完整的事务闭环。

4. 实战避坑:TCP 粘包、分片重组与 URI 参数解析的五个坑

这几个坑看着像玄学,实际都能用协议细节解释。我按“现象 → 原因 → 解决”写,方便你直接对号入座。

4.1 坑一:多个 MSRP 帧粘在同一个 TCP 包里,消息互相吞

现象:发送方连续发两帧 SEND,接收方只处理完第一帧,第二帧的 body 就丢了,或者报解析异常。

原因:TCP 是流协议,没有消息边界。两个 MSRP 帧可能合并成一个 segment 到达,如果解析器只按 Content-Length 读取一次,剩下的字节就被遗留在 socket 缓冲区,下次 read 时作为新帧的开头,但头部已经不完整。

解决:在 ByteWrangler.cxx 的缓冲逻辑里,每次 read 后先尝试解析完整头部,用 Content-Length 判断当前缓冲是否包含完整帧。如果不够,把数据留在 buffer 里继续读;如果够了,取出一帧并把剩余字节前移。注意帧结束标记-------<事务ID>$也必须校验,因为某些实现不严格按 Content-Length 切分,依赖结束标记。我建议两个条件都满足才算一帧结束。

4.2 坑二:分片重组时按 append 拼接,结果乱码或数据错位

现象:大文件传输时,收到的内容出现重复段或缺失段,用十六进制看发现 offset 错位。

原因:Byte-Range 的 start 不一定按顺序到达。对端可能先发 1001-2000,再发 1-1000。如果你的重组逻辑只是buffer.append(body),顺序就乱了。还有一种情况是前一片没到,后一片直接触发 completeMessage,导致上层拿到半截数据。

解决:用 Message-ID 做 key,ByteRange.start 做插入位置。安全做法是用 std::map 缓存分片,等 end == total 时再按 start 排序拼接。不要假设 end == total 就可以直接 append,必须先检查 start 是否等于当前缓存长度。我会在代码里加一个断言:assert(range.start == merged.size()),一旦不满足,立刻打日志而不是默默拼接。

4.3 坑三:To-Path/From-Path 里出现 sip: scheme,解析器直接抛异常

现象:收到对端发来的 MSRP 消息,首行和头部都正确,但 MsrpUrl 构造时报unsupported scheme

原因:规范允许在 MSRP URI 的 path 里用其他 scheme 做回退,但绝大多数实现只认 msrp。有些 SIP 客户端在 SDP 协商时把 contact 直接抄进 To-Path,导致出现sip:user@host;transport=tcp。这个包里的 MsrpUrl 为了标准做了严格校验,遇到非 msrp scheme 就失败。

解决:在 MsrpUrl.cpp 里放宽 scheme 判断,允许 sip 和 sips,但需要特殊处理 host 和 port 的提取方式。更稳妥的是在调用 Session::connect 前,把从 SDP 拿到的 URI 统一转换成 msrp 格式,转换时保留原 host、port 和 transport 参数。注意;tcp是必须的,它是告诉对端该用 TCP 还是 TLS 的关键参数。

4.4 坑四:Windows 下编译报一堆重定义错误,和 winsock 版本有关

现象:VS 打开 vcproj 后按 F7,报几十个redefinitionmacro redefinition,集中在 windows.h 和 winsock2.h。

原因:部分源文件在包含 windows.h 之前没有定义WIN32_LEAN_AND_MEAN,或者没定义_WINSOCKAPI_。MSRP 依赖 socket 和 select,必须用 winsock2.h,它和 windows.h 里的旧版 winsock.h 冲突。

解决:在工程预处理器定义里加上WIN32_LEAN_AND_MEAN;_WINSOCKAPI_;NOMINMAX,并在所有源文件顶部增加#include <winsock2.h>放在第一个。如果你改源码,注意不要重复 include。还有一个容易忽略的地方:链接器增加ws2_32.lib,否则 LINK 阶段报unresolved external symbol __imp_WSAStartup

4.5 坑五:SIP BYE 之后 MSRP 连接没释放,端口被占

现象:呼叫结束,应用退出后再次启动,bind 报Address already in use

原因:SIP 会话有独立的 BYE/CANCEL 终结流程,但 MSRP 连接是另一个生命周期。如果你只在 SIP Dialog 层处理释放,没有通知 MSRP Session 关连接,TCP 端口就会一直 TIME_WAIT。

解决:在 SIP 栈收到 BYE 或 200 OK(BYE) 的回调里,显式调用 Session::disconnect,并发送 TEARDOWN 请求(如果对端支持)。如果对端不支持,至少把 TCP 连接 close。TIME_WAIT 本身不是问题,但主动 close 可以让端口在 2MSL 后释放。如果测试环境等不了,可以在 socket 上设置 SO_REUSEADDR,这在 TcpListener.cpp 的 bind 逻辑里加一行 setsockopt 即可。生产环境不建议依赖它,这只适合频繁重启的调试阶段。

这五个坑覆盖了从传输到协议解析再到与 SIP 层联动的主要问题。剩下的边角问题,比如 chunk size 设成 0、Report 超时无响应,都是围绕这几条的变种。先把这五条在测试环境复现一遍,再去对接真实设备,会顺畅很多。

5. 验证与进阶:搭一个最小对端把 MSRP 跑起来

前面几章讲的是静态代码怎么理解、怎么编译、怎么集成,这一章给一个实际验证手段。我习惯在引入新协议栈时,先用脚本模拟对端,把整个交互跑通,再开始改业务代码。MSRP 既有二进制又有文本头,非常适合用 Python 脚本做对端。

下面是一个最小的接收端脚本,监听 TCP 9000,收一个 SEND 帧,打印内容并回 200 OK:

import socket srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(('0.0.0.0', 9000)) srv.listen(1) conn, _ = srv.accept() data = b'' while True: chunk = conn.recv(4096) if not chunk: break data += chunk if b'-------a1b2$' in data: # 帧尾标记 break # 提取 body 区域,简单打印 body = data.split(b'\r\n\r\n', 1)[1].rsplit(b'-------', 1)[0] print(body.decode('utf-8', errors='replace')) # 回一个 200 OK resp = ( "MSRP a1b2 200 OK\r\n" "To-Path: msrp://127.0.0.1:9000/abc;tcp\r\n" "From-Path: msrp://127.0.0.1:9000/xyz;tcp\r\n" "Message-ID: msg-10001\r\n" "Byte-Range: 1-500/500\r\n" "-------a1b2$\r\n" ).encode() conn.sendall(resp) conn.close()

逻辑说明:这个脚本只做一件事——收齐以-------a1b2$结尾的一帧,拿到 body,按文本打印,然后回一个 200 OK。参数说明:帧尾标记里的a1b2必须和请求首行的事务 ID 一致,否则解析端不认为这是结束。

跑完这个脚本,就可以用这个源码包里的 MsrpRoar 示例程序,指定发一个文件到这个 9000 端口,观察脚本是否打印出内容,以及你的 OutgoingMessage 是否正确收到了 200。如果 200 收不到,抓包看响应行和请求行的事务 ID 是否一致。这一步能验证你改过的分片逻辑和 Report 逻辑是否正常。

再进阶一点,可以改脚本模拟分片接收:先收Byte-Range: 1-500/1000的帧,等第二帧501-1000/1000。收完后,检查你拼接出的完整内容是否和原文件一致。我就是用这种方式,在一天内把分片重组的乱序 bug 复现出来的。

从那以后,我每次改动 ByteWrangler 或 OutgoingMessage 的分片策略,都强制走一遍这个脚本对端,再跑一次完整文件传输,确认内容用 md5sum 对得上。这个习惯帮我挡掉了很多只在边界条件下出现的怪问题。希望帮到你。

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

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

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

立即咨询