1. 为什么要在嵌入式设备上做按键发中文短信这件事先把场景说清楚。你手上有一块 STM32 主控板一块 0.96 寸 OLED 小屏一个 Air780E 蜂窝通信模组外加一两个独立按键。目标很朴素按下按键设备自动把一条中文短信发出去同时 OLED 上把当前状态正在发送、发送成功、发送失败显示出来。这个需求听起来简单但真正动手做过的朋友都知道坑主要集中在三个地方第一中文短信不是你想发就能发它涉及PDU 编码第二Air780E 是 4G Cat.1 模组它吃的是AT 指令指令的时序、回显、超时处理一个都不能马虎第三OLED 显示和串口通信如果都放在主循环里裸跑很容易出现按键按下去没反应或者屏幕卡住不动的情况。我之所以挑这个题目来写是因为它几乎把嵌入式开发里最典型的几类问题全凑齐了外设驱动、串口协议、字符编码、状态机设计、人机交互。你把这套东西跑通一遍以后再遇到类似的MCU 通信模组 显示屏组合基本就是换个模组型号、改改 AT 指令的事。这篇文章适合谁看如果你已经会用 STM32 点灯、会配置串口、能看懂基本的 HAL 库代码那这篇内容你可以直接抄作业。如果你连 OLED 都没点亮过建议先把 I2C 驱动 SSD1306 那部分搞定再回来不然会有点吃力。整篇我会按硬件怎么接 → 模组怎么通 → 中文怎么编 → 状态怎么显示 → 坑怎么填的顺序讲每一步都告诉你为什么这么做而不只是给你一堆代码。提示本文所有 AT 指令和 PDU 编码逻辑都是通用思路具体指令集请以你手上模组的官方手册为准不同固件版本可能存在细微差异。2. 硬件连接与供电最容易被低估的环节2.1 三块板子怎么连线序别接反先把物理层理清楚。整套系统里有三个主要角色STM32 主控、Air780E 模组、OLED 屏。它们之间的连接关系其实不复杂但有几个点新手特别容易翻车。STM32 和 Air780E 之间走的是串口UART。一般做法是STM32 的USART2_TX接模组的RXDSTM32 的USART2_RX接模组的TXD两边GND 必须共地模组的PWRKEY引脚需要由 STM32 控制或者手动拉低开机这里第一个坑就来了TX 和 RX 一定要交叉接。我见过太多人把 TX 接 TX、RX 接 RX然后对着屏幕纳闷为什么一条指令都发不出去。记住一句话发送方的输出要进接收方的输入所以永远是你的 TX 接我的 RX。OLED 这边0.96 寸的 SSD1306 一般是四针 I2C 接口VCC、GND、SCL、SDA。接到 STM32 的任意一组 I2C 上即可比如 I2C1 的 PB6SCL和 PB7SDA。如果你用的是软件模拟 I2C那随便两个 GPIO 都行但要注意加上拉电阻通常模块自带了没带的话自己补两个 4.7k 到 10k 的电阻到 3.3V。按键就简单了一个引脚接按键一端按键另一端接 GND引脚配置成上拉输入按下时读到低电平。记得加个 0.1uF 的电容做硬件消抖软件里再配合 20ms 左右的延时消抖双保险。2.2 供电这件事真的会决定项目成败Air780E 是 Cat.1 模组它在发射瞬间的电流峰值可以冲到2A 甚至更高。这不是吓唬你是实打实的规格。很多人用 STM32 板子上的 3.3V LDO 直接给模组供电结果就是模组一注册网络就重启或者干脆连不上网。正确的做法是给模组单独供电电源要能满足参数建议值说明电压3.3V ~ 4.2V模组典型工作电压注意不是所有模组都吃 3.3V持续电流≥ 1A保证日常通信稳定峰值电流≥ 2A应对发射瞬间的冲击纹波尽量小建议加 100uF 0.1uF 电容组合滤波我个人的习惯是在模组电源脚旁边并一个大电容470uF 甚至 1000uF 的电解电容加一个小陶瓷电容专门吸收发射瞬间的电流波动。这个电容就像一个小水库模组突然要大口喝水的时候先从这个水库里取不至于把整条供电线路拉垮。另外STM32 和模组的逻辑电平要匹配。Air780E 的 IO 一般是 1.8V 或 3.3V 逻辑具体看你的模组版本。如果是 1.8V 逻辑而 STM32 是 3.3V中间就得加电平转换否则长期工作会损伤模组 IO。这一点在选型阶段就要确认清楚别等焊上板子才发现。注意模组的 PWRKEY 开机时序有讲究通常是拉低一段时间比如 500ms 到 1s再释放具体时长查手册。开机后模组会有一段时间的初始化别急着发指令等它把启动信息吐完再说。3. 让 STM32 和 Air780E 说上话AT 指令的收发逻辑3.1 AT 指令的本质一问一答的对话AT 指令说白了就是你和模组之间的对话协议。你发一句它回一句。比如你发AT它回OK就代表通信链路是通的。听起来简单但实际写代码的时候难点在于怎么可靠地判断它回完了。模组的回复通常长这样ATCSQ CSQ: 24,99 OK你发出去的是ATCSQ回来的是一段文本最后以OK或者ERROR结尾。所以你的接收逻辑不能只读一次就完事得循环读、拼接到缓冲区、直到检测到结束标志。我一般会设计一个这样的接收函数伪代码思路// 发送AT指令并等待预期响应 // cmd: 要发送的指令 // expect: 期望看到的响应关键字比如 OK // timeout: 超时时间毫秒 uint8_t AT_SendCmd(char *cmd, char *expect, uint32_t timeout) { HAL_UART_Transmit(huart2, (uint8_t *)cmd, strlen(cmd), 1000); HAL_UART_Transmit(huart2, (uint8_t *)\r\n, 2, 100); // 补回车换行 uint32_t start HAL_GetTick(); memset(rx_buffer, 0, sizeof(rx_buffer)); rx_index 0; while ((HAL_GetTick() - start) timeout) { // 逐字节接收存入 rx_buffer // 每收一个字节就检查缓冲区里是否出现了 expect if (strstr((char *)rx_buffer, expect) ! NULL) { return 1; // 成功 } if (strstr((char *)rx_buffer, ERROR) ! NULL) { return 0; // 失败 } } return 0; // 超时 }这段逻辑的核心思想是发送 → 等待 → 匹配关键字 → 返回结果。超时机制非常重要因为模组偶尔会因为网络问题不回复如果你不加超时程序就会永远卡死在那里。3.2 串口接收为什么建议用中断 空闲检测上面那段伪代码用的是轮询方式简单但有个问题如果主循环里还有别的任务比如刷 OLED轮询会占用大量 CPU 时间。更优雅的做法是用串口空闲中断IDLE配合 DMA。原理是这样的DMA 负责把串口收到的数据自动搬进缓冲区你完全不用管当一帧数据接收完毕串口总线会进入空闲状态这时候触发 IDLE 中断你在中断里打个标志位告诉主循环有一包数据到了去处理吧。这样做的好处是CPU 占用极低主循环可以安心刷屏幕不会丢字节DMA 搬运比手动读寄存器可靠得多天然按帧处理一包数据一次处理完配置步骤大致是开启串口 DMA 接收 → 使能 IDLE 中断 → 在中断服务函数里清除标志、记录接收长度、置位完成标志。主循环检测到标志后把缓冲区内容拿去解析。我实测下来这套方案在 115200 波特率下非常稳即使模组连续吐几百字节的启动信息也不会丢。3.3 模组开机到能发短信中间要过几道关很多人以为模组上电就能发短信其实不是。从冷启动到能发短信中间要经历好几个阶段每一步都得确认通过开机拉 PWRKEY等模组启动通常会看到一堆启动日志AT 握手发AT确认返回OK关闭回显发ATE0这样模组不会把你发的指令原样回显减少干扰查卡状态发ATCPIN?返回CPIN: READY说明 SIM 卡正常查网络注册发ATCREG?返回CREG: 0,1或0,5说明已注册上网络查信号质量发ATCSQ第一个值越大越好一般大于 10 就能用设置短信模式发ATCMGF0切到 PDU 模式发中文必须用 PDU设置短信中心一般模组会自动从 SIM 卡读取不用手动设这一套流程走完才轮到真正发短信。我建议把这套流程写成一个Modem_Init()函数开机时跑一遍任何一步失败就通过 OLED 报错方便定位问题。提示ATCREG?返回的第二个参数1 表示已注册本地网络5 表示已注册漫游网络0 表示未注册2 表示正在搜索。只有 1 或 5 才能发短信。4. 中文短信的核心PDU 编码到底怎么算4.1 为什么中文短信必须用 PDU 模式短信有两种模式Text 模式和 PDU 模式。Text 模式发英文很方便直接ATCMGS号码然后写内容就行。但 Text 模式对中文支持极差不同模组实现还不一样经常出现乱码。PDU 模式就不一样了它把整条短信包括号码、内容、编码方式、短信中心等打包成一串十六进制字符串通用性极强。中文在 PDU 里用的是UCS2 编码也就是每个中文字符占 2 个字节用 Unicode 码点表示。所以发中文短信的标准姿势是ATCMGF0切 PDU 模式 → 构造 PDU 串 →ATCMGS长度→ 发送 PDU 串 → 等OK。4.2 PDU 串的结构拆解一条完整的 PDU 串由这几部分组成以发送为例[短信中心地址] [PDU类型] [目标号码] [协议标识] [编码方式] [有效期] [用户数据长度] [用户数据]我拿一个实际例子来讲假设短信中心号码是8613800100500目标号码是8613800138000内容是你好。第一步处理短信中心地址。去掉号得到8613800100500共 13 位是奇数。奇数要在末尾补F变成8613800100500F然后两两交换位置683108100005F0。最后前面加上短信中心号码的长度字节数含类型位0891683108100005F0。这里的08是长度91是国际号码类型。第二步PDU 类型。发送短信固定用11表示发送、相对有效期、无更多消息。第三步目标号码。同样去、补F、两两交换。8613800138000是 13 位奇数补F得8613800138000F交换得683108103800F0。前面加长度0D13 位号码的十六进制所以是0D683108103800F0。第四步协议标识和编码方式。协议标识固定00编码方式08表示 UCS2中文00表示默认编码英文。发中文就用08。第五步有效期。一般填00或AA表示用默认值。第六步用户数据。先把中文转成 Unicode 码点。你是4F60好是597D拼起来4F60597D共 4 个字节。前面加长度04所以是044F60597D。把这些拼起来完整的 PDU 串就是0891683108100005F011000D683108103800F0000800044F60597D发送时ATCMGS后面的数字是PDU 串去掉短信中心部分之后的长度以字节为单位不含最后的1A。这个长度算错模组就会报错。4.3 用代码自动生成 PDU 串手算 PDU 只适合理解原理实际项目里必须用代码生成。核心就是两个函数一个处理号码一个处理内容。// 号码编码去、补F、两两交换 void EncodePhoneNumber(char *src, char *dst) { char temp[32] {0}; int len 0; char *p src; if (*p ) p; // 跳过加号 while (*p) { temp[len] *p; } if (len % 2 ! 0) { temp[len] F; // 奇数补F } // 两两交换 for (int i 0; i len; i 2) { dst[i] temp[i 1]; dst[i 1] temp[i]; } dst[len] \0; }内容编码稍微麻烦一点因为要把 UTF-8 的中文字符串转成 UCS2。如果你的工程里已经用了 UTF-8 编码保存源码那需要先转成 Unicode 码点。一个简单的办法是预先做一个 GB2312 到 Unicode 的映射表或者直接用现成的转换库。// 中文内容转UCS2十六进制字符串 void EncodeContent(char *src, char *dst) { // 假设 src 是 GB2312 编码 // 逐字查表得到 Unicode 码点再格式化成4位十六进制 // 具体实现依赖你的编码转换方案 }我个人的经验是在 STM32 上做完整的 GB2312 转 Unicode 表会占用不少 Flash如果只是发几条固定短信完全可以把 PDU 串提前算好直接硬编码在程序里。比如设备报警、发送成功这种固定内容提前算好 PDU按键触发时直接发省时省力还不出错。注意PDU 串发送时最后要跟一个0x1ACtrlZ作为结束符模组看到这个字符才会真正把短信发出去。这个字符在代码里写成\x1A。5. OLED 状态显示让设备会说话5.1 显示内容怎么设计才实用OLED 屏幕小0.96 寸只有 128x64 像素能显示的信息有限。所以显示内容要精炼我一般会分几行显示行内容示例第1行系统标题SMS Sender第2行网络状态Net: OK第3行信号强度CSQ: 24第4行当前状态Sending...第5行结果反馈Send OK状态机的设计很关键。整个发送流程可以抽象成几个状态空闲 → 发送中 → 成功 / 失败。每次状态变化就刷新一次 OLED。这样用户一眼就能看出设备在干什么。5.2 用 HAL 库驱动 SSD1306 的关键点SSD1306 的驱动网上代码很多但质量参差不齐。我建议用硬件 I2C比软件模拟稳定得多。配置 I2C 的时候注意时钟频率别超过 400kHzSSD1306 一般支持 400kHz从机地址通常是0x788位地址或0x3C7位地址看你的库怎么定义每次写数据前先发控制字节0x00表示后面是命令0x40表示后面是数据刷屏的时候我习惯先在内存里维护一个显存数组uint8_t oled_buffer[8][128]所有绘制操作都改这个数组最后统一调用一次刷新函数把整个数组推给屏幕。这样做的好处是避免频繁 I2C 通信减少闪烁。// 刷新整个屏幕 void OLED_Refresh(void) { for (int page 0; page 8; page) { OLED_WriteCmd(0xB0 page); // 设置页地址 OLED_WriteCmd(0x00); // 列低地址 OLED_WriteCmd(0x10); // 列高地址 OLED_WriteData(oled_buffer[page][0], 128); } }5.3 状态刷新和串口通信怎么不打架这是很多人会踩的坑串口那边在等模组回复可能要等好几秒OLED 却卡在那里不动用户以为死机了。解决办法是把等待这件事拆开。不要用while死等而是用非阻塞状态机。主循环每次跑一遍检查一下当前处于哪个状态该发指令就发指令该刷屏幕就刷屏幕该检查超时就检查超时。typedef enum { STATE_IDLE, STATE_SENDING, STATE_SUCCESS, STATE_FAIL } SmsState; SmsState current_state STATE_IDLE; void Main_Loop(void) { switch (current_state) { case STATE_IDLE: OLED_ShowString(3, Ready); if (Key_Pressed()) { current_state STATE_SENDING; } break; case STATE_SENDING: OLED_ShowString(3, Sending...); if (Send_Sms_NonBlocking() 1) { current_state STATE_SUCCESS; } else if (Send_Sms_NonBlocking() -1) { current_state STATE_FAIL; } break; case STATE_SUCCESS: OLED_ShowString(3, Send OK); HAL_Delay(2000); current_state STATE_IDLE; break; case STATE_FAIL: OLED_ShowString(3, Send Fail); HAL_Delay(2000); current_state STATE_IDLE; break; } }这样设计之后无论发送过程多慢屏幕始终是活的用户能看到正在发送的提示体验好很多。6. 按键处理与整体流程串联6.1 按键消抖软件和硬件要配合按键这块硬件上我前面说了加电容软件上还要做消抖。最简单的做法是检测到按下后延时 20ms 再检测一次还是按下才认为是真的按下。uint8_t Key_Pressed(void) { if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET) { HAL_Delay(20); // 消抖 if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET) { // 等待松手防止连发 while (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET); return 1; } } return 0; }注意那个等待松手的循环不加的话按住不放会连续触发发送短信一条接一条发出去话费哗哗地掉。6.2 完整流程串一遍把前面所有环节串起来整个程序的主流程是这样的上电初始化 HAL、时钟、GPIO、I2C、UART初始化 OLED显示开机画面初始化模组拉 PWRKEY 开机等待启动依次发 AT、ATE0、ATCPIN?、ATCREG?、ATCSQ、ATCMGF0每一步的结果都显示在 OLED 上失败就停在错误状态进入主循环等待按键按键按下构造 PDU 串发 ATCMGS发 PDU发 0x1A等待 OK更新 OLED 状态回到空闲状态等待下一次按键这个流程里第 3 步的初始化最耗时可能要十几秒。所以 OLED 上要实时显示进度让用户知道设备在干什么而不是黑屏干等。6.3 超时和重试机制通信类项目超时和重试是标配。我的做法是每条 AT 指令给 2 秒超时网络注册这种慢操作给 30 秒超时发送短信给 10 秒超时失败后重试 2 次还失败就报错重试的时候要注意如果是ATCMGS失败得先发一个ESC0x1B退出当前的短信编辑状态再重新开始否则模组会一直等你把短信内容发完。// 发送失败后退出编辑状态 void Sms_Abort(void) { uint8_t esc 0x1B; HAL_UART_Transmit(huart2, esc, 1, 100); HAL_Delay(100); }7. 那些文档里不会写的踩坑经验7.1 模组回显没关解析全乱套刚上手的时候我发ATCSQ模组回的是ATCSQ CSQ: 24,99 OK注意第一行ATCSQ是回显。如果你没关回显解析的时候strstr可能会匹配到错误的位置。所以ATE0一定要在初始化阶段就发出去。关了之后模组就只回CSQ: 24,99和OK干净很多。7.2 PDU 长度算错模组直接报 ERRORATCMGS后面的长度参数指的是PDU 串中目标号码 协议标识 编码 有效期 用户数据这一段的字节数不包括短信中心部分。我第一次做的时候把整个 PDU 串长度填进去了模组一直回ERROR查了半天才发现是长度算错。正确的算法是(strlen(pdu) - 短信中心部分长度) / 2。短信中心部分长度是固定的就是前面那 16 个字符8 字节。7.3 OLED 花屏八成是初始化序列不对OLED 花屏是很常见的问题原因通常有几个初始化命令序列不完整特别是对比度、扫描方向、电荷泵这几条I2C 速率太高数据没跟上电源不稳3.3V 有较大纹波我的经验是先用厂家给的初始化序列跑通了再考虑优化。别自己瞎改命令SSD1306 的手册虽然写得清楚但有些命令的组合是有顺序要求的。7.4 中文乱码编码转换是关键如果你发出去的中文显示成乱码99% 是编码问题。要确认两件事你的源码文件是什么编码Keil 默认可能是 GB2312VSCode 默认是 UTF-8你转 UCS2 的时候是按哪种编码转的最稳妥的办法是统一用 UTF-8 保存源码然后写一个 UTF-8 到 UCS2 的转换函数。如果嫌麻烦就把要发的中文提前转好硬编码成十六进制数组。7.5 发送成功但对方收不到这种情况一般是短信中心号码不对。模组通常会自动从 SIM 卡读取短信中心号码但有些物联网卡需要手动设置。可以用ATCSCA?查询当前短信中心用ATCSCA86xxxxxxxxxxx设置。另外有些物联网卡本身就不支持短信功能或者只支持特定方向的短信。这个在选卡的时候就要问清楚别等调试半天才发现是卡的问题。8. 关于这套方案还能怎么扩展跑通按键发短信之后这套框架其实可以延伸出很多玩法。比如把按键换成传感器温度超限自动发报警短信或者加一个定时器每天定时上报设备状态再或者把 OLED 换成更大的屏显示更多信息。通信模组这块Air780E 支持的不只是短信还有 TCP、MQTT、HTTP 等。如果你要做数据上云完全可以在现有基础上加一层 MQTT 逻辑把短信当成备用通道。我个人的习惯是关键报警走短信到达率高常规数据走网络成本低两者互补。代码结构上我建议把模组操作、PDU 编码、OLED 显示分成独立的模块各自有清晰的接口。这样以后换模组、换屏幕只需要改对应模块不用动主逻辑。这个习惯在项目越做越大的时候能帮你省下大量返工时间。最后说一个我自己的体会嵌入式项目里能跑通和跑得稳之间隔着大量的细节。超时、重试、状态机、电源滤波这些东西在 demo 阶段看不出价值但一旦设备要长时间运行它们就是稳定性的全部。别嫌麻烦该加的机制一个都别省。