2026/9/12 12:51:53

短信PDU编码解码全解析:从7-bit移位到Python实现

短信PDU编码解码全解析:从7-bit移位到Python实现 简介短信PDU编码解码是GSM网络中短信传输的核心技术这份资源面向通信专业学生、嵌入式开发者和短信功能开发者用C语言实现PDU编解码主要流程。资源共6个文件包含2个C源码、1个头文件和1个Makefile另有2个源码备份文件压缩包仅12KB结构精简无冗余适合直接阅读源码逐行对照理解PDU报文结构、电话号码转换、短信中心地址处理、7位/16位字符集编码、校验和计算与解码还原等关键环节。目前已有1791人学习下载。源码中通过code_conversion等模块清晰演示了从手机号与短信内容到二进制PDU数据的生成过程也覆盖了解码时的字段解析与字符集还原逻辑。对想理解短信协议栈底层原理、或需要自行实现/调试PDU编解码功能的开发者来说是一份便于快速上手的入门参考与排错借鉴。1. 短信 PDU 编码解码是短信调试的最后一公里接入短信服务商调试时最让人头疼的不是 HTTP 接口超时而是收到一串类似0891683108200705F011000D91683112345678F800084E2D65877F的十六进制串。短信 PDUProtocol Data Unit就是短信在 GSM 网络里真正传输的二进制报文格式短信平台、短信猫、安卓应用层看到的“短信内容”最终都要编码成 PDU 才能在空口传输。做短信登录和自定义短信验证码接入时验证码内容在 PDU 里是明文可查的这也让 PDU 编码解码成为测试短信接口抓包时的必备技能。这篇文章会把 PDU 从第一个字节拆到最后一个字节给出可直接复制的编码解码代码以及短信中心号码、DCS、UDL 这些最容易踩坑的参数到底该怎么填。2. 短信 PDU 编码格式逐字段拆解从 SMSC 到 TP-UD 的二进制布局一条 SMS-SUBMIT手机发短信的 PDU 由两大部分组成SMSC 地址段和传输协议数据单元TPDU。TPDU 里依次是第一个八位组、TP-MR、目的地址、TP-PID、TP-DCS、TP-VP、TP-UDL、TP-UD。每个字段的长度和字节序都有明确规定下面从最常见的几个字段开始拆。2.1 先拆 SMSC短信中心号码为什么会变成 07 91 68 31 08 20 07 F0SMSC 地址段的结构是三段式第一个字节是 SMSC 地址总长度包含类型号第二个字节是地址类型 TON/NPI后面的字节是 BCD 编码的号码。以中国移动北京短信中心8613800270500为例号码去掉后是 13 位奇数位末尾补F变成8613800270500F每两位反转顺序86反转为6813反转为3180反转为0802反转为2070反转为070F反转为F0所以号码段是68 31 08 20 07 F0加上类型号91是 7 个字节长度字节填07完整结果是07916831082007F0网上能找到的短信中心号码一览表给的都是8613800270500这种十进制串转 PDU 时记得用同样规则。TON/NPI 的常见取值0x91表示国际格式 E.164 号码0x81表示国内格式未知。我用0x91居多短信中心对两者基本都兼容。下面这个函数可以直接用def smsc_to_pdu(number: str) - str: 将短信中心号码转为 PDU 的 SMSC 字段 number: 带号的国际格式如 8613800270500 返回: 完整SMSC字段如 07916831082007F0 if number.startswith(): number number[1:] if len(number) % 2 ! 0: number F bcd for i in range(0, len(number), 2): bcd number[i 1] number[i] length (len(number) // 2) 1 # BCD部分字节数 1个TON/NPI字节 return f{length:02X}91{bcd}参数说明number必须是带的完整国际号码函数内部把去掉后做反转length的计算方式是 BCD 字节数加 1因为91这个类型号也占用一个字节。返回的是十六进制字符串后续拼接 PDU 时直接用。需要注意短信中心号码是奇数位还是偶数位奇偶决定要不要补F这是最常见的错误来源。2.2 目的地址字段号码位数奇偶与 F 填充的两种结果目的地址字段与 SMSC 类似但更灵活。它由三部分组成目的号码长度十进制表示不包含类型号字节、TON/NPI 类型号、BCD 编码的号码。这里有一个容易混淆的点目的号码长度字段是“号码的十进制位数”不是 BCD 字节数也不是十六进制长度。比如发给1381234567811 位数字长度字段填0BTON/NPI 填81国内格式还是91国际格式取决于号码带不带86奇数位补F后做两位反转31 81 32 54 76 F8完整结果是0B813181325476F8def da_to_pdu(number: str) - str: 将目的号码转为 PDU 的 DA 字段 number: 11位国内号码如 13812345678 返回: 如 0B813181325476F8 ton_npi 91 if number.startswith() else 81 if number.startswith(): number number[1:] if len(number) % 2 ! 0: number F bcd for i in range(0, len(number), 2): bcd number[i 1] number[i] return f{len(number):02X}{ton_npi}{bcd}注意len(number)在补F之后取位数这样长度字段就直接等于补位后的字符数恰好十进制位数。有些设备厂商要求目的地址类型必须写91否则短信中心不路由这点在对接硬件短信猫时尤其常见遇到发不出去先检查 DA 段的第二字节而不是怀疑号码写错了。2.3 TP-DCS 决定了文本是谁7-bit、UCS2、8-bit 三选一TP-DCSData Coding Scheme是 PDU 里最影响内容的字段它告诉短信中心/手机用哪种字符集解析 TP-UD。常见取值如下表DCS 值含义适用场景0x00GSM 7-bit 默认字母表纯英文/数字短信长度可达 160 字符0x08UCS2 双字节编码中文短信、Emoji、混合字符0x048-bit 数据二进制短信、WAP Push0x107-bitClass 0闪信直接显示不存储0x18UCS2Class 0中文闪信短信登录验证码这种纯数字场景理论上可以用 7-bit但多数服务商的验证码短信模板里会带“验证码”三个字只要有一个中文字符就必须用 UCS2否则网关侧会把中文按 7-bit 解读成乱码。判断逻辑很简单先尝试将文本按 GSM 7-bit 字符集编码如果存在字符集外的字符就降级到 UCS2。注意 GSM 7-bit 只覆盖 ASCII 一部分像€、^、{这些字符在基础表里并没有需要扩展表转义。2.4 TP-VP 有效期和 TP-SRR一个字节换算成短信中心等待时间TP-VPValidity Period只在 SMS-SUBMIT 里出现取决于第一个八位组里的 TP-VPF 标志位。常用的是相对格式VPF10此时 TP-VP 只占一个字节取值范围 0 到 255映射关系如下VP 值有效期0x005 分钟0x01 - 0x8F(VP1) × 5 分钟最大 12 小时0x90 - 0xA712 小时 (VP-143) × 30 分钟0xA8 - 0xC4(VP-166) 天最大 30 天0xC5 - 0xFF(VP-192) 周最大 63 周填0xA7是 24 小时填0xA8是 2 天很多短信平台默认 24 小时就填0xA7。TP-SRR 是状态报告请求位置 1 后短信中心会在短信送达后返回一条 Delivery Report抓包定位“短信发出去了但用户没收到”这类问题时很有用代价是多占用一条下行消息配额。这两个字段都在第一个八位组里用位标志控制后面会结合代码讲。3. GSM 7-bit 移位编码手算一次就彻底理解打包规则PDU 的文本编码是坑最集中的地方尤其是 7-bit 编码。很多工程师会把 7-bit 理解为“每个字符占一个字节只是只用低 7 位”然后直接在字节里填 ASCII 码。这是错的7-bit 字符在 PDU 里是连续排列的位流字符之间没有字节边界必须通过移位把 7 个字符塞进 8 个字节里。理解这个移位过程比死记代码重要得多。3.1 为什么 7-bit 不能直接按字节发空口带宽约束GSM 网络的一条短信净荷是 140 字节1120 位。如果按 8 位一个字符传 ASCII只能传 140 个字符。但 GSM 03.38 规定的默认字母表只需要 7 位就能表示于是规范把 7 个字符的位连续拼接每 8 位切一个字节这样 1120 位可以承载 160 个字符。代价是编码和解码都必须做位运算而且位顺序是低字节序优先也就是每个字符的最低有效位先进入位流。这也是为什么短信接口抓包时明明发的是 “Hello”TP-UD 却是C8 32 9B FD 06而不是48 65 6C 6C 6F。看到这串数据不要慌先按 7-bit 解一次再对照字母表转换。3.2 用位运算实现 7-bit 打包Hello 得到 C8 32 9B FD 067-bit 打包的标准实现是用一个整数做累加器维护“当前累积了多少位”每处理一个字符就向左移动 7 位并写入。因为每个字符是 7 位总位宽始终小于 8 的整数倍所以需要把超过 8 位的部分拆成字节输出def pack_7bit(text: str) - list[int]: GSM 7-bit 打包 text: 普通ASCII字符串 返回: 字节列表如 Hello - [0xC8, 0x32, 0x9B, 0xFD, 0x06] data [] accumulator 0 bits 0 for ch in text: accumulator | (ord(ch) 0x7F) bits bits 7 while bits 8: data.append(accumulator 0xFF) accumulator 8 bits - 8 if bits 0: data.append(accumulator 0xFF) return data逐行解释 0x7F把字符限制在 7 位范围内 bits将当前字符放到累加器中的空闲位bits 7记录已占位数while 循环把满 8 位的部分作为字节弹出。以H0x48和e0x65为例H写入后bits7e左移 7 位后与累加器相加总位宽 14 位低 8 位 0xC8 先输出剩余 6 位留在累加器。继续处理l、l、o最终得到C8 32 9B FD 06。注意累加器的位顺序是 LSB first这和字节序直觉相反是绝大多数误解的根源。3.3 解包与边界填充位、无效字符和扩展表转义 0x1B解码是打包的逆操作从字节流里一点点抠出 7 位def unpack_7bit(data: bytes) - str: GSM 7-bit 解包 data: 编码后的字节序列 返回: 原始字符串 result [] accumulator 0 bits 0 for byte in data: accumulator | byte bits bits 8 while bits 7: result.append(chr(accumulator 0x7F)) accumulator 7 bits - 7 return .join(result)同样以C8 32 9B FD 06验证第一个字节0xC8的低 7 位是0x48正好是H剩余 1 位保留与下一个字节0x32左移 1 位后组成0x65即e。这里最容易出问题的是解包时多余的填充位最后一次循环后如果bits不足 7说明末尾有填充位直接丢弃即可。GSM 7-bit 还有一个扩展表转义字符0x1B。当文本里出现[、]、^、{、}、|、~、€这些字符时编码器会输出0x1B加一个转义值两个 7-bit 符号表示一个字符。比如[编码为0x1B 0x3C。解码时遇到0x1B必须读下一个 7-bit 值并查扩展表否则会把转义符号当成普通字符输出。扩展表对应的值需要单独维护一张表许多开源实现里都有gsm_ext_alphabet数组直接抄即可。4. 用 Python 实现短信 PDU 编解码的可靠版本理论拆完就该动手了。这里给出一套完整的 Python 编解码函数覆盖 SMS-SUBMIT 和 SMS-DELIVER 两种最常见场景编码后可以直接拼成 PDU 字符串发给短信猫或通过 AT 指令下发也可以用来解析抓包结果。4.1 构造 SMS-SUBMIT PDU 的完整函数SMS-SUBMIT 是手机向短信中心提交消息的格式也是大多数短信猫 AT 指令ATCMGS要求的 PDU。核心参数有短信中心、目标号码、文本内容、有效期、是否需要状态报告。常见做法是先把文本按字符集选型转为 7-bit 或 UCS2再拼装各字段。def encode_sms_submit(smsc: str, to: str, text: str, validity: int 0xA7, status_report: bool False) - str: 构造 SMS-SUBMIT PDU 字符串 smsc: 短信中心号码如 8613800270500 to: 目标号码如 13812345678 text: 短信内容 validity: 有效期值0xA7表示24小时 status_report: 是否请求状态报告 返回: 完整PDU十六进制字符串 # 确定 DCS 和 UD try: data pack_7bit(text) dcs 0x00 udl len(text) # 7-bit 编码 UDL 是字符数 except ValueError: ud text.encode(utf-16-be) data list(ud) dcs 0x08 # UCS2 编码 udl len(data) # UCS2 的 UDL 是字节数 # 第一个八位组 first 0x11 # MTI01, RD0, VPF10 if status_report: first | 0x20 # TP-SRR 置1 smsc_part smsc_to_pdu(smsc) da_part da_to_pdu(to) tpdu f{first:02X}00{da_part}00{dcs:02X}{validity:02X}{udl:02X} tpdu bytes(data).hex().upper() return smsc_part tpdu逻辑说明first初始值0x11二进制 00010001表示这是一条 SMS-SUBMITMTI01TP-VPF10相对有效期RD 位为 0 允许短信中心重复提交。TP-MR固定填00除非需要做消息引用匹配。TP-PID填00表示普通短信。status_reportTrue时把第一个八位组的 bit5 置 1短信中心就会返回状态报告。DCS 和 UDL 是联动的7-bit 时 UDL 填原始字符数UCS2 时 UDL 填编码后的字节数这个区别在纯数字验证码场景看不出问题一旦中文和英文混排就会立刻踩坑。4.2 解析短信中心下发的 SMS-DELIVER PDUSMS-DELIVER 是短信中心发给手机的格式推送短信、验证码下发都走这个方向。解析时要先跳过 SMSC 段再根据第一个八位组的 MTI 区分方向。下面是解析函数输入抓包拿到的完整 PDU输出发送方号码和文本内容def decode_sms_deliver(pdu: str) - dict: 解析 SMS-DELIVER PDU pdu: 完整PDU十六进制字符串 返回: 字典包含 sender、text、dcs、udh raw bytes.fromhex(pdu) pos 0 smsc_len raw[pos] pos 1 smsc_len first raw[pos] pos 1 # 解析发送方号码 sender_len raw[pos] pos 1 ton_npi raw[pos] pos 1 sender for i in range(pos, pos (sender_len 1) // 2): byte raw[i] sender chr((byte 0x0F) 0x30) high (byte 4) 0x0F if high ! 0x0F: sender chr(high 0x30) pos (sender_len 1) // 2 pos 1 # TP-PID dcs raw[pos] pos 1 udl raw[pos] pos 1 ud raw[pos:pos udl] if udl len(raw) else raw[pos:] if (dcs 0x0C) 0x08: # UCS2 编码 text ud.decode(utf-16-be, errorsreplace) else: text unpack_7bit(ud) text text[:udl] if (dcs 0x04) 0 else text return {sender: sender, text: text, dcs: dcs, ud: ud.hex().upper()}参数说明smsc_len是 SMSC 段的长度长度本身占一字节所以pos要加1 smsc_len。发送方号码的长度字段是十进制位数按 BCD 反转规则每字节拆两个数字。DCS 判断用了位与运算0x08的 bit3 为 1 表示 UCS2比直接比较等于0x08更稳妥因为 Class 0 的 UCS2 值是0x18bit3 同样是 1。4.3 关键参数表UDL、DCS、UDHI 三者的联调关系编解码时报错或乱码九成出在这三个参数上它们的换算关系必须成套考虑场景TP-DCSTP-UDL 含义TP-UD 长度纯英文短信无 UDH0x00字符数septet 数字节数 ceil(字符数×7/8)中文短信无 UDH0x08字节数 字符数×2与 UDL 相同7-bit UDH 长短信0x00TP-UDHI1TP-UD 总字节数包含 UDH 头额外计算UCS2 UDH0x08TP-UDHI1字节数包含 UDH 头7-bit 场景最容易出错UDL 填的是字符数但 TP-UD 实际占用的字节数略小于字符数。比如 9 个英文字符UDL09但 TP-UD 只有 8 字节。短信中心按 UDL 读取控制器如果你按字节数填08接收方就会少解一个字符。UCS2 则相反UDL 就是字节数一个汉字算两个字节。4.4 用抓包验证编码测试短信接口抓包时怎么对拍手写 PDU 后必须验证方法是用一个已知消息和短信中心回包的 PDU 做逐字节对拍。以短信登录验证码场景为例假设要给13812345678发 “Hi”短信中心8613800270500不申请状态报告24 小时有效python3 - EOF from pdu import encode_sms_submit print(encode_sms_submit(8613800270500, 13812345678, Hi)) EOF期望输出是0791683108200705F011000D913181325476F80000A702322A。逐字节拆开07 91 68 31 08 20 07 05 F0是 SMSC11是第一个八位组00是 MR0D 91 31 81 32 54 76 F8是目的地址00是 PID00是 DCSA7是 VP02是 UDL32 2A是 “Hi” 的 7-bit 编码。抓包工具里看到的 PDU 如果和这个结果一致说明编码方向没问题。不一致就先查 SMSC 的 BCD 反转和 DCS 值不要急着怀疑短信中心。5. 进阶技巧超长短信 UDH 拼接与 7-bit 转义坑最后处理两个真实项目里才会遇到的边角问题超过 70 个汉字或 160 个英文字符的短信怎么发以及 GSM 7-bit 扩展字符怎么保证不变形。5.1 长短信分片头部IEI00 的 UDH 构造公式超长短信必须由终端自行分片每一片在 TP-UD 开头加一个用户数据头UDHTP-UDHI 位置 1。分片头部格式是固定套路UDHL(1字节) IEI(1字节) IEDL(1字节) 参考号(1字节) 总片数(1字节) 当前片号(1字节)具体到代码UDHL0x05IEI0x008 位参考号的长短信标识IEDL0x03后面紧跟三个数据字节。拆分 160 字中文短信的代码骨架如下def split_long_ucs2(text: str, ref: int 0xCC) - list[str]: 将长短信拆成多片UCS2 PDU的UD部分 max_len 67 # 140 - 6字节UDH头 134字节UCS2下67个汉字 parts [] seg 0 for i in range(0, len(text), max_len): seg 1 chunk text[i:i max_len] udh bytes([0x05, 0x00, 0x03, ref, (len(text) max_len - 1) // max_len, seg]) ud chunk.encode(utf-16-be) parts.append((udh ud).hex().upper()) return parts注意 UDL 此时是len(udh) len(ud)的字节数UCS2 场景直接相加即可。如果是 7-bit 长短信UDH 的 6 字节要按 8 位写入位流文本按 7 位继续拼接TP-UDL 是最终打包后的总字节数不能直接填字符数。这个差异导致 7-bit 长短信的 UDL 计算比 UCS2 麻烦得多实际项目里更推荐统一用 UCS2 拆分虽然每条短信可容纳的字符数少一半但逻辑清晰不易错。5.2 发送前自检7-bit 转义和 UDL 核对清单最后一个常见坑是扩展字符表。短信内容里如果只有 ASCII 的[、]、^、{、}、|、~、€这些字符7-bit 编码器必须把它们映射成0x1B加扩展值。很多团队直接用ord(ch) 0x7F打包结果[0x5B会被当成{0x7B之前的某个基础字母表字符发送接收方看到的是完全不同的符号。发送前用这个检查脚本兜底python3 - EOF text 验证码[123] try: bad [c for c in text if ord(c) 0x7F] if bad: raise ValueError(f非7-bit字符: {bad}) print(7-bit OK) except ValueError as e: print(f需要UCS2: {e}) EOF脚本逻辑很直接文本里只要出现大于0x7F的字符就转 UCS2但这只能兜底中文场景挡不住扩展表字符。真正稳妥的做法是在打包函数里维护一张 GSM 基础字母表映射表遇到0x1B转义字符时输出两个 septet而不是简单截断。短信转发器、短信中心网关的日志里乱码基本都来自这几个扩展字符排查优先级比检查短信中心号码更高。本文还有配套的精品资源点击获取