
简介这份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.0China Mobile Peer to Peer是中国移动短信网关与SP服务提供商之间的通讯协议定义了短信提交、状态报告、上行短信等核心交互。它跑在TCP之上用二进制帧格式传输和HTTP那种文本协议完全不是一个路子。这篇文章不讲空泛的协议概述而是把这份教案文档拆成能落地的工程步骤怎么建连、怎么组包、怎么调参数、怎么排查错误码。适合后端开发、运维工程师、以及需要对接运营商短信通道的技术负责人。如果你正在搜“短信网关可以本地化部署吗”或者“cmpp2.0短信网关”相关的实现方案这篇笔记能帮你少走弯路。2. CMPP2.0协议帧结构拆解从字节偏移到组包逻辑2.1 为什么CMPP2.0不用HTTP而用私有二进制协议短信网关对吞吐量和延迟的要求远高于普通业务接口。一条验证码短信从提交到用户收到运营商侧通常要求秒级完成高峰期一个SP可能每秒要处理几千条提交。如果用HTTPJSON光是文本解析和头部开销就吃掉大量CPU。CMPP2.0采用固定头部变长消息体的二进制格式头部只有12字节解析时直接按偏移量取值不需要词法分析。这是典型的电信级协议设计思路用空间换时间用固定结构换解析效率。另一个原因是状态报告的回传机制。短信提交后网关会异步回传状态报告比如“DELIVRD”表示已送达这个回传通道需要长连接保持。HTTP短连接做这件事要么轮询要么用WebSocket都不如TCP长连接直接。CMPP2.0在一条TCP连接上双向传输提交和回传互不阻塞这是它比HTTP更适合短信场景的根本原因。2.2 消息头12字节的逐字段说明CMPP2.0的每条消息都以一个12字节的消息头开始结构如下字段长度说明Total_Length4字节消息总长度包含消息头本身Command_ID4字节命令类型如0x00000004表示CMPP_SUBMITSequence_ID4字节序列号用于请求与响应配对这三个字段都是网络字节序大端。Total_Length决定了这条消息一共多少字节接收方先读4字节拿到长度再继续读剩余部分。Command_ID是协议的动作标识常见的有0x00000001CMPP_CONNECT建立连接0x00000002CMPP_CONNECT_RESP连接响应0x00000004CMPP_SUBMIT提交短信0x00000005CMPP_SUBMIT_RESP提交响应0x00000006CMPP_DELIVER网关投递含状态报告和上行短信0x00000007CMPP_DELIVER_RESP投递响应0x00000008CMPP_ACTIVE_TEST心跳0x00000009CMPP_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, version0x20): 构造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) # 命令IDCMPP_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返回后如果Status0表示连接成功。之后需要定期发送CMPP_ACTIVE_TEST心跳包通常间隔30秒到60秒。如果超过3个心跳周期没收到响应就应该主动断开重连。重连不要用固定间隔建议用指数退避第一次1秒第二次2秒第三次4秒上限30秒。这样避免网关侧还没恢复时被大量重连请求打垮。心跳包的结构极简消息头12字节Command_ID0x00000008Sequence_ID递增消息体为空。响应包Command_ID0x00000009Sequence_ID与请求一致。心跳包不需要任何业务字段所以Total_Length12。3. 短信提交与状态报告CMPP_SUBMIT的字段配置与回执处理3.1 CMPP_SUBMIT消息体的核心字段CMPP_SUBMIT是SP向网关提交短信的命令消息体字段较多但真正影响下发结果的就几个关键项字段长度说明常见取值Msg_Id8字节消息ID由SP生成网关回执时原样返回自定义唯一值Pk_Total1字节短信分片总数1单条Pk_Number1字节当前分片序号1Registered_Delivery1字节是否需要状态报告1需要Msg_Level1字节消息优先级0普通Service_Id10字节业务代码运营商分配Fee_UserType1字节计费用户类型0按SP计费Fee_Terminal_Id21字节计费号码通常填目标号码Msg_Fmt1字节消息格式0ASCII或8UCS2Msg_Src6字节源号码SP的服务代码Src_Id21字节源终端号通常填SP代码DestUsr_Tl1字节目标号码个数1Dest_Terminal_Id21×N目标号码手机号Msg_Content变长短信内容最长140字节Msg_Fmt的选择直接影响内容编码。如果短信内容全是ASCII字符英文、数字用0内容直接填ASCII字节。如果包含中文必须用8UCS2内容需要转成UTF-16BE编码。很多新手在这里踩坑中文短信用了Msg_Fmt0结果网关返回“消息格式错误”或者用户收到乱码。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_fmt8, registered_delivery1): 构造CMPP_SUBMIT请求包 msg_id: 8字节消息ID service_id: 10字节业务代码 src_terminal: 21字节源终端号 dest_terminal: 21字节目标号码 content: 短信内容字符串 msg_fmt: 0ASCII, 8UCS2 # 编码短信内容 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_idmsg_id, service_idSVC001, src_terminal106900001234, dest_terminal13800138000, content您的验证码是1234565分钟内有效。, msg_fmt8 ) 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_IdCommand_ID0x00000007。如果不回网关会重推导致重复处理。这里有个血泪经验状态报告的处理一定要做幂等因为网络抖动时网关可能重推同一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_Addr6字节 密码9字节 Timestamp4字节整数转字符串注意Timestamp是整数转成10位字符串不是直接拼4字节二进制。4.2 中文乱码Msg_Fmt与编码的对应关系现象用户收到短信显示“您的验证码是”后面跟着一串问号或方块。原因Msg_Fmt0ASCII但内容包含中文或者Msg_Fmt8UCS2但内容用了UTF-8编码。解决中文短信必须用Msg_Fmt8内容用UTF-16BE编码。注意Python的encode(utf-16-be)不带BOM而encode(utf-16)会带BOMBOM会导致网关解析出错。另外短信内容里的换行符要转成\n的UCS2编码不要直接传\r\n。4.3 状态报告丢失Registered_Delivery与回执确认现象短信提交成功CMPP_SUBMIT_RESP返回0但业务侧一直没收到状态报告。原因一Registered_Delivery字段填了0网关不会回状态报告。解决填1。原因二收到CMPP_DELIVER后没有回CMPP_DELIVER_RESP网关认为SP没收到重推几次后放弃。解决收到DELIVER后立即回RESPMsg_Id原样返回。原因三状态报告被业务侧过滤掉了。CMPP_DELIVER的Registered_Delivery1时才是状态报告如果代码里没判断这个字段可能把状态报告当上行短信处理了。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_fmt0但内容包含中文说明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收发。这样出问题时变量最少排查最快。等这三步跑通了再把代码集成到业务系统里。这个习惯帮我省了很多“后悔药”——业务代码里混着协议问题排查起来像大海捞针。希望帮到你。本文还有配套的精品资源点击获取