
1. 方案背景为什么选STM32Air780E做按键发短信之前一直在用手机直接发短信但有些固定场景——比如门岗告警、设备自检结果上报、远程通知——需要设备自己把信息发出去。一开始考虑过4G Cat.1模块加串口透传的方案对比下来Air780E最合适模块本身支持AT指令封装小价格便宜而且短信和TCP/UDP都能跑。STM32负责按键交互和OLED显示Air780E专职通信两个角色分工很明确。这个项目做下来其实就是一条链路按键事件 → 状态刷新 → 串口AT指令 → 短信发出。做完之后你可以把按键替换成传感器触发把短信内容改成报警信息整个框架直接平移。所以这篇不只是讲“按键发短信”更关键的是这套交互流程和AT指令时序控制。适合看这篇的人有两类一是正在做STM324G模块类项目、被AT指令卡住的朋友二是想把“本地设备事件”通过短信或网络上报出去的嵌入式开发者。硬件成本很低主控加模块加屏幕整体下来也就几十块钱。2. 系统整体设计与模块划分2.1 功能需求拆解标题里写的是“按键发送中文短信OLED状态显示”拆开看其实有四个独立需求按键输入带消抖单次触发不给系统带来误判短信内容管理中英文混合编码方式要处理好否则发出去是乱码4G模块控制Air780E的串口AT指令交互上电初始化然后拨号发短信状态显示OLED实时显示当前短信内容、发送进度、结果四个需求之间是串行关系但每个环节都有独立的“坑”。比如很多人以为短信发出去就行结果用PDU模式下中文编码没处理好对面收到全是问号。还有按键消抖不做一次按下触发三次发送短信费直接翻倍。这些都是实际做下来才会发现的细节。2.2 硬件组成与选型理由我用的是普中STM32F103ZET6开发板Air780E通过串口2连接按键接在GPIO上OLED用I2C接口。具体引脚分配看下面的接线表模块STM32引脚说明Air780E TXPA2USART2_TXMCU发数据到模块Air780E RXPA3USART2_RX模块发数据到MCUOLED SCLPB8I2C1时钟OLED SDAPB9I2C1数据按键KEY1PA0短按发送短信按键KEY2PA1短按切换短信内容选串口2而不是串口1是有讲究的。串口1经常被用来做调试输出你在调试的时候需要看log如果把Air780E挂在串口1上log和AT指令数据会混在一起排查问题非常痛苦。串口2留给模块串口1专门输出调试信息两者互不干扰。OLED因为只用来显示状态I2C就够了省引脚。如果想显示大量中文内容可以考虑SPI接口的OLED刷新速度快一些但I2C在这个项目里完全够用界面又不是动画。Air780E的供电要注意一点模块峰值电流在2A左右不能用STM32开发板的3.3V直接供电最好用独立稳压电源或者开发板上的5V转3.3V电路带载能力更强的接口。我实际测试直接用开发板3.3V供电会出现模块频繁重启换成外接3.3V稳压模块后稳定多了。2.3 程序架构思路代码我按“分层”方式组织方便后续灵活改// 分层结构 app_key.c // 按键扫描、消抖、事件上报 app_sms_content.c // 短信内容管理、中文编码 app_air780e.c // Air780E AT指令交互、状态机 app_oled.c // OLED显示刷新 main.c // 任务调度关键的地方在于按键只上报“事件”不直接去操作Air780E。中间加一个简单的事件标志位主循环检测到事件后再调用对应模块。这样做的好处是以后改成传感器触发时只需要在传感器回调里设置同一个事件标志其他逻辑完全不动。3. 按键输入与消抖处理3.1 硬件电路与GPIO配置按键电路很简单一个IO口接按键到GND内部上拉。STM32的PA0和PA1都支持外部中断但我没有用中断而是用的定时器轮询扫描。原因后面说。GPIO配置代码GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOA, GPIO_InitStruct);注意内部上拉要选对否则按键按下时IO电平不确定消抖程序怎么写都没用。按下时读到的电平是低释放时是高这个逻辑要在后面扫描函数里用对。3.2 为什么选择轮询而不是外部中断外部中断看起来很美好按下立刻响应但实际用下来问题不少按键抖动会导致中断反复触发需要额外的软硬件消抖如果按键按下时主循环正在处理AT指令中断服务函数里做不了复杂操作只能置标志位那和轮询的效果其实差不多。更麻烦的是中断里做延时消抖会阻塞系统尤其在Air780E回数据的时候一个过滤延时就可能丢掉AT指令响应。所以我用了5ms定时器轮询的方式。核心流程定时器每5ms触发一次检测IO电平连续多次读到稳定低电平才认为是有效按下检测到按下后等待释放释放后上报按键事件为了防止“长按连续触发”或者“一次按下多次上报”我在释放之后加了一个小延时保护。这个保护时间设成200ms意思是你按下再释放后200ms内即使按键又被按下也不会触发第二次。实际体验下来既不卡手也不会误触。uint8_t key_scan(GPIO_TypeDef* port, uint16_t pin) { static uint8_t stable_cnt 0; static uint8_t key_released 1; if (HAL_GPIO_ReadPin(port, pin) GPIO_PIN_RESET) { if (stable_cnt 3) { stable_cnt; } else { if (key_released) { key_released 0; // 上报一次按键事件 key_event_set(pin); } } } else { stable_cnt 0; key_released 1; } return 0; }这里的“连续3次读到低电平算是有效按下”配合5ms定时器相当于15ms的软件消抖对于机械按键来说足够了。如果你想更保险可以把稳定次数调到5也就是25ms但我测试下来3次就很稳超过反而会让快速连按失效。3.3 按键事件与短信内容联动两个按键各司其职KEY1是发送键KEY2是切换短信内容键。这里我做了一个小设计KEY2每按一次屏幕上显示的内容循环切换当前选中的内容才会被发送。#define SMS_CONTENT_NUM 3 const char* sms_content_table[SMS_CONTENT_NUM] { 设备运行正常定时巡检报告。, 设备告警温度异常请尽快处理。, 位置信息已更新请确认收到。 }; static uint8_t current_content_idx 0; void sms_content_switch(void) { current_content_idx; if (current_content_idx SMS_CONTENT_NUM) { current_content_idx 0; } } const char* sms_content_get(void) { return sms_content_table[current_content_idx]; }发送前OLED上会显示当前待发送的完整内容确认无误再按键发送。这个交互在调试阶段帮我省了很多短信费——一开始没做显示经常发错内容等发现的时候短信套餐已经没了。4. 中文短信编码与PDU格式详解4.1 为什么中文短信需要PDU编码Air780E支持两种短信发送格式Text模式和PDU模式。Text模式只能发ASCII字符中文内容必须用PDU格式。PDU格式的核心就是把中文转成UCS2编码每条短信最大长度70个汉字140字节。如果把中文直接用GBK编码发出去某些模块支持但兼容性很差尤其在跨运营商的情况下很容易乱码。UCS2是通用的所有支持中文短信的GSM模块都认得。4.2 UCS2编码代码实现UCS2编码本质上是把每个汉字转成16位的Unicode码两个字节表示一个字。STM32端需要将UTF-8字符串转成UCS2字节流。如果你用的是STM32标准库字符串常量默认是UTF-8所以需要做一次转换。我写了一个简易转换函数// UTF-8编码的文字转换为UCS2字节序大端 // 返回UCS2字节数 uint16_t utf8_to_ucs2(const uint8_t* utf8_str, uint8_t* ucs2_buf) { uint16_t ucs2_len 0; while (*utf8_str ! 0) { uint8_t c *utf8_str; uint16_t code 0; if (c 0x80) { // ASCII直接映射 code c; utf8_str 1; } else if ((c 0xE0) 0xC0) { // 2字节UTF-8编码 code ((c 0x1F) 6) | (utf8_str[1] 0x3F); utf8_str 2; } else if ((c 0xF0) 0xE0) { // 3字节UTF-8编码 code ((c 0x0F) 12) | ((utf8_str[1] 0x3F) 6) | (utf8_str[2] 0x3F); utf8_str 3; } else { // 中文UTF-8基本都是3字节其他情况先跳过 utf8_str 1; continue; } ucs2_buf[ucs2_len] (code 8) 0xFF; ucs2_buf[ucs2_len] code 0xFF; } return ucs2_len; }转换完以后UC2字节流要拼到AT指令里。“设备运行正常”这几个字的UCS2编码经过转换后看起来是一串十六进制比如“8BBE 5907 8FD0 884C 6B63 5E38”每个汉字对应两个字节。注意这里的转换只支持单字符UTF-8在1~3字节内的情况如果字符串里有特殊符号或生僻字需要扩展解析逻辑。我实际测试下来常用汉字和标点符号都没问题。4.3 完整PDU格式组装PDU格式的短信中心地址、发送号码、用户数据长度都有固定格式。以“8613800100500”这样的短信中心号码为例PDU组装逻辑如下uint16_t pdu_build(uint8_t* pdu_buf, const char* phone_number, const uint8_t* ucs2_data, uint16_t ucs2_len) { uint16_t idx 0; uint8_t i; // 短信中心号码部分这里固定填入8613800100500的PDU编码 // 具体过程8613800100500去掉号偶数位和奇数位交换前面加长度 pdu_buf[idx] 0x08; // 短信中心号码长度 pdu_buf[idx] 0x91; // 国际格式 // 86 13 80 00 10 05 00 - 交换后为 68 31 08 00 01 50 00 static const uint8_t sc_addr[] {0x68, 0x31, 0x08, 0x00, 0x01, 0x50, 0x00}; for (i 0; i sizeof(sc_addr); i) { pdu_buf[idx] sc_addr[i]; } // 短信类型和参数 pdu_buf[idx] 0x11; // 短信提交TP-UDHI等参数关闭 pdu_buf[idx] 0x00; // 消息参考值一般填0 // 目标号码长度和类型 uint8_t phone_len strlen(phone_number); pdu_buf[idx] phone_len; // 号码长度不含号 pdu_buf[idx] 0x91; // 国际格式 pdu_buf[idx] phone_len; // 实际号码长度 // 号码也做奇偶交换 for (i 0; i phone_len; i) { uint8_t num phone_number[i] - 0; if (i 1) { // 奇数为放到高四位 pdu_buf[idx - 1] | (num 4); } else { pdu_buf[idx] num; idx; } } if (phone_len 1) { pdu_buf[idx] 0xF0; // 奇数长度末尾补F } // 协议标识、编码方式、有效期 pdu_buf[idx] 0x00; // TP-PID pdu_buf[idx] 0x08; // TP-DCS为UCS2编码 pdu_buf[idx] 0xAA; // TP-VP有效期 // 用户数据长度 pdu_buf[idx] ucs2_len; // 用户数据 for (i 0; i ucs2_len; i) { pdu_buf[idx] ucs2_data[i]; } return idx; }这里有几个容易踩坑的点短信中心号码是固定的但不同运营商、不同地区可能不一样。我这边的号码是8613800100500如果你不确定自己的短信中心号码可以用ATCSCA查询。电话号码的长度计算不要带“”号但格式类型写0x91表示国际格式。奇数长度的电话号码要在末尾补0xF0否则PDU格式解析会错位。4.4 发送AT指令的完整流程编码完后拼接AT指令发送uint8_t pdu_sms_send(const char* phone, const char* utf8_content) { uint8_t pdu_buf[256]; uint8_t ucs2_buf[256]; uint16_t ucs2_len utf8_to_ucs2((const uint8_t*)utf8_content, ucs2_buf); uint16_t pdu_len pdu_build(pdu_buf, phone, ucs2_buf, ucs2_len); // ATCMGS长度然后回车等待模块返回 sprintf(at_cmd, ATCMGS%d\r, pdu_len); uart2_send_string(at_cmd); // 等待 提示符 if (!wait_for_prompt(, 2000)) { return 0; } // 发送PDU数据最后加0x1A结束符 uart2_send_bytes(pdu_buf, pdu_len); uart2_send_byte(0x1A); // 等待模块返回OK或ERROR if (wait_for_response(OK, 5000)) { return 1; } return 0; }这个流程最关键的一步是必须等待模块返回“”提示符后再发PDU数据。很多人直接ATCMGS发完后立刻跟上PDU数据模块还没准备好数据丢了然后短信没发出去返回ERROR。还有人在PDU数据末尾忘记加0x1ACtrl-Z模块等不到结束符就一直挂着直到超时。5. Air780E模块初始化与串口AT指令交互5.1 模块上电时序Air780E有个上电时序的要求VBAT上电后需要等一段时间模块内部启动完成后才能接受AT指令。我实测下来从VBAT供电到能稳定响应AT指令大约需要2~3秒。如果上电立即发AT指令大概率收到的是空响应或者乱码。我做了个简单的倒计时流程void air780e_power_on(void) { // 拉高PWRKEY引脚一段时间触发开机 HAL_GPIO_WritePin(AIR780E_PWRKEY_GPIO_Port, AIR780E_PWRKEY_Pin, GPIO_PIN_RESET); HAL_Delay(800); HAL_GPIO_WritePin(AIR780E_PWRKEY_GPIO_Port, AIR780E_PWRKEY_Pin, GPIO_PIN_SET); // 等待模块就绪 HAL_Delay(3000); // 发送AT测试 uart2_send_string(AT\r\n); wait_for_response(OK, 1000); }正常运行时我会在系统初始化阶段调用这个函数保证后续AT指令都能得到正常响应。5.2 基础AT指令设置开机后需要做一轮基础配置手机的短信模式要切成PDU模式关闭回声设置短信存储位置为SIM卡或模块内部。这些指令不多但顺序不能乱void air780e_init(void) { // 关闭回声 uart2_send_string(ATE0\r\n); wait_for_response(OK, 1000); // 设置短信格式为PDU uart2_send_string(ATCMGF0\r\n); wait_for_response(OK, 1000); // 设置短信存储位置为SIM卡 uart2_send_string(ATCPMS\SM\,\SM\,\SM\\r\n); wait_for_response(OK, 1000); // 查询短信中心 uart2_send_string(ATCSCA?\r\n); wait_for_response(OK, 1000); }这里ATCMGF0要特别强调Text模式是1PDU模式是0。如果不切模式后面你用ATCMGS发送PDU指令模块会当成普通文本处理编码格式错乱发出去全是乱码。还有个容易被忽略的点Air780E默认波特率是115200有些模块默认是9600或者自动波特率。如果你用的是自动波特率版本上电后第一次发AT指令需要使用特殊格式让它自动识别。我用的是固定115200版本省去这个麻烦串口初始化直接按115200配置。5.3 串口接收与状态机AT指令交互的核心是串口接收。我用了DMA接收空闲中断的方式配合一个环形缓冲区然后在主循环里处理。DMA空闲中断的好处是CPU占用小模块一有数据就能自动收完不用逐字节轮询等待。接收缓存到了之后在解析函数里做关键字匹配typedef struct { uint8_t buffer[512]; uint16_t len; uint16_t rx_state; // 0空闲 1接收中 } Uart2Rx_t; Uart2Rx_t uart2_rx; // 在主循环中轮询 void at_response_handle(void) { if (uart2_rx.len 0) { // 在buffer里查找是否含有关键字 if (strstr((char*)uart2_rx.buffer, OK) ! NULL) { at_result AT_RESULT_OK; } else if (strstr((char*)uart2_rx.buffer, ERROR) ! NULL) { at_result AT_RESULT_ERROR; } else if (strstr((char*)uart2_rx.buffer, ) ! NULL) { at_result AT_RESULT_PROMPT; } uart2_rx.len 0; memset(uart2_rx.buffer, 0, sizeof(uart2_rx.buffer)); } }我这个状态机写得很简略实际项目里AT指令交互往往是多步状态联动发送AT指令A后要等OK再发送AT指令B再等某个特定响应。初学者很容易犯的一个错是在主循环里用HAL_Delay等待响应这样会把整个系统卡死。按键扫描和OLED刷新全停了一旦模块响应慢了半拍系统就像死机一样。正确做法是拆成“发送指令→设置超时→处理响应”的流程不要让等待占满整个主循环。5.4 超时重试机制我在项目里做了一个通用的AT指令发送函数uint8_t at_send_command_wait(const char* cmd, const char* expect, uint16_t timeout_ms) { uint16_t elapsed 0; at_result AT_RESULT_NONE; uart2_send_string(cmd); while (elapsed timeout_ms) { at_response_handle(); if (at_result AT_RESULT_OK expect NULL) { return 1; } if (at_result AT_RESULT_OK strcmp(expect, OK) 0) { return 1; } if (at_result AT_RESULT_ERROR) { return 0; } HAL_Delay(10); elapsed 10; } return 0; }之所以要加超时是因为模块偶尔会响应慢尤其SIM卡注册网络的时候可能几秒钟没有响应。如果你用无限等待整个系统就卡在那了。设置超时后即使模块没响应系统也能继续跑下次按键还能重新触发发送。6. OLED状态显示与中文取模实现6.1 驱动库选型与显示内容规划OLED我用的0.96寸SSD1306I2C接口。驱动上直接用了开源的SSD1306驱动做了简单的移植。显示内容规划三段区域顶部当前模块状态初始化中/待发送/发送中/发送成功/发送失败中部短信内容预览最多显示两行每行8个汉字底部当前短信内容序号和剩余短信条数布局上建议用坐标定位把屏幕横向分成128像素宽的列。OLED的SSD1306一行最多显示16×16点阵的汉字8个所以短信内容要自动换行。我写了个简单换行函数每次计算当前行写完多少个字超过8个自动换到下一行最多显示两行。6.2 中文显示的核心字库取模这是整个OLED显示部分最容易被卡住的地方。SSD1306本身不带字库你要显示中文必须把汉字的点阵数据烧进代码里。我用的是16×16点阵每个汉字需要32字节的数据。取模软件用的是PCtoLCD2002设置要点取模方式逐行取模高位在前点阵大小16×16反色关闭因为白底黑字是常规显示字节顺序横向取模每行2个字节比如“设”字的取模数据是一串类似这样的字节数组static const uint8_t font_set[32] { 0x00, 0x00, 0x7F, 0xFC, ... };显示函数的核心逻辑是把每个字节展开成像素点void oled_show_chinese(uint8_t x, uint8_t y, const uint8_t* font_data) { for (uint8_t row 0; row 16; row) { for (uint8_t col 0; col 16; col) { uint8_t byte_index row * 2 (col / 8); uint8_t bit_index 7 - (col % 8); if (font_data[byte_index] (1 bit_index)) { oled_draw_point(x col, y row, 1); } } } }如果你在取模软件里选的“逐列取模”或者“倒序”显示出来就是镜像或者乱码。这是OLED中文显示最常见的问题没有之一。6.3 状态显示与发送流程联动OLED显示和AT指令发送流程要同步我用了一个简单的枚举变量作为“状态机”每次状态变化就刷新屏幕typedef enum { SMS_STATE_IDLE, SMS_STATE_SENDING, SMS_STATE_SUCCESS, SMS_STATE_FAIL } SmsState_t;发送时流程是屏幕显示“正在发送...”调用AT指令发送函数LED指示灯同步亮起等待响应期间屏幕每隔200ms刷新一串动态点提示用户系统没死机返回OK后显示“发送成功”返回ERROR或超时显示“发送失败请检查信号”这个动态提示非常重要因为AT指令等待可能需要几秒钟如果屏幕一直静止用户会以为设备卡死了。加个简单的动画体验会好很多。6.4 信号强度与网络注册状态显示Air780E还支持查询信号强度指令ATCSQ返回值的范围是0~3199表示无信号。我把这个查询也放到了初始化流程里并且在OLED顶部显示当前信号强度等级。显示格式很简单就是“信号XX%”。这样在调试的时候能直观看到模块是不是已经注册上网络省得每次都要拿串口工具去手动敲指令查。void oled_show_signal(uint8_t percent) { char buf[16]; sprintf(buf, 信号:%d%%, percent); oled_show_string(0, 0, buf); }这里要强调一下ATCSQ返回值不是百分比是RSSI等级值需要自己做转换。实测等级值22以上基本是满格10以下信号很弱短信发送容易失败。7. 实测效果与故障排查记录7.1 完整发送流程演示硬件连接好上电后屏幕先显示“系统初始化中...”同时Air780E开始开机AT握手。我用的是外接电源给模块供电STM32的串口2收到模块的“RDY”提示后开始发送初始化指令序列。整个过程实测时间线大概是时序操作预计耗时上电模块开机AT响应2~3秒初始化ATE0/CMGF/CPMS/CSCA1秒按键发送按下KEY1立即触发ATCMGS等待提示符0.5秒发送PDU等待OK1~2秒完成OLED显示成功总体5秒内完成按键按下到对面手机收到短信完整链路大概4~5秒。这个速度在4G Cat.1模块里算正常不是短信通道的问题是AT指令交互和PDU组装消耗的时间。7.2 踩坑记录一中文乱码第一个坑就是中文乱码。一开始我用Text模式发送短信内容直接是UTF-8字符串“设备运行正常”发出去对面收到的是“璁惧澶囪繍琛屾甯?”。后来换成PDU模式也不能直接用UTF-8的字节流必须转成UCS2。这个坑很多人踩原因在于嵌入式里字符串默认是UTF-8或者GBK而PDU只认UCS2中间的转换步骤不能省略。7.3 踩坑记录二按键误触发送多条第二个坑是按键消抖没做好。第一次测试时按一下KEY1结果对面收到三条一模一样的短信。排查发现是按键没有消抖机械按键的抖动被当成了多次按下。加上消抖逻辑后再测按多少次都只发一条问题解决。这里我想多说一句按键消抖不是只在机械按键上需要如果你以后用继电器或者开关量信号触发发送一样要处理。信号边沿的抖动是物理世界绕不开的现象软件上的处理成本很低值得每个项目都做掉。7.4 踩坑记录三模块供电不足第三个坑就是供电。Air780E的峰值电流确实不小直接用开发板的3.3V供电开机没问题但是一发送短信就重启。后来用5V/2A的电源转3.3V给模块单独供电问题消失。排查的时候用示波器抓了VBAT波形发现发送瞬间电压掉到了3.1V以下超出了模块工作电压范围。7.5 踩坑记录四OLED不亮或者花屏OLED不亮的原因多半是I2C地址不对。SSD1306的I2C地址默认是0x78或0x7A取决于SA0引脚电平。有的模块用的是0x3C有的用0x3D你得自己试。我在代码里专门做了地址扫描初始化时尝试两个地址读到ACK的就是正确的。另外OLED的花屏大多是因为刷新和数据传输不同步。如果你在显示中途有重置或者断电SSD1306内部显存状态会乱。解决方法是初始化时发一次清屏指令把所有GDDRAM清零。8. 扩展思路从按键发短信到设备远程报警系统做完这个项目后你会发现核心链路其实是“事件检测→编码→AT指令发送→状态反馈”。按键只是事件来源的一种短信也只是输出方式的一种。我后续把它改造成了环境监测设备的远程报警传感器检测到温湿度超限就自动发短信到管理员手机OLED上显示当前环境参数和报警状态。改造的部分只有几点把按键触发改成传感器触发设置好阈值判断逻辑短信内容改成格式化的传感器数据增加多目标号码支持用手机号轮询的方式发给多个管理员Air780E还支持TCP/UDP透传这意味着同样的框架可以走MQTT上报或者HTTP POST到服务器。我试过用Air780E的TCP透传功能把设备数据发送到自己的服务器AT指令流程类似只是把ATCMGS换成了ATTCPSEND之类的指令。如果你有更复杂的物联网需求这个板子完全能撑起来。最后分享一个调试技巧AT指令调试的时候STM32的串口1接USB转TTL到电脑串口2接Air780E。电脑上打开串口助手监听串口1的log同时在代码里把发送和接收到的AT指令都打印出来。这样你能实时看到模块和MCU之间到底发生了什么排查定位的效率比盲调高太多。这个项目整体难度适中核心难点在PDU编码和AT指令时序但只要理清了流程一次点亮没有太大问题。如果哪里卡住了可以按我文章里的排查顺序一步步来基本都能解决。