
前阵子帮朋友做了一套设备远程告警的小东西硬件就两个主角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 寸 128x64I2C 接口只占两根线代码驱动方案成熟。这套组合还有一个好处模块化程度高。以后想加个温湿度传感器只需要在 STM32 上多挂一个 I2C 设备短信内容里多拼一段数据就行不用改动通信链路。1.3 单条数据流从按键到手机屏幕系统的数据流大概是这样的按键事件发生后STM32 把 OLED 显示切到“正在发送”然后组装一条 PDU 格式的短信内容通过串口发送 AT 指令给 Air780EAir780E 入网后把短信数据交给基站基站再路由到目标手机。Air780E 会返回一条CMGS: 序号的响应STM32 解析到这个响应后把 OLED 切到“发送成功”。如果哪一步超时或者返回ERROROLED 就切到“发送失败”。整个过程中STM32 扮演的是控制器角色Air780E 扮演的是无线通信管道角色OLED 则是给用户看的“仪表盘”。理解这条链路后面排错就有方向了。2. 硬件连接与关键电路设计2.1 器件清单这个项目用到的硬件不算多我列个实际清单器件型号/规格数量备注主控板STM32F103C8T6 最小系统板1蓝色 pill 板4G模组合宙 Air780E 核心板1自带天线座、SIM卡座OLED0.96寸 SSD1306 I2C14引脚版本按键轻触开关 6x6mm1自锁或非自锁均可电源5V/2A USB电源或电池1模组峰值电流大下载器ST-Link v21SWD 方式下载连接线杜邦线若干尽量短电源线要够粗注意 Air780E 核心板是自带 SIM 卡座和天线座的这个非常重要。我第一次用裸模组设计外围电路SIM 卡座松动导致信号时好时坏排查了很久。直接买核心板能避开大量硬件坑。2.2 接线表一个 GPIO 都不能错接线前先确认引脚功能别凭印象乱接。我用的配置如下STM32引脚外设功能接对方引脚说明PA9USART1_TXAir780E RXD发送 AT 指令PA10USART1_RXAir780E TXD接收模组响应GND电源地Air780E GND必须共地PB6I2C1_SCLOLED SCL时钟线PB7I2C1_SDAOLED SDA数据线PA0按键输入按键一端按键另一端接 GND3.3V电源OLED VCCOLED 供电5V电源输入核心板 5V给 Air780E 供电这里有个许多人栽跟头的点STM32 和 Air780E 的串口电平。STM32F103 的串口电平是 3.3VAir780E 的 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 1OLED 显示函数接收枚举值再去查字模表。这样既省 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。我用的是 USART15 个关键配置波特率、数据位 8、停止位 1、无校验、无硬件流控。发送 AT 指令前一定要在每条指令后面加\r\nAir780E 才能正确解析。串口接收不能简单用一个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()查找。我需要关注的关键字只有几个OKAT 指令执行成功。ERROR指令执行失败。READYSIM 卡已识别。ATCMGS 后模组提示输入 PDU 数据。CMGS:短信发送成功后面的数字是消息序号。CMS ERROR:短信发送失败后面的错误码有具体含义。我用一个at_cmd_runner()状态机来管理整个发送流程先发ATCMGF0切换到 PDU 模式等OK再发ATCMGSlen等然后发 PDU 数据最后发 CtrlZ0x1A等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 也一样设置ATCMGF0进入 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 编码分别是 U4F60 和 U597D在 PDU 里以大端字节序表示为4F60597D。因为用的是 UCS2 编码每个汉字 2 字节所以用户数据长度字段是 2 个汉字 × 2 4 字节写成04。把这些字段串起来完整 PDU 是00 11 00 0E 91 683108108300F0 00 08 00 04 4F60597D逐字段解释字段值含义SMSC长度00使用 SIM 卡默认短信中心PDU类型11SMS-SUBMIT02兼容MR00消息参考号可填 0地址长度0E号码占 14 个半字节地址类型91国际号码格式地址内容683108108300F0反转后的 13800138000PID00协议标识普通短信DCS08编码方案UCS2 中文VP00有效期默认用户数据长度04内容为 4 字节用户数据4F60597D你好4.3 ATCMGS 的长度千万算对PDU 串准备好了发送时还要算一个长度参数。很多人在这一步发不出去短信就是因为长度不对。ATCMGS后面跟的长度是“从 PDU 类型字段开始到用户数据结束”的总字节数也就是不含最前面的 SMSC 长度字节。拿上面的例子说从11到4F60597D一共 19 字节所以命令是ATCMGS19模组收到后会返回一个提示此时把 PDU 十六进制字符串0011000E91683108108300F0000800044F60597D发过去注意这里不要加\r\n发完 PDU 后直接发字节0x1ACtrlZ。0x1A 在 AT 指令里表示“发送结束符”。如果你发的是\r\n模组会认为是普通字符而不是结束标记短信永远发不出去。这个 19 的计算方法我建议在代码里写一个函数自动算不要手动算。完整的 PDU 字节数 19 1SMSC 长度字段 20ATCMGS 参数 总字节数 - 1。我第一次手动算的时候少算了地址长度字段本身导致模组一直返回ERROR后来打印出长度比对才发现差了一个字节。4.4 完整发送流程状态机我在 STM32 端把发送流程做成了一个简单的状态机每一步都带超时保护避免卡死typedef enum { SEND_IDLE, SEND_PDU_MODE, // 发 ATCMGF0 SEND_CMGS, // 发 ATCMGSlen SEND_DATA, // 发 PDU 数据 SEND_WAIT_RESULT, // 等待 CMGS 或 ERROR SEND_OK, SEND_FAIL, } sms_state_e;状态机的推进逻辑不复杂每个状态里调用对应发送函数然后等待模组返回关键字超时则跳转到SEND_FAILOLED 同步更新。整个流程耗时取决于网络状况实测从按下按键到手机收到短信一般 3-6 秒。5. 常见问题与排查技巧实录5.1 OLED 不亮或花屏这个问题的排查优先级最高因为 OLED 不亮整个项目的交互就废了。按以下顺序排查现象可能原因处理方式完全不亮接线错、I2C 地址错检查 SCL/SDA 是否接反用 I2C 扫描程序确认地址是 0x3C 还是 0x3D有背光但无字初始化序列未执行或执行顺序错重新核对初始化命令序列尤其注意 0xAF 开显示命令花屏、乱码刷新逻辑错误、显存越界检查写入坐标是否超出 128x64检查列地址/页地址模式是否设置成功显示半屏I2C 速率过高或电源不稳把 I2C 时钟降到 100kHzOLED 供电加 0.1uF 电容我遇到过最奇葩的情况是 OLED 初始化正常但显示“花屏飘雪花”。排查到最后是 STM32 和 OLED 共用了模组的 3.3V而模组发射时电压跌落导致 OLED 供电不稳。把 OLED 单独供电后恢复正常。5.2 短信发送失败的核心排查短信发不出去无外乎几个原因。我把排查顺序整理成了速查表失败现象排查点操作建议ATCREG? 返回 0,0网络未注册检查天线是否接好换信号好的位置测试确认 SIM 卡能正常上网ATCPIN? 返回 ERRORSIM 卡未识别断电重新插卡检查 SIM 卡触点用酒精擦拭金属面发 CMGS 后无返回模组处于非 PDU 模式或状态异常重新发ATCMGF0确认返回 OK再发ATCMGS19发 PDU 后返回 CMS ERROR 301短信中心未设置或号码异常用ATCSCA8613800755000设置短信中心各地不同发 PDU 后返回 CMS ERROR 500长度错误或内容编码错误重新核算 ATCMGS 长度确认 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 编码如果只是复制别人的代码遇到ATCMGS19和ATCMGS20的差别时根本不知道错在哪。而真正理解了每个字段的含义后无论是换手机号、换运营商、换短信内容都能快速适配。最后分享一个调试小技巧在 STM32 和 Air780E 之间我特意引了一个测试点把 Air780E 的串口 TX 线在杜邦线上中间断开直接接到电脑的 USB-TTL 上。这样既能用电脑串口工具手动敲 AT 指令验证模组状态也能观察 STM32 发出的数据是否干净。排查“STM32 发没发”和“模组收没收到”这两个方向的问题一测便知。这个习惯我后来带到所有涉及串口通信的项目里排查效率提升得很明显。