文章目录
-
- 一、三个串口的分工:先划清边界
-
- [1.1 为什么 USART1 用 115200、USART2 默认 9600](#1.1 为什么 USART1 用 115200、USART2 默认 9600)
- 二、串口的物理层:几根线、什么电平
-
- [2.1 TTL 串口长什么样](#2.1 TTL 串口长什么样)
- [2.2 接线:必须交叉,必须共地](#2.2 接线:必须交叉,必须共地)
- [2.3 电平选择:3.3V 还是 5V](#2.3 电平选择:3.3V 还是 5V)
- 三、波特率:一个除法和一点误差
-
- [3.1 公式](#3.1 公式)
- [3.2 本工程两个串口的实际误差](#3.2 本工程两个串口的实际误差)
- [3.3 那为什么还会乱码](#3.3 那为什么还会乱码)
- [3.4 一个自检小技巧](#3.4 一个自检小技巧)
- [四、CubeMX 里的串口参数](#四、CubeMX 里的串口参数)
- [五、printf 重定向:三行代码 + 一个勾](#五、printf 重定向:三行代码 + 一个勾)
-
- [5.1 核心只有三行](#5.1 核心只有三行)
- [5.2 必须勾 Use MicroLIB](#5.2 必须勾 Use MicroLIB)
- [5.3 为什么等 TXE,不等 TC](#5.3 为什么等 TXE,不等 TC)
- [5.4 printf 到底有多慢:算一笔账](#5.4 printf 到底有多慢:算一笔账)
- [5.5 格式符与换行的几个坑](#5.5 格式符与换行的几个坑)
- [5.6 最隐蔽的死机:USART 时钟没开](#5.6 最隐蔽的死机:USART 时钟没开)
- [六、接收:`HAL_UART_Receive_IT` 是"收满就停"](#六、接收:
HAL_UART_Receive_IT是"收满就停") -
- [6.1 第二个参数是"这次收几个",不是"缓冲区多大"](#6.1 第二个参数是"这次收几个",不是"缓冲区多大")
- [6.2 收满之后,中断就被 HAL 关掉了](#6.2 收满之后,中断就被 HAL 关掉了)
- [6.3 本工程的两种重挂方式](#6.3 本工程的两种重挂方式)
- [6.4 主循环重挂的代价:那 4ms 会丢字节](#6.4 主循环重挂的代价:那 4ms 会丢字节)
- [6.5 关于 USART1 上那个 IDLE 中断](#6.5 关于 USART1 上那个 IDLE 中断)
- [七、为什么不能用阻塞接收 AT 应答](#七、为什么不能用阻塞接收 AT 应答)
-
- [7.1 坑一:收满 64 字节就返回,不管收的是什么](#7.1 坑一:收满 64 字节就返回,不管收的是什么)
- [7.2 坑二:只要 `RxState` 不是 READY,直接返回 `HAL_BUSY`](#7.2 坑二:只要
RxState不是 READY,直接返回HAL_BUSY) - [7.3 坑三:ORE 溢出会让接收彻底失灵](#7.3 坑三:ORE 溢出会让接收彻底失灵)
- [7.4 本工程的两种替代方案](#7.4 本工程的两种替代方案)
- 八、环形缓冲:中断和主循环的"传送带"
-
- [8.1 为什么需要它](#8.1 为什么需要它)
- [8.2 三个函数就够](#8.2 三个函数就够)
- [8.3 留一个空位的设计](#8.3 留一个空位的设计)
- [8.4 为什么不用关中断](#8.4 为什么不用关中断)
- [8.5 `volatile` 一个都不能少](#8.5
volatile一个都不能少) - [8.6 满了怎么办:丢新的,不覆盖旧的](#8.6 满了怎么办:丢新的,不覆盖旧的)
- [8.7 512 是 2 的幂,取模会被优化](#8.7 512 是 2 的幂,取模会被优化)
- 九、按行解析:把字节流变成"一句话"
-
- [9.1 为什么按行](#9.1 为什么按行)
- [9.2 代码](#9.2 代码)
- [9.3 没有 `while(1)`](#9.3 没有
while(1))
- 十、串口调不通:按这个顺序查
-
- [10.1 完全没输出(串口助手空白)](#10.1 完全没输出(串口助手空白))
- [10.2 有输出但是乱码](#10.2 有输出但是乱码)
- [10.3 能收但"只收一次"](#10.3 能收但"只收一次")
- 十一、本章易错点总结
- 十二、小结与下一篇
- [附录 A:本章涉及的完整代码](#附录 A:本章涉及的完整代码)
-
- [A.1 串口初始化参数(`usart.c` 关键行)](#A.1 串口初始化参数(
usart.c关键行)) - [A.2 printf 重定向](#A.2 printf 重定向)
- [A.3 接收中断回调(完整版)](#A.3 接收中断回调(完整版))
- [A.4 蓝牙命令的拼接与重挂(主循环)](#A.4 蓝牙命令的拼接与重挂(主循环))
- [A.5 环形缓冲(`mqtt.c`)](#A.5 环形缓冲(
mqtt.c)) - [A.6 按行解析](#A.6 按行解析)
- [A.7 最小验证:先让串口"能打印、能回显"](#A.7 最小验证:先让串口"能打印、能回显")
- [A.1 串口初始化参数(`usart.c` 关键行)](#A.1 串口初始化参数(
系列第十篇。前面九章,所有调试都靠"串口打印"这四个字撑着。可如果串口本身就是坏的,你连"程序跑到哪了"都不知道。
我第一次点灯失败,串口助手一片空白。查了两小时:不是代码,不是接线,是 Keil 里没勾 Use MicroLIB 。后来调蓝牙又踩一次------HC-06 出厂默认 9600 ,我按 115200 收,屏幕上全是
???,我还以为模块烧了。
一、三个串口的分工:先划清边界
本工程用了三个 USART,各管一摊,绝对不能混:
| 串口 | 引脚 | 总线 | 波特率 | 用途 | 会不会改 |
|---|---|---|---|---|---|
| USART1 | PA9 / PA10 | APB2(100MHz) | 115200 固定 | 电脑调试日志,所有 printf |
从不改 |
| USART2 | PA2 / PA3 | APB1(50MHz) | 9600 ↔ 115200 运行时切 | 蓝牙 HC-06 / ESP-12F | 按 KEY4 切 |
| USART6 | PC6 / PC7 | APB2 | 115200 | 语音模块(预留,未启用) | 不用 |
一条铁律:
调试信息永远只走 USART1。永远不要把
printf指向 USART2。
原因很直接:USART2 上跑的是协议 ------蓝牙命令、ESP 的 AT 指令。你把日志打到 USART2,ESP 会把 [MODE] Manual... 当成一条 AT 指令去解析,回你一个 ERROR;更糟的是,ESP 的应答也会混进你的日志里,两边互相污染,最后谁也看不懂谁。
我见过最典型的翻车现场:为了"省一个 USB 转 TTL",把 printf 重定向到蓝牙串口。结果手机 APP 上收到一堆 [ENV] Temp:27...,而真正的温度 JSON 被顶掉了。分工不是建议,是底线。
1.1 为什么 USART1 用 115200、USART2 默认 9600
不是随便定的:
- USART1 只连电脑,USB 转 TTL 支持 115200 毫无压力,日志刷得快,不占用主循环时间;
- USART2 默认接 HC-06,而 HC-06 出厂波特率就是 9600。你写 115200 它不认,双向都不通;
- 后来 USART2 要接 ESP-12F,ESP-AT 默认 115200,所以才有了"运行时切换"这套机制(第 12 章细讲)。
二、串口的物理层:几根线、什么电平
2.1 TTL 串口长什么样
STM32 引出来的不是电脑那种 ±12V 的 RS-232,而是 TTL 电平:
- 空闲:高电平(3.3V)
- 起始位:低电平(一个位时间)
- 8 个数据位:低位先发(LSB first)
- 校验位:本工程不用
- 停止位:高电平(一个位时间)
一个字符合计 1 起始 + 8 数据 + 1 停止 = 10 位:
text
空闲 起始 D0 D1 D2 D3 D4 D5 D6 D7 停止 空闲
‾‾‾‾\____/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\______
↑
下降沿,接收方靠它对齐时钟
两个容易忽略的点:
- 空闲是高电平 。所以"没接线"和"接了个一直低电平的线"是两回事------前者收到的是噪声/无数据,后者会被当成连续不断的起始位 ,读出来全是
0x00。 - 低位先发 。这就是为什么
0x31(字符'1')在示波器上是1000 1100(反着看)。
2.2 接线:必须交叉,必须共地
text
STM32 USB 转 TTL
PA9 (USART1_TX) ──────▶ RXD
PA10 (USART1_RX) ◀────── TXD
GND ─────────────────── GND ← 这根最容易忘
三条规矩:
- 交叉:A 的 TX 接 B 的 RX。接成"TX 接 TX"是最常见的错误,现象是"发得出去、收不到",或者干脆两边都没反应;
- 共地 :只接 TX/RX 不接 GND,电平没有共同参考点,收到的是随机字节。90% 的"收到乱码"或"什么都没有"都是这根地线;
- 不要接 VCC:USB 转 TTL 的 3.3V/5V 不要往板子上接,除非你明确知道自己在做反向供电。两边各自供电最安全。
2.3 电平选择:3.3V 还是 5V
很多 USB 转 TTL 模块上有个 3.3V/5V 跳线:
- STM32 的 IO 是 3.3V,优先选 3.3V;
- 选 5V 时,多数 F4 的 IO 是 5V 容忍(FT 引脚),短时间能用,但长期不是好习惯;
- 反过来的情况更危险:3.3V 的模块去驱动 5V 单片机的 RX,高电平可能识别不了。
蓝牙模块同理:HC-06 的 VCC 一般接 5V (模块自带 LDO),但它的 TX/RX 是 3.3V 电平 ,直接接 STM32 没问题。别把"模块供电 5V"和"信号电平 5V"混为一谈。
三、波特率:一个除法和一点误差
3.1 公式
text
USARTDIV = 外设总线时钟 ÷ (16 × 波特率) // 16 倍过采样(OVER8=0)
BRR = (整数部分 << 4) | 小数部分
实际波特率 = 总线时钟 ÷ (16 × USARTDIV)
HAL 库会帮你算 BRR,你只需要在 CubeMX 里填波特率。但算一遍能让你在乱码时知道该怀疑谁。
3.2 本工程两个串口的实际误差
USART1(APB2 = 100MHz,115200):
text
USARTDIV = 100,000,000 ÷ (16 × 115200) = 54.2535
整数 54,小数 0.2535 × 16 ≈ 4
实际 = 100MHz ÷ (16 × 54.25) = 115207.4
误差 = +0.0064%
USART2(APB1 = 50MHz,9600):
text
USARTDIV = 50,000,000 ÷ (16 × 9600) = 325.5208
整数 325,小数 0.5208 × 16 ≈ 8
实际 = 50MHz ÷ (16 × 325.5) = 9600.6
误差 = +0.0064%
USART2(APB1 = 50MHz,115200):
text
USARTDIV = 50,000,000 ÷ (16 × 115200) = 27.1267
整数 27,小数 0.1267 × 16 ≈ 2
实际 = 50MHz ÷ (16 × 27.125) = 115207.4
误差 = +0.0064%
三个结果误差都是万分之零点几,远低于可用阈值(一般经验:累计误差 < 2% 就很稳,> 5% 必乱)。
3.3 那为什么还会乱码
波特率误差不是唯一原因,乱码要按这个顺序怀疑:
| 顺序 | 原因 | 特征 |
|---|---|---|
| 1 | 两边波特率不一致 | 全屏 ??? 或方块,一个可读字符都没有 |
| 2 | 时钟配置不对 | 所有波特率都偏同一比例;HSE_VALUE 与实际晶振不符(25M 写成 8M)必崩 |
| 3 | 没共地 | 时好时坏,手一碰就变 |
| 4 | 数据位/校验位不一致 | 大部分字符对、偶尔错,或者高位总丢 |
| 5 | 电平不匹配 | 高温/低温表现不同,接上拉才好 |
| 6 | 线太长/杜邦线虚接 | 抖动,动一下线就变 |
第 2 条最阴:代码一个字没错,但 SystemClock_Config() 里 PLL 参数和实际晶振对不上,所有串口一起偏 。本工程是 HSE 25MHz → PLL M=25/N=400/P=4 → 100MHz,APB1 二分频 50MHz、APB2 一分频 100MHz。你要是换成 8MHz 晶振的板子,必须改 HSE_VALUE 和 PLLM,否则连 printf 都是乱的。
3.4 一个自检小技巧
不确定是哪边的问题时,把两边都设成 9600:
- 9600 对时钟误差的容忍度最高(位数长,采样点更宽松);
- 如果 9600 能通、115200 不通,基本可以断定是时钟偏了 或线太差,而不是"程序写错"。
反过来,如果 9600 也不通,问题一定在接线、共地或电平,别再折腾代码。
四、CubeMX 里的串口参数
三个串口的配置几乎一样,就波特率不同:
| 参数 | 取值 | 说明 |
|---|---|---|
| Baud Rate | 115200 / 9600 | 见上表 |
| Word Length | 8 Bits | 数据位 8 |
| Parity | None | 无校验 |
| Stop Bits | 1 | 1 位停止 |
| Mode | Asynchronous | 异步(不是同步/单线) |
| Hardware Flow Control | None | 不用 RTS/CTS |
| Over Sampling | 16 | 默认,别改成 8 |
引脚复用:
| 串口 | 引脚 | AF |
|---|---|---|
| USART1 | PA9 / PA10 | AF7 |
| USART2 | PA2 / PA3 | AF7 |
| USART6 | PC6 / PC7 | AF8 |
NVIC 优先级(数字越小优先级越高):
| 中断 | 抢占优先级 | 说明 |
|---|---|---|
| EXTI1/2/3/15_10(按键) | 1 | 最高,按键要最先响应 |
| USART2 | 2 | 蓝牙/ESP 数据不能丢 |
| USART1 | 3 | 调试口,优先级最低也无所谓 |
| SysTick | 15(默认) | 最低,所以中断里不能用 HAL_Delay(第 9 章) |
注意 USART6 的 AF 是 AF8 不是 AF7。虽然本工程没启用,但如果你照着 USART1 抄过去,会得到一个"配置全对但没有波形"的怪现象。
五、printf 重定向:三行代码 + 一个勾
5.1 核心只有三行
c
int fputc(int ch, FILE *f)
{
while (!(USART1->SR & (1 << 7))) {} // 等 TXE = 1(发送数据寄存器空)
USART1->DR = (uint8_t)ch; // 写进去,硬件自动发
return ch;
}
printf 内部会一个字符一个字符地调用 fputc,所以只要 fputc 把字符送进 USART1,printf 就"重定向"成功了。
5.2 必须勾 Use MicroLIB
Keil 里:Options for Target → Target → 勾选 Use MicroLIB。
不勾会怎样:
- 标准 C 库的
printf走的是"半主机(semihosting)"路径,需要调试器配合,脱机运行一个字符都出不来; - 有些工程会在
fputc里死循环(因为库函数根本没调用它,你看到的卡死是半主机调用挂住了)。
替代方案是加一串 #pragma import(__use_no_semihosting) 和 _sys_exit 之类的桩函数------麻烦,而且没必要。本工程统一用 MicroLIB。
勾了 MicroLIB 还有两个副作用要知道:
- 代码体积更小(好事);
- 某些标准库函数行为略有差异(比如
printf的浮点支持、malloc实现)。本工程日志全用整数,不受影响。
5.3 为什么等 TXE,不等 TC
USART 状态寄存器 SR 里有两个相邻的标志:
| 位 | 名称 | 含义 |
|---|---|---|
| bit7 | TXE | 发送数据寄存器空------可以写下一个字节了 |
| bit6 | TC | 发送完成------最后一个字节已经完全移出移位寄存器 |
区别:
text
写 DR ──▶ [发送数据寄存器] ──▶ [移位寄存器] ──▶ 引脚
↑ TXE=1 表示这里空了,可以再写
↑ TC=1 表示这里也空了,整个字节真的发完了
- 等 TXE:写进去就能走,硬件自己排队,连续发送效率最高;
- 等 TC :每个字节都要等它彻底发完(115200 下约 87µs),打印速度直接砍半,而且没有任何收益。
只有一种情况需要等 TC:发送完要立刻切换 RS485 方向,或者要进低功耗。普通调试打印用 TXE 就对了。
5.4 printf 到底有多慢:算一笔账
115200、8N1,一个字节 10 位:
text
1 字节 = 10 / 115200 ≈ 86.8µs
| 一行日志长度 | 阻塞时间 |
|---|---|
| 10 字符 | 0.87ms |
| 40 字符 | 3.5ms |
| 80 字符 | 6.9ms |
| 160 字符 | 13.9ms |
本工程最长的一行是 Print_Env(),大约 80 字符 → 7ms。每秒打印一次,占用 0.7% 的时间,完全没问题。
但如果你在下面这些地方写 printf,就会出事:
- 中断里(第 9 章):7ms 的中断执行时间,按键、串口全被拖住;
while(1)里不加延时:日志刷屏,主循环一圈 7ms,按键响应明显发木;- DHT11 读取前后:打印的阻塞会挤占时序窗口。
所以本工程的做法是:日志只在"事件发生"时打印,绝不在循环里无条件打印。
5.5 格式符与换行的几个坑
① %d 打 uint32_t 会出问题
c
printf("%d\r\n", HAL_GetTick()); // ⚠️ 有符号
printf("%lu\r\n", (unsigned long)HAL_GetTick()); // ✅ 正确
HAL_GetTick() 返回 uint32_t,用 %d 会被当成有符号数解释,超过 2^31 后打印出负数。养成习惯:无符号 32 位用 %lu 并强制转换。
② \r\n 还是 \n
本工程统一用 \r\n(回车 + 换行)。如果你在串口助手里又勾了"发送新行/自动换行",会出现每行之间多一个空行------不是代码问题,是助手加了一层。
③ 中文乱码
Keil 在中文 Windows 下默认用 GB2312 保存源文件,而串口助手多半按 UTF-8 解码,于是:
c
printf("温度:%d\r\n", t); // 串口助手里显示 "温度ï¼"
两个办法:把助手编码切成 GB2312,或者日志全部用英文 。本工程所有日志都是英文,就是为了避开这个坑------调试信息不需要优雅,需要谁都能看懂。
④ 浮点
printf("%f") 在 MicroLIB 下能用,但会显著增加代码体积。本工程的温湿度是"整数 + 小数"两个字节,用 %d.%d 拼出来即可,不引入浮点。
5.6 最隐蔽的死机:USART 时钟没开
fputc 里的 while 等的是 TXE。如果 USART1 的外设时钟没使能:
SR寄存器读出来是 0;TXE永远不置位;while永远不退出。
现象:程序一上电就"死"在第一条 printf,连 main 里的灯都不闪。
CubeMX 生成的 HAL_UART_MspInit() 里有这一行:
c
__HAL_RCC_USART1_CLK_ENABLE();
手动写串口驱动时最容易漏的就是它。 同样的坑还有 GPIOA 时钟没开------引脚复用配了也无效。
排查口诀:"printf 卡死查时钟,printf 空白查 MicroLIB。"
六、接收:HAL_UART_Receive_IT 是"收满就停"
6.1 第二个参数是"这次收几个",不是"缓冲区多大"
c
uint8_t BUF[1024] = {0};
HAL_UART_Receive_IT(&huart1, BUF, 2); // 只收 2 个字节
这行代码的含义是:"开中断,收满 2 个字节后叫我一次" ,然后就不再收了。
BUF 有 1024 字节,但这次只用到前 2 个。这是新手最容易误解的参数 :很多人以为 1024 才是"缓冲区大小",写 2 是笔误。实际上它决定的是"这一次接收任务要收多少字节"。
本工程 USART1 的接收就是占位(调试口只发不收),所以只收 2 个字节,收到后置 uartlog = 1。注意:这个标志全工程没有消费者,属于历史遗留。如果你想让调试口真正接收电脑发来的命令,必须自己补上处理和重挂(见 6.2)。
6.2 收满之后,中断就被 HAL 关掉了
HAL_UART_Receive_IT 的完整生命周期:
text
调用 → RxState = BUSY_RX,打开 RXNEIE/PEIE/EIE
│
├── 每来 1 字节 → 中断 → 存进缓冲 → 计数 -1
│
└── 收满 Size 个 → 关闭 RXNEIE/PEIE/EIE → 调用 HAL_UART_RxCpltCallback → RxState = READY
关键就在"关闭中断"这一步 :回调只调用这一次,之后这个串口再也收不到数据 ,直到你重新调用 HAL_UART_Receive_IT。
现象非常典型:
第一次发命令,设备有反应;第二次发,没反应了。
因为第一次收满后中断就停了,后面所有字节全丢。
6.3 本工程的两种重挂方式
① MQTT/ESP:在中断回调里立刻重挂
c
else if(huart->Instance == USART2)
{
if(g_mode == MODE_REMOTE && g_net == NET_MQTT)
{
Esp_Uart_RxByte(esp_rx_byte); // 交给环形缓冲
HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1); // 立刻重挂
}
else
{
uartlog = 2; // 蓝牙:只置标志,主循环处理后再重挂
}
}
② 蓝牙:在主循环里重挂
c
if(uartlog == 2)
{
uartlog = 0;
// 用 bt_buf[0] 拼命令、执行......
HAL_UART_Receive_IT(&huart2, bt_buf, 1); // 处理完再重挂
}
为什么两种不一样?
| 通道 | 数据特征 | 重挂位置 | 理由 |
|---|---|---|---|
| MQTT | ESP 一口气回几十字节,连续不断 | 中断里 | 晚一步就丢字节,必须马上重挂 |
| 蓝牙 | 人一次发一条命令,字节间有间隔 | 主循环 | 主循环要用 bt_buf[0],不能在中断里被覆盖 |
这个设计取舍值得记住:"连续数据流"要在中断里重挂,"人工单条命令"可以在主循环重挂。 判据是------数据会不会在你处理期间继续涌进来。
6.4 主循环重挂的代价:那 4ms 会丢字节
蓝牙在主循环重挂,意味着"收到一个字节 → 等主循环处理 → 再开接收"之间存在空窗期 。9600 波特率下一个字节 1.04ms,主循环通常几十微秒就处理完,理论上没问题。
但本工程有一个"合法的堵点":DHT11 读取时 __disable_irq() 关了约 4ms 中断。
c
__disable_irq(); // 保护 40 位微秒级时序
for(i = 0; i < 5; i++) buf[i] = ReadByte();
__enable_irq();
如果手机正好在这 4ms 里发命令,USART2 的中断进不来:
- 9600 下,4ms ≈ 3~4 个字节被硬件覆盖/丢失;
- 命令变成
CURT_OFF这种残缺串,strstr匹配不到; - 设备什么都不做,只打印一行
[CMD] unknown: ...。
这不是每次都发生,所以特别难查------你按十次可能只失败一次,还以为是蓝牙信号问题。
三个缓解办法:
- 手机端连发 2~3 次 :本工程的命令全是幂等的(
FAN_ON发两次结果一样),重复发没有副作用; - 把蓝牙接收也改成环形缓冲:中断里只存字节,主循环按行/按命令解析,彻底消除空窗期(推荐做法);
- 远程模式下降低传感器频率:本工程已经是 2 秒一次,再降会影响上报实时性,不划算。
排查线索:串口日志里出现
[CMD] unknown,先别怀疑命令写错,先看它是不是"缺了几个字母"。 残缺串就是丢字节的铁证。
6.5 关于 USART1 上那个 IDLE 中断
初始化里还有一行:
c
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
IDLE 是"总线空闲"事件:一帧数据发完、线路回到高电平后触发,常用来做不定长接收(不知道对方会发几个字节)。
但这里有个细节:本工程用的是标准接收模式(HAL_UART_Receive_IT),不是 HAL_UARTEx_ReceiveToIdle_IT 。在 F4 HAL 里,只有后一种模式才会自动清 IDLE 标志并回调;手动打开 IDLEIE 而不处理标志,可能出现"IDLE 中断反复进入"。
由于 USART1 在本工程里只做输出、电脑基本不往板子发数据,这个隐患没有暴露。如果你要抄这段去做"串口不定长收包",请改成:
c
HAL_UARTEx_ReceiveToIdle_IT(&huart1, buf, sizeof(buf)); // 推荐
或者在自己的 USART1_IRQHandler 里手动清:
c
if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
{
__HAL_UART_CLEAR_IDLEFLAG(&huart1); // 内部是"读 SR 再读 DR"
}
"读了不用"的代码,往往是最危险的代码------它平时不响,一旦你开始用就炸。
七、为什么不能用阻塞接收 AT 应答
HAL_UART_Receive(&huart2, buf, 64, 1000) 看起来很好用:收 64 字节或等 1 秒。但本工程在 MQTT 初始化时明确弃用了它,因为三个坑都踩过:
7.1 坑一:收满 64 字节就返回,不管收的是什么
ESP 连 WiFi 时会先刷一串事件:
text
WIFI DISCONNECT
+MQTTDISCONNECTED
WIFI CONNECTED
WIFI GOT IP
ATE0
OK
这些加起来轻松超过 64 字节。函数在 64 字节处直接返回 ,而真正的 OK 还在后面------你拿着半截数据去判断"成功没成功",得到的是误判失败。
7.2 坑二:只要 RxState 不是 READY,直接返回 HAL_BUSY
HAL 的串口是单任务状态机:一个接收没结束,不允许再开第二个。
如果你在别处已经调用过 HAL_UART_Receive_IT,RxState 就是 BUSY_RX,此时再调 HAL_UART_Receive 会立刻返回 HAL_BUSY,一个字节都不收 。现象是 recv 永远是空的,你还以为是接线问题。
7.3 坑三:ORE 溢出会让接收彻底失灵
ORE(OverRun Error)表示"上一个字节还没被读走,下一个又来了"。一旦 ORE 置位,HAL 会中止接收并置错误码,后续数据全部收不到,直到你手动清标志(读 SR 再读 DR)。
连续数据流 + 慢速处理,ORE 几乎必然发生。
7.4 本工程的两种替代方案
| 阶段 | 方案 | 做法 |
|---|---|---|
| 初始化(要等 AT 应答) | Esp_Rx_Poll() |
直接读 USART2->SR / USART2->DR 寄存器,绕开 HAL 状态机 |
| 运行(订阅消息随时来) | 中断 + 环形缓冲 | 中断只存字节,主循环按行解析 |
Esp_Rx_Poll() 的核心就一句:
c
if(USART2->SR & (1 << 5)) // RXNE = 1,收到一个字节
{
uint8_t b = (uint8_t)USART2->DR; // 读 DR 同时清 RXNE
}
读 SR 再读 DR 这个动作,本身就是清标志的标准姿势 ------HAL 里所有 __HAL_UART_CLEAR_* 宏干的都是这件事。
八、环形缓冲:中断和主循环的"传送带"
8.1 为什么需要它
中断来得又快又碎(一字节一次),主循环处理得慢(要攒成一行、还要解析)。两边速度不匹配,就需要一个缓冲区做缓冲。
环形缓冲就是一根首尾相接的传送带:
text
tail(主循环取) head(中断放)
↓ ↓
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ │ d │ a │ t │ a │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑
└──── 已用区(待处理)───────┘
走到末尾就回到开头,所以叫"环形"
8.2 三个函数就够
c
#define ESP_RXBUF_SIZE 512
static uint8_t esp_rxbuf[ESP_RXBUF_SIZE];
static volatile uint16_t esp_rx_head = 0; // 写指针:只由中断改
static volatile uint16_t esp_rx_tail = 0; // 读指针:只由主循环改
// 中断里调用:存一个字节
void Esp_Uart_RxByte(uint8_t b)
{
uint16_t next = (esp_rx_head + 1) % ESP_RXBUF_SIZE;
if(next == esp_rx_tail) return; // 满了:丢弃这个字节
esp_rxbuf[esp_rx_head] = b;
esp_rx_head = next;
}
// 主循环调用:取一个字节,返回 1=取到,0=空
static uint8_t Esp_Rx_GetByte(char *pc)
{
if(esp_rx_head == esp_rx_tail) return 0; // 头尾相等 = 空
*pc = (char)esp_rxbuf[esp_rx_tail];
esp_rx_tail = (esp_rx_tail + 1) % ESP_RXBUF_SIZE;
return 1;
}
8.3 留一个空位的设计
为什么"满"的判断是 next == tail,而不是"head 追上 tail"?
因为头尾相等必须留给"空" 。如果不留空位,head == tail 到底表示"空"还是"满"就说不清。留一个空位后:
head == tail→ 空;(head + 1) % 512 == tail→ 满。
所以 512 字节的数组,实际能存 511 字节。这 1 字节的代价换来一个无歧义的状态判断,非常值。
8.4 为什么不用关中断
关键在于分工:
head只被中断写,主循环只读它;tail只被主循环写,中断只读它。
两边操作的是不同变量 ,不存在"同时改同一个值"的竞争。而 uint16_t 在 Cortex-M4 上是单条指令的原子访问,不会读到"改了一半"的值。
所以:
c
// 主循环判断有没有数据
if(esp_rx_head != esp_rx_tail) // 读 head(中断可能正在改)
最坏情况是"中断刚放进一个字节,主循环这一圈没看到,下一圈看到了"------晚一圈,但不会错。对串口这种场景完全够用。
什么时候必须关中断? 如果你用了"计数器 + 数组"而不是 head/tail 双指针,或者缓冲区索引是 32 位且非原子,那就要加临界区。设计得好,就不需要。
8.5 volatile 一个都不能少
esp_rx_head 和 esp_rx_tail 都加了 volatile,原因和第 9 章的 g_mode 一样:
- 编译器看到主循环里
while(Esp_Rx_GetByte(&c)),会认为head在循环里"没被修改过",把它缓存进寄存器; - 中断改了内存里的
head,寄存器里的副本还是旧值; - 结果:主循环永远读不到新数据,串口看起来"死了"。
volatile 告诉编译器:"这个变量随时可能被外部改,每次都去内存里读。"
判断标准:跨中断/主循环共享的变量,一律
volatile。 不要凭感觉,凭规则。
8.6 满了怎么办:丢新的,不覆盖旧的
c
if(next == esp_rx_tail) return; // 满 → 丢弃这个新字节
为什么丢新的而不是覆盖旧的?
- 覆盖旧的会破坏已经收到的一半数据,解析出更乱的串;
- 丢新的至少保证"已收到的部分是连续的",可能还能用。
缓冲区 512 字节、ESP 一行通常几十字节,正常情况下永远不会满。如果真满了,说明主循环被别的东西堵住了 ------去查是不是又写了 HAL_Delay 或死循环。
8.7 512 是 2 的幂,取模会被优化
c
esp_rx_head = (esp_rx_head + 1) % 512;
编译器看到除数是 2 的幂,会自动优化成位与:
c
esp_rx_head = (esp_rx_head + 1) & 511;
这也是选 512 而不是 500 的原因------不仅好算,还快。缓冲区大小尽量取 2 的幂,是个便宜的好习惯。
九、按行解析:把字节流变成"一句话"
9.1 为什么按行
ESP-AT 的所有应答都是文本行 ,以 \r\n 结尾:
text
+MQTTSUBRECV:0,"2604/cmd-HH",10,FAN_ON
OK
按 \n 切行,每一行就是一条完整信息,比"按字节数切"可靠得多。
9.2 代码
c
static char line[256] = {0};
void Mqtt_Poll(void)
{
char c;
static uint16_t len = 0;
while(Esp_Rx_GetByte(&c)) // 有多少取多少,取完就退出
{
if(c == '\n') // 一行结束
{
line[len] = '\0';
Esp_Handle_Line(line); // 处理这一行
len = 0;
}
else if(c != '\r') // 回车丢掉,只留内容
{
if(len < sizeof(line) - 1)
line[len++] = c;
else
len = 0; // 行太长(乱码/异常)→ 丢弃重来
}
}
}
三个细节:
\r要丢掉 :不丢的话,行尾会带一个回车符,strcmp/strstr匹配会莫名其妙失败;- 行太长要重置 :乱码时可能一直等不到
\n,缓冲会溢出。len = 0直接丢弃,防止越界; - 留一个字节给
'\0':sizeof(line) - 1就是这个意思。
9.3 没有 while(1)
Mqtt_Poll() 的 while 条件是 Esp_Rx_GetByte(&c)------取完当前已有数据就返回,不会等新数据。
这一点极其重要:如果写成 while(1) { 等一个字节; 处理; },主循环就永远出不来,按键、传感器、上报全部停摆。
记住这个模式:"处理完手头的就返回",让主循环有机会干别的。所有"轮询"函数都应该长这样。
十、串口调不通:按这个顺序查
10.1 完全没输出(串口助手空白)
| 顺序 | 检查项 | 怎么确认 |
|---|---|---|
| 1 | 波特率是否一致 | 两边都设 115200,再试 9600 |
| 2 | TX/RX 是否交叉 | TTL 的 RXD 接 PA9、TXD 接 PA10 |
| 3 | GND 是否连上 | 万用表通断档,必查 |
| 4 | 选了对的 COM 口 | 设备管理器里拔插看哪个消失 |
| 5 | Keil 勾了 MicroLIB 吗 | Options → Target |
| 6 | fputc 写的是 USART1 吗 |
打印到底走哪个口 |
| 7 | USART1 时钟使能了吗 | MspInit 里的 __HAL_RCC_USART1_CLK_ENABLE() |
| 8 | PA9 是不是配成 AF7 | MspInit 里的 GPIO 配置 |
| 9 | 程序真的跑到 printf 了吗 | 在 printf 前翻转 LED |
10.2 有输出但是乱码
| 现象 | 原因 |
|---|---|
全屏 ??? / 方块 |
波特率不一致(最常见:HC-06 默认 9600) |
| 大部分对、偶尔错 | 数据位/校验位不一致,或线太长 |
| 所有串口一起乱 | 系统时钟配错(HSE_VALUE/PLL) |
| 中文变乱码 | 源文件 GB2312 vs 助手 UTF-8 |
| 手碰线就变 | 没共地 / 杜邦线虚接 |
| 收到自己发的内容 | TX/RX 短接,或模块回显(ESP 用 ATE0 关回显) |
10.3 能收但"只收一次"
| 现象 | 原因 |
|---|---|
| 第一次命令有效,之后全无 | 回调里没重新 HAL_UART_Receive_IT |
命令偶尔不生效,日志有 [CMD] unknown |
丢字节(DHT11 关中断窗口,见 6.4) |
收到的都是 0x00 |
RX 被拉死 / 没接 / 电平不对 |
| 一进中断就出不来 | IDLE 标志没清(见 6.5) |
十一、本章易错点总结
| 现象 | 真正原因 | 处理 |
|---|---|---|
printf 一个字符都没有 |
没勾 Use MicroLIB | Options → Target → 勾选 |
程序卡在第一条 printf |
USART 时钟没使能,TXE 永远不置位 | 检查 __HAL_RCC_USARTx_CLK_ENABLE() |
| 打印特别慢 | fputc 里等的是 TC 不是 TXE |
改成 SR & (1<<7) |
HAL_GetTick() 打印成负数 |
用了 %d 打 uint32_t |
用 %lu + 强制转换 |
| 日志中文乱码 | 源文件 GB2312,助手按 UTF-8 解 | 改编码,或日志全英文 |
| 每行之间多一个空行 | 代码已发 \r\n,助手又加了一次 |
助手关掉"自动换行" |
| 只收到第一个字节 | 收满后 HAL 关了中断,没重挂 | 回调/处理后重新 Receive_IT |
以为 BUF[1024] 能收 1024 字节 |
第二个参数是"本次收几个" | Receive_IT(&h, buf, 2) 只收 2 个 |
调用 Receive_IT 返回 HAL_BUSY |
上一路接收还没结束(RxState 非 READY) |
先 HAL_UART_AbortReceive_IT 再重开 |
串口助手全 ??? |
两边波特率不一致(HC-06 默认 9600) | 统一波特率,先试 9600 |
| 所有串口一起乱码 | 系统时钟配错 | 核对 HSE_VALUE 与 PLL 参数 |
| 收到的字节时对时错 | 没共地 / 杜邦线虚接 | 接 GND,换线 |
读出来全是 0x00 |
RX 被拉低或没接 | 量空闲电平应为 3.3V |
| 蓝牙命令偶尔没反应 | DHT11 关中断 4ms,9600 下丢 3~4 字节 | 命令重发 2 次,或改环形缓冲 |
日志里出现 [CMD] unknown 且内容残缺 |
丢字节导致命令不完整 | 同上;残缺串就是证据 |
| 手动开了 IDLE 中断后反复进中断 | 标准接收模式不自动清 IDLE 标志 | 改用 HAL_UARTEx_ReceiveToIdle_IT 或手动清 |
中断里做字符串解析/printf |
阻塞太久,丢字节丢按键 | 中断只存字节,解析放主循环 |
环形缓冲变量没加 volatile |
编译器把 head 缓存了 | 共享变量一律 volatile |
| 环形缓冲"满了还覆盖" | 满时覆盖旧数据,解析出乱串 | 满时丢弃新字节 |
把 printf 指到 USART2 |
日志被 ESP 当成 AT 指令 | 调试只走 USART1 |
十二、小结与下一篇
这一章记住五件事:
- 三个串口分工固定:USART1 只打印,USART2 跑协议,绝不混用;
fputc+ MicroLIB:等 TXE 不等 TC,日志要短、要少、全英文;Receive_IT收满即停:第二个参数是"收几个",收完必须重挂;- 连续流用环形缓冲 :head/tail 分工 +
volatile,满时丢新不覆盖; - 按行解析、处理完就返回:绝不在轮询函数里写死循环。
下一篇进入蓝牙远程控制(HC-06):为什么它出厂就是 9600、AT 模式怎么进、手机 APP 发的命令怎么被拼成完整字符串、以及"蓝牙连上了但设备不动"到底是哪几层出了问题。
如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。
附录 A:本章涉及的完整代码
A.1 串口初始化参数(usart.c 关键行)
c
/* USART1:调试口,APB2=100MHz,115200 */
huart1.Instance = USART1;
huart1.Init.BaudRate = 115200;
huart1.Init.WordLength = UART_WORDLENGTH_8B;
huart1.Init.StopBits = UART_STOPBITS_1;
huart1.Init.Parity = UART_PARITY_NONE;
huart1.Init.Mode = UART_MODE_TX_RX;
huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
huart1.Init.OverSampling = UART_OVERSAMPLING_16;
/* USART2:蓝牙/ESP,APB1=50MHz,默认 9600 */
huart2.Init.BaudRate = 9600;
/* 其余同上 */
/* MspInit 里必须有的三件事:时钟、引脚 AF、NVIC */
__HAL_RCC_USART1_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_9 | GPIO_PIN_10;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF7_USART1;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
HAL_NVIC_SetPriority(USART1_IRQn, 3, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);
A.2 printf 重定向
c
/* printf字符输出重定向到USART1 (Keil需勾选 Options->Target->Use MicroLIB) */
int fputc(int ch, FILE *f)
{
while (!(USART1->SR & (1 << 7))) {} // 等 TXE=1(发送数据寄存器空)
USART1->DR = (uint8_t)ch; // 写入,硬件自动发送
return ch;
}
A.3 接收中断回调(完整版)
c
uint8_t BUF[1024] = {0}; // USART1 调试口接收缓冲(本工程只收 2 字节)
volatile uint8_t uartlog = 0; // 1=USART1 收到数据;2=USART2(蓝牙)收到数据
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart->Instance == USART1)
{
uartlog = 1; // 调试口收到数据(注意:本工程没有消费者,属于占位)
}
// USART2:蓝牙和 ESP-12F 分时复用,按当前协议分发
else if(huart->Instance == USART2)
{
if(g_mode == MODE_REMOTE && g_net == NET_MQTT)
{
// MQTT:这 1 字节属于 ESP 的 AT 应答/订阅消息,交给环形缓冲
Esp_Uart_RxByte(esp_rx_byte);
HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1); // 立刻重挂(连续流不能等)
}
else
{
uartlog = 2; // 蓝牙:只置标志,主循环拼命令后自己重挂
}
}
}
A.4 蓝牙命令的拼接与重挂(主循环)
c
if(uartlog == 2)
{
uartlog = 0;
// 逐字节拼进命令缓冲,凑成一条完整命令
uint8_t clen = strlen((char*)bt_cmd_buf);
if(clen < sizeof(bt_cmd_buf) - 1)
{
bt_cmd_buf[clen] = bt_buf[0];
bt_cmd_buf[clen + 1] = '\0';
}
else
{
bt_cmd_buf[0] = '\0'; // 攒满还没匹配上 → 乱码,清空重收
}
// 命令到了就执行(蓝牙和 MQTT 共用同一套命令字符串)
Remote_Cmd_Exec((char*)bt_cmd_buf, 1);
bt_cmd_buf[0] = '\0';
// 重新开 USART2 接收(HAL 收满 1 字节后会自动停)
HAL_UART_Receive_IT(&huart2, bt_buf, 1);
}
A.5 环形缓冲(mqtt.c)
c
#define ESP_RXBUF_SIZE 512
static uint8_t esp_rxbuf[ESP_RXBUF_SIZE];
static volatile uint16_t esp_rx_head = 0; // 写指针(中断改动)
static volatile uint16_t esp_rx_tail = 0; // 读指针(主循环改动)
/* 中断里调用:写一个字节,满了就丢 */
void Esp_Uart_RxByte(uint8_t b)
{
uint16_t next = (esp_rx_head + 1) % ESP_RXBUF_SIZE;
if(next == esp_rx_tail) return; // 满:丢弃新字节
esp_rxbuf[esp_rx_head] = b;
esp_rx_head = next;
}
/* 主循环调用:取一个字节,1=成功,0=空 */
static uint8_t Esp_Rx_GetByte(char *pc)
{
if(esp_rx_head == esp_rx_tail) return 0; // 头尾相等 = 空
*pc = (char)esp_rxbuf[esp_rx_tail];
esp_rx_tail = (esp_rx_tail + 1) % ESP_RXBUF_SIZE;
return 1;
}
A.6 按行解析
c
// 注意:这里没有 while(1),每次调用只处理"当前已有的数据"就返回
void Mqtt_Poll(void)
{
char c;
static char line[256] = {0};
static uint16_t len = 0;
while(Esp_Rx_GetByte(&c))
{
if(c == '\n') // 一行结束
{
line[len] = '\0';
Esp_Handle_Line(line); // 交给行处理函数
len = 0;
}
else if(c != '\r') // 丢掉回车
{
if(len < sizeof(line) - 1) line[len++] = c;
else len = 0; // 行太长 → 丢弃重来
}
}
}
A.7 最小验证:先让串口"能打印、能回显"
第一步:只验证发送
c
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART1_UART_Init();
while(1)
{
printf("hello %lu\r\n", (unsigned long)HAL_GetTick());
HAL_Delay(1000);
}
}
预期:串口助手每 1 秒收到一行 hello 1234。
- 没输出 → 查 MicroLIB、COM 口、TX 接线、GND;
- 乱码 → 查波特率、时钟。
第二步:验证接收(回显)
c
uint8_t rx;
HAL_UART_Receive_IT(&huart1, &rx, 1);
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart->Instance == USART1)
{
printf("recv: %02X\r\n", rx); // 打印收到的字节
HAL_UART_Receive_IT(&huart1, &rx, 1); // 重挂!
}
}
预期:助手发 A(0x41),收到 recv: 41;发多少收多少。
- 只收到第一个 → 忘了重挂;
- 收到的值不对 → 波特率/校验/接线。
第三步:验证环形缓冲
把第二步的回调改成 Esp_Uart_RxByte(rx); HAL_UART_Receive_IT(...),主循环里 while(Esp_Rx_GetByte(&c)) printf("%c", c);。快速发一长串字符,应完整回显不乱序。
三步都过,这一章的串口骨架就可以直接搬到蓝牙和 ESP-AT 上了。