如果你刚拿到一块 STM32C542R 开发板,跟着系列文章把 CubeMX 工程模板和 GPIO 点灯跑通之后,下一步大概率就是想把串口打印搞出来。串口打印这个东西,说简单也简单,配置路径非常固定;说麻烦也麻烦,一旦出现没输出、乱码、掉第一个字符这类鬼问题,新手往往卡一下午。这篇是系列第三篇,我把自己在 C542R 上从零配置串口打印、完成 printf 重定向的完整过程记下来,包括 CubeMX 里每一处参数、三种灵活的 printf 重定向方案,以及实测中踩过的坑。适合刚接触 C5 系列、想快速打通调试通道的朋友,也适合从 F1/F4 转过来的老读者对照参考。
1. 串口打印在调试流程中的定位与方案选择
1.1 为什么串口打印是调试阶段的第一根拐杖
写嵌入式程序,调试手段就那几样:接仿真器单步断点、点 LED 灯、跑逻辑分析仪,再就是串口打印。平时写业务逻辑,单步断点太慢,逻辑分析仪接线麻烦,LED 只能表达“到没到这行”,表达不了“这个变量现在是多少”。串口打印是性价比最高的方案,几行代码就能把变量、状态机、传感器数据全部吐出来,还能顺便打时间戳量性能。
很多新手觉得串口打印只是“往电脑发字符串”,其实它更值钱的地方在于可以快速搭出一个日志系统。后续你跑 RTOS、调协议栈、做 GUI,都需要一个统一的调试输出口。把打印能力一次性配好,后面开发各种外设驱动时会轻松很多。这也是我拿到新片子第一件事必做串口的原因,先打通“信息出口”,再谈其他外设。
1.2 确认 STM32C542R 上的串口资源
C542R 用的是 Cortex-M33 内核,外设布局比 F1 系列要复杂一些。串口这一块,它提供了多个 USART/UART 实例,除了常规串口外,还带了低功耗串口,低功耗串口在停机模式下还能唤醒,那是后面做低功耗设计的事,调试阶段用不上。
做调试口,我习惯优先选 USART1。一个是它引脚固定好找,另一个是它在大部分系列上都有较完整的时钟源和中断路由,工作频率高,不容易有性能瓶颈。具体到 C542R 这块芯片,你打开 CubeMX 的 Pinout & Configuration 界面,在左侧 Categories 里展开 USARTs,找到 USART1,就能看到对应的引脚候选列表,通常会包含 PA9/PA10 这样的默认调试串口引脚,也可能有 PD8/PD9 等复用选项。引脚不同,你后面接线就不同,这个在配置之前先想清楚。
有个细节需要提醒:不同封装、不同型号的引脚映射不完全一样。你手上如果是 LQFP64 封装的 C542R,PA9/PA10 大概率是可用的;如果板子设计把 PA9 拿去接别的外设了,就换另一组复用引脚。总之以 CubeMX 里标出来的可用引脚为准,别照搬别人的工程,也别照搬数据手册的“典型接法”。
1.3 数据发送方式选型:阻塞、中断还是 DMA
串口发送数据,HAL 库给了三种典型方式:阻塞发送HAL_UART_Transmit、中断发送HAL_UART_Transmit_IT、DMA 发送HAL_UART_Transmit_DMA。它们的核心区别在于 CPU 怎么等数据发完。
阻塞方式就是函数内部死等,一个字节一个字节往数据寄存器里塞,发完才返回。好处是逻辑简单、时序可控,坏处是发送期间 CPU 被占用。中断方式是把数据交给中断,CPU 去干别的,发送完成再进中断收尾。DMA 方式最彻底,数据搬运本身由 DMA 控制器做,CPU 只在最后收个完成中断。三者对比如下:
| 发送方式 | CPU 占用 | 代码复杂度 | 适用场景 |
|---|---|---|---|
| 阻塞发送 | 高,全程占用 | 低,直接调用 | 调试打印、低频短报文 |
| 中断发送 | 中,中断处理 | 中,需处理回调 | 需并发处理其他任务的发送 |
| DMA 发送 | 低,仅启动与结束中断 | 高,需管理缓冲区 | 大流量、高频日志输出 |
我的建议很直接:调试阶段用阻塞发送,轮询调用,简单可靠,出问题容易定位。等系统里要同时跑传感器采集、屏幕刷新、通信协议时,再把打印模块改成 DMA 发送也不迟。初期配置串口如果直接上 DMA,一旦数据错乱,你会分不清是 DMA 配置问题还是串口时钟问题,排查成本很高。
2. CubeMX 工程配置细节(手把手版)
2.1 先把时钟树喂饱,否则波特率全白搭
串口波特率不是凭空产生的,它是由串口外设时钟经过分频得到的。串口时钟源头不对,波特率必然有误差,所以配置串口之前,先确认时钟树已经设置好。C542R 这类新内核芯片,时钟系统比 F1 更灵活,但也更容易让人看花眼。
我建议使用外部晶振作为 HSE 输入。在 CubeMX 的 Clock Configuration 页面里,先把 HSE 选为 Crystal/Ceramic Resonator,然后根据开发板实际的晶振频率填入。8 MHz 无源晶振是最常见的,也有板子用 25 MHz 或 12 MHz。填对了晶振频率,后面 PLL 倍频才有意义。
接着是 PLL 配置。STM32C5 系列的 CPU 主频和总线频率比例,不同型号不一样,你需要打开芯片数据手册或参考 CubeMX 给出的最大值提示,把 PLL 倍频到允许的 CPU 主频。这里要特别注意:APB1 和 APB2 总线的时钟频率,会直接影响挂在这两条总线上的串口外设的时钟。CubeMX 界面里,当你修改 APB 预分频器时,右侧时钟树会动态显示各个外设获得的时钟频率,一眼就能看出 USART1 最终分到多少。
填完时钟树后,别急着生成代码。先在 Clock Configuration 页面确认一下 USART1 的时钟源时钟树里是否显示为一个合理值,比如几十 MHz 级别。如果显示 8 MHz 甚至几 MHz,后面的波特率精度会很差,高波特率下会出现乱码。当初我在别的主控上调串口卡了半天,最后定位到是某个总线预分频配错了,APB1 被压得特别低,串口怎么调波特率都不准。所以,时钟树这一步,真值得花两分钟仔细核对。
2.2 USART1 引脚与参数配置
时钟定好后,回到 Pinout & Configuration 页面,左侧 Categories 找到 USART1,Mode 选择 Asynchronous(异步模式)。异步模式就是最常用的 TX 和 RX 两根线,不需要时钟线。选择之后,CubeMX 会自动分配默认引脚,一般是 PA9 对应 USART1_TX,PA10 对应 USART1_RX。你可以点引脚旁边的下拉框换成别的复用引脚。
接下来设置参数:在 Configuration 下方的 Parameter Settings 里,把这几项配置好:
| 参数项 | 推荐值 | 说明 |
|---|---|---|
| Baud Rate | 115200 | 调试串口的万能初始值 |
| Word Length | 8 Bits | 一个字节 8 位,符合绝大多数场景 |
| Parity | None | 无校验,单纯调试打印不需要校验位 |
| Stop Bits | 1 Bit | 1 个停止位,最常见配置 |
| Data Direction | TX and RX | 发送接收都启用 |
| Over Sampling | 16 Samples | 默认 16 倍过采样,精度更好 |
串口助手侧也设置为 115200-8-N-1,这串参数其实就是波特率 115200、8 数据位、无校验位、1 停止位的简写。两个设备必须参数一致才能通信,这个属于基础中的基础,但真有人把停止位配成 2 个然后查半天。
需要关心的是 Over Sampling。HAL 库默认 16 倍过采样,意思是 1 个 bit 的时间被采样 16 次,噪声容忍度高。有些要求高速率的场合会改 8 倍过采样,但普通调试场景不建议动,保持默认最稳。
2.3 生成工程前的三个关键检查
CubeMX 里点 GENERATE CODE 之前,我习惯做三个检查,省得工程生成后改来改去。
第一,确认“每个外设生成独立 .c/.h 文件”这个选项被勾选。在 Project Manager -> Project Settings 页面,把“Generate peripheral initialization as a pair of '.c/.h' files per peripheral”打开。这样生成的代码里,USART1 的初始化函数会独立放在usart.c文件中,而不是全部堆在main.c里。对于后续维护和移植,这个习惯很重要。
第二,把栈空间调大。printf 这类可变参函数在运行时会占用不少栈空间,尤其在输出浮点数时。我一般把 Stack_Size 从默认的 0x400 改成 0x1000 甚至 0x2000。如果你程序里有个很大的局部数组,又调用了 printf,栈溢出会导致程序飞到莫名其妙的地方,表现往往是串口打印到一半就卡死或重启。先给足栈空间,能少踩一个隐藏炸弹。
第三,确认代码生成器选了正确的工具链。CubeIDE 就选 STM32CubeIDE,MDK 就选 MDK-ARM V5/V6,不同的工具链生成的启动文件和链接脚本略有不同。别选错,选错了后面编译会有一堆奇怪报错。
生成代码后,打开main.c,你会看到MX_USART1_UART_Init()这个函数,里面会根据 CubeMX 的配置自动计算出波特率寄存器值。这个函数不用手改,除非你想验证波特率计算逻辑。
3. printf 重定向的三种实现与源码拆解
GPIO 点灯不需要打印,但串口数据要变成人能读的文本,最舒服的方式就是重定向 printf。HAL 库本身提供的是HAL_UART_Transmit这种底层发送函数,要发送格式化字符串得自己拼,效率太低。printf 重定向的本质,就是让 C 库的 printf 在输出字符时,最终调用我们指定的串口发送函数。根据你用的工具链和库不同,有三种常见做法。
3.1 方案一:Keil MDK 环境下勾选 MicroLIB,重写 fputc
用 Keil MDK 开发的话,最简单的方式是勾选 MicroLIB。MicroLIB 是 ARM 编译器提供的一套精简 C 运行库,资源占用小,而且天然避免了很多 semihosting 半主机模式的坑。勾选方法:魔术棒选项卡 -> Target -> 勾选 Use MicroLIB。
与此同时,在你选择的任意 C 源文件里加这样一段代码:
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }原理很简单:printf 最终逐字节调用 fputc,我们的 fputc 把每个字符通过 HAL_UART_Transmit 阻塞发送出去。中间的0xFFFF是超时时间,单位是毫秒,这里设成一个很大的值,基本不会因为超时丢数据。
注意huart1这个变量的名字,CubeMX 生成的默认变量名就是它,如果你改过实例名,这里要对应改。另外这个文件需要能访问到huart1,最简单的做法是#include "main.h",因为huart1在 main.h 中做了 extern 声明。
3.2 方案二:STM32CubeIDE / GCC 环境下重写 _write
不少朋友用的是 STM32CubeIDE 或者 VSCode + arm-none-eabi-gcc 工具链,这种情况下,C 库是 Newlib,printf 底层的字符输出函数不是 fputc,而是_write。所以我们要重定向的是它:
#include <stdio.h> #include "main.h" int _write(int fd, char *ptr, int len) { if (fd == 1) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); } return len; }这里的fd是文件描述符,标准输出 1 对应 stdout。ptr是 printf 缓冲区的数据指针,len是本次要输出的长度。HAL_UART_Transmit 把整段数据一次性发出去,最后 return len 告诉上层“这段数据我已经处理完了”。
有些教程里要求必须把fputc和_write同时重写,或者加#pragma import(__use_no_semihosting),实际上只要你用的是 CubeIDE 的默认连接脚本和 Newlib,重写_write就够了。如果你在例程里看到__io_putchar这种写法,那是早期标准库不同版本时代的产物,现在 MDK 和 GCC 下都可以用上面的两种方式搞定。
3.3 方案三:自封装可变参打印函数,不绑定 C 库底层
第二种方案其实已经能解决大部分问题,但有一类需求它解决不了:我想把同一份日志既通过串口发出去,又在 OLED 上显示,或者写入 Flash 日志区。这种情况,与其依赖 printf 重定向,不如自己封装一个可变参打印函数,完全掌控格式化流程:
#include <stdio.h> #include <stdarg.h> #include <string.h> void debug_printf(const char *fmt, ...) { char buf[128]; va_list args; va_start(args, fmt); vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); HAL_UART_Transmit(&huart1, (uint8_t *)buf, strlen(buf), 0xFFFF); }这个函数的逻辑是:先把用户传入的格式化字符串和参数列表,通过vsnprintf填充进局部缓冲区buf,然后把整个缓冲区通过串口发出去。vsnprintf是 printf 的“字符串版”,不直接输出到 stdout,而是输出到我们指定的数组里。
这套方案的优势非常明显:
- 不依赖 C 库的 stdout 机制,无论什么工具链都能用;
- 格式化后的字符串你可以随便处置,发串口、存 Flash、显屏都行;
- 可以轻松加时间戳,比如在函数开头读取系统 tick,拼进字符串头部。
但它有代价:缓冲区大小固定为 128 字节,如果一次格式化内容超过 128 字节,会被截断。你可以根据需要把buf改成 256、512,但注意这几个数字都会占用栈空间。另外一个坑是,这个函数不能直接用于中断或 RTOS 多任务环境,并发调用会互相覆盖buf。如果确有多任务需求,需要给这个函数加互斥锁,这是后话。
3.4 三种方案到底怎么选
| 对比项 | MicroLIB + fputc | GCC 重写 _write | 自封装 debug_printf |
|---|---|---|---|
| 适用工具链 | Keil MDK | STM32CubeIDE、GCC | 全工具链通用 |
| 实现难度 | 低 | 低 | 中 |
| 可扩展性 | 差,只能输出到串口 | 中,可改发送目标 | 高,可任意处理字符串 |
| 浮点支持 | 需在编译选项里开 | 默认支持 | 默认支持 |
| 推荐场景 | 快速原型验证 | CubeIDE 日常开发 | 正式日志模块设计 |
我的个人习惯是:快速验证某个外设时,用第一种或第二种,方式越简单越好;工程结构稍微正式一点,我会直接建一个debug.c,里面放自封装的debug_printf和配套的日志分级函数,后续扩展不伤筋动骨。
4. 实测:硬件接线、测试代码与波特率误差分析
4.1 硬件接线这一步最容易翻车
配置和代码都写完,最后还是要看硬件。这块板子的串口如果直接通过板载 USB 转串口芯片引出,那方便,插一根 USB 线就行。如果你的板子只引出了排针,就需要 USB 转 TTL 模块,接线规则是:
- 开发板 TX 接 USB 转 TTL 模块的 RX;
- 开发板 RX 接 USB 转 TTL 模块的 TX;
- 开发板 GND 必须接模块 GND,共地是通信的基础;
- 逻辑电平要匹配,C5 系列是 3.3V 供电的片子,USB 转 TTL 模块也必须选 3.3V 电平的型号。
TX 和 RX 交叉连接,这个反复强调很多遍,但还是有好多人接成直连,然后打开串口助手看到一片空白。我自己也会在接线之后用万用表量一下引脚,确认没接反。如果板子上有多个串口座,提前确认你用的是不是 USART1 对应的引脚。
4.2 测试代码与实测效果
进入正题写测试代码。在main.c的while(1)循环里先放一段最朴素的打印:
#include <stdio.h> int counter = 0; while (1) { printf("Hello from STM32C542R, counter = %d\r\n", counter++); HAL_Delay(500); }这里我特意加了\r\n而不是只写\n。很多串口助手里,只有回车加换行才能让输出回到行首,只写\n会导致输出变成楼梯状。调试串口时我习惯所有日志行结尾统一用\r\n,也算是一个团队协作时的代码规范。
如果你要做浮点打印测试,比如printf("temp = %.2f\r\n", 36.6),要注意:MDK 的 MicroLIB 默认不支持浮点打印,需要在编译选项中启用--fpmode或改用完整 C 库。CubeIDE 的 Newlib 默认支持,直接可用。这也是很多新手在 Keil 下打印浮点数得到一堆问号或垃圾值的原因。
编译下载之后,打开串口助手,选择对应的 COM 口,波特率 115200,数据位 8,停止位 1,无校验,打开串口,应该就能在窗口里不断看到计数信息。如果第一行没反应,多半是上电后没复位,摁一下开发板复位键再观察。
4.3 波特率误差是怎么算出来的
很多人以为波特率配对了就行,其实串口通信看的是两边波特率的实际误差。误差超过 2% 到 3%,接收端就会采样错位,产生乱码。HAL 库通过一个叫做 USARTDIV 的分频值来产生波特率,计算方式大致是:
USARTDIV = PCLK / (16 × BAUDRATE)以 PCLK 为 64 MHz、目标波特率 115200 为例:
USARTDIV = 64000000 / (16 × 115200) = 34.7222HAL 库会把这个值拆成整数部分和小数部分写入寄存器。实际算出来的真实波特率为:
真实波特率 = 64000000 / (16 × 34.75) = 115107.9 误差 = (115107.9 - 115200) / 115200 = -0.08%0.08% 的误差远小于 2% 的容忍范围,完全没有问题。但如果你用的是内部 RC 时钟且未校准,RC 振荡器的误差可能在 1% 到 3% 之间波动,当目标波特率较高时,叠加分频取整误差,就容易超过容忍范围。这就是为什么我建议调试阶段就用外部晶振,一劳永逸。
C542R 内部还有一个可以校准的高频 RC 振荡器,如果你的板子确实没有外部晶振,也可以靠 RCC 模块的校准功能降低误差,但效果总归受温度影响。能用外部晶振就尽量用外部晶振。
4.4 如果跑了 RTOS,打印要注意什么
一点扩展提醒。如果你后续在 C542R 上跑 FreeRTOS 或 ThreadX,串口打印就不能像裸机那样毫无顾忌地阻塞发送。试想一个低优先级任务正在 printf 一条很长的日志,数据还没发完,一个高优先级任务抢占了 CPU,那个高优先级任务也想 printf,两个发送就会相互穿插,日志直接乱成一团。二来阻塞发送期间,当前任务会一直卡在 HAL_UART_Transmit 里,影响系统的实时性。
我的处理方式是给打印模块加一个互斥量,让同一时刻只能有一个任务进入发送流程;日志量大的时候,把发送改到 DMA 通道,配合发送完成中断释放信号量。这些都属于后续工程化优化,初期裸机阶段不用管,但心里要有这根弦,别等到高并发问题爆发才回头改架构。
5. 常见问题与排查技巧实录
5.1 完全没输出,先按这个顺序查
串口完全没反应,很多人第一反应就是去改代码,其实大部分问题出在硬件和配置上。我给自己总结了一套排查顺序,按这个走,基本五分钟内能定位:
- 开发板是否在运行:观察板上 LED 是否在闪烁,或者 IDE 里暂停程序看 PC 指针位置;
- 接线是否接反:TX/RX 必须交叉,GND 必须共地;
- 串口助手 COM 口号是否选对:可以在设备管理器里查看端口号,插拔 USB 看哪个 COM 口出现/消失;
- 串口助手参数是否匹配:波特率 115200-8-N-1 是最常见组合,别选错;
- printf 重定向是否真正生效:在程序里直接调
HAL_UART_Transmit(&huart1, (uint8_t*)"A", 1, 0xFFFF),如果这个能输出而 printf 不能,说明重定向没写对; - 引脚是否被占用:CubeMX 里 USART1 引脚有没有和别的外设冲突。
这套顺序的顺序是有讲究的:先确认硬件通路和基本发送函数,再排查上层重定向。很多人一上来就查 printf 重定向,忽视接线和串口助手,结果浪费很久时间在错误的方向上。
5.2 乱码的几种隐藏原因
乱码比没输出稍微友好一点,因为至少说明链路通了,问题多半在“参数不一致”或“信号质量差”上。常见原因有两种:
波特率误差过大。这时候输出往往是一坨可读但夹杂乱码的字符,或者完全不可读。在串口助手里把波特率从 115200 改成 9600 试试,如果低波特率下正常了,基本可以确定是时钟精度问题,回到时钟树检查外部晶振和 PLL 配置。
电平不匹配。如果你用的 USB 转 TTL 模块是 5V 电平,而芯片是 3.3V,即使能收到数据也可能是乱码或者烧毁引脚。要确保模块和芯片同为 3.3V 电平,部分模块有跳线帽可以切换电平,记得确认。
还有一种隐蔽原因是中文编码。如果你 printf 里直接写了中文字符串,源码文件是 UTF-8 编码,而串口助手按 GBK 解码,就会出现中文乱码英文正常的诡异现象。这种情况,要么统一用英文字符串做调试日志,要么把源码编码和串口助手编码保持一致。
5.3 丢第一个字符、输出一段后卡死的处理
第一个字符丢失是很多串口调试的经典问题。根源在于目标板上电或复位的瞬间,TX 引脚的电平还没有完全稳定,串口助手恰好在这个时刻开始接收,收到的第一个字节就可能被吞掉。解决办法很简单,在 main 函数初始化完串口后,延时几十毫秒再开始打印,比如:
HAL_Delay(50); printf("system boot...\r\n");输出一段后卡死的情况,要怀疑两个方向:一个是进入了某个硬件错误中断,程序死在异常处理里;另一个是 printf 内部缓冲区或栈溢出导致程序跑飞。后者对应我前面说的栈空间问题,把 Stack_Size 加大,通常会有改善。如果你用了 DMA 发送,还要检查 DMA 中断优先级是否设置合理,否则 DMA 完成中断进不去,程序会一直等发送完成标志。
5.4 常见问题速查表
| 现象 | 优先排查项 | 解决思路 |
|---|---|---|
| 完全没有输出 | 接线、共地、串口助手 COM 口 | 先调通 HAL_UART_Transmit,再查 printf 重定向 |
| 输出乱码 | 波特率、电平、编码 | 换低波特率测试,确认 3.3V 电平,统一编码 |
| 丢第一个字符 | 上电时序 | 初始化后延时 50ms 再打印 |
| 打印一半卡死 | 栈溢出、HAL 超时、硬件错误中断 | 加大 Stack_Size,检查中断优先级 |
| 浮点打印异常 | 编译器 C 库配置 | MDK 勾选完整库或调整浮点模式 |
| 复位后串口助手无反应 | 串口助手打开时序 | 在程序侧加延时,或复位后重开串口 |
最后再分享一个我自己的习惯。串口打印跑通之后,不要急着接着写外设代码,先把打印封装成一个独立的debug.c/debug.h模块,里面把日志级别、时间戳、发送底层三者分开。后面不管是接传感器、调电机,还是跑协议栈,统一的日志输出口会让你排查问题快很多。这套东西在 C542R 上搭好一次,以后换同系列芯片,基本就是复制粘贴的活。