ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32 UART多串口配置与printf重定向实战指南

STM32 UART多串口配置与printf重定向实战指南 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/11USB 转串口模块通常需要安装驱动。如果电脑识别不到串口设备可以先检查驱动是否正确安装。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 的 PA10USART1_RXRX 接到 PA9USART1_TXGND 共地。打开串口助手设置波特率 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 重定向、编码、工程配置三个层面逐一排查问题基本都能定位。
返回列表