作为一个常年跟 STM32 打交道的嵌入式工程师,每次拿到一块新板子,我做的前三件事里一定包含调通 UART。点灯固然重要,但灯只能告诉你“它活了”,串口却能告诉你“它活成了什么样”。这次接到 STM32C542 的项目,第一反应仍然是先把串口配置和 printf 重定向搞定,把串口终端里的第一行 Hello 打出来。这篇就是围绕 STM32C542 的 UART 配置和 printf 重定向做的一次完整记录,也是我前后踩了不少坑之后沉淀下来的操作流程。无论你是刚入手这块芯片,还是从别的系列迁过来,只要跟着这套思路走,串口基本可以一次点亮。
1. 为什么拿到 STM32C542 的第一件事是调通串口
1.1 串口是所有调试链路的“地面通道”
很多入门教程喜欢把点灯放在最前面,我也承认 LED 是确认主频和 GPIO 的最快方式,但点灯只能暴露“程序有没有跑起来”,暴露不了“程序跑到哪一步挂掉”。串口不一样,它可以把变量、模块状态、错误码、函数执行顺序全部吐出来,尤其是遇到硬 fault 或者逻辑死循环,串口日志加上几个关键位置的标记点,往往比调试器单步还快。
STM32C542 这颗芯片本身定位是中低功耗、高性价比的 Cortex-M33 平台,意味着你大概率以后要在它上面挂各种传感器、无线模组、外设协议。而这些模块最常用的交互接口就是串口,要么是 AT 指令,要么是自定义二进制协议。所以早一点把 UART 通路验证好,后面所有模块接入都会顺很多。我这边的习惯是:在 main 函数初始化完时钟和串口之后,立刻打印固件版本号和编译时间,然后才进主循环。这个做法看着简单,但能极大缩短后期“这代码到底跑没跑”的确认时间。
1.2 这颗芯片上的 USART 与 LPUART 该怎么选
看 STM32C542 的参考手册,串口资源一般不会只有一组,通常包含若干个通用 USART 以及一个低功耗 LPUART。USART 的能力比较全,支持异步、同步、智能卡、LIN、IrDA 这些模式,还有 DMA 和中断;LPUART 则主打低功耗场景,在停机模式下只要时钟源合适,还能接收唤醒信号。
我的建议是:调试串口优先选 USART 里的“老大”,比如 USART1。原因不是它性能高多少,而是它的时钟源选择通常更灵活,引脚排列在封装上也更友好,而且很多评估板默认把 USART1 的 TX/RX 接到了板载调试器或 USB-TTL 上。如果你一上来就挑一个偏门的 UART 引脚,哪怕配置全对,还要跟板子的物理走线作斗争,完全没有必要。LPUART 这种资源留给真正需要低功耗唤醒的产品功能,调试阶段先别碰。
1.3 这是系列第三篇,但你不需要翻回前两篇
这篇是这个系列的第三篇,前面两篇分别解决了环境和工程框架的问题。不过这一篇的内容是自洽的,你只要有一块 STM32C542 的开发板、一套能编译烧录的 STM32CubeMX 工程,以及一根可以用的串口线,就能跟着做。需要说明的是,我下面所有的操作路径都是基于 STM32CubeMX 生成 HAL 库工程,再手动补充重定向代码。这种方式的好处是:一旦你掌握套路,换到同系列的另外一颗芯片,基本只需要重新选引脚和时钟,代码结构不需要大改。
2. 先啃时钟树和引脚复用,串口才不会见面就乱码
2.1 波特率误差是怎么来的,又怎么控制
UART 是异步协议,收发双方没有共享时钟,靠的是双方约定波特率。但 MCU 内部并没有一个专门为 115200 生成的晶振,波特率往往是通过外设时钟分频得到的。分频结果不可能是所有波特率的整数倍,所以必然有误差。误差大了,接收端采样的点就会偏移到数据位的边缘,轻则误码,重则乱码。
STM32C542 的 UART 外设时钟来自 APB 总线时钟,通常需要经过总线预分频。CubeMX 的时钟树页面会帮你把 APB 时钟算好,然后在 UART 配置页里自动计算误差,你只要看它给出的 Baud rate 误差是否在可接受范围内。按我的经验,对于标准 8N1 帧格式,总误差尽量控制在 2% 以内,极限也不要超过 3% 到 4%。如果在里面看到误差超过这个数,别硬调波特率,而是去调外设时钟分频,让 USART 的时钟不是刚好落在某个整数倍附近,有时候把 PCLK 从 64MHz 改成 80MHz,误差立刻小一大截。
2.2 引脚复用表必须查,不能凭样板代码猜
STM32 的 GPIO 脚,很多都挂了一堆复用功能。同一个引脚上,AF1 可能是 UART TX,AF2 可能是 SPI,AF3 又可能是定时器。你光看原理图上有 “PC6/USART1_TX” 还不行,必须去 datasheet 里的 “Alternate function mapping” 表格确认 USART1_TX 对应的 AF 编号。CubeMX 的图形化配置界面其实已经帮你规避了这个问题,你在引脚上选中 USART 功能后,它会自动填好 AF,但如果你手工写寄存器,或者参考了别家板子的代码,这里就特别容易翻车。
还有一个很容易忽略的点:GPIO 的复用配置通常在 HAL_UART_MspInit 回调里完成,而不是在 MX_USART1_UART_Init 里。很多人看到串口初始化函数没配置 GPIO,以为生成代码漏了,其实 HAL 库的设计是外设基础配置和引脚/时钟配置分开。你自己写代码或者移植代码的时候,如果把 MspInit 里的 GPIO 时钟使能或 AF 配置丢了,串口寄存器明明是对的,但引脚没有任何波形,这类问题特别隐蔽。
2.3 CubeMX 参数页里这次该选什么
调试串口不需要花哨功能,参数越简单越不容易出问题。我在 CubeMX 里通常这样配置:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| Mode | Asynchronous | 异步模式,不选同步时钟输出 |
| 波特率 | 115200 | 调试终端常用,速度与稳定性平衡 |
| 数据位 | 8 Bits | 常规上位机默认配置 |
| 奇偶校验 | None | 不需要校验位 |
| 停止位 | 1 Bit | 标准 8N1 帧 |
| 流控 | None | 调试串口不接 CTS/RTS |
| USART1 中断 | 暂不使能 | 先用阻塞方式验证通路 |
这里的核心原则是“先跑通,再起飞”。调试阶段最怕的就是把中断、DMA、FIFO 全部打开,出了问题不知道是底层驱动的问题还是自己业务代码的问题。先把模式切成最朴素的异步阻塞收发,等回环测试通过后,再按需增加中断和 DMA。
3. CubeMX 最小工程落地:从生成代码到回环自测
3.1 新建工程时最容易漏掉的三个配置
如果是从零建工程,有三天配置经常会漏掉,每一个都会让串口问题变得非常诡异。
第一,调试接口没有保持开启。STM32 的 SWD 引脚默认是调试功能,但如果你在 CubeMX 里把 SWD 引脚剥夺给了某个外设,或者不小心在 GPIO 配置里把它拉高拉低,下一次烧录很可能就进不去了。新建工程时,我习惯在 SYS 页面把 Debug 设置为 Serial Wire,烧录和调试口永远留一条命。
第二,RCC 时钟源没有选对。有些板子接了外部高速晶振,有些板子没有。CubeMX 默认可能使用内部 HSI,但工程模板里如果启动文件里先初始化了外部晶振,而实际板上没有,单片机就跑在一个错误甚至不稳定的时钟上,串口波特率自然乱。所以用 CubeMX 时务必确认 RCC 这一项和板子实际硬件一致。
第三,Project Manager 里的生成选项。代码生成时,建议勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”,否则所有外设初始化都堆在 main.c 里,后面维护会很难受。另外,需要确认 Toolchain 选择的是你实际编译器对应版本,CubeMX 生成后直接打开就好,省去手工配置 Flash 算法和 Debug 器的时间。
3.2 初始化顺序和 MspInit 的关系
CubeMX 生成的 main 函数里,代码流程一般是:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); // 在这里加一句 printf 测试 while (1) { } }注意MX_USART1_UART_Init内部会调用HAL_UART_Init,而HAL_UART_Init在执行过程中又会调用HAL_UART_MspInit。这个MspInit才是完成 GPIO 时钟、复用、以及可选中断配置的地方。有些人喜欢手动改初始化顺序,把串口初始化放到某个外设之后,但没改到 GPIO,导致串口模块初始化顺序不对,UART 外设时钟没开,发送函数直接卡死。
我的经验是:在第一次调用 HAL_UART_Transmit 之前,必须确保 SystemClock_Config 和 MX_USART1_UART_Init 都已经被执行过。如果你把 printf 放在外设初始化之前,那一瞬间串口发送必然失败,失败之后 printf 普遍不会有报错提示,只会静默丢数据,很容易排查很久。
3.3 用 TX 接 RX 的回环测试代替盲写 printf
如果你一上来就直接写 printf,结果串口终端一片空白,那你去排查重定向代码、编译器选项、杂七杂八的配置,费时费力。更稳妥的做法是先用一个裸数组发出去,验证最底层的 UART 寄存器通路。
先把 USART1 的 TX 和 RX 引脚用杜邦线短接,然后在 main 里写这样一段:
uint8_t tx_data[] = "Hello STM32C542\r\n"; HAL_UART_Transmit(&huart1, tx_data, sizeof(tx_data) - 1, 1000); uint8_t rx_data[sizeof(tx_data)]; HAL_Receive(&huart1, rx_data, sizeof(rx_data) - 1, 1000);HAL_Receive 会阻塞等待接收指定数目的字节,因为我们把 TX 直接接到了 RX,MCU 自己发的数据自己会收到。如果收发缓存内容一致,说明串口的外设时钟、GPIO 复用、波特率分频全部没问题。到这一步,再去做 printf 重定向,心态就会稳很多,因为你知道底层发送是通的,问题只会出现在重定向层。
4. printf 重定向的三种实现方式与背后的 C 库原理
4.1 为什么 MCU 上的 printf 不会自己往串口跑
printf 是 C 标准库提供的格式化输出函数,它本身不知道“串口”是什么。在 PC 上,标准库把 stdout 定向到终端窗口;在 MCU 上,没有操作系统接管标准输出,所以必须由你提供底层字符输出函数。不同工具链对这个底层函数的命名不一样:ARM 编译器(MDK-ARM)期望你重写fputc,GCC 工具链期望你重写_write,IAR 则可能是putchar或__write。
很多人在串口已经回环测试通过之后,printf 还是没输出,原因就是把精力集中在 printf 本身的格式化参数上,忽略了“printf 的字符最终通过谁发出去”这一环。记住一句话:printf 只是帮你格式化,重定向才是帮它找人发。
4.2 MDK-ARM 环境下用 fputc + MicroLIB 重定向
如果你用的是 Keil MDK,最常见的做法是重写fputc,同时勾选 MicroLIB。MicroLIB 是一个精简版 C 库,它不会依赖半主机模式,省去很多底层系统调用的麻烦。在 Keil 里操作时,打开 Options for Target,在 Target 选项卡勾选 Use MicroLIB,然后在代码中加入:
#include <stdio.h> int fputc(int ch, FILE *f) { uint8_t data = (uint8_t)ch; HAL_UART_Transmit(&huart1, &data, 1, 1000); return ch; }这样printf里的每个字符都会走fputc,进而从串口发送出去。这里有个细节:HAL_UART_Transmit的返回值要判断,但没必要每次都在fputc里全面处理,因为printf一旦在 MCU 中执行,大部分是调试用途,真发送失败我们也只能干瞪眼。不过超时时间建议给一个有限值,不要给HAL_MAX_DELAY,否则串口故障时程序会永久卡死。
MicroLIB 有一个老生常谈的缺点:对浮点%f的支持不是默认开启的。具体表现是打印1.23f会出不来,或者显示0。如果你的日志里需要浮点,一个方案是放弃 MicroLIB,改用标准库配合retarget函数,另一个方案是先把浮点转成字符串再打印。我更推荐前者,后面会提到标准库方式的坑。
4.3 GCC 工具链下通过 _write 重定向
如果你用的工具链是 arm-none-eabi-gcc,那 printf 底层调用的函数是_write,而不是fputc。这个函数不仅负责 printf,还负责 write 系统调用,所以签名和语义也略有不同:
int _write(int file, char *ptr, int len) { (void)file; for (int i = 0; i < len; i++) { uint8_t ch = (uint8_t)ptr[i]; if (HAL_UART_Transmit(&huart1, &ch, 1, 1000) != HAL_OK) { return i; } } return len; }逐字节发送速度不快,但作为调试日志完全够用。如果你希望高效一点,可以一次性把整个ptr指针传给 HAL_UART_Transmit。但要注意len是 int 类型,而 HAL 的 Size 参数是 uint16_t,如果某个日志非常长,超过 65535 会溢出,需要分包处理。大多数日志不会那么长,不过严谨起见还是应该加个判断。
使用 GCC 时,链接阶段需要加上--specs=nano.specs --specs=nosys.specs。前者使用精简 C 库,后者避免依赖半主机 syscall。如果你不加nosys.specs,链接器会尝试链接一些半主机相关的符号,运行到 printf 时可能卡在异常或 BKPT 指令上,这种现象很经典,我最初遇到时还以为板子坏了。
4.4 printf “哑火”的几个典型原因
重定向代码写完后,printf 还是没输出,常见原因集中在四个地方:
- 串口初始化晚于 printf 调用。printf 在
MX_USART1_UART_Init()之前执行,底层的 HAL_UART_Transmit 根本没准备好。 - 工具链选错了重定向目标。在 GCC 里写
fputc,或在 MDK 里写_write,当然不生效。 - 标准库的半主机模式没有被关闭。半主机模式下 printf 不是走串口,而是走调试器,代码可能卡在断点/异常。
- 串口发送中断被阻塞,但 HAL_UART_Transmit 使用阻塞方式等待。如果某个中断优先级设置不合理,导致 TXE 标志一直不被清,发送永远等不到完成。这个多发生在新手配置了串口中断又没写中断处理函数的情况。
排查建议是一层层往下剥:先用HAL_UART_Transmit直接发固定字符串,能通说明 UART 层没问题;再到重定向函数里设置断点,看fputc或_write有没有被调用;最后再看链接器选项和启动文件是否引入了半主机。
5. 调试串口最容易翻车的四个细节点
5.1 乱码:先别怀疑代码,检查这三处
串口乱码的根源,绝大部分不在“串口配置代码”本身,而是在时钟和物理链路。我最常遇到的三个原因是:波特率不匹配,上位机工具和 MCU 配置两边不一致;外部晶振频率配置错误,例如板子上实际是 8MHz 晶振,CubeMX 里却写了 25MHz,导致系统时钟频率漂移;USB-TTL 模块电压不匹配,3.3V 逻辑的板子接了 5V TTL 模块,虽然有些模块标称兼容,但时序边沿已经畸变,速度一高就乱。
排查乱码最快的方法是把波特率降到 9600。低速波特率对时钟误差和信号质量容忍度更高,如果 9600 下依然乱码,重点排查晶振和电平;如果 9600 正常而 115200 乱码,重点检查波特率误差和串口线长度。有条件的话,用逻辑分析仪抓一下 TX 引脚波形,看字长、停止位和实际波特率是不是和预期一致,这是最直观的。
5.2 卡死在 HAL_UART_Transmit 超时循环的完整排查
HAL_UART_Transmit 的最后一个参数是超时时间。如果传入HAL_MAX_DELAY,底层会进入一个无线循环等待 TXE 标志位。一旦串口外设没有正确初始化,或 GPIO 被占住导致时钟未使能,这个循环就永远等不到结束,表现就是程序卡死。
遇到这种卡死,先别急着屏蔽 HAL 等待逻辑。我按这个顺序排查:第一步,看SystemClock_Config是否正常执行,UART 外设时钟有没有被使能;第二步,看MX_USART1_UART_Init是否真的被调用,以及huart1实例是否被MX_USART1_UART_Init正确填充;第三步,看HAL_UART_MspInit里 GPIO 时钟和复用是否配置,通常生成代码没问题,但手动移植容易漏;第四步,检查你的调试器是否停在了HAL_UART_Transmit内部的 while 循环里,这一点断点就能看出来。
还有一个容易忽视的情况:如果在串口中断服务函数里调用了阻塞式 HAL_UART_Transmit,而发送的中断优先级高于当前中断,则可能导致死锁。中断场景下发送应该用HAL_UART_Transmit_IT,或者把超时时间设成很短并接受丢帧,而不是在 ISR 里做 1000ms 的阻塞等待。
5.3 复位后第一行日志莫名丢失
代码跑起来后,终端里最开头往往缺失一行。很多人以为是重定向没写好,实际上原因常常是:MCU 复位时 TX 引脚处于未初始化状态,此时电平被拉到低电平,USB-TTL 转换器会把这一段低电平“识别”成一个0x00字节,导致 PC 端串口工具收到一个空字节;紧接着你 printf 第一行正常数据又来了,上位机可能把前导空字节当成脏数据丢弃了,或者串口工具的启动时刻晚于 MCU 复位。
解决方式很简单:在串口发送第一行日志之前加一个几十毫秒的延时,比如:
HAL_Delay(100); printf("System Boot OK\r\n");也有人会在 GPIO 初始化前先把 TX 引脚配置为带上拉的 GPIO 模式,让电平先稳定在高位,再切入复用功能。这种做法更彻底,但会打乱 CubeMX 生成代码结构,一般调试阶段用HAL_Delay就足够了。
5.4 RTOS 环境下 printf 打架的问题
一旦把 FreeRTOS 或其它 RTOS 跑起来,多任务同时调用 printf 会出现明显的互相穿插,日志一行里混着两个任务的输出,非常难读。根本原因是 printf 内部的输出过程和串口发送不是原子操作,任务 A 打印到一半,任务 B 也进来打印,两个字符串就交错在一起。
解决办法是在重定向函数外层加互斥锁。FreeRTOS 下,可以用一个SemaphoreHandle_t:
static SemaphoreHandle_t uart_lock; void UART_PrintfInit(void) { uart_lock = xSemaphoreCreateMutex(); } int fputc(int ch, FILE *f) { xSemaphoreTake(uart_lock, portMAX_DELAY); uint8_t data = (uint8_t)ch; HAL_UART_Transmit(&huart1, &data, 1, 1000); xSemaphoreGive(uart_lock); return ch; }不过每个字符都拿锁放锁,开销有些大。更高效的方式是在printf调用外层加锁,但这样侵入业务代码。实际上调试阶段,更省事的方法就是让任务打印时自带前缀,比如[task1] ...,交错时也能分辨。如果要做比较正式的日志系统,还是建议把整条日志先格式化到一个缓冲区,然后一次性加锁发送。
6. 从“能打印”到“好用”:日志宏、DMA 与个人习惯
6.1 阻塞发送只配调试,不配产品日志
阻塞式HAL_UART_Transmit和 printf 重定向最大的缺点是会占住 CPU。如果你的主循环里每毫秒要处理一堆业务,而串口日志频繁输出,那 CPU 很大一部分时间都在等待串口移位寄存器慢慢吐数据,这在高主频 MCU 上显得特别浪费。产品上线之后,我们不建议把调试日志留在主循环里高频打印,除非你接受它对实时性的影响。
一个折中方案是使用 DMA 发送,例如HAL_UART_Transmit_DMA。但 DMA 发送需要保证缓冲区在传输期间不被修改,因此最稳妥的做法是维护一个环形缓冲区,把要发送的字符串先抄进环形队列,再由 DMA 逐块发送。这个方案更适合做成一个模块化 logger,而不是简单重定向。
6.2 一个带时间戳和等级控制的日志宏
printf 重定向只是起点,真正好用的调试系统应该带有日志等级和时间戳。下面这个宏是我常用的模板,基于标准 printf 实现,不依赖额外文件系统:
#define LOG_LEVEL_ERROR 0 #define LOG_LEVEL_WARN 1 #define LOG_LEVEL_INFO 2 #define LOG_LEVEL_DEBUG 3 #define LOG_LEVEL LOG_LEVEL_DEBUG #define LOG_PRINT(level, tag, ...) \ do { \ if (level <= LOG_LEVEL) { \ printf("[%lu][%s] ", \ (unsigned long)HAL_GetTick(), tag); \ printf(__VA_ARGS__); \ printf("\r\n"); \ } \ } while (0) #define LOG_ERROR(...) LOG_PRINT(LOG_LEVEL_ERROR, "E", __VA_ARGS__) #define LOG_WARN(...) LOG_PRINT(LOG_LEVEL_WARN, "W", __VA_ARGS__) #define LOG_INFO(...) LOG_PRINT(LOG_LEVEL_INFO, "I", __VA_ARGS__) #define LOG_DEBUG(...) LOG_PRINT(LOG_LEVEL_DEBUG, "D", __VA_ARGS__)HAL_GetTick()返回的是系统上电以来的毫秒数,配合时间戳你能清楚看到事件发生的时间间隔,对排查超时、时序问题非常有用。日志等级可以在编译期裁剪,比如发布版本把LOG_LEVEL改成LOG_LEVEL_ERROR,调试信息就全部消失,不需要逐行删代码。
6.3 我给所有串口调试工程定的几条铁律
最后说点和技术无关但很重要的经验。第一,固定调试串口,不要今天用 USART1,明天换 USART3。调试串口一旦定了,就把它当成一个固定资源保留,新增功能时尽量不要和它抢引脚。第二,所有对外发布测试固件都保留一个隐藏串口打印入口,打印版本、编译时间、启动原因,这些信息在工厂测试和现场问题回溯时特别有价值。第三,串口线要买质量好的,我有过整整一个下午被乱码折磨,最后发现是一根 USB-TTL 线太劣质,换线后一切正常。硬件上的坑,经常比软件上的坑更耽误时间。
这些步骤走完之后,你那块 STM32C542 的串口应该已经能稳定输出 printf 内容了。后面再接无线模组、传感器,或者调试 RTOS 任务优先级,都会有更扎实的地基。我个人的开发模板里,这段 UART 配置和 printf 重定向的代码已经变成固定模块,换芯片型号时只改引脚、时钟和一小段重定向函数,其他逻辑几乎不用动。希望能给你的项目省下一些时间。