news 2026/9/30 7:32:57

C# 通过 CDMA 猫发送中文短信:PDU 编码与 UCS2 实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# 通过 CDMA 猫发送中文短信:PDU 编码与 UCS2 实战

简介:这份资源面向使用CDMA调制解调器进行短信开发的C#程序员,聚焦一个常见却棘手的问题:CDMA猫不支持PDU模式,无法在超级终端直接输入中文短信,只能通过程序以UNICODE编码发送。资源以PDF形式给出可运行的C#代码模板,演示如何用SerialPort类完成串口通信、BigEndianUnicode编码转换、AT+WSCL与AT+CMGF等命令配置,以及CTRL+Z结束标识的写入细节,并提示号码前需去掉86、波特率通常为115200等易错点。压缩包共1个PDF文件,约38KB,内容紧凑,适合作为调试基础模板,按具体猫的厂商与型号调整。目前已有257人学习,可帮助读者绕开乱码与发送失败问题,快速掌握CDMA中文短信的编程发送思路。

1. CDMA 猫发中文短信:为什么 PDU 编码是绕不开的第一道坎

手里有一台 CDMA 模块(常见的是工业级串口猫,插在工控机或上位机上),要用 C# 通过 AT 命令发一条中文短信出去——这个需求听起来简单,真动手就会发现,英文短信一条AT+CMGS就完事,中文却十有八九卡在编码上:发出去是乱码,或者干脆返回ERROR,或者短信发出去了但对方收到一串问号。问题不在 AT 命令本身,而在于中文短信必须走 PDU(Protocol Data Unit)模式,而 PDU 里的中文要经过 UCS2 编码再转十六进制,长度、字节序、拼接方式一个都不能错。

这篇讲的就是这条链路:从串口打开、AT 命令握手、AT+CMGF切换模式,到 PDU 组包、UCS2 编码、AT+CMGS发送、+CMGS回执解析,全部用 C# 落地。适合正在做 C# 上位机、工控数据采集、设备告警短信推送的工程师,也适合刚接触 CDMA 模块、被中文编码折腾过一轮的人。下面按「先跑通最小链路,再抠参数和坑」的顺序来,代码可以直接抄进项目改。

2. 从串口到 AT 握手:把 CDMA 猫拉进可通信状态

2.1 先确认模块到底认哪套 AT 指令集

CDMA 模块和 GSM 模块在 AT 指令上有交集也有差异。发短信的核心命令AT+CMGF、AT+CMGS、AT+CPMS基本通用,但 CDMA 侧对 PDU 模式的支持程度、字符集命令AT+CSCS的取值、以及短信存储位置编号规则,不同厂商(比如一些基于高通平台的模块)会有出入。所以第一步不是写代码,是拿串口工具手动敲一遍,确认模块的真实反应。

常见做法是用串口调试助手,波特率一般 115200(也有 9600 的,看模块手册),8 数据位、1 停止位、无校验。连上后依次发:

AT AT+CPMS? AT+CMGF=? AT+CSCS=?

AT返回OK说明串口通了。AT+CPMS?看短信存储区,返回类似+CPMS: "SM",0,20,"SM",0,20表示 SIM/模块存储可用。AT+CMGF=?返回+CMGF: (0,1)说明支持 0(PDU)和 1(文本)两种模式。AT+CSCS=?看支持的字符集,通常有"IRA"、"UCS2"、"GSM"。

提示:如果AT+CMGF=?只返回(0),说明这个模块只支持 PDU 模式,文本模式发中文这条路直接堵死,必须走 PDU。

这一步别省。我见过有人代码写得没问题,就是模块固件把AT+CSCS锁死在IRA,导致后面 UCS2 编码发出去全乱。手动确认一遍,比在代码里 debug 半天快得多。

2.2 C# 串口初始化:别用默认值,超时和编码都要显式设

C# 里操作串口用System.IO.Ports.SerialPort。这个类有几个默认值很坑:ReadTimeout默认是InfiniteTimeout,一旦模块不回应,读操作会永久阻塞,上位机直接假死。所以初始化时超时必须显式给。

using System; using System.IO.Ports; using System.Text; public class CdmaModem { private SerialPort _port; public bool Open(string portName, int baudRate = 115200) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); // 关键:显式设置超时,避免模块无响应时线程永久阻塞 _port.ReadTimeout = 3000; _port.WriteTimeout = 3000; // 关闭 DTR/DSR 流控,多数工业模块不需要 _port.Handshake = Handshake.None; // 编码用 ASCII,AT 命令本身是 ASCII,中文在 PDU 里已转成十六进制字符串 _port.Encoding = Encoding.ASCII; try { _port.Open(); return true; } catch (Exception ex) { Console.WriteLine($"串口打开失败: {ex.Message}"); return false; } } }

逻辑说明:SerialPort构造函数里dataBits=8、stopBits=One是 AT 通信的标准配置。ReadTimeout设 3000ms 是经验值——模块响应 AT 命令通常在几百毫秒内,3 秒足够,又不至于卡太久。Encoding设 ASCII 是因为我们发给模块的 AT 命令和 PDU 字符串全是 ASCII 可见字符,中文在组包阶段就已经转成 UCS2 十六进制了,串口层不碰中文。

参数说明:portName在 Windows 上是"COM3"这种格式,可以用SerialPort.GetPortNames()枚举。baudRate必须和模块实际波特率一致,不一致会收到乱码或完全无响应。如果模块支持自动波特率,先发AT试探,但工业场景建议固定波特率,避免协商失败。

2.3 写一个带结束符判断的 AT 命令收发方法

AT 命令的收发不是简单的「写进去读出来」。模块的响应以\r\n分隔,最后以OK、ERROR或+CMx:开头的结果码结束。如果只读一次,很可能读到半截。正确做法是循环读到出现终止标志为止。

public string SendAt(string command, int waitMs = 500) { _port.DiscardInBuffer(); // 清掉上次残留 _port.Write(command + "\r"); // AT 命令以 \r 结尾 System.Threading.Thread.Sleep(waitMs); var sb = new StringBuilder(); var deadline = DateTime.Now.AddMilliseconds(_port.ReadTimeout); while (DateTime.Now < deadline) { try { string chunk = _port.ReadExisting(); if (!string.IsNullOrEmpty(chunk)) { sb.Append(chunk); // 出现终止标志就提前退出 if (sb.ToString().Contains("OK\r\n") || sb.ToString().Contains("ERROR\r\n") || sb.ToString().Contains("> ")) // CMGS 等待输入提示符 break; } } catch (TimeoutException) { break; } System.Threading.Thread.Sleep(50); } return sb.ToString(); }

逻辑说明:DiscardInBuffer()清空接收缓冲,防止上一条命令的残留响应干扰判断。Write时命令末尾加\r,这是 AT 规范要求的回车符,有些模块也接受\r\n,但\r最通用。循环里用ReadExisting()非阻塞读取,累积到StringBuilder,一旦检测到OK、ERROR或>(AT+CMGS特有的等待输入提示符)就跳出。

参数说明:waitMs是发送后等待模块开始响应的时间,500ms 对多数模块够用。ReadTimeout决定整个读取循环的上限。注意>这个提示符——发AT+CMGS后模块不会立刻返回OK,而是回一个>等你输入短信内容,这个判断必须加,否则会一直等到超时。

3. PDU 组包:中文短信的 UCS2 编码与长度计算

3.1 PDU 结构拆解:为什么中文必须走 UCS2

一条 PDU 格式的短信,核心结构是:SMSC 地址 + 短信首字节 + 目标号码 + PID + DCS + 时间戳 + 用户数据长度 + 用户数据。其中 DCS(Data Coding Scheme)决定编码方式:00是 7-bit GSM 编码(英文),08是 UCS2 编码(中文)。中文一个字符在 UCS2 下占 2 字节,转成十六进制就是 4 个字符。

很多人第一次发中文失败,是因为用了文本模式AT+CMGF=1然后直接AT+CMGS="号码"发中文。文本模式下模块对中文的支持取决于固件,很多 CDMA 模块根本不认,返回ERROR。稳妥路线是AT+CMGF=0走 PDU,自己组包。

PDU 里目标号码的编码也有讲究:号码要按半字节反转,比如13800138000要写成3108103800F0(补 F 凑偶数位,然后两两反转)。这一步错了,短信会发到错误号码或者直接被拒。

3.2 UCS2 编码与号码半字节反转的 C# 实现

先解决两个基础转换:字符串转 UCS2 十六进制,以及号码转 PDU 格式。

// 中文转 UCS2 十六进制字符串 public static string ToUcs2Hex(string text) { var bytes = Encoding.BigEndianUnicode.GetBytes(text); // UCS2 大端 var sb = new StringBuilder(); foreach (var b in bytes) sb.Append(b.ToString("X2")); return sb.ToString(); } // 目标号码转 PDU 半字节反转格式 public static string EncodePhoneNumber(string number) { // 去掉 +86 等前缀,只留数字 number = number.TrimStart('+'); if (number.Length % 2 != 0) number += "F"; // 奇数位补 F var sb = new StringBuilder(); for (int i = 0; i < number.Length; i += 2) { // 每两位反转 sb.Append(number[i + 1]); sb.Append(number[i]); } return sb.ToString(); }

逻辑说明:Encoding.BigEndianUnicode就是 UCS2 大端序,中文「你好」会得到4F60597D。注意不能用Encoding.Unicode,那是小端,字节序反了对方收到就是乱码。号码反转是因为 PDU 规范里号码按半字节(nibble)存储,低位在前。13800138000补 F 后是13800138000F,两两反转得到3108103800F0。

参数说明:ToUcs2Hex输入是任意中英文字符串,输出是纯十六进制大写字符串,长度一定是 4 的倍数。EncodePhoneNumber输入带不带+都行,内部会去掉,输出长度是偶数。如果号码本身是偶数位,不补 F,直接反转。

3.3 组装完整 PDU 串:长度字段是最容易算错的地方

有了 UCS2 和号码编码,就可以拼 PDU 了。以AT+CMGS为例,PDU 串里第一个字节是 SMSC 地址长度,通常设00表示用模块默认短信中心。

public static string BuildPdu(string phoneNumber, string message) { string smsc = "00"; // 用默认短信中心 string firstOctet = "11"; // 短信首字节:普通提交 string tpMr = "00"; // 消息参考号 string tpDa = EncodePhoneNumber(phoneNumber); string tpDaLength = (phoneNumber.TrimStart('+').Length).ToString("X2"); string tpPid = "00"; // 协议标识 string tpDcs = "08"; // UCS2 编码 string tpScts = "00000000000000"; // 时间戳,模块可自动填 string userData = ToUcs2Hex(message); string userDataLength = (userData.Length / 2).ToString("X2"); // 字节数 return smsc + firstOctet + tpMr + tpDaLength + tpDa + tpPid + tpDcs + tpScts + userDataLength + userData; }

逻辑说明:tpDaLength是目标号码的「数字位数」,不是字节数,13800138000是 11 位,写成0B。userDataLength是用户数据的字节数,UCS2 下每个中文 2 字节,userData.Length / 2就是字节数。tpScts设全 0 让模块自己填时间戳,多数模块支持。

参数说明:firstOctet=11表示普通短信提交,如果需要状态报告可以改成31。tpDcs=08是 UCS2 的固定值,改成00就变英文 7-bit 了。userDataLength最大FF(255 字节),但实际单条短信 UCS2 最多 70 个字符(140 字节),超了要拆多条,这是另一个话题。

注意:AT+CMGS的 PDU 长度参数是「除去 SMSC 部分后的长度」,也就是从firstOctet开始算的字节数,不是整个 PDU 串的长度。这个坑我第一次踩的时候,短信死活发不出去,返回ERROR,查了半天才发现长度多算了 SMSC 那两个字符。

4. 发送与回执:AT+CMGS 的交互时序和 +CMGS 解析

4.1 AT+CMGS 的两段式交互:先发命令,再发内容

AT+CMGS和普通 AT 命令不一样,它是两段式:先发AT+CMGS=<长度>,模块回>提示符,再发 PDU 串 +Ctrl+Z(0x1A),模块才真正发送并返回+CMGS: <mr>和OK。这个时序必须严格遵守,中间不能乱插命令。

public bool SendSms(string phoneNumber, string message) { string pdu = BuildPdu(phoneNumber, message); // PDU 长度 = 总长度/2 - SMSC 部分长度(2字符=1字节) int pduLength = pdu.Length / 2 - 1; // 第一段:发 CMGS 命令 string resp1 = SendAt($"AT+CMGS={pduLength}", 300); if (!resp1.Contains(">")) { Console.WriteLine($"未进入输入模式: {resp1}"); return false; } // 第二段:发 PDU + Ctrl+Z _port.Write(pdu + "\x1A"); System.Threading.Thread.Sleep(3000); // 发送需要时间,等长一点 string resp2 = _port.ReadExisting(); // 成功标志:+CMGS: xx 后跟 OK return resp2.Contains("+CMGS:") && resp2.Contains("OK"); }

逻辑说明:pduLength的计算是pdu.Length / 2 - 1,因为 PDU 串是十六进制字符串,两个字符代表一个字节,减去 SMSC 那一个字节(00两个字符)。SendAt里已经判断了>提示符,这里再确认一次。第二段写入后必须等足够时间,短信发送涉及网络交互,3 秒是保守值,网络差可以加到 5 秒。

参数说明:\x1A是 Ctrl+Z 的 ASCII 码,是 PDU 模式发送的结束标志,不能少也不能用别的字符代替。resp2里+CMGS:后面的数字是消息参考号,可以用来和状态报告对应。如果返回+CMS ERROR: 500,通常是 PDU 格式错误;+CMS ERROR: 515可能是模块没注册网络。

4.2 回执解析与错误码对照

发送失败时,模块返回的错误码是排查的唯一线索。常见错误码和对应原因整理如下:

错误码含义常见原因
500未知错误PDU 格式错误、长度算错
515模块未注册网络无信号、SIM 未就绪
21短信中心地址无效SMSC 未设置
330短信存储满需要先删旧短信
305无效的 PDU 模式参数DCS 或编码不匹配

解析回执时,不要只判断OK,要把+CMS ERROR:后面的码提取出来打日志。工业现场没有日志,出了问题只能靠猜。

private void ParseError(string resp) { int idx = resp.IndexOf("+CMS ERROR:"); if (idx >= 0) { string code = resp.Substring(idx + 11).Trim().Split('\r')[0]; Console.WriteLine($"短信发送失败,错误码: {code}"); } }

逻辑说明:+CMS ERROR:后面跟的是错误码,可能带\r\n,所以要截断。把错误码打出来,对照上面的表,基本能定位问题。

参数说明:idx + 11是+CMS ERROR:这个字符串的长度,Split('\r')[0]取第一段避免把换行符带进日志。

4.3 一个完整的发送流程串起来

把前面的方法串成一次完整调用:

var modem = new CdmaModem(); if (modem.Open("COM3")) { modem.SendAt("AT"); // 握手 modem.SendAt("AT+CMGF=0"); // PDU 模式 modem.SendAt("AT+CSCS=\"UCS2\""); // 字符集 bool ok = modem.SendSms("13800138000", "设备告警:温度过高"); Console.WriteLine(ok ? "发送成功" : "发送失败"); }

逻辑说明:AT+CMGF=0切 PDU 模式,AT+CSCS="UCS2"告诉模块后续用 UCS2 字符集。注意AT+CSCS在某些模块上对 PDU 模式没影响,但设了不亏。SendSms内部完成组包和两段式发送。

参数说明:AT+CSCS的值要用双引号包起来,在 C# 字符串里要转义成\"。如果模块不支持 UCS2 字符集,这条命令会返回ERROR,但不影响 PDU 发送,可以忽略。

5. 避坑与排查:中文短信发不出去时先看这几条

5.1 坑一:短信发出去了,对方收到乱码

现象:AT+CMGS返回OK,流程全对,但接收方看到的是乱码或问号。

原因:UCS2 编码用了小端序。Encoding.Unicode在 C# 里是小端,Encoding.BigEndianUnicode才是 UCS2 大端。PDU 规范要求大端。

解决:确认ToUcs2Hex里用的是BigEndianUnicode。可以拿「你好」测试,正确输出是4F60597D,如果输出604F7D59就是字节序反了。

5.2 坑二:AT+CMGS 返回 ERROR,长度参数怎么算都不对

现象:AT+CMGS=<长度>后模块直接回ERROR,连>提示符都不给。

原因:长度参数算错。这个长度是「PDU 串去掉 SMSC 部分后的字节数」,不是整个 PDU 串的字符数,也不是字节数。

解决:pduLength = pdu.Length / 2 - 1。pdu.Length / 2是整个 PDU 的字节数,减 1 是去掉 SMSC 那一个字节。如果 SMSC 不是00而是实际号码,减去的长度要按 SMSC 实际字节数算。

5.3 坑三:模块无响应,上位机界面卡死

现象:点了发送按钮,界面直接卡住,任务管理器显示进程无响应。

原因:SerialPort.ReadTimeout默认是InfiniteTimeout,模块不回应时读操作永久阻塞,如果在 UI 线程调用就直接卡死。

解决:显式设ReadTimeout,并且把短信发送放到后台线程或Task.Run里,不要在主 UI 线程做串口读写。工业上位机里这是血泪经验,界面卡一次现场就投诉一次。

5.4 坑四:发送成功但对方很久才收到,或者收不到

现象:模块返回OK,但短信延迟几分钟甚至丢失。

原因:模块信号弱、短信中心繁忙,或者AT+CMGS后等待时间不够就发了下一条命令,导致上一条还没真正提交。

解决:发送后至少等 3 秒再操作模块。如果要做批量发送,每条之间加 2-3 秒间隔,并且检查+CMGS:回执确认提交成功。信号质量可以用AT+CSQ查,返回值第一个数小于 10 说明信号很差。

5.5 坑五:换了一张 SIM 卡就发不出去了

现象:同样的代码,换 SIM 卡后全部失败。

原因:不同运营商的短信中心号码不同,PDU 里 SMSC 设00让模块用默认值,但有些模块的默认 SMSC 是空的。

解决:用AT+CSCA?查当前短信中心,如果是空,用AT+CSCA="+8613800xxx500"设置(号码问运营商要)。设置后再发。

6. 进阶:批量发送、状态报告与长短信拆分

单条中文短信跑通后,实际项目里很快就会遇到三个进阶需求:批量发送、确认对方收到、以及超过 70 个字符的长短信。

批量发送的关键不是循环调用SendSms,而是控制节奏。我一般会在每条之间加 2 秒延迟,并且用AT+CPMS?检查存储区,避免模块缓存满导致后续失败。如果一次要发几百条,建议加一个发送队列,失败的重试两次,两次都失败就记日志跳过,不要死磕一条把整个队列堵死。

状态报告需要两步:PDU 首字节从11改成31开启状态报告请求,然后模块收到状态报告后会通过+CDS:主动上报。C# 侧要开一个后台线程持续读串口,解析+CDS:里的状态码。+CDS: 0表示对方已收到,非 0 表示失败。这个机制在告警场景很有用,能确认短信真的送达了,而不是只发出去。

长短信拆分是中文短信最麻烦的部分。UCS2 下单条最多 70 个字符,超过就要拆成多条,每条带 UDH(用户数据头)标识序号,接收方按序号拼接。UDH 占 6 字节,所以拆分后每条实际只能放 67 个字符。拆分逻辑要自己实现:把消息按 67 字符切段,每段前面加050003<参考号><总段数><当前段号>的 UDH,然后userDataLength要把 UDH 的 6 字节算进去。参考号随便取一个 0-255 的值,同一条长短信的所有分段用同一个参考号。

// 长短信拆分核心逻辑示意 public static List<string> SplitLongSms(string message, byte refNum) { const int maxPerPart = 67; // UCS2 下扣除 UDH 后的容量 var parts = new List<string>(); int total = (int)Math.Ceiling(message.Length / (double)maxPerPart); for (int i = 0; i < total; i++) { string seg = message.Substring(i * maxPerPart, Math.Min(maxPerPart, message.Length - i * maxPerPart)); string udh = $"050003{refNum:X2}{total:X2}{(i + 1):X2}"; parts.Add(udh + ToUcs2Hex(seg)); } return parts; }

逻辑说明:050003是 UDH 固定头,表示后续是 16 位参考号的分段信息。refNum同一条短信的所有分段必须一致。total和(i+1)分别是总段数和当前段号,接收方靠这两个值拼接。每段的userDataLength要算成UDH 字节数 + 内容字节数。

参数说明:maxPerPart=67是 UCS2 长短信的标准值,如果模块支持更长的 UDH 可以调整,但 67 最通用。refNum建议用随机数或递增计数器,避免和别的长短信冲突。

最后说个我自己的习惯:每次改完 PDU 组包逻辑,先拿「你好」这种两个字的短消息测,确认4F60597D这个已知结果对得上,再测长消息。PDU 这东西,长度和字节序错一个字符就全盘失败,用已知结果做锚点,比对着日志猜快十倍。希望帮到你。

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

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

Claude Code 多配置管理方案:cc-switch 切换 API Key 与 Base URL

如果你手里同时有公司分配的账号、个人在用的 API 服务商&#xff0c;还有临时要测试的第三方模型入口&#xff0c;那你大概率体会过这种痛苦&#xff1a;每次切换 Claude Code 的配置&#xff0c;都要打开终端重新 export 一遍环境变量&#xff0c;改错了又得翻日志&#xff0…

作者头像 李华
网站建设 2026/9/30 7:32:35

DeepSeek Harness 安装部署:搭建统一模型调用网关

开头直接进入主题。这次我们来看 DeepSeek Harness 的安装部署。目标很明确&#xff1a;在本地搭一个统一模型调用层&#xff0c;把多个云端大模型接口收敛到一个服务里&#xff0c;用相对低的按量成本做日常调用、批量评测和应用接入。说清楚一点&#xff1a;标题里提到的 GPT…

作者头像 李华
网站建设 2026/9/30 7:32:35

DeepSeek-R1提示词工程实战:从推理模型特性到私部署

简介&#xff1a;这是一份围绕北京大学相关团队 DeepSeek 提示词工程与产业应用的讲座整理资料&#xff0c;面向程序员、教师、科研人员、管理者等希望借助自然语言交互优化工作流程的从业者&#xff0c;无需专业技术背景即可学习。资源为单个PDF文件&#xff0c;大小18.66MB&a…

作者头像 李华
网站建设 2026/9/30 7:32:02

遗留系统模块重构与可维护性治理实战

当项目代码已经“支离破碎”&#xff1a;一次遗留系统模块重构与可维护性治理实战你是否遇到过这样的场景&#xff1a;需求评审时&#xff0c;产品经理说“就改一个小功能”&#xff0c;你打开项目仓库&#xff0c;却发现代码已经乱成一团——几千行的上帝类、互相引用的隐式依…

作者头像 李华
网站建设 2026/9/30 7:31:57

MediaPipe姿态检测实战:从摄像头取流到关键点坐标输出

简介&#xff1a;面向Python与人工智能领域希望掌握实时视觉检测的开发者&#xff0c;这套教程演示如何借助MediaPipe预训练模型、无需自建模型&#xff0c;通过普通摄像头完成面部、手部和全身姿态的实时检测。内容涵盖MediaPipe功能概览、依赖库安装、OpenCV视频流读取、图像…

作者头像 李华
网站建设 2026/9/30 7:31:56

用 Python 和 MediaPipe 实现人脸、姿态、手势三合一实时检测

简介&#xff1a;一份面向Python及计算机视觉学习者的实战教程&#xff0c;围绕MediaPipe框架讲解如何调用预训练模型&#xff0c;实时完成面部关键点、手部跟踪与全身姿态估计。教程从环境依赖安装、网络摄像头视频流读取讲起&#xff0c;逐步覆盖面部检测、Holistic模型下的多…

作者头像 李华