
简介C#短信群发源码是一套面向需要快速搭建短信群发功能的C#开发者的完整工程基于短信猫硬件通过串口与GSM AT指令完成短信批量发送常见于企业营销、通知服务等场景也适合入门者理解硬件通信的基本流程。压缩包共114个文件约721KB核心为38个C#源文件含多个窗体及业务逻辑、17个resx/resources资源文件、7个DLL依赖库、3个可执行程序另有解决方案、项目文件与Access数据库sln、csproj、mdb可直接编译运行。已有120人浏览学习该源码。包内包含企业短信群发系统的完整项目涉及串口通信、GSM协议、多线程并发发送、数据库存储、异常处理及WinForm界面设计并涵盖短信模板管理、号码列表处理、发送状态跟踪等模块同时提供多个窗体设计器文件和产品文档便于理解系统结构与二次开发。对于希望掌握C#硬件通信、批量任务处理和企业级界面设计的开发者这是不错的实践参考。1. 短信群发源码别急着跑先弄懂短信猫在C#里到底是什么很多人拿到一份基于短信猫的C#短信群发源码第一反应是打开串口、写一行AT指令、点个按钮发条短信感觉就这么简单。但真放到生产环境里你会发现中文乱码、发到一半设备无响应、SIM卡被运营商临停、端口被占用问题一个接一个。短信猫本质是一块GSM/4G通信模块不是网络短信平台它的唯一通道是串口C#与它交互靠的是AT指令而不是HTTP接口。这套源码适合谁适合做内部通知、告警推送、门店营销的低成本本地部署场景也适合需要离线、可控、不依赖云服务的.NET开发者。想把这套源码改造成稳定可用的群发系统核心不是“发短信”那几行代码而是串口状态管理、AT指令时序、PDU编码和队列调度。2. 短信猫工作原理与选型先定串口、AT指令与PDU模式2.1 短信猫的两种硬件形态C#通信方式跟着变常见短信猫分串口直连和USB转串口两种。串口直连使用RS232接口工业现场用得最多接线简单信号稳定。USB短信猫内部也是一颗串口芯片只是把串口转换成USB方便笔记本使用。C#里无论哪种最终都用System.IO.Ports.SerialPort类操作。第一步不是写发送逻辑而是确认设备枚举到的端口号。using System; using System.IO.Ports; class Program { static void Main() { string[] ports SerialPort.GetPortNames(); foreach (string port in ports) { Console.WriteLine($检测到串口{port}); } Console.ReadKey(); } }这段代码用来枚举当前系统里的所有串口运行后你会看到COM3、COM4之类的编号。USB短信猫插上后如果没有出现先检查USB驱动是否安装很多群发源码跑不起来不是代码问题而是系统里根本没有设备。using System; using System.IO.Ports; SerialPort sp new SerialPort(COM3, 115200, Parity.None, 8, StopBits.One) { NewLine \r, ReadTimeout 3000, WriteTimeout 3000, Handshake Handshake.None, DtrEnable true, RtsEnable true }; try { sp.Open(); sp.WriteLine(AT); string resp sp.ReadExisting(); Console.WriteLine(resp OK ? 设备响应正常 : $异常响应{resp}); } catch (Exception ex) { Console.WriteLine($串口打开失败{ex.Message}); } finally { sp.Close(); }参数说明波特率要与短信猫铭牌一致常见9600或115200错了就收到乱码或无响应NewLine设为\r是因为AT指令的结尾是回车SerialPort.WriteLine会把NewLine追加到命令尾部ReadTimeout和WriteTimeout避免设备卡住时无限等待DtrEnable和RtsEnable置为true是让串口的流控信号线正常工作很多短信猫需要这两根线拉高才愿意接收指令。注意不要用ReadLine短信猫返回的响应可能是AT\r\r\nOK\r\n这种格式ReadLine容易读到空行。ReadExisting简单粗暴测试时够用生产环境我们后面会讲更可靠的行读取方式。2.2 AT指令是核心Text模式和PDU模式怎么选短信猫的控制语言是AT指令集。群发最常用三条ATCMGF设置消息格式ATCMGS发送短信ATCNMI设置新消息通知。ATCMGF1是Text文本模式ATCMGF0是PDU协议数据单元模式。很多第一次写的开发者喜欢用Text模式因为代码短发送英文短信时确实方便但中文会乱码。原因在于Text模式走GSM 7Bit字符集这种字符集根本不含汉字虽然有些模块支持ATCSCSUCS2把字符集切成UCS2但兼容性参差不齐。做基于短信猫的C#群发建议直接放弃Text模式全程用PDU模式。PDU模式下短信内容会以UCS2十六进制串编码每个汉字占两个字节配合ATCMGSTPDU长度下发。虽然代码量增加但换来的是中文稳定、状态报告可解析、编排格式可控。// 设置短信猫进入PDU模式 sp.WriteLine(ATCMGF0); string result sp.ReadExisting(); if (!result.Contains(OK)) { Console.WriteLine(切换PDU模式失败); return; } // 查询SIM卡信号和运营商确认模块在线 sp.WriteLine(ATCSQ); sp.WriteLine(ATCOPS?);ATCSQ返回信号强度结果如CSQ: 18,018表示信号质量一般大于10可以正常发ATCOPS?返回当前运营商。这两条指令不是发送必需但排错时很有价值。如果模块信号差或者没读到运营商后面发一百条也全是失败。打开串口后不要急着发ATCMGS先做一次“AT热启动”序列连续发送几次AT清掉缓冲区残留然后读取并丢弃。短信猫不像新设备它可能残留上次崩溃时的半截指令。这个步骤能减少很多莫名其妙的问题。2.3 选型对照串口直连与USB转串口的C#库选择C#操作短信猫最常见做法是直接使用System.IO.Ports不依赖厂商DLL。有些一体化工控机或短信猫厂商会提供C#封装库看上去是“自带的源码”但那些DLL往往过了几个Windows版本就不更新内部异常也不透明。我一般会先写一个裸AT指令的小工具验证硬件确认能用再决定要不要二次封装。硬件形态接口C#通信类稳定性推荐场景串口短信猫RS232 DB9SerialPort高抗干扰强固定机柜、工控机、长期运行USB短信猫USB转串口SerialPort中驱动版本影响大桌面调试、小批量临时发送4G全网通短信猫串口/USB/网口SerialPort或TCP客户端高但AT指令集更复杂多卡并发、需要远程管理如果要做多卡并发每个短信猫分配一个独立SerialPort实例每只猫一个后台发送线程千万不要让多个线程写同一个串口。USB短信猫在外壳上会标明推荐波特率很多时候是115200但也有老设备只有9600写源码前先对照铭牌。等硬件链路稳定后才能真正进入C#群发源码的实现环节。3. C#短信群发源码实现从打开串口到批量发送的最小可运行工程3.1 工程结构与核心类SerialPort 并发队列一个可用的群发源码至少要拆成三层串口通信层、队列调度层、业务调用层。串口通信层只做命令下发和响应读取队列调度层保证发送不阻塞UI、顺序可控业务调用层把手机号、内容和重试策略封装成任务。这里给出最小结构。public class SmsTask { public string Phone { get; set; } public string Content { get; set; } public int RetryCount { get; set; } public int MaxRetry { get; set; } 3; public SmsStatus Status { get; set; } } public enum SmsStatus { Pending, Submitted, Delivered, Failed }SmsTask是一个任务对象Phone和Content是必填项RetryCount记录当前重试次数MaxRetry控制最多重试几轮Status用于和数据库状态机对应。public class SmsCat : IDisposable { private readonly SerialPort _port; private readonly ConcurrentQueueSmsTask _queue new(); private readonly CancellationTokenSource _cts new(); private Task _worker; private bool _disposed; public SmsCat(string portName, int baudRate) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { NewLine \r, ReadTimeout 3000, WriteTimeout 3000 }; _port.Open(); Initialize(); } private void Initialize() { _port.DiscardInBuffer(); _port.DiscardOutBuffer(); _port.WriteLine(AT); Thread.Sleep(200); _port.ReadExisting(); _port.WriteLine(ATCMGF0); Thread.Sleep(200); _port.ReadExisting(); } public void Start() { _worker Task.Run(() ProcessQueue(_cts.Token)); } private void ProcessQueue(CancellationToken token) { while (!token.IsCancellationRequested) { if (_queue.TryDequeue(out SmsTask task)) { Send(task); } else { Thread.Sleep(200); } } } public void Enqueue(SmsTask task) { _queue.Enqueue(task); } public void Dispose() { _cts.Cancel(); _worker?.Wait(1000); _port?.Close(); _port?.Dispose(); _disposed true; } }这里有几个关键设计。一是ConcurrentQueue 它保证线程安全地从队列尾部入队、头部出队满足群发任务的FIFO顺序。二是ProcessQueue单线程循环这是必须的串口不能并发写单线程消费从根源上避免了多个AT指令交叉。三是Initialize方法负责清空缓冲区并初始化PDU模式DiscardInBuffer/DiscardOutBuffer把缓冲区里的脏数据丢掉防止上一次崩溃留下的残指令干扰判断。队列空时Thread.Sleep(200)是为了不让后台线程空转打满CPU。这个间隔不要调太小否则CPU占用高也不要太大否则批量发送时任务出队会有延迟。200毫秒是平衡值。3.2 发送单条短信的两种编码Text模式与PDU模式先看Text模式群发英文验证码或纯数字可以用它但中文场景不要用。Text模式发送前要执行ATCMGF1然后ATCMGS手机号等待模块返回再写内容并以0x1A结尾。private bool SendText(string phone, string content) { _port.WriteLine($ATCMGS\{phone}\); if (!WaitPrompt()) { return false; } _port.Write(content); _port.Write(new byte[] { 0x1A }, 0, 1); string resp ReadUntil(OK); return resp.Contains(OK); }WaitPrompt方法与ReadUntil方法后面会统一实现它们的核心是逐字符读取直到遇到或OK。0x1A是ASCII码的CtrlZGSM模块收到它才认为短信内容结束。注意这里是UTF-16的字符串直接Write出去Text模式下如果内容含中文方式取决于字符集设置这也是我不推荐它的原因——行为不可预期。再看PDU模式。PDU文本串的结构为SMSC地址长度 SMSC地址 TPDU头部 TPDA消息内容。构造PDU的代码稍长但函数化之后一劳永逸。private string BuildPdu(string phone, string content) { string smsc EncodeSmsc(8613800100500); // 短信中心号码按SIM卡实际值调整 string phonePart EncodePhone(phone); byte[] ucs2Bytes Encoding.Unicode.GetBytes(content); string contentHex BitConverter.ToString(ucs2Bytes).Replace(-, ); string tpduHeader 11000D91 phonePart 0008A0; int contentLen ucs2Bytes.Length / 2; string tpdu tpduHeader contentLen.ToString(X2) contentHex; int tpduLen tpdu.Length / 2; return smsc tpduLen.ToString(X2) tpdu; }代码说明EncodeSmsc把短信中心号码转换成PDU形式EncodePhone把手机号转成BCD码形式tpduHeader中的11000D91含义是11表示TPDU类型SMS Submit、00表示消息参考号通常由模块分配、0D表示目标号码长度加1、91表示国际格式号码0008A0表示协议标识、编码方式UCS2、有效期默认。contentHex是短信内容的UCS2字节十六进制串。tpduLen计算时是半字节数除以2也就是字节数。EncodePhone有很多写法常见做法是这样的private string EncodePhone(string phone) { if (phone.StartsWith()) { phone phone.Substring(1); } if (phone.Length % 2 ! 0) { phone F; } StringBuilder sb new StringBuilder(); for (int i 0; i phone.Length; i 2) { string pair phone.Substring(i, 2); sb.Append(pair[1]); sb.Append(pair[0]); } return sb.ToString(); }这个函数把11位手机号转成PDU要求的BCD码两位一组前后颠倒如果奇数位则在末尾补F。比如13800138000处理后变成31 08 10 38 20 F0这样一组十六进制串。注意手机号前有86就直接去掉保留86开头即可。发送PDU时ATCMGS后面跟的不是短信内容而是TPDU的字节长度。然后等再写完整PDU串最后0x1A结束。TPDU长度不能把SMSC长度算进去否则模块会回复CMS ERROR 305或ERROR。这也是新手源码最常见的翻车点。3.3 批量群发C#线程池控制并发、重试与去重批量群发不能每次new一个SerialPort而是把任务批量塞进队列后台线程逐个消费。如果只有一只短信猫线程池并发没有意义因为底层只有一个串口如果有多只猫则每只猫一个SmsCat实例用Task.Run启动多个ProcessQueue。重试逻辑放在ProcessQueue里Send失败时判断RetryCount是否小于MaxRetry小于则重新Enqueue并把RetryCount加1否则更新状态为Failed。private void Send(SmsTask task) { bool ok false; try { ok SendPdu(task.Phone, task.Content); } catch (TimeoutException) { ok false; } if (ok) { task.Status SmsStatus.Submitted; Console.WriteLine($发送成功{task.Phone}); } else if (task.RetryCount task.MaxRetry) { task.RetryCount; task.Status SmsStatus.Pending; Thread.Sleep(5000); _queue.Enqueue(task); Console.WriteLine($发送失败重试第{task.RetryCount}次{task.Phone}); } else { task.Status SmsStatus.Failed; Console.WriteLine($发送失败{task.Phone}); } }失败后Sleep 5000毫秒再入队一是给模块恢复时间二是避免失败重试风暴。这个5秒不是固定值有人喜欢用指数退避第一次3秒、第二次10秒我一般用固定值就够。去重在批量导入时做读取Excel或数据库时用HashSet 去重键值用手机号加内容拼接同一号码同内容1分钟内不重复发送。4. 群发系统必做的三个关键设计限速、回执与落库4.1 发送频率控制为什么短信猫必须串行限速短信猫的“并发”是假并发一个模块同一时刻只能处理一条AT指令。群发时如果每发一条就立刻提交下一条SIM卡会因短时间大量SMS提交被运营商临时关停模块本身也会出现“”后无响应的情况。所以发送循环里需要限速。最简单的做法是每发一条后至少等待300毫秒再严格一点的可以做成令牌桶。private void SendWithRateLimit(SmsTask task) { Stopwatch sw Stopwatch.StartNew(); Send(task); sw.Stop(); int minIntervalMs 300; int remain minIntervalMs - (int)sw.ElapsedMilliseconds; if (remain 0) { Thread.Sleep(remain); } }这里300毫秒是一个经验起点。不同SIM卡和运营商对短消息中心提交频率限制不同本地卡和物联网卡差异更大。如果你发现发到几十条后设备返回CMGS但不回OK多半是提交太快把间隔调到500毫秒甚至1秒。做营销短信时宁可慢不能让SIM卡被停。限速不只是在发送循环里做还要考虑短信猫的ATCMGS命令本身有超时。所以每次发送时ReadTimeout至少3000毫秒超过就按失败处理。这能避免一条卡死的AT把整个群发队列堵住。4.2 状态报告解析判断短信是否真正送达很多人以为短信猫返回OK就是发送成功其实OK只表示短信猫接受指令。真正的送达状态会通过“状态报告”异步到达。要启用这个能力需要设置ATCNMI2,1然后在串口接收事件里识别CDS:开头的PDU。状态报告的PDU里包含MR消息参考号和STAT状态码你需要把发送时模块返回的CMGS: 中的mr保存下来与状态报告中的MR匹配。匹配上后STAT为0表示送达成功STAT为70表示失败。private int ParseMrFromSend(string response) { // 响应形如 CMGS: 32 int idx response.IndexOf(CMGS:); if (idx 0) return -1; string mrPart response.Substring(idx 6).Trim(); string[] lines mrPart.Split(,, , \r); return int.TryParse(lines[0], out int mr) ? mr : -1; }这个解析函数从ATCMGS的响应里取出MR。为什么要保存MR因为状态报告是异步的可能是几秒后到达也可能几十秒后。每条短信发送时生成一个发送记录把Mr存入数据库收到CDS后用MR反查这条记录才能精确标记为“已送达”。状态报告的PDU解析相对复杂字段顺序与SMS Submit不同。完整的解析需要按3GPP 23.040协议把PDU拆开我这里给一个生产环境常用的策略不做完整状态报告解析只判断CDS:后面的长度和STAT位是否在合理范围内。如果你确实需要精确状态建议用NuGet里的PDU解析库或者把状态报告原始PDU落库事后人工核对。这里要注意状态报告是收费功能不是所有SIM卡套餐都包含测试时先发一条看是否有CDS返回。4.3 短信记录表设计状态机字段与重试策略群发源码跑起来后必须把每次发送记入数据库否则无法对账也无法在程序重启后继续未完成任务。表设计不需要复杂但状态字段要能表达状态机CREATE TABLE SmsLog ( Id INT IDENTITY(1,1) PRIMARY KEY, Phone NVARCHAR(20) NOT NULL, Content NVARCHAR(500) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0待发送 1已提交 2已送达 3失败 RetryCount TINYINT NOT NULL DEFAULT 0, Mr INT NULL, -- 消息参考号回调匹配用 LastError NVARCHAR(200) NULL, CreateTime DATETIME2 NOT NULL DEFAULT SYSDATETIME(), SendTime DATETIME2 NULL, ReportTime DATETIME2 NULL );Status字段对应SmsTask中的SmsStatus枚举这样内存队列和数据库能对照。0待发送对应Pending1已提交对应Submitted2已送达对应Delivered3失败对应Failed。Mr字段非常重要没有它你无法处理状态报告。程序启动时从数据库里捞Status0的记录重新Enqueue进队列发送成功后把Status更新为1收到状态报告后把Status更新为2。程序中途崩溃也不用怕待发送状态还在库里重启后继续。5. 短信猫群发避坑指南5个常见故障的现象、原因与解决5.1 串口占用导致C#抛UnauthorizedAccessException现象程序执行到sp.Open()时抛出“拒绝访问”或UnauthorizedAccessException但串口明明存在。原因串口被短信猫厂商的调试工具、超级终端或者其他实例占用有时候上一次程序没有调用Close就崩溃退出句柄没释放。解决先用设备管理器确认端口再看没有其他程序占用。代码里用using块包裹SerialPort并把Close放进finally。如果是工具占用关掉工具就好如果是句柄残留重启电脑是最后的后悔药。另一种情况你打开了两个SmsCat实例操作同一个COM口也会这样。5.2 中文短信变乱码Text模式的编码陷阱现象ATCMGF1模式下发中文手机收到一组问号或乱码。原因Text模式只支持GSM 7Bit字符集部分模块支持ATCSCSUCS2切换字符集但很多模块实现不完整切换后连英文都发不出去。解决直接用PDU模式UCS2编码不要和模块的字符集死磕。如果确实只能用Text模式那就只发英文数字和符号。这个坑我第一次做群发时就踩过后来把文本编码全部统一成UCS2再没出过乱码。5.3 群发中途设备无响应串口缓冲区溢出与AT超时现象群发到第50条左右ReadTimeout开始报错或者ReadExisting永远返回空。原因模块处理能力有限如果发得快串口缓冲区里的数据没有及时读走模块的接收缓冲区溢出后面指令没被解析。解决一是发送循环里限速到300毫秒以上二是在发送每条之前DiscardInBuffer清掉噪声三是用ReadLine替代ReadExisting按行等待“OK”或“”。另外检查RtsEnable和DtrEnable是否都置为true有些短信猫在流控信号缺失的状态下会随机拒绝指令。5.4 PDU编码字节数对不上长度算错返回CMS ERROR 305现象ATCMGS长度发出后模块回CMS ERROR 305。原因长度算错常见是TPDU长度包含了SMSC部分或者UCS2字节长度没除以2。我见过有人用content.Length计算长度中文字符每字符一个单位而PDU里是两字节这就差一倍。解决严格按照“SMSC地址长度 TPDU长度 TPDU”三部分拼接TPDU长度是header加内容的总字节数。发送时ATCMGS后面只接TPDU长度不包括SMSC部分。判断方法如果所有中文短信都报305优先检查contentLen的计算如果只有部分手机号报错检查手机号奇数位补F的处理。5.5 USB转串口的短信猫在C#里端口号漂移现象短信猫第一次插上时是COM3重启后变成COM9C#源码里写死COM3就找不到设备。原因USB设备的枚举顺序不稳定Windows会动态分配端口号。解决不要写死端口用WMI查询过滤设备名。using System.Management; string FindSmsCatPort() { ManagementObjectSearcher searcher new ManagementObjectSearcher( SELECT * FROM Win32_SerialPort); foreach (ManagementObject obj in searcher.Get()) { string name obj[Name]?.ToString() ?? ; string deviceId obj[DeviceID]?.ToString() ?? ; if (name.Contains(SIM) || name.Contains(GSM) || name.Contains(USB)) { return deviceId; // 形如 COM3 } } return null; }这个函数通过Win32_SerialPort查询设备名称把包含SIM/GSM/USB关键字的串口挑出来返回DeviceID即COM号。你还可以把它和配置文件结合启动时自动识别。注意有些国产USB转串口芯片叫“Prolific USB-to-Serial”名字里没有SIM可以按你的实际设备名调整关键字。6. 把群发源码升级为Windows服务看门狗与定时任务6.1 用Worker Service把SmsCat宿主化新式做法是使用.NET Worker Service模板在里面跑一个BackgroundService把SmsCat生命周期挂到服务的StartAsync和StopAsync。这样不用自己造Windows服务基类。public class SmsWorker : BackgroundService { private readonly SmsCat _cat; private Timer _fetchTimer; public SmsWorker(IConfiguration config) { _cat new SmsCat(config[Port], int.Parse(config[BaudRate])); _cat.Start(); } protected override Task ExecuteAsync(CancellationToken stoppingToken) { _fetchTimer new Timer(_ FetchPendingSms(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10)); return Task.CompletedTask; } private void FetchPendingSms() { // 从数据库捞出待发送记录转为SmsTask并Enqueue } public override Task StopAsync(CancellationToken cancellationToken) { _cat.Dispose(); return base.StopAsync(cancellationToken); } }6.2 看门狗心跳检测与自动重连短信猫长时间运行后会“假死”发送指令不返回。看门狗线程每30秒发一条AT测试连续3次无响应就重开串口。private void Watchdog() { int lostCount 0; while (!_cts.IsCancellationRequested) { Thread.Sleep(30000); try { _port.WriteLine(AT); string r _port.ReadExisting(); if (!r.Contains(OK)) { lostCount; if (lostCount 3) { Reconnect(); lostCount 0; } } else { lostCount 0; } } catch (Exception) { lostCount; if (lostCount 3) { Reconnect(); lostCount 0; } } } }Reconnect方法先Close再重新Open然后执行Initialize。需要注意看门狗和发送循环不能同时写串口所以看门狗发送AT前要加锁或者把心跳也走发送队列。我自己的工程项目里把心跳做成最高优先级任务入队时直接ConcurrentQueue避免锁写串口的麻烦。最后说个教训我第一次做的群发源码设备不停假死一度以为是模块坏了。后来把发送间隔调到500毫秒、加上看门狗和串口重连连续跑了半个月没有重启。这条路没什么玄学把超时、限速、重试这三样做扎实系统就稳了。希望帮到你。本文还有配套的精品资源点击获取