
简介这份PDF是中国移动短信网关通讯协议CMPP2.0的完整技术规范面向从事短信业务开发的工程师、SP服务商技术人员及通信协议学习者用于解决第三方平台接入中国移动短信网络时的接口对接与消息交互问题。资源包共1个PDF文件大小约478KB内容涵盖协议范围、缩略语、网络结构、CMPP功能概述、协议栈、长连接与短连接通信方式以及消息定义等核心章节。其中详细规定了CMPP_CONNECT、CMPP_SUBMIT、CMPP_QUERY、CMPP_DELIVER、CMPP_CANCEL等命令的消息结构、消息头格式与应答机制并给出基本数据类型、端口号及错误处理规则。读者可据此掌握SP与ISMG之间的标准化通信流程理解短信提交、查询、接收与取消的完整交互逻辑为开发高并发短信发送与状态管理应用提供权威参考。目前已有79人学习关注适合需要深入理解运营商短信网关协议的技术人员查阅。1. 短信网关对接的硬骨头CMPP 2.0 到底卡在哪做过运营商短信通道对接的兄弟都懂最磨人的不是写业务逻辑而是跟网关的协议层死磕。我见过太多团队拿着 CMPP 2.0 的 PDF 文档对着十六进制报文一行行数字节一个字段偏移算错整个 CMPP_SUBMIT 发出去就石沉大海连个错误码都拿不到。这份《中国移动短信网关通讯协议 CMPP 2.0》就是干这个用的——它定义了中国移动短信网络里 SP业务提供者、ISMG互联网短信网关、GNS汇接网关三者之间的消息格式、交互流程和错误处理机制。说白了你要接中国移动的短信通道这份文档就是唯一的施工图。它适合两类人一是正在做短信平台对接的后端工程师二是需要理解运营商侧报文结构做抓包排查的运维。协议本身不复杂但细节密度极高一个字节的偏差就能让你调一整天。2. 协议栈与通信方式长连接、短连接和端口怎么选2.1 为什么 CMPP 跑在 TCP 上而不是 UDPCMPP 的协议栈结构很清晰底层是 TCP/IP上面直接承载 CMPP 应用层消息。没有中间件没有 HTTP 封装就是裸 TCP 上跑自定义二进制协议。这个选型在 2002 年定下来的时候核心考量是短信业务对可靠性的要求——每条短信都涉及计费丢一条就是钱的问题。TCP 提供的有序到达、重传机制、流量控制正好匹配短信提交的场景。但 TCP 只保证了字节流的可靠CMPP 自己在应用层又加了一层可靠性每个请求消息都有对应的响应消息通过 Sequence_Id 做请求-应答配对。这意味着你在实现客户端时不能只管发不管收必须维护一个 Sequence_Id 到请求上下文的映射表收到响应后回填结果。常见做法是用一个 ConcurrentHashMap 存 pending 请求超时未响应的做重发或标记失败。2.2 长连接和短连接的参数配置协议支持两种通信方式实际生产环境里长连接是绝对主流。长连接的核心参数有三个心跳间隔 C、响应超时 T、重发次数 N。文档给出的建议值是 C3 分钟T60 秒N3。这三个参数的含义要拆开看C3 分钟链路上没有业务数据时每隔 3 分钟发一个 CMPP_ACTIVE_TEST 链路检测包。注意这是“没有数据包发送时”才发有正常业务流量的时候不需要额外心跳。T60 秒发出任何消息包括心跳和业务消息后等 60 秒没收到响应就重发。N3连续发 3 次都没响应断开连接。滑动窗口 W 默认建议 16意思是接收方在应答前最多同时收到 16 条消息。这个值直接影响吞吐量设太小浪费带宽设太大对端处理不过来会丢包。我一般会根据对端网关的实际处理能力做压测从 16 开始往上调观察响应延迟和超时率。短连接的模式完全不同每次数据交互都新建 TCP 连接发完一对请求-响应就断开。端口号也不一样长连接 SP 与网关之间用 7890短连接用 7900网关之间长连接用 7930短信网关与汇接网关短连接用 9168。这些端口号在防火墙策略配置时经常被漏掉血泪经验是上线前一定跟运营商确认清楚他们侧开放的端口列表。2.3 异步应答机制的实际影响CMPP 的交互采用异步方式收到请求消息后立即回送响应不等业务处理完成。这个设计对客户端实现有直接影响——你不能在收到 CMPP_SUBMIT_RESP 后就认为短信已经送达用户了那个响应只代表网关收到了你的提交请求。真正的送达状态要通过 CMPP_DELIVER 消息异步回传或者你主动发 CMPP_QUERY 去查。很多新手在这里翻车业务代码里同步等待发送结果结果发现 CMPP_SUBMIT_RESP 的 Result0 只表示“网关已接收”用户实际有没有收到完全不知道。正确的做法是把发送和状态回执做成两个独立的异步流程用 Msg_Id 做关联。# 长连接心跳与超时重发的简化实现逻辑 import socket import struct import time import threading class CMPPConnection: def __init__(self, host, port, sp_id, shared_secret): self.host host self.port port self.sp_id sp_id self.shared_secret shared_secret self.sock None self.seq_id 0 self.pending {} # Sequence_Id - 请求上下文 self.heartbeat_interval 180 # C3分钟 self.response_timeout 60 # T60秒 self.max_retries 3 # N3 self.window_size 16 # W16 def connect(self): self.sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(self.response_timeout) self.sock.connect((self.host, self.port)) # 发送 CMPP_CONNECT 消息携带 MD5 认证串 self._send_connect() # 启动心跳线程 threading.Thread(targetself._heartbeat_loop, daemonTrue).start() def _heartbeat_loop(self): 无业务数据时每 C 秒发送链路检测包 last_active time.time() while True: time.sleep(1) if time.time() - last_active self.heartbeat_interval: self._send_active_test() last_active time.time() def _next_seq(self): self.seq_id (self.seq_id 1) % 0x7FFFFFFF return self.seq_id上面这段代码展示了长连接的核心骨架。heartbeat_interval对应协议里的 C 参数response_timeout对应 Tmax_retries对应 N。实际实现中_send_active_test发出后要把 Sequence_Id 注册到 pending 字典里如果在 T 秒内没收到 CMPP_ACTIVE_TEST_RESP就触发重发逻辑连续 N 次失败后关闭连接并触发重连。注意 Sequence_Id 是循环使用的一对请求和应答的流水号必须相同这是配对的关键。3. 消息结构拆解从消息头到 CMPP_SUBMIT 的字节布局3.1 消息头的 12 字节固定格式所有 CMPP 消息都以一个 12 字节的公共消息头开始这个头包含三个字段字段名字节数类型说明Total_Length4Unsigned Integer消息总长度含消息头消息体Command_Id4Unsigned Integer命令类型如 0x00000004 是 CMPP_SUBMITSequence_Id4Unsigned Integer消息流水号顺序累加步长 1循环使用解析的时候先读 12 字节用struct.unpack(!III, header)拿到三个无符号整数。Total_Length 决定了后续还要读多少字节的消息体。Command_Id 决定了用哪个消息体解析器。Sequence_Id 用来做请求-应答配对。这里有个容易踩的坑Total_Length 是包含消息头本身的。比如 CMPP_CONNECT 的消息体是 6161427 字节加上 12 字节消息头Total_Length 应该是 39。我见过有人写代码时把 Total_Length 算成纯消息体长度结果对端解析时读多或读少字节整个 TCP 流就错位了后续所有消息全部解析失败。3.2 CMPP_CONNECT 的认证计算CMPP_CONNECT 是 SP 向 ISMG 注册身份的第一步也是最容易出认证错误的地方。消息体结构如下字段字节数说明Source_Addr6SP 的企业代码如 901234AuthenticatorSource16MD5 认证串Version1版本号高 4 位主版本低 4 位次版本Timestamp4MMDDHHMMSS 格式的整型AuthenticatorSource 的计算公式是MD5(Source_Addr 9字节的0 shared_secret timestamp)。注意 Source_Addr 是 6 字节后面跟 9 个零字节然后是共享密钥最后是 10 位的时间戳字符串。拼出来的字节序列做 MD5得到 16 字节的摘要。import hashlib import struct import time def build_connect_body(sp_id, shared_secret): 构造 CMPP_CONNECT 消息体 # Source_Addr 固定 6 字节不足右补零 source_addr sp_id.ljust(6, \0).encode(ascii) # Timestamp 格式 MMDDHHMMSS timestamp time.strftime(%m%d%H%M%S) # AuthenticatorSource MD5(Source_Addr 9字节0 shared_secret timestamp) auth_source hashlib.md5( source_addr b\x00 * 9 shared_secret.encode(ascii) timestamp.encode(ascii) ).digest() # Version: 高4位主版本号低4位次版本号2.0 即 0x20 version 0x20 # Timestamp 转为 4 字节无符号整数 ts_int int(timestamp) body source_addr auth_source struct.pack(!BI, version, ts_int) return body这段代码里source_addr用ljust(6, \0)保证 6 字节auth_source是 16 字节的 MD5 摘要version用 0x20 表示 2.0 版ts_int是时间戳的整型表示。拼出来的消息体正好 27 字节。ISMG 侧收到后会做同样的 MD5 计算比对 AuthenticatorSource 是否一致。如果认证失败CMPP_CONNECT_RESP 的 Status 会返回 3认证错同时 AuthenticatorISMG 字段为空。3.3 CMPP_SUBMIT 的关键字段与群发限制CMPP_SUBMIT 是 SP 提交短信的核心消息字段最多也最容易填错。挑几个关键字段说Msg_Id8 字节SP 侧填空由 ISMG 在 CMPP_SUBMIT_RESP 中返回。这个 ID 的生成算法在文档里有详细定义高 6 位是月份接着 5 位是日5 位是小时6 位是分6 位是秒然后 22 位是网关代码最后 16 位是序列号。总共 64 位。你不需要自己生成但需要理解这个结构因为后续 CMPP_QUERY 和 CMPP_DELIVER 都用这个 Msg_Id 做关联。Registered_Delivery1 字节是否要求返回状态确认报告。0 不需要1 需要2 产生 SMC 话单仅计费用不发给用户。这里有个重要限制如果要求状态报告就不能群发。文档明确写了“若 SP 对于群发消息不要求状态报告的回送时才可以考虑群发否则必须逐条发送”。原因是状态报告需要按每条短信的 Msg_Id 回传群发时多条短信共享一个 Msg_Id状态报告无法区分。DestUsr_tl1 字节接收信息的用户数量小于 100 个。每个号码 21 字节所以群发 100 个号码的消息体长度会很大实际使用中一般控制在几十个以内。Msg_Fmt1 字节信息格式。0 是 ASCII3 是短信写卡4 是二进制8 是 UCS2 编码15 是含 GB 汉字。发中文短信一般用 15 或 8。用 15 的时候消息内容需要做 GB 编码转换用 8 的时候是 UCS2也就是 UTF-16BE。Msg_Length1 字节信息长度。Msg_Fmt0 时小于 160 字节其他格式小于等于 140 字节。注意这个长度是字节数不是字符数。一条中文短信在 UCS2 编码下最多 70 个字符因为每个字符占 2 字节。def build_submit_body(msg_id, service_id, fee_type, fee_code, src_id, dest_numbers, msg_content, msg_fmt15): 构造 CMPP_SUBMIT 消息体简化版省略部分字段 # Msg_Id 由 SP 填空8 字节全零 msg_id_bytes b\x00 * 8 # Pk_total 和 Pk_number 均为 1表示单条短信 pk_total 1 pk_number 1 # Registered_Delivery1 要求状态报告 registered_delivery 1 # Msg_level 信息级别一般填 0 msg_level 0 # Service_Id 10 字节业务类型标识 service_id_bytes service_id.ljust(10, \0).encode(ascii) # Fee_UserType0 对目的终端计费 fee_user_type 0 # Fee_terminal_Id 21 字节被计费用户号码填空表示无效 fee_terminal_id b\x00 * 21 # TP_pId 和 TP_udhi 一般填 0 tp_pid 0 tp_udhi 0 # Msg_src 6 字节信息内容来源 msg_src src_id.ljust(6, \0).encode(ascii) # FeeType 2 字节资费类别 fee_type_bytes fee_type.encode(ascii) # FeeCode 6 字节资费代码 fee_code_bytes fee_code.ljust(6, \0).encode(ascii) # ValId_Time 和 At_Time 各 17 字节填空表示立即发送 valid_time b\x00 * 17 at_time b\x00 * 17 # Src_Id 21 字节源号码服务代码 src_id_bytes src_id.ljust(21, \0).encode(ascii) # DestUsr_tl 和 Dest_terminal_Id dest_usr_tl len(dest_numbers) dest_terminal_ids b for num in dest_numbers: dest_terminal_ids num.ljust(21, \0).encode(ascii) # 消息内容编码 if msg_fmt 15: content_bytes msg_content.encode(gbk) elif msg_fmt 8: content_bytes msg_content.encode(utf-16-be) else: content_bytes msg_content.encode(ascii) msg_length len(content_bytes) # Reserve 8 字节保留 reserve b\x00 * 8 body (msg_id_bytes struct.pack(!BBB, pk_total, pk_number, registered_delivery) struct.pack(!B, msg_level) service_id_bytes struct.pack(!B, fee_user_type) fee_terminal_id struct.pack(!BB, tp_pid, tp_udhi) struct.pack(!B, msg_fmt) msg_src fee_type_bytes fee_code_bytes valid_time at_time src_id_bytes struct.pack(!B, dest_usr_tl) dest_terminal_ids struct.pack(!B, msg_length) content_bytes reserve) return body这段代码里每个字段的字节数都严格对应文档定义。msg_fmt15时用 GBK 编码msg_fmt8时用 UTF-16BE。dest_numbers是号码列表每个号码补齐到 21 字节。msg_length是编码后的字节长度不是字符长度。最后拼上 8 字节的 Reserve 字段。整个消息体构造完成后加上 12 字节消息头就是完整的 CMPP_SUBMIT 报文。3.4 CMPP_DELIVER 与状态报告的回传路径CMPP_DELIVER 是 ISMG 向 SP 送交短信的操作分两种情况一种是 MO 短信用户上行点播ISMG 把用户发送的内容推给 SP另一种是状态报告ISMG 把短信送达结果推给 SP。状态报告的场景更常见。当 SP 在 CMPP_SUBMIT 里设置了 Registered_Delivery1短信最终送达或失败后ISMG 会构造一条 CMPP_DELIVER 消息其中 Msg_Content 字段里包含状态报告的内容。状态报告的格式在附录里有定义通常是Msg_IdStatSubmit_timeDone_timeDest_terminal_IdSMSC_sequence这样的结构。SP 收到 CMPP_DELIVER 后必须回 CMPP_DELIVER_RESP否则 ISMG 会重发。这里有个坑CMPP_DELIVER_RESP 的 Msg_Id 必须和收到的 CMPP_DELIVER 的 Msg_Id 一致Sequence_Id 也要对应。如果回错了ISMG 会认为你没收到继续重推导致状态报告重复处理。4. 避坑与排查对接 CMPP 网关时最常见的五个翻车现场4.1 认证失败但错误码不明确现象CMPP_CONNECT_RESP 返回 Status3认证错但检查 SP_Id 和 shared_secret 都是对的。原因最常见的是 Timestamp 格式不对。文档要求 MMDDHHMMSS 格式的 10 位整型右对齐。有人用了 Unix 时间戳有人用了 12 位的时间字符串还有人忘了把字符串转成整型。另一个常见原因是 Source_Addr 没有补齐到 6 字节比如 SP_Id 是 901234 正好 6 位没问题但如果是 91234 只有 5 位必须左补零成 091234。解决打印出参与 MD5 计算的原始字节序列逐字节比对。特别注意 9 个零字节不能少shared_secret 是 ASCII 字符串不是十六进制。Timestamp 用time.strftime(%m%d%H%M%S)生成后转 int 再 pack 成 4 字节无符号整数。4.2 消息发出后无响应TCP 连接被重置现象CMPP_SUBMIT 发出后等不到 CMPP_SUBMIT_RESP连接直接断开。原因Total_Length 计算错误导致对端解析消息体时读到了错误的边界TCP 流错位后对端无法识别后续消息直接断开连接。另一个可能是消息体里某个字段的字节数不对比如 Dest_terminal_Id 没有补齐到 21 字节导致后续字段全部偏移。解决用 Wireshark 抓包看发出的十六进制报文。先确认前 4 字节的 Total_Length 是否等于实际发送的总字节数。然后按文档的字段表逐个核对偏移量。建议写一个解析函数把发出的报文再解析一遍对比字段值是否和预期一致。4.3 状态报告重复收到现象同一条短信的 CMPP_DELIVER 状态报告收到了多次。原因SP 侧处理 CMPP_DELIVER 后没有正确回 CMPP_DELIVER_RESP或者回的响应里 Msg_Id 和 Sequence_Id 不匹配ISMG 认为 SP 没收到触发重发机制。解决检查 CMPP_DELIVER_RESP 的构造逻辑确保 Msg_Id 和收到的 CMPP_DELIVER 完全一致。Sequence_Id 也要对应。另外处理状态报告的业务逻辑要做幂等用 Msg_Id 做去重防止重复更新业务状态。4.4 长连接心跳超时导致频繁断连现象连接建立后每隔几分钟就断开重连业务日志里大量 CMPP_ACTIVE_TEST 超时。原因心跳间隔 C 设置得太短或者对端网关的心跳策略和你的不一致。文档建议 C3 分钟但有些网关实际要求更短或更长。另外如果心跳包发出后 T60 秒内没收到响应就重发连续 N3 次失败才断连但有人把 N 设成了 1一次超时就断。解决先跟运营商确认他们侧的心跳参数建议值。然后检查自己的心跳线程是否被业务逻辑阻塞导致没有按时发送。建议把心跳和业务收发放在不同的线程或协程里避免相互影响。4.5 中文短信乱码现象用户收到的短信内容显示为乱码。原因Msg_Fmt 和实际编码不匹配。比如设置了 Msg_Fmt15GB 汉字但消息内容用了 UTF-8 编码或者设置了 Msg_Fmt8UCS2但内容用了 GBK。另一种可能是 Msg_Length 填的是字符数而不是字节数导致对端截断了内容。解决统一编码策略。如果走 GB 通道Msg_Fmt15内容用 GBK 编码Msg_Length 是 GBK 字节数。如果走 UCS2 通道Msg_Fmt8内容用 UTF-16BE 编码Msg_Length 是 UTF-16BE 字节数。发送前用len(content.encode(gbk))或len(content.encode(utf-16-be))确认字节长度。5. 进阶技巧用 CMPP_QUERY 做发送状态补偿和 Msg_Id 反查实际生产环境里状态报告丢失是常有的事。网络抖动、网关重启、SP 侧处理超时都可能导致 CMPP_DELIVER 没收到。这时候不能干等得主动去查。CMPP_QUERY 就是干这个的。CMPP_QUERY 的消息体很简单8 字节的 Msg_Id 1 字节的 Query_Type。Query_Type0 表示按 Msg_Id 查询Query_Type1 表示按时间查询。一般用 0把之前发送时记录的 Msg_Id 填进去ISMG 会返回 CMPP_QUERY_RESP里面包含当前的状态。但这里有个前提你必须在发送 CMPP_SUBMIT 时就把 CMPP_SUBMIT_RESP 返回的 Msg_Id 存下来和业务流水号做关联。我一般会在数据库里建一张短信发送记录表字段包括业务流水号、Msg_Id、目标号码、发送时间、状态、状态报告时间。发送时插入记录收到 CMPP_SUBMIT_RESP 后回填 Msg_Id收到 CMPP_DELIVER 状态报告后更新状态。def query_message_status(conn, msg_id): 通过 CMPP_QUERY 主动查询短信状态 # 构造 CMPP_QUERY 消息体 # Msg_Id 8 字节 Query_Type 1 字节 query_type 0 # 按 Msg_Id 查询 body struct.pack(!Q, msg_id) struct.pack(!B, query_type) # 构造消息头 seq_id conn._next_seq() total_length 12 len(body) header struct.pack(!III, total_length, 0x00000006, seq_id) # 0x06 是 CMPP_QUERY # 发送并等待响应 conn.sock.send(header body) # 响应处理逻辑解析 CMPP_QUERY_RESP提取 Msg_Id 和 Result # Result 字段含义与 CMPP_SUBMIT_RESP 类似 return seq_id这段代码展示了 CMPP_QUERY 的基本构造。struct.pack(!Q, msg_id)把 8 字节的 Msg_Id 打包成大端无符号整数query_type0表示按消息 ID 查询。消息头的 Command_Id 是 0x00000006。发出后等待 CMPP_QUERY_RESP响应里会包含当前的状态信息。实际使用中我会做一个定时补偿任务每隔 5 分钟扫描发送记录表把超过 10 分钟还没有状态报告的记录捞出来逐条发 CMPP_QUERY 去查。查到结果后更新状态如果查询也失败就标记为“状态未知”走人工介入流程。另一个技巧是用 CMPP_QUERY 做 Msg_Id 的反查。有时候你只拿到了一个 Msg_Id想知道它对应哪条业务短信就可以用 Query_Type0 去查ISMG 返回的响应里会带上目标号码和提交时间通过这些信息反查业务表就能定位到具体记录。还有一个容易被忽略的点CMPP_QUERY 本身也有频率限制。不要短时间内对同一个 Msg_Id 反复查询ISMG 可能会限流。我一般设置查询间隔至少 30 秒同一个 Msg_Id 最多查 3 次3 次都查不到就放弃标记为异常。从那以后我每次对接新的 CMPP 网关都强制走一遍完整的联调流程先发 CMPP_CONNECT 确认认证通过再发一条 CMPP_SUBMIT 确认能收到 RESP然后等状态报告确认 CMPP_DELIVER 能正常回执最后发 CMPP_QUERY 确认主动查询通道可用。这四步走完基本能覆盖 90% 的对接问题。希望帮到你。本文还有配套的精品资源点击获取