STM32 从 0 到 1 做智能家居(十):串口与 printf 重定向

文章目录

    • 一、三个串口的分工:先划清边界
      • [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 最小验证:先让串口"能打印、能回显")

系列第十篇。前面九章,所有调试都靠"串口打印"这四个字撑着。可如果串口本身就是坏的,你连"程序跑到哪了"都不知道。

我第一次点灯失败,串口助手一片空白。查了两小时:不是代码,不是接线,是 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  停止  空闲
‾‾‾‾\____/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\__/‾‾\______
     ↑
   下降沿,接收方靠它对齐时钟

两个容易忽略的点:

  1. 空闲是高电平 。所以"没接线"和"接了个一直低电平的线"是两回事------前者收到的是噪声/无数据,后者会被当成连续不断的起始位 ,读出来全是 0x00。
  2. 低位先发 。这就是为什么 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: ...。

这不是每次都发生,所以特别难查------你按十次可能只失败一次,还以为是蓝牙信号问题。

三个缓解办法:

  1. 手机端连发 2~3 次 :本工程的命令全是幂等的(FAN_ON 发两次结果一样),重复发没有副作用;
  2. 把蓝牙接收也改成环形缓冲:中断里只存字节,主循环按行/按命令解析,彻底消除空窗期(推荐做法);
  3. 远程模式下降低传感器频率:本工程已经是 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 上了。

相关推荐
新晨单片机设计3 小时前
S033A-基于STM32单片机拼图游戏机【Proteus仿真+Keil程序+原理图】
stm32·单片机·嵌入式硬件·拼图游戏机
2501_922639584 小时前
率能半导体 SS6811H双通道H桥驱动芯片:38V/1.6A,覆盖舞台灯光、安防相机、办公自动化、游戏机、机器人五大应用场景
单片机·嵌入式硬件·硬件工程
吴建旭 智宅焕6 小时前
智能家居全国交付中内容生产与知识中枢的架构设计:RAG检索增强与交付确定性系统实现
人工智能·智能家居
吴建旭 智宅焕6 小时前
智能家居全国交付中售后规则执行层与客户关系层的分离架构设计
智能家居
comyccccc7 小时前
STM32F407VET6核心板的部分电路绘制学习笔记
笔记·stm32·学习
OIDCAT7 小时前
CH9433技术解析:一颗芯片搞定CAN总线和9线串口
单片机·嵌入式硬件·工控·调试工具·国产芯片
简单电磁炮7 小时前
关于can的调试
c语言·stm32·单片机
2501_922639587 小时前
率能半导体 SS6812T双通道H桥驱动芯片:32V/1.0A,覆盖POS打印机、安防相机、办公自动化、游戏机、机器人五大应用场景
单片机·嵌入式硬件·硬件工程
女神下凡7 小时前
芯参谋(77):裸机(无 OS)存储驱动开发指南
arm开发·单片机·嵌入式硬件·设计规范·1024程序员节