C#短信发送PDU模式实战:中文乱码、TPDU长度与UCS2编码拆解

发布时间:2026/10/26 11:01:12
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 的字段顺序是固定的,少一个字节都发不出去:

顺序字段长度示例说明
1SCA 长度1 字节00后面 SCA 字段的字节数(含 TOA),00 表示用模块里存的号码
2SCA 类型1 字节9191 国际号、81 本地号,SCA 长度为 00 时这一段不存在
3SCA 号码变长—BCD 半字节交换
4首字节1 字节11MTI=01 是 SUBMIT,VPF=10 表示带 1 字节有效期
5TP-MR1 字节00报文参考号
6TP-DA 长度1 字节0D号码的十进制位数,不含 + 和 TOA
7TP-DA 类型1 字节91同 SCA 类型
8TP-DA 号码变长683108001032F4BCD,奇数位补 F
9TP-PID1 字节00普通点对点业务
10TP-DCS1 字节08决定 7-bit / UCS2 / 8-bit
11TP-VP0/1/7 字节FF首字节 VPF 决定这一段存不存在
12TP-UDL1 字节047-bit 填字符数,UCS2 填字节数
13TP-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编码方式单条容量典型用途
00GSM 03.38 七位160 字符纯英文、数字验证码
08UCS2(UTF-16BE)70 字符中文、日文、韩文
048-bit 二进制140 字节自定义协议、铃声、OTA
F0七位 + Class 0160 字符英文闪信,直接弹屏不落信箱
18UCS2 + Class 070 字符中文闪信

判断规则很朴素:正文里出现任何一个不在 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.38ASCII影响
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 位会落进第一个字节的最高位,正好解释为什么

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