短信终端偶尔漏信?先理清 ESP32-C3 的 AT 接收链路
一台放在桌角的短信终端,平时安安静静,设备报警时亮屏、响一声。它不需要每天吸引你的注意,但那条真正有用的消息,最好别漏。
这类小设备容易把开发精力花在屏幕、外壳和菜单上。更隐蔽的问题却藏在串口里:程序正在等待一条 AT 指令回复,恰好又收到“有新短信”的通知,这行数据到底归谁?
下面以 ESP32-C3、ML307C 和小型 LCD 组成的短信终端为例,重新设计接收入口,给出一个可以编译、可以在电脑上验证解析逻辑的最小工程。它用于检查短信接收链路,不是完整寻呼机固件,也没有做实体模组收信测试。

图 1:现有整机的外观参考。照片中的菜单属于既有固件,不代表本文诊断程序的运行效果。
先把两条通道分开
从拆分照片看,结构并不复杂:屏幕在前,主板、电池和蜂窝通信模组在后,按键负责本地操作。这个形态适合独立消息提示、设备告警查看等场景;短信费用、网络覆盖及送达时延仍由 SIM 卡业务和运营商网络决定。

图 2:装配关系参考,保留电池、显示屏与主板的相对位置;未把拆分照片作为新固件的测试证据。
真正开始改软件前,需要先确认“调试串口”和“模组串口”是否是同一个东西。
工程中的主控是 ESP32-C3-WROOM-02。USB1 的 D−、D+ 接到 GPIO18、GPIO19,是主控原生 USB 通道。模组 AT 数据则使用 GPIO20、GPIO21 对应的 UART 网络。两者分开后,打印一句调试日志,就不会顺手把它发给蜂窝模组。

图 3:在 EDA 编辑器中重新导出,再裁成可阅读的局部。注意区分 GPIO 编号、模组焊盘编号和网络名。
| 用途 | 本文核对结果 |
|---|---|
| 模组接收通道 | C3 GPIO20 / RX,网络 MCU_RX,接模组 UART0_TXD |
| 模组发送通道 | C3 GPIO21 / TX,网络 MCU_TX,接模组 UART0_RXD |
| USB 日志与下载 | USB1,GPIO18 D−、GPIO19 D+ |
| 另一个 USB 接口 | USB2 接蜂窝模组 USB,不能当作主控下载口 |
| 既有 LCD 接线 | CLK GPIO4、MOSI GPIO6、CS GPIO7;诊断工程不驱动 LCD |
GPIO20/21 是程序填写的引脚号。不要把原理图上 C3 模组的 11、12 号焊盘,直接填进 HardwareSerial.begin()。

图 4:模组原理图局部。UART0 与 USB 是两组独立接口,不能仅凭接口旁的文字推断联网协议已经可用。
还有一个不能跳过的问题:图中 C3 与 ML307C 的 UART 网络直接相连,但本次没有取得能够确认该具体模组版本 UART 电气规格的完整硬件手册。因此,本文不认定这组直接连接已经满足电平要求,也不建议照图接裸模组上电。实物使用前应核对双方的输入输出电压、耐压与高低电平门限;需要时增加合适的电平转换。电脑上的解析器测试不依赖这项未决条件。
别让“串口丢消息”替电源背锅
工程里存在三条容易混淆的电源网络:电池管理部分输出 +5V,ME6217C33M5G 生成主控所用的 3.3V,JW5359 一路标为 +3.9V,供给 LTE 模组。

图 5:EDA 导出的供电局部。这里展示的是电路连接和标称网络,不能替代负载下的实测电压。
如果模组刚开始发射就重启,优先检查它的供电端电压,而不是先把串口超时从 5 秒改成 30 秒。后续实测应同时记录模组 VBAT 波形、复位现象和串口日志,查看异常是否发生在同一时刻。仅凭稳压芯片名称或平均电流,无法判断脉冲负载下是否稳定。

图 6:PCB 顶面,用于对照主控、SIM 卡座、模组和电源器件的布局。该图是现有工程视图,不是电气兼容性已经通过验证的新版板卡。

图 7:PCB 底面,结合顶面检查连线与回流路径。出板前仍需完成电平核验、规则检查及供电验证。
硬件方面,本文只完成连接关系核对与风险定位;真正落地并验证的改进在下面的软件接收层。
消息可能没丢,只是被另一个函数读走了
常见写法是:发送 AT 指令的函数自己读取串口,等待 OK;主循环里的短信函数也读取同一个串口,寻找 +CMTI。
问题在于,串口不是可以反复读取的邮箱。一个函数读走的字节,另一个函数不会再看到。
例如下面这段合成输入,表示查询信号强度期间收到新短信通知:
+CSQ: 20,99
+CMTI: "SM",7
OK
如果等待回复的函数把三行都收入自己的字符串,最后发现 OK 就返回,短信任务可能永远看不到中间那行。给主循环加快刷新、缩短屏幕延时,都不一定解决根因。
另一个容易忽略的坑,是用“字符串里含有 OK”判断命令成功。测试数据里的 BOOK、NOT_OK 也包含这两个字母,却不是合法的成功结果行。解析应该比较完整行,而不是搜索子串。
一个入口,两种去向
新的接收层遵守三条规则:所有模组字节只经过一个入口;先分离异步短信通知;同一时间只等待一个命令结果。

图 8:本文实现的软件结构。通知队列与命令事务各司其职,图中列出的未实现功能不包含在附件能力之内。
主循环负责持续取走字节,把分片拼成完整行:
// Modem 只承载 AT 数据;Serial 使用原生 USB 输出日志。
while (Modem.available()) {
atPipe.feed(static_cast<char>(Modem.read()));
}
atPipe.tick(millis());
完整行进入解析器后,先识别 +CMTI。一条 +CMTI: "SM",7 表示“SM 存储区的第 7 个槽位值得读取”,不是短信正文。这个通知进入队列,其他数据交给当前 AT 事务。
队列键必须包含存储区和索引:SM,7 与 ME,7 不是同一条记录。本例明确支持 SM、ME 两类通知,索引在语法层允许 0~65535;具体可用范围由模组决定,不能把索引 0 直接当成“无效”。
重复通知只对仍在队列中的记录去重,包括正在读取的队首。成功读取之前不弹出队首;读取失败就停止并留下诊断线索。它避免的是待处理队列重复,并没有实现跨重启的短信内容去重。
命令完成条件也收紧为明确的结果行:
if (!strcmp(line, "OK")) {
finish(Result::Ok);
return;
}
if (!strcmp(line, "ERROR") ||
!strncmp(line, "+CME ERROR:", 11) ||
!strncmp(line, "+CMS ERROR:", 11)) {
finish(Result::Error);
return;
}
这里还有一个不太讨喜、却有必要的选择:超时后停止继续发命令。否则上一条指令迟到的 OK,可能被下一条指令当成自己的成功回复。诊断工具宁可把失去同步暴露出来,也不制造一条看起来正常的日志。
从通知到读取,中间不能省掉存储区
附件采用 PDU 模式,初始化依次发送:
AT
ATE0
AT+CMGF=0
AT+CNMI=2,1,0,0,0
这些设置需要模组及其固件支持;任何一步返回错误,程序都会停止,实际使用前还应确认当前短信服务与存储配置。初始波特率设为 115200,不自动探测波特率,也不替代模组的电源键启动时序。
对于 SM,7,状态机先选择存储区,再读取消息:
AT+CPMS="SM"
AT+CMGR=7
只有收到成功结果、且回复中带有预期的 +CMGR: 头部,才确认队首。PDU 内容直接送到 USB 日志,便于继续检查解码层。
这一步的“成功”仅指诊断程序收到了预期读取响应,不等于短信已可靠送达某个业务系统。示例没有验证每个 PDU 字段,也没有完成中文解码、长短信拼接、屏幕显示、转发或删除。电脑未接收日志、日志输出受阻等情况,也不能当作完成了持久化保存。
为什么不顺便加一个“三秒内来自同一号码就拼接”的功能?因为它可能把两条独立短信拼在一起。完整长短信处理应依据 PDU 中的 UDH 信息,区分拼接引用号、总段数、段序号,并处理乱序、重复和过期段;不能拿到达时间替代协议字段。
先让电脑把边界条件跑一遍
硬件还没接上时,可以先验证接收逻辑。附件中的 tests/test_at_pipe.cpp 把构造的串口字节送入同一份解析器,而不是另写一个“看上去类似”的测试模型。
验证覆盖交错通知、分片到达、不同存储区的相同索引、待处理通知去重、模组错误,以及时间计数回绕后的超时。还专门检查了缓冲区与队列溢出,避免数据被截断后仍报告成功。

图 9:实际日志的排版记录,不是串口仪器截图。24 项断言通过,目标固件编译通过;未连接实体模组。
本次目标构建使用 PlatformIO 的 espressif32@6.9.0、Arduino-ESP32 2.0.17,板型配置为 esp32-c3-devkitc-02。构建报告的静态 RAM 占用为 17,020 字节,Flash 为 271,320 字节;这不代表实际运行时的峰值内存占用。
下载附件后,在工程目录执行:
pio run
# 确认硬件电平、供电与下载方式后再执行:
pio run -t upload
pio device monitor -b 115200
主机逻辑测试可以使用支持 C++11 的编译器:
c++ -std=c++11 -Wall -Wextra -Werror -I include \
tests/test_at_pipe.cpp -o test_at_pipe
./test_at_pipe
本次主机测试实际使用 Zig 0.16.0 的 zig c++。Windows 下可将输出命名为 test_at_pipe.exe;若工具链因过长路径报错,把工程与构建缓存移到较短路径后重试。
想长期运行,还差哪几步
队列容量为 16,单行缓冲区为 512 字节,命令响应缓冲区为 2048 字节。超过限制会显式报错,不能把“做了队列”理解成无限缓存。
同样,内存队列扛不住掉电。冷启动、通知异常或队列溢出后,需要核对模组里的全部存量短信,包括已读消息。只扫描未读消息可能漏掉“已经读取,但业务尚未保存就重启”的记录。本例会提示需要核对,并在相关异常时停止;自动扫描、持久化确认和恢复流程尚未实现。
下一轮真正值得投入的改进,是把“通知—读取—解析—业务保存—确认”连成可恢复的闭环,再加入真实模组的压力测试:连续收信、断电恢复、弱网重注册,以及收信时刷新屏幕。每项测试同时检查消息数量、内容与重复情况,不只看屏幕是否还亮着。
短信终端的难处,往往不是收到第一条消息。把所有字节交给同一个接收入口,再认真处理失败后的状态,才有机会让第几百条消息也走完整条链路。
创作来源与许可:立创开源广场 《EDA-Pager寻呼机》,EDA课程案例团队,项目成员 JasonYANG170;页面标示许可为 GPL 3.0,工程与素材核对日期为 2026-09-20。图 1~2 为该项目实物照片的裁剪、轻微对比度调整与锐化版本;图 3~7 来自对应工程在嘉立创 EDA 编辑器中的重新导出与局部排版,PCB 图另做亮度调整。上述引用素材保留其原有 GPL 3.0 许可,工程可从项目页面获取,不因本文的平台许可选项而改变。图 8 为本文绘制的流程图,图 9 根据本次实际验证日志制作。
本文重新组织技术分析,并独立编写 AT 接收诊断工程与测试,未复制项目正文;原工程与固件不包含在新代码附件中。本文新增文字与图 8~9 按 CC BY 4.0 分享,独立代码按附件 MIT 许可分享。软件验证范围仅限文中所述,不包含实体硬件、联网及收信效果验证。
技术交流,欢迎关注微信公众号:美男子玩编程。




