C#短信发送PDU模式实战:中文乱码、TPDU长度与UCS2编码拆解
简介:C#短信发送程序(pdu_class)是一份面向C#开发者、通信协议学习者与桌面应用开发者的实战型源码包,重点解决基于GSM 03.38标准构造与解析PDU、通过SMSC完成短信提交的问题。压缩包共45个文件、约484KB,以10个cs源码为核心,辅以exe、resources、config、resx、pdb等编译与配置资源,呈现出一个可运行的SMSCSharp解决方案结构。已有157人学习下载,适合用来理解短信发送的底层实现。内容围绕Submit PDU展开,涵盖7位、8位与UCS-2编码转换、手机号码与短信中心号码编码、长短信拆分与重组、发送逻辑封装、异常处理及测试场景等,读者可据此梳理PDU字段含义、调试发送流程,并借鉴窗口界面与配置管理的组织方式,提升对GSM通信协议和C#编码技巧的掌握。
1. PDU 模式发短信:Text 模式下中文为什么必然乱码
把 C# 短信发送程序接到 GSM 模块上,第一次发中文几乎都会失败:AT+CMGF=1 的 Text 模式看着简单,模块却按自己的字符集解释内容,中文过去就是一串问号,或者干脆回一个 +CMS ERROR。问题不在串口,也不在编码类,而是 Text 模式从头到尾没说明白“这段字节按哪个字符集解释”。PDU 模式把编码方式、正文长度、目标号码全部写进报文,一个 pdu_class 该干的活就是:号码转 BCD、正文按 7-bit 或 UCS2 打包、拼出 TPDU、算准长度,再交给 AT+CMGS。下面按报文结构、编码层、组包发送、踩坑现场、联调验证的顺序拆,适合手里已经有模块、要在工控机或上位机里做短信告警的人。
2. 拆一条 PDU:SCA、TPDU 与那个永远算不对的长度
一条短信在网络侧就是一个十六进制串,十几到一百四十字节不等。C# 短信发送程序写成 pdu_class 之后,真正的工作量集中在两件事:把字节拼对,把长度算对。前者查字段表就行,后者是返工重灾区——模块不会告诉你哪一位错了,只会丢一个 +CMS ERROR: 304 回来,然后你对着日志一位一位数。
PDU 分成两段:前面的 SCA(短信中心地址),后面的 TPDU(真正的报文)。SMS-SUBMIT 的字段顺序是固定的,少一个字节都发不出去:
| 顺序 | 字段 | 长度 | 示例 | 说明 |
|---|---|---|---|---|
| 1 | SCA 长度 | 1 字节 | 00 | 后面 SCA 字段的字节数(含 TOA),00 表示用模块里存的号码 |
| 2 | SCA 类型 | 1 字节 | 91 | 91 国际号、81 本地号,SCA 长度为 00 时这一段不存在 |
| 3 | SCA 号码 | 变长 | — | BCD 半字节交换 |
| 4 | 首字节 | 1 字节 | 11 | MTI=01 是 SUBMIT,VPF=10 表示带 1 字节有效期 |
| 5 | TP-MR | 1 字节 | 00 | 报文参考号 |
| 6 | TP-DA 长度 | 1 字节 | 0D | 号码的十进制位数,不含 + 和 TOA |
| 7 | TP-DA 类型 | 1 字节 | 91 | 同 SCA 类型 |
| 8 | TP-DA 号码 | 变长 | 683108001032F4 | BCD,奇数位补 F |
| 9 | TP-PID | 1 字节 | 00 | 普通点对点业务 |
| 10 | TP-DCS | 1 字节 | 08 | 决定 7-bit / UCS2 / 8-bit |
| 11 | TP-VP | 0/1/7 字节 | FF | 首字节 VPF 决定这一段存不存在 |
| 12 | TP-UDL | 1 字节 | 04 | 7-bit 填字符数,UCS2 填字节数 |
| 13 | TP-UD | ≤140 字节 | 4E2D6587 | 正文 |
后面所有代码,都以这条报文作为对照样本:给号码 +8613800001234 发两个汉字“中文”,完整 PDU 是0011000D91683108001032F40008FF044E2D6587,共 20 个字节。有了这个基准,任何一位对不上都能立刻定位。
2.1 SCA 写 00 还是写全:两种起始长度的差别
网上抄来的例子大量以0891或0791开头,那是带完整短信中心号码的写法:第一个字节 08 表示后面还有 8 个字节(1 字节 TOA 加 7 字节号码),所以整条报文比“00 开头”的版本多 9 个字节。两种写法都能发,但混着用就出事——你照抄的例子里长度参数是按带 SCA 算的,代码里又改成 00 开头,长度立刻多 9,模块直接拒收。
我一般统一成 SCA 写 00,把短信中心号码交给模块自己存:
AT+CSCA="+8613800000500",145第二个参数 145 就是 0x91 的十进制,表示国际号码格式,这个值写错了模块会回 OK 但发不出去。写 00 的好处是号码换了不用改代码,坏处是换卡、换模块时必须记得重新下发 AT+CSCA,否则就是+CMS ERROR: 330(短信中心地址未知)。如果产品要发给不同地区的卡,反而建议在 PDU 里写死 SCA,少一次依赖。
一个小细节:SCA 号码里的“+86”不要自己算进长度,第一个字节统计的是 SCA 字段的字节数,不是号码位数。写0891时长度字节是 08,写0791时是 07,取决于短信中心号码是 15 位还是 13 位。
2.2 TP-DCS 一个字节定生死:00、08、04 与 F0
DCS 是整条报文里最该先确认的字段。它决定模块怎么解释后面的 TP-UD,也决定 UDL 填的是字符数还是字节数:
| DCS | 编码方式 | 单条容量 | 典型用途 |
|---|---|---|---|
| 00 | GSM 03.38 七位 | 160 字符 | 纯英文、数字验证码 |
| 08 | UCS2(UTF-16BE) | 70 字符 | 中文、日文、韩文 |
| 04 | 8-bit 二进制 | 140 字节 | 自定义协议、铃声、OTA |
| F0 | 七位 + Class 0 | 160 字符 | 英文闪信,直接弹屏不落信箱 |
| 18 | UCS2 + Class 0 | 70 字符 | 中文闪信 |
判断规则很朴素:正文里出现任何一个不在 GSM 基本字符表里的字符,就整条走 UCS2。别做“英文段用 7-bit、中文段用 UCS2”的混合优化,一条短信只有一个 DCS,混不了。有些老代码会在 DCS=00 的情况下硬塞 UTF-8 字节进去,模块照单全收,手机收到是一堆问号——这是最典型的错误:没有报错、短信照发、内容全乱,排查时容易怀疑串口或者天线。
Class 0 的坑在别处:闪信不落收件箱,测试时“发了但收不到”,先确认自己用的不是 F0/18。
2.3 AT+CMGS 的长度到底数哪一段
这是新手第一个卡点。AT+CMGS 后面的数字,是 TPDU 的字节数,不包含 SCA 部分。按 GSM 07.05 的说法,SMSC 地址的字节不计入长度。用上面的样本算一遍:
- 完整 PDU 十六进制串 40 个字符,除以 2 得 20 字节;
- 开头是 00,SCA 段占 1 字节;
- 20 − 1 = 19,所以命令是
AT+CMGS=19。
如果 SCA 写全(0891...),SCA 段占 1 + 8 = 9 字节,长度就是 20 − 9 = 11。公式统一成:
int scaOctets = 1 + (scaHex == "00" ? 0 : Convert.ToInt32(scaHex.Substring(0, 2), 16)); int tpduLength = pduHex.Length / 2 - scaOctets;三个常见错法:拿十六进制字符数当长度(多一倍)、把 SCA 算进去(多 1 或 9)、四舍五入写错(UDL 奇数位时字节数要向上取整)。这三种都表现为 304,肉眼分辨不出来,只能把 PDU 打印出来逐段核对。因此每次发送前把pduHex、scaOctets、tpduLength三个值一起写日志,是我调试 PDU 阶段养成的一个习惯,比事后靠猜测有效得多。
3. 编码层:七位打包、UCS2 与字符表里的几个暗坑
组包的字节拼装本身不难,难在编码。GSM 03.38 那张表跟 ASCII 长得像但不完全一样,七位打包又涉及位级别的搬运,UCS2 还有个大小端问题。这三块任何一处写错,报文字段全对也发不出正确内容。
3.1 GSM 03.38 基本表:跟 ASCII 不一致的那些位置
好消息是数字 0x30–0x39、大写字母 0x41–0x5A、小写字母 0x61–0x7A 在 GSM 七位表和 ASCII 里位置完全一致,所以纯英文验证码直接按 ASCII 码值打包通常不会错。坏消息是剩下的符号位大面积冲突:
| 码值 | GSM 03.38 | ASCII | 影响 |
|---|---|---|---|
| 0x00 | @ | NUL | 用 0 当结束符会截断正文 |
| 0x02 | $ | STX | 金额符号发出去变控制符 |
| 0x03 | ¥ | ETX | 同上 |
| 0x24 | ¤ | $ | 美元符号位置不对 |
| 0x40 | ¡ | @ | 邮箱地址必坏 |
| 0x5B–0x60 | Ä Ö Ñ Ü § ¿ | [ \ ] ^ _ ` | 中括号、下划线全乱 |
| 0x7B–0x7F | ä ö ñ ü à | { | } ~ DEL | 花括号、波浪号全乱 |
所以映射表必须显式写出来,不能(byte)c一把梭。另外有一组扩展字符(€ { } [ ] \ ~ ^ |)占用两个 septet:先发 0x1B 转义,再发对应的基本字符码。这直接导致 UDL 的计算出现偏差——正文里有一个{,字符数是 1,septet 数却是 2,UDL 要填 2。含这类字符的正文,长度计算和普通字符不是一回事,写校验逻辑时容易漏。
我一般的处理是:需要发英文且只含0-9 A-Z a-z和少数几个常用符号时才走 7-bit,其余情况一律 UCS2。UCS2 每条少 90 个字符,但省掉一整类映射问题,在告警短信这种场景里很划算。
3.2 七位打包:8 个字符压成 7 个字节的位操作
七位打包的实质是把每个字符的 7 个 bit 首尾相接,填满一个字节就换下一个字节。8 个字符正好 56 bit,压成 7 个字节。逐位写的版本慢但不容易错:
// 把字符串按 GSM 03.38 打包成七位字节流 static byte[] Pack7Bit(string text) { // n 个 septet 占 ceil(n*7/8) 个字节 var packed = new byte[(text.Length * 7 + 7) / 8]; int bitPos = 0; foreach (char c in text) { int v = Gsm7Bit.ToSeptet(c); // 查表得 0x00–0x7F,未知返回 -1 if (v < 0) throw new ArgumentException($"字符 {c} 不在 GSM 基本表内"); for (int b = 0; b < 7; b++, bitPos++) { if (((v >> b) & 1) != 0) packed[bitPos >> 3] |= (byte)(1 << (bitPos & 7)); } } return packed; }bitPos是全局位下标,bitPos >> 3定位字节、bitPos & 7定位字节内的位,低位先写。这样第 8 个字符的第 0 位会落进第一个字节的最高位,正好解释为什么
本文还有配套的精品资源,点击获取