☰
UART宏定义与printf重定向:STM32多串口工程化配置指南
2026/10/3 12:14:26 网站建设 项目流程

在嵌入式开发中,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 MDK(armcc)int fputc(int ch, FILE *f)标准 C 库重定向
IAR EWARMint putchar(int out)或size_t __write(...)IAR 库实现略有差异
GCC(arm-none-eabi-gcc)int _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 环境需要实现_write:

int _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无8N1
UART2GPS 接收9600无8N1
UART3RS485 总线38400偶校验8E1
UART4蓝牙透传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 中文乱码

现象:串口助手能正常收到英文和数字,但中文显示成乱码。

排查顺序:

  1. 检查终端编码。Windows 上常用 GBK/GB2312,而编译工具链和源码文件输出的一般是 UTF-8。两者不一致时,中文必然乱码。把串口助手编码切换为 UTF-8,或者在编译选项中统一字符集。
  2. 检查波特率。实际波特率和串口助手设置不一致时也会出现乱码,但这种情况下通常连英文字符都是错的。如果英文正常只有中文乱码,优先怀疑字符编码问题。
  3. 检查数据是否被截断。如果发送缓冲区较小,或者发送函数在高波特率下逐字节阻塞发送,中文这种多字节字符可能在中间被截断,接收端就会显示异常。

5.2 串口完全无输出

现象:printf 没有任何输出,但接线正确、串口助手设置正确。

排查顺序:

  1. 看重定向函数是否真正参与链接。GCC 工程使用 nano.specs 时,如果没有正确实现_write,printf 会直接静默失败。
  2. 看发送函数是否一直阻塞在HAL_UART_Transmit中。例如 UART 没有初始化成功,HAL 库返回HAL_BUSY,fputc会一直等待。
  3. 看硬件接线和驱动。TX 是否接到对端 RX,USB 转串口芯片如 FT232R、CP2102 的驱动是否安装正确,调试器上是否选对了串口口号。
  4. 看 CubeMX 重新生成后,宏定义指向的句柄是否仍然存在。例如huart1在某个条件下被条件编译裁掉,DEBUG_UART_HANDLE引用的就是一个不存在的对象。

5.3 多串口中断冲突

现象:多个串口同时收发时,高负载场景下出现丢数据或卡死。

常见原因:

  • 中断优先级设置不合理。调试串口数据量小,优先级可以低一些;RS485 有协议时限要求,优先级应该更高。
  • 某些芯片的多个 UART 共用一个中断向量号。例如部分型号的 UART4 和 UART5 共用中断,中断处理函数里必须判断是哪个串口触发的,不能只处理其中一个。
  • 接收中断没有及时读取数据,数据寄存器被下一个字节覆盖,产生溢出错误ORE,之后如果不主动清除,可能一直阻塞接收。

解决办法:给每个串口规划明确的 NVIC 优先级;接收中断里优先把数据搬到环形缓冲区;高波特率场景使用 DMA 接收,降低 CPU 负担。

5.4 问题速查表

问题现象常见原因检查方式处理建议
printf 中文乱码终端编码

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询