简介这份PDF是中国移动短信网关通讯协议CMPP2.0的完整技术规范文档面向从事短信业务开发的工程师、SP服务商技术人员及通信协议学习者用于解决第三方平台接入中国移动短信网络时的接口对接与消息交互问题。文档系统梳理了协议的范围、缩略语、网络结构、功能概述、协议栈与通信方式并重点展开消息定义部分涵盖CMPP_CONNECT、CMPP_TERMINATE、CMPP_SUBMIT、CMPP_QUERY、CMPP_DELIVER、CMPP_CANCEL等命令的消息头格式、参数结构与应答机制同时涉及长连接与短连接、端口号、心跳与错误处理等细节。资源包内共1个PDF文件大小约478KB内容为2002年4月发布的V2.0版本原文目录层级清晰便于按章节查阅。目前已有79人学习适合需要理解CMPP协议报文结构、排查短信提交与状态查询问题的开发者作为案头参考。1. 中国移动短信网关通讯协议 CMPP2.0从教案 PDF 到能跑通的 SP 接入手里只有一份《教案之中国移动短信网关通讯协议cmpp2.0.pdf》很多人第一反应是「这不就是个教学讲义吗能拿来对接真实网关」——我一开始也这么想直到被一个 SP 短信下发项目按在地上摩擦了两周。CMPP2.0China Mobile Peer to Peer是中国移动短信网关与 SP服务提供商之间的事实标准协议跑在 TCP 长连接上负责短信提交、状态报告、上行短信这几件事。它不像 HTTP 那样随手 curl 就能调握手、心跳、序列号、字节序任何一处对不上网关直接静默断链日志里连个像样的报错都不给你。这份教案 PDF 的价值在于把协议字段和交互流程讲全了但真正落地时你需要的是「照着字段表把二进制包拼出来、把连接维持住、把状态报告对上号」。这篇写给要接短信网关的后端和运维从协议结构讲到最小可跑客户端再到那些教案里不会写的踩坑点。2. CMPP2.0 协议结构拆解为什么它必须用二进制而不是文本2.1 消息头 12 字节所有交互的地基CMPP2.0 的每一条消息都由「消息头 消息体」组成消息头固定 12 字节这是整个协议最容易翻车的地方因为它涉及字节序和长度自包含。字段定义如下字段长度说明Total_Length4 字节整个消息含消息头的总长度Command_ID4 字节命令类型如 0x00000001 是 ConnectSequence_ID4 字节序列号请求与响应必须一致Msg_Body变长具体命令的消息体关键点所有多字节整数都是网络字节序大端。教案里通常只写「4 字节整数」不会强调字节序但 Java 的DataOutputStream.writeInt()默认就是大端Python 得用struct.pack(I, x)C 里得用htonl()。我见过有人用 Python 的struct.pack(I, x)本机小端拼包连上后网关直接不响应查了一整天才发现是字节序问题。Total_Length是自包含的——它包含消息头自身的 12 字节。这个设计让接收方可以先读 4 字节拿到总长再决定后续读多少。如果你算长度时漏掉了消息头网关会认为包不完整一直等后续字节表现为「连接建立了但没有任何响应」。2.2 核心命令类型SP 接入只需要这几个CMPP2.0 定义了十几种命令但一个标准的 SP 下行短信 状态报告 上行接收场景实际只需要下面这几个Command_ID名称方向用途0x00000001CMPP_CONNECTSP→网关建立连接认证0x80000001CMPP_CONNECT_RESP网关→SP连接认证响应0x00000002CMPP_TERMINATE双向断开连接0x00000004CMPP_SUBMITSP→网关提交短信0x80000004CMPP_SUBMIT_RESP网关→SP提交响应0x00000008CMPP_DELIVER网关→SP投递上行短信或状态报告0x80000008CMPP_DELIVER_RESPSP→网关投递响应0x00000007CMPP_ACTIVE_TEST双向心跳保活0x80000007CMPP_ACTIVE_TEST_RESP双向心跳响应注意Command_ID的最高位请求命令最高位是 0响应命令最高位是 1即0x80000000 | 原命令。这个规律让你在解析时能快速判断收到的是请求还是响应。2.3 为什么是二进制而不是文本协议有人会问为什么不用 JSON 或 XML 这种好调试的格式答案是短信网关对吞吐和延迟极度敏感。二进制协议省去了文本解析开销固定头部让接收方能快速分流。代价就是调试痛苦——你没法直接cat出来看必须用抓包工具或自己写解析器。这也是为什么教案 PDF 里那些字段表如此重要它是你手写编解码器的唯一依据。3. 用 Python 手写一个最小 CMPP2.0 客户端3.1 环境准备与依赖选择我一般用 Python 做协议验证因为struct模块处理二进制非常直接。不需要额外依赖标准库足够。如果你要上生产可以考虑用asyncio做异步但验证阶段同步阻塞就够了。import socket import struct import time import threading # CMPP2.0 命令 ID 常量 CMPP_CONNECT 0x00000001 CMPP_CONNECT_RESP 0x80000001 CMPP_SUBMIT 0x00000004 CMPP_SUBMIT_RESP 0x80000004 CMPP_DELIVER 0x00000008 CMPP_DELIVER_RESP 0x80000008 CMPP_ACTIVE_TEST 0x00000007 CMPP_ACTIVE_TEST_RESP 0x80000007 CMPP_TERMINATE 0x00000002这段定义了协议里用到的命令常量。0x80000000是响应标志位所有响应命令都是请求命令加上这个位。实际对接时网关地址、端口、SP 的Source_Addr、Shared_Secret由运营商提供这些不在代码里硬编码用配置文件或环境变量传入。3.2 消息头打包与解包函数def pack_header(total_length, command_id, sequence_id): 打包 12 字节消息头全部大端 return struct.pack(III, total_length, command_id, sequence_id) def unpack_header(data): 解包消息头返回 (total_length, command_id, sequence_id) if len(data) 12: raise ValueError(数据不足 12 字节无法解析消息头) return struct.unpack(III, data[:12])pack_header用III格式串表示大端三个I表示三个无符号 4 字节整数。unpack_header做反向操作。这里有个细节total_length必须等于 12 加上消息体长度算错会导致网关解析失败。我习惯在打包消息体之前先算好总长而不是先拼消息体再回头补长度那样容易漏算。3.3 CMPP_CONNECT 认证包构造连接认证是第一步也是最容易失败的一步。CMPP_CONNECT的消息体结构是Source_Addr6 字节SP 编号AuthenticatorSource16 字节MD5 摘要Version1 字节Timestamp4 字节。import hashlib def build_connect(source_addr, shared_secret, timestamp): 构造 CMPP_CONNECT 消息 source_addr: SP 企业代码6 字节不足补 \x00 shared_secret: 网关分配的共享密钥 timestamp: 4 字节整数格式 MMDDHHMMSS # Source_Addr 固定 6 字节不足补零 addr_bytes source_addr.encode(ascii)[:6].ljust(6, b\x00) # AuthenticatorSource MD5(Source_Addr 9个0 Shared_Secret Timestamp) # 注意这里的 Source_Addr 是原始字符串不是补零后的 ts_bytes struct.pack(I, timestamp) raw source_addr.encode(ascii) b\x00 * 9 shared_secret.encode(ascii) ts_bytes auth_source hashlib.md5(raw).digest() # 消息体6 16 1 4 27 字节 body addr_bytes auth_source struct.pack(B, 0x20) ts_bytes total_len 12 len(body) header pack_header(total_len, CMPP_CONNECT, 1) return header bodyAuthenticatorSource的算法是 CMPP2.0 里最容易被写错的地方。教案里通常写「MD5(Source_Addr 9 个 0 Shared_Secret Timestamp)」但那个「9 个 0」是字节 0x00不是字符0。我见过有人写成b000000000结果认证一直返回错误码 1认证失败。另外Timestamp是 4 字节整数格式是MMDDHHMMSS比如 3 月 15 日 14 点 30 分 00 秒就是0315143000但它是作为整数打包的不是字符串。Version字段填0x20表示 CMPP2.0填0x30表示 CMPP3.0。如果你拿到的网关是 2.0填错版本号也会认证失败。3.4 发送短信与解析状态报告认证通过后就可以发CMPP_SUBMIT了。这个包的消息体字段很多但核心是这几个Msg_Id8 字节由 SP 生成、Pk_total1 字节短信总条数、Pk_number1 字节当前条序号、Registered_Delivery1 字节是否要状态报告、Msg_Level1 字节、Service_Id10 字节、Fee_UserType1 字节、Fee_terminal_Id21 字节、TP_pId1 字节、TP_udhi1 字节、Msg_Fmt1 字节、Msg_src6 字节、FeeType2 字节、FeeCode6 字节、ValId_Time17 字节、At_Time17 字节、Src_Id21 字节、DestUsr_tl1 字节、Dest_terminal_Id21×N 字节、Msg_Length1 字节、Msg_Content变长。def build_submit(msg_id, src_id, dest_number, content, service_id): 构造 CMPP_SUBMIT 消息单条短信场景 # Msg_Id 8 字节SP 自定义通常用时间戳序列号 msg_id_bytes struct.pack(Q, msg_id) # 固定字段填充 pk_total struct.pack(B, 1) pk_number struct.pack(B, 1) registered_delivery struct.pack(B, 1) # 需要状态报告 msg_level struct.pack(B, 0) service_id_bytes service_id.encode(ascii)[:10].ljust(10, b\x00) fee_user_type struct.pack(B, 0) fee_terminal_id b\x00 * 21 tp_pid struct.pack(B, 0) tp_udhi struct.pack(B, 0) msg_fmt struct.pack(B, 15) # 15 表示 GBK 编码 msg_src b\x00 * 6 fee_type b00 fee_code b000000 valid_time b\x00 * 17 at_time b\x00 * 17 src_id_bytes src_id.encode(ascii)[:21].ljust(21, b\x00) # 目标号码DestUsr_tl 是号码个数每个号码 21 字节 dest_bytes dest_number.encode(ascii)[:21].ljust(21, b\x00) dest_usr_tl struct.pack(B, 1) # 短信内容GBK 编码Msg_Length 是字节长度 content_bytes content.encode(gbk) msg_length struct.pack(B, len(content_bytes)) body (msg_id_bytes pk_total pk_number registered_delivery msg_level service_id_bytes fee_user_type fee_terminal_id tp_pid tp_udhi msg_fmt msg_src fee_type fee_code valid_time at_time src_id_bytes dest_usr_tl dest_bytes msg_length content_bytes) total_len 12 len(body) header pack_header(total_len, CMPP_SUBMIT, 2) return header bodyMsg_Fmt填 15 表示 GBK 编码填 8 表示 UCS2 编码。国内短信网关绝大多数用 GBK但如果你发的是纯英文用 ASCII 也能过。Msg_Length是字节长度不是字符数——一个中文字符在 GBK 里占 2 字节所以「你好」的Msg_Length是 4。这个字段填错会导致短信内容截断或乱码。Registered_Delivery填 1 表示要求网关回状态报告。状态报告是通过CMPP_DELIVER命令推送给 SP 的里面包含Msg_Id、Stat状态码、Submit_time、Done_time等字段。你需要把Msg_Id和之前提交时生成的对应起来才能知道哪条短信发送成功了。3.5 心跳保活与断线重连CMPP2.0 连接空闲超过一定时间通常是 30 秒到 60 秒会被网关断开。你需要定期发CMPP_ACTIVE_TEST网关回CMPP_ACTIVE_TEST_RESP。def heartbeat_loop(sock, interval30): 心跳线程每 interval 秒发一次 ACTIVE_TEST seq 100 while True: time.sleep(interval) try: body b\x00 # ACTIVE_TEST 消息体只有 1 字节保留字段 total_len 12 len(body) packet pack_header(total_len, CMPP_ACTIVE_TEST, seq) body sock.sendall(packet) seq 1 except Exception as e: print(f心跳发送失败: {e}) break心跳的Sequence_ID要递增网关不强制要求连续但递增便于排查。如果心跳连续几次没收到响应基本可以判定连接已断需要重连。重连时要重新走CMPP_CONNECT认证流程不能直接复用旧连接。4. 对接真实网关时的避坑清单4.1 认证失败但错误码不明确现象CMPP_CONNECT_RESP返回Status1但教案里只写「1 表示认证失败」不告诉你具体哪里错了。原因AuthenticatorSource计算错误是最常见的其次是Source_Addr和网关登记的不一致或者Timestamp格式不对。解决先确认Source_Addr和Shared_Secret与运营商提供的一字不差。然后检查 MD5 输入Source_Addr用原始字符串不补零中间 9 个字节是0x00Timestamp是 4 字节大端整数。我习惯把 MD5 输入打印成 hex和运营商给的测试向量比对。4.2 短信提交成功但收不到状态报告现象CMPP_SUBMIT_RESP返回Result0成功但迟迟收不到CMPP_DELIVER推送的状态报告。原因Registered_Delivery字段没填 1或者网关配置里没开状态报告回推。另外状态报告是通过CMPP_DELIVER的Registered_Delivery字段区分的——如果这个字段是 0表示上行短信是 1表示状态报告。解决确认CMPP_SUBMIT里Registered_Delivery1。然后检查CMPP_DELIVER的解析逻辑状态报告的Msg_Content前几个字节是Msg_Id后面跟着Stat、Submit_time、Done_time、Dest_terminal_Id、SMSC_sequence。别把状态报告当上行短信处理了。4.3 长短信拼接失败现象发送超过 70 个中文字符的短信接收方看到的是乱序或截断的内容。原因CMPP2.0 本身不负责长短信拼接需要 SP 自己用TP_udhi和Pk_total/Pk_number做分片。TP_udhi1表示消息头里带 UDH用户数据头UDH 里包含分片序号和总片数。解决长短信要拆成多条CMPP_SUBMIT每条Pk_total是总片数Pk_number是当前片序号TP_udhi1并在Msg_Content前面加上 6 字节 UDH05 00 03 XX YY ZZ其中XX是分片参考号同一条长短信的所有分片相同YY是总片数ZZ是当前片序号。这个 UDH 格式在教案里通常一笔带过但不写对接收方就拼不起来。4.4 连接被网关主动断开现象连接建立后几分钟内被网关断开日志显示Connection reset by peer。原因心跳间隔太长或者Sequence_ID回绕后重复或者发送了网关不认识的命令。解决心跳间隔设为 30 秒以内。Sequence_ID用 4 字节无符号整数从 1 开始递增回绕到 0 后继续。如果网关对Sequence_ID有连续性要求确保请求和响应的Sequence_ID一致。另外别在认证成功前发CMPP_SUBMIT网关会直接断链。4.5 中文乱码现象短信内容里的中文变成问号或方块。原因Msg_Fmt和实际编码不匹配。Msg_Fmt15要求 GBKMsg_Fmt8要求 UCS2。如果你用 UTF-8 编码内容但填了 15网关按 GBK 解码就会乱。解决国内网关统一用 GBKMsg_Fmt15内容用content.encode(gbk)。如果内容里有 GBK 不支持的字符比如 emoji要么过滤掉要么改用 UCS2 编码并填Msg_Fmt8但 UCS2 下Msg_Length是字符数×2。5. 从教案到生产把 CMPP2.0 客户端做成可维护的组件5.1 用状态机管理连接生命周期手写脚本验证通过后下一步是把它变成可维护的组件。我一般用状态机管理连接DISCONNECTED → CONNECTING → CONNECTED → AUTHENTICATED → READY。每个状态对应不同的允许操作比如READY才能发CMPP_SUBMIT。这样出问题时看一眼当前状态就知道卡在哪一步。class CMPPClient: def __init__(self, host, port, source_addr, shared_secret): self.host host self.port port self.source_addr source_addr self.shared_secret shared_secret self.sock None self.state DISCONNECTED self.seq 0 def next_seq(self): self.seq (self.seq 1) 0xFFFFFFFF return self.seq def connect(self): self.sock socket.create_connection((self.host, self.port), timeout10) self.state CONNECTED # 发送 CMPP_CONNECT等待 CMPP_CONNECT_RESP # ... self.state AUTHENTICATEDnext_seq用位与操作保证Sequence_ID在 4 字节范围内回绕。状态字段让你在日志里能快速定位问题——如果日志显示一直卡在CONNECTING那就是 TCP 层没通卡在AUTHENTICATED之前那就是认证包有问题。5.2 消息 ID 的生成与映射Msg_Id是 8 字节CMPP2.0 规定它由 SP 生成格式通常是Msg_Id 网关代码(4字节) 时间(4字节) 序列(4字节)但实际只要全局唯一即可。我一般用时间戳(4字节) 自增序列(4字节)拼成 8 字节。关键是要维护一个Msg_Id → 业务ID的映射表因为状态报告回来时只带Msg_Id你需要知道它对应哪条业务短信。import time class MsgIdGenerator: def __init__(self): self.counter 0 def generate(self): self.counter (self.counter 1) 0xFFFFFFFF ts int(time.time()) 0xFFFFFFFF return (ts 32) | self.counter这个生成器把时间戳放高 32 位计数器放低 32 位保证同一秒内不重复跨秒也不重复。映射表用 Redis 或本地字典都行关键是状态报告回来时能查到。5.3 状态报告的异步处理状态报告是网关主动推送的你的客户端需要有一个独立的接收线程收到CMPP_DELIVER后先回CMPP_DELIVER_RESP再把状态报告丢到业务队列里异步处理。别在接收线程里做耗时操作否则会阻塞后续消息。def receive_loop(self): while self.state AUTHENTICATED: header self.sock.recv(12) if len(header) 12: break total_len, cmd_id, seq unpack_header(header) body_len total_len - 12 body b while len(body) body_len: chunk self.sock.recv(body_len - len(body)) if not chunk: break body chunk if cmd_id CMPP_DELIVER: # 先回响应 resp pack_header(12 8, CMPP_DELIVER_RESP, seq) struct.pack(Q, msg_id) struct.pack(B, 0) self.sock.sendall(resp) # 再异步处理状态报告 self.handle_deliver(body)recv(12)先读消息头拿到总长再循环读消息体这是处理 TCP 粘包的标准做法。CMPP_DELIVER_RESP的消息体是 8 字节Msg_Id 1 字节ResultResult0表示成功接收。5.4 监控指标别等用户投诉才知道短信没发出去生产环境必须监控这几个指标连接状态是否AUTHENTICATED、心跳延迟、CMPP_SUBMIT_RESP的Result分布、状态报告的Stat分布、消息队列积压量。我一般用 Prometheus 打点Result ! 0和Stat ! DELIVRD都触发告警。教案 PDF 不会讲这些但没有监控的短信网关就是黑匣子出了问题只能靠猜。6. 一个容易被忽略的细节Sequence_ID 与并发请求的对应关系CMPP2.0 是异步协议你可以在一个连接上并发发多条CMPP_SUBMIT网关的响应不保证按顺序回来。这时候Sequence_ID就是你唯一的后悔药——你必须用Sequence_ID把请求和响应配对而不是靠顺序。我踩过的坑早期图省事发一条等一条响应Sequence_ID固定不变。测试环境没问题上了生产并发一高响应全乱套CMPP_SUBMIT_RESP里的Sequence_ID对不上导致大量短信被误判为失败。后来改成每个请求分配独立Sequence_ID用一个dict存seq → 请求上下文收到响应后按seq取出来处理问题才解决。class PendingRequests: def __init__(self): self.map {} def add(self, seq, context): self.map[seq] context def pop(self, seq): return self.map.pop(seq, None)这个PendingRequests结构很简单但它是并发场景下不丢响应的关键。context里存业务 ID、发送时间、重试次数。如果某个seq超过 30 秒没收到响应就触发超时重试或告警。另一个细节是Sequence_ID的回绕。4 字节无符号整数最大0xFFFFFFFF回绕到 0 后继续递增。如果你的PendingRequests里还存着旧的seq回绕后可能覆盖。解决办法是回绕时清空或检查冲突实际中 40 亿条消息才回绕一次概率极低但代码里加个判断不亏。最后说个习惯每次对接新网关我都会先用教案 PDF 里的字段表手写一个最小CMPP_CONNECT包用tcpdump抓包确认字节流和预期一致再往上叠业务逻辑。这个笨办法帮我省了至少三次「以为是代码问题、其实是网关配置问题」的排查时间。协议对接没有捷径字节对上了一切就都对了。希望帮到你。本文还有配套的精品资源点击获取