前阵子帮朋友做了一套设备远程告警的小东西,硬件就两个主角:STM32F103C8T6 和合宙 Air780E。按键一按,一条中文短信就从 4G 网络发出去,旁边的 OLED 实时显示“正在发送”“发送成功”这些状态。这个组合在物联网个人项目里非常典型,一套下来把单片机的串口通信、外部中断、I2C 显示、4G 模组的 AT 指令和 PDU 短信编码全串起来了。这篇文章就把我实际跑通这套方案的细节写出来,适合正在用 STM32 做通信类毕设、或者想给传统设备加短信上报功能的朋友。
项目本身不复杂,但里面坑不少:OLED 不亮、中文乱码、PDU 长度算错、模组注册不上网络,任何一个卡住都让人抓狂。我尽量把从硬件接线到代码逻辑都讲透,尤其是 PDU 编码这部分,网上讲得清楚的不多,而这恰恰是“中文短信”能否发出去的关键。
1. 项目整体设计与选型思路
1.1 需求拆解:按键、显示、发送三件事
先把这个项目要实现的功能拆开,其实就是三件事:
- 输入:按键触发一次发送动作,要求按一下只触发一次,不能抖动导致连发。
- 显示:OLED 要把当前系统状态展示出来,包括启动、网络注册、发送中、发送成功、发送失败。
- 通信:STM32 通过串口控制 Air780E,把一条中文短信通过 4G 网络发到指定手机号。
这三件事看起来彼此独立,实际在代码里是互相耦合的。按键按下后,主循环要切换状态机,OLED 要根据状态刷新显示,同时串口要按顺序发出 AT 指令,还要解析 Air780E 返回的响应。所以项目把“状态机”作为主线来组织代码,所有模块都围绕状态切换服务。
1.2 为什么是 STM32F103 + Air780E 这个组合
选型这事,我其实是走了弯路的。最开始想过用 ESP32,它自带 WiFi,但短信要走 4G 网络,WiFi 帮不上忙。后来想直接用 Air780E 的 LuatOS 脚本自己跑业务,不必外挂 MCU,但那样就少了“单片机控制模组”这个练习价值,而且后续想接传感器、做本地逻辑时,脚本方案不如 MCU 方案灵活。
最终定下来 STM32F103C8T6 + Air780E,理由很实在:
- STM32F103C8T6 是“蓝 pill”开发板,几十块钱,HAL 库资料铺天盖地,问题好排查。虽然是 64KB Flash,但跑这个项目绰绰有余。
- Air780E 是合宙的 4G Cat.1 模组,支持标准 AT 指令,也支持 LuatOS。它比 NB-IoT 速率高、时延低,比 5G 便宜得多,功耗和价格刚好是物联网项目最常用的档位。最关键的是它支持 PDU 模式发中文短信,这是传统 2G 模组也能干、NB-IoT 反而不见得方便的事。
- OLED 选 SSD1306 驱动的 0.96 寸 128x64,I2C 接口,只占两根线,代码驱动方案成熟。
这套组合还有一个好处:模块化程度高。以后想加个温湿度传感器,只需要在 STM32 上多挂一个 I2C 设备,短信内容里多拼一段数据就行,不用改动通信链路。
1.3 单条数据流:从按键到手机屏幕
系统的数据流大概是这样的:
按键事件发生后,STM32 把 OLED 显示切到“正在发送”,然后组装一条 PDU 格式的短信内容,通过串口发送 AT 指令给 Air780E,Air780E 入网后把短信数据交给基站,基站再路由到目标手机。Air780E 会返回一条+CMGS: 序号的响应,STM32 解析到这个响应后,把 OLED 切到“发送成功”。
如果哪一步超时或者返回ERROR,OLED 就切到“发送失败”。整个过程中,STM32 扮演的是控制器角色,Air780E 扮演的是无线通信管道角色,OLED 则是给用户看的“仪表盘”。理解这条链路,后面排错就有方向了。
2. 硬件连接与关键电路设计
2.1 器件清单
这个项目用到的硬件不算多,我列个实际清单:
| 器件 | 型号/规格 | 数量 | 备注 |
|---|---|---|---|
| 主控板 | STM32F103C8T6 最小系统板 | 1 | 蓝色 pill 板 |
| 4G模组 | 合宙 Air780E 核心板 | 1 | 自带天线座、SIM卡座 |
| OLED | 0.96寸 SSD1306 I2C | 1 | 4引脚版本 |
| 按键 | 轻触开关 6x6mm | 1 | 自锁或非自锁均可 |
| 电源 | 5V/2A USB电源或电池 | 1 | 模组峰值电流大 |
| 下载器 | ST-Link v2 | 1 | SWD 方式下载 |
| 连接线 | 杜邦线若干 | 尽量短,电源线要够粗 |
注意 Air780E 核心板是自带 SIM 卡座和天线座的,这个非常重要。我第一次用裸模组设计外围电路,SIM 卡座松动导致信号时好时坏,排查了很久。直接买核心板能避开大量硬件坑。
2.2 接线表:一个 GPIO 都不能错
接线前先确认引脚功能,别凭印象乱接。我用的配置如下:
| STM32引脚 | 外设功能 | 接对方引脚 | 说明 |
|---|---|---|---|
| PA9 | USART1_TX | Air780E RXD | 发送 AT 指令 |
| PA10 | USART1_RX | Air780E TXD | 接收模组响应 |
| GND | 电源地 | Air780E GND | 必须共地 |
| PB6 | I2C1_SCL | OLED SCL | 时钟线 |
| PB7 | I2C1_SDA | OLED SDA | 数据线 |
| PA0 | 按键输入 | 按键一端 | 按键另一端接 GND |
| 3.3V | 电源 | OLED VCC | OLED 供电 |
| 5V | 电源输入 | 核心板 5V | 给 Air780E 供电 |
这里有个许多人栽跟头的点:STM32 和 Air780E 的串口电平。STM32F103 的串口电平是 3.3V,Air780E 的 UART 接口通常兼容 3.3V,因此可以直接连。但如果你用的是别的模组或者自制底板,务必查手册确认电平,必要时加电平转换芯片,比如 TXS0108,否则长期使用可能烧坏模组 IO。
还有一个容易被忽略的点:Air780E 的供电不要从 STM32 最小系统板的 3.3V 引脚取。4G 模组在发射瞬间电流可能冲到 1A 以上,板载 LDO 扛不住会造成电压跌落,模组就会反复重启。我实测下来,用带 2A 输出的 5V 电源单独给核心板供电,再分开给 STM32 和 OLED 供电,是最稳的。
2.3 按键电路设计:不是接个 GPIO 那么简单
按键看着简单,实际是第一个容易翻车的地方。机械按键在按下和释放瞬间会产生约 5-20ms 的高频抖动,如果不处理,一次按下可能被识别成多次。我的做法是硬件和软件双重处理:
硬件上给出两种方案:
- 方案一:按键一端接 PA0,另一端接 GND,在 PA0 到 GND 之间并联一个 0.1uF 的陶瓷电容,利用电容充放电把抖动毛刺滤掉,同时把 STM32 的内部上拉打开。
- 方案二:在按键供电端串一个 10k 电阻,构成 RC 低通滤波,再接施密特触发缓冲器。这个对新手不友好,我建议用方案一就足够了。
软件上我在按键检测到低电平后延时 20ms 再确认一次,第二次确认仍为低电平才认为按键有效,随后等待按键释放,避免长按导致的重复触发。20ms 这个值是经验值,小于机械抖动的上限 10-20ms 就没问题,大于会感觉按键响应迟钝。
2.4 OLED 和模组的注意事项
OLED 这里有两种主流型号,4 脚 I2C 版本和 7 脚 SPI 版本。我强烈建议用 I2C 四脚版,因为只占两个 IO,代码里用 HAL 库的硬件 I2C 或者软件模拟 I2C 都方便。SSD1306 的 I2C 地址默认是 0x3C,个别模块是 0x3D,要在代码里区分。接线时注意 OLED 的 VCC 接 3.3V,不要接到 5V 上,否则长期运行可能损坏屏幕。
Air780E 核心板一般需要按一下 PWRKEY 按键开机,或者上电自动开机。如果你的模组版本需要 PWRKEY 控制,而你又想全自动启动,可以用一个 MOS 管或者直接用一个 GPIO 拉低 PWRKEY 600ms 实现开机。核心板通常已经焊了按键,手动按一下即可,测试阶段不折腾。
3. STM32 端代码核心实现
3.1 OLED 驱动与中文显示方案
OLED 显示这部分,我用的方案是 HAL 库+I2C 驱动 SSD1306。初始化序列网上很容易找到,核心是发送一串配置命令,把屏幕从休眠状态唤醒、打开电荷泵、设置显示时钟分频、设置列地址扫描方向等。初始化完成后,向 GDDRAM 写入像素数据,屏幕才会亮。
这里说一个 OLED 驱动最关键的认知:SSD1306 内部有 128x64 = 8192 个像素点,对应 1KB 显存。I2C 写数据时,按列地址递增逐个写入,每写一列需要 8 个像素。实际操作中我用一个uint8_t buffer[128 * 64 / 8]作为本地显存,先修改显存里的数据,再整屏刷新。这个做法避免了一个个像素操作时的闪烁。
中文显示是最有意思的部分。0.96 寸 OLED 默认只能显示 ASCII 字符,因为 ASCII 字符可以用 8x16 点阵表示,写入 SSD1306 很方便。但中文字符至少需要 16x16 点阵,一个字占 32 字节。
STM32F103C8T6 的 Flash 只有 64KB,装不下完整中文字库。我的做法是“用多少存多少”:把项目里实际会用到的中文字手动画进字模,用取模软件 PCtoLCD2002 转成数组,放进一个只读表里。
以“正在发送”为例,我先在取模软件里输入“正在发送”四个字,设置格式为“阴码、列行式、逆向输出、每行 2 字节”,生成 4 个 32 字节的数组,按顺序放进一个const uint8_t font_16x16[][32]表里。显示时,用字符在表里的序号索引数组,把像素写入 OLED 显存对应位置。
这里有个编码坑必须提:Keil 工程里的源文件如果保存成 UTF-8 编码,中文字符串常量在编译后是 UTF-8 字节序列,不能直接用来索引 GB2312 字库。我写代码时根本不去用字符串匹配中文字符,而是直接用枚举或者索引号定位字模,绕开了编码兼容问题。比如定义一个枚举STATUS_CN_SENDING = 0, STATUS_CN_SENT_OK = 1,OLED 显示函数接收枚举值,再去查字模表。这样既省 Flash,又永远不会乱码。
3.2 按键消抖与触发逻辑
按键我用了两种消抖思路结合的方式。主循环里做了一个 10ms 时基扫描,每次扫描读取 PA0 电平,连续读到两次低电平才认为有效,然后在状态机里触发一次发送。同时,PA0 配置成外部中断模式(EXTI0,下降沿触发),中断里只置一个标志位,具体工作在主循环里做。
不要在中断里做延时消抖,也不要直接在中断里发短信。这是新手容易犯的错。外部中断服务函数里堆了延时,整个系统会卡顿,而且串口发送又是阻塞的,会导致其他中断丢失。我实际踩过一次:中断里直接调用 HAL_UART_Transmit 发 AT 指令,结果 Air780E 返回数据时进入串口中断,和按键中断抢占,系统直接卡死。后来改成中断只置标志位,主循环轮询消抖并处理发送,问题立刻消失。
代码逻辑大致这样:
// 按键状态机 typedef enum { KEY_IDLE, KEY_CONFIRM, KEY_TRIGGERED, } key_state_e; void key_scan(void) { static key_state_e state = KEY_IDLE; static uint8_t stable_count = 0; uint8_t level = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_GPIO_Pin); switch (state) { case KEY_IDLE: if (level == GPIO_PIN_RESET) { stable_count++; if (stable_count >= 2) { // 连续两次低电平 state = KEY_CONFIRM; g_send_trigger = 1; // 主循环里根据这个标志位发送 } } else { stable_count = 0; } break; case KEY_CONFIRM: if (level == GPIO_PIN_SET) { state = KEY_IDLE; stable_count = 0; } break; default: break; } }而在主循环里,检测到g_send_trigger后,先把它清零,再进入发送状态机,OLED 同步更新。这种方法在实际测试中很稳,快速连按也不会漏触发。
3.3 串口 AT 指令收发与响应解析
STM32 和 Air780E 的通信基础是串口,波特率 115200。我用的是 USART1,5 个关键配置:波特率、数据位 8、停止位 1、无校验、无硬件流控。发送 AT 指令前一定要在每条指令后面加\r\n,Air780E 才能正确解析。
串口接收不能简单用一个HAL_UART_Receive阻塞等待,因为 Air780E 返回数据的时间不确定,而且返回的数据可能是分帧到达的。我用的方法是开启接收中断,把收到的每个字节放进环形缓冲区,主循环轮询缓冲区,用超时判断一帧数据结束。帧结束的标志是 100ms 内没有新字节到达。
响应解析的核心是查关键字,不需要把 AT 指令的完整输出都搞明白。例如:
uint8_t uart_buffer[256]; volatile uint16_t buffer_head = 0; volatile uint16_t buffer_tail = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uart_buffer[buffer_head] = rx_byte; buffer_head = (buffer_head + 1) % 256; HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }然后在主循环里从缓冲区取数据,把完整的一帧拷贝到临时数组后,依次执行strstr()查找。我需要关注的关键字只有几个:
OK:AT 指令执行成功。ERROR:指令执行失败。READY:SIM 卡已识别。>:AT+CMGS 后模组提示输入 PDU 数据。+CMGS::短信发送成功,后面的数字是消息序号。+CMS ERROR::短信发送失败,后面的错误码有具体含义。
我用一个at_cmd_runner()状态机来管理整个发送流程:先发AT+CMGF=0切换到 PDU 模式,等OK;再发AT+CMGS=<len>,等>;然后发 PDU 数据,最后发 Ctrl+Z(0x1A),等+CMGS:或ERROR。
3.4 时钟与下载配置
工程创建时有个小细节:STM32F103 系列系统时钟配置为 72MHz,需要在 HAL 库的SystemClock_Config()里设置 HSE 8MHz 外部晶振 + PLL 倍频到 72MHz。如果用的是 Blue Pill 板,板载晶振是 8MHz(有些版本是 25MHz,看模块标注),配置错了会导致串口波特率跑偏,Air780E 收到全是乱码。这个坑我帮无数人排查过,每次串口收发乱码先查时钟配置。
下载程序我用 ST-Link v2,在 Keil 里设置好 SWD 下载方式,装好Keil.STM32F1xx_DFP芯片包。接法是 ST-Link 的 SWDIO 接 SWDIO、SWCLK 接 SWCLK、GND 接 GND、3.3V 接 3.3V。如果遇到“No target connected”,多半是接线松动或者板子供电不足。
4. PDU 编码与短信发送流程
4.1 为什么中文短信必须走 PDU 模式
Air780E 的短信发送有两种模式:Text 模式和 PDU 模式。Text 模式只适合纯英文、纯数字的短信,中文内容很难兼容,因为中文在短信协议里必须编码成 UCS2,也就是每个汉字占两个字节的 Unicode,而 Text 模式没有标准的扩展机制。
PDU 模式是 3GPP TS 23.040 定义的一种二进制协议单元。它把短信的所有信息,包括接收号码、编码方案、内容本身,打包成一串十六进制字节。PDU 模式对中英文都支持,实际项目中几乎都用它。Air780E 也一样,设置AT+CMGF=0进入 PDU 模式,然后直接发送十六进制字符串。
对初学者来说,PDU 看起来很玄,但拆开看就是一串“地址信息 + 编码信息 + 内容”。理解它的关键在于搞清楚每个字段的长度含义。
4.2 PDU 结构拆解:一个“你好”就够了
我直接用最简单的一个例子讲透。目标是给手机号 13800138000 发一条内容为“你好”的中文短信。
先把手机号转成 PDU 里的地址格式。地址部分由三块组成:地址长度、地址类型、地址内容。
手机号 13800138000 是中国大陆号码,最稳妥的做法是加上国家码 86,变成 8613800138000,共 13 位数字。13 是奇数,在末尾补一个 F 凑成 14 位,然后每两位数字互换高低半字节:
原始序列:86 13 80 01 38 00 0F 交换后:68 31 08 10 83 00 F0
所以地址内容就是683108108300F0。地址长度字段表示数字占的半字节数,包括补的 F,一共 14 个半字节,十六进制是0E。地址类型字段表示号码类型,中国号码用91表示“国际号码”,如果直接写 11 位号码不用国家码,则用81。
接下来是短信内容。中文“你好”的 Unicode 编码分别是 U+4F60 和 U+597D,在 PDU 里以大端字节序表示为4F60597D。因为用的是 UCS2 编码,每个汉字 2 字节,所以用户数据长度字段是 2 个汉字 × 2 = 4 字节,写成04。
把这些字段串起来,完整 PDU 是:
00 11 00 0E 91 683108108300F0 00 08 00 04 4F60597D逐字段解释:
| 字段 | 值 | 含义 |
|---|---|---|
| SMSC长度 | 00 | 使用 SIM 卡默认短信中心 |
| PDU类型 | 11 | SMS-SUBMIT,02兼容 |
| MR | 00 | 消息参考号,可填 0 |
| 地址长度 | 0E | 号码占 14 个半字节 |
| 地址类型 | 91 | 国际号码格式 |
| 地址内容 | 683108108300F0 | 反转后的 13800138000 |
| PID | 00 | 协议标识,普通短信 |
| DCS | 08 | 编码方案,UCS2 中文 |
| VP | 00 | 有效期,默认 |
| 用户数据长度 | 04 | 内容为 4 字节 |
| 用户数据 | 4F60597D | 你好 |
4.3 AT+CMGS 的长度千万算对
PDU 串准备好了,发送时还要算一个长度参数。很多人在这一步发不出去短信,就是因为长度不对。
AT+CMGS后面跟的长度是“从 PDU 类型字段开始,到用户数据结束”的总字节数,也就是不含最前面的 SMSC 长度字节。拿上面的例子说,从11到4F60597D,一共 19 字节,所以命令是:
AT+CMGS=19模组收到后会返回一个>提示,此时把 PDU 十六进制字符串0011000E91683108108300F0000800044F60597D发过去,注意这里不要加\r\n,发完 PDU 后直接发字节0x1A(Ctrl+Z)。0x1A 在 AT 指令里表示“发送结束符”。如果你发的是\r\n,模组会认为是普通字符而不是结束标记,短信永远发不出去。
这个 19 的计算方法,我建议在代码里写一个函数自动算,不要手动算。完整的 PDU 字节数 = 19 + 1(SMSC 长度字段) = 20,AT+CMGS 参数 = 总字节数 - 1。我第一次手动算的时候少算了地址长度字段本身,导致模组一直返回ERROR,后来打印出长度比对才发现差了一个字节。
4.4 完整发送流程状态机
我在 STM32 端把发送流程做成了一个简单的状态机,每一步都带超时保护,避免卡死:
typedef enum { SEND_IDLE, SEND_PDU_MODE, // 发 AT+CMGF=0 SEND_CMGS, // 发 AT+CMGS=<len> SEND_DATA, // 发 PDU 数据 SEND_WAIT_RESULT, // 等待 +CMGS 或 ERROR SEND_OK, SEND_FAIL, } sms_state_e;状态机的推进逻辑不复杂:每个状态里调用对应发送函数,然后等待模组返回关键字,超时则跳转到SEND_FAIL,OLED 同步更新。整个流程耗时取决于网络状况,实测从按下按键到手机收到短信,一般 3-6 秒。
5. 常见问题与排查技巧实录
5.1 OLED 不亮或花屏
这个问题的排查优先级最高,因为 OLED 不亮,整个项目的交互就废了。按以下顺序排查:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 完全不亮 | 接线错、I2C 地址错 | 检查 SCL/SDA 是否接反,用 I2C 扫描程序确认地址是 0x3C 还是 0x3D |
| 有背光但无字 | 初始化序列未执行或执行顺序错 | 重新核对初始化命令序列,尤其注意 0xAF 开显示命令 |
| 花屏、乱码 | 刷新逻辑错误、显存越界 | 检查写入坐标是否超出 128x64,检查列地址/页地址模式是否设置成功 |
| 显示半屏 | I2C 速率过高或电源不稳 | 把 I2C 时钟降到 100kHz,OLED 供电加 0.1uF 电容 |
我遇到过最奇葩的情况是 OLED 初始化正常,但显示“花屏飘雪花”。排查到最后是 STM32 和 OLED 共用了模组的 3.3V,而模组发射时电压跌落导致 OLED 供电不稳。把 OLED 单独供电后恢复正常。
5.2 短信发送失败的核心排查
短信发不出去,无外乎几个原因。我把排查顺序整理成了速查表:
| 失败现象 | 排查点 | 操作建议 |
|---|---|---|
| AT+CREG? 返回 0,0 | 网络未注册 | 检查天线是否接好;换信号好的位置测试;确认 SIM 卡能正常上网 |
| AT+CPIN? 返回 ERROR | SIM 卡未识别 | 断电重新插卡,检查 SIM 卡触点;用酒精擦拭金属面 |
发 +CMGS 后无>返回 | 模组处于非 PDU 模式或状态异常 | 重新发AT+CMGF=0确认返回 OK;再发AT+CMGS=19 |
| 发 PDU 后返回 +CMS ERROR 301 | 短信中心未设置或号码异常 | 用AT+CSCA="+8613800755000"设置短信中心(各地不同) |
| 发 PDU 后返回 +CMS ERROR 500 | 长度错误或内容编码错误 | 重新核算 AT+CMGS 长度;确认 PDU 末尾发送了 0x1A 而非换行 |
| 没任何响应 | 串口接线或波特率不对 | 用串口助手手动发AT\r\n看是否返回 OK,排除硬件问题 |
这里重点提醒:AT 指令的返回是带换行的,比如\r\nOK\r\n。在用strstr()判断关键字时,我用的是“缓冲区里是否包含指定子串”,而不是比较整行字符串,能省掉很多换行符匹配的麻烦。
5.3 按键连触或失效
按键连触说明消抖做得不够。我建议把消抖时间从 20ms 提高到 50ms,实测还是连触就查硬件电容是否焊好。按键失效则多半是 GPIO 没有配置内部上拉,或者按下后电平没有变化。用万用表量一下按键两端电压,按下前应该是高电平,按下后是低电平,逻辑清楚后问题好定位。
还有一个隐藏问题:如果用了外部中断触发方式,频繁按键可能把中断标志积压,导致一次按键进两次中断。我的解决办法是主循环扫描替代中断触发,干脆不用 EXTI。反正发送短信本身是秒级操作,不需要微秒级响应,扫描足够用。
5.4 中文乱码的根源
中文乱码最烦人,因为屏幕上显示乱码或短信收到乱码,问题根源完全不一样。短信收到乱码,九成是 DCS 编码方案没设对,或者 PDU 用户数据里混入了 GBK 编码而非 UCS2。我在调试时直接把 PDU 打印出来,用十六进制比对“4F60597D”,一眼就能看到是不是编码错了。
OLED 显示乱码,根源是字模索引错乱。如果直接拿中文字符串的 GB2312 字节去做字模表索引,本来应该连续的两个字节映射到别的位置,屏幕自然乱。所以我前面推荐用枚举代替字符串匹配,这是最省心的方式。
6. 实测记录与一点个人体会
整套系统我跑了两周才稳定下来。最终测试环境是室内窗边位置,Air780E 核心板接好天线,手机号用一张正常的 4G 卡。开机后 OLED 依次显示“系统启动”“网络注册中”“信号正常”,按一次按键,大约 4 秒后手机收到“你好,这是一条来自STM32的短信”。OLED 显示“发送成功”,整套流程干干净净。
这个项目给我最大的体会是:嵌入式系统里,通信协议的理解比代码本身重要得多。比如 PDU 编码,如果只是复制别人的代码,遇到AT+CMGS=19和AT+CMGS=20的差别时根本不知道错在哪。而真正理解了每个字段的含义后,无论是换手机号、换运营商、换短信内容,都能快速适配。
最后分享一个调试小技巧:在 STM32 和 Air780E 之间,我特意引了一个测试点,把 Air780E 的串口 TX 线在杜邦线上中间断开,直接接到电脑的 USB-TTL 上。这样既能用电脑串口工具手动敲 AT 指令验证模组状态,也能观察 STM32 发出的数据是否干净。排查“STM32 发没发”和“模组收没收到”这两个方向的问题,一测便知。这个习惯我后来带到所有涉及串口通信的项目里,排查效率提升得很明显。