news 2026/10/17 13:51:43

C#短信猫开发:PDU编码、AT指令与串口通信实战详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#短信猫开发:PDU编码、AT指令与串口通信实战详解

简介:C#短信发送程序(pdu_class)是一份面向需要掌握GSM短信底层开发技能的C#程序员的实例工程,围绕PDU(Protocol Data Unit)类实现短信发送功能,覆盖7位/8位/16位编码、Submit PDU构造、SMSC号码编码、IMEI/MSISDN转换、消息分割与错误处理等关键点。压缩包共45个文件,大小约484KB,以cs源代码、可执行程序exe、项目配置config、资源文件以及PDB调试文件为主,目录结构清晰,便于直接运行、阅读和二次开发。已有156人学习下载,适合正在做串口通信、短信网关或需要理解GSM 03.38协议的开发者参考。通过该工程可完整看到PDU类封装、界面调用及数据访问模块的配合方式,示例结构便于将理论编码规则落地为可运行的C#代码,也是理解底层通信协议与C#编程结合的实用素材。

1. C#短信发送程序(pdu_class):为什么说PDU编码是短信开发里最容易被绕晕的一环

做过短信猫(GSM Modem)开发的人基本都承认一件事:串口收发AT指令不难,难的是把一条中文短信变成一串看着像乱码的十六进制PDU串。这份C#短信发送程序的核心就是一个叫pdu_class的辅助类,它把PDU编码、解码、长度计算全部封装好,开发者只需要传入手机号和短信内容,就能拿到可以直接发给模块的PDU串。相比直接用Text模式发英文短信,用PDU模式才能发中文、才能设置有效期和状态报告。对初学者来说,读透这一个类,相当于把短信开发的底层路径走了一遍;对熟手来说,这份代码最大的价值在于解码部分——接收短信时把设备回传的PDU还原成人话,这个逆向过程很多封装库都做得不透,而pdu_class给了你一条可以自己改的完整链路。

2. PDU编码与解码器结构:把中文变成十六进制串的关键路径

2.1 PDU数据格式拆解:SMSC与TPDU各占什么

PDU模式全称是Protocol Data Unit,它和Text模式最大的差异在于:一切都是十六进制字符串,连长度都要自己算。一条发送用的PDU串,逻辑上分成两段——SMSC(短消息中心地址)和TPDU(传输协议数据单元)。SMSC通常是08开头,表示后面地址字节数;如果你不想指定短信中心,可以用00填充,让设备默认取SIM卡里的短信中心号码。TPDU里则依次排列着SMS类型标识、消息参考号、目标号码、协议标识、编码方案、有效期和用户数据。pdu_class里最值得读的方法是组装TPDU的那一段,因为它把每个字段的字节数和转换规则都写成了清晰的顺序逻辑,而不是一堆魔法数字散落在事件处理里。

从代码结构看,pdu_class实际上包含了两组能力:一组是编码(Encode),把输入的文本和号码转成PDU串;另一组是解码(Decode),把收到的PDU串还原成文本和发送方号码。很多人在网上找到的同类代码只做了编码,发短信可以,读短信就不行。这份资源能覆盖收发两端,说明作者当年是拿着真实模块调过的。我一般拿到这种类库,会先看它的DCS(Data Coding Scheme)字段怎么处理:发中文必须用UCS2,即DCS设为08,每个汉字占4个十六进制字符;发英文则用7-bit压缩,DCS为00,字符数会少一些,能塞进140字节的短信里。

2.2 中文与英文的两套编码路径:UCS2与7-bit的取舍

编码是PDU模式里最容易出错的部分,尤其是中文。UCS2的思路很简单:把每个汉字取它的Unicode码点占两个字节,然后拼成十六进制串。C#里用Encoding.Unicode可以一步到位,但有一个坑——C#的Encoding.Unicode默认是UTF-16 LE(小端序),而短信PDU要求的是BE(大端序),所以不能直接拿Encoding.Unicode.GetBytes的结果拼串,需要把高低字节对调。pdu_class在这一点上的处理是稳妥的:它没有直接调Encoding.Unicode,而是手动按字节高低位拼接,避免了常见的乱码问题。

// 中文转UCS2编码(PDU模式专用) private static string ChineseToUcs2(string text) { StringBuilder sb = new StringBuilder(); byte[] bytes = Encoding.Unicode.GetBytes(text); // 默认小端序,需要反转 for (int i = 0; i < bytes.Length; i += 2) { // 高位字节在前,低位字节在后,与默认的小端序相反 sb.Append(bytes[i + 1].ToString("X2")); sb.Append(bytes[i].ToString("X2")); } return sb.ToString(); }

这段代码的核心逻辑就是字节序反转。参数上要注意的是:ToString("X2")保证每个字节输出两位十六进制,不足两位左边补0,这是PDU串合法性的基础。如果你写成了ToString("X"),那么0A这种字节会变成A,整条串的长度就全错位了。实际使用时,我一般会先拿一个已知汉字比如“中”验证:它的Unicode码点是4E2D,经过这个函数应该输出4E2D。如果你的输出是2D4E,说明端序没处理好。

英文编码走的是7-bit压缩,原理是把7位ASCII码按位紧凑拼接,最终8个字符压成7个字节。这个算法比UCS2复杂,但好处是同样的140字节能发160个字符。pdu_class里这部分用的是标准查表加位偏移的方法:维护一个bitOffset指针,每次把当前字符的低7位左移到正确位置,再与前一次残留的数据做或运算。初学的人容易在这里钻牛角尖,其实直接用现成实现就好,重点是理解输出长度从n个字符变成ceil(n * 7 / 8)字节,这个长度会影响到后面AT+CMGS的第二个参数。

2.3 解码方向:收到的PDU如何还原成可读文本

解码是pdu_class里让我最满意的部分。设备在收到短信后,如果使用AT+CMGL或AT+CMGR读取,返回的PDU串是完整的,包括SMSC信息。解码的第一步是跳过SMSC长度字节,然后解析出TPDU里的发送方号码,号码也是十六进制串,需要做反向压位还原。第二步是读取DCS字段,判断这条短信是7-bit还是UCS2编码,再进入不同的分支。中文短信解码时要注意:有些号码段或运营商会在用户数据开头插入一个协议头(比如长短信拼接信息),格式通常是一串以05开头的字节,表示后面跟了5个字节的包头。如果不跳过这5个字节,解出来的文本开头会出现一个奇怪的方框字符。

// PDU解码:提取用户数据段的文本内容 private static string DecodeUserData(string pdu, int dataStartIndex, int dcs) { string hex = pdu.Substring(dataStartIndex); // 处理长短信的UDHI头(05开头表示有协议头) if (hex.StartsWith("05") && hex.Length >= 12) { int headerLen = Convert.ToInt32(hex.Substring(0, 2), 16); hex = hex.Substring((headerLen + 1) * 2); // 跳过长度字节和头数据 } if (dcs == 0x08) { return Ucs2ToChinese(hex); } else { return SevenBitToString(hex); } }

这段代码里最值得留意的参数是dataStartIndex,它需要调用方精确计算:SMSC部分占了2 + 2 * SMSC长度字节,TPDU里有3个固定字节(SMS类型、MR、号码长度),号码本身是半字节长度,再加上PID和DCS各1字节。任何一位算错,后面的文本全是乱码。我的习惯是先用已知的收件箱短信做验证,在解码逻辑里临时输出十六进制原始串,肉眼对照UD区域是否和原文一致,而不是直接看转换后的文本。

3. 串口链路与AT指令交互:从发送到接收的完整主流程

3.1 手机号压位与长度计算:两条必须背熟的规则

PDU模式下,手机号不是直接以ASCII形式放进串里的。国内11位手机号,比如13800138000,要先在最前面加一个“91”表示国际格式,然后从右往左两两分组,每组内高低位互换,最后一位如果落单要补F。所以13800138000会变成31 08 30 01 38 00 F1这样的字节序列。这个变换就是pdu_class里名为PhoneNumberToPdu的函数:

// 手机号转PDU格式(93开头为国际格式,号码奇偶位互换) private static string PhoneNumberToPdu(string phone) { if (phone.StartsWith("+")) { phone = phone.Substring(1); } if (phone.Length % 2 != 0) { phone += "F"; // 奇数长度末尾补F } StringBuilder sb = new StringBuilder(); for (int i = 0; i < phone.Length; i += 2) { sb.Append(phone[i + 1]); sb.Append(phone[i]); // 高低位互换 } return "91" + sb.ToString(); // 91表示国际格式 }

这个函数的参数含义很直观:传入的phone必须是去掉了+号的纯数字串。我在实际项目里遇到过一次翻车:号码里带了空格或短横线,长度判断失灵,压位后整个号全错了。所以调用前一定要做清洗,正则里只留数字。这个函数输出的串前面不用加长度字节,因为TPDU中号码长度是单独一个字节表示的,取的是压位后字节数的一半,也就是原始号码的位数。

计算AT+CMGS需要的长度时,规则是:PDU串去掉开头的SMSC长度字节(通常是2位十六进制),剩下的TPDU部分,按字节数除以2。举例来说,假设SMSC长度是08,那么SMSC部分占18个十六进制字符(1字节长度 + 8字节地址 = 9字节),整条PDU是56个十六进制字符,则TPDU长度 = (56 - 18) / 2 = 19。这个19就是要传给AT+CMGS的数值。很多人直接用整条PDU的长度除以2,结果设备一直返回ERROR,检查半天才发现是长度含义没搞清。

3.2 串口参数与AT指令交互:发送一条中文短信的完整过程

硬件链路上,短信猫模块一般通过串口与上位机通信,常见波特率是9600和115200,数据位8位,无校验,1位停止位,即8N1。流控建议设成None,除非你确定模块使用的是硬件流控。连接方式上,我一般用System.IO.Ports.SerialPort类,设置完参数后Open,然后写入AT指令。

发送一条中文短信的完整AT指令序列是固定的:先发AT+CMGF=0,把模块切到PDU模式;再发AT+CMGS=<长度>,此时模块会返回一个ASCII字符“>”,表示可以输入PDU数据;接着写入PDU串,最后补一个十六进制的0x1A(对应键盘的CTRL+Z)作为结束符。这里的一个常见坑是:很多模块在收到“>”后如果超过一定时间没有收到数据,会自动退出发送状态,所以在SendData之前最好确认一下串口的WriteTimeout不要设得太短。

// 发送PDU短信的核心流程 public bool SendSms(string phone, string content) { string pdu = PduBuilder.BuildSms(phone, content); int tpduLength = (pdu.Length - (Convert.ToInt32(pdu.Substring(0, 2), 16) + 1) * 2 + 2) / 2; serialPort.WriteLine("AT+CMGF=0\r"); Thread.Sleep(100); serialPort.Write("AT+CMGS=" + tpduLength + "\r"); // 等待模块返回 ">" 提示符 StringBuilder response = new StringBuilder(); int waitCount = 0; while (waitCount < 10 && !response.ToString().Contains(">")) { response.Append(serialPort.ReadExisting()); Thread.Sleep(100); waitCount++; } if (!response.ToString().Contains(">")) { return false; // 没有收到提示符,放弃本次发送 } serialPort.Write(pdu); serialPort.Write(new byte[] { 0x1A }, 0, 1); // Ctrl+Z 结束 return true; }

这段代码里比较关键的有三处:第一处是tpduLength的计算,直接解析PDU串的SMSC长度字节动态算,而不是写死某个值,这样换不同短信中心也能适配;第二处是等待“>”的循环,用超时控制而不是无限等待,避免模块掉线时程序卡死;第三处是结束符必须发0x1A,发成“\r”或“\n”模块不会把短信真正送出去。我碰到过有人把结束符写成字符串“\x1A”,这在C#里会被当作字符串结束,实际发送到串口的是空字节,模块必然没反应。

3.3 接收短信与解码流程:PDU模式下的收件箱处理

接收短信的AT指令通常是AT+CMGL=4表示读取所有未读短信,AT+CMGR=1表示读取指定索引的短信。模块返回的数据格式是多行AT响应,最后以OK结束。每条短信内容先是AT+CMGR: 状态码,然后换行,接着是PDU串。pdu_class的解码入口接收的就是这一整行PDU,然后依次解析SMSC、发送方号码、DCS和用户数据。

这里要注意:模块返回的PDU串里有不可见字符吗?没有,它就是纯十六进制字符串。但有些模块会把PDU串分成多行返回,比如某些4G模组在长短信时会插入换行符。解析前建议先做一次字符串清理:去掉所有\r、\n、空格。我见过年轻同事在这里直接按行读取然后取第一行,结果长短信的PDU被截断,解码出来一半是乱码。常见的做法是先把返回内容里的空白字符全部移除,再进入解码器。

// 从AT+CMGL返回结果中提取并解析PDU短信 public List<string> ReadInbox() { serialPort.WriteLine("AT+CMGL=4\r"); Thread.Sleep(500); string rawData = serialPort.ReadExisting(); rawData = rawData.Replace("\r", "").Replace("\n", "").Replace(" ", ""); List<string> results = new List<string>(); int idx = rawData.IndexOf("+CMGL:"); while (idx >= 0) { int start = rawData.IndexOf('"', idx) + 1; // 跳过状态字段 // 裁剪PDU串(从状态后的引号到最后OK之前) int pduStart = rawData.IndexOf("OK", idx); string pduLine = rawData.Substring(idx, pduStart - idx); results.Add(DecodeSms(pduLine)); idx = rawData.IndexOf("+CMGL:", idx + 1); } return results; }

这段读取逻辑里有几个参数值得留意:ReadExisting是一次性把缓冲区里的数据取回来,如果模块响应慢,可能漏掉后半段,所以我一般会在ReadLine或事件回调里做二次读取。IndexOf("+CMGL:", idx)这个循环条件看着没问题,但实际用的时候要注意:如果收件箱为空,模块只返回OK,循环不会进入,结果是空列表,这符合预期。解码后的短信如果想标记为已读,需要额外发AT+CMGD=索引删除,或者用AT+CMGL=0读已读短信,这取决于业务需求。

4. 短信猫开发避坑:五个最容易翻车的现场复原

4.1 现象:AT+CMGS返回ERROR,发送流程直接中断

这是最频繁的报错。原因往往不是模块坏了,而是长度参数算错了。AT+CMGS后面跟的TPDU长度必须精确到字节,很多初学开发者把整个PDU串的长度除以2,忽略了SMSC部分不计入这个长度。解决方法是严格按照PDU串的结构去算:先取出SMSC长度标识(前两位十六进制转十进制),SMSC部分占(2 + 该值 * 2)个十六进制字符,TPDU长度等于(PDU总长度 - SMSC部分长度) / 2。

示例: PDU = 0891683108200015F011000B813180021262F10008A0054F60597D SMSC长度标识 = 08,SMSC部分占 2 + 8*2 = 18 个半字节 TPDU长度 = (PDU总长度 - 18) / 2

我踩过坑的地方在于:某些模块对TPDU长度为0的指令会直接返回ERROR,而不是给出提示。所以调试时先确认长度变量是否真的算对了,最好的检验方法是拿一条已知能发送的PDU串,手工数一遍长度,再在断点里观察计算结果。

4.2 现象:中文短信发出去变成一串问号或完全空白

现象是模块返回OK,但收到的短信是“??”或空内容。原因大概率是DCS字段设成了00(7-bit编码),而内容里包含汉字。7-bit根本表达不了中文,设备只能用替代字符填充。解决方法是确认DCS设为08,并在编码前用正则或字符范围检测文本是否含非ASCII字符,一旦发现就强制走UCS2分支。

// 判断是否包含中文字符,决定DCS编码方案 public static string BuildSms(string phone, string content) { bool hasChinese = Regex.IsMatch(content, @"[\u4e00-\u9fa5]"); string dcs = hasChinese ? "08" : "00"; string userData = hasChinese ? ChineseToUcs2(content) : SevenBitEncode(content); string phonePdu = PhoneNumberToPdu(phone); string tpdu = "11" + "00" + phonePdu + "00" + dcs + "00" + userData; return "0891683108200015F1" + tpdu; // 短信中心地址可以动态配置 }

这个函数里埋了一个比较隐蔽的坑:Regex的中文范围判断只覆盖基本区,生僻字可能漏判。我一般再加一个条件,只要content.Any(c => c > 127)就按UCS2处理,因为7-bit只支持0-127的ASCII。

4.3 现象:串口打开失败,报Access denied或设备占用

原因不是代码问题,而是串口被另一个程序占用。常见的占用来源包括串口调试助手、超级终端,以及同一个程序里重复New了SerialPort对象但没释放。解决方法是:搜索电脑上所有SerialPort.GetPortNames()列出的端口,逐个尝试打开;打开前用try-catch捕获UnauthorizedAccessException;程序退出时确保调用Close和Dispose。

// 安全打开串口,失败时给出明确提示 public bool OpenPort(string portName, int baudRate) { try { serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); serialPort.Handshake = Handshake.None; serialPort.ReadTimeout = 1000; serialPort.WriteTimeout = 1000; serialPort.Open(); return true; } catch (UnauthorizedAccessException) { // 端口被占用时的处理 return false; } catch (IOException) { // 端口不存在或已被拔出 return false; } }

这里面有个容易被忽略的参数:Handshake。如果模块的串口默认不带流控,而你在代码里设置了Hardware握手,模块那边没有对应引脚信号,Open会成功但Write会超时。我一般统一设成None,除非设备手册明确要求开流控。

4.4 现象:短信发出去后接收方收到两条,一条是正文一条是空短信

这个现象看起来很诡异,但原因很简单:PDU串发送结束后多了个多余的结束符。模块收到0x1A后会把当前数据送出,如果你的程序在Send之后又调用了WriteLine(serialPort, "\r")或者再次发送0x1A,就会产生第二条空短信。解决方法是严格检查发送流程:0x1A只能发一次,且不能在它之后再写任何字符。调试时可以在串口监视器里观察实际发出的字节序列,确认0x1A后面没有其他数据。

4.5 现象:程序一直卡在ReadExisting读取不到返回数据

原因通常是模块的响应时间比想象中慢,尤其是在开机或断网后恢复时,AT指令可能延迟几秒。解决方法是改用事件驱动或带超时的循环读取,不要用一次ReadExisting就期待完整结果。可以用SerialPort.DataReceived事件把每次到达的数据拼接到一个缓冲区,再判断是否包含OK或ERROR标记。

// 事件驱动方式接收模块响应 private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { SerialPort sp = (SerialPort)sender; string data = sp.ReadExisting(); responseBuilder.Append(data); if (responseBuilder.ToString().Contains("OK") || responseBuilder.ToString().Contains("ERROR")) { // 触发信号,通知主线程继续 responseSignal.Set(); } }

这里最值得关注的是responseSignal,它是一个AutoResetEvent或者ManualResetEvent。主线程发送指令后调用WaitOne(timeout),避免用Sleep固定等待,既能缩短单条短信的发送耗时,也能在模块无响应时按超时退出而不是无限卡住。

5. 进阶:把这套代码改造成可复用的短信服务组件

5.1 从“写死”到配置驱动:参数外置与回调封装

pdu_class的原始形态通常是把所有逻辑堆在一个窗体事件里,短信中心号码、串口号、波特率硬编码。真正常规项目的做法是把发送和接收逻辑提取成一个独立的SmsService类,构造函数接收串口配置对象,对外暴露SendMessage和MessageReceived事件。

// 可复用的短信服务组件核心结构 public class SmsService { private readonly SerialPort _port; public event Action<SmsMessage> MessageReceived; public SmsService(string portName, int baudRate) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.DataReceived += OnDataReceived; } public bool Send(string phone, string content) { // 复用pdu_class的编码逻辑 return true; } public void Start() { _port.Open(); _port.WriteLine("AT+CMGF=0"); } public void Stop() { _port.Close(); } }

这样的好处很明显:业务层不需要关心PDU格式,只需要订阅MessageReceived事件获取解码后的SmsMessage对象。SmsMessage里包含From、Content、ReceivedAt三个属性,和实际业务对接非常自然。

5.2 验证与回归:没有真实模块时怎么测试

没有短信猫硬件的时候,可以用虚拟串口工具创建一对互联的虚拟端口,程序连COM1,一个串口调试助手连COM2,程序把AT指令和PDU发送到COM1后,在COM2的调试窗口里能完整看到发出的数据。这个办法能验证你的PDU串长度、AT指令格式是否正确,但验证不了模块的真实响应。

如果手头有模块,最稳的验证方式是:先用串口调试助手手动发送AT+CMGF=0和AT+CMGS,确认模块能正常发送。再把pdu_class生成的PDU串手动复制到调试助手里发送,看看是否成功。如果手动能发、程序里发不出去,基本可以断定是串口时序或发送流程的问题。

从那以后,我每次拿到新模块的第一件事,就是写一个10行的串口回环测试:发AT然后等OK,再发AT+CMGF=0等OK,全部通过才接业务代码。这一步只花两分钟,却能省掉后面两个小时的排错时间。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 13:51:21

Python编程基础之字典

前言 刚开始学 Python 的人常会问&#xff1a;列表&#xff08;list&#xff09;已经能存一串数据了&#xff0c;为什么还要学字典&#xff1f;答案藏在一个很具体的场景里——当你要保存的是「成对」的数据&#xff0c;比如每门课对应一个分数、每个人对应一个电话&#xff0c…

作者头像 李华
网站建设 2026/10/17 13:50:17

Java泛型从入门到原理:类型擦除、通配符与实战避坑指南

如果你写过几年 Java&#xff0c;一定在某个深夜被ClassCastException支配过——那一行异常信息只告诉你哪里转失败了&#xff0c;就是不告诉你到底传进来的是什么。泛型就是解决这个问题的&#xff0c;它把本该在运行期爆发的类型问题&#xff0c;直接摁死在编译期。这篇就围绕…

作者头像 李华
网站建设 2026/10/17 13:50:17

SAP MM寄售全流程实施:从主数据配置到MRKO结算

简介&#xff1a;本资源是一份面向SAP MM模块初、中级实施顾问与企业供应链信息化人员的供应商寄售全流程实操指南&#xff0c;聚焦解决寄售业务在SAP系统中配置难、操作易出错、结算逻辑不清晰等典型问题。文档以结构化步骤详解11个关键环节&#xff0c;涵盖MRP2视图采购类型“…

作者头像 李华
网站建设 2026/10/17 13:50:08

A-Star算法迷宫小游戏:从原理到Word论文的完整实现方案

简介&#xff1a;这是一份面向计算机相关专业毕业设计或课程论文写作的参考文档&#xff0c;主题为基于A-Star搜索算法的迷宫小游戏设计。文档以毕业论文格式排版&#xff0c;涵盖了摘要、关键词、导言、相关理论、技术实施、结果讨论与参考文献等完整章节&#xff0c;重点阐述…

作者头像 李华
网站建设 2026/10/17 13:49:52

RAL电子色卡使用指南:看懂编码、提取色号、避坑实操

简介&#xff1a;劳尔色卡电子版是以文档格式保存的官方色号速查文件&#xff0c;面向平面设计师、工业产品设计师、建筑与室内设计从业者&#xff0c;以及制造、印刷、涂料等领域的色彩管理人员&#xff0c;帮助用户在项目选色、远程沟通、生产校对等环节快速统一颜色标准。资…

作者头像 李华
网站建设 2026/10/17 13:46:58

图算法入门:邻接矩阵与邻接表存储,DFS和BFS遍历实战

简介&#xff1a;这是一份面向算法学习者的图解数据结构笔记&#xff0c;聚焦“图”这一复杂非线性结构。内容从图的定义出发&#xff0c;系统梳理无向图、有向图、邻接点、顶点的度、完全图、子图、路径与回路、连通分量、强连通分量以及带权值的网等核心概念&#xff0c;并对…

作者头像 李华