☰
本地短信通道搭建:C#短信猫群发源码与AT指令/PDU编码详解
2026/10/12 2:39:38 网站建设 项目流程

简介:这是一份面向C#开发者的短信群发完整源码,依托短信猫硬件实现GSM网络批量短信发送,适合企业营销、通知服务等场景,可下载后直接编译运行。压缩包共114个文件,体积721KB,核心为38个C#源文件,配合17个resx资源、相关dll、exe及mdb数据库,构成一个完整的企业级短信群发项目。源码涵盖串口通信与AT指令控制短信猫、多线程并发发送、短信模板与号码管理、发送状态跟踪、用户权限控制等关键功能,并包含Windows窗体界面与完善异常处理,可帮助开发者快速理解C#与硬件交互的完整链路。已有120人学习下载,通过研究该项目,读者既能掌握串口编程、GSM协议等底层技术,也能借鉴企业级应用的模块划分与数据库设计思路。

1. 短信猫群发还是门手艺:为什么 C# 短信群发源码在本地环境依然能打

做内部通知系统的时候,我遇到一个实际需求:系统告警时要给十几个值班手机号发短信。云平台要先审核签名、模板,一两天都下不来;而硬件短信猫插上串口,写一段 C# 短信群发源码,当天就能把通道跑通。短信猫本质就是一个 GSM 模块加串口,上位机通过 AT 指令控制它发短信,不需要公网接入,也不依赖第三方平台,特别适合内网部署、临时通道、小批量高频通知这类场景。你手里如果已经有现成的猫,或者预算只够买一两台设备,这篇就值得看完。下文会从 AT 指令协议讲起,一路落到 C# 串口实现、PDU 编码、群发排障和节奏控制,照着做能搭出一套可用的本地短信发送通道。

2. 短信猫与 AT 指令:把硬件协议拆成一条条可调试的命令

2.1 短信猫为什么用 AT 指令说话

短信猫的完整链路一般是:GSM 模块 + SIM 卡座 + 天线 + 串口芯片,外壳做成 DB9 串口猫或 USB 猫。上位机写的 C# 程序,本质上是在和模块里的固件对话,对话协议就是 AT 指令。AT 是 Attention 的缩写,每条指令以 AT 开头,模块收到后执行并返回结果。这套协议从早期调制解调器时代沿用至今,所以短信猫的调试思路和串口设备调试是一样的:发指令、读响应、判断是否成功。

我在拿到一台新猫时,最优先做的事情不是写代码,而是用串口助手把指令和环境先验证一遍。常见的做法是先发一条 AT,模块如果能返回 OK,说明串口通路和模块固件都正常。下面这张表是群发场景里最常用的几条 AT 指令,建议先存下来,后面所有代码都围绕它们展开:

指令作用典型返回
AT测试串口通路和模块状态OK
AT+CMGF=1切换到文本模式(Text Mode)OK
AT+CMGF=0切换到 PDU 模式OK
AT+CSCA?查询短信中心号码+CSCA: "8613800xxxxxxx",145
AT+CMGS=<长度>发送一条短信,长度是 TPDU 的字节数回车后出现 >,发送内容后返回 +CMGS: 序号
AT+CSQ查询信号强度+CSQ: 17,99,第一个数是信号等级

每次发送短信之前,我还会先发 AT+CSCA? 确认短信中心号码还在。有些模块换 SIM 卡后会丢掉中心号码配置,导致提示发送成功但对方收不到。这个细节在群发场景里特别容易踩坑,留到第 5 章展开。

2.2 Text 模式只适合纯英文,中文短信要过 PDU

AT 指令里有两个容易让人迷惑的发送路径:Text 模式和 PDU 模式。Text 模式写法直观,指令是 AT+CMGS="13800138000",后面直接跟短信内容再发结束符,但它的字符集和编码方式由模块决定,很多模块出厂默认不支持中文内容,发出去就是问号或乱码。PDU 模式看起来是一长串十六进制字符,但内容编码完全由程序控制,中文用 UCS2 编码,英文数字可以走 GSM 7bit 编码,这才是中文短信群发里可靠的做法。

我一般索性不用 Text 模式,全部走 PDU。PDU 模式下的 AT 指令是 AT+CMGS=<TPDU长度>,模块收到后返回一个 > 提示符,程序再写入完整的 PDU 字符串并以十六进制 0x1A 结尾。整个过程中程序需要精确计算长度,长度算错,模块要么卡住不返回,要么直接回 ERROR。因此 PDU 编码器的正确性是整个源码的命脉,第 4 章会给完整实现。

2.3 硬件选型与调试环境的 4 个要点

短信猫硬件看着都是一个小盒子,但差异很影响开发效率。拿到的猫不同,串口参数、指令集、供电要求都可能不一样。选型和调试阶段,我建议盯住 4 个点:

一是串口形态。DB9 串口猫直接用电脑的串口或 USB 转串口线连接,USB 猫内部往往已经集成了一颗 USB 转串口芯片,插上电脑后出现一个虚拟 COM 口。后者更常见,但需要装对应驱动,设备管理器里没看到 COM 口时先查驱动。二是模块固件版本。不同固件对 AT 指令的支持细节有差异,买猫时问清楚关键指令是否支持,或者到手后用串口助手逐个验证。三是天线。信号强度直接决定发送成功率,天线没拧紧的情况下,AT+CSQ 返回的信号等级会很低,甚至注册不上网络。四是电源。GSM 模块发射瞬间电流峰值能到 2A 左右,供电不足的表现是发送时模块自动重启,这是很多初看像代码问题的故障根源,属于血泪经验,后面单独说。

调试顺序我固定为:先串口助手验证 AT 通,再验证 SIM 卡注册,然后手动发一条 PDU 短信,全部通过了才写 C# 代码。不要在没验证硬件的情况下直接开写群发程序,那样出了问题很难定位到底在硬件还是软件。

3. C# 串口通信层:打开端口、发指令、等响应的完整骨架

3.1 SerialPort 的参数清单与打开端口的三个坑

C# 里操作串口用的是 System.IO.Ports.SerialPort,参数设置看似简单,但和短信猫对接时有一个常见误区:短信猫默认波特率是 9600,数据位 8,停止位 1,无校验。有些 USB 猫的固件被改过波特率,可能是 19200 或 115200,如果连 AT 都没反应,先确认设备资料上的波特率,不要盲目相信默认值。

using System; using System.IO.Ports; using System.Threading; public class SmsCatPort { private SerialPort _port; public bool Open(string comName, int baudRate = 9600) { _port = new SerialPort(comName, baudRate, Parity.None, 8, StopBits.One); _port.ReadTimeout = 500; _port.WriteTimeout = 500; _port.Encoding = System.Text.Encoding.ASCII; try { _port.Open(); return true; } catch (Exception ex) { Console.WriteLine("打开串口失败: " + ex.Message); return false; } } }

这里把编码设成 ASCII 是刻意的,因为 AT 指令和 PDU 串都是 ASCII 字符,不要设成 UTF-8,否则某些中文环境下的 BOM 字符会混进指令里。ReadTimeout 和 WriteTimeout 设为 500 毫秒,目的是防止模块无响应时程序卡死。打开串口失败的三个坑我已经踩过多次:一是端口号不对,USB 猫插入不同 USB 口会漂移 COM 号,不要在代码里硬编码 COM3,做成参数或下拉框选择;二是端口被占用,串口助手没完全退出时程序会打不开,报 Access Denied;三是驱动装错,设备管理器里显示的是 Unknown Device 而不是 COM 口序号,这种情况要先解决驱动再谈代码。

3.2 发 AT 指令要带 \r:指令格式与响应等待的代码骨架

AT 指令的结尾不是简单地发字符串,而是需要以回车符结尾,大多数模块接受 \r,也有模块要求 \r\n,我统一用 \r,因为它在绝大多数固件上的表现一致。发送之后不能立刻发下一条,必须等模块把当前指令的响应吐完。下面是封装好的 SendCommand 方法,整个源码里所有操作都会复用它。

public string SendCommand(string command, int timeoutMs = 1500) { _port.DiscardInBuffer(); // 清空残留响应,避免干扰判断 _port.Write(command + "\r"); // 指令必须以回车结尾 string buffer = string.Empty; var sw = System.Diagnostics.Stopwatch.StartNew(); while (sw.ElapsedMilliseconds < timeoutMs) { buffer += _port.ReadExisting(); if (buffer.Contains("OK") || buffer.Contains("ERROR")) break; Thread.Sleep(50); } return buffer; }

逻辑说明:先清空接收缓冲区,再写入指令,随后循环读取响应,直到超时或收到 OK/ERROR 关键字。用 ReadExisting 而不是 ReadLine,是因为模块的返回格式在不同固件上不同,有的带 \r\n,有的直接连续输出,ReadLine 容易被换行符差异卡住。参数方面,timeoutMs 默认 1500 毫秒,查询信号或发送短信这类慢操作需要单独调大到 3000 到 5000 毫秒。如果返回的响应里既没有 OK 也没有 ERROR,那就是超时,可能是串口参数不对,也可能模块死机,需要重置。

3.3 用超时加重试处理不稳定的串口响应

串口通信不像 TCP 那样有确认机制,响应丢失、延迟、粘包都是常态。发送短信前查信号、设置 PDU 模式、查短信中心,每一步都可能失败,所以源码里不能假设每条指令都一次成功。我的做法是抽一个方法,统一做重试:

public string SendWithRetry(string command, int retryCount = 3, int timeoutMs = 1500) { string resp = string.Empty; for (int i = 0; i < retryCount; i++) { resp = SendCommand(command, timeoutMs); if (resp.Contains("OK")) return resp; Console.WriteLine($"第 {i + 1} 次重试: {command} -> {resp}"); Thread.Sleep(200); } return resp; }

这里的核心逻辑是:只认 OK 为成功,ERROR 和超时都进入重试。重试间隔 200 毫秒,避免模块还在处理上一条指令时又收到新指令。注意重试次数不要设置太大,短信猫响应慢是常态,但连续 3 次都失败,基本说明链路有问题,继续重试只会堆积串口缓冲区。我在调试时会把每次原始响应都打印出来,虽然群里上线后要关掉日志,但开发阶段这些日志就是定位问题的眼睛。

4. 中文短信的关键:PDU 编码与 AT+CMGS 发送流程

4.1 PDU 结构逐字段拆解

PDU 模式发送短信时,程序构造的字符串是一个完整的协议数据单元,里面包含了短信中心号码、目标号码、编码方式、用户数据和长度。很多人看到一长串十六进制就发怵,其实只要按字段拆开,它比想象中简单。下面以一条到 13800138000 的中文短信为例,把结构拆开看:

字段段示例值含义
短信中心长度08表示短信中心号码字段的字节数
短信中心号码91 683108200705F091 表示国际格式,后面是两两互换后的中心号码
协议标识11表示 SMS Deliver 类型,发送时用 11
消息参考号00由模块自动处理,填 00
目标号码0D 91 683108801300F00D 是号码长度,91 是国际格式,后面是互换后的对方号码
协议标识00普通短信填 00
编码方式0808 表示 UCS2 编码,中文短信要用这个
有效期AAAA 表示最大有效期,约 24 小时
用户数据长度14中文按 UCS2 编码后每个字占 2 字节
用户数据4F60597DFF08"你好世界" 的 UCS2 十六进制串

这里可以看到,整个流程里程序要处理的最麻烦部分是号码互换和 UCS2 转换。号码互换的规则是去掉开头的 +,如果号码长度是奇数则在末尾补 F,然后相邻两位互换。比如 8613800138000 变为 683108801300F0,这个规则在下面的 C# 代码里会严格实现。

4.2 C# 实现中文转 PDU 的编码器

PDU 编码器是源码的核心模块,一个短信猫程序能不能用,就看这段编码对不对。下面给出一个可直接复用的实现,包含中心号码编码、目标号码编码、UCS2 转换和整体 PDU 拼装。

using System.Text; public static class PduEncoder { // 号码转为 SCA/DA 格式:去掉+,奇数位补F,相邻互换 public static string EncodeNumber(string number) { if (number.StartsWith("+")) number = number.Substring(1); if (number.Length % 2 == 1) number += "F"; // 奇数长度补 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 十六进制串,一个汉字两个字节 public static string EncodeText(string text) { byte[] bytes = Encoding.BigEndianUnicode.GetBytes(text); StringBuilder sb = new StringBuilder(); foreach (byte b in bytes) sb.Append(b.ToString("X2")); return sb.ToString(); } // 组装完整 PDU 串,供 AT+CMGS 使用 public static string BuildPdu(string centerNumber, string targetNumber, string text) { string sca = "91" + EncodeNumber(centerNumber); string scaLen = (sca.Length / 2).ToString("X2"); string target = "0D91" + EncodeNumber(targetNumber); string tpduHeader = "1100" + target + "0008AA"; string ucsContent = EncodeText(text); string contentLen = (ucsContent.Length / 2).ToString("X2"); string tpdu = tpduHeader + contentLen + ucsContent; return scaLen + sca + tpdu; } // 计算 AT+CMGS 需要的 TPDU 字节数(不含短信中心部分) public static int GetTpduLength(string pdu, string centerNumber, string targetNumber, string text) { string sca = "91" + EncodeNumber(centerNumber); return (pdu.Length - sca.Length) / 2; } }

说明一下几个关键点。EncodeNumber 里的补 F 规则只针对中心号码和目标号码本身,一个号码去掉 + 后是 11 位或 13 位,补一个 F 变成偶数位再互换。目标号码前固定加 0D,这个值表示目标号码的字节数,如果号码带 + 的长度不同,0D 要重新计算,代码里按真实号码长度算更稳妥,这里用固定 0D 是为了突出主流程。EncodeText 用 BigEndianUnicode 保证高字节在前,这是 UCS2 的标准字节序。BuildPdu 里 scaLen 是 (sca.Length / 2),因为 sca 本身是偶数长度,直接换算成字节数即可。

4.3 发送 PDU 的 AT 指令完整流程

编码器构造出 PDU 串之后,发送过程分两步:先发 AT+CMGS=<TPDU长度>,等模块返回 > 提示符,再写入 PDU 内容并追加 0x1A 结束字符。0x1A 是十六进制的 26,对应键盘上的 Ctrl+Z,是 GSM 模块约定俗成的短信内容结束符。整个过程不能用第 3 章的 SendCommand 直接处理,因为它包含两段交互。

public bool SendPduMessage(string centerNumber, string targetNumber, string text) { string pdu = PduEncoder.BuildPdu(centerNumber, targetNumber, text); int tpduLen = PduEncoder.GetTpduLength(pdu, centerNumber, targetNumber, text); string cmgsResponse = SendCommand($"AT+CMGS={tpduLen}", 2000); if (!cmgsResponse.Contains(">")) // 模块没有进入等待输入状态 { Console.WriteLine("未收到 > 提示符,发送终止"); return false; } _port.DiscardInBuffer(); // 清掉提示符前后的残留字符 _port.Write(pdu); // 写入 PDU 十六进制串 _port.Write("\x1A"); // 结束符,告诉模块内容写完 Thread.Sleep(200); string result = ReadUntilOk(5000); return result.Contains("+CMGS") || result.Contains("OK"); }

这段代码有 3 个容易出错的地方。第一,AT+CMGS 的等号后面不能有空格,某些模块对空格敏感,返回 ERROR。第二,SendCommand 是发送并等待 OK/ERROR,但在 PDU 发送场景下,AT+CMGS 返回的是 > 而不是 OK,所以这里不能用第 3 章那个方法,需要单独写一个等待 > 的逻辑。第三,发送失败后模块可能卡在 > 状态,下一次发送前要补发一条 AT 或者重新开关串口。我习惯在 SendPduMessage 开头主动 DiscardInBuffer,虽然不能保证清干净,但能减少粘包干扰。判断成功时优先认 +CMGS: 序号,因为部分固件发送成功时只返回 +CMGS 而不返回 OK,只判断 OK 会误判。

5. 短信猫群发常见问题排查:从信号灯到发送漏单

5.1 指令返回 OK 但对方没收到短信

现象:程序显示 AT+CMGS 返回 +CMGS: 序号,发送流程完整走完,但收件人手机始终收不到短信。这个是最容易让人困惑的情况,因为链路看起来全通。

原因:最常见的是短信中心号码错误或为空。换过 SIM 卡、猫被重新刷过固件、或者 SIM 卡来自非本地运营商时,模块里保存的短信中心号码可能不对。第二个容易被忽略的原因是 SIM 卡欠费或套餐里短信服务被停用,模块能注册网络但无法发送。

解决:先用 AT+CSCA? 查询当前中心号码,和 SIM 卡运营商官方公布的短信中心号码比对,不一致就用 AT+CSCA=<号码> 重新设置。然后拔下 SIM 卡插到普通手机上,确认短信功能本身可用。这个排查顺序能拦下八成“发送成功但没收到”的案例,不要一上来就怀疑 PDU 编码。

5.2 群发到中间某条开始连续失败

现象:前几十条发送正常,到某个时间点开始连续返回 ERROR,重试也救不回来,重启程序后又能恢复一段时间。

原因:这类问题通常是两个因素叠加。一是发送间隔太短,模块和网络侧来不及处理,信号稍微波动就触发限流。二是 SIM 卡被运营商临时限制发送频率,群发场景很容易触发这个限制,尤其是短时间内连续向不同号码发送。

解决:把发送间隔提高到 2 秒以上,每发送 50 到 100 条暂停 10 秒,让模块和网络缓冲一下。代码里的重试机制要保留,但连续失败 3 次以上就应该停止发送并告警,而不是无限重试。还要检查一下失败时刻的 AT+CSQ 信号值,如果信号等级掉到 10 以下,说明网络环境本身不稳定,继续发只会积累失败。

5.3 发送过程中模块频繁重启

现象:群发跑到一半,模块灯灭了又亮,程序串口连接断开,重连后又能继续发。代码和发送参数看起来都没有问题,断电重启后前端一部分成功、一部分失败。

原因:这是典型的供电不足。GSM 模块发射瞬间电流峰值能达到 2A,如果电源适配器电流不够,或者 USB 口供电能力不足,模块就会掉电重启。这个问题在 USB 猫上尤其明显,USB 口能提供的电流通常有限。

解决:换一个电流余量足够的电源,常见的做法是使用 5V 2A 以上的适配器,或者带独立供电的 USB HUB。如果是 DB9 串口猫,检查一下串口线的供电引脚是否正常。这个问题在硬件层面解决,改代码是治不好的,调试时电源问题会伪装成各种随机失败,浪费大量时间,算是这个领域最典型的玄学。

5.4 收到的短信是问号或乱码

现象:对方收到短信,内容变成一串问号、乱码,或者英文字符正常但中文全丢。

原因:最常见的是用了 Text 模式发送中文。Text 模式依赖模块内部字符集映射,多数模块不支持中文,或者默认字符集不是 UCS2。另一种情况是 PDU 模式下编码方式和实际内容不一致,比如内容按 GSM 7bit 编码,但短信里有中文,或者 UCS2 编码字节序反了。

解决:统一走 PDU 模式,编码方式固定为 08,文本转换用 BigEndianUnicode。不要在代码里混用 Text 模式发中文,也不要手动拼接中文的 GB2312 编码,模块固件对这两种方式的支持都不统一。如果已经用了 PDU 还是乱码,检查 EncodeText 的字节序,把 BigEndianUnicode 换成 Unicode 试试,后者的字节序是反的,两个对比一下就能确定问题方向。

5.5 模块注册不上网络,指示灯一直闪

现象:模块指示灯不规律闪烁或长时间常亮但 AT+CSPR 查询显示未注册,AT+CSQ 返回 99,99,发送短信直接 ERROR。

原因:指示灯状态一般对应模块的网络注册状态,常亮一般是已注册,慢闪可能是找网中,快闪可能是信号差或 SIM 卡问题。AT+CSQ 返回 99,99 表示信号无法测量,常见原因有三个:天线没接或没拧紧、SIM 卡没插好或卡槽氧化、模块禁用了射频功能。

解决:先物理检查天线接口和 SIM 卡触点,然后发 AT+CFUN=1 确保射频模块开启,最后用 AT+CSQ 看信号值变化。如果信号值在 10 以下,换一个位置再试。信号弱的情况下发送成功率很低,不要指望是代码问题。

6. 群发源码的参数调优:间隔、重试与信号强度验证

6.1 线程安全的发送队列与失败重试

群发不能简单写一个 for 循环逐个调用 SendPduMessage,因为串口是独占设备,多线程同时写会互相干扰。我的做法是把手机号码放进一个线程安全的队列,由一个消费者线程串行发送。这样即使后续要增加暂停、批量停止、优先级调整,改动都只集中在队列控制层,不会动到底层串口代码。核心结构大致如下:

ConcurrentQueue<string> phoneQueue = new ConcurrentQueue<string>(); volatile bool running = true; void SendLoop(string centerNumber, string content, int intervalMs) { while (running) { if (!phoneQueue.TryDequeue(out string phone)) { Thread.Sleep(1000); continue; } bool ok = SendPduMessage(centerNumber, phone, content); if (!ok) phoneQueue.Enqueue(phone); // 失败重新入队,稍后再试 Thread.Sleep(intervalMs); } }

这里的关键参数有两个。intervalMs 是每条短信的发送间隔,短了容易被限流,长了影响整体速度,我一般取 2000 到 3000 毫秒,晚间或节假日信号拥堵时取 5000。失败重入队需要控制重试次数,我在生产环境里的做法是给每个号码维护一个重试计数器,超过 3 次就丢弃并记录到失败日志,避免失败号码无限循环占住队列。

6.2 发送节奏与信号强度验证

每批群发任务开始前,我会用 AT+CSQ 检查信号,这个命令返回两个数字,第一个是信号等级,范围 0 到 31,越大越好,读取时做一次斜率判断:如果连续发送过程中信号等级掉得很快,要主动降速。群发完成后,把返回值、成功条数、失败条数、每条短信发送耗时写进日志,这是判断一个短信猫真正可靠性的依据。

做这件事有了几年经验后,我的习惯变成了:任何一次群发任务结束后都翻一遍日志里失败码。固定跑到某一个号码附近才失败,多半是号码本身已经停机或空号,可以换一批测试号验证。如果失败分布均匀且间隔性出现,优先调大发送间隔而不是改代码。串口这种链路,玄学现象背后基本都能归结到信号、供电、参数和节奏四个变量上。

这套基于短信猫的 C# 群发源码,说到底是一个把 AT 指令、PDU 编码和串口控制做成可靠管线的过程。先用串口助手确认硬件能发,再把编码器单测跑通,最后加队列和重试,整条链路就是可维护的。如果后续要支持接收短信,还有 AT+CNMI 和 AT+CMGR 两条路可以继续深入,建议在一个测试号码上反复验收入库、去重、回执解析几个环节再做扩展。希望这篇笔记能把你在短信猫接入上的不同路都踩平一些,省下来的时间拿去处理真正麻烦的业务逻辑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询