ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ MSRP协议栈解析:从SIP信令到富媒体文件传输的完整实践

C++ MSRP协议栈解析:从SIP信令到富媒体文件传输的完整实践 简介一份基于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 信令把通话建起来了但你想在会话里传一张图片、一份 PDFSIP 自己干不了。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://aliceexample.com:9000/abc;tcp From-Path: msrp://bobexample.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/BYEMSRP 里是 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 表示长度未知这在某些实现里会出现解析时必须容忍。除了 SENDSETUP 和 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 连接管理与 TLSTcpListener 与 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.hLinux 下则是 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 时代的工程文件。新版 VS2015 以后打开它会提示“安全警告”和一个版本升级对话框选择“是”即可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 做 keyByteRange.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:userhost;transporttcp。这个包里的 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报几十个redefinition或macro 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 OKimport 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, errorsreplace)) # 回一个 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 对得上。这个习惯帮我挡掉了很多只在边界条件下出现的怪问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表