
简介这是一套基于短信猫硬件的C#短信群发完整工程源码面向需要快速搭建短信通知、营销群发功能或希望深入学习WinForm桌面应用与GSM通信的开发者。项目涉及串口通信、AT指令集、多线程批量发送、数据库存取等核心知识并实现了联系人管理、客户导入、短信模板、发送状态跟踪等常用功能整套代码可编译运行便于直接复用或二次改造。压缩包共114个文件总大小721KB以38个C#源文件为主配合17组resx/resources界面资源、多张界面图片、7个DLL运行库、MDB数据库及解决方案文件界面层、业务层与数据层划分清晰可通过frmMain主窗体、frmSendSMS发送界面和frmTel通讯录等快速定位关键逻辑。已有120人学习下载适合具备基础C#语法、想理解短信猫对接流程与群发任务调度机制的读者参考。1. C#短信群发源码基于短信猫一个还在发光的上位机方案“C#短信群发源码基于短信猫”这个标题放在今天看有点复古但在设备告警、机房值班、内网系统通知这些场景里它依然是性价比最高的方案之一不用对接云端 API、不按条付费一条短信几秒钟就能从本地 GSM 模块发出去。很多 C# 上位机项目里最后一环的“通知人”就是它。这篇文章会从短信猫怎么连电脑讲起一直写到群发调度、PDU 中文编码和那些只有跑过才知道的坑让你能照着代码把一套能稳定群发的小系统搭起来。2. 短信猫串口连接与AT握手从零看到一个“OK”短信猫的本质就是一个 GSM 模块加 SIM 卡、天线和串口电路电脑通过串口发 AT 指令来控制它。AT 是模块的“母语”所有发短信、读状态、查信号的操作都走这一条线。所以第一步不是写业务代码而是先把串口打开让程序能看到模块回复的 OK。2.1 选型看四点接口类型、波特率、模块协议与SIM卡做短信群发前先选硬件。接口类型上USB 短信猫比内置串口猫方便拔插即用但 USB 猫本质是 USB 转串口芯片常见 CH340、PL2303、FTDI 这几类驱动要先装好。波特率方面早年模块多默认 9600也有出厂设成 115200 的买回来先用厂商给的调试工具试一遍别上来就猜。协议上尽量选标准 AT 指令兼容的模块比如常见的华为 EM310、SIMCom 系列这类主流 GSM 模块有些厂家只提供 DLL 封装不直接开放 AT这种设备换一台电脑就未必能用我会特意避开。SIM 卡用普通手机卡即可物联网卡要确认开通了短信功能很多物联网卡默认只走流量。选型维度推荐做法备注接口USB 转串口驱动装好后在设备管理器看 COM 号波特率9600 / 115200以模块手册为准连接时严格匹配协议标准 AT避免只给 DLL 的封闭设备SIM 卡普通语音短信卡物联网卡需确认短信已开通2.2 串口参数与最小打开代码C# 里操作串口用的是 System.IO.Ports.SerialPort。参数看起来简单但每个都对不齐就收不到响应尤其是波特率和超时。以下是一份可用初始化代码using System.IO.Ports; var sp new SerialPort { PortName COM3, // 实际端口枚举后动态获取 BaudRate 9600, // 与模块保持一致 Parity Parity.None, DataBits 8, StopBits StopBits.One, Handshake Handshake.None, ReadTimeout 3000, // 读响应超时给余量 WriteTimeout 1500, Encoding Encoding.ASCII // AT指令和PDU都是ASCII十六进制 }; sp.Open(); sp.DiscardInBuffer(); // 清掉模块上电时输出的乱码 Thread.Sleep(200); // 等模块稳定这里最容易被忽略的是Encoding Encoding.ASCII。有些例子用默认编码写 AT 指令没事但后面写 PDU 十六进制字符串时如果编码不对会把字节搞乱模块直接报 ERROR。打开串口后不要立刻发指令。模块上电时会往串口吐一些调试信息若不先DiscardInBuffer()第一次读响应可能拿到的是这些残留内容误导你判断模块状态。2.3 AT握手与自动识别把程序跑起来先看到OKAT 握手就是发一个AT\r模块如果活着就会回OK。这里有个关键细节AT 指令的结束符是回车\r不是换行。SerialPort 的WriteLine默认追加换行很多模块也能兼容但为了规范我习惯手工Write(AT\r)。读取响应时不建议用ReadLine。模块返回的结束符有时不标准ReadLine容易超时或漏读我一般自己读字节private static string ReadUntil(SerialPort sp, string[] markers, int waitMs) { var sb new StringBuilder(); var deadline Environment.TickCount waitMs; while (Environment.TickCount deadline) { while (sp.BytesToRead 0) sb.Append((char)sp.ReadByte()); var s sb.ToString(); if (markers.Any(m s.Contains(m))) break; Thread.Sleep(50); } return sb.ToString(); }这个ReadUntil会一直读直到在缓冲区里看到目标字符串或者超时。markers可以传new[] { OK, ERROR }这样一次调用把正常和异常都覆盖到了。有了读函数识别短信猫就是遍历串口列表逐个试static bool IsSmsModem(SerialPort sp) { try { if (!sp.IsOpen) sp.Open(); sp.DiscardInBuffer(); sp.Write(AT\r); var r ReadUntil(sp, new[] { OK, ERROR }, 1500); return r.Contains(OK); } catch { return false; } } // 遍历所有串口 foreach (var name in SerialPort.GetPortNames()) { using var probe new SerialPort(name, 9600); if (IsSmsModem(probe)) Console.WriteLine($发现短信猫: {name}); }实际项目里不要写死 COM 号。USB 猫换个 USB 口COM 号就可能变程序启动时自动扫描一遍把识别到的端口写进配置文件比手工改代码省事得多。3. 中文短信的PDU编码UCS2转换与ATCMGS发送串口通了、AT 握手也过了接下来就是发短信的核心编码。这里也是 C# 短信群发源码里最容易写错、也最容易让新手翻车的部分。3.1 为什么中文必须走PDUAT 指令有两种短信模式文本模式ATCMGF1和 PDU 模式ATCMGF0。文本模式简单发英文短信时直接ATCMGS手机号然后写内容就行但中文在新手眼里就是“玄学”——模块经常把中文回显成乱码。原因在于文本模式下内容字节先经过串口编码再被模块本地字符集解码两边的字符集对不上中文就碎了。PDU 模式则不同它要求你先把短信内容编码成十六进制字符串比如“你好”变成 UCS2 编码的十六进制模块只负责把这些字节送到短信中心不参与你内容的字符集判断。所以中文群发统一走 PDU 模式稳定、无乱码。PDU 本身分两块短信中心地址SMSC和 TPDU。多数模块在 SIM 卡里已经存了短信中心号码发送时可以省略 SMSCATCMGS后面带的长度就是 TPDU 的字节数。这个长度算错了模块会一直回 ERROR后面怎么做都是白搭。3.2 中文UCS2编码与TPDU构造一条发给国内手机号的中文短信TPDU 结构由六部分组成PDU 类型1 字节、消息参考号 MR1 字节、目标地址 DA长度 1 字节 类型 1 字节 地址内容、协议标识 PID1 字节、编码方案 DCS1 字节、用户数据 UD。我直接给出一个能用的编码函数private static string EncodeSmsPdu(string mobile, string content) { // 1) 目标号码加国家码86补F两两反转 var number 86 mobile; if (number.Length % 2 1) number F; var daHex new StringBuilder(); for (int i 0; i number.Length; i 2) daHex.Append(number[i 1]).Append(number[i]); // 地址长度 补F前的号码位数十六进制地址类型91表示国际格式 var da ((mobile.Length 2).ToString(X2)) 91 daHex; // 2) 内容UCS2编码BigEndianUnicode就是UCS2的.NET实现 var udBytes Encoding.BigEndianUnicode.GetBytes(content); var udHex BitConverter.ToString(udBytes).Replace(-, string.Empty); // 3) TPDU11发送类型无有效期00MR00PID08DCS(UCS2) return 1100 da 0008 udHex; }这里每个字符都要说清楚。mobile是 11 位手机号加 86 后变成 13 位补一个 F 凑成偶数然后两两反转86 13 81 23 45 67 8F变成68 31 18 32 54 76 F8这是 GSM 地址的半字节反转规则。da的前两位是地址长度数值是手机号加 86 后的位数即 13写成十六进制0D。地址类型91表示国际格式号码前隐含一个。最后1100里的11是 PDU 类型表示这是一条不带有效期的 SMS-SUBMIT00是 MR00是 PID08告诉短信中心内容用 UCS2 解码。调用时ATCMGS需要的是 TPDU 的字节数不是十六进制字符串长度var pdu EncodeSmsPdu(13812345678, 你好这是C#短信猫测试); int tpduLen pdu.Length / 2; // 两个十六进制字符 1个字节这一行就是大部分源码调不通的根源。PDU 编码正确模块才肯回复提示符。3.3 ATCMGS发送流程写入0x1A后的那几秒决定成败发送流程是固定的先ATCMGS长度\r模块回此时写 PDU 十六进制字符串最后发一个字节0x1A表示“短信内容结束”。注意0x1A必须按字节发不能写成字符串\u001A否则可能被编码成两个字节模块永远等不到结束符。完整发送方法如下public static SmsResult SendOne(SerialPort sp, string mobile, string content) { var pdu EncodeSmsPdu(mobile, content); int len pdu.Length / 2; // 1) 让模块进入PDU发送状态 sp.Write(ATCMGS len \r); var resp ReadUntil(sp, new[] { }, 1500); if (!resp.Contains()) return SmsResult.Fail(模块未进入发送态: resp); // 2) 写入PDU十六进制字符串 sp.Write(pdu); // 3) 发送结束符0x1A sp.Write(new byte[] { 0x1A }, 0, 1); // 4) 等待最终结果CMGS: 序号 或 CMS ERROR: 码 var final ReadUntil(sp, new[] { CMGS:, CMS ERROR, OK, ERROR }, 5000); if (final.Contains(CMGS:)) return SmsResult.Ok(); if (final.Contains(CMS ERROR)) return SmsResult.Fail(final.Trim()); return SmsResult.Fail(发送超时: final); }ReadUntil第一个等待目标只写了一个模块收到ATCMGS后一般会在 1 秒内回\r\n。如果超时没等到基本是 PDU 长度算错或者模块当前状态不对。最后读结果时把CMGS:、CMS ERROR、OK都放进标记数组是因为不同模块返回格式略有差异多认几个关键字比死等一个准。发送成功后模块返回的CMGS: mr只是说短信已提交给短信中心不代表对方已经收到。做群发系统时状态记录到“已提交”这一层就够了真要确认到达得靠状态报告ATCSMP 设置请求状态报告但那是后话。单条发送能稳定返回CMGS:这个模块就可以进入队列调度环节了。4. 群发调度与并发控制队列、线程与断点重发很多人拿到短信猫做的第一件事是写个 for 循环一条条发。这在十几条时没问题上百条就开始翻车串口是单通道模块处理能力有限程序稍不顺就把 AT 指令和短信内容搅在一起。群发不是“循环发送”而是“队列 工作线程 状态管理”。4.1 单猫串行化一个队列一个消费者线程SerialPort 不是线程安全的。两个线程同时Write缓冲区里会交织出无法解析的指令模块直接回 ERROR。所以正确的模型是一只短信猫对应一个待发送队列由一个消费者线程串行取任务、串行发短信。即便你的业务层有 10 个线程从数据库捞任务最终投递到短信猫时也必须是单行道。public class SmsModemWorker { private readonly SerialPort _sp; private readonly ConcurrentQueueSmsTask _queue new ConcurrentQueueSmsTask(); private volatile bool _running; public void Post(SmsTask task) _queue.Enqueue(task); public void Start() { _running true; var t new Thread(Loop) { IsBackground true }; t.Start(); } private void Loop() { while (_running) { if (_queue.TryDequeue(out var task)) { try { var r SendOne(_sp, task.Mobile, task.Content); if (r.Success) task.MarkSent(); else task.MarkRetry(r.Message); } catch (Exception ex) { task.MarkRetry(ex.Message); } // 模块内部处理一条短信需要时间别急着发下一条 Thread.Sleep(200); } else { Thread.Sleep(100); } } } }Post是生产者入口业务线程可以随时往队列里丢任务Loop是消费者一次只取一条发送完睡 200ms 再取下一条。这个 200ms 不是拍脑袋短信模块提交一条短信到短信中心返回CMGS:之后内部还在收尾立刻发下一条可能触发CMS ERROR: 304。发送间隔可以根据模块实际表现调我用 200–500ms 之间比较稳。这个线程模型就是状态机里最典型的“空闲 → 发送 → 空闲”循环。C# 线程在这个场景里只有一个职责不要让主界面的按钮卡住也不要让数据库操作阻塞发送。4.2 从数据库取任务断点续发的后悔药程序跑着跑着断电了怎么办已发送的记录在哪内存队列再快一断电就清零。所以群发任务最好落地到数据库。用 SQLite 或 Access 都行老项目里用 Access 的也不少反正核心就是一张任务表CREATE TABLE SmsTask ( TaskId INTEGER PRIMARY KEY AUTOINCREMENT, Mobile TEXT NOT NULL, Content TEXT NOT NULL, Status TEXT DEFAULT PENDING, -- PENDING / SENT / FAILED RetryCount INTEGER DEFAULT 0, LastError TEXT, CreateTime DATETIME DEFAULT CURRENT_TIMESTAMP );取任务时不要一次把几千条全捞出来var pending db.QuerySmsTask( SELECT * FROM SmsTask WHERE StatusPENDING AND RetryCount3 ORDER BY TaskId LIMIT 100); foreach (var task in pending) { modem.Post(task); }LIMIT 100是故意留的保险。程序崩了以后这 100 条里可能有一部分已经提交短信中心但状态没来得及更新重启后它们还会被重新捞出来个别短信可能重复发送。但比起“发到一半不知道发了谁”重复一两条的代价更小。先保证不丢任务再考虑幂等。发送成功就把状态改成 SENT失败但重试次数没到就放回 PENDING并累加重试次数UPDATE SmsTask SET StatusSENT, SendTimet WHERE TaskIdid; UPDATE SmsTask SET StatusPENDING, RetryCountRetryCount1 WHERE TaskIdid AND RetryCount3;这就是整个方案的“后悔药”只要有这张表什么时候断电关机都不慌程序起来接着扫 PENDING 就行。4.3 失败重试与掉线检测发送计数别乱加重试逻辑里最容易犯的错是收到堆 ERROR 就把所有任务标记为 FAILED。实际上CMS ERROR 304是“发送队列满”或“模块忙”隔几秒再发就好了302是 SIM 卡没识别重试也没用。所以重试要看错误码分类不是一视同仁。模块长时间运行后可能假死串口开着但 AT 指令发出去没任何响应。我一般单独跑一个心跳线程每 30 秒发一次AT\r连续三次没回 OK 就关闭串口重新打开并重新执行ATCMGF0切回 PDU 模式private void HeartbeatLoop() { int failCount 0; while (_running) { try { _sp.Write(AT\r); var r ReadUntil(_sp, new[] { OK, ERROR }, 1500); failCount r.Contains(OK) ? 0 : failCount 1; if (failCount 3) { ReopenModem(); // 关闭串口重新打开重新ATCMGF0 failCount 0; } } catch { failCount; } Thread.Sleep(30000); } }心跳的价值不是“检测到故障”而是“故障后自动恢复”。没有心跳群发任务一卡就是半个晚上有人盯着也未必能及时发现。有了它短信猫能自己从假死里爬回来。5. 短信猫发短信的常见问题与排查5个真实踩坑记录这个章节是多年跑现场攒下来的。每一条都有人问过我也都对应一个具体的修改动作。5.1 发送返回ERROR但你没查CMS ERROR码现象程序日志里全是ERROR但看不出为什么失败。原因模块返回的CMS ERROR: 302、304、305各有含义。302 是 SIM 卡未就绪304 是短信发送队列满或模块忙305 是短消息中心地址错误。只看“失败”两个字永远定位不到问题。解决把CMS ERROR后面的错误码原样记录下来再对照模块手册的 CMS 错误码表。我习惯把 AT 返回的原始字符串完整写进日志而不是只记录一个布尔值。排一次错日志里什么都有比现场抓包省事得多。同时可以手工打ATCPIN?查 SIM 卡状态返回READY才是正常。5.2 USB猫换个USB口就找不到设备现象昨天还在用的 COM3今天程序打不开串口报“端口不存在”。原因USB 转串口设备插入不同 USB 口时Windows 会分配新的 COM 号。代码里写死 COM3一换口就废。解决程序启动时用SerialPort.GetPortNames()遍历所有串口逐个发AT\r做握手识别找到能回 OK 的端口再使用。这个方法在第 2 章已经给过代码。另外可以在设备管理器里把 USB 转串口的 COM 号固定比如锁成 COM8这样桌面部署时少一个变量。5.3 模块假死串口开着但什么都不回现象程序跑了一天某次发送后一直卡在ReadUntil超时后续任务全堵在队列里。原因模块长时间运行后偶发死锁尤其是信号差、连续发送失败时内部协议栈卡住不再响应任何 AT 指令。解决第 4 章的心跳线程就是为此设计的。超时后关闭串口、重新打开、重新设置 PDU 模式。如果重启串口还不活只能断电工程上会给短信猫配一个可控电源插座看门狗检测到长时间无响应就直接断电再上电。这招不优雅但有效。5.4 长短信被截断还出现半个字现象超过 70 个汉字的短信发出去对方收到多条其中一条结尾是乱码。原因中文 UCS2 编码下一条短信最多 70 个汉字140 字节。代码如果按字符数 70 截断没问题但有人按字节数 140 截断就可能把双字节的汉字从中间切开短信中心解出来的就是乱码。解决拆条必须按字符边界。超过 70 字时用 67 字一组拆分留出富余或者干脆按字符索引切var parts new Liststring(); for (int i 0; i content.Length; i 67) parts.Add(content.Substring(i, Math.Min(67, content.Length - i)));如果希望对方手机把多条合并成一条长短信还要在每条 PDU 的用户数据前加 6 字节的 UDH 协议头这时每条最多 67 个汉字。不加 UDH 也能发只是对方收到的是多条独立短信看内容也能懂。5.5 同一内容一小时内发太多被运营商风控现象连续发了两百条后突然大量CMS ERROR或者日志显示发送成功但客户手机收不到。原因普通手机 SIM 卡的短信通道不适合批量营销。同一内容短时间大量发送容易被短信中心风控甚至暂停短信功能。解决控制速率每条至少间隔 300ms单批控制在几百条以内。如果业务场景本身就是营销群发短信猫不是合适的通道应该走运营商或云服务商的行业短信接口有模板审核、有到达率保障。短信猫适合的是设备告警、内部通知这类低频高价值场景别拿它刷量。6. 从能发到能上线验证方法、长短信与多猫池代码能跑通和系统能上线之间还差一层验证体系。6.1 三个层次的验证方法第一层用串口调试助手手动验证模块。发送ATCSQ看信号强度响应是CSQ: rssi,berrssi在 10 以上才算可用99 表示无信号。再发ATCPIN?确认 SIM 卡就绪。这两个指令通了硬件和环境就稳了。第二层用程序单发验证编码。固定一个测试手机号分别发一条纯英文和一条中文短信确认 PDU 编码无误、对方能正常收到。第三层才是批量压测。从 100 条开始观察任务表里 SENT 比例、错误码分布、模块发热情况和卡内余额。抽样让几个同事报一下收到时间和内容确认不是“假成功”。6.2 长短信合并与多猫池扩展单只短信猫的发送速度大约每秒一到两条一天几万条的任务不适合它。需要扩容时把多个SmsModemWorker实例放进一个列表调度时优先选待发送任务最少、且健康状态正常的猫var workers new ListSmsModemWorker(); SmsModemWorker PickFreeWorker() { return workers .Where(w w.IsHealthy) .OrderBy(w w.PendingCount) .FirstOrDefault(); }多猫池里的每只猫都要有心跳自恢复某只猫连续失败次数超过阈值就把它摘除任务自动转移到其他猫。物理上还要注意 USB Hub 供电多猫同时发短信时瞬时电流大供电不足会直接让模块重启。我现在做新项目已经很少再用短信猫了但每次回看这套代码最值钱的还是那几个排查习惯查错误码、做心跳、按字符边界拆长短信。这三件事都做对了短信猫其实比云短信 API 更让人省心。希望帮到你。本文还有配套的精品资源点击获取