在嵌入式开发中,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 EWARM | int 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 |
| UART2 | GPS 接收 | 9600 | 无 | 8N1 |
| UART3 | RS485 总线 | 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 中文乱码
现象:串口助手能正常收到英文和数字,但中文显示成乱码。
排查顺序:
- 检查终端编码。Windows 上常用 GBK/GB2312,而编译工具链和源码文件输出的一般是 UTF-8。两者不一致时,中文必然乱码。把串口助手编码切换为 UTF-8,或者在编译选项中统一字符集。
- 检查波特率。实际波特率和串口助手设置不一致时也会出现乱码,但这种情况下通常连英文字符都是错的。如果英文正常只有中文乱码,优先怀疑字符编码问题。
- 检查数据是否被截断。如果发送缓冲区较小,或者发送函数在高波特率下逐字节阻塞发送,中文这种多字节字符可能在中间被截断,接收端就会显示异常。
5.2 串口完全无输出
现象:printf 没有任何输出,但接线正确、串口助手设置正确。
排查顺序:
- 看重定向函数是否真正参与链接。GCC 工程使用 nano.specs 时,如果没有正确实现
_write,printf 会直接静默失败。 - 看发送函数是否一直阻塞在
HAL_UART_Transmit中。例如 UART 没有初始化成功,HAL 库返回HAL_BUSY,fputc会一直等待。 - 看硬件接线和驱动。TX 是否接到对端 RX,USB 转串口芯片如 FT232R、CP2102 的驱动是否安装正确,调试器上是否选对了串口口号。
- 看 CubeMX 重新生成后,宏定义指向的句柄是否仍然存在。例如
huart1在某个条件下被条件编译裁掉,DEBUG_UART_HANDLE引用的就是一个不存在的对象。
5.3 多串口中断冲突
现象:多个串口同时收发时,高负载场景下出现丢数据或卡死。
常见原因:
- 中断优先级设置不合理。调试串口数据量小,优先级可以低一些;RS485 有协议时限要求,优先级应该更高。
- 某些芯片的多个 UART 共用一个中断向量号。例如部分型号的 UART4 和 UART5 共用中断,中断处理函数里必须判断是哪个串口触发的,不能只处理其中一个。
- 接收中断没有及时读取数据,数据寄存器被下一个字节覆盖,产生溢出错误
ORE,之后如果不主动清除,可能一直阻塞接收。
解决办法:给每个串口规划明确的 NVIC 优先级;接收中断里优先把数据搬到环形缓冲区;高波特率场景使用 DMA 接收,降低 CPU 负担。
5.4 问题速查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| printf 中文乱码 | 终端编码 |