简介:这是面向 SIP 客户端开发者的 MSRP 协议参考实现源码包,适合需要学习 VoIP 富媒体消息传输、或在自有 SIP 栈中接入 MSRP 能力的工程师。包内完整覆盖 MSRP 会话建立、TCP/TLS 连接管理、SEND/TEARDOWN 报文处理等核心逻辑,并包含 MsrpUrl、ByteRange、StatusLine、RequestLine、IncomingMessage 等关键模块实现,还涉及地址解析、路径管理、监听器与传输连接组件,能够清晰展示 SIP 与 MSRP 在会话协商、通道建立、消息收发和会话终止各阶段的配合方式。资源共 46 个文件,以 23 个 h 头文件和 16 个 cpp 源文件为主体,头文件负责协议字段与类接口声明,源文件实现状态机和收发逻辑,另有 Makefile、vcproj 等工程与构建配置,整体仅 56KB,适合直接阅读、断点调试或移植复用。已有 135 人学习浏览。借助这份精简源码,读者可以快速理解 MSRP 报文结构、URI 参数和连接生命周期,并以此为骨架开展二次开发或协议排错。
1. MSRP 客户端协议栈:SIP 只负责开会,文件传输得靠 MSRP
MSRP(Message Session Relay Protocol)在 SIP 生态里是典型的“会干活但不太抢眼”的角色:SIP 只负责把会话建起来、把邀请发出去,真正传文件、图片、大文本的是 MSRP。这个 MSRP.zip 是一套 C++ 写的 MSRP 客户端协议栈,从 TcpListener、TcpConnection 到 ByteWrangler、OutgoingMessage 都齐了,基本覆盖连接管理、报文编解码、消息收发、成功/失败报告这条完整链路。适合两类人:一是正在做 SIP 客户端、需要补一个文件传输模块的开发者;二是想在嵌入式或桌面端移植 MSRP 栈、先看看别人怎么组织连接与解析的工程师。它不能直接当现成 SDK 用,但能把 MSRP 的报文格式、字节边界、连接生命周期这些最烦人的问题暴露得很彻底。
2. 拆包后的模块地图:ByteWrangler、TcpConnection 与 Message 的职责边界
拿到 ZIP 第一件事别急着编译,先把文件清单“过筛子”。这个包里的文件命名很杂,MsrpRoar.cpp、ByteWrangler.cxx、Path.cpp、RelayRecord.h混在一起,初看像随手丢的工位。真正动手前,我习惯按“构建、连接、报文、会话”四类分一遍,能省下后面至少半天的阅读时间。
2.1 文件名不会说谎:先剔除构建缓存,再按职责归类
解压后先做一次顶层过滤,把 Visual Studio 生成的缓存文件甩开:
unzip MSRP.zip -d msrp-src cd msrp-src find . -maxdepth 1 -type f ! -name '*.ncb' ! -name '*.vcproj' | sort这里排除msrp.ncb是有原因的:.ncb是 Visual C++ 的智能感知缓存文件,记录类库浏览索引,程序一打开 IDE 就会重写它,解压出来纯属干扰。msrp.vcproj是 VS 工程文件,属于构建入口,不是源码,也先放到一边。
剩下可以按职责画一张映射表:
| 职责 | 文件 | 一句话作用 |
|---|---|---|
| 连接生命周期 | TcpListener.cpp、TcpConnection.cpp、Connection.cpp、TransportGroup.h | 监听、接受、维持和销毁 TCP 连接 |
| 报文编解码 | ByteWrangler.cxx、MsrpRequestLine.cpp、MsrpStatusLine.cpp | 字节流切包、请求行/状态行解析 |
| URI 与地址 | MsrpUrl.cpp、Path.cpp、Address.cpp | 处理 msrp:// URI、To-Path/From-Path 头域 |
| 消息对象 | OutgoingMessage.cpp / IncomingMessage.h | 封装发给对端和从对端收到的消息 |
| 分片与范围 | ByteRange.cpp | 负责大文件的 Byte-Range 区间计算 |
| 结果回报 | ReportSuccess.cpp、ReportFailure.cpp、Status.cpp | 发送成功/失败后的报告与状态映射 |
| 会话入口 | MsrpRoar.cpp、Session.h、Stack.h | 把上述模块串成完整会话流程 |
| DNS 辅助 | DnsResolver.cpp | 解析 MSRP URL 中的主机名 |
这张表的阅读顺序是有讲究的:先看TcpListener和TcpConnection,明白“数据从哪来”;再看ByteWrangler,明白“字节怎么变成报文”;最后看OutgoingMessage和IncomingMessage,明白“报文怎么进业务层”。从底层往上层读,比从头文件到实现乱翻要顺得多。
2.2 连接管理骨架:监听一个端口,维护多个 TCP 连接
MSRP 数据面几乎总是走 TCP,信令面的 SIP 很可能也走 TCP,但两者是两条独立的连接。我在这个包里看到TcpListener和TcpConnection分工很明确:一个负责 accept 新连接,一个负责具体连接上的收发。结构大致是这样:
class TcpListener { public: bool start(const Address& addr); // 绑定端口并 listen TcpConnection* accept(); // accept 后封装成连接对象 void onReadable(TcpConnection* c); // 可读事件交给 ByteWrangler }; class TcpConnection { public: bool send(const char* data, size_t len); void close(); };这段代码不是从包里原样抄的,是这一类 MSRP 栈最常见的组织方式:TcpListener负责被动接受对端连接,或者在主动协商完成后由客户端逻辑发起TcpConnection。TransportGroup.h的存在说明它还要管理一组连接,一个会话里可能有多个 MSRP 连接共存,分组后可以按会话整体关停,不用逐个连。
参数上要注意两个点。第一,addr里必须是真实可达的 IP 和端口,SDP 协商完后对端会拿这个地址来回连,端口错了直接握手失败。第二,send的len是字节数,MSRP 报文的长度不是靠 Content-Length 判定的,而是看结束分隔符,所以发送函数不能把“缓冲没写满”当成发送完成。
2.3 ByteWrangler:TCP 是流水,报文是集装箱
字节流解析是 MSRP 实现里最脏最累的活,也是这个包把ByteWrangler.cxx单独拆出来的原因。TCP 不会保证一次 recv 恰好是一个完整报文,它可能给你半条消息,也可能一次塞给你三条。ByteWrangler 这个词里的 Wrangler 很贴切——它不是在“读”字节,是在“驯服”字节。
// 判断一条 MSRP 报文是否收全:靠结束行中的 '$',而不是长度字段 bool isComplete(const char* p, size_t len) { if (len < 9) return false; size_t i = len; if (p[i - 1] == '\n') --i; if (p[i - 1] == '\r') --i; return p[i - 1] == '$' && std::string(p + i - 7, 7) == "-------"; }这个函数把最关键的判断逻辑写透了:MSRP 报文的结束标志不是空行,而是七个短横线加上消息 ID 再加上一个$。收包时如果发现末尾没有$,说明还有后续分段,要继续攒在 Buffer 里。Buffer.h在这个包里就是干这个用的,它不像 Linux 内核 skb 那样复杂,就是一个能动态扩容的字节容器。
很多新手在这里翻车:拿到recv返回的len就开始解析起始行,结果报文只到了一半,MsrpRequestLine.cpp里解析 URI 直接越界。正确顺序永远是:收进 Buffer → 找结束分隔符 → 完整后才交给解析器。
3. 从 INVITE 到 SEND:把 MSRP 会话跑通的最小链路
协议栈拆完只能算认识了零件,真正验证“能不能用”要看最小链路能不能跑通。MSRP 和 SIP 的分工非常清楚:SIP 用 INVITE 协商双方支持什么、数据走哪个地址;协商完成后,MSRP 在单独的 TCP 连接上把数据一块块送过去。我把这条链路拆成三小步,按顺序走一遍就知道哪个模块没对上。
3.1 在 SIP INVITE 里声明 MSRP 能力
要让对端知道你打算用 MSRP 传文件,INVITE 的 SDP 里必须携带m=message描述行。一个常见的最小 INVITE 长这样:
INVITE sip:bob@example.com SIP/2.0 Via: SIP/2.0/TCP 192.0.2.1:5060 Contact: <sip:alice@192.0.2.1:5060> Content-Type: application/sdp Content-Length: 128 v=0 o=alice 2890844526 2890844526 IN IP4 192.0.2.1 s=MSRP session c=IN IP4 192.0.2.1 m=message 9000 TCP msrp a=accept-types:text/plain image/jpeg注意m=message 9000 TCP msrp这行:协议名是msrp,传输层是TCP,端口 9000 必须与TcpListener实际监听的端口一致。后面a=accept-types是告诉对端我能收哪些类型,图片、纯文本、application/octet-stream 都可以,但类型协商不匹配时对端可能直接回 406,这个字段不能省。
SIP 侧收到 200 OK 后,INVITE 事务就结束了,但 MSRP 的 TCP 连接还没建立。很多调试日志停在这里,以为 SIP 200 OK 就等于文件传完了,这是把会话建立和数据传输混在了一起。SIP 只是“开会通知”,真正“递文件”在下一步。
3.2 建 TCP 连接:SETUP/TEARDOWN 不是 RFC 标准的“万能钥匙”
SIP 协商成功后,发起方需要根据 SDP 里的 MSRP URL 主动向对端发 TCP 连接请求。这个包的摘要里把连接建立阶段写作 SETUP、关闭阶段写作 TEARDOWN。严格讲,RFC 4975 标准里 MSRP 只有 SEND 和 REPORT 两个方法,SETUP/TEARDOWN 更像是在测试工具或封装层里约定的扩展语义,用来标记连接初始化与连接关闭。
实际编码时,我一般把 “SETUP” 理解为 TCP connect 成功后的一次握手确认,例如:
MSRP 6ceb4 SEND To-Path: msrp://bob@192.0.2.2:9000/2a34;tcp From-Path: msrp://alice@192.0.2.1:9000/aa83;tcp Message-ID: setup-001 Content-Type: application/octet-stream connection check -------6ceb4$这段报文的作用不是传业务数据,而是验证 TCP 链路已经可用。To-Path和From-Path里的;tcp参数不能漏,它告诉解析器这是走 TCP 还是 TLS。如果对端在这个阶段不回 200,基本可以判定是地址可达性问题,先检查防火墙和监听端口,不要继续排查上层协议。
3.3 真正的 SEND:Message-ID、Byte-Range 和结束分隔符
业务数据通过 SEND 方法传输。一次完整的小消息发送如下:
MSRP d93kswow SEND To-Path: msrp://bob@192.0.2.2:9000/2a34;tcp From-Path: msrp://alice@192.0.2.1:9000/aa83;tcp Message-ID: 456 Byte-Range: 1-200/400 Content-Type: text/plain Hello, this is a test message -------d93kswow$逐项说参数:Message-ID是这条业务消息的唯一标识,对端回 REPORT 时靠它找到匹配的发送事务;Byte-Range: 1-200/400表示整条消息 400 字节,这次发送的是第 1 到第 200 字节;接收端只有等最后一个分片的 Byte-Range 上界等于总长度,才算收到完整文件。结尾那一行必须是-------加消息 ID 加$,这是报文结束的硬标志,前面第 2 章里isComplete判断的就是它。
这里最容易踩的是 Byte-Range 的起点。MSRP 的范围计数从 1 开始,不是从 0 开始。文件读到内存后,数组下标 0 对应的字节在报文里是 1,发送时下标要加一,接收时再减回来。因为这个细节写错一字节,我曾经排查过两小时,最后发现文件开头少了一个字节。
4. 避坑记录:编译选项、报文分片与 SIP 客户端联调的八个坑
协议栈这种东西,文档写得再漂亮,真正跑起来全是细节。下面这五条是我拆这个包时踩过或见过别人踩的典型问题,按“现象→原因→解决”的顺序整理,希望对你有用。
4.1 recv 返回一半报文就开始解析,导致起始行越界
现象:日志里 MsrpRequestLine 解析时报错,或者MsrpUrl::parse拿到一个残缺的msrp://地址,程序崩溃或返回 400。
原因:TCP 是字节流,一次 recv 可能只读了报文的前半段。很多初学代码在 recv 后直接调解析函数,没把数据先攒进 Buffer。
解决:按Buffer.h的缓冲思路来,先append再扫描结束分隔符,确认$出现后,才把整块缓冲交给MsrpRequestLine或MsrpStatusLine解析。我一般会在解析器外层套一层“完整帧检测”,不完整就继续 recv。
4.2 Makefile.in 直接 configure 报错,构建系统互相打架
现象:Unix 下执行./configure提示找不到 configure 脚本,或 make 时生成了一大堆依赖错误;Windows 上又发现 vcproj 和源码路径对不上。
原因:包里同时存在Makefile.in和msrp.vcproj,说明它兼容 autotools 和 Visual Studio 两套构建。Makefile.in是 autotools 的模板,不是能直接用的 Makefile,必须先运行autoreconf -i和./configure生成真正的 Makefile。
解决:Linux 上依次执行autoreconf -i、./configure、make;Windows 直接用 VS 打开msrp.vcproj,不要改成手动维护 Makefile。如果某个模块依赖系统库,优先把缺失的依赖包补上,不要强行#define绕过错。
4.3 msrp.ncb 报“数据库损坏”,其实是 IDE 缓存
现象:双击工程时 Visual C++ 提示 IntelliSense 数据库损坏或无法打开.ncb文件;重新生成又报内存不足。
原因:.ncb是旧版 Visual C++ 的浏览缓存,不属于源码。ZIP 解压时间戳混乱后,IDE 发现缓存与源码不匹配就会报错。
解决:直接删除msrp.ncb,再打开工程让 IDE 重新生成缓存。顺手把它加进.gitignore或.zipignore,以后解压资源包时也不用担心这类文件污染工程。
4.4 SIP 200 OK 后 MSRP 连接迟迟不建立
现象:INVITE 协商成功,SIP 日志一切正常,但对端迟迟没有发起 MSRP TCP 连接,或者连上了不发任何报文,直到超时。
原因:最常见是 SDP 里的m=message端口和From-Path中的端口不一致。对端拿着 SDP 的端口来连接,如果那个端口上TcpListener没在监听,连接必然失败。其次要确认主动连接的方向,某些实现要求邀请方先 connect,某些要求被邀请方先 connect,两边的模式不一致也会卡住。
解决:先把 SDP 端口、TcpListener绑定端口、From-Path里的端口三项列出来对比,保证完全一致。再通过抓包看 TCP SYN 是否发出、对端是否回 RST,用这个办法把问题定位在路由层还是协议层。网络地址转换环境下,还要确保 SDP 填写的是对外可达地址,不是内网地址。
4.5 Byte-Range 下标差一,文件首字节丢失或拼接错位
现象:本地文件 1024 字节,发送后对端收到 1023 字节;或者分片文件拼接后中间有缺口、重复段。
原因:MSRP 的 Byte-Range 从 1 计数,而 C/C++ 数组下标从 0 计数。发送方把offset直接当 Byte-Range 起点写,接收方把首字节当数组第 0 位读,两边对不上。
解决:发送时按start_byte = offset + 1计算范围,接收时把Byte-Range的起点减一当作文件写入偏移。我习惯在ByteRange.cpp的解析函数里就统一成range.start = field.start - 1,这样上层业务代码不用每次都在心里换算一遍。
4.6 忘了在 SEND 里声明 Success-Report: yes,结果没回调
现象:消息明明发送成功,对端也收到了,但上层应用一直等不到发送成功的通知。
原因:MSRP 默认不需要 REPORT,只有发送方在 SEND 头域里显式声明Success-Report: yes或Failure-Report: yes,对端才需要在处理完后发 REPORT。
解决:发送 SEND 前检查报文的三个控制字段:Message-ID必须存在,Byte-Range必须正确,需要确认机制时在头域里主动加Success-Report: yes。ReportSuccess.cpp和ReportFailure.cpp只有在收到对端 REPORT 后才会被触发,别把这两个模块当成同步返回值。
5. 分片传送与确认:用 Byte-Range 加 Report 把大文件拆明白
ReportSuccess.cpp、ReportFailure.cpp和ByteRange.cpp这三个文件,是所有大文件传输场景的核心。理解它们,才算把 MSRP 从“发一条消息”升级到“传一个完整文件”。
5.1 大文件分片:Byte-Range 就是文件的“页码”
文件超过单条 MSRP 报文能承载的上限时,必须拆成多个 SEND 分片。每个分片携带整体上的范围信息,接收端需要把跟byte-range相关的字段存下来,边收边写文件。这里有一个可参考的分块循环:
chunk_size = 4096 total = os.path.getsize(local_path) offset = 0 while offset < total: data = read_chunk(local_path, offset, chunk_size) end_byte = offset + len(data) # 当前块在文件里的截止位置 start_byte = offset + 1 # MSRP Byte-Range 从 1 开始 send_send(data, total, start_byte, end_byte) offset = end_byte逻辑上要注意三点。第一,end_byte是闭区间写入,读 4096 字节时下标到 4095,但Byte-Range写的是1-4096/8192。第二,最后一帧的Byte-Range上界必须等于total,接收端看到这个值才知道文件收完了。第三,Message-ID在同一个文件的所有分片中保持一致,接收端才能把分片归到同一个文件句柄下;如果每片都换 ID,对端会当成多条独立消息处理,拼接就乱了。
接收端通常有类似IncomingMessage::appendPayload的入口,它把人参段里的 offset 恢复成写文件的 lseek 位置:
int64_t writeOffset = byteRange.start - 1; // 范围转下标 fseek(outFile, writeOffset, SEEK_SET); fwrite(payload, 1, payloadLen, outFile);这一段代码没有实际内容,功能很直观:把 MSRP 的 1-based 范围,换算成fseek需要的 0-based 偏移。文件名后缀和总长度最好在首帧就确定下来,并在首帧的 Content-Type 里带出,例如application/octet-stream加上Content-Disposition: attachment; filename=xxx.bin。
5.2 Report 机制:成功和失败都会回信
REPORT 是 MSRP 唯一的“回执”方法。对端处理完 SEND 后,如果发送方要求报告,它就会回一个 REPORT。格式大致如下:
MSRP d93kswow REPORT To-Path: msrp://alice@192.0.2.1:9000/aa83;tcp From-Path: msrp://bob@192.0.2.2:9000/2a34;tcp Message-ID: 456 Byte-Range: 1-400/400 Status: 200 -------d93kswow$注意To-Path和From-Path与之前 SEND 报文正好对调:对端把自己的地址放From-Path,把对方的地址放To-Path。Message-ID必须原样返回,发送端靠它找到原始发送记录。Status: 200表示处理成功。如果处理中出错,ReportFailure.cpp就会收到一个非 2xx 的状态码。
常见状态码可以维护一张映射表:
| 状态码 | 含义 | 常见触发场景 |
|---|---|---|
| 200 | OK | 对端完整收到并处理成功 |
| 400 | Bad Request | 起始行或头域格式错误,多半是结束分隔符不对 |
| 403 | Forbidden | 对端拒绝来自该 Path 的连接 |
| 413 | Message Too Large | 单条消息超过对端限制,需要减chunk_size |
| 481 | No Such Message | Message-ID 对不上,或分片归属混乱 |
ReportSuccess.cpp和ReportFailure.cpp的职责就是把这些状态码转换成上层回调:成功就推进下一个分片或标记任务完成,失败就计数重发。重发时注意消息 ID 是否要换:同一分片重发可以保留原 Message-ID,但 Byte-Range 必须与上次一致,否则接收端可能重复写入。
6. 一个能直接抄的调试手法:给 MSRP 报文加全链路透传日志
协议栈跑起来后,最头疼的是“两个模块都觉得自己没问题”。信令和媒体不在同一条链路,SIP 日志只到 200 OK,MSRP 数据在另一条 TCP 连接上,一旦文件传不完整,日志里什么线索都没有。我从这个包里学到的最实用技巧,是在报文进出两侧各加一段透传日志,把原始字节原样打印出来。
void traceFrame(const char* tag, const char* data, size_t len) { fprintf(stderr, "[MSRP][%s] len=%zu\n%.*s\n--frame-end--\n", tag, len, (int)len, data); }调用位置选两个:一个在ByteWrangler判定报文完整、开始解析前,打印收到的原始报文;另一个在TcpConnection::send写入 socket 前,打印即将发出的报文。这两个点分别代表“栈边界收到的”和“栈边界发出的”,任何一端的异常都能立刻看出是发送方拼错了报文,还是接收方解析错了字节。
日志里的tag我习惯用RX和TX,但最好再加上方向和时间戳。len一定要打印,因为 MSRP 报文长度和业务负载长度不是一回事,中间有头域就有几十字节的偏差。报文内容里的换行和结束分隔符原样输出,特别关注末尾那七个短横线和$,很多问题一眼就能看出来——结束行少一个横线、Content-Length算错,这类错误在日志里比在抓包里更容易观察到。
从那以后,我每次做 MSRP 联调都强制先开这一层 trace,把 RX/TX 两端的报文各留存一份。SIP 侧的 200 OK 只是开会通知,真正传数据的 TCP 连接上发生了什么,只有这份透传日志最清楚。分片传大文件时,我还会把Byte-Range单独提出来打一条摘要,确认每一帧的范围是连续的。这套习惯帮我省下的排查时间,比写全套抓包分析脚本多得多,希望帮到你。
本文还有配套的精品资源,点击获取