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接模组的RXD
- STM32 的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 的 PB6(SCL)和 PB7(SDA)。如果你用的是软件模拟 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,就代表通信链路是通的。听起来简单,但实际写代码的时候,难点在于怎么可靠地判断"它回完了"。
模组的回复通常长这样:
AT+CSQ +CSQ: 24,99 OK你发出去的是AT+CSQ,回来的是一段文本,最后以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,这样模组不会把你发的指令原样回显,减少干扰 - 查卡状态:发
AT+CPIN?,返回+CPIN: READY说明 SIM 卡正常 - 查网络注册:发
AT+CREG?,返回+CREG: 0,1或0,5说明已注册上网络 - 查信号质量:发
AT+CSQ,第一个值越大越好,一般大于 10 就能用 - 设置短信模式:发
AT+CMGF=0,切到 PDU 模式(发中文必须用 PDU) - 设置短信中心:一般模组会自动从 SIM 卡读取,不用手动设
这一套流程走完,才轮到真正发短信。我建议把这套流程写成一个Modem_Init()函数,开机时跑一遍,任何一步失败就通过 OLED 报错,方便定位问题。
提示:
AT+CREG?返回的第二个参数,1 表示已注册本地网络,5 表示已注册漫游网络,0 表示未注册,2 表示正在搜索。只有 1 或 5 才能发短信。
4. 中文短信的核心:PDU 编码到底怎么算
4.1 为什么中文短信必须用 PDU 模式
短信有两种模式:Text 模式和 PDU 模式。Text 模式发英文很方便,直接AT+CMGS="号码"然后写内容就行。但 Text 模式对中文支持极差,不同模组实现还不一样,经常出现乱码。
PDU 模式就不一样了,它把整条短信(包括号码、内容、编码方式、短信中心等)打包成一串十六进制字符串,通用性极强。中文在 PDU 里用的是UCS2 编码,也就是每个中文字符占 2 个字节,用 Unicode 码点表示。
所以发中文短信的标准姿势是:AT+CMGF=0切 PDU 模式 → 构造 PDU 串 →AT+CMGS=<长度>→ 发送 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。前面加长度0D(13 位号码的十六进制),所以是0D683108103800F0。
第四步,协议标识和编码方式。协议标识固定00,编码方式08表示 UCS2(中文),00表示默认编码(英文)。发中文就用08。
第五步,有效期。一般填00或AA,表示用默认值。
第六步,用户数据。先把中文转成 Unicode 码点。"你"是4F60,"好"是597D,拼起来4F60597D,共 4 个字节。前面加长度04,所以是044F60597D。
把这些拼起来,完整的 PDU 串就是:
0891683108100005F011000D683108103800F0000800044F60597D发送时,AT+CMGS=后面的数字是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 串发送时,最后要跟一个
0x1A(Ctrl+Z)作为结束符,模组看到这个字符才会真正把短信发出去。这个字符在代码里写成"\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 的时候注意:
- 时钟频率别超过 400kHz,SSD1306 一般支持 400kHz
- 从机地址通常是
0x78(8位地址)或0x3C(7位地址),看你的库怎么定义 - 每次写数据前先发控制字节,
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、AT+CPIN?、AT+CREG?、AT+CSQ、AT+CMGF=0
- 每一步的结果都显示在 OLED 上,失败就停在错误状态
- 进入主循环,等待按键
- 按键按下,构造 PDU 串,发 AT+CMGS,发 PDU,发 0x1A
- 等待 OK,更新 OLED 状态
- 回到空闲状态,等待下一次按键
这个流程里,第 3 步的初始化最耗时,可能要十几秒。所以 OLED 上要实时显示进度,让用户知道设备在干什么,而不是黑屏干等。
6.3 超时和重试机制
通信类项目,超时和重试是标配。我的做法是:
- 每条 AT 指令给 2 秒超时
- 网络注册这种慢操作给 30 秒超时
- 发送短信给 10 秒超时
- 失败后重试 2 次,还失败就报错
重试的时候要注意,如果是AT+CMGS失败,得先发一个ESC(0x1B)退出当前的短信编辑状态,再重新开始,否则模组会一直等你把短信内容发完。
// 发送失败后退出编辑状态 void Sms_Abort(void) { uint8_t esc = 0x1B; HAL_UART_Transmit(&huart2, &esc, 1, 100); HAL_Delay(100); }7. 那些文档里不会写的踩坑经验
7.1 模组回显没关,解析全乱套
刚上手的时候,我发AT+CSQ,模组回的是:
AT+CSQ +CSQ: 24,99 OK注意第一行AT+CSQ是回显。如果你没关回显,解析的时候strstr可能会匹配到错误的位置。所以ATE0一定要在初始化阶段就发出去。关了之后,模组就只回+CSQ: 24,99和OK,干净很多。
7.2 PDU 长度算错,模组直接报 ERROR
AT+CMGS=后面的长度参数,指的是PDU 串中"目标号码 + 协议标识 + 编码 + 有效期 + 用户数据"这一段的字节数,不包括短信中心部分。我第一次做的时候把整个 PDU 串长度填进去了,模组一直回ERROR,查了半天才发现是长度算错。
正确的算法是:(strlen(pdu) - 短信中心部分长度) / 2。短信中心部分长度是固定的,就是前面那 16 个字符(8 字节)。
7.3 OLED 花屏,八成是初始化序列不对
OLED 花屏是很常见的问题,原因通常有几个:
- 初始化命令序列不完整,特别是对比度、扫描方向、电荷泵这几条
- I2C 速率太高,数据没跟上
- 电源不稳,3.3V 有较大纹波
我的经验是,先用厂家给的初始化序列,跑通了再考虑优化。别自己瞎改命令,SSD1306 的手册虽然写得清楚,但有些命令的组合是有顺序要求的。
7.4 中文乱码,编码转换是关键
如果你发出去的中文显示成乱码,99% 是编码问题。要确认两件事:
- 你的源码文件是什么编码?Keil 默认可能是 GB2312,VSCode 默认是 UTF-8
- 你转 UCS2 的时候,是按哪种编码转的?
最稳妥的办法是统一用 UTF-8 保存源码,然后写一个 UTF-8 到 UCS2 的转换函数。如果嫌麻烦,就把要发的中文提前转好,硬编码成十六进制数组。
7.5 发送成功但对方收不到
这种情况一般是短信中心号码不对。模组通常会自动从 SIM 卡读取短信中心号码,但有些物联网卡需要手动设置。可以用AT+CSCA?查询当前短信中心,用AT+CSCA="+86xxxxxxxxxxx"设置。
另外,有些物联网卡本身就不支持短信功能,或者只支持特定方向的短信。这个在选卡的时候就要问清楚,别等调试半天才发现是卡的问题。
8. 关于这套方案还能怎么扩展
跑通按键发短信之后,这套框架其实可以延伸出很多玩法。比如把按键换成传感器,温度超限自动发报警短信;或者加一个定时器,每天定时上报设备状态;再或者把 OLED 换成更大的屏,显示更多信息。
通信模组这块,Air780E 支持的不只是短信,还有 TCP、MQTT、HTTP 等。如果你要做数据上云,完全可以在现有基础上加一层 MQTT 逻辑,把短信当成备用通道。我个人的习惯是,关键报警走短信(到达率高),常规数据走网络(成本低),两者互补。
代码结构上,我建议把模组操作、PDU 编码、OLED 显示分成独立的模块,各自有清晰的接口。这样以后换模组、换屏幕,只需要改对应模块,不用动主逻辑。这个习惯在项目越做越大的时候,能帮你省下大量返工时间。
最后说一个我自己的体会:嵌入式项目里,"能跑通"和"跑得稳"之间隔着大量的细节。超时、重试、状态机、电源滤波,这些东西在 demo 阶段看不出价值,但一旦设备要长时间运行,它们就是稳定性的全部。别嫌麻烦,该加的机制一个都别省。