STM32 GSM短信PDU模式实战:从编码到抄表系统应用 1. 项目背景与核心挑战最近在折腾一个基于STM32的远程抄表项目核心功能是通过GSM模块我手头用的是经典的TC35i自动上报电表读数。原理图刚画完静下心来一想整个系统的“灵魂”其实不在STM32本身而在于如何稳定、可靠地通过短信SMS这个看似古老却依然顽强的通道来收发数据。对于嵌入式开发者来说直接操作GSM模块收发短信尤其是处理中文和长数据是个绕不开的坎。很多人一开始觉得AT命令发短信很简单但真到了项目里要处理编码、应对网络延迟、确保数据完整坑就一个个冒出来了。这篇文章我就结合手头这个抄表系统的实际需求把GSM短信收发特别是PDU模式这块硬骨头掰开揉碎了讲清楚。无论你是做物联网设备、远程监控还是任何需要GSM通信的项目这些底层细节都能帮你省下大量调试时间。2. 短信收发模式深度解析Text vs. PDU给GSM模块发AT命令控制其收发短信主要有两种模式Text模式和PDU模式。选择哪种模式直接决定了你代码的复杂度和系统的能力上限这可不是随便选选的。2.1 Text模式快速上手与局限性Text模式顾名思义就是文本模式。在这种模式下你发送和接收的短信内容可以直接视为普通ASCII字符串对于英文和数字而言。对于初学者或者仅需处理英文通知的简单应用它几乎是零门槛。2.1.1 基本操作流程使用Text模式收发短信AT命令序列非常直观设置模式首先发送ATCMGF1\r\n将模块设置为Text模式。\r\n回车换行是每条AT命令必须的终止符少了它模块根本不会响应这是新手最容易栽跟头的地方之一。发送短信接着发送ATCMGS8613812345678\r\n。这里的关键是号码格式必须包含国际区号“86”。发送这条命令后模块会返回一个单独的提示符通常是一个空格或符号此时它处于等待输入短信正文的状态。输入正文并发送在提示符后输入你的短信内容例如Hello, meter reading is 00123.然后紧接着发送十六进制值0x1A即CtrlZ。这个字符不是可见字符在代码中你需要直接发送这个字节。成功发送后模块会回复CMGS: mr\r\nOK其中mr是消息参考号。2.1.2 Text模式的“阿喀琉斯之踵”Text模式虽然简单但局限性非常明显字符集限制默认只能处理GSM 7-bit默认字母表基本就是英文、数字和少量符号。直接发送中文会变成乱码。功能单一无法方便地设置短信有效期、送达报告等高级参数。数据承载能力弱对于我们的抄表系统电表读数可能包含数字、小数点未来可能还需要设备ID、状态码等。如果仅用Text模式数据格式设计会受限且无法发送二进制数据。实操心得Text模式适合做前期功能验证和调试。你可以用它快速测试模块是否正常工作、信号强度如何。但在实际产品中尤其是涉及中文或结构化数据时几乎一定会切换到PDU模式。2.2 PDU模式强大而复杂的核心PDUProtocol Data Unit模式是短信的底层协议数据单元模式。在这种模式下所有信息包括短信中心号码、发送方/接收方号码、时间戳、编码方式、短信内容本身都被编码成一个连续的十六进制字符串HEX串进行传输。正因为其“底层”它功能无比强大支持中文UCS2编码、长短信Concatenated SMS、设置状态报告、指定有效期等等。2.2.1 PDU模式的核心AT命令切换到PDU模式后常用的命令与Text模式类似但内涵完全不同ATCMGF0\r\n设置为PDU模式。ATCMGSlength\r\n发送短信。这里的length不是字符数而是整个PDU串的字节数十进制。发送此命令后同样会收到提示符此时你需要发送的是计算好长度的PDU十六进制字符串ASCII字符形式最后以0x1A结束。ATCMGRindex\r\n读取指定索引的短信。返回的信息是PDU格式的十六进制串。ATCMGLstat\r\n列出符合状态的短信。例如ATCMGL4会列出所有短信。2.2.2 一个PDU串的实例拆解假设模块返回一条读取短信的响应CMGR: 1,,35 0891683108200705F0040BA13178512534F4000850103101934220106CA14E8B002C0020660E5929518D8BF4 OK0891...8BF4这一长串就是PDU数据。解析它就像拆解一个数据包短信中心地址SCA0891...F0。08表示地址长度8个半字节91表示国际格式683108200705F0是短信中心号码奇偶位交换处理后的。PDU类型TP-DCS04。这个字节包含大量信息消息类型SMS-SUBMIT、是否需要状态报告、有效期格式等。消息参考TP-MR00。通常由模块自动填充。目标地址长度TP-DA0B。表示目标手机号长度为11位。目标地址类型A1。A1通常表示国际号码。目标号码3178512534F4。这是经过奇偶位交换处理的手机号138xxxxxxxx。协议标识TP-PID00。数据编码方案TP-DCS08。这是关键08表示消息体采用UCS2编码16-bit Unicode这就是支持中文的奥秘。有效期TP-VP50。表示有效期。用户数据长度TP-UDL10。表示用户数据即短信内容长度为16个字节注意对于UCS2编码一个中文字符占2字节所以这里实际是8个字符。用户数据TP-UD3101934220106CA14E8B002C0020660E5929518D8BF4。这就是UCS2编码后的短信内容。我们需要将其解码。解码过程将上述HEX串两两分组每组转换为一个Unicode码点。例如6CA1对应 Unicode U6CA1即“没”字。完整解码后短信内容是“没有消息就是好消息”。你看中文出来了。注意事项PDU串的解析和构建是短信编程中最繁琐的部分。你需要仔细处理号码的奇偶位交换Nibble Swapping、计算正确的长度字段TP-UDL对于不同编码意义不同、以及正确的十六进制编码/解码。一个字节算错整条短信就废了。3. 基于STM32与TC35i的PDU短信收发实战理论讲完了我们落到实战。如何在STM32上驱动TC35i稳定地收发PDU格式短信这里分步骤拆解。3.1 硬件连接与驱动层准备TC35i是一个经典的GSM模块通过串口UART与MCU通信。连接很简单STM32的某个USART的TX接TC35i的RXRX接TXGND共地。关键是电源TC35i的峰值电流可能超过2A必须使用能提供足够电流的电源模块并确保电源线足够粗靠近模块处加一个大电容如1000uF缓冲否则模块在发射信号时可能重启。3.1.1 串口驱动与AT命令框架首先你需要一个健壮的串口驱动支持DMA收发最佳可以减轻CPU负担。然后实现一个简单的AT命令交互框架typedef struct { uint8_t buffer[256]; uint16_t len; osSemaphoreId_t semaphore; // 用于等待响应 } at_response_t; // 发送AT命令并等待响应 bool at_send_command(const char* cmd, const char* expected_resp, uint32_t timeout_ms) { uart_send_string(cmd); // 发送命令如“ATCMGF0\r\n” // ... 启动超时定时器在串口中断中收集响应到 at_response.buffer ... // 等待信号量或循环检查直到超时或收到完整响应 // 检查 at_response.buffer 中是否包含 expected_resp return found; }这个框架的核心是状态机和超时处理。GSM模块响应时间不定网络指令如发短信可能更慢必须有超时重试机制。3.2 PDU短信发送函数实现发送一条中文PDU短信是核心任务。我们将其分解为几个函数3.2.1 PDU编码函数集// 1. 将手机号转换为PDU格式奇偶位交换 void phone_number_to_pdu_format(const char* phone, char* pdu_out) { // 示例将 8613812345678 转换为 683108214365F7 // 步骤去掉号奇数位补‘F’两两交换 // 代码实现略... } // 2. UCS2编码函数核心中的核心 uint16_t string_to_ucs2_hex(const char* utf8_str, char* hex_out) { // 将UTF-8字符串转换为UCS2 Big-Endian的十六进制字符串 // 例如“中文” - “4E2D6587” // 需要处理UTF-8到Unicode码点的转换 // 返回编码后的字节数用于计算TP-UDL } // 3. 构建完整PDU串 uint16_t build_pdu_string(const char* sca, const char* phone, const char* msg, char* pdu_out) { char temp_buf[512]; uint16_t offset 0; // a. 构建SCA字段 // b. 构建PDU类型例如 0x11 (需要状态报告) // c. 构建目标地址 // d. 设置编码为UCS2 (0x08) // e. 设置有效期 // f. 编码短信内容并计算长度 uint16_t ud_len string_to_ucs2_hex(msg, temp_buf); // g. 填写UDL (对于UCS2UDL是字节数) // h. 拼接所有部分到 pdu_out // i. 返回整个PDU串的**字节长度**注意是字节数不是HEX字符数 }3.2.2 发送流程集成在应用层发送短信的流程如下bool send_sms_pdu(const char* phone, const char* message) { // 1. 设置PDU模式 if (!at_send_command(ATCMGF0\r\n, OK, 2000)) return false; // 2. 构建PDU字符串 char pdu_hex_string[400]; // 足够大的缓冲区 uint16_t pdu_byte_length build_pdu_string(8613800550500, phone, message, pdu_hex_string); // PDU串的字节长度用于ATCMGS命令 // 3. 发送ATCMGS命令附带长度 char cmgs_cmd[30]; sprintf(cmgs_cmd, ATCMGS%d\r\n, pdu_byte_length); uart_send_string(cmgs_cmd); // 4. 等待 提示符 if (!wait_for_prompt(, 5000)) return false; // 5. 发送PDU HEX字符串 uart_send_string(pdu_hex_string); // 6. 发送结束符 CtrlZ (0x1A) uint8_t ctrl_z 0x1A; uart_send_byte(ctrl_z); // 7. 等待最终响应 return at_wait_for_response(CMGS:, 30000); // 发送短信可能耗时较长 }3.3 PDU短信接收与解析对于抄表系统接收可能不是必须的主要是下发指令但实现接收能让你系统更完整。3.3.1 接收流程模块收到新短信可能会通过CMTI提示需先使能ATCNMI2,1。根据CMTI提示的索引使用ATCMGRindex读取PDU数据。解析PDU数据提取发送方号码和短信内容。3.3.2 解析函数示例解析是编码的逆过程。你需要一个状态机或一系列函数来逐字段解析typedef struct { char sender[32]; char timestamp[32]; char message[256]; uint8_t encoding; // 0: GSM7, 8: UCS2 } sms_message_t; bool parse_pdu_string(const char* pdu_hex, sms_message_t* msg_out) { // 1. 解析SCA长度跳过SCA字段 // 2. 解析PDU类型判断是接收(SMS-DELIVER)还是其他 // 3. 解析发送方地址和号码 // 4. 解析时间戳 // 5. 解析数据编码方案(DCS)判断是GSM7还是UCS2 // 6. 根据编码方案解码用户数据(UD)字段 // - 如果是UCS2 (DCS0x08)调用 ucs2_hex_to_string // 7. 将结果填充到 msg_out 结构体 }踩坑实录在解析时间戳时PDU中的时间是“年-月-日-时-分-秒-时区”的BCD码且“年”是两位例如0x21表示2021年。时区字段需要正确处理才能转换为本地时间。我曾因为忽略时区导致显示的时间总是差几个小时。4. 抄表系统应用中的关键问题与优化把基础的短信收发跑通只是万里长征第一步。在真实的抄表系统中我们需要考虑更多工程问题。4.1 数据编码与压缩策略电表读数通常是数字比如123.45 kWh。直接UCS2编码“123.45”会占用12字节6个字符*2。这很浪费。方案一二进制编码。将浮点数123.45转换为4字节的float或使用定点数例如乘以100转为整数12345用2字节存储。这样一个读数仅需2-4字节极大地节省了短信费用一条短信70个字符UCS2下只能35个汉字或70个数字字母。方案二自定义协议。在PDU的用户数据部分设计一个简单的TLV类型-长度-值结构。例如[设备ID: 2字节] [读数类型: 1字节] [读数值: 4字节] [CRC校验: 2字节]这样一条短信可以承载多个表计的数据效率极高。4.2 长短信Concatenated SMS处理当数据量超过140字节GSM7编码或70字符UCS2编码时就需要使用长短信。长短信原理是将一条长信息分割成多条普通短信发送并在每条短信的UDH用户数据头中携带总条数、当前序号和唯一标识符。UDH结构0x00 0x03 0xXX 0xYY 0xZZ 0xNN 0xMM0x00UDH长度。0x03信息元素标识表示连接信息。0xXX 0xYY 0xZZ唯一标识符同一长短信的所有部分相同。0xNN总条数。0xMM当前序号从1开始。实现在build_pdu_string函数中如果需要分割就要为每一部分添加UDH并调整TP-UDL和用户数据的起始位置。接收端则需要一个重组缓冲区根据标识符、总条数和序号将碎片重新组装。4.3 可靠性设计与状态机工业环境要求高可靠。短信发送可能失败信号弱、中心繁忙。状态机设计为短信发送任务设计一个状态机如IDLE-SENDING-WAITING_CONFIRM-SUCCESS/FAILED。失败后能根据策略重试例如间隔5秒、30秒、1分钟各重试一次。ACK与超时利用PDU模式可以请求状态报告Delivery Report。在发送时设置TP-RD和TP-VP字段。当短信送达对方短信中心或手机时你会收到一条CDS开头的状态报告PDU。这是最可靠的送达确认方式。存储转发在STM32的Flash或外置SPI Flash中开辟一个短信队列。所有要发送的短信先存入队列主循环按序发送。即使设备中途断电上电后也能从队列中恢复未发送的消息。4.4 电源管理与信号处理TC35i功耗不低。在电池供电的抄表终端中需要精细的电源管理。休眠与唤醒通过ATCFUN0进入最低功耗休眠通过ATCFUN1唤醒。定时唤醒如每天凌晨2点上报数据。信号质量监测定期执行ATCSQ查询信号强度。如果信号太差如10可以延迟发送或尝试多次。ATCREG?可以查询网络注册状态。异常恢复增加看门狗Watchdog。如果AT命令长时间无响应可能是模块“卡死”可以通过控制其PWRKEY引脚或复位引脚进行硬件复位。这是最后的保障手段。5. 调试技巧与常见问题排查调试GSM短信串口日志是你的眼睛。一定要让模块和STM32的通信日志完整地输出到你的PC串口助手。5.1 搭建清晰的调试环境硬件上使用USB转TTL工具同时监听STM32与TC35i之间的串口通信。这样你就能看到所有原始AT命令和响应。软件上在代码中为所有AT交互添加详细的日志输出包括发送的命令、接收到的原始数据、解析后的状态。5.2 常见问题速查表问题现象可能原因排查步骤发送AT无OK返回1. 串口波特率不对TC35i默认96002. 未发送\r\n终止符3. 硬件连接错误TX/RX接反4. 模块未开机或供电不足1. 检查波特率设置2. 确认发送了0x0D 0x0A3. 用万用表测电压重启模块ATCMGS后收不到提示1. PDU模式下的长度length计算错误2. 模块未准备好网络未注册3. 上一个命令未完成1.重点检查length是PDU字节数不是HEX字符串长度2. 发送ATCREG?检查注册状态3. 增加命令间延时短信发送失败返回CMS ERROR1. 短信中心号码错误2. 目标号码格式错误3. PDU编码错误特别是SCA和DCS4. 用户卡欠费或未开通短信功能1. 用ATCSCA?查询当前短信中心号2. 检查号码是否包含86PDU格式是否正确3. 使用在线PDU解析工具校验你的PDU串4. 换张手机卡测试收到短信内容是乱码1. 编码方式不匹配对方发UCS2你用GSM7解析2. 长短信未正确重组3. 解码函数有bug1. 检查PDU中的DCS字段0x00是GSM70x08是UCS22. 检查是否有UDH尝试手动重组3. 用已知正确的PDU串测试解码函数模块偶尔无响应或复位1. 电源纹波过大发射时电压跌落2. 天线接触不良或阻抗不匹配3. 环境干扰大1.重中之重加强电源滤波靠近模块加大电容2. 检查天线连接确保天线已安装3. 尝试更换位置远离强干扰源5.3 必备的在线工具PDU编码/解码器在开发阶段不要硬算。多用网上的PDU编码解码工具搜索“SMS PDU decoder”把你的手机号、内容填进去生成标准的PDU串和你代码生成的对比。这是最高效的调试方法。串口助手选择一款能显示十六进制、并能发送十六进制数据的串口助手。发送CtrlZ0x1A时必须用十六进制发送。折腾GSM短信模块尤其是PDU模式确实需要不少耐心去处理那些底层的编码细节和网络的不确定性。但一旦打通你会发现这条通道在很多偏远地区或无网络覆盖的场景下依然是可靠的数据传输后备方案。我的经验是前期把基础函数编解码、构建、解析封装测试好做成一个独立的sms_pdu.c/h模块以后在任何项目里都能直接复用。在抄表系统里我现在已经将数据打包成二进制协议一条短信能装下十几个表计的数据稳定运行了半年多。最后提醒一点多买几张不同运营商的SIM卡做测试某些地区的网络对长短信或频繁短信的支持可能有差异实地测试永远比实验室模拟更重要。