STM32 可移植教程 03:USART 串口通信------让开发板能"对话"(实战篇)
第一篇你点亮了 LED,第二篇你用状态机管了两个按键。但有一件事从第一篇到现在一直没解决:你怎么知道程序里发生了什么?
想看看按键状态机当前在哪个状态?想知道长按事件有没有触发?想看看 s_mode 切到哪个值了?------你只能靠一颗 LED 猜。LED 亮、灭、闪、快闪......信息量不超过 3 个 bit。
第二篇调试按键状态机的时候,你是不是经历过这种时刻:按了一下按键,LED 没反应------但到底是按键没检测到?还是事件没触发?还是模式切换逻辑写错了?LED 回答不了这些问题。你只能加断点,停下来,看变量,再跑------调一个按键逻辑可能要反复下载十几次。
真正高效的调试,是让开发板自己把信息发出来。 发到你的电脑上,你想看什么变量就打印什么变量,想看什么状态就打印什么状态。
这就是本篇要做的事:把 printf 输出重定向到串口,让 STM32 能对你说话。
如果你还没看前两篇:本篇依赖第一篇搭建的 VSCode 环境 + LED 驱动(
app_led.h/c),以及第二篇的按键状态机(app_key.h/c)。如果前两篇还没跑通,建议先回去做完------环境有了、按键能控制 LED 了,这第三篇才能顺利往下走。
本篇目标
最终现象:
text
PC 串口助手实时显示:
"Hello, count = 1, LED mode = 0"
"[KEY1] short press -> switch to mode 1"
"[KEY2] short press -> fast blink 100ms (mode 1)"
"[KEY1] long press -> back to mode 0"
...
同时在串口助手敲入命令:
敲 "mode 2" → 板子切到 SOS 模式
敲 "led on" → LED 立即亮
敲 "status" → 板子汇报当前状态
按键控制 + 串口命令,两个通道各自独立,同时生效。
跑通标准:
text
串口助手收到开机打印信息
每秒打印一次计数器和当前模式
按下 KEY1/KEY2,串口实时显示事件信息
在串口助手敲 "mode 1" → 板子切到快闪模式
在串口助手敲 "status" → 板子汇报当前状态
按键和串口命令可以交叉使用,互不冲突
编译无 error
全程 HAL_GetTick 非阻塞,无 HAL_Delay
准备工作
| 项目 | 说明 |
|---|---|
| 上一篇工程 | 已完成第二篇 02_key_input,按键状态机 + 双按键模式切换正常工作 |
| USB 转串口模块 | CH340、CP2102 或 FT232,本篇开始必备,后面每一篇都会用到 |
| 杜邦线 | 母对母,至少 3 根(TX、RX、GND) |
| 串口助手 | SSCOM、PuTTY、MobaXterm、或 VSCode 的 Serial Monitor 插件均可 |
| 第三篇工程 | 建议复制 02_key_input 改名为 03_usart_printf |
USB 转串口模块长什么样?
这是一个拇指大小的小板子,通常蓝色或紫色 PCB。一头是 USB 口(插电脑),另一头是一排 4~6 个排针,典型丝印看下面这张图就够了。
![淘宝搜"CH340 模块 USB 转 TTL",几块钱一个。买的时候选 3.3V/5V 兼容的------STM32 的 GPIO 是 3.3V 电平,用 5V 电平的模块可能会烧引脚。
注意:你开发板上的 ST-Link 是 SWD 协议,用来下载程序和调试的。它不走 UART 协议,不能当串口用。串口通信需要专门的 USB 转串口芯片(CH340/CP2102/FT232)。除非你用的是 Nucleo 板(板载 ST-Link 集成了 VCP 虚拟串口),否则必须外接这个模块。
找到 CH340 的 COM 口
模块插到电脑上之后,打开 Windows 设备管理器,展开"端口 (COM 和 LPT)"------你会看到一个类似 USB-SERIAL CH340 (COM5) 的设备。记住这个 COM 号,后面打开串口助手时要选它。
如果设备管理器里出现黄色感叹号,说明驱动没装。去搜"CH340 驱动"下载安装(wch.cn 官网有),装完重新插拔 USB 就行了。
认识 USART:板子和电脑怎么"说话"
在写代码之前,你需要理解三个概念:什么是串行通信、为什么双方要"约好"一个速度、以及那两根线(TX/RX)到底怎么接。
串行 vs 并行
计算机内部的数据总线是并行的------8 位、16 位、32 位数据同时传输。但你要是用 8 根线 + 1 根地线把 STM32 和电脑连起来------又占引脚又怕干扰,不现实。
串行通信 就是把一个字节打散成 8 个 bit,排好队,一个一个地从一根线上发过去。收的那一端再把这 8 个 bit 拼回一个字节。
![STM32 内部的 USART 外设就是专门做这件事的------把并行字节转成串行 bit 流发出去(TX),把收到的串行 bit 流转回并行字节(RX)。
波特率:两个人必须用同一个节奏说话
串行通信没有额外的时钟线------接收方不知道发送方什么时候开始发数据。所以双方必须提前约好一个速度。
这个速度就叫波特率(Baud Rate),单位是 bps(bits per second,每秒多少位)。最常见的三个值是:
| 波特率 | 常见用途 |
|---|---|
| 9600 | 比较慢,很多老设备或长线调试会用 |
| 115200 | 最常用,本篇就用这个 |
| 921600 | 较高速,适合短距离、接线可靠的场景 |
![你必须保证 CubeMX 配的波特率 = 串口助手选的波特率。 差一个数字就全是乱码。这是串口调试中最常见的问题------收到一堆 ???? 或看不懂的字符,99% 是波特率没对上。 |
数据帧:8N1
除了速度,双方还要约定一个字节怎么"打包"。最常见的格式叫 8N1 :
![- 8 = 8 个数据位(一字节)
- N = No parity(无校验位------不加额外的错误检测位)
- 1 = 1 个停止位
一个字节实际传输 1+8+1 = 10 个 bit。115200 / 10 ≈ 11520 字节/秒,大约 11KB/s。打印几行文字绰绰有余。
USART 和 UART 有区别吗?
USART 多了一个 S(Synchronous),能做同步通信(多一根时钟线)。但实际使用中几乎 100% 用的都是异步模式(Asynchronous),此时 USART = UART,两个词可以混用。
TX/RX 交叉:最容易接错的线
USART 通信只需要两根数据线 + 一根地线:
| STM32 引脚 | 方向 | USB 转串口模块引脚 |
|---|---|---|
| PA9(TX)--- 发送 | → | RXD--- 接收 |
| PA10(RX)--- 接收 | ← | TXD--- 发送 |
| GND | ↔ | GND |
TX 接 RX、RX 接 TX------交叉连接。 你的嘴(TX)对对方的耳朵(RX),反过来也一样。TX 接 TX、RX 接 RX 是不通的。
还有一项必不可少:GND 必须连通。 两边没有共同的参考地,信号电平就像漂在海上的船------接收方不知道哪里是"高"哪里是"低",看到的全是乱码。
![## 硬件连接详解
接线实操
拿出来你的 USB 转串口模块,一般能看到 4~6 个引脚排针。你需要接的只有三根:
- 模块
RXD接 STM32PA9 / USART1_TX - 模块
TXD接 STM32PA10 / USART1_RX - 模块
GND接 STM32GND
模块上的丝印是站在模块的角度写的。 模块的 TXD 意思是"模块从这里往外发数据",所以要接到 STM32 的 RX(接收引脚)。模块的 RXD 意思是"模块从这里收数据",所以要接到 STM32 的 TX(发送引脚)。
![### 一个快速验证接线的方法
不确定接对了没?用串口助手发数据,把模块的 TX 和 RX 短接(用一根杜邦线直接连起来)。如果串口助手发出去的数据立刻原样显示回来(这叫"回环测试"),说明模块本身是好的,驱动也装对了。
![### 电平匹配:3.3V 还是 5V?
STM32 的 GPIO 是 3.3V 电平。USB 转串口模块通常支持 3.3V 和 5V 两种电平,通过跳线或短路焊盘选择。确认你的模块跳线在 3.3V 一侧。 如果模块只有 5V 档------STM32 的很多引脚是 5V 耐受的(标注 FT 的引脚,PA9 和 PA10 都是),但长期 5V 工作不推荐。
CubeMX 配置步骤
1. 复制工程
把 02_key_input 整个文件夹复制一份,重命名为 03_usart_printf。删掉 build 目录(如果存在的话),用 VSCode 打开新目录。
2. 打开 .ioc 并添加 USART1
双击 03_usart_printf.ioc。
左侧 Connectivity → USART1,Mode 选择 Asynchronous。右侧参数面板会出现默认配置:
| 参数 | 值 | 含义 |
|---|---|---|
| Mode | Asynchronous | 异步模式 |
| Baud Rate | 115200 Bits/s | 波特率 |
| Word Length | 8 Bits | 一个数据包 8 位 |
| Parity | None | 无校验 |
| Stop Bits | 1 | 1 个停止位 |
这就是 8N1,不需要改动任何默认值。
配置完成后,CubeMX 芯片引脚图上 PA9 和 PA10 会被标记为 USART1_TX 和 USART1_RX。
![### 3. 确认 KEY1、KEY2、LED 引脚都在
总共用到的引脚:
| 引脚 | User Label | 功能 | 来源 |
|---|---|---|---|
| PB5 | LED | GPIO_Output | 第一篇 |
| PA0 | KEY1 | GPIO_Input, Pull-up | 第二篇 |
| PC13 | KEY2 | GPIO_Input, Pull-up | 第二篇 |
| PA9 | --- | USART1_TX | 本篇新增 |
| PA10 | --- | USART1_RX | 本篇新增(用于接收命令) |
4. 生成代码
Project Manager → Toolchain = Makefile → GENERATE CODE。
生成后打开 Core/Src/main.c,你会看到 CubeMX 自动增加了 MX_USART1_UART_Init() 函数(约 20 行),里面按你刚才填的参数初始化了 huart1。
Core/Inc/main.h 顶部也多了一行:
c
extern UART_HandleTypeDef huart1;
这个 huart1 就是 USART1 的"句柄"(handle)------一个包含了 USART1 所有配置和状态的结构体。后面所有串口操作都需要传这个 &huart1。
5. 打开 USART1 接收中断
串口发送不需要中断(在 fputc 里直接调用阻塞发送就行),但串口接收需要中断------你不知道 PC 什么时候发数据过来,不能傻等。
在 .ioc 里继续配置:
- Connectivity → USART1 → NVIC Settings 标签页
- 勾选 USART1 global interrupt → Enabled
![勾选后点 GENERATE CODE 重新生成。这次Core/Src/stm32f1xx_it.c里会出现:
c
void USART1_IRQHandler(void)
{
HAL_UART_IRQHandler(&huart1);
}
这一行是 CubeMX 自动生成的------USART1 的任何中断(发送完成、接收完成、错误等)都会先进 USART1_IRQHandler,然后 HAL 库根据中断类型分发到对应的回调函数。我们只关心"接收完成"回调,后面会在 app_uart.c 里实现它。
完整代码:app_uart
新建两个文件:
Core/Inc/app_uart.hCore/Src/app_uart.c
在 Makefile 的 C_SOURCES 里加入 Core/Src/app_uart.c \。
app_uart.h
c
#ifndef APP_UART_H
#define APP_UART_H
#include "main.h"
#include <stdint.h>
void App_UART_Init(void);
void App_UART_Print(const char *text);
void App_UART_PrintBytes(const uint8_t *data, uint16_t len);
/** 串口接收到的命令 */
typedef enum {
APP_UART_CMD_NONE = 0,
APP_UART_CMD_MODE_0, /* "mode 0" */
APP_UART_CMD_MODE_1, /* "mode 1" */
APP_UART_CMD_MODE_2, /* "mode 2" */
APP_UART_CMD_LED_ON, /* "led on" */
APP_UART_CMD_LED_OFF, /* "led off" */
APP_UART_CMD_STATUS, /* "status" */
APP_UART_CMD_UNKNOWN, /* 不认识 */
} App_UART_Cmd;
/** 启动串口接收中断(初始化后调用一次) */
void App_UART_StartRX(void);
/** 主循环调用:检查是否有新命令。无命令返回 APP_UART_CMD_NONE */
App_UART_Cmd App_UART_Poll(void);
#endif
前三个是发送函数:
App_UART_Init()--- 初始化串口模块App_UART_Print(text)--- 发送一个 C 字符串。内部调用strlen算出长度,然后用HAL_UART_Transmit一次性发送整个字符串App_UART_PrintBytes(data, len)--- 发送指定长度的原始字节数据
后三个是接收相关:
App_UART_Cmd--- 命令枚举,和App_KeyEvent一样的思路:把输入事件抽象成一个枚举值App_UART_StartRX()--- 启动中断接收。本质是调用HAL_UART_Receive_IT开始监听第一个字节App_UART_Poll()--- 主循环里调用,有命令就返回,没命令就返回APP_UART_CMD_NONE。和App_Key_GetEvent()是同一个设计模式------读者应该已经很熟悉了
app_uart.c
c
#include "app_uart.h"
#include <stdio.h>
#include <string.h>
/*
* 默认使用 USART1。
* 如果你的串口是 USART2 或 USART3:
* 在编译选项里加 -DAPP_UART_HANDLE=huart2
* 或者在 Include 之前 #define APP_UART_HANDLE huart2
*/
#ifndef APP_UART_HANDLE
#define APP_UART_HANDLE huart1
#endif
extern UART_HandleTypeDef APP_UART_HANDLE;
void App_UART_Init(void)
{
/* CubeMX 已经初始化了 huart1,这里暂时为空 */
}
void App_UART_Print(const char *text)
{
if (text == NULL) return;
HAL_UART_Transmit(&APP_UART_HANDLE,
(uint8_t *)text,
(uint16_t)strlen(text),
HAL_MAX_DELAY);
}
void App_UART_PrintBytes(const uint8_t *data, uint16_t len)
{
if (data == NULL || len == 0) return;
HAL_UART_Transmit(&APP_UART_HANDLE,
(uint8_t *)data,
len,
HAL_MAX_DELAY);
}
/*
* ============================================================
* printf 重定向:本篇最核心的一个函数
* ============================================================
*
* 你在 main.c 里写 printf("Hello\n"),C 标准库做两件事:
* 1. 格式化:把 "Hello\n" 加上你的参数,生成最终字符串
* 2. 输出:把字符串的每个字符依次交给 fputc() 输出
*
* 在 PC 上,fputc() 写到屏幕/文件。
* 在 STM32 上,我们重写 fputc(),让每个字符通过 USART 发出去。
* 结果:printf 的全部输出就到了串口助手。
*
* ── 弱符号(weak symbol)机制 ──
*
* C 标准库(newlib)里已经定义了一个 fputc,但加了 __weak 属性:
*
* __attribute__((weak)) int fputc(int ch, FILE *f) { return ch; }
*
* 这是一个"弱符号"------链接器在链接时,如果找到了另一个同名的
* "强符号"(没有 __weak 的),就用强符号覆盖弱符号。
*
* 你在下面定义的 fputc 就是强符号,所以最终 printf 调用的
* 是你的版本------字符全进了 USART。
*/
int fputc(int ch, FILE *f)
{
uint8_t data = (uint8_t)ch;
/*
* 串口经典坑:\n 只换行不回车。
* \n (LF, ASCII 10) = 光标下移一行
* \r (CR, ASCII 13) = 光标回到行首
*
* 串口助手收到 \n 只换行,不回到第一列:
* Hello
* World
*
* 加上 \r 才是真正的换行:
* Hello
* World
*
* 所以 fputc 在遇到 \n 时自动在前面补一个 \r。
*/
if (ch == '\n') {
uint8_t cr = '\r';
HAL_UART_Transmit(&APP_UART_HANDLE, &cr, 1u, HAL_MAX_DELAY);
}
HAL_UART_Transmit(&APP_UART_HANDLE, &data, 1u, HAL_MAX_DELAY);
return ch;
}
这段代码你应该逐行理解的东西:
-
#ifndef APP_UART_HANDLE--- 和第一篇的APP_LED_ON_LEVEL、第二篇的APP_KEY_ACTIVE_LEVEL一样。默认用 USART1,换板子时可以覆盖为 USART2 或 USART3。可移植的习惯从第一篇贯穿到第三篇。 -
HAL_UART_Transmit的三个参数 ---&APP_UART_HANDLE(用哪个串口)、数据指针、数据长度、HAL_MAX_DELAY(超时时间)。HAL_MAX_DELAY的意思是"一直等,等到全部发完为止",大约 0xFFFFFFFF 毫秒(≈49 天),实际上等于无限等待。 -
弱符号覆盖 --- 你不需要修改 C 标准库的任何代码,只需要在自己的
.c文件里写一个同名函数,链接器自动选你的版本。这是 C 语言里最优雅的"注入"机制之一。![ ,4.
\n→\r\n自动转换 --- 代码里正常写\n,串口上正常显示换行。每次手动写\r\n太容易忘,放在fputc里一劳永逸。![### 串口接收:中断 + 行缓冲(追加在 app_uart.c 末尾)
上面完成了"板子说话"。下面的代码让"板子听话"。
思路很简单:
- 用一个行缓冲区
rx_line[32]攒字符 - HAL 的 RX 中断每收到一个字节就调用
HAL_UART_RxCpltCallback - 在回调里把字节写入
rx_line,遇到\r或\n就解析整行命令 - 解析结果存成
App_UART_Cmd,主循环通过App_UART_Poll()取走
和
App_Key_GetEvent()的相似之处: 按键状态机在中断/轮询里检测到按键事件后存起来,主循环调用App_Key_GetEvent()取走。串口接收完全一样------中断里收到完整命令行后存起来,主循环调用App_UART_Poll()取走。事件产生和事件消费分开------这是贯穿第二篇到第三篇的核心设计模式。![```c
/* ── 串口接收:中断 + 行缓冲 ── */
#include <stdbool.h>
static char rx_line32;
static uint8_t rx_idx = 0;
static uint8_t rx_byte; /* 中断接收的单个字节 */
static bool rx_cmd_ready = false;
static App_UART_Cmd rx_cmd = APP_UART_CMD_NONE;
/* 解析一行命令。支持的命令见前面 App_UART_Cmd 枚举 */
static App_UART_Cmd parse_line(const char *line)
{
if (strcmp(line, "mode 0") == 0) return APP_UART_CMD_MODE_0;
else if (strcmp(line, "mode 1") == 0) return APP_UART_CMD_MODE_1;
else if (strcmp(line, "mode 2") == 0) return APP_UART_CMD_MODE_2;
else if (strcmp(line, "led on") == 0) return APP_UART_CMD_LED_ON;
else if (strcmp(line, "led off") == 0) return APP_UART_CMD_LED_OFF;
else if (strcmp(line, "status") == 0) return APP_UART_CMD_STATUS;
else return APP_UART_CMD_UNKNOWN;
}
void App_UART_StartRX(void)
{
HAL_UART_Receive_IT(&APP_UART_HANDLE, &rx_byte, 1);
}
App_UART_Cmd App_UART_Poll(void)
{
if (rx_cmd_ready) {
rx_cmd_ready = false; /* 取走即消费 */
return rx_cmd;
}
return APP_UART_CMD_NONE;
}
/* ── HAL 回调:USART 收到一个字节时由 HAL 自动调用 ── */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart != &APP_UART_HANDLE) return;
/*
* \r (回车, ASCII 13) 或 \n (换行, ASCII 10) 表示一行结束。
* 两种都处理------串口助手可能发 \r\n,也可能只发 \n。
*/
if (rx_byte == '\r' || rx_byte == '\n') {
if (rx_idx > 0) {
rx_line[rx_idx] = '\0';
rx_cmd = parse_line(rx_line);
rx_cmd_ready = true;
rx_idx = 0;
}
} else if (rx_idx < sizeof(rx_line) - 1) {
rx_line[rx_idx++] = (char)rx_byte;
}
/* 重新使能接收------每次处理完都要再次调用,否则只收一个字节就停了 */
HAL_UART_Receive_IT(&APP_UART_HANDLE, &rx_byte, 1);
}
**逐行理解:**
1. **`rx_line[32]`** --- 行缓冲区。为什么 32 字节?"mode 0" 才 6 个字符,32 绰绰有余。命令协议简单,不需要大缓冲区。
2. **`parse_line()`** --- `strcmp` 逐条匹配。命令不认识就返回 `APP_UART_CMD_UNKNOWN`,主循环会打印 "unknown command" 提示用户。
3. **`HAL_UART_Receive_IT(&APP_UART_HANDLE, &rx_byte, 1)`** --- 这行在两个地方出现:
- `App_UART_StartRX()` 里初次调用------启动接收
- 回调末尾再次调用------"重新武装"中断,准备收下一个字节
如果不重新调用,收完一个字节后 USART 就不会再触发中断了。**这是一个常见的坑。**
4. **`rx_cmd_ready` 标志** --- 中断只负责"攒够一行 → 解析 → 置标志",不做任何耗时操作。**中断回调里绝对不能调用 `printf`**(`printf` 内部会调用阻塞的 `HAL_UART_Transmit`,在中断上下文里阻塞是致命的)。所有的 printf 响应都在主循环里完成。
5. **`HAL_UART_RxCpltCallback` 的参数 `huart`** --- 如果你同时用了多个串口(比如 USART1 + USART2),所有串口的接收完成中断都会进这个函数。所以第一步就要 `if (huart != &APP_UART_HANDLE) return;` 过滤。
### 一个常见的疑问
**`HAL_MAX_DELAY` 会不会让程序卡住?**
会。`HAL_UART_Transmit` 是阻塞的------它要等到 USART 硬件把数据全部移出 TX 引脚之后才返回。在 115200 波特率下,发一个字节大约需要 87 微秒,一行 `printf("Hello, count = 12345\r\n")` 大约 40 个字符 × 87μs ≈ 3.5ms。
3.5ms 听起来不多,但在这期间 CPU 不能干任何别的事。如果你的主循环每圈都 printf 好几行,按键状态机的 Tick 间隔会被拉长到十几毫秒甚至几十毫秒,消抖窗口会受影响。
**现阶段怎么办:** 用 `HAL_GetTick()` 控制 printf 的频率(比如每秒只打一行),这样阻塞时间平摊到 1000ms 里就不算什么了。工程延申里会给出更彻底的解决方案。
## main.c 调用方式
本篇 main.c 是在第二篇双按键 + LED 模式切换器的基础上,给每个按键事件加上 printf 输出,再每秒打印一次计数器。
### 1. 包含头文件(USER CODE BEGIN Includes)
```c
/* USER CODE BEGIN Includes */
#include "app_led.h"
#include "app_key.h"
#include "app_uart.h"
#include <stdio.h>
/* USER CODE END Includes */
2. 变量定义(USER CODE BEGIN PV)
c
/* USER CODE BEGIN PV */
static App_Key s_key1;
static App_Key s_key2;
typedef enum {
MODE_SWITCH = 0,
MODE_FAST,
MODE_SOS,
MODE_COUNT
} LedMode;
static LedMode s_mode = MODE_SWITCH;
static bool s_continuous_mode = false;
static uint32_t s_blink_tick = 0;
static uint8_t s_sos_step = 0;
static uint32_t s_sos_tick = 0;
/* 定时打印(非阻塞) */
static uint32_t s_print_tick = 0;
static uint32_t s_print_count = 0;
/* USER CODE END PV */
3. 初始化(USER CODE BEGIN 2)
c
/* USER CODE BEGIN 2 */
App_LED_Init();
App_Key_Init(&s_key1, KEY1_GPIO_Port, KEY1_Pin, APP_KEY_ACTIVE_LEVEL);
App_Key_Init(&s_key2, KEY2_GPIO_Port, KEY2_Pin, APP_KEY_ACTIVE_LEVEL);
App_UART_Init();
App_UART_StartRX(); /* 启动接收中断------开始监听 PC 命令 */
/* 开机打印------验证串口一通就正常工作 */
printf("\r\n");
printf("================================\r\n");
printf(" STM32 USART Printf Demo\r\n");
printf(" KEY1: mode switch\r\n");
printf(" KEY2: action\r\n");
printf(" Commands: mode 0/1/2 | led on/off | status\r\n");
printf("================================\r\n\r\n");
/* USER CODE END 2 */
4. while 循环
把第二篇的按键事件配上 printf,再加一个每秒打印一次的计数器。
c
/* USER CODE BEGIN WHILE */
while (1)
{
App_Key_Tick(&s_key1);
App_Key_Tick(&s_key2);
/* ====== 串口命令:和按键一样,改变 s_mode ====== */
App_UART_Cmd cmd = App_UART_Poll();
switch (cmd) {
case APP_UART_CMD_MODE_0:
s_mode = MODE_SWITCH;
s_continuous_mode = false;
s_sos_step = 0;
App_LED_Off();
printf("[CMD] mode 0 (SWITCH)\r\n");
break;
case APP_UART_CMD_MODE_1:
s_mode = MODE_FAST;
s_continuous_mode = false;
s_sos_step = 0;
App_LED_Off();
printf("[CMD] mode 1 (FAST)\r\n");
break;
case APP_UART_CMD_MODE_2:
s_mode = MODE_SOS;
s_continuous_mode = false;
s_sos_step = 0;
App_LED_Off();
printf("[CMD] mode 2 (SOS)\r\n");
break;
case APP_UART_CMD_LED_ON:
App_LED_On();
s_continuous_mode = false; /* 手动控制 LED 时退出自动模式 */
s_sos_step = 0;
printf("[CMD] LED ON\r\n");
break;
case APP_UART_CMD_LED_OFF:
App_LED_Off();
s_continuous_mode = false;
s_sos_step = 0;
printf("[CMD] LED OFF\r\n");
break;
case APP_UART_CMD_STATUS:
printf("[STATUS] mode=%d, count=%lu\r\n",
s_mode, s_print_count);
break;
case APP_UART_CMD_UNKNOWN:
printf("[CMD] unknown command\r\n");
break;
default: break;
}
/* ====== KEY1:模式切换 ====== */
App_KeyEvent e1 = App_Key_GetEvent(&s_key1);
switch (e1) {
case APP_KEY_EVENT_PRESS:
s_mode = (LedMode)((s_mode + 1) % MODE_COUNT);
s_continuous_mode = false;
s_sos_step = 0;
App_LED_Off();
printf("[KEY1] short press -> switch to mode %d\r\n", s_mode);
break;
case APP_KEY_EVENT_LONG_PRESS:
s_mode = MODE_SWITCH;
s_continuous_mode = false;
s_sos_step = 0;
App_LED_Off();
printf("[KEY1] long press -> back to mode 0\r\n");
break;
default: break;
}
/* ====== KEY2:在当前模式下执行操作 ====== */
App_KeyEvent e2 = App_Key_GetEvent(&s_key2);
switch (s_mode) {
case MODE_SWITCH:
if (e2 == APP_KEY_EVENT_PRESS) {
App_LED_Toggle();
printf("[KEY2] short press -> toggle LED (mode 0)\r\n");
}
break;
case MODE_FAST:
if (e2 == APP_KEY_EVENT_PRESS) {
s_continuous_mode = true;
s_blink_tick = HAL_GetTick();
printf("[KEY2] short press -> fast blink 100ms (mode 1)\r\n");
}
if (e2 == APP_KEY_EVENT_LONG_PRESS) {
s_continuous_mode = true;
s_blink_tick = HAL_GetTick();
printf("[KEY2] long press -> burst blink 50ms (mode 1)\r\n");
}
if (e2 == APP_KEY_EVENT_RELEASE) {
s_continuous_mode = false;
App_LED_Off();
printf("[KEY2] release -> stop blink (mode 1)\r\n");
}
break;
case MODE_SOS:
if (e2 == APP_KEY_EVENT_PRESS) {
s_continuous_mode = true;
s_sos_step = 0;
s_sos_tick = HAL_GetTick();
printf("[KEY2] short press -> start SOS (mode 2)\r\n");
}
if (e2 == APP_KEY_EVENT_RELEASE) {
s_continuous_mode = false;
s_sos_step = 0;
App_LED_Off();
printf("[KEY2] release -> stop SOS (mode 2)\r\n");
}
break;
default: break;
}
/* ====== 持续行为:快闪 / SOS ====== */
if (s_continuous_mode) {
uint32_t now = HAL_GetTick();
if (s_mode == MODE_FAST) {
uint32_t interval = App_Key_IsPressed(&s_key2) ? 50u : 100u;
if (now - s_blink_tick >= interval) {
s_blink_tick = now;
App_LED_Toggle();
}
}
else if (s_mode == MODE_SOS) {
if (now - s_sos_tick >= 100) {
s_sos_tick = now;
static const uint8_t seq[] = {
1,0, 1,0, 1,0,
2,2,2,0, 2,2,2,0, 2,2,2,0,
1,0, 1,0, 1,0,
0,0, 0,0, 0,0, 0,0,
};
uint8_t cmd = seq[s_sos_step];
s_sos_step = (s_sos_step + 1) % (sizeof(seq));
if (cmd == 1 || cmd == 2) App_LED_On();
else App_LED_Off();
}
}
}
/* ====== 定时打印计数器 ====== */
{
uint32_t now = HAL_GetTick();
if (now - s_print_tick >= 1000) {
s_print_tick = now;
s_print_count++;
printf("Hello, count = %lu, LED mode = %d\r\n",
s_print_count, s_mode);
}
}
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */
}
对比第二篇的 while 循环,本篇加了四种东西:
printf("...")事件日志 --- 每个按键事件触发时,printf 一行描述。以前你只能通过 LED 闪不闪来猜"事件触发了吗",现在串口上直接告诉你。- 开机打印 --- 初始化时输出几行信息,让你一眼确认串口通了。
s_print_tick/s_print_count--- 用HAL_GetTick()做非阻塞的每秒一次计数和打印。注意这里没有用HAL_Delay(1000),和三篇以来的非阻塞原则一致。- 串口命令处理 ---
App_UART_Poll()+switch(cmd),和按键事件处理一模一样的模式。命令改变的是同一份变量(s_mode、s_continuous_mode),所以按键和命令自然共存。
核心工程思维:程序分"做事的代码"和"汇报的代码"。 做事的代码(状态机、LED 控制)是程序的骨架。汇报的代码(printf)是程序的感官。汇报不影响骨架的逻辑------你把所有 printf 删掉,程序行为完全不变。但有了汇报,你从黑盒变成了白盒。
第二个工程思维:多种输入源操作同一份状态。 按键可以切模式,串口命令也可以切模式,两种输入各走各的通道(App_Key_GetEvent vs App_UART_Poll),但最终修改的是同一组变量。这就是"输入与逻辑解耦"的雏形。
实战:用 printf 追踪状态机
前面说 printf 是用来调试的。那就拿第二篇的状态机来练手------在 app_key.c 的状态机里加上调试输出,让你亲眼看到状态机的每一次跳转。
在 app_key.c 里加调试打印
在 app_key.c 顶部加上 #include <stdio.h>,然后在每个状态跳转的地方加 printf。
但有一个问题:app_key.c 本身不应该依赖串口------它是"纯逻辑"模块。所以更好的做法是用一个调试开关:
c
/* app_key.c 顶部 */
#include <stdio.h>
/* 调试开关:定义 APP_KEY_DEBUG 以启用状态机追踪 */
#ifdef APP_KEY_DEBUG
#define KEY_TRACE(fmt, ...) printf("[FSM] " fmt "\r\n", ##__VA_ARGS__)
#else
#define KEY_TRACE(fmt, ...) ((void)0)
#endif
然后在状态机关键跳转处加一行:
c
case KS_IDLE:
if (down) {
s_state = KS_DEBOUNCE_DOWN;
s_entry_tick = now;
KEY_TRACE("IDLE -> DEBOUNCE_DOWN");
}
break;
case KS_DEBOUNCE_DOWN:
if (!down) {
s_state = KS_IDLE;
KEY_TRACE("DEBOUNCE_DOWN -> IDLE (bounce!)");
} else if (now - s_entry_tick >= APP_KEY_DEBOUNCE_MS) {
s_state = KS_PRESSED;
s_entry_tick = now;
SetEvent(APP_KEY_EVENT_PRESS);
KEY_TRACE("DEBOUNCE_DOWN -> PRESSED (stable, PRESS event)");
}
break;
用法:想调试状态机的时候,在编译时加 -DAPP_KEY_DEBUG(Makefile 的 CFLAGS 里或直接在代码顶部 #define APP_KEY_DEBUG)。不想打印了就删掉这行定义------所有 KEY_TRACE 都变成空,不产生任何代码。
这种"调试宏"是嵌入式开发的标准操作。 你需要调试时打开,发布时关掉,一行代码不改,只改一个 define。
实际效果
打开 APP_KEY_DEBUG 后,下载,按下按键再松开,串口助手会输出:
text
[FSM] IDLE -> DEBOUNCE_DOWN
[FSM] DEBOUNCE_DOWN -> PRESSED (stable, PRESS event)
[FSM] PRESSED -> DEBOUNCE_UP
[FSM] DEBOUNCE_UP -> IDLE (stable, RELEASE event)
这就是你会看到的最有价值的信息。 四个状态跳转,清清楚楚。如果有一次跳转没发生(比如消抖失败,DEBOUNCE_DOWN 直接跳回了 IDLE),你也会看到------(bounce!) 那一行解释了为什么。
和 LED 比起来:看 LED 你只知道"好像按下有效了/好像没效"。看这个,你知道状态机在哪个状态、走了哪条分支、为什么。
这就是串口打印给调试带来的质变。
编译、下载和验证
编译
Ctrl+Shift+B 或终端 make -j8。
常见编译报错:
| 报错信息 | 原因 | 修复 |
|---|---|---|
undefined reference to 'fputc' |
app_uart.c 没加入 Makefile 的 C_SOURCES |
加 Core/Src/app_uart.c \ |
unknown type name 'FILE' |
app_uart.c 里少了 #include <stdio.h> |
加上 |
'huart1' undeclared |
CubeMX 没启用 USART1 | 回 CubeMX 确认 USART1 Mode = Asynchronous |
下载
bash
make flash
打开串口助手
- 确认 USB 转串口模块已插入电脑
- 打开串口助手(SSCOM / PuTTY / VSCode Serial Monitor 等)
- 选择正确的 COM 口(去设备管理器确认)
- 设 115200 波特率、8 数据位、1 停止位、无校验、无流控
- 开启本地回显(SSCOM 里叫"显示发送"、PuTTY 里叫"Local echo")------否则你敲命令时看不到自己打了什么
- 点击"打开串口"
![### 验证步骤
| 步骤 | 操作 | 期望串口输出 | 期望 LED 行为 |
|---|---|---|---|
| 1 | 上电/复位 | 看到开机信息 + "Hello, count = 1" | --- |
| 2 | 等 2 秒 | "Hello, count = 2" ... "Hello, count = 3" | --- |
| 3 | 按 KEY1 一次 | [KEY1] short press -> switch to mode 1 |
LED 灭 |
| 4 | 按 KEY2 一次 | [KEY2] short press -> fast blink 100ms (mode 1) |
LED 快闪 |
| 5 | 松 KEY2 | [KEY2] release -> stop blink (mode 1) |
LED 灭 |
| 6 | 串口助手敲 mode 0 回车 |
[CMD] mode 0 (SWITCH) |
LED 灭 |
| 7 | 按 KEY2 一次 | [KEY2] short press -> toggle LED (mode 0) |
LED 翻转 |
| 8 | 串口助手敲 mode 2 回车 |
[CMD] mode 2 (SOS) |
LED 灭 |
| 9 | 串口助手敲 status 回车 |
[STATUS] mode=2, count=... |
--- |
| 10 | 串口助手敲 led off 回车 |
[CMD] LED OFF |
LED 灭(SOS 停止) |
| 11 | 串口助手敲 xyz 回车 |
[CMD] unknown command |
--- |
如果串口输出和上表一致,LED 行为也和上表一致------按键控制和串口命令完全共存,互不干扰。
工程延申:printf 的阻塞代价与非阻塞 TX
阻塞到底有多大影响?
我们来算一笔账。在 115200 波特率下,发一个字节约需 87μs。主循环里那行计数器 printf:
c
printf("Hello, count = %lu, LED mode = %d\r\n", s_print_count, s_mode);
这大约 40 个字符 = 40 × 87μs ≈ 3.5ms 。在主循环里跑按键 Tick(几十微秒)、跑 LED 逻辑(几微秒)------所有有用的工作加起来不到 100 微秒。然后 printf 一跑就是 3.5ms,占了整个循环时间的 97%。
![虽然我们把打印频率控制在了每秒一次,所以 平均影响不大。但如果某一次打印恰好和按键消抖窗口重叠------按键状态机在 3.5ms 的 printf 期间完全没有被 Tick,消抖可能就因为漏了几次采样而失效。
现阶段能接受吗? 能。每秒一行 printf,阻塞时间占比 ≈ 3.5ms / 1000ms = 0.35%。对按键消抖(20ms 窗口)的影响微乎其微。但你要知道这个代价的存在。
解决方案:中断驱动的环形缓冲区
产品级的做法是:代码往一个内存缓冲区里写数据(极快,微秒级),USART 的TX 完成中断 从缓冲区取数据发出去(硬件自动,不占 CPU)。两者完全解耦。
![下面给出一个最小实现。你现在可以不敲,但理解它的思路对后面的 DMA 篇章很有帮助。
c
/* 环形缓冲区:非阻塞串口 TX ------ 工程延申,可不敲入工程 */
#define TX_BUF_SIZE 256
static uint8_t tx_buf[TX_BUF_SIZE];
static uint16_t tx_head = 0; /* 写入位置 */
static uint16_t tx_tail = 0; /* 发送位置 */
static bool tx_busy = false;
/* 往缓冲区写一个字节。缓冲区满则丢弃(非阻塞) */
static bool tx_buf_put(uint8_t byte)
{
uint16_t next = (tx_head + 1) % TX_BUF_SIZE;
if (next == tx_tail) return false; /* 满 */
tx_buf[tx_head] = byte;
tx_head = next;
return true;
}
/* 从缓冲区取一个字节发送。调用者保证缓冲区非空 */
static uint8_t tx_buf_get(void)
{
uint8_t byte = tx_buf[tx_tail];
tx_tail = (tx_tail + 1) % TX_BUF_SIZE;
return byte;
}
static bool tx_buf_empty(void)
{
return tx_head == tx_tail;
}
/* 启动一次 TX 中断发送 */
static void tx_start(void)
{
if (!tx_buf_empty() && !tx_busy) {
tx_busy = true;
uint8_t byte = tx_buf_get();
HAL_UART_Transmit_IT(&APP_UART_HANDLE, &byte, 1);
}
}
/* TX 完成中断回调(HAL 自动调用) */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
tx_busy = false;
tx_start(); /* 发下一个字节 */
}
/* 非阻塞 fputc ------ 字符写入缓冲区即返回 */
int fputc(int ch, FILE *f)
{
if (ch == '\n') {
tx_buf_put('\r');
}
tx_buf_put((uint8_t)ch);
tx_start(); /* 触发发送 */
return ch;
}
原理:
fputc不再调用阻塞的HAL_UART_Transmit,而是把字节写入tx_buf环形缓冲区------写内存只需要几十纳秒,完全不阻塞。tx_start()检查缓冲区非空且 USART 空闲,就启动一次中断发送(HAL_UART_Transmit_IT)。- 发完一个字节后,HAL 自动调用
HAL_UART_TxCpltCallback,里面继续取下一个字节发送。 - USART 以 115200 的速度慢慢发,CPU 照常跑主循环。两者各干各的。
这个方案是串口非阻塞发送的标准解法。 目前阶段你不需要把它敲进工程------阻塞 printf 足够调试用了。但当你后面做到需要高频日志、或者产品级固件时,回来翻这一段就有现成的模板。
移植到其他板子的修改点
| 修改点 | 为什么 | 在哪里改 |
|---|---|---|
| USART 外设 | 有些板子的 PA9/PA10 已被占用,需要用 USART2/USART3 | CubeMX 选另一个 USART,代码里 #define APP_UART_HANDLE huart2 |
| TX/RX 引脚 | CubeMX 会自动重映射,但还是要确认引脚和模块连对了 | 看 CubeMX Pinout 视图 |
| 波特率 | 遇到乱码先降到 9600 确认接线,调通后再升回 115200 | CubeMX 和串口助手两边同步改 |
| VCP 串口 | Nucleo/Discovery 板子的板载 ST-Link 自带了虚拟串口(VCP),不需要外接 CH340 模块 | 设备管理器里 ST-Link 下面会多出一个 COM 口,直接用 |
一些最小系统板(如 STM32F103C8T6 Blue Pill)的 PA9/PA10 可能不引出来。这种情况下用 USART2(PA2/PA3)或 USART3(PB10/PB11)。CubeMX 里选了 USART2 之后,代码侧定义
APP_UART_HANDLE为huart2就行了。
常见问题排查
1. 串口助手完全空白------什么都收不到
| 按顺序检查 | 怎么做 |
|---|---|
| ① 模块的 RXD 是否接 STM32 PA9 (TX) | 模块丝印可能写 TXD/RXD,记住:接 STM32 TX 的要选模块的"收"脚(RXD) |
| ② GND 是否连通 | STM32 GND ↔ 模块 GND------没共地是空白/乱码的第一大原因 |
| ③ COM 口对不对 | 设备管理器 → 端口,看模块对应的 COM 号。插拔模块前后对比 |
| ④ 波特率对不对 | CubeMX = 115200 = 串口助手 |
⑤ MX_USART1_UART_Init 是否被调用 |
main.c 里有这个函数调用吗 |
2. 收到全是乱码 / 问号 / 看不懂的字符
| 按顺序检查 | 怎么做 |
|---|---|
| ① 波特率 | 这是 99% 乱码的原因。 CubeMX 和串口助手必须完全一致 |
| ② 数据格式 | 两端都必须是 8N1 |
| ③ GND 共地 | 没接地 = 电平基准漂移 = 乱码 |
| ④ 电平 | 模块电压跳线在 3.3V |
3. 编译通过、下载成功,串口助手也有输出,但 LED 行为不正常了
HAL_MAX_DELAY 导致 printf 阻塞太久,按键 Tick 间隔被拉大,消抖失效。临时验证:在 main 循环里删掉 printf 那行(只保留按键逻辑),看 LED 是否恢复正常。如果是------说明 printf 太频繁了,降低打印频率(比如从每圈打印改成每 1000ms 打印一次)。
4. fputc 编译报重复定义
你的工程里可能有两个地方定义了 fputc。检查是否在多个 .c 文件里写了 int fputc(int ch, FILE *f)。只需要一个地方定义(app_uart.c)。
5. 串口助手上看到"KEY1 short press"输出了三次(虽然 LED 只闪了一次)
printf 本身没有问题------按键状态机确实只触发了一次 PRESS 事件。如果你看到重复输出,检查是不是在主循环的多个位置对同一个事件做了多次 printf。一个 App_KeyEvent 变量只在 switch(event) 里处理一次。
6. 每次下载后串口助手显示乱码几秒然后正常
这是正常的。STM32 复位期间,TX 引脚电平不稳定,串口助手可能收到一些垃圾字节。开机后先输出一个 \r\n 清一下行,然后正式开始打印,这个问题就不会影响了。
7. 串口助手里敲命令没反应------板子不响应
| 按顺序检查 | 怎么做 |
|---|---|
| ① 本地回显开了吗 | 没开回显 = 敲了命令也看不到自己打的字。打开串口助手的"本地回显"/"显示发送"选项 |
| ② 发送格式对吗 | 有些串口助手默认发 hex(十六进制),切到"ASCII"或"字符"模式 |
| ③ 结尾加回车了吗 | 板子以 \r 或 \n 作为命令结束标志。只敲字符不敲回车 = 命令永远攒不够一行 |
| ④ NVIC 中断开了吗 | CubeMX → USART1 → NVIC Settings → USART1 global interrupt 是否勾选 |
⑤ App_UART_StartRX() 调了吗 |
main.c 初始化里有没有这行?没调用 = 中断从未启动 |
8. 敲命令后板子回显了 "unknown command"
命令格式不对。支持的命令只有:mode 0、mode 1、mode 2、led on、led off、status。注意大小写、空格------"Mode 0"、"mode0" 都识别不了。
9. 敲了 mode 1 但是 LED 没反应------隔了好几秒才切过去
USART RX 引脚 (PA10) 悬空或松了?引脚悬空 = 电气噪声触发随机中断 = 行缓冲区被垃圾字节填满。
本篇小结
| 收获 | 具体来说 |
|---|---|
| 串口通信基础 | 波特率 = 双方约好的速度;8N1 = 通用数据格式;TX/RX 交叉 = 嘴对耳朵 |
| fputc 重定向 | C 标准库的弱符号机制------不改库代码,写同名函数就能覆盖默认行为 |
| 弱符号(weak symbol) | 标准库的 fputc 加了 __weak,你的同名函数不加------链接器选你的。这是 C 语言最优雅的注入机制 |
| 串口接收中断 | HAL_UART_Receive_IT + 行缓冲------每个字节触发中断,攒够一行解析命令 |
| 多输入源共存 | 按键(App_Key_GetEvent)和串口命令(App_UART_Poll)操作同一份状态,各走各的通道 |
| 从黑盒到白盒 | 按键事件、状态机状态、模式切换------全部 printf 出来。调试从"猜"变成"看" |
| 调试宏 | KEY_TRACE 模式------需要时 #define,不需要时删掉,一行不改 |
| 中断上下文约束 | 中断回调里只做轻量操作(存字节、置标志),耗时操作(printf)丢给主循环 |
| 非阻塞串口(工程延申) | 环形缓冲区 + TX 中断------CPU 和 USART 完全解耦。当前调试够用,产品级要用这个模式 |
| 三篇以来的非阻塞原则延续 | 用 HAL_GetTick() 控制 printf 频率,不用 HAL_Delay------从第一篇到现在始终如一 |
串口通信是嵌入式开发的"基础设施"。后面的篇章里------定时器状态、ADC 采样值、I2C 通信结果------全部通过串口输出到电脑上看。双向通信(TX + RX)的打通,意味着板子和电脑之间有了完整的对话能力:板子汇报状态,电脑下达命令。
printf 是嵌入式开发最重要的调试工具,没有之一。 学会用 printf,你就告别了"LED 二进制猜状态"的原始时代。加上串口命令,你就有了远程控制的能力------后面所有的调试和交互都建立在这条通道上。
练习
-
改打印频率: 把 1000ms 改成 200ms,用秒表对着串口助手看打印速度是否确实变成了每秒 5 行。再改到 50ms------观察按键操作是否开始出现延迟或失灵。这个实验让你直观感受 printf 频率和系统响应性的关系。
-
打印状态机完整路径: 在
app_key.c里加上KEY_TRACE宏(参照上面的代码),编译时定义APP_KEY_DEBUG。按下按键、按住不放、松开------观察串口输出的完整状态跳转路径。确认你看到了消抖失败时DEBOUNCE_DOWN -> IDLE (bounce!)的跳变。 -
格式化输出模式名: 把
printf("LED mode = %d", s_mode)改成打印文字而不是数字:mode 0 打 "SWITCH",mode 1 打 "FAST",mode 2 打 "SOS"。提示:写一个辅助函数const char* ModeName(LedMode m)返回字符串指针。 -
加入运行时间戳: 在每行 printf 前面加上
HAL_GetTick()的值(格式:[12345ms] Hello, count = 1)。这样你可以算出任意两个事件之间隔了多少毫秒。提示:printf("[%lums] ...", HAL_GetTick(), ...)。 -
添加新命令: 在命令表里加一条
blink命令------敲blink就相当于在 MODE_FAST 下按了一次 KEY2(开始快闪)。你需要修改app_uart.c的parse_line()和app_uart.h的命令枚举,再在 main.c 的 switch 里添加处理。这个练习让你透彻理解"添加新命令"的完整流程。 -
命令回显: 在中断回调收到
\r或\n时,除了解析命令,还让板子把收到的命令行原样回发(printf("[ECHO] %s\r\n", rx_line))。观察回显和命令响应在串口助手上的输出顺序。注意:前面说过中断里不能调 printf------那怎么实现回显?提示:设一个rx_echo_pending标志,在主循环的App_UART_Poll()里处理。 -
环形缓冲区实验: 如果前 6 个练习都做完了还有余力,尝试把环形缓冲区代码加入
app_uart.c,替换阻塞的fputc。把打印频率调到每圈一次,观察非阻塞版的 LED 控制和命令响应是否依然流畅。
下一篇预告
本篇打通了 STM32 和 PC 之间的双向通信------板子能说(TX/printf)也能听(RX/命令)。下一篇开始接触 STM32 的定时器:用硬件定时器替代 HAL_GetTick() 的软件轮询,实现精确的 LED PWM 调光和毫秒级定时任务。定时器是嵌入式开发最核心的外设之一,也是后面 DMA、输入捕获等高级功能的基础。
](https://i-blog.csdnimg.cn/direct/6470da6716e64a8182500b26a3119918.png)