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

发布时间:2026/10/17 13:51:53
C#短信猫开发:PDU编码、AT指令与串口通信实战详解 简介C#短信发送程序pdu_class是一份面向需要掌握GSM短信底层开发技能的C#程序员的实例工程围绕PDUProtocol 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串还原成文本和发送方号码。很多人在网上找到的同类代码只做了编码发短信可以读短信就不行。这份资源能覆盖收发两端说明作者当年是拿着真实模块调过的。我一般拿到这种类库会先看它的DCSData 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)字节这个长度会影响到后面ATCMGS的第二个参数。2.3 解码方向收到的PDU如何还原成可读文本解码是pdu_class里让我最满意的部分。设备在收到短信后如果使用ATCMGL或ATCMGR读取返回的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中号码长度是单独一个字节表示的取的是压位后字节数的一半也就是原始号码的位数。计算ATCMGS需要的长度时规则是PDU串去掉开头的SMSC长度字节通常是2位十六进制剩下的TPDU部分按字节数除以2。举例来说假设SMSC长度是08那么SMSC部分占18个十六进制字符1字节长度 8字节地址 9字节整条PDU是56个十六进制字符则TPDU长度 (56 - 18) / 2 19。这个19就是要传给ATCMGS的数值。很多人直接用整条PDU的长度除以2结果设备一直返回ERROR检查半天才发现是长度含义没搞清。3.2 串口参数与AT指令交互发送一条中文短信的完整过程硬件链路上短信猫模块一般通过串口与上位机通信常见波特率是9600和115200数据位8位无校验1位停止位即8N1。流控建议设成None除非你确定模块使用的是硬件流控。连接方式上我一般用System.IO.Ports.SerialPort类设置完参数后Open然后写入AT指令。发送一条中文短信的完整AT指令序列是固定的先发ATCMGF0把模块切到PDU模式再发ATCMGS长度此时模块会返回一个ASCII字符“”表示可以输入PDU数据接着写入PDU串最后补一个十六进制的0x1A对应键盘的CTRLZ作为结束符。这里的一个常见坑是很多模块在收到“”后如果超过一定时间没有收到数据会自动退出发送状态所以在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(ATCMGF0\r); Thread.Sleep(100); serialPort.Write(ATCMGS 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); // CtrlZ 结束 return true; }这段代码里比较关键的有三处第一处是tpduLength的计算直接解析PDU串的SMSC长度字节动态算而不是写死某个值这样换不同短信中心也能适配第二处是等待“”的循环用超时控制而不是无限等待避免模块掉线时程序卡死第三处是结束符必须发0x1A发成“\r”或“\n”模块不会把短信真正送出去。我碰到过有人把结束符写成字符串“\x1A”这在C#里会被当作字符串结束实际发送到串口的是空字节模块必然没反应。3.3 接收短信与解码流程PDU模式下的收件箱处理接收短信的AT指令通常是ATCMGL4表示读取所有未读短信ATCMGR1表示读取指定索引的短信。模块返回的数据格式是多行AT响应最后以OK结束。每条短信内容先是ATCMGR: 状态码然后换行接着是PDU串。pdu_class的解码入口接收的就是这一整行PDU然后依次解析SMSC、发送方号码、DCS和用户数据。这里要注意模块返回的PDU串里有不可见字符吗没有它就是纯十六进制字符串。但有些模块会把PDU串分成多行返回比如某些4G模组在长短信时会插入换行符。解析前建议先做一次字符串清理去掉所有\r、\n、空格。我见过年轻同事在这里直接按行读取然后取第一行结果长短信的PDU被截断解码出来一半是乱码。常见的做法是先把返回内容里的空白字符全部移除再进入解码器。// 从ATCMGL返回结果中提取并解析PDU短信 public Liststring ReadInbox() { serialPort.WriteLine(ATCMGL4\r); Thread.Sleep(500); string rawData serialPort.ReadExisting(); rawData rawData.Replace(\r, ).Replace(\n, ).Replace( , ); Liststring results new Liststring(); 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循环不会进入结果是空列表这符合预期。解码后的短信如果想标记为已读需要额外发ATCMGD索引删除或者用ATCMGL0读已读短信这取决于业务需求。4. 短信猫开发避坑五个最容易翻车的现场复原4.1 现象ATCMGS返回ERROR发送流程直接中断这是最频繁的报错。原因往往不是模块坏了而是长度参数算错了。ATCMGS后面跟的TPDU长度必须精确到字节很多初学开发者把整个PDU串的长度除以2忽略了SMSC部分不计入这个长度。解决方法是严格按照PDU串的结构去算先取出SMSC长度标识前两位十六进制转十进制SMSC部分占(2 该值 * 2)个十六进制字符TPDU长度等于(PDU总长度 - SMSC部分长度) / 2。示例 PDU 0891683108200015F011000B813180021262F10008A0054F60597D SMSC长度标识 08SMSC部分占 2 8*2 18 个半字节 TPDU长度 (PDU总长度 - 18) / 2我踩过坑的地方在于某些模块对TPDU长度为0的指令会直接返回ERROR而不是给出提示。所以调试时先确认长度变量是否真的算对了最好的检验方法是拿一条已知能发送的PDU串手工数一遍长度再在断点里观察计算结果。4.2 现象中文短信发出去变成一串问号或完全空白现象是模块返回OK但收到的短信是“”或空内容。原因大概率是DCS字段设成了007-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 ActionSmsMessage 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(ATCMGF0); } public void Stop() { _port.Close(); } }这样的好处很明显业务层不需要关心PDU格式只需要订阅MessageReceived事件获取解码后的SmsMessage对象。SmsMessage里包含From、Content、ReceivedAt三个属性和实际业务对接非常自然。5.2 验证与回归没有真实模块时怎么测试没有短信猫硬件的时候可以用虚拟串口工具创建一对互联的虚拟端口程序连COM1一个串口调试助手连COM2程序把AT指令和PDU发送到COM1后在COM2的调试窗口里能完整看到发出的数据。这个办法能验证你的PDU串长度、AT指令格式是否正确但验证不了模块的真实响应。如果手头有模块最稳的验证方式是先用串口调试助手手动发送ATCMGF0和ATCMGS确认模块能正常发送。再把pdu_class生成的PDU串手动复制到调试助手里发送看看是否成功。如果手动能发、程序里发不出去基本可以断定是串口时序或发送流程的问题。从那以后我每次拿到新模块的第一件事就是写一个10行的串口回环测试发AT然后等OK再发ATCMGF0等OK全部通过才接业务代码。这一步只花两分钟却能省掉后面两个小时的排错时间。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询