简介:这份PDF文档是中国移动短信网关通讯协议CMPP2.0的完整技术规范,面向从事短信业务开发的工程师、SP服务商技术人员及通信协议学习者,用于解决第三方平台接入中国移动短信网络时的接口对接与消息交互问题。文档系统梳理了协议的范围、缩略语、网络结构、功能概述、协议栈与通信方式,并重点展开消息定义部分,涵盖CMPP_CONNECT、CMPP_TERMINATE、CMPP_SUBMIT、CMPP_QUERY、CMPP_DELIVER、CMPP_CANCEL等命令的消息头格式、参数结构与应答机制,同时涉及长连接与短连接、端口号、心跳及错误处理等细节。资源包内仅含1个PDF文件,大小约478KB,篇幅紧凑、目录层级清晰,便于按章节检索查阅。目前已有79人学习,适合需要快速掌握CMPP2.0报文结构、排查短信提交与状态查询问题的开发者作为案头参考。
1. 教案之中国移动短信网关通讯协议cmpp2.0.pdf:从协议文档到可跑通的短信收发链路
手里拿到一份《教案之中国移动短信网关通讯协议cmpp2.0.pdf》,很多人第一反应是翻两页就放下——满篇的字段定义、字节序、状态码,看着像天书。但如果你正在做短信通知、验证码下发、或者企业内部告警推送,这份文档其实是一张藏宝图。CMPP2.0(China Mobile Peer to Peer)是中国移动短信网关与SP(服务提供商)之间的通讯协议,定义了短信提交、状态报告、上行短信等核心交互。它跑在TCP之上,用二进制帧格式传输,和HTTP那种文本协议完全不是一个路子。这篇文章不讲空泛的协议概述,而是把这份教案文档拆成能落地的工程步骤:怎么建连、怎么组包、怎么调参数、怎么排查错误码。适合后端开发、运维工程师、以及需要对接运营商短信通道的技术负责人。如果你正在搜“短信网关可以本地化部署吗”或者“cmpp2.0短信网关”相关的实现方案,这篇笔记能帮你少走弯路。
2. CMPP2.0协议帧结构拆解:从字节偏移到组包逻辑
2.1 为什么CMPP2.0不用HTTP而用私有二进制协议
短信网关对吞吐量和延迟的要求远高于普通业务接口。一条验证码短信从提交到用户收到,运营商侧通常要求秒级完成,高峰期一个SP可能每秒要处理几千条提交。如果用HTTP+JSON,光是文本解析和头部开销就吃掉大量CPU。CMPP2.0采用固定头部+变长消息体的二进制格式,头部只有12字节,解析时直接按偏移量取值,不需要词法分析。这是典型的电信级协议设计思路:用空间换时间,用固定结构换解析效率。
另一个原因是状态报告的回传机制。短信提交后,网关会异步回传状态报告(比如“DELIVRD”表示已送达),这个回传通道需要长连接保持。HTTP短连接做这件事要么轮询要么用WebSocket,都不如TCP长连接直接。CMPP2.0在一条TCP连接上双向传输,提交和回传互不阻塞,这是它比HTTP更适合短信场景的根本原因。
2.2 消息头12字节的逐字段说明
CMPP2.0的每条消息都以一个12字节的消息头开始,结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| Total_Length | 4字节 | 消息总长度,包含消息头本身 |
| Command_ID | 4字节 | 命令类型,如0x00000004表示CMPP_SUBMIT |
| Sequence_ID | 4字节 | 序列号,用于请求与响应配对 |
这三个字段都是网络字节序(大端)。Total_Length决定了这条消息一共多少字节,接收方先读4字节拿到长度,再继续读剩余部分。Command_ID是协议的动作标识,常见的有:
- 0x00000001:CMPP_CONNECT(建立连接)
- 0x00000002:CMPP_CONNECT_RESP(连接响应)
- 0x00000004:CMPP_SUBMIT(提交短信)
- 0x00000005:CMPP_SUBMIT_RESP(提交响应)
- 0x00000006:CMPP_DELIVER(网关投递,含状态报告和上行短信)
- 0x00000007:CMPP_DELIVER_RESP(投递响应)
- 0x00000008:CMPP_ACTIVE_TEST(心跳)
- 0x00000009:CMPP_ACTIVE_TEST_RESP(心跳响应)
Sequence_ID由请求方生成,响应方原样返回。实际编码时,Sequence_ID通常用原子递增计数器生成,保证同一连接上不重复。注意:CMPP2.0的Sequence_ID是32位无符号整数,回绕后从1重新开始,不要用0。
2.3 用Python构造一个CMPP_CONNECT请求包
下面是一个最小化的CMPP_CONNECT请求包构造代码,用Python的struct模块完成字节打包:
import struct import socket def build_cmpp_connect(source_addr, authenticator, timestamp, version=0x20): """ 构造CMPP_CONNECT请求包 source_addr: SP的企业代码,6字节字符串 authenticator: 认证码,16字节MD5 timestamp: 时间戳,4字节整数,格式MMDDHHMMSS version: 协议版本,0x20表示CMPP2.0 """ # 消息体:Source_Addr(6) + Authenticator(16) + Version(1) + Timestamp(4) body = struct.pack('!6s16sB I', source_addr.encode('utf-8'), authenticator, version, timestamp) # 命令ID:CMPP_CONNECT = 0x00000001 command_id = 0x00000001 sequence_id = 1 # 实际使用时应从计数器获取 # 总长度 = 12字节头 + 消息体长度 total_length = 12 + len(body) # 打包消息头:Total_Length(4) + Command_ID(4) + Sequence_ID(4) header = struct.pack('!III', total_length, command_id, sequence_id) return header + body # 使用示例 source_addr = '901234' # 企业代码,需向运营商申请 authenticator = b'\x00' * 16 # 实际应为MD5(Source_Addr + 9位密码 + Timestamp) timestamp = 1024153000 # 示例:10月24日15:30:00 packet = build_cmpp_connect(source_addr, authenticator, timestamp) print(f'包长度: {len(packet)} 字节') print(f'十六进制: {packet.hex()}')这段代码的关键点:struct.pack的格式字符串'!6s16sB I'中,!表示网络字节序,6s表示6字节字符串,16s表示16字节字节串,B表示1字节无符号字符,I表示4字节无符号整数。注意B和I之间有个空格,这是Python struct格式字符串的可读性分隔,不影响解析。authenticator的计算方式是MD5(Source_Addr + 密码 + Timestamp),其中密码是运营商分配的9位字符串,Timestamp是4字节整数转成字符串后的形式。很多新手在这里翻车:把Timestamp当成字符串直接拼进去,结果MD5对不上,连接返回错误码。
2.4 连接建立后的心跳与重连策略
CMPP_CONNECT_RESP返回后,如果Status=0表示连接成功。之后需要定期发送CMPP_ACTIVE_TEST心跳包,通常间隔30秒到60秒。如果超过3个心跳周期没收到响应,就应该主动断开重连。重连不要用固定间隔,建议用指数退避:第一次1秒,第二次2秒,第三次4秒,上限30秒。这样避免网关侧还没恢复时被大量重连请求打垮。
心跳包的结构极简:消息头12字节,Command_ID=0x00000008,Sequence_ID递增,消息体为空。响应包Command_ID=0x00000009,Sequence_ID与请求一致。心跳包不需要任何业务字段,所以Total_Length=12。
3. 短信提交与状态报告:CMPP_SUBMIT的字段配置与回执处理
3.1 CMPP_SUBMIT消息体的核心字段
CMPP_SUBMIT是SP向网关提交短信的命令,消息体字段较多,但真正影响下发结果的就几个关键项:
| 字段 | 长度 | 说明 | 常见取值 |
|---|---|---|---|
| Msg_Id | 8字节 | 消息ID,由SP生成,网关回执时原样返回 | 自定义唯一值 |
| Pk_Total | 1字节 | 短信分片总数 | 1(单条) |
| Pk_Number | 1字节 | 当前分片序号 | 1 |
| Registered_Delivery | 1字节 | 是否需要状态报告 | 1(需要) |
| Msg_Level | 1字节 | 消息优先级 | 0(普通) |
| Service_Id | 10字节 | 业务代码 | 运营商分配 |
| Fee_UserType | 1字节 | 计费用户类型 | 0(按SP计费) |
| Fee_Terminal_Id | 21字节 | 计费号码 | 通常填目标号码 |
| Msg_Fmt | 1字节 | 消息格式 | 0(ASCII)或8(UCS2) |
| Msg_Src | 6字节 | 源号码 | SP的服务代码 |
| Src_Id | 21字节 | 源终端号 | 通常填SP代码 |
| DestUsr_Tl | 1字节 | 目标号码个数 | 1 |
| Dest_Terminal_Id | 21×N | 目标号码 | 手机号 |
| Msg_Content | 变长 | 短信内容 | 最长140字节 |
Msg_Fmt的选择直接影响内容编码。如果短信内容全是ASCII字符(英文、数字),用0,内容直接填ASCII字节。如果包含中文,必须用8(UCS2),内容需要转成UTF-16BE编码。很多新手在这里踩坑:中文短信用了Msg_Fmt=0,结果网关返回“消息格式错误”或者用户收到乱码。
3.2 用Python提交一条中文短信的完整代码
import struct import socket import hashlib import time def build_cmpp_submit(msg_id, service_id, src_terminal, dest_terminal, content, msg_fmt=8, registered_delivery=1): """ 构造CMPP_SUBMIT请求包 msg_id: 8字节消息ID service_id: 10字节业务代码 src_terminal: 21字节源终端号 dest_terminal: 21字节目标号码 content: 短信内容字符串 msg_fmt: 0=ASCII, 8=UCS2 """ # 编码短信内容 if msg_fmt == 8: # UCS2编码:UTF-16BE,不含BOM content_bytes = content.encode('utf-16-be') else: content_bytes = content.encode('ascii') msg_length = len(content_bytes) # 消息体按字段顺序打包 body = struct.pack('!8sB B B B 10s B 21s B 6s 21s B', msg_id, # Msg_Id 1, # Pk_Total 1, # Pk_Number registered_delivery, # Registered_Delivery 0, # Msg_Level service_id.encode('utf-8'), # Service_Id 0, # Fee_UserType dest_terminal.encode('utf-8'), # Fee_Terminal_Id msg_fmt, # Msg_Fmt '10690000'.encode('utf-8'), # Msg_Src src_terminal.encode('utf-8'), # Src_Id 1) # DestUsr_Tl # 追加目标号码和内容 body += dest_terminal.encode('utf-8') body += struct.pack('!B', msg_length) body += content_bytes command_id = 0x00000004 sequence_id = 2 # 实际应从计数器获取 total_length = 12 + len(body) header = struct.pack('!III', total_length, command_id, sequence_id) return header + body # 使用示例 msg_id = b'\x01\x02\x03\x04\x05\x06\x07\x08' packet = build_cmpp_submit( msg_id=msg_id, service_id='SVC001', src_terminal='106900001234', dest_terminal='13800138000', content='您的验证码是123456,5分钟内有效。', msg_fmt=8 ) print(f'提交包长度: {len(packet)} 字节')这段代码里有个容易忽略的细节:Dest_Terminal_Id字段在消息体里是21字节,但实际手机号只有11位,后面需要用\x00填充到21字节。上面的代码直接用了dest_terminal.encode('utf-8'),如果号码不足21字节,struct.pack会自动补零,这是Python struct的特性。但要注意:如果号码超过21字节会截断,所以传入前要校验长度。
3.3 状态报告的解析与业务侧确认
CMPP_DELIVER是网关主动推送给SP的消息,包含两种内容:状态报告和上行短信。通过Registered_Delivery字段区分:如果该字段为1,表示这是状态报告;如果为0,表示这是上行短信。
状态报告的消息体里,Msg_Content字段的前8字节是原短信的Msg_Id,之后是7字节的状态报告内容,格式为“Stat:XXX”。常见的Stat值:
- DELIVRD:已送达
- EXPIRED:已过期
- DELETED:已删除
- UNDELIV:无法送达
- ACCEPTD:已接受
- UNKNOWN:未知
收到状态报告后,SP需要回一个CMPP_DELIVER_RESP,消息体只有8字节的Msg_Id,Command_ID=0x00000007。如果不回,网关会重推,导致重复处理。这里有个血泪经验:状态报告的处理一定要做幂等,因为网络抖动时网关可能重推,同一Msg_Id的状态报告可能收到多次。
3.4 长短信的分片与合并逻辑
一条短信内容超过70个中文字符(UCS2编码下140字节)时,需要分片。CMPP2.0通过Pk_Total和Pk_Number字段标识分片。Pk_Total是总片数,Pk_Number是当前片序号,从1开始。网关侧会根据这两个字段和Msg_Id把分片合并成一条长短信下发给用户。
分片时要注意:每个分片的Msg_Id必须相同,否则网关无法关联。Pk_Total最大值为255,但实际运营商通常限制在3到5片,超过可能被丢弃。分片内容按字节切分,UCS2编码下每片最多140字节,但第一片要预留6字节的UDH头(用户数据头),所以实际每片最多134字节。UDH头的格式是05 00 03 XX YY ZZ,其中XX是Msg_Id的低8位,YY是总片数,ZZ是当前片号。这个UDH头需要SP自己拼接到内容前面,网关不会自动加。
4. 避坑与排查:CMPP2.0对接中最容易翻车的五个地方
4.1 连接返回错误码0x01到0x08的含义与处理
CMPP_CONNECT_RESP的Status字段返回非0时,表示连接失败。常见错误码:
- 0x01:消息结构错误。通常是Total_Length算错了,或者字段顺序不对。
- 0x02:非法源地址。Source_Addr与运营商备案的不一致。
- 0x03:认证失败。Authenticator计算错误,检查MD5拼接顺序。
- 0x04:版本太高。Version字段填了0x30但网关只支持0x20。
- 0x05:版本太低。Version填了0x10。
- 0x06:时间戳错误。Timestamp与网关时间差超过10分钟。
- 0x07:不支持的操作。Command_ID不在网关支持列表里。
- 0x08:资源不足。网关连接数满了,稍后重试。
排查时先用Wireshark抓包,对比自己发的字节和文档定义的字段偏移。最常见的是Authenticator算错:MD5的输入是Source_Addr(6字节)+ 密码(9字节)+ Timestamp(4字节整数转字符串),注意Timestamp是整数转成10位字符串,不是直接拼4字节二进制。
4.2 中文乱码:Msg_Fmt与编码的对应关系
现象:用户收到短信显示“您的验证码是”后面跟着一串问号或方块。
原因:Msg_Fmt=0(ASCII)但内容包含中文,或者Msg_Fmt=8(UCS2)但内容用了UTF-8编码。
解决:中文短信必须用Msg_Fmt=8,内容用UTF-16BE编码。注意Python的encode('utf-16-be')不带BOM,而encode('utf-16')会带BOM,BOM会导致网关解析出错。另外,短信内容里的换行符要转成\n的UCS2编码,不要直接传\r\n。
4.3 状态报告丢失:Registered_Delivery与回执确认
现象:短信提交成功(CMPP_SUBMIT_RESP返回0),但业务侧一直没收到状态报告。
原因一:Registered_Delivery字段填了0,网关不会回状态报告。解决:填1。
原因二:收到CMPP_DELIVER后没有回CMPP_DELIVER_RESP,网关认为SP没收到,重推几次后放弃。解决:收到DELIVER后立即回RESP,Msg_Id原样返回。
原因三:状态报告被业务侧过滤掉了。CMPP_DELIVER的Registered_Delivery=1时才是状态报告,如果代码里没判断这个字段,可能把状态报告当上行短信处理了。
4.4 序列号回绕导致的请求响应错配
现象:运行几天后,提交短信偶尔超时,但网关侧显示已处理。
原因:Sequence_ID用32位整数,从1开始递增,回绕到0后继续。如果代码里用0作为初始值,或者回绕后没重置,可能导致请求和响应的Sequence_ID错配。
解决:Sequence_ID从1开始,回绕到0xFFFFFFFF后从1重新开始,跳过0。每次发送请求前记录Sequence_ID,收到响应时校验是否匹配。不匹配的响应直接丢弃,不要处理。
4.5 心跳超时与TCP半开连接
现象:连接显示正常,但提交短信无响应,重启后恢复。
原因:TCP半开连接。网络设备(如防火墙)静默丢弃了连接,但SP侧socket没有收到FIN/RST,认为连接还在。心跳包发出去也没响应,但代码没处理心跳超时。
解决:每次发送心跳后启动定时器,如果30秒内没收到CMPP_ACTIVE_TEST_RESP,主动关闭socket并重连。同时设置socket的SO_KEEPALIVE选项,让操作系统层也帮忙检测。重连后要重新发送CMPP_CONNECT,不能直接复用旧连接的Sequence_ID。
5. 进阶技巧:用Jasmin短信网关做本地化联调与协议验证
5.1 为什么本地联调需要Jasmin这类短信网关
直接连运营商网关做测试成本很高:需要申请测试通道、配置IP白名单、每次调试都要走工单。Jasmin是一个开源的短信网关(GitHub上可搜到),支持SMPP、CMPP等多种协议,可以在本地搭建一个模拟网关,用来验证SP侧的组包逻辑、状态报告处理、长短信分片等。它的价值在于:你可以在没有运营商通道的情况下,把CMPP2.0的交互流程跑通,确认代码没有协议层面的错误。
Jasmin的安装通常用Docker,一条命令拉起:
docker run -d --name jasmin -p 2775:2775 -p 8990:8990 \ -v /path/to/jasmin.conf:/etc/jasmin/jasmin.conf \ jookies/jasmin:latest2775是SMPP端口,8990是HTTP管理接口。CMPP2.0需要额外配置一个CMPP Server组件,Jasmin的配置文件里可以定义cmpp_server,指定监听端口和认证信息。配置好后,SP侧把目标地址从运营商网关IP改成localhost,就能在本地完成提交和回执的全流程。
5.2 用Jasmin验证CMPP_SUBMIT的字段正确性
Jasmin收到CMPP_SUBMIT后,会把短信内容、源号码、目标号码等字段记录到日志或转发到下一个组件。通过查看Jasmin的日志,可以确认SP侧发送的字段是否符合预期。比如,如果Jasmin日志里显示msg_fmt=0但内容包含中文,说明SP侧编码设置错了。如果dest_terminal_id后面有乱码,说明号码填充有问题。
Jasmin的日志级别可以在配置文件里调到DEBUG,这样能看到每个字段的原始字节。对比自己代码里struct.pack的输出,就能定位到具体是哪个字段偏移错了。这种验证方式比抓包更直观,因为Jasmin会把字段名和值对应打印出来。
5.3 本地联调与运营商对接的差异点
本地联调通过不代表运营商侧一定通过,有几个差异要注意:
第一,运营商网关对Source_Addr有严格校验,必须是备案的企业代码,本地Jasmin通常不校验。第二,运营商网关对短信内容有敏感词过滤,本地没有。第三,运营商网关的Sequence_ID可能有自己的起始值,不一定是1。第四,运营商网关的心跳间隔可能要求更短,比如20秒。
建议在本地联调通过后,先向运营商申请测试账号,用测试账号做一轮真实提交,确认状态报告能正常回传。测试期间把日志级别调到最细,记录每个请求和响应的完整字节,方便出问题时对比。
5.4 一个我常用的排查习惯
每次对接新网关,我会先写一个最小化的Python脚本,只做三件事:发CMPP_CONNECT、发CMPP_ACTIVE_TEST、发一条CMPP_SUBMIT。不接业务逻辑,不接数据库,纯socket收发。这样出问题时,变量最少,排查最快。等这三步跑通了,再把代码集成到业务系统里。这个习惯帮我省了很多“后悔药”——业务代码里混着协议问题,排查起来像大海捞针。希望帮到你。
本文还有配套的精品资源,点击获取