1. 项目缘起与整体设计思路
按键发短信这件事,听起来像是二十年前的玩法,但在嵌入式圈子里,它一直是个非常经典的练手项目。原因很简单:它把GPIO输入检测、I2C显示驱动、UART串口通信、AT指令交互、PDU编码这几个嵌入式开发中最常见的知识点串成了一条完整的链路。你把这套东西跑通一遍,基本就摸清了单片机跟通信模组打交道的核心套路。
我这次用的是STM32F103C8T6加上Air780E模组。Air780E是合宙推出的一款Cat.1通信模组,支持4G全网通,走的是UART串口跟主控通信,用标准AT指令控制。相比早期的2G模组,它的网络覆盖和稳定性都好不少,而且价格也不贵,拿来练手非常合适。OLED用的是0.96寸的SSD1306,I2C接口,四根线就能点亮,显示状态信息足够用了。
整个项目的功能目标很明确:按下一个按键,STM32通过AT指令控制Air780E发送一条中文短信到指定手机号,同时OLED屏幕上实时显示当前的操作状态——比如“正在初始化”“正在发送”“发送成功”或者“发送失败”。这个过程中,中文短信的PDU编码是最容易踩坑的地方,后面我会详细拆解。
为什么选这个方案而不是其他?我考虑过几个点。第一,Air780E的AT指令集比较规范,文档也齐全,遇到问题容易排查。第二,STM32F103C8T6是最经典的入门芯片,资料多、成本低,HAL库开发效率高。第三,OLED用I2C而不是SPI,接线更简单,虽然刷新率低一些,但显示几行状态文字完全够用。第四,按键直接用GPIO轮询加软件消抖,没必要上中断,因为发短信本身是个低频操作,不需要毫秒级响应。
这个项目适合谁?如果你已经点亮过LED、用过UART串口打印调试信息,那就可以直接上手。如果你连I2C和UART都还没碰过,建议先把这两个外设玩明白再来看这个项目,不然调试起来会比较痛苦。
注意:Air780E需要插入有效的SIM卡才能正常工作,建议使用已激活的物联网卡或普通手机卡,确保卡内有余额或流量套餐支持短信功能。
2. 硬件选型与接线方案详解
2.1 核心器件清单与选型理由
先把物料清单列清楚,方便你对照准备:
| 器件 | 型号 | 数量 | 备注 |
|---|---|---|---|
| 主控芯片 | STM32F103C8T6 | 1 | 最小系统板即可 |
| 通信模组 | Air780E | 1 | 合宙Cat.1模组 |
| 显示屏 | 0.96寸OLED SSD1306 | 1 | I2C接口,4针 |
| 按键 | 轻触按键 | 1 | 6x6mm常规款 |
| 电阻 | 10kΩ | 1 | 按键上拉用 |
| 电阻 | 1kΩ | 1 | 限流保护 |
| 电容 | 100μF | 1 | 电源滤波 |
| 电源 | 5V/2A | 1 | 给模组供电 |
Air780E对供电的要求比较高,峰值电流可以到2A左右。如果你直接用STM32板子上的3.3V或者5V引脚给它供电,大概率会出现模组反复重启或者注册网络失败的情况。我的做法是单独用一个5V/2A的电源适配器给Air780E供电,STM32用另一路5V供电,两边共地就行。
OLED选0.96寸I2C版本,是因为它只需要两根信号线(SCL和SDA),加上VCC和GND一共四根线。市面上有些0.9寸的OLED对I2C时序兼容性不太好,我实测过几款,0.96寸的SSD1306最稳定,驱动库也最成熟。
2.2 接线方案与注意事项
接线这块我整理了一个对照表,照着接就行:
| STM32引脚 | 连接目标 | 说明 |
|---|---|---|
| PA9 (USART1_TX) | Air780E RX | 串口发送 |
| PA10 (USART1_RX) | Air780E TX | 串口接收 |
| PB6 (I2C1_SCL) | OLED SCL | I2C时钟 |
| PB7 (I2C1_SDA) | OLED SDA | I2C数据 |
| PA0 | 按键一端 | 按键输入 |
| 3.3V | OLED VCC | 显示屏供电 |
| GND | OLED GND / 按键另一端 / 模组GND | 共地 |
按键另一端接GND,PA0配置为上拉输入,按下时读到低电平。10kΩ上拉电阻可以省掉,因为STM32内部有上拉,但如果你发现按键抖动严重,外接一个10kΩ上拉会更稳。
Air780E的UART波特率默认是115200,STM32的USART1也配置成115200、8位数据位、1位停止位、无校验。这里有个细节:Air780E的TX引脚输出是1.8V电平,而STM32的RX引脚识别高电平阈值是0.7×VDD=2.31V(VDD=3.3V时)。严格来说1.8V不够,但实测下来大多数情况下STM32能识别到,如果你遇到通信不稳定,加一个电平转换模块更保险。
提示:接线完成后,先不要急着写代码。用USB转TTL模块直接连接Air780E,在电脑串口助手里发送“AT”测试模组是否正常响应。这一步能帮你排除硬件问题,省去后面大量调试时间。
3. AT指令与PDU编码核心解析
3.1 Air780E短信相关AT指令流程
Air780E发送短信的AT指令流程不算复杂,但顺序不能乱。我把它拆成几个阶段:
第一阶段:基础检查
AT // 测试模组是否响应 AT+CPIN? // 查询SIM卡状态 AT+CSQ // 查询信号质量 AT+CREG? // 查询网络注册状态AT+CPIN?返回+CPIN: READY说明SIM卡正常。AT+CSQ返回的第一个数字是信号强度,范围0-31,越大越好,低于10基本没法用。AT+CREG?返回+CREG: 0,1或+CREG: 0,5都表示已注册上网络。
第二阶段:配置短信参数
AT+CMGF=0 // 设置为PDU模式 AT+CSCS="GSM" // 字符集设置为GSM AT+CSMP=17,167,0,8 // 设置短信参数这里重点说AT+CMGF=0。短信有两种模式:Text模式和PDU模式。Text模式看起来简单,但发中文会乱码,因为Text模式默认只支持ASCII字符。PDU模式虽然编码麻烦,但支持中文、长短信、状态报告等完整功能。所以做中文短信,PDU模式是唯一选择。
AT+CSMP=17,167,0,8这个参数里,最后一个“8”表示短信编码方式为UCS2(即Unicode),这是中文短信必须的设置。17表示短信的默认编码格式,167是协议标识,0是编码方式。
第三阶段:发送短信
AT+CMGS=<长度> // 长度是PDU串去掉中心号码后的字符数 > <PDU串> // 收到“>”提示后输入PDU串 <Ctrl+Z> // 发送模组返回+CMGS: <消息参考号>表示发送成功,返回ERROR就是失败了。
3.2 中文短信PDU编码原理与实操
PDU编码是整个项目里最容易卡住的地方。我一开始也在这上面折腾了很久,后来把逻辑理清楚了就简单了。
PDU串的结构是这样的:
00 // 短信中心号码长度(用00表示使用默认) 11 // 短信中心号码类型 00 // 目标号码长度 91 // 目标号码类型(91表示国际号码) <目标号码> // 经过半字节交换的号码 00 // 协议标识 08 // 编码方式(08表示UCS2) <有效期> // 一般用AA <短信长度> // 用户数据的字节数 <用户数据> // UCS2编码的中文内容目标号码的处理有个“半字节交换”的规则。比如手机号是13800138000,先在前面加“86”(中国区号),变成8613800138000,然后每两个数字交换位置:68 31 08 10 83 00 00,最后如果位数是奇数就补“F”。这个交换规则是PDU协议规定的,不交换的话号码就错了。
中文内容的UCS2编码,就是把每个汉字转成4位十六进制的Unicode码。比如“你好”两个字的Unicode是4F60597D,直接拼上去就行。
在STM32上实现这个编码,我写了一个函数,输入手机号和中文内容,输出完整的PDU串。核心逻辑是:
// 将单个字符转换为十六进制字符串 void char2hex(char c, char *hex) { if(c >= '0' && c <= '9') { hex[0] = '0' + (c - '0') / 10; hex[1] = '0' + (c - '0') % 10; } else if(c >= 'A' && c <= 'F') { hex[0] = '0' + (c - 'A' + 10) / 10; hex[1] = '0' + (c - 'A' + 10) % 10; } } // 半字节交换 void swap_nibbles(char *src, char *dst, int len) { for(int i = 0; i < len; i += 2) { dst[i] = src[i+1]; dst[i+1] = src[i]; } }中文转UCS2需要一张Unicode映射表,或者用GB2312到Unicode的转换表。我图省事,直接在代码里硬编码了常用汉字的Unicode值,因为项目里短信内容就那么几条固定的。如果你需要发任意中文,建议移植一个GB2312到Unicode的转换表,大概几百KB的Flash空间。
注意:PDU串的长度计算要准确。
AT+CMGS=后面的长度参数是PDU串中除去短信中心号码部分后的字符数(两个十六进制字符算一个字节)。算错了模组会返回ERROR,而且不告诉你具体哪里错了,非常难排查。
4. 软件架构与关键代码实现
4.1 整体软件流程设计
软件部分我分成四个模块:OLED显示驱动、按键检测、AT指令收发、PDU编码。主循环的逻辑很直接:
- 初始化OLED、USART1、I2C1、GPIO
- 显示“系统初始化中”
- 依次发送AT指令检查模组状态
- 显示“就绪,等待按键”
- 检测按键是否按下
- 按下后执行发送流程,OLED同步更新状态
- 发送完成后回到等待状态
这个流程里,AT指令的收发是同步阻塞的。也就是说,发一条指令后等模组回复,收到回复或者超时了再发下一条。这样做的好处是逻辑简单,不容易出错。缺点是发送过程中按键没响应,但发短信本来就是个几秒钟的操作,用户不会在意。
4.2 OLED状态显示驱动实现
OLED我用的是HAL库加软件I2C的方式。为什么不用硬件I2C?因为STM32F103的硬件I2C有历史遗留的稳定性问题,虽然新版HAL库改善了很多,但软件I2C更可控,引脚随便选,移植也方便。
软件I2C的核心就是控制SCL和SDA两根线的电平:
#define OLED_SCL_PIN GPIO_PIN_6 #define OLED_SCL_PORT GPIOB #define OLED_SDA_PIN GPIO_PIN_7 #define OLED_SDA_PORT GPIOB void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(2); SDA_LOW(); delay_us(2); SCL_LOW(); delay_us(2); } void I2C_WriteByte(uint8_t data) { for(int i = 0; i < 8; i++) { if(data & 0x80) SDA_HIGH(); else SDA_LOW(); SCL_HIGH(); delay_us(2); SCL_LOW(); delay_us(2); data <<= 1; } }OLED显示状态信息,我定义了四个状态:IDLE(就绪)、SENDING(发送中)、SUCCESS(成功)、FAIL(失败)。每个状态对应屏幕上不同的显示内容。SSD1306的显存是128x64位,我用的8x16字体,一行能显示16个字符,总共能显示4行。
显示“发送成功”的时候,我还会在第二行显示发送时间戳,方便确认操作记录。时间戳用STM32的SysTick计数换算成秒数,虽然不精确,但够用了。
4.3 按键检测与消抖处理
按键检测看起来简单,但实际做的时候有几个坑。第一个是抖动,机械按键按下和松开的时候会有几十毫秒的电平抖动,不处理的话一次按下会被识别成多次。第二个是长按,如果用户按住不放,代码会一直触发发送。
我的处理方式是:检测到低电平后延时20ms再检测一次,如果还是低电平就确认按下。然后等待按键松开,松开后再延时20ms确认。这样一次按下只触发一次操作。
uint8_t Key_Scan(void) { if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { HAL_Delay(20); if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); HAL_Delay(20); return 1; } } return 0; }这个函数放在主循环里轮询,返回1表示有一次有效的按键按下。注意HAL_Delay在中断里不能用,但主循环里没问题。
4.4 AT指令收发与超时处理
AT指令的收发我封装了两个函数:一个发送指令,一个等待预期回复。
void AT_SendCmd(char *cmd) { HAL_UART_Transmit(&huart1, (uint8_t *)cmd, strlen(cmd), 1000); HAL_UART_Transmit(&huart1, (uint8_t *)"\r\n", 2, 100); } uint8_t AT_WaitResponse(char *expected, uint32_t timeout) { uint8_t buf[256]; uint32_t start = HAL_GetTick(); uint16_t idx = 0; while((HAL_GetTick() - start) < timeout) { if(HAL_UART_Receive(&huart1, &buf[idx], 1, 10) == HAL_OK) { idx++; if(idx >= sizeof(buf) - 1) break; buf[idx] = '\0'; if(strstr((char *)buf, expected) != NULL) { return 1; } } } return 0; }这里有个细节:HAL_UART_Receive的超时设成10ms,这样每次循环不会卡太久。整个等待过程有个总超时,比如发送短信我给30秒,因为网络不好的时候模组可能需要更长时间。
提示:Air780E的回复有时候会分多次到达,比如先回一个
\r\n,再回OK\r\n。所以接收缓冲区要足够大,而且要用strstr做子串匹配,不能要求完全相等。
5. 实操过程与调试记录
5.1 分阶段调试步骤
我强烈建议不要一次性把代码写完再调试,而是分阶段来,每个阶段验证通过再进入下一个。
第一阶段:OLED点亮
先写一个最简单的OLED测试程序,在屏幕上显示“Hello”。如果显示正常,说明I2C通信没问题。如果花屏或者不亮,检查接线和I2C地址。SSD1306的I2C地址通常是0x78(8位地址)或0x3C(7位地址),HAL库用的是左移一位的8位地址,所以填0x78。
第二阶段:串口通信测试
用USB转TTL连接Air780E,在电脑上确认模组能正常响应AT指令。然后把Air780E接到STM32上,写一个透传程序,STM32收到什么就转发到电脑串口,电脑发什么就转发给Air780E。这样你能在电脑上看到模组的回复,确认STM32和模组之间的通信正常。
第三阶段:AT指令流程验证
在透传模式下,手动在电脑上发送AT指令,走一遍完整的短信发送流程。确认PDU串正确、模组能成功发出短信。这一步过了,说明你的PDU编码逻辑是对的。
第四阶段:整合代码
把OLED显示、按键检测、AT指令收发整合到一起,加上状态机逻辑。这个阶段主要调试的是状态切换和超时处理。
5.2 实测记录与参数调整
我在实测中遇到几个问题,记录一下:
第一个问题是模组注册网络慢。第一次上电后,AT+CREG?返回+CREG: 0,2(正在搜索网络),持续了大概15秒才变成+CREG: 0,1。所以初始化代码里要加一个循环等待,每2秒查一次,最多等30秒。
第二个问题是短信发送成功后,模组返回的+CMGS: 15里的数字是消息参考号,不是错误码。我一开始以为15是错误,查了手册才知道是正常的。
第三个问题是OLED在发送过程中刷新太频繁导致闪烁。后来改成只在状态变化时刷新,而不是每次循环都刷新,就稳定了。
| 问题现象 | 原因 | 解决方法 |
|---|---|---|
| 模组反复重启 | 供电不足 | 单独5V/2A供电 |
| AT指令无回复 | TX/RX接反 | 交换TX和RX接线 |
| 短信发送ERROR | PDU长度算错 | 重新计算CMGS参数 |
| OLED花屏 | I2C地址错误 | 改为0x78 |
| 按键触发多次 | 抖动未处理 | 加20ms延时消抖 |
| 中文显示乱码 | 未用UCS2编码 | 设置AT+CSMP最后参数为8 |
5.3 完整发送流程的代码逻辑
把整个发送流程串起来,主循环里大概是这样:
while(1) { if(Key_Scan()) { OLED_ShowString(0, 0, "Sending..."); // 检查模组状态 if(!AT_CheckModule()) { OLED_ShowString(0, 2, "Module Error"); continue; } // 构建PDU串 BuildPDU("13800138000", "测试短信", pdu_buf); // 发送短信 char cmd[32]; sprintf(cmd, "AT+CMGS=%d", pdu_len); AT_SendCmd(cmd); if(AT_WaitResponse(">", 5000)) { HAL_UART_Transmit(&huart1, (uint8_t *)pdu_buf, strlen(pdu_buf), 5000); HAL_UART_Transmit(&huart1, (uint8_t *)"\x1A", 1, 1000); if(AT_WaitResponse("+CMGS", 30000)) { OLED_ShowString(0, 2, "Send OK"); } else { OLED_ShowString(0, 2, "Send Fail"); } } HAL_Delay(3000); OLED_Clear(); OLED_ShowString(0, 0, "Ready"); } }这段代码里,BuildPDU函数负责把手机号和中文内容编码成PDU串,pdu_len是计算出来的长度。发送完成后延时3秒再回到就绪状态,让用户能看到结果。
6. 常见问题排查与避坑经验
6.1 AT指令交互类问题
模组不回复任何指令
先检查波特率。Air780E默认115200,但有些批次可能是9600。如果不确定,可以尝试几个常见波特率。再检查TX/RX是否接反,这个错误太常见了,我至少犯过三次。最后检查供电,用万用表量一下模组的VCC引脚,确保在3.8V到4.2V之间(Air780E的供电范围)。
AT+CPIN?返回ERROR
说明SIM卡没识别到。检查卡座是否接触良好,SIM卡是否插反,卡是否欠费。物联网卡有时候需要先激活才能用,联系运营商确认一下。
AT+CSQ返回的信号值很低
信号值低于10的话,短信大概率发不出去。换个位置,或者接一根外置天线。Air780E的板载天线效果一般,如果项目对信号要求高,建议换成外置棒状天线。
6.2 PDU编码类问题
短信发送后对方收到乱码
99%是编码方式没设对。确认AT+CSMP=17,167,0,8这条指令执行了,最后的“8”就是UCS2编码。另外确认PDU串里的编码方式字节也是“08”。
AT+CMGS返回ERROR
最常见的原因是PDU长度算错了。长度参数是PDU串中用户数据部分的字节数,不是整个PDU串的长度。比如你的PDU串是0011000D9168310801380000AA044F60597D,那么用户数据是4F60597D,长度是4个字节,AT+CMGS=4。注意这里说的是“字节数”,但PDU串里每个字节是用两个十六进制字符表示的,所以实际字符数是8。
中文短信内容超过70个字
UCS2编码下,一条短信最多70个字符(每个汉字算一个字符)。超过70个字需要拆分长短信,PDU协议里有UDH(用户数据头)来处理。这个比较复杂,建议先确保单条短信能发成功,再研究长短信。
6.3 硬件与显示类问题
OLED显示内容闪烁
不要在每次循环里都调用OLED刷新函数。SSD1306的显存写入需要时间,频繁刷新会导致屏幕闪烁。正确的做法是只在状态变化时刷新,或者用双缓冲机制。
按键按下没反应
先确认按键接线是否正确,用万用表量一下按下时PA0是否接地。如果接线没问题,检查代码里GPIO初始化是否正确配置为上拉输入。HAL库里的配置是GPIO_MODE_INPUT加GPIO_PULLUP。
STM32和Air780E通信不稳定
如果用的是杜邦线连接,线太长或者接触不良都会导致通信错误。尽量用短一点的线,或者直接焊在板子上。另外,Air780E的TX输出是1.8V电平,如果STM32识别不稳定,加一个TXS0108E电平转换模块。
提示:调试的时候,在STM32的UART接收中断里加一个LED翻转,每收到一个字节翻转一次。这样你能直观地看到模组有没有回复数据,比看串口打印快得多。
7. 项目扩展与个人实操体会
这个项目跑通之后,可以往几个方向扩展。第一个是加一个RTC时钟模块,在短信内容里带上时间戳,这样每条短信都有时间记录。第二个是加一个温湿度传感器,按键后把当前环境数据一起发出去,做成一个简易的远程环境监测终端。第三个是把按键换成定时器触发,每隔一小时自动发送一次状态短信,做成一个定时上报设备。
我在实际做这个项目的时候,最大的体会是:PDU编码一定要单独测试。不要把它跟AT指令流程混在一起调,那样出了问题你根本不知道是编码错了还是指令流程错了。我的做法是在电脑上用Python写一个小脚本,先把PDU串生成出来,用串口助手手动发给模组,确认能发出正确的中文短信后,再把这段逻辑移植到STM32上。这样能省掉大量在单片机上反复烧录调试的时间。
另一个体会是供电一定要重视。我一开始用STM32板子上的5V引脚给Air780E供电,模组一注册网络就重启,折腾了半天才想到是电流不够。后来单独用一个5V/2A的电源,问题立刻消失。嵌入式项目里,电源问题引起的故障至少占三成,遇到莫名其妙的现象先查供电。
最后分享一个小技巧:Air780E的固件版本不同,AT指令的响应格式可能有细微差异。如果发现手册上的指令跟实际返回不一致,先查一下固件版本(AT+CGMR),然后找对应版本的AT指令手册。合宙的官网有各个版本的文档,别拿旧手册调新固件。