简介:基于短信猫的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相关指令,下单前跟卖家确认支持
AT+CMGF=0、AT+CMGS。 - 供电和天线:优选带独立电源适配器和外置天线的型号,发射功率有保障。
- 串口电平:部分模块是TTL电平,需要转USB或串口卡;工业级模块很多直接出RS232,工控机上更方便。
还要特别注意SIM卡:普通手机SIM卡、物联网卡混着用都行,但有些纯流量物联网卡不支持短信功能,必须确认能收发短信再批量买。我遇到过一张卡插上去能注册网络但一发送就返回ERROR,查了半天是SIM卡的短信功能没开通,这个跟代码无关,纯业务层面的坑。
2. PDU模式原理:中文短信能不能发,就看这里
2.1 Text模式和PDU模式怎么选
短信猫发短信有两种常用模式:Text模式和PDU模式。
AT+CMGF=1是Text模式,直接发ASCII文本,实现简单,发英文、数字没问题,但发中文基本都会乱码,因为中文字符编码和模块内部字符集转换容易出岔子。AT+CMGF=0是PDU模式,所有内容以十六进制字节流发送,中文用UCS2编码,这是目前发中文短信最可靠的方式。
项目里只要涉及中文,无脑选PDU模式。
2.2 一条完整的PDU短信长什么样
以给13800138000发送内容“测试”为例,拆分一条完整PDU:
00 11 00 0D 91 68 31 08 10 83 00 F0 00 08 AA 04 6D 4B 8B D500:短信中心(SMSC)地址长度,0表示使用SIM卡默认短信中心,简单省事。11:TP-MTI(消息类型)为普通短信提交,TP-RD(拒绝重复)置1,保证相同短信不会重复接收。00:TP-MR,消息参考号,单条发送时一般填0。0D:目标地址长度,这个数字不是手机号位数,是国际格式号码的十六进制长度。8613800138000长度13,十六进制就是0D。91:目标地址类型,91表示国际格式(号码前带+86)。68 31 08 10 83 00 F0:目标号码的BCD码反转,86 13 80 01 38 00 0F每两位交换后得到。00:TP-PID,协议标识,普通短信填0。08:TP-DCS,编码方案。08表示UCS2(16位Unicode)编码,这是中文短信的关键。AA:TP-VP,有效期,AA表示4天,可根据需求调整。04:TP-UDL,用户数据长度。UCS2编码下“测试”两个汉字占4个字节,所以是04。6D 4B 8B D5:“测”的Unicode是0x6D4B,“试”是0x8BD5,按BigEndian组合后转十六进制。
这里最容易被新手卡住的是:AT+CMGS后面跟的长度,不是整条PDU的长度,而是去掉SMSC字段后TPDU部分的字节数。上面的例子中,从11开始到D5结束,共25字节?我算一下:11(1)+00(1)+0D(1)+91(1)+7个号码字节+00(1)+08(1)+AA(1)+04(1)+4个内容字节,一共1+1+1+1+7+1+1+1+1+4 = 19字节。加上前面SMSC长度字段00是整条PDU20字节,但AT+CMGS的参数是19。
2.3 短信猫的基础状态查询
写代码之前,先用串口助手手动验证模块状态,避免代码跑半天发现是硬件问题:
AT:返回OK,确认模块通信正常。AT+CPIN?:返回+CPIN: READY,确认SIM卡识别成功。AT+CSQ:返回+CSQ: 15,0,第一个数字是信号强度,范围0-31,数值越高越好。低于10说明信号很差,低于5基本发不出去。AT+CSCA?:查询短信中心号码。如果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-DCS,UCS2 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); } }这段代码里你会发现一个细节:AT+CMGS的长度参数用的是tpduLength,也就是去掉SMSC长度字节后从11开始的字节数。我在第一次写的时候直接把fullPdu.Length / 2传进去,结果模块一直返回ERROR,调试了很久才发现多算了一个字节。这也是PDU模式最常见的错误来源之一。
还有一点补充:上面的EncodePhone函数里,电话号码长度我写了phone.Length,这其实不对,应该是国际格式后的长度。如果你传入的是13800138000,那它转换成8613800138000之后长度才是13。正确写法是计算national.Length,上面为了简洁省略了,实际务必改过来。
3.3 发送单条短信:写指令、等提示符、收确认
单条短信的发送流程是严格分三步的,顺序不能乱:
- 设置PDU模式:
AT+CMGF=0 - 发送
AT+CMGS=<TPDU长度>,等待模块返回>提示符 - 追加发送PDU十六进制字节流,最后以
0x1A(Ctrl+Z)结尾,等待+CMGS: <序号>或者ERROR
public bool SendSms(string phone, string content) { var (pdu, tpduLength) = PduCoder.Encode(phone, content); if (tpduLength > 140) // UCS2编码下最多70个汉字 { Console.WriteLine("短信内容过长,单条不能超过70个汉字"); return false; } SendCommand("AT+CMGF=0\r"); string resp = SendCommand($"AT+CMGS={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模式,AT+CMGL=4可以列出所有未读短信,解析也是逆向的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 ConcurrentQueue<SmsMessage> _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卡欠费或短信中心故障,重试再多也是白搭,还占着队列影响后续短信发送。
还有一个实用技巧:开通短信状态报告AT+CSMP=17,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模块在发射瞬间峰值电流能到2A,USB口供电能力有限,电压一掉模块就重启,表现为COM口消失。
解决办法很简单:换带独立电源适配器的工业级模块,或者用带外部供电的USB HUB。从那以后我再也没有在项目里推荐过USB免驱款,图省事的结果就是后续维护麻烦。另外,程序里要监听串口状态,定期枚举SerialPort.GetPortNames(),发现串口丢失就尝试重新打开。
5.2 信号强度决定一切
短信发不出去,第一个查的是AT+CSQ。我遇到过客户把短信猫塞进金属机柜深处,天线紧贴配电柜铁板,信号强度直接报+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 Status=0,发送成功后更新状态,失败记录错误原因。这样串口资源、发送队列、SIM卡状态都和业务系统完全解耦,后续就算把短信猫换成云平台,也只需要改服务内部实现,业务表结构完全不用动。
我在实际项目里就是用这套结构,把一个短信猫服务跑成了Windows服务,搭了一个简单的管理界面看发送日志和失败统计,稳定运行了大半年没再出过幺蛾子。如果你正在做类似的需求,这套基于PDU编码的串口链路、队列发送架构,以及上面那些经验,应该能帮你省下不少排查的时间。
本文还有配套的精品资源,点击获取