1. 背景与核心概念
1.1 为什么要重新理解 UART 配置
在日常嵌入式开发里,UART 串口是使用频率最高、也最容易出问题的外设之一。无论是调试日志输出、跟传感器通信、对接 WiFi/蓝牙模块,还是做出厂测试的 AT 指令通道,都离不开串口。很多初学者以为串口就是“初始化一下、发送一个字符串”这么简单,但真正到了实际项目里,会遇到一串很现实的问题:printf 打印不出来、多个串口不知道该往哪个口发、换了一个板子代码就跑不通、中文输出乱码、进入 HardFault……
这些问题大多不是 UART 硬件本身的问题,而是我们在代码组织方式上没有把“串口”抽象好。尤其是当你同时需要“调试口”和“业务串口”时,如果所有 printf 都写死到某一个串口,后续做日志切换、板级适配会非常痛苦。
本文围绕 UART 的宏定义、printf 输出选择、多串口配置三个核心点展开,通过一个 STM32 HAL 库示例,演示如何用宏把串口资源、printf 输出目标、缓冲区大小管理起来,并把常见的坑和排查思路整理出来。
1.2 什么是 UART 串口通信
UART 是 Universal Asynchronous Receiver/Transmitter 的缩写,全双工异步串行通信接口。它只需要两根数据线:TX 发送、RX 接收,收发双方约定好波特率、数据位、停止位、奇偶校验位之后,就可以按字节流传输数据。硬件层还有流控引脚 RTS/CTS,但不是所有场景都需要。
嵌入式开发中经常提到 USART 和 UART,简单区分一下:
| 名称 | 说明 | 典型应用 |
|---|---|---|
| UART | 异步串口,不支持同步时钟 | 普通串口调试、传感器、蓝牙模块 |
| USART | 通用同步/异步收发器,支持同步模式 | 带时钟同步的通信场景,或兼容 UART 模式 |
绝大多数情况下,我们把 USART 直接当 UART 用。学习时不用过分纠结这两个名字,只要知道 STM32 的 USART 外设既能同步也能异步,而串口调试更多用异步模式即可。
1.3 宏定义与 printf 重定向的关系
串口功能本身很简单,但代码写得好不好,差异很大。宏定义在这里的作用并不是“显摆技巧”,而是为了做到几个非常实际的目标:
- 串口资源可配置:换板子后,只需改宏,不用改业务代码。
- printf 输出目标可选:编译期决定日志走哪个串口,运行时也能动态切换。
- 缓冲区大小可调整:不同项目对日志长度、接收缓存需求不同,通过宏统一控制。
- 代码可读性高:写
DEBUG_USART比写记忆中的&huart1更清楚。
printf 重定向则是把 C 库函数printf最终要调用的底层字符输出函数,从默认的显示器输出重定向到串口。以 ARMCC/Keil 环境为例,最常见的做法是重写fputc,让每一个字符都通过HAL_UART_Transmit发出去。
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }这么写之后,printf("hello\n")的内容就会从串口 1 发出去。但这个写法有一个局限:printf 的输出目标被固定死在huart1上。如果项目里还有第二个串口,或者你想把日志切换到别的串口,只靠上面这段代码就不够了。
1.4 printf 重定向的常见误区
很多新手在重定向 printf 后,运行程序发现没有任何输出,于是开始在初始化代码里找问题。但实际上问题往往出在以下几个被忽略的地方:
- 没有启用微库或关闭半主机模式。在 Keil MDK 环境下,默认的 C 库
printf依赖半主机模式,串口没有实现对应底层函数时,调用 printf 会进入 HardFault 或直接卡死。 - 波特率不对。printf 重定向成功,但串口助手设置的波特率和代码不一致,看起来就像“没输出”。
- 时钟和引脚配置错误。HAL_UART_Transmit 卡在超时等待标志位,多半是串口时钟或 GPIO 复用配置丢失。
- 调用了
fputc,但没有包含头文件或函数签名不对,编译器用了别的弱定义。
这些问题会在后面的实战部分逐一演示如何规避。
2. 环境准备与版本说明
2.1 本文的硬件与软件环境
本文示例以 STM32F103 系列芯片为背景,使用 STM32CubeMX 生成初始化代码,再在 Keil MDK 中编写业务逻辑。之所以选这套环境,是因为 STM32 的 HAL 库代码结构清晰、寄存器操作封装完整,非常有利于讲清楚“配置在哪里”“宏定义如何替换”。
注意:不同芯片、不同 HAL 库版本,生成的初始化函数名可能略有差异,但核心思路完全一致。如果你使用的是 GD32、AT32、NXP 或其他 MCU,只需把外设句柄和初始化接口替换成对应平台的 API 即可。
版本方面不限定死,重点演示配置思路:
| 项目 | 说明 |
|---|---|
| 芯片型号 | STM32F103C8T6(或兼容型号) |
| 开发环境 | STM32CubeMX + Keil MDK |
| 外设库 | STM32 HAL 库 |
| 调试工具 | 任意 USB 转串口模块(CH340、CP2102 等) |
| 系统 | Windows 10/11 |
USB 转串口模块通常需要安装驱动。如果电脑识别不到串口设备,可以先检查驱动是否正确安装。CH340、CP2102、FT232 这类芯片的驱动均需要到厂商官网下载匹配版本,避免安装到错误版本导致设备管理器里显示异常。
2.2 示例工程的整体结构
为了演示“多串口 + 宏定义 + printf 选择”,本文设计一个双串口场景:
- USART1:作为调试串口,输出系统启动信息和日志。
- USART2:作为业务串口,用于接收外部设备发送的命令,并将处理结果回传。
- 通过宏
PRINTF_UART_INDEX选择 printf 默认输出到 USART1 或 USART2。 - 提供
printf_uart_select()运行时切换函数,便于在业务代码中动态改变 printf 输出目标。
最终的项目文件结构规划如下:
Demo_UART/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── uart_config.h │ │ └── usart.h │ └── Src/ │ ├── main.c │ ├── uart_config.c │ ├── usart.c │ └── stm32f1xx_it.c ├── Drivers/ │ └── ... └── Demo_UART.ioc其中uart_config.h和uart_config.c是我们后加的串口管理文件,用于统一管理串口句柄、宏定义和 printf 重定向。
3. 核心配置与原理拆解
3.1 在 STM32CubeMX 中创建双串口工程
打开 STM32CubeMX,新建工程并选择 STM32F103C8T6,然后在芯片引脚图上配置两个串口:
- USART1 模式选择
Asynchronous,波特率设置为115200 Bits/s,数据位 8,无校验,停止位 1。 - USART2 同样选择
Asynchronous,波特率设置为115200 Bits/s,其他参数保持一致。 - 系统时钟可以使用默认 HSI 或外部晶振。如果使用外部晶振,需要确认板子上是否有 8MHz 晶振。
在 CubeMX 的Project Manager里配置工程名称、工具链(MDK-ARM)并生成代码。
生成后的usart.c会包含两个初始化函数:MX_USART1_UART_Init()和MX_USART2_UART_Init(),同时会在main.c中自动声明huart1和huart2两个全局句柄。
// 文件路径:Core/Src/usart.c UART_HandleTypeDef huart1; UART_HandleTypeDef huart2; void MX_USART1_UART_Init(void) { 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; HAL_UART_Init(&huart1); }3.2 用宏定义统一管理串口资源
直接使用huart1、huart2虽然能跑,但代码里到处都是硬件句柄,后期维护成本很高。建议在uart_config.h里定义一组“逻辑串口”宏:
// 文件路径:Core/Inc/uart_config.h #ifndef __UART_CONFIG_H #define __UART_CONFIG_H #include "usart.h" #include "stdio.h" #define DEBUG_UART (&huart1) // 调试串口 #define CMD_UART (&huart2) // 业务串口 // printf 默认输出串口选择 #define PRINTF_UART_1 1 #define PRINTF_UART_2 2 #ifndef PRINTF_UART_INDEX #define PRINTF_UART_INDEX PRINTF_UART_1 #endif #endif这样修改了一个好处:如果换了硬件平台,把huart1和huart2的映射关系调整一下,业务代码里DEBUG_UART、CMD_UART的引用不用逐个修改。
3.3 printf 重定向原理
Keil MDK 环境下,printf最终会调用fputc来输出单个字符。默认实现是发送到调试器或半主机通道。我们需要把fputc重写为“通过串口发一个字节”。
最直接的写法:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(DEBUG_UART, (uint8_t *)&ch, 1, 0xFFFF); return ch; }这段代码的原理是:
ch是printf格式化后产生的一个字符。(uint8_t *)&ch取该字符的地址,作为HAL_UART_Transmit的数据源。0xFFFF是超时时间,单位毫秒。这里设置一个较大值,避免发送阻塞时立刻返回错误。
在 Keil 中还需要注意两点:
- 勾选 Use MicroLIB。在工程选项的 Target 页签中勾选
Use MicroLIB,这样可以避免完整的 C 库依赖半主机模式的默认实现。 - 如果不使用 MicroLIB,则需要自行实现
fputc并屏蔽半主机模式,否则程序运行到 printf 时可能进入 HardFault。
3.4 多串口 printf 输出选择
上面固定写死DEBUG_UART的做法依然不够灵活。更好的方案是把“当前 printf 使用哪个串口”提升为可配置状态。
首先定义一个全局指针,指向当前 printf 输出串口句柄:
static UART_HandleTypeDef *s_print_uart = DEBUG_UART;再提供两个函数:
void printf_uart_set(UART_HandleTypeDef *huart) { s_print_uart = huart; } UART_HandleTypeDef *printf_uart_get(void) { return s_print_uart; }然后fputc改为:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(s_print_uart, (uint8_t *)&ch, 1, 0xFFFF); return ch; }这样在运行时调用printf_uart_set(&huart2);,后面的 printf 内容就会全部发到 USART2。这是一个非常实用的模式,在“日志切换”“进入 AT 指令测试模式”的场景中很常见。
3.5 编译期选择与运行时切换结合
宏定义可以在编译期就确定默认输出串口。在uart_config.h中我们已经定义了PRINTF_UART_INDEX,那么可以在初始化代码里根据该宏设置s_print_uart的初始值:
void uart_config_init(void) { #if PRINTF_UART_INDEX == PRINTF_UART_1 printf_uart_set(DEBUG_UART); #elif PRINTF_UART_INDEX == PRINTF_UART_2 printf_uart_set(CMD_UART); #else printf_uart_set(DEBUG_UART); #endif printf("[UART] printf output initialize done.\r\n"); }这样有两种切换方式:
- 编译期固定:修改
uart_config.h中PRINTF_UART_INDEX的宏值,重新编译。 - 运行期动态:调用
printf_uart_set(&huart2),随时切换。
两种方式结合,既能满足“发布版本固定日志口”的需求,也能满足“调试时现场切口”的灵活性。
4. 完整实战:双串口 printf 输出与多串口配置
4.1 创建工程并添加串口配置文件
在 STM32CubeMX 中完成 1 个串口初始化后,在工程的Core/Inc和Core/Src目录下分别新建uart_config.h和uart_config.c。
注意:由于uart_config.c会被 Keil 编译,因此要把这个文件手动添加到 Keil 工程的 Source Group 中。很多初学者生成了文件但忘记添加,导致编译器报“未定义符号uart_config_init”,这一点要特别留意。
4.2 编写 uart_config.h
// 文件路径:Core/Inc/uart_config.h #ifndef __UART_CONFIG_H #define __UART_CONFIG_H #include "usart.h" #include <stdio.h> // 逻辑串口映射 #define DEBUG_UART (&huart1) #define CMD_UART (&huart2) // printf 默认输出串口 #define PRINTF_UART_1 1 #define PRINTF_UART_2 2 #ifndef PRINTF_UART_INDEX #define PRINTF_UART_INDEX PRINTF_UART_1 #endif void uart_config_init(void); void printf_uart_set(UART_HandleTypeDef *huart); UART_HandleTypeDef *printf_uart_get(void); #endif4.3 编写 uart_config.c
// 文件路径:Core/Src/uart_config.c #include "uart_config.h" static UART_HandleTypeDef *s_print_uart = DEBUG_UART; void printf_uart_set(UART_HandleTypeDef *huart) { s_print_uart = huart; } UART_HandleTypeDef *printf_uart_get(void) { return s_print_uart; } void uart_config_init(void) { #if PRINTF_UART_INDEX == PRINTF_UART_1 printf_uart_set(DEBUG_UART); #elif PRINTF_UART_INDEX == PRINTF_UART_2 printf_uart_set(CMD_UART); #else printf_uart_set(DEBUG_UART); #endif printf("[UART] printf output init done.\r\n"); }这里要注意一个细节:uart_config_init必须在串口初始化之后调用,因为s_print_uart本身只是保存了一个指针,指针指向的huart1、huart2必须已经完成MX_USARTx_UART_Init()初始化。
4.4 重定向 printf
在 Keil MDK 中,重定向fputc即可。如果使用 GCC 工具链(例如 STM32CubeIDE),则通常需要重写_write函数,两者思路相同。
在main.c中添加:
// 文件路径:Core/Src/main.c #include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(printf_uart_get(), (uint8_t *)&ch, 1, 0xFFFF); return ch; }同时确保 Keil 工程勾选了Use MicroLIB。如果不使用 MicroLIB,又不想勾选,则需要在工程中加入以下代码来禁用半主机模式:
#include <rt_sys.h> void _ttywrch(int ch) { (void)ch; } struct __FILE { int handle; }; FILE __stdout; FILE __stdin; void _sys_exit(int return_code) { (void)return_code; while (1); }这类代码属于编译环境兼容层,不同编译器差异较大。最省事的方案仍然是勾选 MicroLIB,对于绝大多数串口日志场景完全够用。
4.5 用宏定义管理接收缓冲区
除了 printf 输出,串口接收也需要通过宏管理。以 USART2 接收外部命令为例,在uart_config.h中定义接收缓冲区大小:
#define CMD_RX_BUF_SIZE 128 #define CMD_RX_MAX_LEN (CMD_RX_BUF_SIZE - 1)再在uart_config.c中定义缓冲区:
static uint8_t s_cmd_rx_buf[CMD_RX_BUF_SIZE]; static uint8_t s_cmd_rx_len = 0; static uint8_t s_cmd_rx_complete = 0;这里使用“一帧结束判定”和简单的接收回显。实际项目中,可以使用空闲中断、DMA 或者环形缓冲区,但为了把原理讲清楚,先用最简单的HAL_UART_Receive_IT方式。
4.6 启动串口中断接收
在main.c中,初始化完成后调用:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_USART2_UART_Init(); uart_config_init(); // 启动串口2中断接收 HAL_UART_Receive_IT(&huart2, s_cmd_rx_buf, 1); printf("System boot OK.\r\n"); while (1) { if (s_cmd_rx_complete) { printf("Recv CMD: %s\r\n", s_cmd_rx_buf); s_cmd_rx_complete = 0; s_cmd_rx_len = 0; memset(s_cmd_rx_buf, 0, CMD_RX_BUF_SIZE); HAL_UART_Receive_IT(&huart2, s_cmd_rx_buf, 1); } } }注意:这里s_cmd_rx_buf和s_cmd_rx_complete需要跨文件访问时,在uart_config.h中声明为 extern。
4.7 编写串口中断回调
在stm32f1xx_it.c或stm32f1xx_hal_uart.c的 UART 中断回调函数中处理接收:
// 文件路径:Core/Src/stm32f1xx_it.c #include "uart_config.h" extern uint8_t s_cmd_rx_buf[]; extern uint8_t s_cmd_rx_len; extern uint8_t s_cmd_rx_complete; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { if (s_cmd_rx_len < CMD_RX_MAX_LEN) { if (s_cmd_rx_buf[s_cmd_rx_len] == '\r' || s_cmd_rx_buf[s_cmd_rx_len] == '\n') { s_cmd_rx_buf[s_cmd_rx_len] = '\0'; s_cmd_rx_complete = 1; } else { s_cmd_rx_len++; HAL_UART_Receive_IT(&huart2, &s_cmd_rx_buf[s_cmd_rx_len], 1); } } else { s_cmd_rx_buf[s_cmd_rx_len] = '\0'; s_cmd_rx_complete = 1; } } }这种逐字节接收的方式虽然效率不高,但它简单直观,非常适合理解“接收中断 + 缓冲区管理”的基本流程。实际项目如果数据量大,建议改用 DMA + 空闲中断。
4.8 运行与验证
编译下载后,使用串口调试助手连接 USB 转串口模块:
- 将 USB 转串口模块的 TX 接到 STM32 的 PA10(USART1_RX),RX 接到 PA9(USART1_TX),GND 共地。
- 打开串口助手,设置波特率 115200,数据位 8,停止位 1,无校验。
- 复位板子,应该能看到:
[UART] printf output init done. System boot OK.- 如果把 USB 转串口模块切换到 USART2 对应的引脚,并在代码里调用
printf_uart_set(&huart2);,则重新复位后,同样的内容会从 USART2 发出。
这就验证了双串口配置和 printf 输出选择都生效了。
5. 常见问题与排查思路
5.1 printf 完全没有输出
这是最常遇到的问题。按下面的顺序排查:
| 检查点 | 原因 | 解决方案 |
|---|---|---|
| 是否勾选 MicroLIB | 半主机模式导致卡死 | Keil 中勾选 Use MicroLIB |
| 串口初始化是否完成 | 引脚或时钟错误 | 检查 MX_USARTx_UART_Init 是否被调用 |
| 波特率是否一致 | 串口助手和代码不一致 | 统一为 115200 |
| 接线是否正确 | TX/RX 接反 | 交换 USB 转串口模块的 TX/RX |
| 引脚复用是否正确 | HAL 库未配置 GPIO_AF | 检查 CubeMX 生成代码 |
排查技巧:先用HAL_UART_Transmit(&huart1, (uint8_t *)"A", 1, 0xFFFF);直接发一个字节,如果这样也没有输出,说明串口底层就有问题,不用先查 printf。
5.2 printf 中文乱码
中文乱码的根本原因是“字符编码不一致”。也就是说,源文件里保存的汉字是什么编码,串口助手按什么编码显示。如果两边的编码方式不一致,就会出现乱码。
常见场景:
- 源文件是 UTF-8,串口助手用 GBK/GB2312 显示。
- 源文件是 GB2312,串口助手用 UTF-8 显示。
- Keil MDK 默认编辑器对中文支持依赖本地编码,不同版本的 MDK 行为还不完全一样。
解决方案:
- 尽量用英文日志,避免编码问题。
- 确需输出中文时,统一源文件编码和串口助手显示编码。
- 也可以把中文转成 Unicode 转义序列,不过可读性差,工程中不常用。
- 串口助手大多数支持“UTF-8”和“GBK”显示切换,尝试切换一下显示编码即可。
5.3 多串口 printf 固定到某一个口
很多人的需求是“USART1 打印日志,USART2 也打印日志,互不影响”。直接用同一个printf做不到,因为printf是全局唯一的输出函数。思路有两条:
- 实现两个打印函数,例如
debug_printf和cmd_printf,内部各自调用HAL_UART_Transmit。这种方式不依赖fputc,也不占用printf。 - 用
printf_uart_set在切换后输出到指定串口。注意切换后要恢复,否则后续日志会串口。
如果需要真正同时输出到两个串口,也可以定义一个printf_broadcast函数,内部对多个串口逐一执行HAL_UART_Transmit,适合日志需要分发的场景。
5.4 进入 HardFault
调用 printf 后进入 HardFault,通常和半主机模式有关。在 Keil 中,如果未勾选 MicroLIB,且没有关闭半主机模式,printf会尝试通过半主机通道输出,MCU 中并没有对应硬件,从而触发 HardFault。
解决思路:
- 勾选 MicroLIB。
- 或者实现
_sys_exit、_ttywrch等半主机函数,阻止进入半主机分支。 - 使用 SEGGER RTT 或 SWO 输出日志时不要使用标准 printf 重定向,应使用库提供的专用输出接口。
5.5 HAL_UART_Transmit 卡死
HAL_UART_Transmit的最后一个参数是超时时间。如果设置为HAL_MAX_DELAY,并且串口硬件状态异常,会出现永久等待。例如 GPIO 复用配置错误、串口时钟未开启,都会导致发送标志位一直不置位。
解决办法:
- 减少超时时间,例如改为
1000,让异常快速暴露。 - 检查
HAL_UART_Init的返回值。 - 检查 GPIO 是否配置为复用功能,而不是普通输出。
6. 最佳实践与工程建议
6.1 用逻辑串口抽象硬件
不要直接在业务代码里写&huart1、&huart2。用宏或者全局变量定义逻辑串口,例如DEBUG_UART、CMD_UART、GPS_UART,这样硬件调整的改动范围会小很多。
#define DEBUG_UART (&huart1) #define LORA_UART (&huart2)当你的板子从 F103 换到 F407,串口引脚变化后,只需要修改 CubeMX 配置和这里的一行宏定义,业务逻辑完全不用动。
6.2 区分“日志输出”与“业务通信”
调试日志和业务数据混在同一个串口、同一个 printf 通道里,是后期问题最多的地方。比如业务串口收到一帧数据,你顺手 printf 出去,如果此时另一个线程也在 printf,两条日志会交错,接收端解析可能出问题。
建议:
- 调试日志固定走专门串口。
- 业务通信使用独立的串口和回调函数。
- 日志输出尽量带时间戳、级别、模块名。
- 业务数据帧不要走 printf 格式化,直接以字节数组发送。
6.3 缓冲区大小用宏管理
无论是发送缓冲区还是接收缓冲区,都不要在代码里散落魔法数字。比如:
#define LOG_BUF_SIZE 256 #define CMD_RX_BUF_SIZE 128这样即使将来要加大缓冲区,也只需修改宏定义,程序其他部分会自动适配。
接收缓冲区的溢出也要重点关注。当接收数据超过缓冲区时,应该做截断处理,并记录一次溢出事件,而不是让缓冲区继续写入越界地址。
6.4 printf 重定向的跨平台写法
如果项目需要在 Keil 和 GCC 两种工具链之间切换,可以写一个条件编译分支:
#if defined(__ARMCC_VERSION) int fputc(int ch, FILE *f) { HAL_UART_Transmit(s_print_uart, (uint8_t *)&ch, 1, 0xFFFF); return ch; } #elif defined(__GNUC__) int _write(int file, char *ptr, int len) { for (int i = 0; i < len; i++) { HAL_UART_Transmit(s_print_uart, (uint8_t *)&ptr[i], 1, 0xFFFF); } return len; } #endif注意:GCC 环境还需要在链接或重定向层面避免半主机,STM32CubeIDE 中在项目属性里设置底层函数,或者直接使用上面这段_write,并配合setvbuf(stdout, NULL, _IONBF, 0);关闭标准输出缓冲。这里不展开具体 IDE 菜单,因为不同版本设置入口差异较大。
6.5 谨慎使用全局 printf_uart_set
运行时切换 printf 输出串口这个功能很方便,但也要小心。如果两个模块同时调用printf_uart_set,会出现“把 printf 输出切到了别人的串口”的典型问题。建议:
- 设置前保存旧值。
- 使用完毕后恢复。
- 或者只允许在初始化阶段调用。
void some_module_entry(void) { UART_HandleTypeDef *old = printf_uart_get(); printf_uart_set(&huart2); printf("module start\r\n"); printf_uart_set(old); }这样局部切换不会影响其他模块的日志输出。
6.6 接收回调中不要做耗时操作
串口中断回调函数是在中断上下文执行的,不要在里面调用 printf、HAL_Delay、大循环等操作。如果需要打印数据,建议在回调里设置标志位,然后在主循环中处理:
volatile uint8_t rx_flag = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { rx_flag = 1; } } while (1) { if (rx_flag) { rx_flag = 0; // 处理接收数据 } }这能有效避免中断嵌套、日志阻塞带来的问题。
6.7 正确关闭串口接收
在调试过程中,如果串口一直处于中断接收状态,调试器复位后可能还会触发中断。建议在系统进入低功耗前调用HAL_UART_AbortReceive关闭接收,避免意外唤醒。生产环境下也要考虑收到无用数据时如何处理,而不是让缓冲区无限堆积。
7. 总结与学习路线
本文从 UART 的基础概念讲起,结合 STM32 HAL 库演示了宏定义统一管理串口资源、printf 重定向原理、多串口 printf 输出选择以及双串口收发配置的方法。所有代码都遵循“逻辑串口 + 宏定义 + 可切换 printf 输出目标”的思路,实际项目里可以直接套用。
如果你刚接触 UART,建议按照下面的路线继续练习:
- 先熟练掌握单个串口的初始化、轮询发送、中断接收。
- 尝试用宏定义把串口句柄抽象出来,模拟板级适配。
- 研究 DMA + 空闲中断接收不定长数据。单字节中断接收适合学习,但不适合大数据量场景。
- 学习环形缓冲区,解决串口数据连续接收与主循环消费之间的矛盾。
- 在真实项目里再验证本文的 printf 输出切换方案,体会编译期宏与运行时切换各自的适用场景。
串口是嵌入式调试的“眼睛”,把这套配置和代码组织方式吃透,后面调试 SPI、I2C、CAN 时,你会少走很多弯路。遇到打印不出数据时,先用最底层的HAL_UART_Transmit确认硬件通路,再从 printf 重定向、编码、工程配置三个层面逐一排查,问题基本都能定位。