UART宏定义与printf重定向:STM32多串口工程化配置指南

📅 发布时间:2026/9/1 17:05:55
UART宏定义与printf重定向:STM32多串口工程化配置指南
在嵌入式开发中UART 串口通信是最常用的调试手段之一而宏定义则是管理串口配置最基础也最有效的工具。很多工程师能够用 HAL 库或标准外设库点亮一个串口但当项目进入多串口阶段遇到 printf 输出通道切换、板级引脚差异、中断优先级管理等问题时代码就开始失去控制。这篇文章以 STM32 和 HAL 库为主环境同时兼顾 GD32、ESP32 等常见平台围绕 UART 宏定义、printf 输出选择、多串口配置三条主线给出可复现的工程化写法并在最后整理一份可直接用于验收的发布前检查清单。1. 先理解 UART 宏定义要解决的真实问题1.1 没有宏定义时串口代码为什么难维护很多串口例程会把初始化代码直接写在函数里寄存器计算、引脚编号、波特率都是硬编码。以 STM32F1 为例一段直接操作寄存器的代码是这个样子void DebugUart_Init(void) { RCC-APB2ENR | RCC_APB2ENR_USART1EN; GPIOA-CRH ~(GPIO_CRH_CNF10_Msk | GPIO_CRH_MODE10_Msk); GPIOA-CRH | GPIO_CRH_CNF10_1 | GPIO_CRH_MODE10; USART1-BRR 0x1D4C; USART1-CR1 | USART_CR1_TE | USART_CR1_RE | USART_CR1_UE; }这段代码在单个串口的开发板上能跑但放进真实项目后会暴露出三个问题。第一外设实例、引脚、波特率全部散落在函数内部换一个引脚或者换一个串口需要逐行重新计算和修改。第二硬件改版后很难通过全局搜索快速定位所有需要修改的位置。第三多串口项目里相似代码会被复制出很多份改一处漏一处排查问题时往往先从这种低级错误开始。1.2 宏定义的价值在于把“变的东西”集中起来宏定义的本质是给一个“可能变化”的配置项一个稳定的名字。UART 初始化时真正会变的东西通常只有四类串口外设实例例如 USART1、USART2、UART4。引脚和复用功能例如 PA9/PA10、PB10/PB11。通信参数例如波特率、数据位、停止位、奇偶校验。中断号和优先级。把这四类信息抽成宏之后函数体和驱动层只依赖这些宏的名字不依赖具体某个串口。硬件一变只需要修改头文件里的宏不需要动驱动逻辑。UART 和 I2C、SPI 不同它是异步串行通信收发双方之间没有时钟线必须在初始化阶段约定波特率、数据位、奇偶校验和停止位。这些参数只要有一项不匹配接收到的数据就是乱码或者完全不可用。因此 UART 的配置项天然比 I2C、SPI 这类带时钟线的同步接口更多也天然适合用宏集中管理。1.3 一组最小可用的 UART 宏定义以 STM32 HAL 库为例一个可以供多个文件引用的串口配置头文件可以这样组织#ifndef UART_CONFIG_H #define UART_CONFIG_H #include main.h /* 串口外设实例 */ #define DEBUG_UART_INSTANCE USART1 /* 通信参数 */ #define DEBUG_UART_BAUDRATE 115200UL #define DEBUG_UART_WORD_LENGTH UART_WORDLENGTH_8B #define DEBUG_UART_STOPBITS UART_STOPBITS_1 #define DEBUG_UART_PARITY UART_PARITY_NONE /* 引脚定义 */ #define DEBUG_UART_TX_PORT GPIOA #define DEBUG_UART_TX_PIN GPIO_PIN_9 #define DEBUG_UART_RX_PORT GPIOA #define DEBUG_UART_RX_PIN GPIO_PIN_10 /* 中断配置 */ #define DEBUG_UART_IRQN USART1_IRQn #define DEBUG_UART_IRQ_PRIORITY 5 #endif对应的初始化函数可以写成void DebugUart_Init(void) { UART_HandleTypeDef huart {0}; huart.Instance DEBUG_UART_INSTANCE; huart.Init.BaudRate DEBUG_UART_BAUDRATE; huart.Init.WordLength DEBUG_UART_WORD_LENGTH; huart.Init.StopBits DEBUG_UART_STOPBITS; huart.Init.Parity DEBUG_UART_PARITY; huart.Init.Mode UART_MODE_TX_RX; huart.Init.HwFlowCtl UART_HWCONTROL_NONE; huart.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart); }当硬件改版把调试串口从 USART1 换到 USART3并且 TX/RX 引脚变为 PB10/PB11 时只需要修改头文件里的宏不需要动初始化函数。这就是宏定义最直接的收益。2. printf 输出选择重定向、编译期切换与运行期切换2.1 为什么 printf 在单片机里默认没有输出C 标准库的 printf 最终通过 stdout 输出在 PC 上 stdout 对应控制台终端。单片机没有操作系统也没有终端设备printf 只是把数据送进了库内部的缓冲并没有连接任何硬件。因此要让 printf 工作第一步是做重定向把库函数最终写字符的动作接到 UART 发送函数上。不同编译器的重定向入口不同编译器 / 工具链需要实现的函数说明Keil MDKarmccint fputc(int ch, FILE *f)标准 C 库重定向IAR EWARMint putchar(int out)或size_t __write(...)IAR 库实现略有差异GCCarm-none-eabi-gccint _write(int fd, char *ptr, int len)newlib 系统调用层ESP-IDF / 其他 RTOS通常已有日志组件建议直接使用日志接口Keil 环境最常用的写法#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }GCC 环境需要实现_writeint _write(int fd, char *ptr, int len) { for (int i 0; i len; i) { HAL_UART_Transmit(huart1, (uint8_t *)ptr[i], 1, HAL_MAX_DELAY); } return len; }这里要注意如果工程里同时有多个串口fputc中写死huart1就限制了 printf 只能输出到 UART1。这就是“printf 输出选择”要解决的问题。2.2 用宏定义决定 printf 输出到哪个串口最直接的办法是把fputc里的串口句柄也抽成一个宏#define DEBUG_UART_HANDLE (huart1) int fputc(int ch, FILE *f) { HAL_UART_Transmit(DEBUG_UART_HANDLE, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }换输出串口时只需要修改DEBUG_UART_HANDLE。如果再配合板级宏可以做到同一份代码编译出不同输出通道#if defined(BOARD_V1) #define DEBUG_UART_HANDLE (huart1) #elif defined(BOARD_V2) #define DEBUG_UART_HANDLE (huart3) #else #error Unknown board, please define BOARD_V1 or BOARD_V2 #endif这是编译期切换程序运行后不会再改变输出通道适合硬件型号固定、日志走固定调试串口的量产场景。编译期切换的另一个好处是未被选中的串口代码可以被编译器优化掉减少代码体积。2.3 运行期切换用全局句柄变量某些场景需要在运行中切换 printf 的输出通道。例如一台设备同时有本地调试串口和远程调试串口程序在检测到上位机连接后希望把日志改发到另一个串口。此时不能再把句柄用宏写死在fputc里需要引入一个可变的指针变量UART_HandleTypeDef *g_debug_uart huart1; int fputc(int ch, FILE *f) { if (g_debug_uart ! NULL) { HAL_UART_Transmit(g_debug_uart, (uint8_t *)ch, 1, HAL_MAX_DELAY); } return ch; } void DebugPort_Select(UART_HandleTypeDef *huart) { if (huart ! NULL) { g_debug_uart huart; } }调用DebugPort_Select(huart2)之后后续 printf 输出就会切到 UART2。运行期切换灵活但有两个代价第一切换瞬间如果另一个串口正在发送可能出现数据交错第二在多线程或中断环境下对全局指针变量的读写需要加临界保护否则可能读到不完整的指针值。3. 多串口配置的工程化组织3.1 真实项目里的串口分布一个同时带 GPS、RS485 和蓝牙模组的设备串口分配大致如下串口实例功能波特率奇偶校验数据格式UART1调试日志115200无8N1UART2GPS 接收9600无8N1UART3RS485 总线38400偶校验8E1UART4蓝牙透传115200无8N1不同外设对通信参数的要求差异很大。RS485 常配偶校验GPS 模块多为 9600 8N1蓝牙透传通常与调试口接近。如果每个串口都单独写一套初始化函数初始化代码会迅速膨胀而且项目里会充满“看起来一样但参数不同”的重复片段。3.2 用配置表管理多串口工程上一种成熟做法是“宏定义 结构体数组”的组合。每个串口的硬件参数在配置表中集中描述驱动层用一个通用函数统一初始化#define TABLESIZE(arr) (sizeof(arr) / sizeof((arr)[0])) #define UART_CHANNEL_MAX 4 typedef struct { USART_TypeDef *instance; GPIO_TypeDef *tx_port; uint16_t tx_pin; GPIO_TypeDef *rx_port; uint16_t rx_pin; uint32_t baudrate; uint32_t word_length; uint32_t stop_bits; uint32_t parity; uint8_t irq_priority; } UartConfig; static UART_HandleTypeDef s_huart[UART_CHANNEL_MAX]; static const UartConfig g_uart_config[] { { .instance USART1, .tx_port GPIOA, .tx_pin GPIO_PIN_9, .rx_port GPIOA, .rx_pin GPIO_PIN_10, .baudrate 115200UL, .word_length UART_WORDLENGTH_8B, .stop_bits UART_STOPBITS_1, .parity UART_PARITY_NONE, .irq_priority 5, }, { .instance USART2, .tx_port GPIOA, .tx_pin GPIO_PIN_2, .rx_port GPIOA, .rx_pin GPIO_PIN_3, .baudrate 9600UL, .word_length UART_WORDLENGTH_8B, .stop_bits UART_STOPBITS_1, .parity UART_PARITY_NONE, .irq_priority 6, }, /* 继续增加 UART3、UART4 配置 */ }; void UartDrv_InitAll(void) { uint32_t i; UART_HandleTypeDef *huart; for (i 0; i TABLESIZE(g_uart_config); i) { huart s_huart[i]; huart-Instance g_uart_config[i].instance; huart-Init.BaudRate g_uart_config[i].baudrate; huart-Init.WordLength g_uart_config[i].word_length; huart-Init.StopBits g_uart_config[i].stop_bits; huart-Init.Parity g_uart_config[i].parity; huart-Init.Mode UART_MODE_TX_RX; huart-Init.HwFlowCtl UART_HWCONTROL_NONE; huart-Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart); } }新增一个串口时只需要在配置表中增加一行并确认s_huart数组容量足够不需要再复制一整段初始化函数。配置表的顺序建议与逻辑通道编号一致避免查找时还要逐个对名字。3.3 宏定义与配置表如何配合搜索材料里经常出现“宏定义数组”这个关键词。在 C 语言中宏定义本身不能直接声明“数组元素”但可以通过宏来统一配置表中的公共参数或者用宏定义数组长度。例如把所有串口相同的默认参数抽成宏#define UART_DEFAULT_MODE UART_MODE_TX_RX #define UART_DEFAULT_FLOWCTL UART_HWCONTROL_NONE #define UART_DEFAULT_OVERSAMPLING UART_OVERSAMPLING_16配置表中就不再重复填写这些字段每个串口只保留真正不同的参数。这样做的好处是减少冗余同时降低漏改概率。如果某一天需要把所有串口从 16 倍过采样改为 8 倍过采样只需要修改这一个宏。4. 宏定义如何与 HAL 库、CubeMX 生成代码协作4.1 CubeMX 生成代码应该怎么改很多 STM32 工程用 CubeMX 生成初始化代码。默认生成的main.c中每个串口对应一个独立的初始化函数例如MX_USART1_UART_Init()。工程化时要区分两类文件生成文件main.c、usart.c不建议手工修改重新生成会覆盖。用户层文件uart_config.h、log.c、uart_drv.c自己维护负责引用生成文件中的句柄。推荐的分层方式如下user/ uart_config.h // UART 宏定义 uart_drv.c // 初始化、配置表、引脚复用 log.c // printf 重定向、日志接口 core/ main.c // CubeMX 生成不手工修改 usart.c // CubeMX 生成不手工修改CubeMX 负责生成外设初始化用户层负责“选哪个串口、走哪个引脚、printf 输出到哪里”。这样重新生成 CubeMX 代码时用户配置不会丢失。4.2 中断处理和引脚复用也要被宏管理UART 配置不仅包括初始化参数还包括引脚复用、中断使能和中断处理函数名。以 STM32F1 为例TX/RX 引脚要配置为复用推挽输出void UartDrv_InitPins(const UartConfig *cfg) { GPIO_InitTypeDef gpio {0}; gpio.Mode GPIO_MODE_AF_PP; gpio.Speed GPIO_SPEED_FREQ_HIGH; gpio.Pin cfg-tx_pin; HAL_GPIO_Init(cfg-tx_port, gpio); gpio.Pin cfg-rx_pin; HAL_GPIO_Init(cfg-rx_port, gpio); }上面的代码没有处理不同端口对应的时钟使能实际工程中应该把时钟使能也封装成宏例如定义DEBUG_UART_TX_GPIO_CLK_ENABLE()这样每个串口可以独立指定自己的端口时钟而不是全部盲目使能 GPIOA 和 GPIOB。不同芯片的复用功能映射差异很大。例如 GD32 的部分串口支持 7、8、9 位数据位有些型号还支持 8 倍过采样ESP32 的 UART 引脚几乎可以任意映射到大多数 GPIO但不同 SoC 版本支持的映射范围不同。配置前必须查阅对应参考手册不能假设 STM32 的写法在 GD32 上原样可用。5. 常见问题排查从现象找根因5.1 printf 中文乱码现象串口助手能正常收到英文和数字但中文显示成乱码。排查顺序检查终端编码。Windows 上常用 GBK/GB2312而编译工具链和源码文件输出的一般是 UTF-8。两者不一致时中文必然乱码。把串口助手编码切换为 UTF-8或者在编译选项中统一字符集。检查波特率。实际波特率和串口助手设置不一致时也会出现乱码但这种情况下通常连英文字符都是错的。如果英文正常只有中文乱码优先怀疑字符编码问题。检查数据是否被截断。如果发送缓冲区较小或者发送函数在高波特率下逐字节阻塞发送中文这种多字节字符可能在中间被截断接收端就会显示异常。5.2 串口完全无输出现象printf 没有任何输出但接线正确、串口助手设置正确。排查顺序看重定向函数是否真正参与链接。GCC 工程使用 nano.specs 时如果没有正确实现_writeprintf 会直接静默失败。看发送函数是否一直阻塞在HAL_UART_Transmit中。例如 UART 没有初始化成功HAL 库返回HAL_BUSYfputc会一直等待。看硬件接线和驱动。TX 是否接到对端 RXUSB 转串口芯片如 FT232R、CP2102 的驱动是否安装正确调试器上是否选对了串口口号。看 CubeMX 重新生成后宏定义指向的句柄是否仍然存在。例如huart1在某个条件下被条件编译裁掉DEBUG_UART_HANDLE引用的就是一个不存在的对象。5.3 多串口中断冲突现象多个串口同时收发时高负载场景下出现丢数据或卡死。常见原因中断优先级设置不合理。调试串口数据量小优先级可以低一些RS485 有协议时限要求优先级应该更高。某些芯片的多个 UART 共用一个中断向量号。例如部分型号的 UART4 和 UART5 共用中断中断处理函数里必须判断是哪个串口触发的不能只处理其中一个。接收中断没有及时读取数据数据寄存器被下一个字节覆盖产生溢出错误ORE之后如果不主动清除可能一直阻塞接收。解决办法给每个串口规划明确的 NVIC 优先级接收中断里优先把数据搬到环形缓冲区高波特率场景使用 DMA 接收降低 CPU 负担。5.4 问题速查表问题现象常见原因检查方式处理建议printf 中文乱码终端编码