简介:这是C#编写的企业短信群发系统源代码,基于短信猫硬件设备,面向需要快速搭建短信批量发送功能的.NET开发者。源码完整可编译运行,内置短信模板管理、号码列表处理、发送状态跟踪等模块,适用于营销通知、企业内部消息触达等场景。压缩包共114个文件,以38个cs主程序文件为核心,搭配17个resx界面资源配置、dll依赖库及exe可执行程序,另有gif/jpg图形素材和mdb数据库文件,整个rar包仅721KB,结构清晰。已有119人学习下载,项目覆盖串口通信、GSM/AT指令、多线程并发、数据库操作、WinForms界面设计等关键知识点,适合希望深入理解短信猫开发或进行二次开发的开发者参考。 短信群发这东西,听起来简单,实际做起来坑不少。尤其是用短信猫,很多人以为买回来插上就完事,结果不是乱码就是漏发,要么串口打不开,要么模块死机。这篇文章我就把自己做C#短信群发源码(基于短信猫)的完整思路、代码、踩坑过程都摊开了讲,从原理到落地,一条龙说清楚。
这套方案的核心是:通过C#操作短信猫(GSM Modem),调用底层AT指令,实现短信的批量发送、状态确认和失败重发。适合的场景很明确:企业内部通知、会员营销短信、物流提醒、故障告警等需要批量发送短信且对成本敏感的中小项目。它不依赖第三方短信平台的按条计费,一次买断硬件,资费就是SIM卡本身的短信套餐。
1. 整体设计与选型思路
1.1 短信猫的工作原理
先把底层的逻辑搞明白。短信猫本质上就是一个工业级的GSM/GPRS通信模块,最常见的是华为的MG323、EM310、SIMCOM的SIM800系列,外置一个串口或者USB转串口接口,再插一张能发短信的SIM卡就构成了一个可以独立发短信的设备。
和手机发短信不一样,短信猫没有屏幕也没有按键,它只认命令。这些命令就是AT指令集,是计算机和模块之间通信的标准语言。整个流程是这样的:C#程序通过串口把AT指令发出去,模块收到指令后返回"OK"或"ERROR",如果指令带数据请求,就返回具体的应答信息,比如"AT+CMGS"发送指令会返回一个>提示符,然后程序把短信内容发过去,最后再发送十六进制的1A表示确认结束。
这个逻辑和HTTP请求是完全不同的思维模式。HTTP无状态、请求就响应;AT指令则是有状态的会话,模块在发短信的每一个阶段都有自己的状态。刚开始做的时候容易犯的错误就是:把HTTP那种"发出-返回"的思维硬套在AT指令上,结果总是拿不到预期的响应或者拿到的响应慢了半拍。
1.2 为什么选短信猫而不是短信平台
市面上面向开发者的短信服务商不少,按条收费,接入也方便。但为什么还是有很多项目在用短信猫?核心原因就两个:成本控制和数据私有化。
成本上,如果发送量不大——比如一天几百条,用短信平台每条三到五分钱,一年下来也是一笔不小的开支。短信猫是买断制的,一个工业级模块从几十到两百块不等,一张SIM卡用企业短信套餐能发几千条,长期来看对低频发送场景很有优势。数据私有化就更直接了,手机号名单、发送内容、发送成功与否都存放在自己的服务器和数据库里,不会经过第三方平台,对某些敏感行业来说是硬需求。
当然短信猫的缺点也很明显:受信号环境影响,并发量低,一条短信发送需要好几秒,不可能像平台那样一秒发几百条。这个方案更适合"低频高价值"的发送场景,比如药品入库通知、门禁异常告警、客户预约提醒这类。
2. 核心实现与关键代码
2.1 串口通信的建立
C#里面操作短信猫,本质上就是操作串口。.NET自带的System.IO.Ports.SerialPort类就够用了,不需要额外装驱动库。关键参数就四个:波特率、数据位、停止位、校验位。
大多数短信猫出厂默认波特率是9600或115200,数据位8、停止位1、无校验。注意,有些工业模块默认是19200,这跟设备出厂设置有关。我在项目里遇到过必须先把波特率设为115200发AT指令确认模块在线,然后再切到指定波特率的奇葩情况,因为那个模块固件重置后默认值不是9600。
private SerialPort _port; private void OpenPort(string portName, int baudRate = 9600) { _port = new SerialPort { PortName = portName, BaudRate = baudRate, DataBits = 8, StopBits = StopBits.One, Parity = Parity.None, ReadTimeout = 500, WriteTimeout = 500, Encoding = Encoding.GetEncoding("ISO-8859-1") }; _port.Open(); }这里有一个很重要的细节:Encoding必须设置成ISO-8859-1而不能是默认的UTF-8。因为AT指令的应答和短信内容在文本模式下是按照单字节字符处理的,如果用UTF-8去解码,中文内容会被编码成多字节,模块收到之后根本识别不了,返回的信息也会乱掉。
2.2 发送AT指令与等待响应
发AT指令最忌讳的就是"发完就忘"。短信猫的执行是有延迟的,有的指令在信号不好的时候能迟滞好几秒。所以核心代码必须是一个"发送并等待指定响应"的同步方法,不能靠Sleep死等,否则程序会卡死,也不能发了就不管,否则下一个指令就会因为上一个还没执行完而出错。
private readonly object _lockObj = new object(); private readonly StringBuilder _buffer = new StringBuilder(); private void Port_DataReceived(object sender, SerialDataReceivedEventArgs e) { string data = _port.ReadExisting(); lock (_lockObj) { _buffer.Append(data); } } private string SendCommandWithWait(string command, string[] expectedResponses, int timeout = 3000) { lock (_lockObj) { _buffer.Clear(); _port.WriteLine(command); DateTime deadline = DateTime.Now.AddMilliseconds(timeout); while (DateTime.Now < deadline) { string current = _buffer.ToString(); foreach (string response in expectedResponses) { if (current.Contains(response)) return current; } Thread.Sleep(50); } throw new TimeoutException($"指令超时: {command}, 当前返回: {_buffer.ToString()}"); } }这里把_lockObj锁定,保证了同一时刻只有一个线程能往串口里写指令。有人会问,为什么要这样锁?因为短信猫模块本身没有并发处理能力,你前一条指令还没处理完,后一条又进来了,模块会直接返回ERROR,甚至死机需要断电重启。
2.3 短信内容的编码处理
短信内容分两种情况:纯英文/数字和包含中文。纯英文直接用文本模式AT+CMGF=1,发送的数据就是ASCII,简单粗暴。但只要内容里有中文,就麻烦了,因为GSM协议最早是为英文字符设计的,中文不在它的内置字符集里。
处理办法是把短信内容转成UCS2编码(也叫UTF-16 BE),然后用PDU模式发送。C#里面用UnicodeEncoding就能转:
private string StringToUCS2(string message) { UnicodeEncoding ucs2 = new UnicodeEncoding(true, false); byte[] bytes = ucs2.GetBytes(message); StringBuilder sb = new StringBuilder(); foreach (byte b in bytes) { sb.Append(b.ToString("X2")); } return sb.ToString(); }UnicodeEncoding(true, false)的第一个参数true表示大端字节序,false表示不输出BOM头。UCS2编码要求每两个字节表示一个字符,大端序是传输标准,顺序不能搞反。很多新人栽在这里:用BitConverter转出来的字节序是反的(小端),结果发出去的中文是一串乱码。
2.4 PDU模式下的短信发送
前面说的是文本模式,它虽然简单,但处理中文很麻烦。更通用的做法是直接走PDU模式。PDU是一个二进制的协议数据单元,短信中心号、发送方号码、内容编码、有效期等信息都按特定格式打包在一起。
C#实现的发送核心代码,这里给出一个已经跑通的版本,三个核心参数:短信息中心号码、目标手机号、消息内容。
public string BuildPdu(string smscNumber, string phoneNumber, string message) { // 1. 短信中心号码处理:86开头后补F补齐奇数位 string smsc = "00" + ParityChange("86" + smscNumber); // 2. 发送方号码处理:86 + 手机号 string from = "00" + ParityChange("86" + phoneNumber); // 3. 协议头:14表示基本参数+号码+协议标识+编码方案+时间 string head = "1100"; // 4. 有效期:A7表示最高优先级,24小时 string validity = "A7"; // 5. UCS2编码的消息内容 string content = StringToUCS2(message); int contentLength = content.Length / 2; // PDU全文 = 短信中心长度 + 短信中心 + 消息头 + 发送号码长度 + 发送号码 + 协议头 + 有效期 + 内容长度 + 内容 string pdu = string.Format("{0:X2}{1}{2}{3:X2}{4}{5}{6}{7:X2}{8}", smsc.Length / 2, smsc, head, from.Length / 2, from, validity, contentLength, content); return pdu; }发送时用AT+CMGS=<内容长度>(内容长度是指短信内容的字节数,不是整个PDU的长度),等模块返回>后,把PDU字符串发过去,再发送十六进制的1A结束。
public bool SendByPdu(SmsData sms) { string pdu = BuildPdu(sms.Smsc, sms.Phone, sms.Content); string cmd = "AT+CMGS=" + (pdu.Length / 2); string response = SendCommandWithWait(cmd, new[] { ">" }, 2000); if (!response.Contains(">")) return false; _port.Write(pdu + (char)26); string final = WaitForResponse(new[] { "OK", "ERROR", "+CMS ERROR" }, 10000); return final.Contains("OK"); }注意(char)26就是0x1A的ASCII字符,用_port.Write直接写进去。在C#里不能用WriteLine写,因为WriteLine会自动追加换行符,模块会把换行也算进去,导致PDU解析失败。
3. 实操过程与核心环节实现
3.1 硬件选型与SIM卡准备
做这个项目之前,硬件选择是最容易踩坑的第一步。网上能买到的模块大致分三类。
第一类是早年很流行的USB短信猫,插头长得像U盘,插上电脑就多出一个COM口。这类模块优点是便宜、免拆机,缺点也很明显:USB转串口芯片质量参差不齐,驱动兼容性差,大批量发送时容易掉设备。如果你只是学习练手或者公司内部几十条的通知,可以选这个。
第二类是串口短信猫,外壳有DB9串口接口,用USB转串口线跟电脑连接。这类模块一般是工规级的设计,稳定性比USB短信猫强不少,发送吞吐量也更好。我就是用这个方案跑的,连续发几百条没出过设备掉线的事。
第三类是原生的GSM/GPRS开发板或模块,比如SIM800C、SIM7600CE这种,需要自己做电源电路、电平转换和天线接口。难度上了一个台阶,但是灵活性最高,适合集成到自己的硬件设备里。
表格对比一下:
| 方案 | 成本 | 稳定性 | 开发难度 | 适用场景 |
|---|---|---|---|---|
| USB短信猫 | 低 | 中 | 低 | 学习、小批量通知 |
| 串口短信猫 | 中 | 高 | 低 | 企业生产环境 |
| GSM开发板 | 高 | 高 | 高 | 嵌入式集成项目 |
SIM卡的选择也马虎不得。必须找运营商开通短信功能的卡,如果是要发大量内容的营销短信,还要确认是否有企业短信资质,否则用个人卡发营销内容很容易触发运营商的垃圾短信拦截机制,发不出去。
3.2 电源与天线的细节
很多人做完硬件接线后,发一两条好好的,连续发几条后模块突然没响应,重启又好了。很大概率是电源问题。
GSM模块在发射信号的瞬间电流峰值高达2A,如果电源的电流输出能力不足,电压就会被拉低,模块直接重启或死机。所以电源纹波要小,额定电流最好在3A以上,线材也要够粗够短。天线也是,模块工作在高频,天线接口要有良好的射频匹配和接地屏蔽,这样才能保证信号质量和稳定的发送成功率。
我实测过一个项目,天线放在金属机箱内部,信号强度始终只有一格,短信发送失败率超过30%,把天线引到机箱外面后,成功率立刻回到99%。这类问题用软件很难发现,所以硬件阶段就排掉,能省掉后续大量排障时间。
3.3 发送调度与队列设计
短信猫是串行设备,同一时刻只能发一条短信。而群发业务又是一个典型的生产者-消费者模型:主线程不断把待发送短信丢进队列,后台的发送线程从队列里取出一条,完成AT指令的全流程后,再取下一条。
public class SmsQueue { private readonly ConcurrentQueue<SmsTask> _queue = new ConcurrentQueue<SmsTask>(); private readonly AutoResetEvent _signal = new AutoResetEvent(false); private readonly SmsModem _modem; public SmsQueue(SmsModem modem) { _modem = modem; var thread = new Thread(ProcessLoop) { IsBackground = true }; thread.Start(); } public void Enqueue(SmsTask task) { _queue.Enqueue(task); _signal.Set(); } private void ProcessLoop() { while (true) { _signal.WaitOne(); while (_queue.TryDequeue(out SmsTask task)) { try { bool success = _modem.SendByPdu(task.ToSmsData()); task.Result = success ? "成功" : "失败"; } catch (Exception ex) { task.Result = $"异常: {ex.Message}"; } SaveToDatabase(task); Thread.Sleep(200); // 模块处理间隔 } } } }这个设计看着简单,但它解决了两个实际问题。第一,短信发送是慢操作,一条至少三秒,如果直接在UI线程里同步发送,界面必卡死。第二,任务入库和短信发送解耦,即使程序中途崩溃,数据库里的任务状态也可以用来做恢复和补发。
3.4 失败重试与状态记录
短信发送失败的原因五花八门:SIM卡欠费、信号弱、对方关机、短信中心返回错误代码等等。所以发送结果必须详细记录到数据库,便于排查和精确补发。
数据库表至少要有这几个字段:
CREATE TABLE SmsLog ( Id INT IDENTITY PRIMARY KEY, PhoneNumber VARCHAR(20), Content NVARCHAR(500), SendTime DATETIME, Status VARCHAR(10), ErrorCode VARCHAR(50), AttemptCount INT DEFAULT 0 );重试逻辑不宜无脑重试超过两次。因为如果模块本身出了问题,比如信号差导致大量失败,不断重试只会加深积压。比较好的做法是:第一次失败先检查错误码,如果错误码是"SIM卡未注册"或"内存已满",先重置模块再重试;如果错误码是"对端关机",这个重试没有意义,直接标记失败。重试间隔可以设计成指数退避,第一次3秒、第二次10秒、第三次30秒,超过三次就不再自动处理,进入人工确认队列。
4. 常见问题与排查技巧实录
4.1 模块无响应或返回乱码
这个在串口调试阶段极其常见。先确认串口是不是被占用了,比如你去打开设备管理器双击COM口查看属性,一些调试工具也会占住串口。典型的案例是:程序启动时打开串口失败,提示"拒绝访问"。
然后是波特率问题。如果设置不对,你发AT过去,模块要么没反应,要么返回不可读的乱码。可以通过尝试几个常见的波特率组合来定位:9600、19200、115200,发AT等OK,哪个通了就用哪个。
再有就是模块本身的串口线的问题,特别是DB9线,有几根是交叉线,有几根是直连线,低级错误却经常让人排查半天。
4.2 中文短信全是乱码
这种问题十有八九是编码转换问题。如果你用的是文本模式,那就是AT+CSMP设置不对,或者发送的编码方式根本就不是UCS2,直接用GB2312字节流当PDU发了。PDU模式下要严格按照XX十六进制字符串拼接格式,且内容长度必须是字节数,不是字符数。
我给出一个自检方法:在程序里把要发的消息转成UCS2后,先打印出来看是否符合Unicode码点。比如"你好"对应的UCS2是4F606D(不带空格),你打印出来必须是这个十六进制序列,如果出来的是604F266D这种倒序,那就是大小端方向搞反了。
4.3 明明发送成功但对方收不到
这是最让人抓狂的问题。AT指令返回OK,数据库记了成功,但用户那边就是收不到。排查思路要分成几条线并行。
先看是不是模块GSM网络信号太弱。发送时信号强度AT+CSQ返回值小于10的基本就是信号盲区,短信虽然提交成功了,但会被短信中心延迟发送甚至丢弃。再看是不是被运营商风控了。连续短时间给不同号码发送相同内容的短信,会被运营商的垃圾短信过滤系统拦截,这部分短信发送后看起来"OK",实际没有投递成功。需要控制发送频率,比如同一内容每分钟不超过10条,每条间隔5到10秒,发送时段也避开凌晨这样的敏感时间窗口。
4.4 长时间运行后发送越来越慢
这个我有过切身教训。程序跑了一天后,发短信越来越卡,刚开始一条三秒,到后来一条十几秒甚至超时。后来排查下来有两个原因:一个是队列里堆积了大量失败重试的任务,不断重试把正常任务挤在后面;另一个是模块串口缓冲区里残留了上一次的大量数据,每次发送前先清空缓冲区再发指令,问题明显缓解。
要特别说明一下短信发送的官方限流。运营商短信中心对单卡发短信有频率限制,当你发得太多太快,短信中心会临时限制该号码的短信业务,此时模块会返回类似+CMS ERROR:302(SIM ME short message storage full)或者干脆长时间不给响应。做群发功能,频率控制是必须做的,别以为设备快就拼命地塞。
5. 群发策略与合法合规注意事项
短信猫虽然能发几条、几百条甚至上千条,但它的本质还是民用/工业级通信设备,跟运营商正规的多通道短信平台不同。所以群发场景下,一定要特别注意发送策略和合规问题。
频率控制是刚需。我建议按下面这个经验值来控制:
| 限制项 | 推荐值 |
|---|---|
| 单条间隔 | 200-300ms |
| 同内容每分钟 | 不超过10条 |
| 单卡日发送量 | 300条以内 |
| 连续发送总时长 | 不超过2小时 |
为什么单卡一天不超过300条?因为运营商的阈值检测是动态的,不同地区、不同卡类型不一样,但超过一定量后轻则限制短信功能,重则停机。宁可发得慢一点,也不要让卡被关了。
合规方面也必须讲清楚。短信群发涉及两个核心原则:一是必须有用户授权,群发营销类短信需要接收者明确同意,否则就是垃圾短信;二是发送内容不得涉及违法信息,发前务必经过内部内容审核。有条件的公司,还应该按国家相关规定办理正规资质。这不是套话,是做这类系统绕不过去的红线,开发之前就要在需求层面确定好赠送短信用途的合规性。
我自己在做类似系统时,都会在群发前把号码池做一次过滤,剔除历史投诉过的、退订过的、无效的号码,从源头降低被投诉举报的概率。
6. 项目扩展与优化方向
短信猫方案做到能用不难,但要做到"好用",还要几个方向可以优化。
多通道并发是提升发送量的常见做法。一台电脑用USB HUB接多个短信猫,每个猫一张卡,程序里面做一个简单的负载均衡,哪个猫空闲就给它分配任务。但要注意每张卡的发送量和频率都要独立控制,不能把总频率限制在几台设备之间共享,否则还是会被运营商识别。
数据统计功能也得跟上。发送成功率按小时统计、失败原因占比分析、各号码段的表现差异,这些数据不仅能体现系统的运行状况,还能反过来指导发送策略的调整。我用的方案是把发送日志实时汇总到一张统计表,用定时任务每天出报表,对接企业自身的报表平台即可。
还有一点值得做的,是把发送模块抽成独立的Windows服务。这样即使没有用户登录,程序也能在后台定时发送。配合一个控制台管理页面,运营人员可以随时查看队列状态和发送结果,不用每次发短信都要打开一个窗口程序。
按我长期使用的经验,一套短信猫群发系统稳定运行的关键,不在于代码写得多花哨,而在于:TE指令流程是否可靠、重试机制是否合理、硬件电源信号是否稳定、频率控制是否严格。这四个环节把控住了,短信发送成功率长期维持在98%以上是没问题的。
最后再分享一个很多人忽略的小技巧:给模块写一个简单的"看门狗"机制。每发50条短信后,主动发一条AT指令探测模块状态,如果连续探测3次都无响应,就通过串口DTR置位或者继电器断电的方式给模块做一次硬重启,然后重新初始化。这个操作能解决95%以上的模块长期运行死机问题,比任何代码层面的优化都管用。
本文还有配套的精品资源,点击获取