C#短信猫群发源码解析:串口、AT指令与PDU编码全链路

发布时间:2026/10/20 2:26:48
C#短信猫群发源码解析:串口、AT指令与PDU编码全链路 简介这是一份基于短信猫的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→ 发ATCMGF0设置PDU模式 → 等待OK→ 发ATCSQ查信号强度 → 全部通过后才进入短信发送循环。private bool InitializeModem() { // 打开串口后先给模块一点启动时间 Thread.Sleep(2000); // 第一步发送AT指令看模块是否响应 sp.WriteLine(AT\r); string response sp.ReadExisting(); if (!response.Contains(OK)) return false; // 第二步设置PDU模式0表示PDU1表示Text模式 sp.WriteLine(ATCMGF0\r); Thread.Sleep(500); response sp.ReadExisting(); if (!response.Contains(OK)) return false; // 第三步查询信号强度返回格式 CSQ: rssi,ber sp.WriteLine(ATCSQ\r); response sp.ReadExisting(); // rssi大于10时信号可用小于5基本发不出去 return response.Contains(CSQ:) ParseSignal(response) 5; }这里最关键的是每一句指令之后必须Sleep一段时间再读因为串口是异步通道模块处理指令需要时间你立刻读往往只读到空串。另外AT指令必须以\r结尾有些模块兼容\r\n但最稳妥的还是\r。信号强度ATCSQ返回的第一个数字范围是0到3110以上通常可以正常发信5以下经常出现发送超时。3.2 为什么必须用PDU模式中文短信的唯一路径很多短信猫同时支持Text模式和PDU模式其中Text模式只能发英文和数字发中文会出现乱码。原因是Text模式下GSM模块直接使用7bit或8bit编码而中文属于UCS2字符集必须用PDU封装成十六进制字节串。所以做中文短信群发ATCMGF0是绕不开的。PDU编码的本质是把短信中心号码、目标号码、协议头、用户数据全部转成十六进制字符串然后拼接成一个长串通过ATCMGS发送。其中最麻烦的是号码反转中国移动的短信中心号码通常是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短消息08UCS2 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代表MTI1且有效期为默认000800中00表示无协议头08表示UCS2编码。如果你要发英文短信把编码方式改成007bit并且内容不转UCS2即可但群发场景下中文是刚需所以直接统一用UCS2最稳妥。3.4 发送流程拼接、计算长度、等待 提示符PDU串拼接好之后通过网络调试助手或者串口调试先手工发一次确认模块返回提示符再发送十六进制的1A即CtrlZ表示结束。这一步代码里体现为两个独立的Write中间要等待模块返回字符不能一次性写完否则模块会直接丢弃指令。public bool SendSms(string targetNumber, string content) { string smscNumber 8613800270500; // 短信中心号码按当地运营商修改 string pdu BuildPDU(smscNumber, targetNumber, content); // ATCMGSPDU总长度 int pduLength (pdu.Length - 8) / 2; // 减去smsc长度字节和8是协议头的半字节数 sp.WriteLine(ATCMGS pduLength \r); // 等待 提示符 string response sp.ReadExisting(); if (!response.Contains()) return false; // 发送PDU串并以CtrlZ结尾 sp.Write(pdu \x1A); Thread.Sleep(2000); response sp.ReadExisting(); // 成功返回 CMGS: 编号失败返回 CMS ERROR return response.Contains(CMGS:); }这里的计算逻辑是整个PDU串里开头两个字符是短信中心号码的长度字段接着是短信中心号码本身剩余部分全是TPDU。ATCMGS需要的参数是TPDU的字节数所以要先减去短信中心长度字段和数据。我第一次写的时候直接用整个PDU的长度除以2结果模块一直报错后来打印出来一算才发现自己多算了。建议你调试时把pdu和pduLength都打印到控制台对比一下这种长度性的错误肉眼一看就能定位。4. C#核心代码串口交互、PDU编码与发送流程4.1 串口通信的读写模型为什么不能同步等待在C#里写短信猫程序最容易犯的错误是把串口当网络Socket一样同步读。实际上短信猫的响应时机是不固定的开机后第一次响应可能需要2秒而信号波动时ATCMGS的响应甚至可能拖到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 BlockingCollectionstring[] queue new BlockingCollectionstring[](); // 业务线程调用入队即返回不等待发送完成 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: 304SIM卡忙。这个500毫秒是经验值你可以在实际场景里调大到800或1000群发速度的瓶颈不在代码而在模块本身。4.3 完整调用示例串口初始化到群发30条把上面的模块拼起来一个最小可运行的C#控制台程序大概是这样的结构class Program { static SerialPort sp; static BlockingCollectionstring[] 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 BlockingCollectionstring[](); Thread consumer new Thread(ProcessQueue); consumer.IsBackground true; consumer.Start(); // 模拟群发从Excel或数据库读取号码后逐个入队 for (int i 0; i 30; i) { string phone 13800000000; string content 您的验证码是1234565分钟内有效; 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比如入队时记录手机号、ATCMGS时记录PDU长度、收到CMGS:时记录回执编号、收到CMS ERROR时记录错误码。这样就算半夜系统崩了第二天翻日志也能看出是哪一步出了问题。5. 短信群发避坑清单5个最常见的翻车现场与排查5.1 新卡插上后一直返回 SIM PIN required现象模块初始化时ATCPIN?返回CPIN: SIM PIN所有发信指令都会回CMS ERROR: 302。原因SIM卡启用了PIN码校验短信猫没有交互界面输入PIN码。解决把SIM卡插到手机上在安全设置里关闭PIN锁再插回短信猫确认ATCPIN?返回CPIN: READY再继续后续操作。5.2 串口能打开但发任何指令都无响应现象代码不报错ReadTimeout到了之后返回空串模块像死了一样。原因大概率是USB转串口线驱动未装好或者当前程序之前异常退出导致串口被占用。解决先打开设备管理器确认COM口在存在然后重启电脑或拔插一次USB转串口设备再用串口调试助手验证能否收到OK。如果调试助手能通而程序不通检查你是不是把Handshake设成了RequestToSend或RequestToSendXOnXOff强制改为None。5.3 PDU串长度报错模块返回 CMS ERROR: 500现象手工拼接的PDU串看着没问题但模块拒绝发送错误码500表示未知错误。原因十有八九是ATCMGS后面的长度参数算错了。解决先打开串口调试助手用ATCMGF0切到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长度。如果你短信中心号码长度是7PDU串开头是07那么这个8个字符要被扣掉也就是4个字节。拿调试助手试几次找到一个自己能确定正确的对比例子以后直接照抄那个公式。5.4 信号强度显示为0短信发不出去现象ATCSQ返回CSQ: 0,0发信超时失败。原因模块天线没接、信号盲区、或者SIM卡欠费被运营商临时停服务。解决先看看天线是否拧紧然后换一个位置测试信号强度值。数值在10到20之间属于正常范围。如果确认天线没问题是欠费运营商一般会在停机后用短信通知你这时候模块自己也能收到那条通知你可以先看一下收件箱内容辅助判断。5.5 连续发送几十条后模块越来越慢直到卡死现象前10条秒发第20条开始等待时间从2秒变成10秒最后卡在某个响应上不再返回。原因模块内部没有清空串口缓冲区或者SIM卡因为连续写入进入保护状态。解决在每条短信之间的Thread.Sleep(500)基础上加大到800毫秒在每次发送前清空接收缓冲区sp.DiscardInBuffer()另外检查你有没有在发送过程中做日志写盘操作导致线程阻塞。这种方法根治不了硬件层面的过热但能把单轮200条以内的群发撑过去。6. 发信可靠性进阶回执解析与重试策略到这里为止代码能发短信了但离“可靠群发”还有一步。短信猫发出去的短信模块自己并不知道是否送达它只能告诉你“这消息已经提交给运营商网关”。真正的送达回执需要启用ATCNMI的递送报告功能这属于模块层面能拿到的最有用的业务数据。具体做法是初始化时发送ATCNMI2,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秒让模块喘口气。这样既能保证速度又能把单次长时间连续发信导致模块过热死掉的风险降到最低。每一批结束后去查一次ATCSQ如果信号强度比开始前掉了5以上就再加大暂停时间。从那以后我每次上线短信程序都强制走一遍这个流程基本没再遇到模块发着发着就罢工的尴尬。这套源码的核心链路就是串口驱动、AT指令、PDU编码、队列消费四件事你把每一件都搞清楚短信群发模块再玄学也就是个能控制的盒子。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询