简介:这是一份基于短信猫的C#短信群发完整工程源码,面向需要快速搭建短信批量发送功能的.NET开发者与中小企业项目。压缩包共114个文件,包含38个C#源代码文件、17个resx/resources资源文件、7个DLL库与3个可执行程序等,整体仅721KB,结构小巧且完整;随包附带MDB数据库、解决方案文件及界面设计器文件,下载后可直接编译运行,省去从零搭建的繁琐步骤。目前已有120人学习下载,适合正在研究串口通信、GSM AT指令、多线程并发发送以及WinForm界面设计的开发者。源码涵盖登录权限管理、短信模板维护、号码列表处理、发送状态跟踪、客户与用户管理等企业级功能模块,既可作为课堂练习的完整范例,也能在实际业务中作为短信通知、营销群发的基础框架,便于逐步调试、按需二次扩展。
1. 短信群发其实不复杂:一套源码解决发信链路
很多人对短信群发的印象停留在云平台API或者需要牌照的SP通道,实际上在局域网内部、工厂告警、柜面通知这类场景里,硬件短信猫加C#源码仍然是最稳、最可控、最不依赖外部网络的一条路。这套C#短信群发源码核心价值在于:它把串口操作、AT指令交互、PDU中文编码、发送队列整条链路都写好了,拿到手改改串口号和短信内容就能跑起来,特别适合需要定时批量发通知、但又不想把数据交给第三方平台的内部系统。下面先讲清短信猫工作的底层逻辑,再逐层拆代码里的关键实现,最后把我在实际部署中踩过的坑挨个列出来。
2. 硬件与串口接入:短信猫选型、SIM卡准备和串口参数
2.1 短信猫到底是什么,和手机发短信有什么区别
短信猫本质上就是一个没有屏幕和按键的GSM模块,最常见的是基于西门子TC35、华为MG323或者中兴MG815这类工业级模块制作的。它通过串口(RS232或者USB转串口)和电脑连接,电脑用AT指令控制它完成发短信、收短信、查信号等操作。和手机相比,它胜在可以7x24小时通电运行、模块级稳定性更高、不会被锁屏杀掉进程,而且多个短信猫可以并联扩展通道数。
我第一次接触这个项目时也犹豫过:现在云短信那么便宜,为什么还要用短信猫?后来跟一个做设备告警系统的朋友聊才明白,很多工厂的机房是内网环境,服务器不能出公网,云平台SDK根本调不通。而短信猫只需要一根串口线、一张SIM卡、一个电源,完全物理隔离,数据不出内网,对某些有合规要求的场景反而是最优解。
选型时要注意以下几点:第一,优先选串口直连的型号,USB转串口的芯片偶尔会掉线,量产环境里不如原生串口稳;第二,确认模块支持中文短信,也就是支持PDU模式,这个后面代码里会用到;第三,电源电流至少要1A,很多短信猫瞬时发射电流能达到2A,用普通USB口供电容易重启。
2.2 SIM卡准备:不关PIN码就是第一道坎
新买来的SIM卡默认都会启用PIN码校验,而短信猫上电后是无法像手机那样弹出输入框让你输PIN码的,结果就是模块一直返回+CPIN: NOT INSERTED或者SIM PIN required,看起来像卡没插好,实际上只是没解锁。
解决办法是:先用任意一部手机把这张SIM卡插进去,在设置里关闭PIN码校验(有些手机叫“SIM卡锁”),然后拔出来插回短信猫。这一条极其重要,因为几乎每个第一次用短信猫的人都会在这个地方卡半小时。
2.3 串口参数:所有数据都走这一条8针链路
短信猫的串口参数在大多数模块上是固定的:波特率9600、数据位8、停止位1、无校验、无流控。C#里我用的是System.IO.Ports.SerialPort,这是.NET自带的串口库,不需要引入第三方DLL。设置串口参数的时候要注意ReadTimeout和WriteTimeout至少要给500毫秒,否则模块响应慢一点就抛超时异常。
using System.IO.Ports; SerialPort sp = new SerialPort(); sp.PortName = "COM3"; // 根据设备管理器里的实际端口修改 sp.BaudRate = 9600; // 短信猫固定波特率 sp.DataBits = 8; sp.StopBits = StopBits.One; sp.Parity = Parity.None; sp.Handshake = Handshake.None; // 短信猫默认无流控 sp.ReadTimeout = 1000; // 毫秒,模块响应慢时不至于秒断 sp.WriteTimeout = 1000; sp.Open();这段代码的逻辑并不复杂,核心是PortName要和设备管理器里看到的端口号一致,Handshake必须设为None,因为短信猫通常不使用RTS/CTS流控,如果误开了流控,会出现能写不能读的诡异现象。ReadTimeout和WriteTimeout我习惯给1000毫秒,实测TC35模块冷启动后第一次AT指令响应可能需要2秒,所以如果你用的是老模块,建议放宽到1500。
2.4 端口识别与权限:程序跑起来之前的小检查
在Windows上部署时,打开设备管理器,在“端口(COM和LPT)”下找到对应的COM号。如果用的是USB转串口线,还需要确认驱动装好,常见的是CH340或者CP2102芯片的驱动。在Linux服务器上部署时,短信猫通常映射为/dev/ttyUSB0或/dev/ttyS0,而且当前用户必须加入dialout组才能获得访问权限,这也是新手常遇到的问题。
# Linux下查看串口设备 ls -l /dev/ttyUSB* # 如果权限不足,将当前用户加入dialout组 sudo usermod -a -G dialout $USER # 重启会话后生效3. AT指令链路:初始化、信号检测与PDU编码
3.1 上电初始化:先打招呼再谈业务
短信猫的上电初始化顺序是有讲究的。模块启动需要时间,不能一打开串口就发短信指令,否则收不到OK回执。我惯用的流程是:打开串口 → 等待2秒 → 发AT测试通信 → 等待OK→ 发AT+CMGF=0设置PDU模式 → 等待OK→ 发AT+CSQ查信号强度 → 全部通过后才进入短信发送循环。
private bool InitializeModem() { // 打开串口后先给模块一点启动时间 Thread.Sleep(2000); // 第一步:发送AT指令,看模块是否响应 sp.WriteLine("AT\r"); string response = sp.ReadExisting(); if (!response.Contains("OK")) return false; // 第二步:设置PDU模式,0表示PDU,1表示Text模式 sp.WriteLine("AT+CMGF=0\r"); Thread.Sleep(500); response = sp.ReadExisting(); if (!response.Contains("OK")) return false; // 第三步:查询信号强度,返回格式 +CSQ: rssi,ber sp.WriteLine("AT+CSQ\r"); response = sp.ReadExisting(); // rssi大于10时信号可用,小于5基本发不出去 return response.Contains("+CSQ:") && ParseSignal(response) >= 5; }这里最关键的是每一句指令之后必须Sleep一段时间再读,因为串口是异步通道,模块处理指令需要时间,你立刻读往往只读到空串。另外AT指令必须以\r结尾,有些模块兼容\r\n,但最稳妥的还是\r。信号强度AT+CSQ返回的第一个数字范围是0到31,10以上通常可以正常发信,5以下经常出现发送超时。
3.2 为什么必须用PDU模式:中文短信的唯一路径
很多短信猫同时支持Text模式和PDU模式,其中Text模式只能发英文和数字,发中文会出现乱码。原因是Text模式下GSM模块直接使用7bit或8bit编码,而中文属于UCS2字符集,必须用PDU封装成十六进制字节串。所以做中文短信群发,AT+CMGF=0是绕不开的。
PDU编码的本质是把短信中心号码、目标号码、协议头、用户数据全部转成十六进制字符串,然后拼接成一个长串,通过AT+CMGS发送。其中最麻烦的是号码反转:中国移动的短信中心号码通常是+8613800270500,需要去掉加号后按两位一组反转,奇数长度时末尾补F。目标手机号的处理同理。
下面这段是判断当前短信猫到底支不支持中文的关键步骤:先切到Text模式发英文,再切回PDU模式发中文,如果两种模式都能成功,说明模块是完整版;如果Text模式下中文直接显示成乱码,而PDU能发通,说明模块本身没问题,只是之前没有正确切换模式。
3.3 PDU编码核心代码:从字符串到十六进制字节串
在C#里实现PDU编码,我一般分成三个函数:EncodePhoneNumber处理号码反转,EncodeUCS2把中文字符串转换成UCS2十六进制,BuildPDU负责组合整个PDU串。下面这个示例可以直接搬到你的项目里:
// 号码反转:+8613800270500 -> 683108200705F0 public static string EncodePhoneNumber(string number) { if (number.StartsWith("+")) number = number.Substring(1); // 奇数长度末尾补F,凑成偶数 if (number.Length % 2 == 1) number += "F"; StringBuilder sb = new StringBuilder(); for (int i = 0; i < number.Length; i += 2) { sb.Append(number[i + 1]); sb.Append(number[i]); } return sb.ToString(); } // 中文转UCS2十六进制(Big-Endian) public static string EncodeUCS2(string text) { StringBuilder sb = new StringBuilder(); byte[] bytes = Encoding.BigEndianUnicode.GetBytes(text); foreach (byte b in bytes) { sb.Append(b.ToString("X2")); } return sb.ToString(); } // 组合完整PDU:短信中心 + 11开头 + 目标号码长度 + 目标号码 + 协议头 public static string BuildPDU(string smscNumber, string targetNumber, string content) { string smsc = EncodePhoneNumber(smscNumber); string smscLength = (smsc.Length / 2).ToString("X2"); string target = EncodePhoneNumber(targetNumber); string targetLength = (target.Length / 2).ToString("X2"); string pdu = "1100" + targetLength + target + "000800"; // 00=短消息,08=UCS2 string contentHex = EncodeUCS2(content); string contentLen = (contentHex.Length / 2).ToString("X2"); return smscLength + smsc + pdu + contentLen + contentHex; }参数说明:EncodePhoneNumber里做的反转是PDU协议规定的“半字节对调”,类似把电话号码的奇偶位互换,末尾补F是为了凑整字节。EncodeUCS2使用Encoding.BigEndianUnicode得到的就是标准的UCS2编码,顺序就是PDU要求的Big-Endian。BuildPDU里的1100代表MTI=1且有效期为默认,000800中00表示无协议头,08表示UCS2编码。如果你要发英文短信,把编码方式改成00(7bit)并且内容不转UCS2即可,但群发场景下中文是刚需,所以直接统一用UCS2最稳妥。
3.4 发送流程:拼接、计算长度、等待 > 提示符
PDU串拼接好之后,通过网络调试助手或者串口调试先手工发一次,确认模块返回>提示符,再发送十六进制的1A(即Ctrl+Z)表示结束。这一步代码里体现为两个独立的Write,中间要等待模块返回>字符,不能一次性写完,否则模块会直接丢弃指令。
public bool SendSms(string targetNumber, string content) { string smscNumber = "8613800270500"; // 短信中心号码,按当地运营商修改 string pdu = BuildPDU(smscNumber, targetNumber, content); // AT+CMGS=<PDU总长度> int pduLength = (pdu.Length - 8) / 2; // 减去smsc长度字节和8是协议头的半字节数 sp.WriteLine("AT+CMGS=" + pduLength + "\r"); // 等待 ">" 提示符 string response = sp.ReadExisting(); if (!response.Contains(">")) return false; // 发送PDU串,并以Ctrl+Z结尾 sp.Write(pdu + "\x1A"); Thread.Sleep(2000); response = sp.ReadExisting(); // 成功返回 +CMGS: <编号>,失败返回 +CMS ERROR return response.Contains("+CMGS:"); }这里的计算逻辑是:整个PDU串里,开头两个字符是短信中心号码的长度字段,接着是短信中心号码本身,剩余部分全是TPDU。AT+CMGS需要的参数是TPDU的字节数,所以要先减去短信中心长度字段和数据。我第一次写的时候直接用整个PDU的长度除以2,结果模块一直报错,后来打印出来一算才发现自己多算了。建议你调试时把pdu和pduLength都打印到控制台对比一下,这种长度性的错误肉眼一看就能定位。
4. C#核心代码:串口交互、PDU编码与发送流程
4.1 串口通信的读写模型:为什么不能同步等待
在C#里写短信猫程序,最容易犯的错误是把串口当网络Socket一样同步读。实际上短信猫的响应时机是不固定的,开机后第一次响应可能需要2秒,而信号波动时AT+CMGS的响应甚至可能拖到10秒。我推荐用事件驱动或独立线程循环来收数据。下面是一个最简的事件订阅模型:
// 订阅串口数据接收事件 sp.DataReceived += new SerialDataReceivedEventHandler(OnDataReceived); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { string data = sp.ReadExisting(); // 把收到的内容追加到缓冲区 System.Threading.Monitor.Enter(lockObj); buffer += data; System.Threading.Monitor.Exit(lockObj); }逻辑很简单,模块返回什么我们就收什么,放入公共缓冲区,后续业务代码再从缓冲区里查找关键字。这里lockObj是提前定义的静态对象,因为串口事件回调运行在后台线程,而业务代码运行在UI线程或主线程,不加锁会出现缓冲区数据错乱。参数上要注意的是ReadExisting()会把当前缓冲区里的所有字符一次性读走,所以即使模块只回了一半数据,也不会卡在底层IO上。
4.2 发送队列:串口是单工通道,别让线程打架
短信猫的串口是半双工通道,同一时间只能有一个指令在链路上跑。如果多个线程同时调用SendSms,轻则指令交错导致模块解析失败,重则一张SIM卡被频繁写入造成SIM卡锁死。我在项目里用BlockingCollection实现一个先进先出的发送队列,所有短信请求先入队,一个后台线程按顺序消费。
private BlockingCollection<string[]> queue = new BlockingCollection<string[]>(); // 业务线程调用:入队即返回,不等待发送完成 public void EnqueueSms(string phone, string content) { queue.Add(new string[] { phone, content }); } // 后台消费线程 public void ProcessQueue() { foreach (var item in queue.GetConsumingEnumerable()) { bool result = SendSms(item[0], item[1]); if (!result) { // 失败重试,最多3次 for (int i = 0; i < 3 && !result; i++) { Thread.Sleep(1000); result = SendSms(item[0], item[1]); } } Thread.Sleep(500); // 两条短信之间给模块一点缓冲时间 } }BlockingCollection的GetConsumingEnumerable会阻塞等待新元素,因此消费线程不会空转。这里故意在每条短信之间加500毫秒的Sleep,是给模块留出从发送态回到空闲态的时间。有些模块在连续发信时如果不加间隔,会在第5条左右开始返回+CMS ERROR: 304(SIM卡忙)。这个500毫秒是经验值,你可以在实际场景里调大到800或1000,群发速度的瓶颈不在代码而在模块本身。
4.3 完整调用示例:串口初始化到群发30条
把上面的模块拼起来,一个最小可运行的C#控制台程序大概是这样的结构:
class Program { static SerialPort sp; static BlockingCollection<string[]> queue; static void Main(string[] args) { sp = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); sp.Open(); Thread.Sleep(2000); if (!InitializeModem()) { Console.WriteLine("短信猫初始化失败,请检查串口与SIM卡"); return; } queue = new BlockingCollection<string[]>(); Thread consumer = new Thread(ProcessQueue); consumer.IsBackground = true; consumer.Start(); // 模拟群发:从Excel或数据库读取号码后逐个入队 for (int i = 0; i < 30; i++) { string phone = "13800000000"; string content = "您的验证码是123456,5分钟内有效"; queue.Add(new string[] { phone, content }); } Console.ReadKey(); } }如果你要把这个程序接到自己的系统里,只需要替换Main里的号码列表来源,比如从SQL Server的某个表里读取到期用户手机号,然后调用EnqueueSms入队即可。整个程序的核心价值就在于你把所有号码都丢进队列,后台线程会一条接一条地发,即使某一条失败了也不会阻塞后续短信。
4.4 日志与状态回读:调试的好帮手
上线之后你会发现,看不到模块的状态就等于盲人摸象。我习惯在每次指令交互后把发送的指令和收到的响应都写进日志文件。下面的代码展示了一个最简单的日志记录结构,你可以扩展成按天切分的格式:
public static void Log(string message) { string path = AppDomain.CurrentDomain.BaseDirectory + "sms.log"; File.AppendAllText(path, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}\r\n"); }在SendSms的每个关键步骤都调用Log,比如入队时记录手机号、AT+CMGS时记录PDU长度、收到+CMGS:时记录回执编号、收到+CMS ERROR时记录错误码。这样就算半夜系统崩了,第二天翻日志也能看出是哪一步出了问题。
5. 短信群发避坑清单:5个最常见的翻车现场与排查
5.1 新卡插上后一直返回 SIM PIN required
现象:模块初始化时AT+CPIN?返回+CPIN: SIM PIN,所有发信指令都会回+CMS ERROR: 302。原因:SIM卡启用了PIN码校验,短信猫没有交互界面输入PIN码。解决:把SIM卡插到手机上,在安全设置里关闭PIN锁,再插回短信猫,确认AT+CPIN?返回+CPIN: READY再继续后续操作。
5.2 串口能打开但发任何指令都无响应
现象:代码不报错,ReadTimeout到了之后返回空串,模块像死了一样。原因:大概率是USB转串口线驱动未装好,或者当前程序之前异常退出导致串口被占用。解决:先打开设备管理器确认COM口在存在;然后重启电脑或拔插一次USB转串口设备;再用串口调试助手验证能否收到OK。如果调试助手能通而程序不通,检查你是不是把Handshake设成了RequestToSend或RequestToSendXOnXOff,强制改为None。
5.3 PDU串长度报错,模块返回 CMS ERROR: 500
现象:手工拼接的PDU串看着没问题,但模块拒绝发送,错误码500表示未知错误。原因:十有八九是AT+CMGS后面的长度参数算错了。解决:先打开串口调试助手,用AT+CMGF=0切到PDU模式,然后把PDU串和长度逐字节核对。我后来写了一个自检函数,直接用实际发送的PDU长度反过来验算:
public int CalcPduLength(string pdu, string smsc) { // 减去短信中心号码长度字段和短信中心号码本身 int smscBytes = smsc.Length / 2; return (pdu.Length / 2) - smscBytes - 1; }注意这里smsc是包含“通讯中心号码长度”在内的PDU前缀,实际减去smscBytes + 1就是TPDU长度。如果你短信中心号码长度是7,PDU串开头是07,那么这个8个字符要被扣掉,也就是4个字节。拿调试助手试几次,找到一个自己能确定正确的对比例子,以后直接照抄那个公式。
5.4 信号强度显示为0,短信发不出去
现象:AT+CSQ返回+CSQ: 0,0,发信超时失败。原因:模块天线没接、信号盲区、或者SIM卡欠费被运营商临时停服务。解决:先看看天线是否拧紧,然后换一个位置测试信号强度值。数值在10到20之间属于正常范围。如果确认天线没问题是欠费,运营商一般会在停机后用短信通知你,这时候模块自己也能收到那条通知,你可以先看一下收件箱内容辅助判断。
5.5 连续发送几十条后模块越来越慢直到卡死
现象:前10条秒发,第20条开始等待时间从2秒变成10秒,最后卡在某个响应上不再返回。原因:模块内部没有清空串口缓冲区,或者SIM卡因为连续写入进入保护状态。解决:在每条短信之间的Thread.Sleep(500)基础上加大到800毫秒;在每次发送前清空接收缓冲区sp.DiscardInBuffer();另外检查你有没有在发送过程中做日志写盘操作导致线程阻塞。这种方法根治不了硬件层面的过热,但能把单轮200条以内的群发撑过去。
6. 发信可靠性进阶:回执解析与重试策略
到这里为止,代码能发短信了,但离“可靠群发”还有一步。短信猫发出去的短信,模块自己并不知道是否送达,它只能告诉你“这消息已经提交给运营商网关”。真正的送达回执需要启用AT+CNMI的递送报告功能,这属于模块层面能拿到的最有用的业务数据。
具体做法是:初始化时发送AT+CNMI=2,1,0,0,0,让模块在短信状态变化时主动推送通知。发送成功以后,模块会推送一条类似+CMGS: 123的响应,或者+CDS: ...的回执指示。我一般把解析逻辑写在OnDataReceived里,识别到+CDS关键字后解析里面包含的状态码:
// 回执状态解析:只提取最终状态字节 public static int ParseStatus(string report) { // 典型的回执串最后两位是状态,00=送达,01-0F=转发中 int idx = report.LastIndexOf("+CDS:"); if (idx >= 0) { string hex = report.Substring(report.Length - 2); return Convert.ToInt32(hex, 16); } return -1; }状态码为0表示已送达,非0表示失败或运营商短信中心处理中。注意回执是异步推送的,可能与下一条指令的响应混在一起,所以我建议你不要拿回执作为唯一判断依据,而是把它作为统计用的辅助数据。实践中我的准则是:模块返回+CMGS:就认为发送成功,回执只是用来做报表和对账。
最后分享一条个人习惯:我在做短信群发项目时,会先把所有手机号从Excel读入内存,去重后按每50条一组分批次入队,每批次之间暂停5秒,让模块喘口气。这样既能保证速度,又能把单次长时间连续发信导致模块过热死掉的风险降到最低。每一批结束后去查一次AT+CSQ,如果信号强度比开始前掉了5以上,就再加大暂停时间。从那以后我每次上线短信程序都强制走一遍这个流程,基本没再遇到模块发着发着就罢工的尴尬。这套源码的核心链路就是串口驱动、AT指令、PDU编码、队列消费四件事,你把每一件都搞清楚,短信群发模块再玄学也就是个能控制的盒子。希望帮到你。
本文还有配套的精品资源,点击获取