简介:面向需要掌握C#短信发送底层实现的开发者,这份程序围绕PDU协议单元展开,重点解决GSM短信编码、提交短信封装与短信中心对接等问题,适合具备一定C#基础、希望理解通信协议细节的学习者参考。压缩包共45个文件、约484KB,以cs源码、exe可执行程序、resources与resx界面资源、config配置及pdb调试文件为主,另含解决方案与项目文件,便于直接查看工程结构和运行示例。资源已有157人学习下载,体量轻但主题集中。内容覆盖7位、8位与UCS-2编码,Submit PDU构造、长短信拆分、号码与SMSC参数处理,以及异常处理和测试用例思路;可帮助读者梳理从短信内容编码到PDU数据发送的完整链路,并借鉴窗口界面、配置读取与辅助类的组织方式,适合作为协议学习和小型工具开发的参考样例。
1. 从一个告警短信发不出去的下午说起:这套 C# PDU 发送程序解决什么问题
去年冬天帮一个机房改造告警链路,设备温度一超阈值就得给值班手机发短信,对方明确要求不经过任何云端服务,纯本地完成。我翻出这套 C#短信发送程序,核心是一个叫 pdu_class 的 PDU 编解码类,配一个串口短信模块就能工作:它把手机号和正文组装成 PDU 十六进制串,用 AT+CMGS 交给模块发出去,中文走 UCS2、英文走 7-bit,还能接收并解析模块主动上报的短信。
这项目适合两类人:手上有串口短信模块、需要离线发告警或验证码的工控开发;以及想搞明白 PDU 报文结构、准备自己撸一遍编码的 C# 开发者。下面按「跑起来 → 拆报文 → 改业务」的顺序展开,代码都是能直接往项目里搬的写法。
2. 工程骨架与运行环境:串口短信程序怎么在两小时内跑通
2.1 目录与类职责:pdu_class 在整条链路里的位置
这类短信工程我习惯分成三层,压缩包里也是这个思路:编解码层只做纯字符串和字节的转换,不碰串口,方便写单元测试;传输层负责串口参数、AT 指令收发和超时;服务层才是业务代码,决定发给谁、发什么、失败怎么重试。三层的边界一旦乱了,后面调 bug 会非常痛苦——尤其是串口这种状态型资源,混进业务逻辑里就变成黑匣子。
| 文件/目录 | 职责 | 是否依赖串口 |
|---|---|---|
| Core/PduCodec.cs | PDU 组装与解析,pdu_class 的 C# 实现 | 否 |
| Core/Gsm7Bit.cs | 7-bit 打包与解包 | 否 |
| Core/SmsSubmit.cs | 发送报文的数据结构 | 否 |
| Transport/SerialModem.cs | 打开串口、发 AT、等响应 | 是 |
| Service/SmsService.cs | 对外方法 Send(phone, text) | 间接 |
| appsettings.json | 串口号、波特率、短信中心号码 | 否 |
展开说一句 PduCodec 的定位:它对外只暴露两个方法,BuildSubmit 负责把「号码 + 正文」变成十六进制串并算出 TPDU 长度,ParseDeliver 负责把模块上报的整串 PDU 还原成号码和正文。中间的编码细节全被它藏在内部,服务层拿到的就是一串 hex 和一个 int,这种设计后面接短信队列、接 Web API 都省事。
2.2 开发环境、串口驱动与硬件准备
软件侧要求不高,.NET Framework 4.6 以上或者 .NET 6/8 都能跑。新版本里串口类被拆进了独立包,建完项目先补依赖:
dotnet add package System.IO.Ports硬件侧才是玄学重灾区。短信模块通常走串口,电脑这头用一根 USB 转串口线,插上后在设备管理器里能看到类似 COM3 的端口号,这个号要记下来填进配置。供电必须单独说:模块在发射瞬间的峰值电流能到 2A 左右,直接用 USB 口那点电流供电,表现就是「偶尔能发、一发就重启、AT 突然没响应」,我在这上面翻过车,后来一律给模块单独接电源,只共地。电话卡也要提前处理,把 PIN 码关掉,否则每次上电都要先发 AT+CPIN 解锁,程序里没写这段就永远等不到 OK。
还有一步很多人省:先用任意串口助手手工发一遍 AT,确认模块回 OK、AT+CSQ 返回的信号值在 10 以上、AT+CSCA? 能读出短信中心号码。这一步手工过不了,写再多 C# 代码都是白搭,先排除硬件和卡的问题再说。
2.3 第一次运行:从枚举串口到发出第一条测试短信
先跑一个最小程序,只做三件事:列出串口、打开指定口、切到 PDU 模式并确认短信中心可读。
static void Main() { // 1. 列出当前机器上所有串口,防止端口号填错 foreach (var p in SerialPort.GetPortNames()) Console.WriteLine($"发现串口: {p}"); // 2. 打开串口并握手,参数要和模块一致 using var modem = new SerialModem("COM3", 9600); if (!modem.Open()) { Console.WriteLine("串口打开失败或模块无响应"); return; } // 3. 关回显、切 PDU 模式、读短信中心 Console.WriteLine(modem.SendAt("ATE0")); // 关掉指令回显 Console.WriteLine(modem.SendAt("AT+CMGF=0")); // 0 = PDU 模式,必须 Console.WriteLine(modem.SendAt("AT+CSCA?")); // 确认短信中心号码 }这段的关键参数只有三个:串口号要换成设备管理器里实际看到的那个,写成 COM3 只是示例;波特率 9600 是多数模块的默认值,也有出厂就设成 115200 的,以模块手册为准;ATE0 是关回显,不关的话模块会把收到的指令原样吐回来,后面判断响应时容易把「AT+CMGS」这串回显误当成结果。CMGF=0 是整条链路的前提,文本模式(CMGF=1)虽然写起来简单,但中文编码受模块固件影响很大,同一个字符串在不同模块上表现不一致,所以这套程序从头到尾只走 PDU 模式。
最小程序跑通后再上完整发送:调用 Send,内部会走 BuildSubmit 组装报文、AT+CMGS 发送、等 OK 或 ERROR。第一条测试短信建议先发纯英文,比如 test,确认链路通了再发中文,这样一旦出问题能立刻定位是编码问题还是通道问题。
3. PDU 报文拆解:pdu_class 里中文短信怎么变成十六进制串
3.1 一条完整 PDU 的字节分布
PDU 模式下的发送报文就是一串十六进制字符,结构固定,理解这张表比背代码有用得多。以给 13800138000 发送「测试」为例,完整串是0011000B913108103800F00008AA046D4B8BD5,逐段拆开看:
| 段 | 示例 | 字节数 | 含义 |
|---|---|---|---|
| SMSC 长度 | 00 | 1 | 后面跟 0 字节,表示用卡内默认短信中心 |
| TPDU 首字节 | 11 | 1 | SMS-SUBMIT,无有效期字段 |
| TP-MR | 00 | 1 | 消息参考号,部分模块要求递增 |
| TP-DA 长度 | 0B | 1 | 号码位数,11 位 |
| TP-DA 类型 | 91 | 1 | 国际格式号码 |
| TP-DA 地址 | 3108103800F0 | 6 | 半字节交换后的号码 |
| TP-PID | 00 | 1 | 普通短信 |
| TP-DCS | 08 | 1 | 08 表示 UCS2,00 表示 7-bit |
| TP-VP | AA | 1 | 相对有效期,AA 约等于 4 天 |
| TP-UDL | 04 | 1 | 用户数据字节数,不是字符数 |
| TP-UD | 6D4B8BD5 | 4 | 正文编码后的内容 |
最容易错的是最后两段。UDL 在 UCS2 下等于正文的字节数,「测试」是两个汉字,每个汉字 UTF-16 编码占 2 字节,所以是 04 而不是 02。很多人在这里写成字符数,短信要么被截断,要么模块直接回 CMS ERROR,排查半天以为是模块问题。另一个是 AT+CMGS 后面那个长度参数,它等于 TPDU 字节数,也就是整串减去 SMSC 段(本例 17 - 1 = 16),不是整串字节数,差一个字节就发不出去。
3.2 手机号的半字节交换与长度字段
号码不是原样塞进去的。PDU 要求每个字节存两位数字,且低位在前,所以 13800138000 要两两一组、组内交换,奇数位末尾补 F:31 08 10 38 00 F0。前面的长度字段 0B 是号码位数(11),类型字段 91 表示带国家码的国际格式。如果号码本身带了 +86 前缀,要把 + 去掉、长度改成 13,否则长度和实际地址字节对不上,网关会直接丢弃。
// 号码编码:长度 + 类型 + 半字节交换后的数字 public static string EncodeNumber(string phone) { var sb = new StringBuilder(); sb.Append(phone.Length.ToString("X2")); // 号码位数,不是字节数 sb.Append("91"); // 国际格式 for (int i = 0; i < phone.Length; i += 2) { if (i + 1 < phone.Length) sb.Append(phone[i + 1]).Append(phone[i]); // 组内交换 else sb.Append('F').Append(phone[i]); // 奇数位补 F } return sb.ToString(); }这段没有复杂逻辑,但有个隐藏前提:入参必须是纯数字。业务侧传进来的号码经常带空格、短横线、+ 号,如果不在入口做清洗,长度字段就会算错。我一般在外层先用正则去掉所有非数字字符,再判断是否以 86 开头,统一成不带国家码的 11 位格式传进来,编码函数只负责转换,不做任何业务判断。
3.3 UCS2 与 7-bit 编码的判定和实现
中文必须走 UCS2,英文理论上可以走 7-bit 省空间,但混排时最稳的做法是整条按 UCS2 处理,因为 7-bit 一个字符 7 位,遇到特殊符号还要查扩展表,为省那点长度引入的复杂度不值得。判定规则就一句:正文里有任何非 ASCII 字符,一律 UCS2,DCS 写 08。
// UCS2:每个字符 2 字节,大端序,直接转十六进制串 public static string EncodeUcs2(string text) { var bytes = Encoding.BigEndianUnicode.GetBytes(text); return BitConverter.ToString(bytes).Replace("-", ""); }注意这里必须是 BigEndianUnicode,不是默认的 Unicode 编码。C# 的 Encoding.Unicode 是小端,转出来的字节顺序反了,手机上收到的就是乱码,这个坑我在第一次写 PDU 编码时踩过,发了十几条全是问号,最后拿手机对比报文才看出来。
7-bit 的打包是另一套算法:把 8 个字符的 7 位数据紧凑地塞进 7 个字节,靠位移拼接,末尾留空位。这段代码网上版本很多,能跑通的标准写法如下:
// 7-bit 打包:把 n 个 7 位字符压成 ceil(n*7/8) 个字节 private static byte[] Encode7Bit(string text) { var src = Encoding.ASCII.GetBytes(text); var dst = new byte[(src.Length * 7 + 7) / 8]; int bitPos = 0; foreach (var b in src) { int idx = bitPos >> 3; // 当前写到第几个字节 int shift = bitPos & 7; // 在字节内的偏移 dst[idx] |= (byte)((b << shift) & 0xFF); if (shift > 1 && idx + 1 < dst.Length) dst[idx + 1] |= (byte)(b >> (8 - shift)); // 溢出的高位落到下一个字节 bitPos += 7; } return dst; }参数上只需要注意一点:7-bit 模式下 UDL 填的是字符数而不是字节数,这一点和 UCS2 相反,两套规则混着记特别容易错。我现在的习惯是把 UDL 的计算封进各自的编码分支里,外面不手写,省得来回切换时脑子打结。
3.4 接收方向的 PDU 解析与中文还原
接收比发送麻烦,因为 SMSC 段长度是变长的,必须动态跳过。解析入口先读第一个字节 n,然后跳过 (n+1)*2 个十六进制字符——+1 是类型字节,乘 2 是十六进制字符数。后面的顺序是:TPDU 首字节、被叫号码长度、号码类型、号码、PID、DCS、时间戳(7 字节,BCD 编码)、UDL、UD。
解析号码时不能用长度字段直接当字节数,因为长度是位数,字节数要向上取整。中文还原则看 DCS,08 就按 BigEndianUnicode 转回来,00 就走 7-bit 解包。时间戳那 7 个字节是 BCD 编码的年月日时分秒加时区,如果业务要入库,记得把年份按两位处理,20 开头还是 19 开头要自己补。
4. 发送链路逐段拆:从调用方法到 AT+CMGS 回 OK
4.1 组装 TPDU 与长度计算
把前面几段拼起来就是完整的发送报文构造。这段代码是整个项目的主干,建议原样抄,改动只在 DCS 和有效期策略上。
// 组装 SMS-SUBMIT 报文,返回 hex 串,并输出 TPDU 长度 public static string BuildSubmit(string phone, string content, out int tpduLength) { var sb = new StringBuilder(); sb.Append("00"); // SMSC:用默认 sb.Append("11"); // SMS-SUBMIT,无有效期 sb.Append("00"); // TP-MR 消息参考号 sb.Append(EncodeNumber(phone)); // 号码段 sb.Append("00"); // TP-PID:普通短信 string ud; if (IsAscii(content)) { sb.Append("00"); // DCS:7-bit ud = BitConverter.ToString(Encode7Bit(content)).Replace("-", ""); } else { sb.Append("08"); // DCS:UCS2 ud = EncodeUcs2(content); } sb.Append("AA"); // TP-VP:相对有效期 sb.Append((ud.Length / 2).ToString("X2")); // UDL:字节数 sb.Append(ud); var full = sb.ToString(); tpduLength = (full.Length - 2) / 2; // 减去 SMSC 那一个字节 return full; }参数说明:tpduLength 是 out 参数,专门给 AT+CMGS= 用,别自己去猜整串长度;content 传进来之前最好做一次去空白处理;手机号格式按上一节说的统一成纯数字。DCS 的判断我用 IsAscii 一刀切,实际业务里如果希望全走 UCS2,直接把分支砍掉、DCS 固定 "08" 也完全没问题,兼容性反而更好。
4.2 串口配置与 AT 指令时序
发送短信不是「写进去就完事」,中间有严格的时序:先发 AT+CMGS=长度并等模块回 ">" 提示符,再写 PDU 串加一个 Ctrl+Z(0x1A)结尾,最后等 +CMGS 和 OK。跳过等 ">" 这步,模块会把整串当无效指令直接回 ERROR。
// 发送一条短信,成功返回 true public bool SendSms(string phone, string text) { var pdu = PduCodec.BuildSubmit(phone, text, out int len); var r = SendAt($"AT+CMGS={len}", 5000); if (!r.Contains(">")) return false; // 没等到提示符就终止 _port.Write(pdu + "\x1A"); // Ctrl+Z 是结束符,不能换行 var resp = ReadUntil(30000, "+CMGS", "ERROR"); // 发送比普通 AT 慢,给 30 秒 return resp.Contains("+CMGS") && resp.Contains("OK"); }时序这块有两个参数值得单独调:CMGS 的等待超时要给到 30 秒以上,网络差的时候模块回 +CMGS 很慢,超时设短了就会出现「明明发出去了但程序判定失败,重试导致收到两条」;Ctrl+Z 必须跟在 PDU 串后面直接写,中间不能加 \r\n,加了模块会认为报文结束位置不对。另外每条 AT 指令发送前最好清一次接收缓冲,避免上一次的残留响应混进本次判断。
4.3 长短信拆分与参考号管理
超过 70 个汉字的短信必须拆成多条,靠 UDH 头告诉手机哪几段属于同一条。UDH 固定 6 字节:05 00 03 RR TT NN,05 是头长度,00 是拼接标识,03 是后续长度,RR 是同组参考号,TT 是总段数,NN 是当前段号。DCS 不变,但 UDL 要把 UDH 也算进去。
| 编码方式 | 单条可用字节 | 扣除 UDH 6 字节后可容纳 | 实际字符上限 |
|---|---|---|---|
| UCS2 | 140 | 134 | 67 个汉字 |
| 7-bit | 140 | 134 | 153 个 ASCII 字符 |
拆分时最容易翻车的是参考号:同一组短信的 RR 必须相同,不同组必须不同,最简单的做法是用一个静态计数器加一,到 255 后回绕。TT 在拆完之前就算好了,NN 从 1 开始递增。此外每条被拆分短信的 TP-MR 也应该递增,某些模块对重复的参考号会直接丢弃,表现就是「发出去了但只收到一段」。
5. 避坑与排查:串口短信程序最常见的 5 个翻车现场
5.1 发 AT 完全没回 OK
现象:程序打开串口成功,但第一条 AT 就超时,串口助手手工发也一样。原因通常是三类:串口号选错(虚拟串口和实际模块不是同一个)、波特率与模块不一致、模块没收到足够的供电或 DTR 没拉高。解决顺序是先用串口助手以 9600 和 115200 各试一次,再把模块单独供电、把 DTR 置为 true,最后换一根线排除转接线问题。
5.2 短信发出去了,中文变成问号或乱码
现象:AT 返回 +CMGS 和 OK,手机收到一串问号。原因基本是 DCS 和实际编码不匹配,比如 DCS 写 00 但正文按 UCS2 塞进去,或者反过来。还有一种是转码时用了小端的 Encoding.Unicode。解决办法是把 DCS 和编码函数绑死在同一个分支里,中间不要有第二个地方判断编码,改完拿一条固定测试串(比如「测试123」)验证字节序。
5.3 返回 +CMS ERROR: 500 或直接 ERROR
现象:PDU 串发下去,模块回 CMS ERROR。原因多是长度参数算错——AT+CMGS 后面跟的是 TPDU 字节数,有人填了整串字节数,也有人填了字符数;另一个常见原因是号码长度字段与实际地址字节不匹配,比如号码里混进了空格。解决方式是打印出 PDU 串和长度参数,人工按表核一遍,重点看 UDL 和 CMGS 两个数字。
5.4 长短信只收到前半段
现象:发 100 个汉字,对方只收到前面 67 个,后面的没了,也没有报错。原因是分段后每条的 UDL 没把 UDH 的 6 字节算进去,超出 140 字节的部分被模块截断。解决是把单条上限算清楚:UCS2 每条正文最多 67 字符,7-bit 最多 153 字符,分段函数里强制校验,超了直接抛异常而不是静默丢弃。
5.5 多线程下收到上一条的回显,判断逻辑全乱
现象:后台线程在发短信,界面线程同时在读串口,结果 A 请求收到了 B 请求的 OK,出现「明明失败却报成功」。原因是 SerialPort 不是线程安全的,多个地方同时 ReadExisting 会把数据切碎。解决是给串口操作加一把锁,或者干脆做成单消费者队列:所有发送请求进队列,一个专用线程串行处理,DataReceived 事件里的数据也只往同一个缓冲区里追加,读写严格分线程。
6. 二次开发:把 pdu_class 接进自己的告警与验证码业务
真要落到业务里,我建议先动结构再动代码。第一刀是把 SmsService 收成接口,方法签名就Task<bool> SendAsync(string phone, string text, CancellationToken ct),内部包一层队列和重试;第二刀是把串口参数、短信中心号码、单条长度上限全部挪进配置,换模块时只改配置不改代码;第三刀是给 PduCodec 补单元测试,用固定的号码和正文断言输出 hex,包括一条中文、一条英文、一条 100 字长短信,这三个用例能挡住后面 80% 的回归问题。
| 改造点 | 具体做法 | 验证方式 |
|---|---|---|
| 发送入口 | 接口化,内部排队串行发送 | 并发调用 20 次,串口无交叉响应 |
| 失败重试 | 只对超时重试,CMS ERROR 不重试 | 拔卡触发错误,确认不产生重复短信 |
| 长短信 | 按 67 字符切分,参考号自增 | 发 200 字,手机端完整拼接 |
| 状态落库 | 记录 PDU、长度、响应原文 | 出错时能凭日志复现报文 |
验证方式也得说清楚,别只靠「发一条试试」。我一般准备三张卡:一张正常卡、一张欠费卡、一张信号弱的卡,分别验证成功路径、错误路径和超时路径;再用串口助手把模块返回的原始报文抓下来存档,出问题时对比日志里的 PDU 和模块回显,几分钟就能定位是编码错还是时序错。
实在要偷懒,至少把发送方法包成幂等:外部传一个业务单号,内部维护最近 N 条已发记录,重复单号直接返回成功,避免重试把值班手机轰炸一遍。这个后悔药吃着不香,但没有的时候真难受。
从那以后我每次接新的串口模块,都会先用串口助手把 AT、CMGF=0、CMGS 这条最小链路手工走一遍,确认硬件和卡没问题再写代码,省下来的排查时间远比那十分钟值。希望帮到你。
本文还有配套的精品资源,点击获取