C#短信群发源码实战:串口通信与PDU编码解析
简介这是一套基于短信猫硬件的C#短信群发系统完整源码面向需要批量短信能力的软件开发人员与企业项目团队尤其适用于快速搭建企业级短信通知与营销渠道。项目通过串口或USB连接短信猫硬件依托GSM网络自动收发短信覆盖号码分组、模板编辑、定时发送、状态反馈等常见业务场景下载后可直接编译运行无需再修改核心逻辑。资源为rar压缩包共114个文件总大小约721KB其中包含38个cs源代码文件、完整的sln/csproj工程配置、17个resx/resources界面资源、7个依赖dll、数据库备份及多张界面图片构成一套结构清晰的企业短信群发解决方案。已有119人学习浏览。深入阅读源码可系统掌握C#串口编程、GSM AT指令集、多线程并发调度、WinForm窗体设计、文件与数据库操作等关键技术同时借助内置的登录、用户权限等模块也能理解企业级桌面应用的基本分层与错误处理思路是一份贴合真实业务场景的练手与参考工程。 做工厂设备对接这几年短信网关这块一直是个绕不开的老需求。无论是设备告警通知、订单进度提醒还是内部系统给客户发验证码短信猫这种方案虽然看起来“土”但在内网隔离、成本敏感的场景里依然很能打。这篇文章就基于我实际维护过的一套C#短信群发源码把短信猫的通信原理、PDU编码细节、串口操作坑点一次讲透代码都是可以直接抄走的级别。C#短信群发源码基于短信猫这个项目本质上做的事情很简单通过电脑串口连接一个GSM模块也就是短信猫用AT指令控制它往指定手机号发送短信。和云短信平台比它不依赖外网、没有按条计费的服务商抽成只要一张SIM卡就能跑特别适合工厂内部告警、连锁门店通知、学校成绩群发这类场景。本文的目标读者是手里正好有短信猫设备、或者打算用C#做工业通信上位机的开发者我会把从串口初始化到PDU封包再到群发调度的完整代码逻辑讲清楚。1. 短信猫方案的整体设计与技术选型1.1 短信猫到底是什么它和普通手机的区别在哪短信猫本质上就是一个去掉屏幕和按键的工业级手机模块核心是GSM/CDMA通信芯片常见的方案有WaveCom、西门子TC35、华为EM310这些老型号现在市面上更多的是国产的4G全网通模块。它对外暴露的是一个串口通过串口接收AT指令然后模块自动完成射频发射、网络注册、短信收发这些动作。选短信猫而不是直接用手机蓝牙方案主要图三点一是工业模块支持7x24小时不间断运行不会像手机那样自动休眠二是它对AT指令的响应是标准化的程序好控制三是多卡型号四口、八口短信猫可以插多张SIM卡并发发送群发吞吐量能线性扩展。缺点也明显发送速度依赖运营商网关一张卡一小时也就几百条上限单卡完全不适合营销级别的百万级群发。1.2 技术选型短信猫 vs HTTP短信API怎么选很多人一上来就问现在都有阿里云短信、腾讯云短信了为什么还要自己搞短信猫这个问题的答案取决于业务约束条件。对比维度短信猫方案云短信API方案网络依赖完全不依赖互联网内网隔离可用必须能访问公网单条成本按SIM卡套餐算流量卡甚至几分钱通常3-5分/条发送速度单卡约5-10条/分钟多卡可叠加每秒几百条起步资质要求不需要企业资质认证需要企业实名、签名报备适用场景设备告警、内部通知、小批量群发营销短信、验证码高并发场景我这里维护的这套源码最初就是给一个工厂的PLC设备告警系统用的设备间在独立VLAN里物理上就不允许访问外网云短信完全走不通。哪怕后来有些分厂想接入云短信也只能做一个抽象接口层底层同时支持短信猫和HTTP两种实现。这一点非常关键——代码架构上不要把发送方式写死一定要留扩展点。1.3 系统整体架构和各模块职责系统按功能拆成四层串口通信层、AT指令封装层、PDU编解码层、业务调度层。串口通信层负责和硬件的数据读写AT指令封装层负责把“设置PDU模式”“发送短信”这类操作转成具体的指令串并解析响应PDU编解码层负责把短信内容尤其是中文转成电信协议要求的十六进制串业务调度层负责号码遍历、失败重试、发送日志这些界面和业务逻辑。这四层各管各的好处是换硬件的时候只需要改串口通信层。我后来把这套代码从WaveCom换到某国产4G模块改的代码量不到三十行就是换了一下波特率和几个初始化指令。2. 串口通信与AT指令的核心原理2.1 用C#操作串口SerialPort其实够用C#操作串口用的就是System.IO.Ports.SerialPort这个类它默认支持常用的波特率、数据位、校验位配置而且是事件驱动模式有数据进来会触发DataReceived事件不需要自己开一个线程死循环去轮询。不过SerialPort有个隐藏坑它的DataReceived事件是在线程池线程上触发的不是UI线程。如果你在事件处理函数里直接操作界面控件大概率会报“线程间操作无效”的错。这就要么用Invoke同步到UI线程要么干脆把收到的数据塞进一个队列由另一个工作线程统一处理。我在这个项目里就是后者——收到了就往ConcurrentQueue里放主逻辑循环去取这样串口事件处理函数永远只做一件事不阻塞也不会因为UI卡顿丢数据。2.2 AT指令交互模型每一问都有一答AT指令是短信猫的“母语”它遵循一问一答的交互模型。你发一行文本过去模块必然回一行或多行响应要么是OK表示成功要么是ERROR或CMS ERROR: xxx表示失败。关键的AT指令其实就那么几条AT测试串口是否通ATE0关闭回显ATCMGF0把短信格式设为PDU模式ATCMGS长度通知模块准备接收PDU数据ATCSQ查信号强度返回99表示没信号。一个完整的发送流程是先ATCMGF0切到PDU模式再ATCMGSTPDU长度此时模块会回一个“”提示符紧接着把PDU十六进制串发过去以十六进制的0x1A也就是CtrlZ字符结尾表示“发吧”。模块最终会回CMGS: 编号和OK。注意ATCMGS后面的长度是PDU数据中TPDU部分的字节数不是整个PDU的长度更不是字符长度。这个数算错一个字节模块就会直接回ERROR。这是初学者最容易卡住的地方。等号后面的长度再拆细一点看发送PDU不含短信中心地址是由“文件头字节消息参考号目标号码长度目标号码协议标识编码方案有效期用户数据长度用户数据”组成的。ATCMGS要的长度就是这个PDU的字节数不是十六进制字符串的字符数除以2算出来的——实际上你如果按“完整PDU长度”-1去算刚好等于TPDU长度因为短信中心长度字节不算进去。为了稳妥我在代码里直接数TPDU部分的十六进制字符对数量就是bytes.Length - 1这个数值作为CMGS参数传进去实测最准。2.3 PDU编码中文短信绕不开的UCS2短信有两种编码格式7-bit编码和UCS2编码。7-bit编码是针对纯英文和数字的同样的140字节能装160个字符UCS2编码每个汉字占两字节所以一条短信最多70个汉字。发中文短信必须用PDU模式UCS2编码文本模式ATCMGF1对中文支持极差大部分模块根本不认。UCS2编码在C#里就是一句话的事把字符串每个字符强制转成ushort再转十六进制保留四位。比如“测”字的Unicode码点是0x6D4B在PDU里就是“6D4B”。这并不是什么高深的算法但很多人第一次写的时候容易用错编码用了Encoding.Default或者GB2312去转出来的十六进制串在SIM卡里存的是“”或者抠字基本报废。3. 核心编码实现从串口到PDU的完整链路3.1 串口初始化与设备检测打开短信猫串口这一步参数一定要跟设备手册对齐。绝大多数短信猫出厂默认9600波特率、8数据位、无校验、1停止位但个别设备可能不同。我见过一个老模块默认是19200搞了半天才知道波特率不对。所以代码里最好把波特率做成配置项别写死。private SerialPort _port; public bool Open(string portName, int baudRate 9600) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout 1000, WriteTimeout 1000, Encoding Encoding.ASCII }; _port.Open(); // 发送AT指令验证模块在线 if (SendAtCommand(AT\r, 2000).Contains(OK)) { SendAtCommand(ATE0\r, 500); // 关闭回显让后续响应更干净 SendAtCommand(ATCMGF0\r, 500); // 切到PDU模式 return true; } return false; } private string SendAtCommand(string command, int waitMs) { _port.DiscardInBuffer(); _port.Write(command); string response string.Empty; var endTime DateTime.Now.AddMilliseconds(waitMs); while (DateTime.Now endTime) { response _port.ReadExisting(); if (response.Contains(OK) || response.Contains(ERROR) || response.Contains()) break; Thread.Sleep(20); } return response; }这段代码里有几个细节值得说一下。DiscardInBuffer是在每次发指令前把串口缓冲区里残留的旧数据清掉不然上次的响应碎片会混到这次的判断里导致“假成功”。循环里用ReadExisting而不是ReadLine是因为PDU模式下模块返回的“”提示符后面不一定有换行符ReadLine会一直等下去直到超时。3.2 中文PDU的构造过程PDU构造是整个程序里最容易犯错的地方我把每一步拆开讲清楚。public string BuildPdu(string phoneNumber, string content) { // 1. 短信中心号码填空串表示用SIM卡默认的短信中心 string smsc 00; // 2. 手机号加86国家码并转成半字节交换格式 // 13800138000 - 8613800138000 - 683108310080F0... string targetNumber 91 SwapNibbles(86 phoneNumber); // 3. 基本参数01表示短消息类型为“提交”11表示带有效期 string firstByte 11; // 4. 消息参考号00 string tpMr 00; // 5. 目标号码长度不含91按半字节个数计算即13位手机号 13个半字节 0D string numberLength (86 phoneNumber).Length.ToString(X2); // 6. 协议标识00默认 string pid 00; // 7. 编码方案08表示UCS2每个汉字占2字节 string dcs 08; // 8. 有效期AA代表4天 string vp AA; // 9. 短信内容转UCS2 string contentHex StringToUcs2(content); // 10. 用户数据长度按字节数1个汉字2字节 string udl (contentHex.Length / 2).ToString(X2); // TPDU部分不含短信中心 string tpdu firstByte tpMr numberLength targetNumber pid dcs vp udl contentHex; // 完整PDUSMS中心长度 “00” TPDU return smsc tpdu; } private static string SwapNibbles(string number) { // 号码半字节交换12 - 21 StringBuilder sb new StringBuilder(number.Length); for (int i 0; i number.Length; i 2) { if (i 1 number.Length) { sb.Append(number[i 1]).Append(number[i]); } else { sb.Append(F).Append(number[i]); // 奇数长度补F } } return sb.ToString(); } private static string StringToUcs2(string text) { StringBuilder sb new StringBuilder(); foreach (char c in text) { sb.Append(((ushort)c).ToString(X4)); } return sb.ToString(); }几个容易懵的点第一号码为什么加“86”因为国内手机号是11位PDU协议里要求国际格式前头补86变成13位这样半字节交换后正好是整数13114个半字节不会出现奇数长度补位的问题第二91这个头是“国际号码格式”的标识固定写法别动它第三numberLength这个字段官方说法是“地址字段的长度”指的就是去掉91之后的半字节个数。3.3 发送短信的完整函数有了PDU串之后发送就剩按步骤执行AT指令的问题。public bool SendSms(string phoneNumber, string content) { if (string.IsNullOrWhiteSpace(phoneNumber) || string.IsNullOrWhiteSpace(content)) return false; if (content.Length 70) { // 超长短信拆分成多条发送这里直接循环调用自身 return SendLongSms(phoneNumber, content); } string pdu BuildPdu(phoneNumber, content); // TPDU长度 整个PDU十六进制字符数/2 - 1短信中心地址本身的长度字节 int tpduLength pdu.Length / 2 - 1; // 通知模块准备接收 string response SendAtCommand(ATCMGS tpduLength \r, 1000); if (!response.Contains()) { Log($模块未返回提示符响应{response}); return false; } // 发送PDU内容以0x1A结尾 _port.Write(pdu \u001A); // 等待最终的OK/ERROR响应 var end DateTime.Now.AddMilliseconds(5000); string finalResponse string.Empty; while (DateTime.Now end) { finalResponse _port.ReadExisting(); if (finalResponse.Contains(OK)) return true; if (finalResponse.Contains(ERROR) || finalResponse.Contains(CMS ERROR)) { Log($发送失败响应{finalResponse}); return false; } Thread.Sleep(50); } Log(等待响应超时); return false; }提示ATCMGS后面跟的tpduLength一定是整个PDU字节数减1。假设pdu是“0011000D9168310813005F000008AA0C6D4B6D4B”它总共44个十六进制字符也就是22字节减1得21。这个21是TPDU的字节数ATCMGS要的就是它别用别的算法否则模块会报错。3.4 群发调度遍历号码、防卡死、失败重试单发搞定了群发其实就是对号码列表循环调用SendSms。但真的直接for循环去发坑马上就来了模块在发送后需要时间把短信交给基站一般2-5秒发得太快模块来不及响应下一条ATCMGS直接失败如果发完一个号码就Sleep十秒几百个号码发下来效率又太难看。我这里的做法是把“最小间隔时间”做成可配置项默认1500毫秒发送成功的记录直接跳过失败的重试两次。实际跑下来一张卡一小时能稳定发出大约500-700条已经接近单卡的物理上限了。public SmsSendResult BatchSend(Liststring numbers, string content) { int successCount 0; var failedList new Liststring(); foreach (var number in numbers.Distinct()) { bool ok false; for (int retry 0; retry 2 !ok; retry) { ok SendSms(number, content); if (!ok) Thread.Sleep(500); } if (ok) successCount; else failedList.Add(number); Thread.Sleep(SendIntervalMs); // 默认1500ms } return new SmsSendResult { SuccessCount successCount, FailedList failedList }; }要注意Distinct去重是必须的实际业务中号码列表很容易因为导入数据不干净出现重复重复发送既浪费短信条数也拉慢整体速度。另外失败列表我建议直接导出到文本或数据库表不要只在内存里留一个List机器挂了一次就全丢了。4. 常见问题与排查技巧实录4.1 模块一直不回OK第一步查什么这个问题九成是通信参数不对。先不要写C#代码直接用串口调试助手连上短信猫手动发一个AT回车。如果连调试助手都不回OK那就不是程序的问题是串口本身没通。这时候按顺序检查串口号是否正确去设备管理器看、波特率是否匹配有些不常见的模块默认57600、USB转串口的驱动是否装好国产CH340芯片经常被识别成未知设备要手动装驱动。另外一个非常隐蔽的坑短信猫是串口设备但Windows上如果你同时装了虚拟串口软件或者某个调试工具占用了这个串口SerialPort.Open()会抛UnauthorizedAccessException。IOException则是设备被拔了或者线松了。这些异常类型要分别捕获写进日志排查的时候一眼就知道是哪个环节。4.2 中文变成“??”或“抠字”编码哪里错了中文显示乱码排除法很直接先用调试助手手动发ATCMGF0切换到PDU模式然后用其他成熟的发短信工具比如厂商自带的Demo程序发一条中文试试。如果厂商Demo也是乱码那SIM卡或者模块固件有问题如果厂商Demo正常而你的代码发出来乱码那问题出在你的UCS2编码实现上。常见错误是用了System.Text.Encoding.UnicodeUTF-16 LE去转码然后直接把字节转HexString。Unicode编码在每个字符前会多一个字节顺序标记BOM而且字节序是小端跟PDU要求的UCS2大端顺序正好相反。正确姿势就是我上面写的“直接遍历字符转ushort”不要走Encoding类。4.3 批量发送中途失败率突然飙升先看信号和SIM卡状态群发跑到一半开始大面积CMS ERROR: 302或者超时这通常不是代码问题而是运营商的频控机制启动了。一张SIM卡短时间内发太多条运营商会触发反垃圾策略直接限速或者拦截表现就是刚开始正常发到二三十条后模块明明显示已发出但对方收不到或者模块一直回ERROR。解决的话第一要控制发送频率我建议单卡不要超过每分钟10条第二是观察模块信号值ATCSQ查询结果中第一个数字只要低于10就是信号很差要调整天线位置第三真要做大批量群发老老实实上四口或八口短信猫每张卡分别控制节奏效果比单卡硬怼好得多。这套源码里我也加了一个简单的“路由”逻辑多个串口同时连接时派发任务前先检查该串口对应SIM卡上一分钟的发送计数超过阈值就自动切到下一张卡。4.4 为什么程序一退出模块就没反应了SerialPort没有正确关闭或者程序是直接杀进程退出的串口没释放。这时候Windows不会立刻把串口标记为可用你重新打开程序会报“串口被占用”。解决办法是在程序退出时确保执行Dispose最好用using或者try-finally包住串口对象。另外异常退出时可以在Main入口挂AppDomain.CurrentDomain.ProcessExit事件在回调里确保串口关闭。这个细节看着小但在现场设备上调试时特别磨人曾经有次客户那边一个短信猫连在工控机上程序崩溃后串口一直占用着重启软件无效最后只能重启工控机才好。4.5 硬件层面的供电问题也是常见隐形杀手短信猫发射瞬间的峰值电流可以到2A如果用的是电脑USB口供电电流根本不够表现就是单发偶尔能成一群发就掉线甚至模块重启。我这边用的是DC 12V/1A的电源适配器给短信猫单独供电USB只做数据通信这个问题就消失了。还有一个容易忽略的点多口短信猫的SIM卡槽是有顺序的有的卡槽必须优先插SIM1不然模块搜索不到网络。具体的卡槽规则每款硬件不一样拿到设备先看说明书别想当然。5. 协议之外的工程化补全5.1 加入Socket触发让外部系统能远程发短信短信猫通常在工控机上别的业务系统如果也要发短信总不能都往这台机器上跑。我给这套基础群发功能加了一个轻量级的HTTP监听接口业务系统POST过来一个JSON里面是手机号和内容监听线程解析完再调SendSms。这样做能把短信发送能力彻底服务化跟C#上位机开发里常见的扫码枪触发事件、TCP设备通信都串起来了——底层同一个串口操作类上层通过Socket或者HTTP接口对接整个系统的复用性一下子提升了不少。5.2 写日志是刚需别偷懒群发程序跑在生产环境里一定要有完整的发送日志。至少要记录每次发送的时间、目标号码、短信内容脱敏保留、PDU串这个方便排查编码问题、模块原始响应、最终成功失败。这样一旦有用户说“没收到短信”你可以根据日志里的“CMGS: 编号”去跟运营商对证据。日志文件建议按天滚动保留至少30天。我实际工作中就遇到过这样一个case客户说某天晚上9点到10点的告警短信全没收到排查后发现那段时间SIM卡刚好欠费停机。如果没有日志这个问题根本没法定位——短信猫不会在界面上告诉你SIM卡欠费了。5.3 扩展思路从短信猫到4G DTU、再到云端如果业务量真的上去了短信猫的硬件扩展性确实有限。我建议保留这篇源码里的PDU编解码和AT指令封装层把串口通信层替换成基于TCP/IP的透传模块很多4G DTU直接支持TCP连接就能把短信发送能力从“本地串口”升级成“局域网服务”。更进一步的话可以加一层接口抽象ISmsSender让同一个群发调度逻辑既能跑短信猫也能切换到HTTP云短信API这样不管业务怎么变上层代码都稳如老狗。最后再分享一个实际使用心得串口通信和短信AT指令这两个坎迈过去之后后面做任何跟硬件通信相关的C#项目扫码枪、PLC、网口设备都会觉得顺畅很多因为套路是共通的——打开通道、写指令、读响应、解析状态。这套源码给我的最大价值不是“能发短信”这四五个字而是把这种“硬件通信协议解析异常处理”的工程框架完整跑通了一遍往后再接新设备改改协议层就能复用。本文还有配套的精品资源点击获取