
如果你刚拿到一块 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 封装的 C542RPA9/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 找到 USART1Mode 选择 Asynchronous异步模式。异步模式就是最常用的 TX 和 RX 两根线不需要时钟线。选择之后CubeMX 会自动分配默认引脚一般是 PA9 对应 USART1_TXPA10 对应 USART1_RX。你可以点引脚旁边的下拉框换成别的复用引脚。接下来设置参数在 Configuration 下方的 Parameter Settings 里把这几项配置好参数项推荐值说明Baud Rate115200调试串口的万能初始值Word Length8 Bits一个字节 8 位符合绝大多数场景ParityNone无校验单纯调试打印不需要校验位Stop Bits1 Bit1 个停止位最常见配置Data DirectionTX and RX发送接收都启用Over Sampling16 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 就选 STM32CubeIDEMDK 就选 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 库是 Newlibprintf 底层的字符输出函数不是 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 fputcGCC 重写 _write自封装 debug_printf适用工具链Keil MDKSTM32CubeIDE、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 上搭好一次以后换同系列芯片基本就是复制粘贴的活。