☰
C#手动拼PDU发短信:串口AT指令硬核实践
2026/10/21 3:52:31 网站建设 项目流程

简介:这是一份面向C#中级开发者与通信协议学习者的PDU短信发送实战项目,聚焦GSM底层通信原理在Windows平台的落地实现。资源通过完整的SMSCSharp工程,封装了PDU编码/解码核心类、Submit PDU构造逻辑、MSISDN与SMSC号码二进制转换、多段长短信拆分重组及串口级发送控制等关键能力,有效解决企业级短信网关对接、嵌入式设备远程通知等实际场景中的协议适配难题。压缩包共45个文件,含10个核心C#源码(如PDUClass.cs、Form1.cs)、6个可执行程序(exe)、5个配置文件(config)及配套资源文件(resx、ico、csproj等),结构清晰,便于调试与二次开发;整体仅484KB,轻量易部署。目前已有157人学习下载,读者可直接运行Demo验证7位/8位/UCS-2多编码短信收发,复现PDU十六进制报文生成过程,并基于现有框架快速集成到自有系统中。

1. C#短信发送程序(pdu_class):不是调API,是亲手拼PDU、发AT指令、和串口“掰手腕”的硬核通信实践

你有没有试过,在没有网络、没有云服务、甚至没有SIM卡管理后台的现场——比如一台嵌入式工控机、一个老旧的电力采集终端、或某高校实验室里那台连着GSM模块的STM32开发板——突然需要让设备主动发一条告警短信?这时候,HttpClient.PostAsync没用,Twilio SDK连不上,阿里云短信控制台远在千里之外。真正能救命的,是一段能直连串口、识别模块响应、手动构造PDU编码、逐字节校验并可靠触发发送的C#代码。这个pdu_class项目,就是为这种“断网即失联、离线要告警”的硬场景而生的:它不依赖任何第三方服务,不封装黑盒SDK,核心逻辑全部展开在PduEncoder.cs和SerialSmsSender.cs里,从ASCII转7-bit、到UCS2编码、再到TP-UD长度计算,每一步都可调试、可打断点、可替换为自定义加密头。适合嵌入式C#开发者、工业物联网实施工程师、以及所有需要把短信能力“焊死”进设备固件里的同学——它跑在.NET Framework 4.6.1上,最小仅需128KB内存,连Windows CE都能扛。


2. 架构与目录结构:五个文件撑起一条端到端短信链路

这个项目轻量得惊人:整个源码包解压后只有5个C#文件,总行数不到1200行,却完整覆盖了从原始字符串到空中电波的全链路。它没用NuGet包(连System.IO.Ports都是Framework内置),没分层架构图,但每个文件都承担明确且不可替代的角色。我把它拆成三层来看:输入层 → 编码层 → 传输层。下面直接看目录树和每个文件的定位,再告诉你为什么这么设计。

2.1 文件清单与职责映射(真实项目结构)

pdu_class/ ├── Program.cs // 主入口:演示如何组合使用,含串口配置、号码/内容输入、发送流程 ├── PduEncoder.cs // 核心:PDU模式编码器,含7-bit压缩、UCS2转换、TPDU头组装、校验码生成 ├── SerialSmsSender.cs // 核心:串口通信控制器,封装AT指令序列、超时重试、状态机解析(+CMGS OK / ERROR) ├── SmsMessage.cs // 数据模型:封装手机号、短信内容、编码类型(GSM-7 / UCS2)、是否长短信 └── Utils.cs // 工具类:HEX字符串互转、字节数组填充、AT响应关键词提取(如提取+CMGS: 42中的42)

提示:这不是玩具Demo。SerialSmsSender.cs里实现了完整的AT指令状态机——它不只发AT+CMGS,还会先发AT+CPIN?查SIM卡、AT+CSQ查信号强度、AT+CMGF=0切PDU模式,失败时自动重试3次并返回具体错误码(如+CMS ERROR: 302表示无信号)。这些逻辑全在代码里,没藏在DLL里。

2.2 技术选型深挖:为什么坚持用PDU模式而非Text模式?

很多新手一上来就搜“C# 发短信”,找到的全是AT+CMGF=1+AT+CMGS的Text模式示例。但Text模式有致命缺陷:它无法发送中文(会乱码)、不支持长短信拆分、模块兼容性差(尤其老款SIMCOM/SIM800系列)。而本项目强制走PDU模式(AT+CMGF=0),原因很实在:

  • 中文必过:PDU要求内容必须UCS2编码,"你好"→"\u4f60\u597d"→hex: 4F60 597D,模块原生识别;
  • 长短信可控:SmsMessage类里有IsConcatenated属性,开启后自动按134字节切片、加UDH头、设Reference Number;
  • 错误可溯:PDU返回的+CMGS: <mr>中<mr>是消息参考号,后续可用AT+CMGL="ALL"拉取已发记录比对;
  • 工业级健壮:Text模式下AT+CMGS后直接跟文本,换行符处理极易出错;PDU模式则全程十六进制字符串,无字符集歧义。

所以,当你看到PduEncoder.Encode()方法里那一长串位运算和字节移位时,请相信——这不是炫技,是工业现场用血泪经验换来的确定性。

2.3 核心依赖与运行环境约束(实测有效)

项目要求说明
.NET版本.NET Framework 4.6.1(非Core/5/6!因System.IO.Ports.SerialPort在旧版更稳定)
操作系统Windows 7/10/11(x64/x86均可),不支持Linux/macOS(串口驱动差异大)
硬件依赖USB转TTL串口模块(CH340/CP2102)、GSM模块(SIM800L/SIM900A等,需AT指令兼容)
串口参数波特率:9600(默认,可在Program.cs中改)、数据位:8、停止位:1、无校验、无流控
权限要求程序需管理员权限运行(Windows下访问COM端口常需提权,否则UnauthorizedAccessException)

注意:如果你用的是USB转串口模块,务必安装对应芯片驱动(CH340官网驱动、CP2102 Silicon Labs驱动),并在设备管理器中确认COM端口号(如COM4)。项目里默认写COM3,运行前必须手动改——这是新手翻车第一高发区。


3. PDU编码核心拆解:手把手还原“你好”如何变成一串十六进制

PDU编码是整个项目的灵魂,也是最易出错的环节。PduEncoder.cs不是简单调用Encoding.Unicode.GetBytes(),而是严格遵循ETSI GSM 03.40标准,对每一个字节做位级操作。下面以发送"你好"到13812345678为例,逐段解析关键代码。

3.1 TP-DA(目标地址)组装:手机号如何变成BCD编码?

GSM模块要求手机号以BCD(二进制编码十进制)格式传入,且需补91国际前缀、反转字节顺序。例如13812345678→+8613812345678→ BCD化:

// PduEncoder.cs 中的 ToBcdString 方法 public static string ToBcdString(string phoneNumber) { if (string.IsNullOrWhiteSpace(phoneNumber)) return "00"; // 去掉+号,补86前缀(中国) var clean = phoneNumber.StartsWith("+") ? phoneNumber.Substring(1) : phoneNumber; clean = clean.StartsWith("86") ? clean : "86" + clean; // BCD规则:每两位数字一组,低位在前,高位在后;奇数位补F var bcd = new StringBuilder(); for (int i = 0; i < clean.Length; i += 2) { if (i + 1 < clean.Length) { bcd.Append(clean[i + 1]); // 先写后一位 bcd.Append(clean[i]); // 再写前一位 } else { bcd.Append("F"); // 奇数位补F bcd.Append(clean[i]); } } return bcd.ToString(); }

执行结果:13812345678→"8613812345678"→"683118234567F8"
→ 这串HEX就是PDU中TP-DA字段的值(长度12字节)。

参数说明:ToBcdString的输入必须是纯数字字符串(+8613812345678或13812345678均可),输出为大写HEX字符串。若号码含空格/横线,会抛FormatException——这是故意设计,逼你在调用前清洗输入。

3.2 TP-UD(用户数据)编码:中文为何必须UCS2?

Text模式发中文会变????,根源在于GSM默认用GSM-7bit字符集(仅支持ASCII子集)。PDU模式虽支持GSM-7bit,但中文必须走UCS2(即UTF-16BE)。PduEncoder.cs中的EncodeUcs2方法做了两件事:

  1. 将字符串转为Unicode字节数组(Big-Endian);
  2. 按GSM规范,将字节数组每2字节一组,高位字节在前,低位字节在后。
// PduEncoder.cs 中的 EncodeUcs2 方法 public static byte[] EncodeUcs2(string text) { if (string.IsNullOrEmpty(text)) return new byte[0]; // 强制UTF-16 Big-Endian,避免小端序导致模块解析失败 var unicodeBytes = Encoding.BigEndianUnicode.GetBytes(text); // GSM PDU要求UCS2数据前加00(表示无特殊头),此处省略,由EncodePdu统一加 return unicodeBytes; } // 调用示例:EncodeUcs2("你好") → byte[4] = { 0x4F, 0x60, 0x59, 0x7D } // 对应HEX字符串:"4F60597D"

逻辑说明:Encoding.BigEndianUnicode是关键!用Encoding.Unicode(默认Little-Endian)会导致字节序颠倒,模块收到604F7D59,解析成乱码。这个细节在官方文档里藏得很深,我当年在某电力终端上调试了两天才揪出来。

3.3 完整PDU字符串生成:把所有字段拼成一行HEX

EncodePdu方法是总装线,它把TP-MTI、TP-MR、TP-DA、TP-PID、TP-DCS、TP-UDL、TP-UD等字段按ETSI标准顺序拼接,并在开头补上PDU长度字节(整个PDU的字节数,非字符串长度):

// PduEncoder.cs 中的 EncodePdu 方法(精简核心逻辑) public static string EncodePdu(SmsMessage msg) { var pdu = new List<byte>(); // 1. TP-MTI + TP-MMS + TP-RD + TP-VPF + TP-SRR + TP-UDHI + TP-RP // 固定为 01(SMS-Submit),其他位按需置0 → 0x01 pdu.Add(0x01); // 2. TP-MR(Message Reference)→ 随机字节,模块返回时带此值,用于追踪 pdu.Add((byte)DateTime.Now.Millisecond); // 3. TP-DA(目标地址)→ BCD编码后的HEX转字节 var daBytes = HexStringToBytes(ToBcdString(msg.Destination)); pdu.Add((byte)daBytes.Length); // 地址长度(字节数,非字符数) pdu.AddRange(daBytes); // 4. TP-PID(Protocol Identifier)→ 00(普通短信) pdu.Add(0x00); // 5. TP-DCS(Data Coding Scheme)→ 08(UCS2编码) pdu.Add(0x08); // 6. TP-UDL(User Data Length)→ UCS2字节数(注意:不是字符数!"你好"是4字节) var udBytes = EncodeUcs2(msg.Content); pdu.Add((byte)udBytes.Length); // 7. TP-UD(User Data)→ 直接追加UCS2字节数组 pdu.AddRange(udBytes); // 8. 计算整个PDU的字节数(用于AT+CMGS指令首字节) var pduLength = (byte)pdu.Count; // 9. 最终PDU字符串 = [PDU长度] + [HEX编码的PDU字节数组] var pduHex = BitConverter.ToString(pdu.ToArray()).Replace("-", ""); return pduLength.ToString("X2") + pduHex; // 首字节为长度,大写HEX }

执行结果(简化):
EncodePdu(new SmsMessage{ Destination="13812345678", Content="你好" })
→ 返回"2701000C683118234567F80008044F60597D"
→ 共39字符(19.5字节),首字节27表示整个PDU共39字节(0x27=39)。

参数说明:SmsMessage的Content字段长度受UCS2限制——每2字节占1个字符位置,故最大长度为70字符(140字节)。若超长,EncodePdu会抛ArgumentException并提示“UCS2编码后超长”,这是安全熔断。


4. 串口通信与AT指令控制:如何让模块乖乖听话?

光有PDU字符串没用,还得让GSM模块接收、解析、发射。SerialSmsSender.cs就是那个“发号施令”的指挥官,它用SerialPort类直连硬件,用状态机管理AT指令流。这不是简单的WriteLine+ReadLine,而是精确到毫秒的时序控制。

4.1 AT指令状态机设计:为什么不能用ReadLine()?

GSM模块的AT响应有三大坑:

  • 响应可能分多行到达(如+CSQ: 24,0后跟OK);
  • 模块可能在发送中插入+CMTI: "SM",1(新短信通知),干扰主流程;
  • OK/ERROR可能被截断(如只收到O就超时)。

因此,SerialSmsSender.cs放弃ReadLine(),改用ReadExisting()+ 正则匹配,并内置超时计时器:

// SerialSmsSender.cs 中的 SendAtCommand 方法(核心片段) private string SendAtCommand(string command, int timeoutMs = 3000) { _serialPort.DiscardInBuffer(); // 清空输入缓冲区,防历史数据干扰 _serialPort.Write(command + "\r"); // AT指令必须\r结尾,不能\n var sw = Stopwatch.StartNew(); var responseBuilder = new StringBuilder(); while (sw.ElapsedMilliseconds < timeoutMs) { if (_serialPort.BytesToRead > 0) { var chunk = _serialPort.ReadExisting(); responseBuilder.Append(chunk); // 关键:实时检测成功/失败关键词,不等超时 var response = responseBuilder.ToString(); if (response.Contains("OK") && !response.Contains("ERROR")) return response; if (response.Contains("ERROR") || response.Contains("+CMS ERROR")) return response; } Thread.Sleep(10); // 避免CPU空转 } return $"TIMEOUT: {command}"; // 超时返回明确错误 }

逻辑说明:SendAtCommand是所有AT交互的基座。它不假设响应格式,而是持续读取直到捕获OK或ERROR。Thread.Sleep(10)是经验值——太短耗CPU,太长拖慢整体发送速度(实测10ms平衡性最佳)。

4.2 发送全流程:从初始化到CMGS的七步握手

一次成功发送不是发一条AT+CMGS就完事。SerialSmsSender.SendSms()方法严格遵循GSM协议,执行以下7步(每步都带错误检查):

  1. AT→ 检查模块在线(响应OK);
  2. AT+CPIN?→ 检查SIM卡是否就绪(响应+CPIN: READY);
  3. AT+CSQ→ 检查信号强度(+CSQ: 24,0表示强信号,99,0表示无信号);
  4. AT+CMGF=0→ 切换到PDU模式;
  5. AT+CSCS="UCS2"→ 设置字符集(虽PDU模式下此步非必须,但部分模块需显式声明);
  6. AT+CMGS=<length>→ 发送PDU长度,等待>提示符;
  7. <pdu_hex_string><Ctrl+Z>→ 发送完整PDU字符串,结尾用ASCII 26(0x1A)。
// SerialSmsSender.cs 中的 SendSms 方法(关键步骤6&7) public bool SendSms(SmsMessage msg, out string result) { // ... 步骤1-5省略 ... // 步骤6:发送AT+CMGS指令,等待>提示符 var pdu = PduEncoder.EncodePdu(msg); var cmgsResponse = SendAtCommand($"AT+CMGS={pdu.Length / 2}", 5000); // 长度是HEX字符数/2 if (!cmgsResponse.Contains(">")) { result = $"CMGS failed: {cmgsResponse}"; return false; } // 步骤7:发送PDU数据 + Ctrl+Z _serialPort.Write(pdu); _serialPort.Write("\x1A"); // ASCII 26 = Ctrl+Z,GSM模块的“发送确认符” // 等待最终响应(+CMGS: <mr> 或 ERROR) var finalResponse = SendAtCommand("", 10000); // 长超时,因模块需时间发射 result = finalResponse; return finalResponse.Contains("+CMGS:"); }

参数说明:SendAtCommand("", 10000)的空命令参数表示“只读不发”,专用于等待模块最终响应。10000ms超时是保守值——实测SIM800L在弱信号下可达8秒,设太短会误判失败。


5. 避坑指南:那些让我重启三次模块才搞懂的玄学问题

这个项目看着简单,但串口+GSM模块的组合,堪称嵌入式开发的“玄学黑洞”。下面5条全是我在某高校实验室连续调试72小时后记下的血泪经验,每一条都对应一个真实翻车现场。

5.1 现象:AT+CMGS后永远收不到>,卡死在SendAtCommand超时

原因:模块未进入PDU模式,或AT+CMGF=0执行失败但被忽略。常见于模块刚上电时,AT指令返回OK,但内部状态未就绪,需加延时。
解决:在SendAtCommand("AT")后,强制Thread.Sleep(500);并在AT+CMGF=0后,用SendAtCommand("AT+CMGF?")二次确认返回+CMGF: 0。

5.2 现象:短信发出去了,但手机收到乱码(如鐢佃瘽鍙峰凡鏇存崲)

原因:PduEncoder.EncodeUcs2用了Encoding.Unicode(Little-Endian),但模块只认Big-Endian。
解决:必须用Encoding.BigEndianUnicode,且在EncodeUcs2方法开头加断言:Debug.Assert(Encoding.BigEndianUnicode.GetString(EncodeUcs2("a")) == "a");。

5.3 现象:发送成功(返回+CMGS: 42),但手机根本没收到

原因:PDU中TP-DA(目标地址)BCD编码错误。常见错误是忘记补86前缀,或ToBcdString里奇数位补了0而非F。
解决:用串口助手手动发PDU验证。例如AT+CMGS=13→0011000D91683118234567F800080001024F60(发"好"到138...),对比程序生成的HEX是否一致。

5.4 现象:程序第一次运行正常,重启后UnauthorizedAccessException

原因:Windows未释放串口句柄。SerialPort对象未Dispose(),或程序异常退出导致端口被锁。
解决:在Program.cs的Main方法末尾加try-finally,确保sender?.Dispose();更彻底方案是每次运行前,用net stop tcpip+net start tcpip重置网络栈(影响小,但治标)。

5.5 现象:长短信(>70中文)发送失败,模块返回+CMS ERROR: 500

原因:未启用UDH(User Data Header),PDU中缺少050003XXYYZZ头。SmsMessage.IsConcatenated = true只是开关,PduEncoder需额外生成UDH。
解决:修改PduEncoder.EncodePdu,当msg.IsConcatenated为true时,在TP-UD前插入6字节UDH:05 00 03 [ref] [total] [seq](ref随机,total=2,seq=1/2)。本项目默认未实现,需自行扩展。

提示:第5.5条是本项目当前的“已知缺口”。如果你需要长短信,别急着改——先用AT+CMGL="ALL"查模块已发记录,确认单条PDU是否真超长。很多时候是TP-UDL字段填错了字节数(该填UCS2字节数,误填字符数)。


6. 二次开发实战:给pdu_class加个“短信日志回传”功能

现在你已经能发短信了,但工业现场往往需要“发完立刻知道结果”。比如设备发完告警,要马上把+CMGS: 42这个消息参考号,通过另一条短信回传给运维中心。这就需要改造SerialSmsSender,让它支持“发送后回调”。

6.1 改造思路:注入回调函数,解耦发送与日志

不改SendSms签名(保持向后兼容),而是新增一个Action<string, bool, string>类型的OnSmsSent事件。当SendSms完成时,触发该事件,传入:手机号、是否成功、原始响应。

// SerialSmsSender.cs 新增字段 public event Action<string, bool, string> OnSmsSent; // 在 SendSms 方法末尾(发送完成后)添加: if (OnSmsSent != null) { OnSmsSent(msg.Destination, success, result); }

6.2 实战:用回调实现“失败自动重发+日志落盘”

在Program.cs中订阅事件,实现智能重发策略:

// Program.cs 中 Main 方法内 var sender = new SerialSmsSender("COM4", 9600); sender.OnSmsSent += (number, success, response) => { var logEntry = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} | {number} | {(success ? "OK" : "FAIL")} | {response}"; File.AppendAllText("sms_log.txt", logEntry + Environment.NewLine); if (!success && response.Contains("CMS ERROR: 302")) // 无信号 { Console.WriteLine($"信号弱,30秒后重试..."); Thread.Sleep(30000); // 重新构建SmsMessage并调用sender.SendSms(...) } }; // 启动发送 var msg = new SmsMessage { Destination = "13812345678", Content = "设备温度超限!" }; sender.SendSms(msg, out var result);

逻辑说明:这个回调不是简单打日志。它把response字符串作为判断依据——CMS ERROR: 302(无信号)、CMS ERROR: 517(存储满)、ERROR(通用错误)可触发不同策略。日志文件sms_log.txt按日期滚动?加个if (File.Exists("sms_log.txt") && new FileInfo("sms_log.txt").Length > 1024*1024)判断即可。

6.3 进阶技巧:用PDU解析器反向验证发送内容

pdu_class目前只有编码器,没有解码器。但调试时,你常需要把模块返回的+CMT:新短信PDU(如07916831082000F0040B91683118234567F800081150403170917010403170917010403170917010)转成可读内容。我一般在Utils.cs里加一个临时解码方法:

// Utils.cs 新增(仅调试用) public static string DecodePdu(string pduHex) { try { var bytes = HexStringToBytes(pduHex); // 解析TP-OA(源地址):bytes[2]是长度,bytes[3..]是BCD var oaLen = bytes[2]; var oaBytes = bytes.Skip(3).Take(oaLen).ToArray(); var phoneNumber = FromBcdString(oaBytes); // 反向BCD解码 // 解析TP-UD:从TP-UDL位置开始(跳过固定头) var udStart = 3 + oaLen + 5; // 简化计算,实际需解析TP-DCS等 var udLen = bytes[udStart - 1]; // TP-UDL字段 var udBytes = bytes.Skip(udStart).Take(udLen).ToArray(); return $"From: {phoneNumber}, Content: {Encoding.BigEndianUnicode.GetString(udBytes)}"; } catch { return "Invalid PDU"; } }

从那以后我每次在现场调试新模块,都会先用串口助手抓一条+CMT:响应,粘贴进VS立即窗口执行Utils.DecodePdu("xxx"),3秒内确认模块是否真收到了正确内容。这招比看LED灯闪烁靠谱十倍。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询