1. 项目缘起与整体方案拆解
按键发短信这件事,听起来像是功能机时代的老古董,但在工业现场、远程告警、老人看护、农业大棚这些场景里,它依然是最朴素也最可靠的一种通信手段。我这次要做的,就是拿一块 STM32 最小系统板,配上一片 Air780E 的 4G Cat.1 模组,再加一块 0.96 寸的 OLED 屏,实现“按一下键,发一条中文短信,屏幕上同步显示发送状态”的完整闭环。核心关键词就四个:STM32、Air780E、OLED、AT 指令,而真正让大多数人卡住的,是中文短信背后的 PDU 编码。
先说清楚这个项目能干什么。你手头有一块 STM32F103C8T6 或者类似的板子,接上 Air780E 模组,再挂一块 I2C 的 OLED,按下按键之后,模组把一条预设好的中文短信发到指定手机号,OLED 上实时显示“正在发送”“发送成功”或者“发送失败”。它解决的是“设备侧主动告警”的问题——不需要联网服务器,不需要云平台,一条短信直达。适合谁看?适合已经会点 STM32 基础、能点灯能串口打印、想往物联网通信方向迈一步的朋友;也适合做毕设、做工业报警器、做远程抄表的工程师拿来当参考。
为什么选 Air780E 而不是别的模组?这是有讲究的。市面上常见的 4G 模组里,Air780E 是 Cat.1 制式,功耗和成本都比 Cat.4 低,而且它原生支持 AT 指令,串口透传逻辑简单,中文短信走 PDU 模式也很成熟。相比之下,某些模组虽然便宜,但 AT 指令集不完整,发中文短信时容易踩坑。OLED 选 0.96 寸 I2C 版本,是因为它只要两根线,不占用太多 IO,STM32F103C8T6 这种引脚紧张的芯片最合适。整个方案的核心思路就是:STM32 当大脑,负责按键扫描、状态机调度、OLED 刷新;Air780E 当嘴巴,负责把 PDU 编码后的短信发出去;OLED 当脸面,负责把过程可视化。
这里有个关键决策点:短信内容怎么组织?直接发 ASCII 英文最简单,但标题要求中文,就必须用 PDU 模式。PDU 是“Protocol Data Unit”的缩写,中文短信在 GSM 协议里不能直接以明文发送,必须经过 UCS2 编码再拼成 PDU 串。很多人第一次接触 PDU 会觉得头大,其实拆开看就是几段固定格式加一串十六进制。我后面会详细讲怎么算。
另一个决策点是按键触发方式。我试过轮询和外部中断两种,最后选了轮询加软件消抖。原因很简单:外部中断在按键抖动时容易误触发,而轮询放在主循环里,配合 20ms 的消抖延时,稳定得多。OLED 刷新也不能放在中断里,I2C 时序对时间敏感,中断里刷屏容易导致通信失败。
整体硬件连接是这样的:STM32 的 USART2 接 Air780E 的串口,波特率 115200;I2C1 接 OLED,地址通常是 0x78;按键接在 PA0,下拉输入,按下为高电平。电源部分要注意,Air780E 在发射瞬间电流能冲到 2A,所以供电必须给足,最好单独用一路 3.8V 的 DC-DC,不要直接从 STM32 的 3.3V LDO 上取电,否则一发短信就复位。
2. 核心细节解析与实操要点
2.1 Air780E 的 AT 指令交互逻辑
Air780E 上电之后,串口会输出一堆启动日志,这时候不要急着发指令,等它稳定下来,先发一个AT,看它回不回OK。这是最基本的握手。如果连AT都不回,先查波特率,Air780E 默认是 115200,但有些固件版本是 9600,我遇到过两次,折腾了半天才发现是波特率不对。
握手成功之后,要依次配置几个关键参数。第一,AT+CMGF=0,把短信模式设成 PDU 模式。注意,AT+CMGF=1是文本模式,只能发英文,发中文会乱码或者直接失败。第二,AT+CSCS="UCS2",设置字符集为 UCS2,这一步很多人会漏掉,结果 PDU 串拼出来是对的,但模组解析出错。第三,AT+CSMP=17,167,0,8,设置短信参数,其中最后一个参数 8 表示 UCS2 编码。这个指令里的 17 是 FO 参数,167 是 VP 有效期,0 是 PID,8 是 DCS。DCS 设为 8 就是告诉模组“我要用 UCS2 发中文”。
发短信的指令是AT+CMGS=<length>,这里的 length 是 PDU 串去掉开头两个字符后的长度,也就是 TPDU 的长度,不是整个 PDU 串的长度。这个细节坑了无数人。举个例子,如果你的 PDU 串是0011000D916831...,那么 length 就是总长度除以 2 再减去 1。因为 PDU 串是十六进制字符串,两个字符代表一个字节,而AT+CMGS要的是字节数,且不包含 SMSC 地址部分。
发完AT+CMGS=<length>之后,模组会回一个>提示符,这时候才能把 PDU 串发进去,最后以Ctrl+Z(0x1A)结尾。注意,Ctrl+Z是十六进制的 0x1A,不是字符串“Ctrl+Z”。我在代码里用USART_SendData(USART2, 0x1A)直接发这个字节。如果发的是Ctrl+C(0x1B),就是取消发送。
2.2 中文短信的 PDU 编码计算
PDU 编码是這個项目里最硬核的部分。我把它拆成四段来看:SMSC 地址段、TPDU 首字节、目标号码段、短信内容段。
SMSC 地址段通常写00,表示使用模组默认的短信中心号码。如果你要指定,就写07加号码长度加91加号码。我一般直接用00,省事。
TPDU 首字节是11或01,11表示有状态报告,01表示没有。我选11,因为 OLED 上要显示发送状态,有状态报告更靠谱。
目标号码段稍微复杂。假设手机号是13800138000,先算长度:11 位,十六进制是0B。然后加91表示国际格式。接着把号码两两颠倒:13 80 01 38 00 00变成31 08 10 83 00 F0。注意最后如果奇数位,补F。所以号码段就是0B91加3108108300F0。
短信内容段是 UCS2 编码。每个中文字符对应一个 Unicode 码点,比如“你”是4F60,“好”是597D。把每个字符的 Unicode 码点转成十六进制字符串,拼起来就是内容段。内容长度是字符数乘以 2,再转成十六进制。比如“你好”两个字,长度是 4 个字节,十六进制04。
把这些拼起来,完整的 PDU 串就是:00+11+00+0B91+3108108300F0+00+08+04+4F60597D。等等,这里有个00和08是什么?00是 TP-PID,08是 TP-DCS,表示 UCS2。这两个字节不能漏。
所以完整 PDU 是:0011000B913108108300F00008044F60597D。长度计算:去掉开头的00,剩下11000B913108108300F00008044F60597D,数一下字符数,是 38 个字符,除以 2 得 19 字节。所以AT+CMGS=19。
我在代码里写了一个pdu_encode函数,输入手机号和中文内容,输出 PDU 串和长度。这个函数我调了整整一个下午才跑通,主要坑在号码颠倒和长度计算上。
2.3 OLED 状态显示的设计
OLED 用 I2C 驱动,我选的是 SSD1306 芯片,0.96 寸,128x64 分辨率。驱动库我用的是自己精简过的 HAL 库版本,没有用现成的 u8g2,因为 u8g2 占 Flash 太大,STM32F103C8T6 只有 64KB Flash,塞进去有点紧张。
显示内容分三行:第一行显示“短信发送”,第二行显示当前状态,比如“正在发送...”“发送成功”“发送失败”,第三行显示重试次数或者错误码。状态切换的时候,我只刷新变化的那一行,不全屏刷新,这样 I2C 占用时间短,不会阻塞主循环。
OLED 初始化的时候要注意,0.9 寸和 0.96 寸的 I2C 地址可能不一样,0.96 寸通常是 0x78,0.9 寸有些是 0x7A。我手里这块 0.96 寸的,地址是 0x78。如果你买的是 0.9 寸,先扫 I2C 地址确认一下。另外,OLED 的供电最好是 3.3V,有些模块标称 5V 兼容,但长期跑 5V 容易烧。
显示汉字需要取模。我用的是 PCtoLCD2002 软件,取模方式选“阴码+逐列式+顺向”,每个汉字 16x16 像素,占 32 个字节。把取模数据放到一个数组里,显示的时候按列写入。这里有个小技巧:把常用的状态文字提前取好模,比如“正在发送”“发送成功”“发送失败”,存成常量数组,用的时候直接索引,不用动态取模。
3. 实操过程与核心环节实现
3.1 硬件连接与供电检查
先把硬件接起来。STM32F103C8T6 的 PA2 接 Air780E 的 RXD,PA3 接 TXD,GND 共地。OLED 的 SCL 接 PB6,SDA 接 PB7,VCC 接 3.3V,GND 共地。按键一端接 PA0,另一端接 3.3V,PA0 配置成下拉输入。Air780E 的 VCC 接 3.8V,这个 3.8V 我用的是单独的 DC-DC 模块,输入 5V,输出 3.8V,最大电流 3A。
供电检查这一步不能省。我第一版直接拿 STM32 板子上的 3.3V 给 Air780E 供电,结果一按按键发短信,STM32 就复位,OLED 黑屏。后来用示波器看,Air780E 发射瞬间把 3.3V 拉到了 2.1V,STM32 的 BOR 复位了。换成独立 3.8V 供电之后,问题消失。所以记住:Air780E 的供电必须独立,且电流能力不低于 2A。
串口线也要注意,Air780E 的串口是 1.8V 电平还是 3.3V 电平?我这款是 3.3V 电平,可以直接接 STM32。如果你买的是 1.8V 版本,中间要加电平转换。不确定的话,先看模组丝印或者问卖家。
3.2 STM32 端代码框架
代码分四个模块:串口驱动、OLED 驱动、按键扫描、PDU 编码。串口驱动用 HAL 库的HAL_UART_Receive_IT开中断接收,把模组返回的数据存到一个环形缓冲区里。主循环里解析缓冲区,找OK、ERROR、+CMGS:这些关键字。
OLED 驱动我写了三个函数:OLED_Init、OLED_ShowString、OLED_Refresh。OLED_ShowString支持 ASCII 和汉字,汉字用索引查表。OLED_Refresh把显存刷到屏幕,我用的页地址模式,每次刷一页,8 页刷完。
按键扫描放在SysTick中断里,每 10ms 扫一次,连续 3 次读到高电平才确认按下。确认按下后,置一个标志位key_pressed,主循环检测到这个标志位就启动发送流程。
发送流程是一个状态机:IDLE->SEND_AT->WAIT_OK->SEND_CMGF->WAIT_OK->SEND_CMGS->WAIT_PROMPT->SEND_PDU->WAIT_RESULT->DONE。每个状态等模组回复,超时 3 秒就跳到ERROR状态,OLED 显示“发送失败”,然后重试一次。
3.3 PDU 编码函数的实现细节
pdu_encode函数的输入是手机号字符串和中文内容字符串,输出是 PDU 串和长度。我一步步写:
第一步,处理手机号。把手机号的每个字符转成数字,然后两两交换。如果长度是奇数,最后补F。前面加0B91,其中0B是长度,91是国际格式。
第二步,处理内容。把每个中文字符的 Unicode 码点取出来,转成 4 位十六进制字符串,拼起来。内容长度是字符数乘以 2,转成 2 位十六进制。
第三步,拼 TPDU。11+00+ 号码段 +00+08+ 内容长度 + 内容段。
第四步,拼 SMSC。00+ TPDU。
第五步,算长度。PDU 串总字符数除以 2,再减去 1,就是AT+CMGS的参数。
我举个例子,手机号13800138000,内容“你好”。手机号处理:13 80 01 38 00 00-> 交换 ->31 08 10 83 00 F0,前面加0B91,得到0B913108108300F0。内容处理:“你”是4F60,“好”是597D,拼起来4F60597D,长度04。TPDU 是11+00+0B913108108300F0+00+08+04+4F60597D。完整 PDU 是00+11000B913108108300F00008044F60597D。长度:去掉开头00,剩下 38 个字符,除以 2 得 19。所以发AT+CMGS=19。
这个函数我封装成void pdu_encode(char *phone, char *content, char *pdu_out, int *len_out),在 STM32 上跑没问题,因为字符串不长,栈空间够用。
3.4 发送流程的代码实现
主循环里检测到key_pressed标志后,先清标志,OLED 显示“正在发送...”,然后进入状态机。状态机用switch-case实现,每个 case 里发对应的 AT 指令,然后等回复。
发AT的时候,我直接调HAL_UART_Transmit发字符串AT\r\n,然后等 500ms,检查缓冲区里有没有OK。如果有,进入下一步;如果没有,重发一次,再没有就报错。
发AT+CMGF=0和AT+CSCS="UCS2"类似,都是发指令等OK。
发AT+CMGS=19之后,等模组回>。这个>是提示符,不是OK。我在缓冲区里找>字符,找到之后发 PDU 串,最后发0x1A。
发完 PDU 之后,模组会回+CMGS: <mr>和OK,表示发送成功。如果回ERROR或者+CMS ERROR,就是失败。我在缓冲区里找+CMGS:和OK,找到就显示“发送成功”,否则显示“发送失败”。
这里有个细节:模组返回的数据可能分多次到达,所以缓冲区要够大,我设了 256 字节。解析的时候用strstr找关键字,不要用strcmp,因为返回的数据前后可能有其他字符。
3.5 OLED 显示刷新与状态同步
OLED 刷新我放在主循环里,每 100ms 刷一次。状态变量sms_state有四个值:STATE_IDLE、STATE_SENDING、STATE_SUCCESS、STATE_FAIL。根据这个变量决定显示什么文字。
显示“正在发送...”的时候,我加了一个小动画,每 500ms 切换一次“.”的数量,从“.”到“..”到“...”,这样用户知道系统没死机。这个动画用SysTick计数实现,不占用额外定时器。
显示“发送成功”之后,保持 3 秒,然后自动回到STATE_IDLE,OLED 显示“按按键发送短信”。显示“发送失败”之后,保持 5 秒,然后回到STATE_IDLE,同时允许用户再按一次重发。
OLED 的显存我用了 1024 字节的数组,OLED_Refresh的时候按页写入。I2C 速率设成 400kHz,刷一屏大概 20ms,不影响主循环。
4. 常见问题与排查技巧实录
4.1 模组不回复 AT 指令
这是最常见的问题。先查波特率,Air780E 默认 115200,但有些固件是 9600。如果波特率对,查串口线有没有接反,TX 接 RX,RX 接 TX。如果线也对,查供电,用万用表量模组 VCC 是不是 3.8V,发射瞬间会不会掉。我遇到过供电不足导致模组反复重启,串口一直输出启动日志,根本不进 AT 模式。换独立供电之后解决。
还有一个坑:Air780E 上电后需要几秒钟初始化,如果你上电就发AT,它可能还没准备好。我在代码里加了 5 秒延时,等模组稳定后再发AT。
4.2 中文短信发送失败
如果模组回ERROR,先检查AT+CMGF=0和AT+CSCS="UCS2"有没有设对。然后检查 PDU 串的长度计算,AT+CMGS的参数是不是 TPDU 长度。我踩过的坑是长度算成了整个 PDU 串的长度,结果模组一直回ERROR。后来用串口助手手动发,对比长度,才发现问题。
另一个坑是Ctrl+Z发成了字符串。在代码里,HAL_UART_Transmit发的是字节数组,0x1A是一个字节,不是两个字符1和A。如果你用printf发"0x1A",那就错了。
还有,短信中心号码如果设错,也会失败。我一般用00让模组用默认的,如果默认的不对,就手动设AT+CSCA="+8613800100500",这个号码各地不同,问运营商。
4.3 OLED 不显示或显示乱码
OLED 不显示,先查 I2C 地址。用HAL_I2C_IsDeviceReady扫一下,地址 0x78 或 0x7A。如果地址对但不显示,查初始化序列,SSD1306 的初始化命令不能少,特别是0xAE(关显示)、0xD5(时钟分频)、0xA8(复用率)、0x8D(电荷泵)这几个。电荷泵不打开,屏幕不亮。
显示乱码通常是取模方式不对。PCtoLCD2002 里选“阴码+逐列式+顺向”,如果选成“阳码”或者“逆向”,显示就是乱的。另外,汉字取模是 16x16,ASCII 是 8x16,显示的时候坐标要算对,不然会重叠。
4.4 按键误触发
按键误触发一般是消抖没做好。我在SysTick里每 10ms 扫一次,连续 3 次读到高电平才确认,这样 30ms 内的抖动都滤掉了。如果还误触发,加一个 100ms 的冷却时间,发完短信之后 100ms 内不响应按键。
还有一种情况是按键接的上拉或下拉不对。我用的下拉输入,按键接 3.3V,按下为高。如果你接 GND,就要配上拉输入,按下为低。配置错了,按键一直触发或者一直不触发。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模组不回 AT | 波特率不对 | 换 9600 试 | 改代码波特率 |
| 模组不回 AT | 供电不足 | 量 VCC 电压 | 独立 3.8V 供电 |
| 发中文失败 | PDU 长度错 | 手动算长度 | 用 TPDU 长度 |
| 发中文失败 | 字符集不对 | 查 CSCS | 设 UCS2 |
| OLED 不亮 | 电荷泵没开 | 查初始化 | 加 0x8D 命令 |
| OLED 乱码 | 取模方式错 | 查取模设置 | 阴码逐列顺向 |
| 按键误触发 | 消抖不够 | 加延时 | 30ms 消抖 |
| 发送后复位 | 电源跌落 | 示波器看 | 独立供电 |
4.6 实操心得与避坑技巧
第一个心得:PDU 编码不要手算,写个函数在 PC 上先验证。我用 Python 写了一个小脚本,输入手机号和内容,输出 PDU 串和长度,跟 STM32 上的函数对比,确保一致。这样调试的时候心里有底。
第二个心得:串口调试助手是你的好朋友。先用串口助手手动发 AT 指令,确认模组能发中文短信,再把流程搬到 STM32 上。这样能把硬件问题和代码问题分开。
第三个心得:OLED 显示状态的时候,不要显示太多文字,三行足够。第一行标题,第二行状态,第三行错误码。错误码用数字,比如E01表示模组无响应,E02表示 PDU 错误,E03表示发送失败。这样排查的时候一目了然。
第四个心得:Air780E 的固件版本不同,AT 指令集可能有差异。我遇到过一款固件不支持AT+CSMP的第四个参数,后来换成AT+CMGF=0加AT+CSCS="UCS2"就够了。所以如果指令报错,先查固件手册。
第五个心得:STM32 的串口接收中断里不要做太多事,只把数据存进缓冲区,解析放在主循环。中断里做strstr会阻塞太久,导致丢数据。
第六个心得:如果项目要长期运行,加一个看门狗。我在主循环里喂IWDG,防止模组死机导致程序卡死。模组死机的时候,STM32 可以复位模组,通过控制模组的 RESET 引脚。
这个项目我从画板子到调通,前前后后花了大概一周时间,其中 PDU 编码占了一半。现在回头看,最难的不是技术本身,而是把每个细节都抠清楚。短信发送成功那一刻,OLED 上跳出“发送成功”四个字,那种成就感还是挺实在的。如果你也在做类似的东西,希望这些经验能帮你少走点弯路。