
很多做嵌入式开发的朋友在项目初期都体会过“串口自由”的快乐一个调试串口连上电脑printf 一打世界尽在掌握。但等到项目真正做起来板子上挂了三四个串口外设有的接蓝牙模块有的接传感器有的接上位机事情就变得复杂了。最头疼的往往是这几个问题第一个printf 默认输出到哪个串口我想让它在调试串口和通信串口之间切换难道要每次重写 fputc第二不同固件版本里串口引脚分配不一样代码里到处都是huart1、huart2改一个板子就要全局搜索替换太容易出错。第三网上代码风格五花八门有的按宏定义管理串口有的直接在函数里硬编码新接手项目的人根本不知道哪个串口是干嘛的。这篇文章就针对这三个痛点把 UART 的宏定义设计、printf 输出选择、多串口配置这三块内容彻底串起来讲清楚。我会从 UART 基础概念讲起然后重点拆解一套我自己项目里常用的串口配置框架用宏定义统一管串口资源通过一个宏开关切换 printf 输出通道再配合多串口初始化与收发处理代码。这套方案在 STM32 上验证过但设计思路对其他 MCU 平台同样适用。如果你是刚接触嵌入式串口开发的新手这篇文章能帮你把 UART 的基本概念和 printf 重定向原理弄明白如果你已经有项目经验可以直接跳到第三、四节看看这套宏定义管理多串口的思路能不能移植到你的代码里。1. UART 基础与相关概念1.1 到底什么是 UARTUART 的全称是 Universal Asynchronous Receiver/Transmitter通用异步收发器。从名字就能看出来它最核心的特点是“异步”。所谓异步就是发送方和接收方不需要共享同一个时钟信号。通信双方各自用自己的时钟只要提前约定好波特率就能正确解析出每一位数据。这和我们平常接触的 I2C、SPI 不太一样I2C 和 SPI 都是同步通信主机在发送数据的同时会输出时钟信号从机跟着时钟去采样数据。UART 的物理连接非常简单最少只需要两根线TX发送数据线RX接收数据线通信双方要交叉连接A 的 TX 接 B 的 RXA 的 RX 接 B 的 TX。如果是和电脑通信通常还要共地否则电平参考不一致会出现乱码甚至完全收不到数据的现象。数据格式上UART 的一帧数据一般由这几部分组成组成部分说明起始位1 位低电平表示一帧数据的开始数据位通常为 8 位也有 7 位、9 位等配置校验位可选奇校验、偶校验或无校验停止位通常为 1 位或 2 位高电平表示一帧结束收发双方必须保证波特率、数据位、停止位、校验位完全一致否则就会出现乱码。这也是串口通信调试中最常见的坑看起来线接对了设备也枚举出来了但打印全乱多半就是这些参数没对齐。1.2 UART 和 USART 有什么区别很多初学者会在数据手册里看到 USART 和 UART 两个词。简单说USART 的全称是 Universal Synchronous/Asynchronous Receiver/Transmitter比 UART 多了一个 Synchronous。也就是说USART 既能做异步通信也能做同步通信在硬件上多了一个时钟引脚。但在绝大多数实际项目中我们使用 USART 时都是工作在异步模式下功能上和 UART 没什么区别。所以在写代码和做配置时很多人不会刻意区分这两个概念称呼上统一叫串口。1.3 常见的 UART 应用场景UART 在嵌入式系统中可以说是“万金油”级别的存在应用场景非常广泛调试日志输出最经典的应用printf 重定向到串口通过串口助手查看日志。与 GPS 模块、蓝牙模块、Wi-Fi 模块、4G 模块通信这些外设几乎清一色是串口接口。与其他 MCU 通信板级通信最简方案。通过 TTL 转 USB 芯片与电脑通信比如 CP2102、FT232、CH340 等芯片。也正因为串口使用频率高一个项目里往往不止一个串口。有的负责调试日志有的负责和模块通信有的负责和另一块板子握手。这时候如果没有一种清晰的多串口管理方案代码很快就会变得混乱不堪。2. 环境准备与版本说明在开始写代码之前先明确一下本文的演示环境。这个环境不需要和我完全一致关键是理解配置思路然后迁移到自己的工程里。我在编写本文时使用的环境如下硬件平台STM32F103 系列开发板以小容量 Flash 型号为参考开发环境Keil MDK STM32CubeMX固件库STM32 HAL 库调试工具USB 转 TTL 模块串口助手软件有一点要特别说明本文的核心是宏定义和多串口配置思路不是某一个芯片的特有功能。大部分代码稍作修改就可以移植到 STM32 其他系列、GD32、N32 等基于 ARM Cortex-M 内核的 MCU 上。宏定义的方式更是和具体芯片无关即使你用的是寄存器开发也可以参考这套框架。如果你用的开发环境不是 Keil MDK而是 GCC 或者 IARprintf 重定向的实现方式会有细微差别主要在于底层_write或fputc的实现路径不同。不过宏定义管理多串口的思路完全一致。下面我给出一个比较典型的工程结构方便后面实操部分对照Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── uart_config.h │ │ └── usart.h │ └── Src/ │ ├── main.c │ ├── uart_config.c │ ├── usart.c │ └── stm32f1xx_hal_msp.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── MDK-ARM/ └── ...其中uart_config.h和uart_config.c就是本文要重点设计的两个文件。理解了这个自定义分层之后再去看 STM32CubeMX 生成的代码就不会觉得混乱了。3. 核心宏定义设计与 printf 重定向原理3.1 为什么要用宏定义管理串口先看一段反面教材。很多项目里的串口代码是这样写的// 这里直接操作全局句柄 HAL_UART_Transmit(huart1, (uint8_t *)Hello\r\n, strlen(Hello\r\n), 1000);如果只有一个串口这么写问题不大。但如果有三个串口调试串口是 USART1蓝牙模块是 USART2传感器是 USART3那么代码里就会到处出现huart1、huart2、huart3。某一天硬件改版把调试串口从 USART1 换成了 USART2那么所有涉及打印日志的地方都要改。有人会说了用全局搜索替换不就行了但在工程化项目中huart2可能同时被蓝牙模块使用简单替换会引入隐蔽的 bug。宏定义的价值就在于把“串口的逻辑功能”和“串口的物理编号”解耦。我们在代码里不再直接面对huart1、huart2而是定义一个逻辑宏比如DEBUG_UART代表调试串口BLE_UART代表蓝牙串口。日后硬件改版只需要修改宏的映射关系代码逻辑一行都不用动。这就是常说的“配置与逻辑分离”虽然宏定义不是最强大的抽象手段但在 C 语言嵌入式项目中它简单、高效、零开销非常适合做资源映射。3.2 串口资源的宏定义设计下面这套宏定义设计我把它放在独立的uart_config.h头文件中方便集中管理。// 文件路径Core/Inc/uart_config.h #ifndef __UART_CONFIG_H #define __UART_CONFIG_H #include usart.h /* 串口逻辑编号定义 */ #define UART_DEBUG 0 #define UART_BLE 1 #define UART_SENSOR 2 /* 串口逻辑编号到物理句柄的映射 */ #define DEBUG_UART_HANDLE (huart1) #define BLE_UART_HANDLE (huart2) #define SENSOR_UART_HANDLE (huart3) /* printf 默认输出串口可以在这里切换 */ #define PRINTF_UART_HANDLE DEBUG_UART_HANDLE #endif /* __UART_CONFIG_H */这个文件做的事情非常清晰第一组宏UART_DEBUG、UART_BLE、UART_SENSOR定义了逻辑编号当项目里需要数组、回调表等按序号索引的结构时使用。第二组宏把逻辑编号映射到具体物理串口的句柄本质上是“逻辑功能”到“硬件资源”的映射表。第三组宏PRINTF_UART_HANDLE是 printf 的输出目标这是整个设计中最关键的一个开关。为什么把逻辑编号和物理句柄分开因为有些场景需要传编号比如printf(串口%d发送成功, UART_DEBUG)有些场景需要传句柄比如直接调用HAL_UART_Transmit。两套宏配合使用既能满足代码层面的逻辑清晰又不妨碍底层硬件的操作。3.3 带参数的宏封装串口发送函数有了句柄映射之后下一步是封装发送和接收接口。这里我建议使用带参数的宏或者封装成内联函数。带参数的宏写起来非常简洁// 文件路径Core/Inc/uart_config.h续 #define UART_SEND_BYTE(handle, byte) \ HAL_UART_Transmit(handle, (uint8_t *)(byte), 1, 100) #define UART_SEND_STRING(handle, str) \ HAL_UART_Transmit(handle, (uint8_t *)(str), strlen(str), 100) #define UART_SEND_DATA(handle, buffer, len) \ HAL_UART_Transmit(handle, (uint8_t *)(buffer), (len), 100)使用方式如下// 向调试串口发送字符串 UART_SEND_STRING(DEBUG_UART_HANDLE, System Init OK\r\n); // 向蓝牙模块发送一帧数据 uint8_t ble_data[] {0xAA, 0x55, 0x01, 0x00}; UART_SEND_DATA(BLE_UART_HANDLE, ble_data, sizeof(ble_data));注意宏定义中的100是超时时间单位是毫秒。在实际项目中这个值要根据波特率和单次发送数据量去调整。如果串口波特率很低数据量又大100ms 可能不够会导致发送超时返回错误。更稳妥的做法是把它单独定义成一个宏方便统一调整#define UART_TIMEOUT_MS (500)可能有人会问为什么不直接封装成函数因为用宏的好处是零调用开销而且宏的内联行为在某些优化等级下比编译器自动内联更可控。但宏也有缺点比如调试时不方便打断点、参数类型检查不严格。所以在实际工程中我更推荐在宏的基础上再套一层函数封装代码结构会更清晰。这个放到第四节的完整示例中展示。3.4 printf 重定向到串口的实现原理在标准 C 库中printf最终会调用一个底层字符输出函数。在 ARM 平台的 Keil MDK 环境中这个函数就是fputc。如果我们能够重新实现fputc让它把字符写到串口而不是标准输出设备那么所有调用printf的地方就自动把内容输出到了串口。这就是所谓的“printf 重定向”。重定向代码一般长这样// 文件路径Core/Src/uart_config.c #include stdio.h #include uart_config.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(PRINTF_UART_HANDLE, (uint8_t *)ch, 1, 100); return ch; }这里的重要设计点在于重定向函数调用的不是huart1而是PRINTF_UART_HANDLE。也就是说printf 输出到哪个串口完全由宏决定。如果要切换 printf 输出到蓝牙串口只需要修改uart_config.h/* 把 printf 切到蓝牙串口 */ #define PRINTF_UART_HANDLE BLE_UART_HANDLE重新编译烧录所有日志就都从蓝牙串口出去了其他代码一字不改。这个方案的关键是把“printf 输出目标”从代码实现中抽离变成编译期配置。这在调试不同硬件版本、做功能裁剪时非常有用。除了 Keil 的fputc方案还有一种常见的方式是重写_write函数这在 GCC 环境下比较常用int _write(int file, char *ptr, int len) { HAL_UART_Transmit(PRINTF_UART_HANDLE, (uint8_t *)ptr, len, 100); return len; }不管哪种方式核心逻辑都是一样的把标准输出导向我们的串口句柄。3.5 printf 中文乱码的原因和处理思路一旦 printf 重定向成功很多人会遇到一个典型问题英文和数字打印正常中文却乱码。这个问题其实和 UART 本身无关而是和编码方式有关。在 Keil MDK 环境中源文件的默认编码可能是 GBK 或 GB2312而你的串口工具有时按 UTF-8 解析有时按 GBK 解析两边不一致就会乱码。另外STM32CubeMX 生成的工程源码通常默认是 UTF-8 编码如果你在 Keil 里另建了源文件却没有统一编码就会把不同编码的源文件混在同一个工程里。解决思路有两条第一统一源文件编码。打开 Keil MDK 的 Edit - Configuration - Editor - Encoding把整个工程的源文件统一设为 UTF-8。串口助手也设置为 UTF-8 显示。第二程序里尽量避免直接输出中文字符串改用 UTF-8 编码的十六进制字节数组或者干脆使用英文日志。嵌入式设备调试日志的核心目标是传递信息英文日志配合十六进制数据在很多场景下反而更安全。如果是字符串中有中文且源文件是 UTF-8 编码串口助手也设为 UTF-8那么乱码问题基本就能解决。注意不要在代码里混用编码否则会出现“同一个文件里中文英文都对但换一个文件就乱了”的诡异情况。4. 完整实战多串口配置与 printf 输出切换前面铺垫了概念和原理现在做一个完整的实战示例。这个示例会建立一个三串口的工程实现以下功能USART1 作为调试串口连接 USB 转 TTL输出系统日志。USART2 作为蓝牙模块通信串口。USART3 作为传感器数据接收串口。printf 默认输出到调试串口。通过宏定义可以切换 printf 输出到任意串口。所有串口句柄在代码中以逻辑名称引用不出现裸奔的huartx。4.1 创建项目结构我们可以先使用 STM32CubeMX 生成基础工程。在 CubeMX 中使能 USART1、USART2、USART3三个串口都配置为异步模式。USART1 参数1152008N1打开串口中断或 DMA 可以根据需求选择。USART2 和 USART3 分别设置为模块通信和传感器接收需要的波特率。USART3 因为是接收传感器数据建议使用中断接收方式在回调函数中处理数据。CubeMX 生成工程之后在 Core/Inc 下新建uart_config.h在 Core/Src 下新建uart_config.c。这样把自定义配置和 CubeMX 生成的代码分开管理后续重新生成代码时不容易被覆盖。4.2 编写宏定义头文件uart_config.h的设计如下// 文件路径Core/Inc/uart_config.h #ifndef __UART_CONFIG_H #define __UART_CONFIG_H #include usart.h #include string.h /* 串口逻辑编号 */ typedef enum { UART_CH_DEBUG 0, UART_CH_BLE, UART_CH_SENSOR, UART_CH_MAX } UART_Channel; /* 串口逻辑编号到物理句柄的映射宏 */ #define DEBUG_UART_HANDLE (huart1) #define BLE_UART_HANDLE (huart2) #define SENSOR_UART_HANDLE (huart3) /* printf 输出串口可在此切换 */ #define PRINTF_UART_HANDLE DEBUG_UART_HANDLE /* 串口收发超时时间 */ #define UART_TIMEOUT_MS 500 /* 封装发送接口 */ #define UART_SEND_BYTE(handle, byte) \ HAL_UART_Transmit(handle, (uint8_t *)(byte), 1, UART_TIMEOUT_MS) #define UART_SEND_STRING(handle, str) \ HAL_UART_Transmit(handle, (uint8_t *)(str), strlen(str), UART_TIMEOUT_MS) #define UART_SEND_DATA(handle, buffer, len) \ HAL_UART_Transmit(handle, (uint8_t *)(buffer), (len), UART_TIMEOUT_MS) #endif /* __UART_CONFIG_H */这个头文件中有两个关键点值得说明。第一逻辑编号用枚举而不是纯宏。枚举的好处是可以参与编译器的类型检查UART_CH_MAX还能用来限制范围方便在数组中做边界判断。第二PRINTF_UART_HANDLE是编译期开关。它是所有 printf 输出的唯一出口修改这个宏就能切换 printf 的物理串口。后续如果固件有两个版本一个版本日志走调试串口另一个版本日志走蓝牙串口只需要在编译脚本或者构建配置里定义不同的宏就行。4.3 编写串口配置实现文件接着编写uart_config.c在源文件中实现发送函数封装和 printf 重定向。// 文件路径Core/Src/uart_config.c #include uart_config.h #include stdio.h void UART_Init(void) { /* 这里可以放串口全局初始化逻辑如 DMA 配置、中断优先级设置等 */ /* 大多数初始化由 CubeMX 完成此函数用于业务层的额外配置 */ } UART_Status UART_Send(UART_Channel ch, uint8_t *data, uint16_t len) { UART_HandleTypeDef *handle NULL; switch (ch) { case UART_CH_DEBUG: handle DEBUG_UART_HANDLE; break; case UART_CH_BLE: handle BLE_UART_HANDLE; break; case UART_CH_SENSOR: handle SENSOR_UART_HANDLE; break; default: return UART_ERROR; } if (HAL_UART_Transmit(handle, data, len, UART_TIMEOUT_MS) ! HAL_OK) { return UART_ERROR; } return UART_OK; } /* printf 重定向Keil MDK 环境 */ int fputc(int ch, FILE *f) { HAL_UART_Transmit(PRINTF_UART_HANDLE, (uint8_t *)ch, 1, UART_TIMEOUT_MS); return ch; }这里用到了UART_Status、UART_OK、UART_ERROR这几个自定义类型。它们可以定义在头文件里typedef enum { UART_OK 0, UART_ERROR, UART_TIMEOUT } UART_Status;注意fputc中调用的句柄是PRINTF_UART_HANDLE不是写死的huart1。这是整个设计中最核心的“输出选择”逻辑。你可能会想为什么要包一层UART_Send函数不直接用宏我的考虑是函数体的调试体验更好可以在开发者眼中看到完整的调用栈。宏适合非常高频、非常简短的调用而串口发送这种涉及错误处理的场景函数封装可以做统一状态管理和错误处理。两者结合高低搭配才是工程化项目的合理选择。4.4 多串口接收处理串口通信不仅有发送还有接收。实际开发中接收往往比发送更麻烦因为数据什么时候来是未知的。对于多串口接收我建议使用 HAL 库的中断接收方式。CubeMX 中使能 USART1、USART2、USART3 的全局中断然后在main.c或uart_config.c中启动接收。以下是一个简单的接收框架使用单字节中断接收配合回调函数把不同串口的数据分发到不同的处理函数中。// 文件路径Core/Src/uart_config.c续 /* 接收缓冲区 */ uint8_t uart_rx_byte[UART_CH_MAX]; void UART_StartReceive(void) { HAL_UART_Receive_IT(huart1, uart_rx_byte[UART_CH_DEBUG], 1); HAL_UART_Receive_IT(huart2, uart_rx_byte[UART_CH_BLE], 1); HAL_UART_Receive_IT(huart3, uart_rx_byte[UART_CH_SENSOR], 1); } /* HAL 库接收回调 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart huart1) { UART_OnDebugByte(uart_rx_byte[UART_CH_DEBUG]); HAL_UART_Receive_IT(huart1, uart_rx_byte[UART_CH_DEBUG], 1); } else if (huart huart2) { UART_OnBleByte(uart_rx_byte[UART_CH_BLE]); HAL_UART_Receive_IT(huart2, uart_rx_byte[UART_CH_BLE], 1); } else if (huart huart3) { UART_OnSensorByte(uart_rx_byte[UART_CH_SENSOR]); HAL_UART_Receive_IT(huart3, uart_rx_byte[UART_CH_SENSOR], 1); } }这里要注意HAL_UART_RxCpltCallback是所有 UART 中断接收共用的回调函数所以必须在函数内部通过huart参数判断中断来自哪个串口。不能用if (huart PRINTF_UART_HANDLE)这种写法其实也可以但建议用物理句柄直接判断因为接收处理函数是绑定硬件的和 printf 输出目标无关。在各个硬件串口对应的处理函数中再根据业务逻辑决定使用哪个逻辑通道。比如蓝牙模块的数据解析可以这样写// 文件路径Core/Src/uart_config.c续 void UART_OnBleByte(uint8_t byte) { /* 将字节放入蓝牙数据环形缓冲区 */ /* 解析逻辑放到主循环或单独任务中处理 */ }我在实际项目中通常会在每个业务串口上挂一个环形缓冲区中断只负责往缓冲区里丢字节主循环或 RTOS 任务负责从缓冲区取数据进行协议解析。这种方式避免了串口数据处理和时间相关的耦合问题。4.5 运行与验证将所有代码编译下载到开发板后用 USB 转 TTL 模块连接 USART1打开串口助手设置波特率 115200、8N1。如果一切正常调用printf的地方就能正常在串口助手上显示。我们可以写一段测试代码来验证多串口和 printf 输出选择// 文件路径Core/Src/main.cmain 函数中的示例代码 #include uart_config.h int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_USART2_UART_Init(); MX_USART3_UART_Init(); UART_StartReceive(); /* printf 输出到 DEBUG_UART_HANDLE */ printf([SYS] System boot OK, UART test start\r\n); uint8_t ble_frame[] {0xAA, 0x55, 0x01, 0x00, 0x0D, 0x0A}; UART_Send(UART_CH_BLE, ble_frame, sizeof(ble_frame)); uint8_t sensor_cmd 0x01; UART_SEND_BYTE(SENSOR_UART_HANDLE, sensor_cmd); while (1) { /* 主循环 */ } }预期结果调试串口输出[SYS] System boot OK, UART test start。蓝牙串口收到AA 55 01 00 0D 0A。传感器串口收到01。如果要把 printf 输出切到蓝牙串口只需要修改uart_config.h#define PRINTF_UART_HANDLE BLE_UART_HANDLE重新编译烧录系统日志就会从蓝牙串口发出去。对于调试串口被临时占用的情况这个特性非常实用。5. 常见问题与排查思路在实际调试串口和 printf 的过程中情况往往不是一次就能跑通。我整理了下面这张问题排查表覆盖了最常见的几类问题。问题现象常见原因解决思路printf 完全没有输出没有实现 fputc 重定向确认代码中已有fputc或_write实现printf 完全没有输出使用了 MicroLIB 但勾选项不对在 Keil 中勾选 Target - Use MicroLIBprintf 输出到错误的串口PRINTF_UART_HANDLE映射配置不对检查uart_config.h中宏定义的目标句柄数据到电脑上显示乱码波特率或数据位/停止位不一致确认串口助手和 MCU 配置完全一致中文输出乱码源文件编码不统一统一为 UTF-8串口工具也设为 UTF-8只能发一次第二次卡死HAL_UART_Transmit 阻塞时间太长检查超时时间或改用中断/DMA 发送中断接收只收到一次数据回调中没有重新启动接收在回调末尾重新调用HAL_UART_Receive_IT多个串口互相干扰中断优先级配置冲突为不同串口配置合理中断优先级宏定义修改后没生效编译器缓存了旧的宏值执行 Clean 后重新编译确认宏在编译前被包含排查建议按照“硬件 - 配置 - 代码”的顺序进行。先用短接或示波器确认 TX 和 RX 信号正常再检查配置参数和宏定义最后确认代码逻辑。一条线走下来大部分问题都能定位。关于 MicroLIB 要多说两句。在 Keil 环境中不勾选 MicroLIB 时标准 C 库的printf功能完整但代码体积更大。勾选 MicroLIB 后printf功能被精简代码占用减小但某些复杂格式化能力可能受限。如果只是为了串口输出日志MicroLIB 完全够用而且重定向更简单。如果项目中使用了一些复杂的浮点格式化要注意测试是否正常。[\text{If printf outputs garbage, check the newline characters too. Some serial assistants treat \r\n differently. Using \r\n at the end of log strings is a common habit in embedded development. If you only send \n, the cursor may not return to the beginning of the line, causing the next line to overwrite the previous one visually. It looks like garbled output but is actually a line break issue. In uart_config.h, define a macro like this:#define LOG_CRLF \r\n然后日志输出统一使用printf(...%s, LOG_CRLF)或者直接在字符串里写\r\n不要只写\n。这是一个很小的细节但能省去不少调试时间。]6. 最佳实践与工程建议6.1 串口命名与注释规范多串口工程里命名规范直接影响代码可读性。我建议遵循以下约定硬件层句柄沿用 CubeMX 生成的huart1、huart2命名不随便改动。业务层宏定义采用“功能 UART_HANDLE”的格式比如DEBUG_UART_HANDLE、BLE_UART_HANDLE、GPS_UART_HANDLE。每个串口在uart_config.h中写明用途注释/* USART1系统调试日志输出115200-8-N-1 */ #define DEBUG_UART_HANDLE (huart1) /* USART2蓝牙模块通信9600-8-N-1 */ #define BLE_UART_HANDLE (huart2) /* USART3传感器数据接收115200-8-N-1 */ #define SENSOR_UART_HANDLE (huart3)不要写“USART1 接蓝牙”这种只描述物理连接的注释要写“蓝牙模块负责传输设备状态帧波特率 9600”这种能描述功能和业务逻辑的注释。6.2 串口资源分配原则在硬件设计阶段就要提前规划串口资源。下面几条原则值得注意调试串口尽量固定复用不要和业务串口混用。调试日志数据量大、频率高如果和关键业务串口共用可能干扰业务通信。低频外设和高频外设尽量分在不同串口。GPS 模块每秒输出一帧 NMEA 报文数据量不大但持续不断适合独立串口。对波特率敏感的外设比如某些蓝牙模块只能在特定波特率下工作要单独分配串口不要和其他模块共用一个串口做分时复用。需要长时间连续接收数据的串口建议开启 DMA而不是 CPU 中断逐字节搬运避免高负载下丢数据。6.3 printf 在正式产品中的工程化处理有一类问题在开发阶段不明显到了量产阶段就暴露出来printf 的实时性。HAL_UART_Transmit是阻塞式发送。如果日志打印非常频繁CPU 大部分时间都阻塞在发送上导致主流程响应变慢。这时候有几种处理方式第一种用中断发送。把日志内容放入缓冲区由中断异步发送CPU 可以继续执行主逻辑。第二种用 DMA 发送。DMA 搬运数据不占用 CPU适合大块日志输出。第三种实现分级日志。在调试版本中打开 printf在发布版本中用宏关闭打印。分级日志的宏定义可以这样实现// 文件路径Core/Inc/uart_config.h续 #define LOG_LEVEL_DEBUG 0 #define LOG_LEVEL_INFO 1 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL_ERROR 3 #ifndef LOG_LEVEL #define LOG_LEVEL LOG_LEVEL_DEBUG #endif #define LOG_DEBUG(fmt, ...) \ do { \ if (LOG_LEVEL LOG_LEVEL_DEBUG) \ printf([DBG] fmt \r\n, ##__VA_ARGS__); \ } while (0) #define LOG_INFO(fmt, ...) \ do { \ if (LOG_LEVEL LOG_LEVEL_INFO) \ printf([INF] fmt \r\n, ##__VA_ARGS__); \ } while (0) #define LOG_ERROR(fmt, ...) \ do { \ if (LOG_LEVEL LOG_LEVEL_ERROR) \ printf([ERR] fmt \r\n, ##__VA_ARGS__); \ } while (0)在正式发布版本中把LOG_LEVEL定义为LOG_LEVEL_ERROR调试信息、普通信息都会被编译器优化掉。为什么说编译器会优化掉因为if (0)分支在编译阶段就会被判定为假编译器会丢弃分支内的代码。这套分级日志方案配合宏切换 printf 输出目标串口模块的工程化程度会提升一个档次。6.4 串口初始化失败时的安全兜底要注意一点如果某个串口初始化失败比如硬件引脚被复用、外部模块未供电代码要能给出明确提示而不是静默失败。我习惯在初始化之后主动发送一条自检信息UART_Send(UART_CH_DEBUG, (uint8_t *)[SYS] UART self-test...\r\n, 24);如果这条信息能正常出现在调试串口上说明硬件链路基本没问题。如果出不来优先检查接线和引脚复用配置。7. 总结与下一步实践这篇文章从 UART 的基本概念出发围绕宏定义、printf 重定向、多串口配置三个核心点给出了一套完整的代码方案。关键内容可以总结为四点。第一UART 是异步串行通信接口通信双方必须预先约定波特率和数据格式。第二宏定义是管理多串口资源的有效手段把逻辑功能和物理编号解耦遇到硬件改版只需要修改映射关系。第三printf 重定向的核心在于实现底层fputc或_write函数输出目标由PRINTF_UART_HANDLE宏决定切换串口不用改业务代码。第四多串口工程要重视命名规范、日志分级和接收缓冲区设计。下一步可以继续学习的内容有很多。比如串口 DMA 收发如何与环形缓冲区配合这是高负载串口通信的常见方案。再比如如何用状态机解析串口协议帧这在处理 AT 指令、自定义协议时非常实用。如果你用的是带 RTOS 的工程串口任务间如何通过队列传递数据也是一个值得深入的话题。在动手实践时有三个地方要额外留意。硬件改版时先检查串口宏映射不要硬改业务代码。中文乱码先查源文件编码再查串口工具编码。多个串口同时工作时合理配置中断优先级并在回调中及时重新开启接收。串口这东西看起来简单真正做好其实不容易。但只要把配置层和业务层分离开用宏定义把关键资源管理起来多串口开发就会变得清晰很多。建议你先在自己手头的开发板上搭一个三串口的工程把 printf 切换、多串口收发都跑通一遍逐步迭代出自己的配置框架。