简介基于短信猫的C#短信群发源码是一套功能完整、可直接编译运行的企业级短信群发系统主要帮助.NET开发者在较短时间内搭建出具备批量发送、状态跟踪和号码管理能力的短信服务适合企业营销、通知提醒等场景也适合希望学习串口通信与短信交互原理的读者。源码覆盖从底层硬件通信到上层业务界面的完整链路包括AT指令操作短信猫、串口参数配置与读写、多线程并发发送、群发记录入库以及用户权限控制与异常处理同时提供清晰的窗体界面便于直接使用或在此基础上进行二次开发。压缩包内共一百一十四个文件以三十八个.cs源文件为核心辅以.resx界面资源、.gif与.jpg图片素材、DLL动态库和exe可执行程序另含mdb数据库文件及sln解决方案结构清晰总体积仅七百余KB。目前已有119人学习该资源学习价值明确既可将其视为C#与短信猫结合的完整实战范例也能在现有基础上增加定时发送、模板分组、导入号码、发送统计等功能快速构建可投入使用的短信群发平台。 前阵子帮客户做了一套内网运维告警系统需求很朴素设备一告警给值班人员发条短信。第一反应是接云短信平台结果客户现场网络策略严格业务网和数据网物理隔离短信接口根本出不去。折腾了一圈最后回到短信猫方案——一台巴掌大的GSM模块插张SIM卡通过串口用AT指令发短信。这就是这篇博文的来源一个基于短信猫的C#短信群发实现覆盖AT指令、PDU编码、串口通信、批量发送队列和落地过程中的各种坑给同样在搞C#上位机、工控系统、内部告警系统的朋友一个可以直接抄的参考。我猜你搜到这篇大概率也是因为项目中出现了“需要发短信但云平台走不通”的场景要么是内网隔离要么是想省掉按条计费的长尾成本要么是短信内容涉及内部数据不方便过第三方通道。短信猫方案在工业、运维、企业内部系统里一直没被淘汰一个几百块的成本一张能收发短信的SIM卡就能变成一个完全自主可控的本地短信网关。下面我从选型、原理、代码、群发架构、踩坑记录几个层面尽量讲透。1. 为什么选短信猫以及硬件选型的关键点1.1 云平台做不到的那些事云短信平台比如阿里云、腾讯云短信确实方便但在真实项目里会遇到几类硬伤网络隔离环境不可用很多工控现场、政企内网要求数据不能出内网云平台SDK直接歇菜。数据敏感告警内容可能包含设备IP、服务器型号、内部工单号走第三方通道需要额外审批。按量计费不适合高频告警有些现场一天几万条设备状态通知按条付费一年下来够买几十个短信猫了。依赖公网稳定性外网抖动、DNS拦截、防火墙策略都可能让告警发不出去短信猫走串口完全本地化不依赖这些。短信猫的定位不是替代云平台而是在“不能上云”“价格敏感”“要求自主可控”的场景里提供一个离线可用的硬件网关。1.2 选型时容易被忽略的细节市面上的短信猫主要分两种一种是USB免驱的消费级GSM modem插上就出一个COM口一种是工业级串口短信猫DB9或接线端子连接带独立电源适配器和外置天线。我的建议是项目里用优先选工业级串口款原因后面踩坑部分会说。选硬件时重点看这几点频段支持国内用至少要支持GSM 900/1800MHz现在很多模块是四频段全球通用这个基本都不是问题。是否支持标准AT指令一般GSM模块都支持标准AT指令集但有些定制版会阉割PDU相关指令下单前跟卖家确认支持ATCMGF0、ATCMGS。供电和天线优选带独立电源适配器和外置天线的型号发射功率有保障。串口电平部分模块是TTL电平需要转USB或串口卡工业级模块很多直接出RS232工控机上更方便。还要特别注意SIM卡普通手机SIM卡、物联网卡混着用都行但有些纯流量物联网卡不支持短信功能必须确认能收发短信再批量买。我遇到过一张卡插上去能注册网络但一发送就返回ERROR查了半天是SIM卡的短信功能没开通这个跟代码无关纯业务层面的坑。2. PDU模式原理中文短信能不能发就看这里2.1 Text模式和PDU模式怎么选短信猫发短信有两种常用模式Text模式和PDU模式。ATCMGF1是Text模式直接发ASCII文本实现简单发英文、数字没问题但发中文基本都会乱码因为中文字符编码和模块内部字符集转换容易出岔子。ATCMGF0是PDU模式所有内容以十六进制字节流发送中文用UCS2编码这是目前发中文短信最可靠的方式。项目里只要涉及中文无脑选PDU模式。2.2 一条完整的PDU短信长什么样以给13800138000发送内容“测试”为例拆分一条完整PDU00 11 00 0D 91 68 31 08 10 83 00 F0 00 08 AA 04 6D 4B 8B D500短信中心SMSC地址长度0表示使用SIM卡默认短信中心简单省事。11TP-MTI消息类型为普通短信提交TP-RD拒绝重复置1保证相同短信不会重复接收。00TP-MR消息参考号单条发送时一般填0。0D目标地址长度这个数字不是手机号位数是国际格式号码的十六进制长度。8613800138000长度13十六进制就是0D。91目标地址类型91表示国际格式号码前带86。68 31 08 10 83 00 F0目标号码的BCD码反转86 13 80 01 38 00 0F每两位交换后得到。00TP-PID协议标识普通短信填0。08TP-DCS编码方案。08表示UCS216位Unicode编码这是中文短信的关键。AATP-VP有效期AA表示4天可根据需求调整。04TP-UDL用户数据长度。UCS2编码下“测试”两个汉字占4个字节所以是04。6D 4B 8B D5“测”的Unicode是0x6D4B“试”是0x8BD5按BigEndian组合后转十六进制。这里最容易被新手卡住的是ATCMGS后面跟的长度不是整条PDU的长度而是去掉SMSC字段后TPDU部分的字节数。上面的例子中从11开始到D5结束共25字节我算一下11(1)00(1)0D(1)91(1)7个号码字节00(1)08(1)AA(1)04(1)4个内容字节一共1111711114 19字节。加上前面SMSC长度字段00是整条PDU20字节但ATCMGS的参数是19。2.3 短信猫的基础状态查询写代码之前先用串口助手手动验证模块状态避免代码跑半天发现是硬件问题AT返回OK确认模块通信正常。ATCPIN?返回CPIN: READY确认SIM卡识别成功。ATCSQ返回CSQ: 15,0第一个数字是信号强度范围0-31数值越高越好。低于10说明信号很差低于5基本发不出去。ATCSCA?查询短信中心号码。如果SIM卡默认短信中心异常需要手动设置。3. 源码实现串口通信到短信下发的完整链路3.1 串口初始化别小看这几行参数C#操作串口主要用System.IO.Ports.SerialPort先用NuGet装包System.IO.Ports。初始化代码如下public class SmsCatClient { private SerialPort _port; public bool Open(string portName, int baudRate 9600) { try { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout 3000, WriteTimeout 3000, Encoding Encoding.ASCII }; _port.Open(); return true; } catch (Exception ex) { Console.WriteLine($打开串口失败: {ex.Message}); return false; } } }短信猫的默认波特率通常是9600但有些工业模块能跑到115200具体看说明书。Encoding属性也值得注意AT指令本身是ASCII文本这里设成ASCII是最稳妥的PDU的十六进制字节流后面用byte[]发送不走这个编码。然后是串口写入和读取返回。我习惯用同步方式简单直观private string SendCommand(string command, int waitMs 500) { _port.DiscardInBuffer(); _port.Write(command); Thread.Sleep(waitMs); return _port.ReadExisting(); }注意Thread.Sleep这一步不能省。短信猫模块处理AT指令需要时间刚写完指令立刻读大概率什么都读不到。群发场景下这个等待时间也是天然的发短信间隔。3.2 PDU编码器全是细节PDU编码是整个短信发送的核心拿上面的规则写成C#类public static class PduCoder { // 将手机号编码为TP-DA字段 private static string EncodePhone(string phone) { // 转国际格式去掉号 string national phone.StartsWith() ? phone.Substring(1) : phone; if (!national.StartsWith(86)) national 86 national; var sb new StringBuilder(); if (national.Length % 2 1) national F; for (int i 0; i national.Length; i 2) { sb.Append(national[i 1]); sb.Append(national[i]); } return sb.ToString(); } // 生成完整PDU串返回(PDU十六进制字符串, TPDU长度) public static (string Pdu, int TpduLength) Encode(string phone, string content) { var tpu new StringBuilder(); // 短信中心SMSC设为0使用SIM卡默认短信中心 // 注意实际项目中如果模块读不到默认中心这里必须填入CSCA查询结果 tpu.Append(11); // TP-MTI TP-RD tpu.Append(00); // TP-MR tpu.Append(phone.Length.ToString(X2)); // 号码BCD长度这里传的是国际格式位数 string encodedPhone EncodePhone(phone); tpu.Append(91); // 国际格式 tpu.Append(encodedPhone); tpu.Append(00); // TP-PID tpu.Append(08); // TP-DCSUCS2 tpu.Append(AA); // TP-VP byte[] ucs2Bytes Encoding.BigEndianUnicode.GetBytes(content); string contentHex Convert.ToHexString(ucs2Bytes); tpu.Append(ucs2Bytes.Length.ToString(X2)); // TP-UDL tpu.Append(contentHex); // TP-UD string fullPdu 00 tpu.ToString(); // 前面手工拼接SMSC长度字节 int tpduLength tpu.ToString().Length / 2; return (fullPdu, tpduLength); } }这段代码里你会发现一个细节ATCMGS的长度参数用的是tpduLength也就是去掉SMSC长度字节后从11开始的字节数。我在第一次写的时候直接把fullPdu.Length / 2传进去结果模块一直返回ERROR调试了很久才发现多算了一个字节。这也是PDU模式最常见的错误来源之一。还有一点补充上面的EncodePhone函数里电话号码长度我写了phone.Length这其实不对应该是国际格式后的长度。如果你传入的是13800138000那它转换成8613800138000之后长度才是13。正确写法是计算national.Length上面为了简洁省略了实际务必改过来。3.3 发送单条短信写指令、等提示符、收确认单条短信的发送流程是严格分三步的顺序不能乱设置PDU模式ATCMGF0发送ATCMGSTPDU长度等待模块返回提示符追加发送PDU十六进制字节流最后以0x1ACtrlZ结尾等待CMGS: 序号或者ERRORpublic bool SendSms(string phone, string content) { var (pdu, tpduLength) PduCoder.Encode(phone, content); if (tpduLength 140) // UCS2编码下最多70个汉字 { Console.WriteLine(短信内容过长单条不能超过70个汉字); return false; } SendCommand(ATCMGF0\r); string resp SendCommand($ATCMGS{tpduLength}\r, 300); if (!resp.Contains()) { Console.WriteLine($未获取到发送提示符: {resp}); return false; } // 将十六进制PDU转成字节发送 byte[] pduBytes Convert.FromHexString(pdu); _port.Write(pduBytes, 0, pduBytes.Length); _port.Write(new byte[] { 0x1A }, 0, 1); string finalResp ReadUntil(CMGS, ERROR, 5000); return finalResp.Contains(CMGS); }ReadUntil是我自己封装的一个方法思路是循环读取串口数据直到返回里出现CMGS或ERROR超时强退。这也是发短信最容易出问题的一步很多模块返回慢超时设太短会误判发送失败。3.4 接收短信和后续扩展方向短信猫不仅能发也能收。收短信同样用PDU模式ATCMGL4可以列出所有未读短信解析也是逆向的PDU过程。如果你的项目需要“收到短信后自动回复”或者“远程通过短信下发指令”可以把收短信的解析逻辑也做出来核心还是PDU解码把十六进制转回UCS2字符串。这个就不展开写了原则是TP-DCS为08时按Encoding.BigEndianUnicode转字符串目标号码和发送号码的BCD反转逻辑跟编码时完全相反。4. 批量群发不能直接for循环得靠队列4.1 短信猫本质上是单通道设备很多第一次接短信猫的人第一反应是“群发嘛多线程并行发不就行了”。这个想法很危险。GSM模块在物理上就是一个单射频通道同一时刻只能处理一条AT指令。如果你开十个线程同时往同一个串口写指令轻则指令交错导致解析失败重则串口缓冲区错乱、模块假死。我在测试时踩过一次开了5个线程并行发短信结果第一个小时发出去的成功率只有60%而且串口偶尔报“由于以前的函数调用被挂起而失败”最后还是老老实实改为单线程队列。正确的群发姿势是用一个队列收集待发送短信一个后台线程循环取队列发送。发送间隔建议500ms-1s既能避免模块过载也能降低被运营商误判为垃圾短信的风险。4.2 队列发送的骨架代码public class SmsQueueWorker { private readonly ConcurrentQueueSmsMessage _queue new(); private readonly SmsCatClient _catClient; private CancellationTokenSource _cts; public void Enqueue(string phone, string content) { _queue.Enqueue(new SmsMessage(phone, content)); } public async Task StartAsync() { _cts new CancellationTokenSource(); await Task.Run(() ProcessLoop(_cts.Token)); } private void ProcessLoop(CancellationToken token) { while (!token.IsCancellationRequested) { if (_queue.TryDequeue(out var msg)) { bool ok _catClient.SendSms(msg.Phone, msg.Content); if (!ok) { // 失败重试一次仍失败写入日志 Log($发送失败: {msg.Phone} - {msg.Content}); } // 控制间隔避免模块过热或触发风控 Thread.Sleep(800); } else { Thread.Sleep(200); } } } }这个结构的好处是业务系统只需要调用Enqueue不关心串口状态、发送频率、失败处理。发送线程始终是单条的天然串行不会产生并发冲突。4.3 失败重试和发送确认机制群发最怕的不是失败而是“我以为发了但实际没发”。短信猫返回CMGS: 参考号才算真正发送成功如果返回ERROR可能是当前网络阻塞、短信中心拒收、PDU格式错误等。我的重试策略是失败后间隔2秒重试一次共3次。连续失败3次的短信写入一张FailedLog表每天人工复核一次。不要无限重试如果SIM卡欠费或短信中心故障重试再多也是白搭还占着队列影响后续短信发送。还有一个实用技巧开通短信状态报告ATCSMP17,167,0,25然后发送时设置TP-SRR位模块就能通过CDS通知短信是否真正到达接收方。短信猫一般没法区分“手机已收到”和“短信中心已接受”状态报告能把这条链路补全。4.4 单猫极限和多猫扩展单条GSM模块的稳定发送速率大约是每分钟30-60条取决于信号和发送间隔一天也就几万条。如果业务量更大两个思路一台工控机插多个短信猫每个猫一个COM口启动多个SmsQueueWorker队列根据COM口做哈希轮询分发。天线和SIM卡独立互不干扰。换4G LTE模块部分新款模块支持LTE Cat-1发短信速度和稳定性更好但AT指令集和PDU原理是通用的。我实际项目里用到过一台机器管3个短信猫总吞吐量在每分钟120条左右完全够用。关键是每个串口独立线程别交叉使用。5. 实测中踩过的坑按优先级排序5.1 串口丢失和“USB供电不足”玄学这是我最想吐槽的一个坑。第一次买了个USB免驱短信猫插在工控机的USB口上发了几十条就规律性掉串口。重启电脑能好但过一会儿又掉。查了一圈发现是USB供电不稳定造成的GSM模块在发射瞬间峰值电流能到2AUSB口供电能力有限电压一掉模块就重启表现为COM口消失。解决办法很简单换带独立电源适配器的工业级模块或者用带外部供电的USB HUB。从那以后我再也没有在项目里推荐过USB免驱款图省事的结果就是后续维护麻烦。另外程序里要监听串口状态定期枚举SerialPort.GetPortNames()发现串口丢失就尝试重新打开。5.2 信号强度决定一切短信发不出去第一个查的是ATCSQ。我遇到过客户把短信猫塞进金属机柜深处天线紧贴配电柜铁板信号强度直接报CSQ: 99无信号。把天线拉出来用吸盘固定到机柜外侧信号立刻回到20以上问题解决。排查经验信号值0-31之间越大越好。小于10时先考虑天线的摆放位置而不是换SIM卡。不同运营商的信号覆盖不一样同一个位置移动卡满格、电信卡可能只有5。项目正式上线前建议双卡双号实际测一轮。天线接口要拧紧松动会导致发射功率异常。5.3 中文乱码的根源是编码方式不一致PDU模式下中文乱码有几种可能TP-DCS没设成08模块按ASCII解析中文自然变成问号或乱码。十六进制转换用了Encoding.Unicode而不是Encoding.BigEndianUnicode。Encoding.Unicode在Windows上默认是小端序产生的字节序跟GSM要求的UCS2正好相反内容全乱。用SerialPort.Write(string)直接写PDU字符串而不是转成byte[]写入。SerialPort内部会做一次编码转换十六进制字符串如果中间混入\n之类的控制字符很可能被模块误读。最稳的做法就是上面代码里的Convert.FromHexString(pdu)转byte数组一次Write完再补0x1A。5.4 单条短信70个汉字的硬限制PDU模式下TP-UD最大是140字节UCS2编码一个汉字占2字节所以单条最多70个汉字。超过70字要么程序里自己拆分要么换成支持长短信自动拼接的模块。大多数短信猫模块不支持长短信自动拼接程序里拆成多条独立发送接收方会看到多条短信。如果要做成“一条长短信”需要手动实现长短信协议TP-UDHI置位 8位引用号 分片序号复杂度会高不少。我的建议是业务上约束告警内容在70字以内写不下的进邮件或附件。短信定位成“通知你有事发生”细节留在系统里反而更实用。5.5 串口权限和Windows服务运行账号程序如果部署成Windows服务默认运行账号是LocalSystem访问串口一般没问题但如果你用普通用户账号跑服务偶尔会遇到Access to the port is denied的报错。把服务运行账号改成管理员或者在组策略里给对应用户分配串口访问权限即可。另外防病毒软件有时会拦截串口操作遇到莫名的UnauthorizedAccessException先检查杀毒软件有没有把进程加白名单。6. 合规使用和业务落地建议最后必须说一句短信猫是工具合法使用才是前提。我接触到的场景基本是企业内部的告警通知、预约确认、验证码下发这些都属于业务性消息。做群发之前务必确保接收方有明确的订阅意愿并且提供退订机制。短信猫一旦被恶意用于垃圾短信影响的不仅是运营商风控封卡还有企业声誉和法律风险这个不是技术问题是底线问题。在业务落地层面我的经验是不要把串口操作直接写在业务系统里。推荐做一层独立服务业务库建一张SmsOutbox表业务系统只负责插入待发送记录短信发送服务定期轮询SELECT * FROM SmsOutbox WHERE Status0发送成功后更新状态失败记录错误原因。这样串口资源、发送队列、SIM卡状态都和业务系统完全解耦后续就算把短信猫换成云平台也只需要改服务内部实现业务表结构完全不用动。我在实际项目里就是用这套结构把一个短信猫服务跑成了Windows服务搭了一个简单的管理界面看发送日志和失败统计稳定运行了大半年没再出过幺蛾子。如果你正在做类似的需求这套基于PDU编码的串口链路、队列发送架构以及上面那些经验应该能帮你省下不少排查的时间。本文还有配套的精品资源点击获取