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 模块上第一次发中文几乎都会失败ATCMGF1 的 Text 模式看着简单模块却按自己的字符集解释内容中文过去就是一串问号或者干脆回一个 CMS ERROR。问题不在串口也不在编码类而是 Text 模式从头到尾没说明白“这段字节按哪个字符集解释”。PDU 模式把编码方式、正文长度、目标号码全部写进报文一个 pdu_class 该干的活就是号码转 BCD、正文按 7-bit 或 UCS2 打包、拼出 TPDU、算准长度再交给 ATCMGS。下面按报文结构、编码层、组包发送、踩坑现场、联调验证的顺序拆适合手里已经有模块、要在工控机或上位机里做短信告警的人。2. 拆一条 PDUSCA、TPDU 与那个永远算不对的长度一条短信在网络侧就是一个十六进制串十几到一百四十字节不等。C# 短信发送程序写成 pdu_class 之后真正的工作量集中在两件事把字节拼对把长度算对。前者查字段表就行后者是返工重灾区——模块不会告诉你哪一位错了只会丢一个 CMS ERROR: 304 回来然后你对着日志一位一位数。PDU 分成两段前面的 SCA短信中心地址后面的 TPDU真正的报文。SMS-SUBMIT 的字段顺序是固定的少一个字节都发不出去顺序字段长度示例说明1SCA 长度1 字节00后面 SCA 字段的字节数含 TOA00 表示用模块里存的号码2SCA 类型1 字节9191 国际号、81 本地号SCA 长度为 00 时这一段不存在3SCA 号码变长—BCD 半字节交换4首字节1 字节11MTI01 是 SUBMITVPF10 表示带 1 字节有效期5TP-MR1 字节00报文参考号6TP-DA 长度1 字节0D号码的十进制位数不含 和 TOA7TP-DA 类型1 字节91同 SCA 类型8TP-DA 号码变长683108001032F4BCD奇数位补 F9TP-PID1 字节00普通点对点业务10TP-DCS1 字节08决定 7-bit / UCS2 / 8-bit11TP-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把短信中心号码交给模块自己存ATCSCA8613800000500,145第二个参数 145 就是 0x91 的十进制表示国际号码格式这个值写错了模块会回 OK 但发不出去。写 00 的好处是号码换了不用改代码坏处是换卡、换模块时必须记得重新下发 ATCSCA否则就是CMS ERROR: 330短信中心地址未知。如果产品要发给不同地区的卡反而建议在 PDU 里写死 SCA少一次依赖。一个小细节SCA 号码里的“86”不要自己算进长度第一个字节统计的是 SCA 字段的字节数不是号码位数。写0891时长度字节是 08写0791时是 07取决于短信中心号码是 15 位还是 13 位。2.2 TP-DCS 一个字节定生死00、08、04 与 F0DCS 是整条报文里最该先确认的字段。它决定模块怎么解释后面的 TP-UD也决定 UDL 填的是字符数还是字节数DCS编码方式单条容量典型用途00GSM 03.38 七位160 字符纯英文、数字验证码08UCS2UTF-16BE70 字符中文、日文、韩文048-bit 二进制140 字节自定义协议、铃声、OTAF0七位 Class 0160 字符英文闪信直接弹屏不落信箱18UCS2 Class 070 字符中文闪信判断规则很朴素正文里出现任何一个不在 GSM 基本字符表里的字符就整条走 UCS2。别做“英文段用 7-bit、中文段用 UCS2”的混合优化一条短信只有一个 DCS混不了。有些老代码会在 DCS00 的情况下硬塞 UTF-8 字节进去模块照单全收手机收到是一堆问号——这是最典型的错误没有报错、短信照发、内容全乱排查时容易怀疑串口或者天线。Class 0 的坑在别处闪信不落收件箱测试时“发了但收不到”先确认自己用的不是 F0/18。2.3 ATCMGS 的长度到底数哪一段这是新手第一个卡点。ATCMGS 后面的数字是 TPDU 的字节数不包含 SCA 部分。按 GSM 07.05 的说法SMSC 地址的字节不计入长度。用上面的样本算一遍完整 PDU 十六进制串 40 个字符除以 2 得 20 字节开头是 00SCA 段占 1 字节20 − 1 19所以命令是ATCMGS19。如果 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影响0x00NUL用 0 当结束符会截断正文0x02$STX金额符号发出去变控制符0x03¥ETX同上0x24¤$美元符号位置不对0x40¡邮箱地址必坏0x5B–0x60Ä Ö Ñ Ü § ¿[ \ ] ^ _ 中括号、下划线全乱0x7B–0x7Fä ö ñ ü à{ | } ~ DEL花括号、波浪号全乱所以映射表必须显式写出来不能(byte)c一把梭。另外有一组扩展字符€ { } [ ] \ ~ ^ |占用两个 septet先发 0x1B 转义再发对应的基本字符码。这直接导致 UDL 的计算出现偏差——正文里有一个{字符数是 1septet 数却是 2UDL 要填 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 位会落进第一个字节的最高位正好解释为什么本文还有配套的精品资源点击获取