拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CMPP2.0协议实战:从教案PDF到可运行SP短信网关客户端

CMPP2.0协议实战:从教案PDF到可运行SP短信网关客户端

简介:这份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.0(China Mobile Peer to Peer)是中国移动短信网关与 SP(服务提供商)之间的事实标准协议,跑在 TCP 长连接上,负责短信提交、状态报告、上行短信这几件事。它不像 HTTP 那样随手 curl 就能调,握手、心跳、序列号、字节序任何一处对不上,网关直接静默断链,日志里连个像样的报错都不给你。这份教案 PDF 的价值在于把协议字段和交互流程讲全了,但真正落地时,你需要的是「照着字段表把二进制包拼出来、把连接维持住、把状态报告对上号」。这篇写给要接短信网关的后端和运维:从协议结构讲到最小可跑客户端,再到那些教案里不会写的踩坑点。

2. CMPP2.0 协议结构拆解:为什么它必须用二进制而不是文本

2.1 消息头 12 字节:所有交互的地基

CMPP2.0 的每一条消息都由「消息头 + 消息体」组成,消息头固定 12 字节,这是整个协议最容易翻车的地方,因为它涉及字节序和长度自包含。字段定义如下:

字段长度说明
Total_Length4 字节整个消息(含消息头)的总长度
Command_ID4 字节命令类型,如 0x00000001 是 Connect
Sequence_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_Addr(6 字节,SP 编号)+AuthenticatorSource(16 字节,MD5 摘要)+Version(1 字节)+Timestamp(4 字节)。

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 + body

AuthenticatorSource的算法是 CMPP2.0 里最容易被写错的地方。教案里通常写「MD5(Source_Addr + 9 个 0 + Shared_Secret + Timestamp)」,但那个「9 个 0」是字节 0x00,不是字符'0'。我见过有人写成b'000000000',结果认证一直返回错误码 1(认证失败)。另外Timestamp是 4 字节整数,格式是MMDDHHMMSS,比如 3 月 15 日 14 点 30 分 00 秒就是0315143000,但它是作为整数打包的,不是字符串。

Version字段填0x20表示 CMPP2.0,填0x30表示 CMPP3.0。如果你拿到的网关是 2.0,填错版本号也会认证失败。

3.4 发送短信与解析状态报告

认证通过后,就可以发CMPP_SUBMIT了。这个包的消息体字段很多,但核心是这几个:Msg_Id(8 字节,由 SP 生成)、Pk_total(1 字节,短信总条数)、Pk_number(1 字节,当前条序号)、Registered_Delivery(1 字节,是否要状态报告)、Msg_Level(1 字节)、Service_Id(10 字节)、Fee_UserType(1 字节)、Fee_terminal_Id(21 字节)、TP_pId(1 字节)、TP_udhi(1 字节)、Msg_Fmt(1 字节)、Msg_src(6 字节)、FeeType(2 字节)、FeeCode(6 字节)、ValId_Time(17 字节)、At_Time(17 字节)、Src_Id(21 字节)、DestUsr_tl(1 字节)、Dest_terminal_Id(21×N 字节)、Msg_Length(1 字节)、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 = b'00' fee_code = b'000000' 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 + body

Msg_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, interval=30): """心跳线程,每 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返回Status=1,但教案里只写「1 表示认证失败」,不告诉你具体哪里错了。

原因:AuthenticatorSource计算错误是最常见的,其次是Source_Addr和网关登记的不一致,或者Timestamp格式不对。

解决:先确认Source_Addr和Shared_Secret与运营商提供的一字不差。然后检查 MD5 输入:Source_Addr用原始字符串(不补零),中间 9 个字节是0x00,Timestamp是 4 字节大端整数。我习惯把 MD5 输入打印成 hex,和运营商给的测试向量比对。

4.2 短信提交成功但收不到状态报告

现象:CMPP_SUBMIT_RESP返回Result=0(成功),但迟迟收不到CMPP_DELIVER推送的状态报告。

原因:Registered_Delivery字段没填 1,或者网关配置里没开状态报告回推。另外,状态报告是通过CMPP_DELIVER的Registered_Delivery字段区分的——如果这个字段是 0,表示上行短信;是 1,表示状态报告。

解决:确认CMPP_SUBMIT里Registered_Delivery=1。然后检查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_udhi=1表示消息头里带 UDH(用户数据头),UDH 里包含分片序号和总片数。

解决:长短信要拆成多条CMPP_SUBMIT,每条Pk_total是总片数,Pk_number是当前片序号,TP_udhi=1,并在Msg_Content前面加上 6 字节 UDH:05 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_Fmt=15要求 GBK,Msg_Fmt=8要求 UCS2。如果你用 UTF-8 编码内容但填了 15,网关按 GBK 解码就会乱。

解决:国内网关统一用 GBK,Msg_Fmt=15,内容用content.encode('gbk')。如果内容里有 GBK 不支持的字符(比如 emoji),要么过滤掉,要么改用 UCS2 编码并填Msg_Fmt=8,但 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), timeout=10) self.state = 'CONNECTED' # 发送 CMPP_CONNECT,等待 CMPP_CONNECT_RESP # ... self.state = 'AUTHENTICATED'

next_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 字节Result,Result=0表示成功接收。

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抓包确认字节流和预期一致,再往上叠业务逻辑。这个笨办法帮我省了至少三次「以为是代码问题、其实是网关配置问题」的排查时间。协议对接没有捷径,字节对上了,一切就都对了。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表