
做嵌入式项目的过程中“发短信”这个需求大家迟早会碰到。网上能查到的大部分是GSM模块发英文短信的Demo可真要发中文一堆人卡在PDU编码和汉字转码上。最近我正好做了个小东西STM32F103C8T6做控制配合合宙Air780E 4G Cat.1模块入网发短信一个实体按键触发发送一块0.96寸I2C OLED实时显示待机、发送中、成功或失败的状态。整套跑通之后短信报警器、远程提醒器、甚至简单的短信遥控开关都能在此基础上快速扩展。这篇文章把整个项目从硬件选型、中文短信PDU编码原理、AT指令交互、OLED中文显示到状态机代码框架全部捋一遍。重点讲清楚三个最容易被卡住的地方第一中文短信为什么必须用PDU模式UCS2编码到底怎么算第二STM32和Air780E之间串口交互的状态机怎么设计才不容易卡死第三OLED显示中文的编码坑Keil工程里经常是GBKU8g2要的是UTF-8。适合手里有STM32开发板、想快速实现“按键发送中文短信”的嵌入式爱好者参考。1. 项目整体设计与硬件选型思路这个项目本质上是一个“单向短信通知终端”本地有一个按键输入触发STM32组装一条中文短信通过串口交给Air780E发送OLED负责把当前状态展示给人看。整个系统没有复杂的操作系统也没有网络协议栈核心就是把串口、GPIO、I2C、AT指令、状态机这五件事做好。1.1 为什么选STM32F103C8T6 Air780E这个组合先说说Air780E为什么比很多传统方案合适。早期做短信项目最常见的是SIM800/900这类GSM模块2G网络在城市里退网趋势明显信号覆盖越来越差而且GSM模块也偏老。Air780E是4G Cat.1模块在国内4G网络下信号好、资费便宜关键是AT指令集成熟发短信这种基础功能非常稳定。STM32F103C8T6不用多说玩嵌入式的谁手里没有几片64KB Flash、20KB RAM跑U8g2 OLED库加串口状态机绰绰有余。1.2 按键和OLED的作用边界设计任务里有一个很关键的点OLED不是装饰它是整个交互反馈的核心。没有OLED你按了按键根本不知道模块是在搜网、正在发送还是已经失败了。所以我专门划分了四个核心显示状态OLED显示状态触发时机用户能读到的信息READY / 信号格数初始化完成轮询CSQ设备待机中信号强度直观可见预览短信内容短按按键确认将发送给谁、内容是什么SENDING...长按按键进入发送流程系统正在和Air780E交互SEND OK / SEND FAILATCMGS返回结果本次发送结果按键只保留一个短按预览、长按3秒发送这样既避免多个按键的硬件复杂度交互也符合直觉。1.3 系统流程的全貌整个系统的运行流程是这样的上电后STM32先初始化串口、I2C、GPIO然后U8g2驱动OLED显示启动画面同时通过串口向Air780E发送AT指令做网络注册检测。注册完成后进入待机状态。用户短按按键OLED显示短信预览长按3秒OLED切到SENDINGSTM32组一条包含目标手机号和短信内容的PDU报文通过ATCMGS指令交给Air780E。模块返回CMGS成功后会再确认一次OK。最后OLED根据结果显示发送成功或失败然后回到待机。这个流程里状态机的稳定是第一优先级。2. 硬件接线与电源设计Air780E不是随便就能带动的硬件看起来很简单Air780E串口接STM32OLED走I2C按键接一个GPIO。但Air780E的电源需求是个大坑搞不好就会造成模块不断重启表现就是串口偶尔有输出、偶尔完全没有。这点必须单独拿出来说。2.1 器件清单和接线表我用的器件如下都是在普通开发板和模块店能买到的标准件器件型号/规格说明MCUSTM32F103C8T6蓝色Pill板或自制最小系统4G模块合宙Air780E必须刷AT固件默认串口波特率115200OLED0.96寸 128x64 SSD1306I2C接口地址0x3C或0x3D按键6x6轻触开关自复位按下接地SIM卡移动/联通/电信 nano SIM短信不需要数据流量套餐能注册网络就行天线合宙配套4G天线必须接否则信号极差电源5V/2A USB供电 降压模块严禁直接从STM32的3.3V给Air780E供电接线关系表STM32引脚外设引脚说明PA2 (USART2_TX)Air780E UART_RXDSTM32发送到模块PA3 (USART2_RX)Air780E UART_TXD模块发送到STM32PB6OLED SCLI2C时钟软件I2C也可用任意两个GPIOPB7OLED SDAI2C数据PB0按键一端另一端接GNDPB0配置为输入上拉GNDAir780E GND、OLED GND共地必须接牢我选择USART2而不是USART1是有原因的。STM32F103C8T6的最小系统板上USART1的PA9、PA10通常已经被板载的USB转串口芯片占用。如果你用USART1调试的时候要反复拔跳线实在不方便。USART2的PA2、PA3留出来专门接Air780E调试串口和模块串口彻底分开省心很多。2.2 电源设计别让小模块拖垮整个系统Air780E的峰值电流在发射时可能接近2A虽然平均电流没那么大但对电源的瞬态响应要求很高。如果你用AMS1117-3.3这类最大输出1A左右的LDO很容易在模块突然发射时报USB供电电压跌落模块重启串口打印就断了。我的做法是外部用5V/2A电源适配器先过一个低压差能力强的降压模块降到4V左右最好选择能输出2A以上的方案例如MP1584这类开关电源模块或者MIC29302这种大电流LDO。4V供电给Air780E同时单独用AMS1117-3.3给STM32和OLED供电两个供电回路用GND共地。另外Air780E的供电线尽量短、尽量粗建议20AWG以上。模块旁边加一个1000uF电解电容和几个100nF陶瓷电容稳住瞬态跌落。我一开始用杜邦线飞线没加电容模块老是不稳定加了电容之后一切正常。2.3 SIM卡与天线实操注意事项SIM卡槽要确认是nano卡还是micro卡吹拉弹唱再仔细都不如插入时听一声“咔哒”。如果模块上电后ATCPIN?一直返回ERROR99%是SIM卡没插到位或者卡座接触不良。天线也要检查我见过有人拿了贴片天线随便往模块边上一放CSQ值只有5到8短信发半天都是失败。天线馈线端子和模块天线焊盘必须焊接牢靠天线位置尽量远离STM32和OLED减少干扰。3. 中文短信PDU编码绕不开的UCS2这是整个项目技术含量最高、也是网上最啰嗦讲不清楚的部分。先说结论要在国内发中文短信必须用PDU模式短信内容用UCS2编码即Unicode的UTF-16BE。Text模式发英文非常方便但中文内容无法正确编码因为GSM 7-bit字符集根本不含汉字。3.1 PDU模式的短信结构长什么样PDU模式下一条短信的内容不是发送原始文本而是一串十六进制字节。以发给13800138000、内容为“你好”为例我给STM32组出来的完整PDU是00 11 00 0D 91 68 31 08 10 83 00 0F 00 08 04 4F 60 59 7D逐段拆开看字节段十六进制含义SMSC长度00短信中心地址长度为0让模块读取SIM卡默认短信中心协议头11SMS-SUBMIT即发送短信消息参考号00可置0由模块填充目标号码长度0D13目标号码含有13个数字号码类型91国际格式号码前补86中国区号目标号码68 31 08 10 83 00 0F861380013800的半字节反转结果协议标识00默认编码方式08DCS8表示UCS2用户数据长度044字节用户数据4F 60 59 7D“你好”的UCS2编码这里很多人第一次看会懵我换成通俗类比。“你好”在计算机内部存储时UTF-16BE编码是4F 60 59 7D这叫用户数据。目标号码为什么变成68 31 08 10 83 00 0F因为PDU格式的号码是半字节反转存储的。比如86 13 80 01 38 00 0每两个数字倒过来变成68 31 08 10 83 00最后一个0补上F凑一整字节所以是0F。3.2 ATCMGS的长度到底怎么算ATCMGS指令格式是ATCMGS长度这里的长度是TP层的字节数也就是PDU整个串去掉SMSC长度字段后剩余部分。刚才那串PDU一共19字节去掉第一个SMSC长度字节00还剩18字节。所以发送指令是ATCMGS18模块收到这条指令后会返回一个“”的提示符此时把整串PDU以可打印的hex ASCII形式发过去最后追加一个0x1ACtrlZ作为结束符。如果追加0x1B是取消发送。这个流程必须严格按照时序来先发ATCMGS18等再发PDU串和0x1A。不能一次性把ATCMGS和PDU连在一起发。3.3 任意汉字的UCS2码怎么获得你要发的内容不可能永远是“你好”。最简单的方法用Python运行一行代码在终端里获取任意字符串的UCS2字节text 温度过高请检查设备 bytes text.encode(utf-16-be) print(bytes.hex().upper())输出就是一串形如6E2962E9...的hex字符串直接填入你的PDU用户数据段。我自己在项目中维护了一个把汉字字符串转UCS2的小脚本生成C数组常量使用。注意这里的字符编码转换必须在PC上完成因为STM32里的字符串如果做成GBK编码直接用UCS2发送会变成一堆乱码。3.4 短信中心号码怎么办PDU里的SMSC长度可以直接置0模块会自动读取SIM卡里保存的短信中心号码。如果你用的不是运营商默认SIM卡或者查询返回异常可以先执行ATCSCA?查看当前短信中心。如果为空或者不是你所在地的号码可以用ATCSCA8613800270500这样的指令手动设置。但多数情况下国内运营商的SIM卡都烧录了短信中心置0即可不需要手动配置这也是发短信比走流量省心的原因之一。4. 代码实现从CubeMX配置到完整状态机这一章是整个项目的“动手”部分。我用STM32CubeMX生成工程骨架然后补U8g2移植、AT交互层和短信发送状态机。整体代码量不大但有几个细节直接决定项目稳定性。4.1 CubeMX中需要激活的外设在STM32CubeMX中我激活的外设很少逐个说明USART2异步模式波特率1152008位数据无校验1停止位。Air780E的默认AT口波特率是115200别选成9600很多调不通的案例都是波特率不匹配。I2C1如果直接用硬件I2C配Fast Mode400kHz。但我实际用的是软件I2CCubeMX只需要把PB6、PB7配置为GPIO输出开漏即可。GPIOPB0作为输入上拉开启。按键按下接地读取为低电平。在CubeMX里把USART2全局中断打开设置优先级要高于普通外设。中断里只做一件事把收到的一个字节丢进接收缓冲区。4.2 U8g2移植与OLED中文显示U8g2库在STM32上的移植比较成熟。我用的配置是128x64的SSD1306I2C软件模式核心代码#include u8g2.h u8g2_t u8g2; uint8_t u8x8_gpio_and_delay(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch (msg) { case U8X8_MSG_GPIO_AND_DELAY_INIT: break; case U8X8_MSG_DELAY_MILLI: HAL_Delay(arg_int); break; case U8X8_MSG_GPIO_I2C_CLOCK: HAL_GPIO_WritePin(SCL_GPIO_Port, SCL_Pin, arg_int ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case U8X8_MSG_GPIO_I2C_DATA: HAL_GPIO_WritePin(SDA_GPIO_Port, SDA_Pin, arg_int ? GPIO_PIN_SET : GPIO_PIN_RESET); break; default: return 0; } return 1; } void oled_init(void) { u8g2_Setup_ssd1306_i2c_128x64_noname_f(u8g2, U8G2_R0, u8x8_byte_sw_i2c, u8x8_gpio_and_delay); u8g2_InitDisplay(u8g2); u8g2_SetPowerSave(u8g2, 0); }显示中文的关键是字体选择必须用支持中文的字体u8g2_SetFont(u8g2, u8g2_font_unifont_t_chinese); u8g2_DrawUTF8(u8g2, 0, 16, 发送中...); u8g2_SendBuffer(u8g2);这里有个坑必须强调U8g2的DrawUTF8函数接收的是UTF-8编码字符串。但Keil MDK默认把源码中的中文字符串按GBK编码存储。如果你直接在Keil里写u8g2_DrawUTF8(u8g2, 0, 16, 发送中);大概率是乱码。解决办法在Keil的Edit - Configuration - Encoding里把文件编码设置为UTF-8并把源文件重新保存为UTF-8格式。如果不想改工程编码就老老实实用UCS2转UTF-8的常量数组自己拼接显示内容。4.3 AT指令交互层串口接收与超时判断AT指令交互最怕串口接收丢失。我的做法是USART2中断接收把收到的每个字节存入at_rx_buf同时维护一个索引。主循环中每50ms检查一次如果索引不为0且连续300ms没有新字节进来就认为一条AT响应结束了开始用strstr匹配关键词。volatile uint8_t at_rx_buf[512]; volatile uint16_t at_rx_len 0; volatile uint32_t last_rx_time 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { static uint8_t rx_byte; if (huart-Instance USART2) { if (at_rx_len sizeof(at_rx_buf) - 1) { at_rx_buf[at_rx_len] rx_byte; } last_rx_time HAL_GetTick(); HAL_UART_Receive_IT(huart2, rx_byte, 1); } }发送AT指令时用HAL_UART_Transmit发送字符串然后调用at_wait_response(OK, 1000)这种轮询函数等待匹配。要注意并不是所有AT指令的响应都是“OK”有的返回ERROR有的先返回“”。所以我的匹配函数支持传入多个关键词谁先出现就返回谁的索引。判断响应完成的逻辑uint8_t at_wait_response(const char *expect, uint32_t timeout_ms) { uint32_t start HAL_GetTick(); while (HAL_GetTick() - start timeout_ms) { if (at_rx_len 0 HAL_GetTick() - last_rx_time 300) { if (strstr((char*)at_rx_buf, expect) ! NULL) { at_rx_len 0; return 1; } at_rx_len 0; return 0; } } at_rx_len 0; return 0; }4.4 PDU组包函数实现根据前面讲的PDU结构组包函数C代码就是按字节依次填入数组uint16_t sms_pdu_encode(uint8_t *pdu, const char *number, const uint8_t *ucs2_data, uint8_t ucs2_len) { uint16_t i 0; uint8_t j; uint8_t num_len strlen(number); pdu[i] 0x00; // SMSC长度 pdu[i] 0x11; // SMS-SUBMIT pdu[i] 0x00; // 消息参考号 pdu[i] num_len; // 目标号码位数 pdu[i] 0x91; // 号码类型国际格式 for (j 0; j num_len; j 2) { uint8_t hi number[j] - 0; uint8_t lo (j 1 num_len) ? (number[j 1] - 0) : 0x0F; pdu[i] (lo 4) | hi; } pdu[i] 0x00; // PID pdu[i] 0x08; // DCSUCS2 pdu[i] ucs2_len; // 用户数据长度 memcpy(pdu[i], ucs2_data, ucs2_len); i ucs2_len; return i; }使用时要先把目标号码带上中国区号86比如13800138000要写成861380013800这样号码长度是13位和前面PDU里的0D对得上。4.5 完整发送短信的代码流程发送函数是核心它的时序是void sms_send(const char *number, const uint8_t *ucs2, uint8_t ucs2_len) { uint8_t pdu[128]; char pdu_hex[256]; char cmd[64]; uint16_t pdu_len sms_pdu_encode(pdu, number, ucs2, ucs2_len); // 把PDU字节转成可打印hex for (uint16_t k 0; k pdu_len; k) { sprintf(pdu_hex k * 2, %02X, pdu[k]); } // 注意ATCMGS的长度 PDU总字节数 - 1SMSC长度字段不计入 sprintf(cmd, ATCMGS%d\r\n, pdu_len - 1); uart_send_string(cmd); if (!at_wait_response(, 2000)) { oled_show_send_fail(NO PROMPT); return; } uart_send_string(pdu_hex); uart_send_byte(0x1A); // CtrlZ结束 if (at_wait_response(CMGS, 5000)) { oled_show_send_success(); } else { oled_show_send_fail(NO CMGS); } }这段代码里最容易出问题的其实是pdu_len - 1这个参数。很多人把整个PDU的19字节直接填进去但ATCMGS要求的是TP层长度去掉SMSC长度字段计算方式前面章节已经说清楚了。如果你发现模块返回ERROR而不是“”第一件事就检查这个长度。4.6 按键消抖与主状态机按键用的是PB0输入上拉按下为低电平。短按和长按的区别采用时间累积判断void key_scan(void) { if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET) { HAL_Delay(10); // 简单消抖 if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET) { key_press_tick 10; } } else { key_press_tick 0; } }主状态机我写成一个switch-case按不同状态执行对应动作状态间的迁移条件非常清晰switch (g_sms_state) { case SMS_IDLE: if (key_press_tick 50) { g_sms_state SMS_SENDING; oled_show_sending(); sms_send(861380013800, hello_ucs2, sizeof(hello_ucs2)); g_sms_state SMS_WAIT_RESULT; } else if (key_short_press_detected) { oled_show_preview(); } break; case SMS_WAIT_RESULT: // sms_send内部已经同步等待结果并更新OLED g_sms_state SMS_IDLE; break; }我实际使用的状态机比这个稍复杂增加了超时重试和SMS_FAIL后按任意键返回IDLE的逻辑。基本原则是不要在一个状态里无限阻塞空闲时让出CPU给OLED刷新和AT轮询。5. 调试实录与避坑清单这个项目调试过程踩了不少坑有些坑属于“不自己踩一遍根本不可能知道”这里一并记录下来按症状排查就行。5.1 OLED黑屏或花屏最常见原因是I2C地址不对。SSD1306的I2C地址由模块上的电阻决定常见是0x3C也有0x3D。如果你用U8g2一直初始化失败可以写一个I2C扫描小程序把0x00到0x7F所有地址扫一遍看到哪个地址有ACK就填哪个。另外软件I2C对GPIO配置有要求必须配置为开漏输出并且要外接上拉电阻。很多模块已经板载上拉如果你自己做板子SCL和SDA上要各加一个4.7k电阻。5.2 Air780E串口完全无响应第一步检查供电用万用表测Air780E供电引脚电压4V左右模块启动瞬间电压不能跌到3.3V以下。第二步检查波特率绝大多数Air780E AT固件默认115200。第三步检查串口连线STM32的TX接模块的RXDSTM32的RX接模块的TXD别接反。第四步是检查模块固件版本如果你手里的模块是LuatOS脚本固件串口默认可能跑的是脚本接口而不是AT指令需要先刷回AT固件这一步在合宙官网上可以找到工具和说明。5.3 短信发送失败SIM卡和信号质量先查ATCPIN?如果返回CME ERROR: SIM not inserted说明SIM卡没插好。接着查ATCSQ返回如CSQ: 23,0第一位23就是信号强度范围0到31低于10基本很难发出去。这时候调整天线位置、远离金属遮挡信号会好转。还有一点很多人忽略发短信不需要数据流量所以不需要配置APN也不需要等ATCGATT1。如果发现模块一直在注册数据网络那是你多余的指令在捣乱。5.4 中文短信发出去变成乱码这是编码问题几乎是必踩坑。PDU模式下短信正文只能是UCS2编码如果你直接把GBK编码的汉字字节填进去接收方看到的必然是乱码。解决办法就是严格按照3.3节说明用Python先把文本转成UTF-16BE的hex再填入PDU的User Data段。另外注意用户数据长度字段TP-UDL必须是字节数不是字符数一对UCS2汉字是两个字节这个长度错了模块也会拒绝发送。5.5 按键误触发或长按不灵敏按键直接接GPIO到GNDSTM32内部上拉默认是弱上拉大概30k到50k大多数轻触按键没问题但如果你的按键线比较长建议外部加10k上拉到3.3V。消抖延时10ms够用如果环境干扰大可以加20ms。长按检测我用的是轮询累加方式10ms中断里累加超过3秒计数到300就触发。中断里不要做太多事情累加一个全局变量就足够了。5.6 遇到的典型案例有次OLED显示正常短信发送却一直失败。我逐条查AT指令发现ATCMGS18返回ERROR。排查后是STM32组装PDU时号码写成了13800138000而不是带86区号的861380013800导致号码长度变成11和0D对不上。这个案例说明了PDU里每个字节都不能想当然特别是号码位数的计算要严格按半字节来。6. 实测效果与个人体会整个系统搭完实测上电后大约4秒钟Air780E完成网络注册OLED显示READY。短按按键显示短信预览长按3秒后OLED切到“SENDING...”随后大约2到5秒内收到模块返回CMGSOLED显示“SEND OK”。整个过程一气呵成没有任何人工干预。功耗方面待机时整个系统电流大约80mA由OLED常亮和STM32待机电流贡献。Air780E在发送短信的瞬间电流会冲到800mA以上这是我前面强调电源设计的原因。如果你做的是电池供电版本建议给OLED加休眠功能Air780E支持ATCFUN0进入飞行模式待机可以大幅降低待机电流。这个项目的扩展空间很大我个人建议按这几个方向继续做加一个DS18B20温度传感器变成温度超限自动短信报警器OLED显示实时温度。把短信内容扩展成动态数据例如设备编号加报警类型用sprintf拼PDU内容变量。增加多个按键对应不同收件人或不同短信模板。Air780E本身也支持TCP/UDP通信后续可以把短信发送和MQTT上报统一在一个框架里STM32只负责采集和状态显示。最后分享一个小技巧调试这类AT指令项目强烈建议在STM32的串口输出调试日志里打印出你实际发给Air780E的完整PDU hex串然后复制到PC端的串口调试助手里手动发送对比结果。我遇到过好几次代码逻辑看着没问题实际是内存里某个字节被覆盖导致PDU长度不对一打印出来立刻就能定位。嵌入式调试永远要让中间变量可见。