STM32F103串口printf调试全攻略:CubeMX配置、重定向封装与踩坑指南
简介面向STM32F103C8T6入门开发者这是一套基于STM32CubeMX生成、由Keil MDK5打开的串口输出与printf重定向封装示例工程。工程从RCC时钟、GPIO引脚配置起步逐步进入USART外设初始化链路并把printf标准库函数重定向到串口发送通道便于初学者理解STM32 HAL库的底层调用关系与MDK编译调试流程。rar压缩包内共139个文件约3.45MB主要包含C/H源码文件、Keil MDK5的uvprojx/uvoptx工程文件、CubeMX的IOC图形配置以及hex/axf可执行产物、map/lst构建报告和编译中间文件通过这些文件可对照源码、配置界面与输出映射逐层查看串口外设初始化、printf重定向与硬件调试信息。已有1925人学习下载适合在标准库与HAL库之间切换、刚接触串口通信或需要快速验证printf输出的开发者直接参考复用。同时该示例体积小、目录完整可直接作为后续项目的基础调试模块改造扩展。1. 串口printf这件事卡住过一多半的F103初学者STM32F103C8T6这颗芯片便宜、资料多但再简单的串口输出也挡不住一批人卡在“printf死活不出字”“一调用就死机”“中文全是乱码”这些原地上。这份资源包的关键词是STM32CUEB其实就是STM32CubeMX、KEIL5、stm32f103c8t6、串口输出、printf函数封装我拆完之后把整条链路整理成了一份能照着复现的实战笔记先用CubeMX生成工程再决定用哪种printf重定向最后把串口输出真正封装成好用的日志工具。适合刚用HAL库做串口调试、以及想自己搭一套调试打印体系的开发者阅读。2. 用CubeMX生成串口工程时钟、引脚与Keil5的衔接2.1 为什么选择CubeMX生成代码而不是手写寄存器很多初学者习惯“寄存器流”直接操作寄存器觉得CubeMX生成的代码冗余。但实际做项目时从零起步的板子用CubeMX至少有三个实在好处。第一时钟树可视化。STM32F103C8T6最高72MHz需要经过PLL倍频在CubeMX里直接拖HCLK到72MHz软件会自动选中合法的分频系数不会出现系统时钟算错导致串口波特率对不上的问题。第二外设初始化有固定顺序生成代码里HAL_Init、SystemClock_Config、MX_GPIO_Init、MX_USART1_UART_Init层层调用新手不容易漏掉某个外设的时钟使能。第三引脚冲突检查是自动的PA9/PA10用作USART1后如果再去配置成其他功能软件会立刻报错。不过我也见过有人F103C8T6的板子CubeMX生成代码后Flash容量被误识别成128KB而实际芯片是64KB编译时链接器按128KB布局也没问题但下载后部分功能可能异常。常见做法是在CubeMX的Project Manager里确认Device选择正确C8T6对应Flash是64KBRAM是20KB如果软件仍显示128KB手动改一下芯片型号再生成一遍。选型上还有一个边界要认清F103C8T6只有三个串口USART1/2/3其中USART1和USART3挂在APB2上USART2挂在APB1上。如果你的工程里还要使用定时器做高频计数建议把串口放在APB2总线上避免APB1时钟倍频后波特率误差增大。具体在CubeMX的时钟配置里可以看总线时钟值我一般会对照数据手册确认一下。另外HAL库的串口收发函数默认是阻塞轮询模式中断和DMA是之后扩展出来的初始化阶段用轮询跑通printf流程后面再逐步替换。有人担心HAL库生成的代码太慢其实在F103这种72MHz主频下115200波特率的字节间隔接近87usHAL_UART_Transmit轮询TC标志的开销远小于一个字节的传输时间性能不是瓶颈。2.2 在CubeMX里配置USART1的关键参数与引脚分配以最常用的USART1为例PA9是TXPA10是RX在CubeMX里的具体配置步骤如下。打开CubeMX新建工程选择MCU型号STM32F103C8Tx。在System Core - SYS中Debug选择Serial Wire否则生成的工程在板载调试器上可能进不了调试接口。在System Core - RCC中HSE选择Crystal/Ceramic Resonator板子上如果有外部8MHz晶振就用这个没有外部晶振则跳过直接用内部HSI但HSI精度不如HSE串口高速通信时建议用外部晶振。在Clock Configuration页面把HCLK拖到72MHz软件会自动计算各总线分频。在Connectivity - USART1中Mode选择Asynchronous此时软件会自动把PA9、PA10配置为串口功能。下面参数栏配置Baud Rate: 115200; Word Length: 8 Bits; Parity: None; Stop Bits: 1; 其他默认。在System Core - NVIC中勾选USART1 global interrupt方便后续使用中断接收。在Project Manager - Project中Toolchain选择MDK-ARM V5这样生成的就是Keil5工程。生成之后PA9/PA10默认被复用为USART1_TX/RX但在板卡原理图上很多开发商会在串口引脚和MCU之间加跳线或电平转换芯片。调试前先确认TTL电平方向不要直接拿USB转TTL模块和RS232电平混接这是我见过翻车最多的一步。参数上有一个容易忽略的点CubeMX默认生成的初始化代码中MX_USART1_UART_Init会调用HAL_UART_Init而内部会根据波特率计算USARTDIV写入BRR寄存器。如果系统主频不是72MHz波特率会有偏差115200这种常见速率误差还小到了460800等高速率时F103本身BRR只有16位分频精度有限串口助手就可能看到首字节乱码。一般调试建议用115200或38400。参数配置表格如下参数项推荐值说明Baud Rate115200调试日志最常用的波特率线缆质量差时降到9600Word Length8 Bits配合ASCII字符输出ParityNone无校验简化收发Stop Bits1标准配置ModeAsynchronous异步串口无需时钟线NVIC使能全局中断为后续中断接收做准备波特率的选择不是越高越好。在F103C8T6上如果系统时钟误差超过2%115200速率下每一帧的位时间偏移会累积到起始位中间导致接收端采样错误。用便宜的蓝牙模块或USB转TTL模块时我建议先用9600验证链路再逐步提速这样可以排除模块本身质量问题。2.3 时钟树与波特率误差为什么输出会随机丢字节很多人在串口输出正常后偶尔会碰到千字节里丢一两字节第一反应是干扰但其实可能出在时钟分频精度上。STM32F103C8T6的USART1挂在APB2上APB2默认最高72MHzUSART2挂在APB1上最高36MHz。串口波特率生成器通过USARTDIV对时钟进行分频但BRR寄存器只有16位不是所有波特率都能整除。以USART1、72MHz时钟为例计算115200波特率时USARTDIV 72000000 / (16 * 115200) 39.0625BRR值可以精确表示0.0625所以实际波特率误差接近0。但如果是USART2APB1时钟36MHz计算9600波特率时USARTDIV 36000000 / (16 * 9600) 234.375实际波特率为36000000 / (16 * 234) ≈ 9615.38误差约0.16%问题不大。但如果用HSI内部时钟且没有经过PLL校准HSI本身精度可能偏差1%以上这时就算公式计算正确实际波特率也不准。排查这种问题时我一般会在CubeMX的Clock Configuration中确认APB1和APB2的时钟值再用示波器量TX引脚的波特率。没有示波器时可以用逻辑分析仪抓一个字节对比脉冲宽度和理论位时间。更简单的方法是在串口助手里发送一个0x55测试字节0x55的位模式是10101010波形上每个位宽度都一样可以直观地确认波特率误差。注意还有一个常见误配置当USART1的时钟选择为PCLK2但CubeMX里APB2的分频系数设置成2时USART1时钟会变成36MHz这和手册上USART1可以使用72MHz不一致头文件里的__HAL_RCC_USART1_CONFIG会被设置成RCC_USART1CLKSOURCE_PCLK2依然是72MHz才对。如果看到波特率差一半重点检查时钟树里APB2的Prescaler是否被错误设置成2或4。2.4 生成代码后Keil5工程的结构与编译注意点生成完成后在工程目录下打开MDK-ARM文件夹里的.uvprojx文件。Keil5工程里会看到下面几个分组Application/User/Core包含main.c、stm32f1xx_it.c、system_stm32f1xx.c等关键文件。Application/User/Startupstartup_stm32f103c8tx.s启动文件。Drivers/STM32F1xx_HAL_DriverHAL库源代码很多文件编译时可全部包含也可以只保留需要的。Drivers/CMSIS内核定义和设备头文件。打开main.c后初始化顺序通常在main函数里是int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); while (1) { } }这里需要注意HAL_Init里会设置SysTick定时器作为HAL库时基默认1ms中断一次。如果你后面用到HAL_GetTick()它就是从SysTick里取计数值不用自己再额外写延时函数。而MX_GPIO_Init会把PA9、PA10初始化成复用推挽输出和复用输入。编译前要在Keil5的工程选项里做两件事第一在Device页确认芯片型号是STM32F103C8TxFlash大小为64KB第二在C/C页的Define里确认有USE_HAL_DRIVER和STM32F103x8两个宏定义这是CubeMX生成的不要手动删除。若没有编译会报出大量HAL库头文件找不到的错误。初次下载时还有一个坑如果用的是板载调试器Debug页里选择对应调试器然后点击Setting确认SW Device能识别到芯片否则下载提示No Target connected原因通常是SYS的Debug模式没选Serial Wire芯片复位后SWD引脚被复用掉了。代码里若某个GPIO配置把SWD引脚占用了也会出现这种现象。除了工程结构还有一点值得注意CubeMX生成的stm32f1xx_hal_msp.c里会实现HAL_UART_MspInit它负责使能USART时钟和配置GPIO引脚。如果你在main里手动重新初始化GPIO或者用__HAL_RCC_GPIOA_CLK_ENABLE反复使能时钟容易和MspInit里的操作冲突。我一般只改main.c里的业务逻辑不碰MspInit文件除非要调整引脚复用。如果添加了额外的源文件可以在Keil5工程里新建一个Group比如App把logger.c、debug.c等文件放进去。CubeMX生成的代码在重新生成时会保留这些Group但如果文件放在CubeMX工程的Core/Src外部重新生成后可能不会被包含。所以我的习惯是自定义文件都放在一个独立的App目录下并且在CubeMX的Project Manager中添加Include Path这样重新生成代码时不会丢。3. 把printf嫁接到串口三种重定向方式与底层逻辑3.1 printf重定向的本质C库与底层输出的桥接在Keil5的ARM编译环境中C标准库的printf函数内部维护了一个输出缓冲最终通过一个底层回调函数把字符写出去。对于使用MicroLIB的工程这个回调是fputc对于完整标准库则会走fputc以及一系列半主机支持函数。所谓重定向就是自己实现fputc让它把数据送到串口外设而不是默认的调试器输出。很多初学者以为printf是硬件层面的函数其实不是。它只是把格式化好的字符串逐字符交给fputcfputc内部才真正操作串口寄存器或HAL库的发送函数。这就解释了为什么在主函数里调printf之前串口没有初始化程序会死掉——fputc里调用HAL_UART_Transmit时串口外设时钟都还没开向外设寄存器写入会触发总线错误。正确的顺序是先把CubeMX生成的MX_USART1_UART_Init执行完再去打印。重定向方案的选择要看具体场景。裸机调试、逻辑分析、固件升级时的进度输出对实时性要求不高用阻塞发送最简单通信协议解析时则不应该在中断处理函数里调用printf否则中断被打印时间阻塞数据帧就丢掉了。这时更适合用自封装的环形缓冲加后台发送。下面展开三种常用方案。3.2 方案一MicroLIB配合fputc重定向MicroLIB是Keil针对嵌入式优化过的C库体积小、不需要半主机支持。在Target页勾选Use MicroLIB后只需实现fputc即可。我常用的写法是/* 串口重定向把标准输出送到USART1 */ #include stdio.h int fputc(int ch, FILE *f) { /* 一次性发送单个字符等待发送完成后再返回 */ HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }这段代码的关键点有三个。第一fputc的返回值是int发送成功后必须把字符返回否则标准库认为发送失败后续字符串会被截断。第二HAL_UART_Transmit最后一个参数是超时时间单位是毫秒我这里给0xFFFF在115200波特率下发送一个字符大约不到0.1ms几乎不会超时但在调试暂停操作中如果发送期间程序被调试器停下SysTick不更新超时时间到后函数返回HAL_TIMEOUT字符丢失。第三huart1是USART1的句柄CubeMX生成的名字默认是huart1如果用的是USART2要相应改成huart2。使用MicroLIB的好处是省Flash。一个只输出字符串的工程在-O2优化下代码量能比标准库少3-4KB。代价是printf默认不支持浮点%f如果你要打印温度值比如printf(%.2f\r\n, temp)MicroLIB会输出空字符串或乱码。解决方法是改用整数分拆打印或者换标准库方案。我一般是在温度显示需求出现前先用MicroLIB后期需要浮点了再整体切换而不是混用。还有一点要注意MicroLIB下的printf和scanf都尽量使用内置缓冲但sprintf仍然可用。如果使用printf输出出现串行数据中夹杂0x00可能是\0字符被当作可见字符发送了检查一下格式化字符串里是否有未初始化的char数组。3.3 方案二不使用MicroLIB完整标准库重定向如果需要完整标准库的浮点支持或者使用了fopen、malloc等复杂库函数就要关掉半主机模式。Keil的ARM编译器默认在半主机模式下运行如果工程里没有连接调试器桩代码程序运行到半主机相关调用时会进入死循环或HardFault。我常在项目里这样写/* 标准库重定向关闭半主机让printf走串口 */ #pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; FILE __stdin; void _sys_exit(int x) { x x; while (1); } int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; } int fgetc(FILE *f) { uint8_t ch; HAL_UART_Receive(huart1, ch, 1, 0xFFFF); return ch; }这段代码里#pragma import(__use_no_semihosting)告诉链接器不要链接半主机相关实现随后必须自己提供_sys_exit等桩函数。__stdout和__stdin是标准库内部引用的文件句柄没有它们链接会报错。fgetc是接收重定向用来实现scanf或getchar如果只需要输出可以省略。这种方式下printf的浮点功能可以正常使用但链接后的程序会明显变大而且重定向回调更脆弱。比如printf内部可能会调用fgets、fputs等如果你的fgetc写得不完整也会出现阻塞。常见做法是标准库重定向只用于单次格式化输出不用于逐字符交互。如果只是想要浮点支持也可以不关半主机模式直接勾选Use MicroLIB之外的选项然后在Linker配置里添加相关库。有经验的开发者通常会在标准库重定向里额外加上_ttywrch、_sys_open等桩函数避免链接器自动生成跳转。不过这些函数在F103上几乎不会用到补全后反而让代码更难看。我自己更倾向于只用标准库重定向但所有日志输出都走自己的封装函数不直接调用printf。3.4 方案三自封装可变参数打印函数不再依赖printf在一些工程里尤其是代码规范严格的团队会禁止直接使用printf原因是它重入性差、全局锁机制在裸机上效果有限且输出目标固定。这时我习惯做一个自定义的UART_Printf用vsnprintf格式化到局部缓冲再一次性发送。典型实现#include stdarg.h #include string.h void UART_Printf(const char *fmt, ...) { char buf[128]; va_list args; int len; va_start(args, fmt); len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len 0) { HAL_UART_Transmit(huart1, (uint8_t *)buf, len, 100); } }这个封装的好处是printf的重定向不再依赖C库内部实现fputc甚至可以保留默认的不支持状态。vsnprintf会把格式化的结果放到buf里随后用HAL库的发送函数送出去。缓冲区大小128是事先分配的如果日志很长比如几百字节的结构体打印缓冲区会截断这时可以根据需要调大或改用动态分配。这个函数放在中断里也要小心。vsnprintf本身不重入如果中断处理和主循环同时调用UART_Printf会出现字符串交叉。我的习惯是加一个全局锁变量或者干脆在中断里只置标志把格式化工作放到主循环执行。对于F103C8T6这种单核裸机临界区保护最简单的方式是进入函数前关闭全局中断发送完再开。但HAL_UART_Transmit是阻塞函数关中断期间系统时间戳会丢需要权衡。多任务系统里还可以封装一个带RTOS互斥锁的版本。还有一种更省RAM的做法把buf声明成static避免每次进入函数都在栈上分配128字节。但静态缓冲区在重入时更危险中断里调用同一个函数会直接互相覆盖。我通常的做法是主循环里的日志用大一点的静态缓冲中断里的日志用专用小型缓冲两者配置不同的输出等级。3.5 三种方案对比什么时候选哪个把三种方案放在一起看边界很清楚方案代码量Flash占用浮点输出重入安全适用场景MicroLIB fputc几行小不支持低裸机调试日志标准库重定向十几行大支持低需要浮点和文件操作自定义UART_Printf十几行中支持可加锁产品代码、RTOS、协议解析我见过一些工程把三种方式混在一起导致链接时出现多个fputc定义或者printf输出的字符被重复打印。原因是Keil5工程里可能存在多个包含fputc的源文件链接器不报错但只有一个生效。排查方法是在工程里搜索fputc确保只保留一份实现。如果项目已经有RTOS在自定义封装里加互斥锁会更容易。裸机环境可以考虑把发送放到DMA上但DMA和printf结合时vsnprintf产生的字符串先在RAM里再交给DMA外设这里要注意DMA的内存访问和外设总线冲突不过那是另外一个话题了。最终我的选型经验是调试阶段先在CubeMX工程里勾选MicroLIB用两行fputc快速跑通当代码进入产品化阶段把日志替换成自定义的UART_Printf并加上时间戳、等级过滤和锁。这两步切换成本很低但对后续调试效率提升很大。4. 串口输出的五个典型坑从看不见字符到随机乱码4.1 坑一程序跑起来了串口助手一个字符都没收到现象下载程序后在main里调用printf(hello\r\n)串口助手没有收到任何数据用调试器单步能看到程序确实执行了打印语句。原因大多数情况下是时钟配置错误。波特率是根据外设时钟计算出来的如果CubeMX生成的SystemClock_Config里HCLK不是72MHz而是8MHz那么初始化后的BRR寄存器值对应的实际波特率会和115200差很远。另一种原因是没有把PA9、PA10配置为复用功能如果之前有人改过GPIO初始化代码把PA9设成了普通推挽输出数据就不会从串口引脚发出。解决先检查RCC_ClkInitStruct.PeriphClockSelection是否包含RCC_PERIPHCLK_USART1然后确认huart1.Init.BaudRate是不是115200。如果时钟部分没有问题接着看GPIO初始化GPIO_InitStruct.Mode必须是GPIO_MODE_AF_PPTX和GPIO_MODE_INPUT配合上下拉RX。我调试这种问题最快的办法是先用逻辑分析仪抓PA9引脚看是否有脉冲。有脉冲说明硬件和GPIO配置对问题在波特率没脉冲说明软件根本没把数据送出来。顺带说一句如果使用的是STM32F103C8T6的最小系统板有的板子设计上把PA9/PA10串联了0欧电阻或跳线帽跳线帽没装也会一个字符都不输出。这种硬件问题在开发阶段很常见排查时先用万用表量一下MCU引脚到串口转接芯片之间的通路。4.2 坑二英文字符正常中文字符全部变成乱码现象printf(温度25)串口助手显示的温度正常但汉字全是乱码而且乱码形态随机有时同一个汉字每次显示还不一样。原因这是编码问题。Keil5的源码文件默认编码是ANSI/GB2312而很多现代的串口助手默认用UTF-8解码。printf是逐字符输出的汉字在GB2312下每字两字节在UTF-8下则是三字节发送方按自己保存的字节序列发出去接收方按另一种编码解码自然乱码。更麻烦的是如果汉字字符集转换不完全会出现半个字符的残留影响后续输出。解决最快的办法是输出内容全用英文调试日志里尽量不要带中文字面量。如果必须用中文需要把Keil5里的源码文件另存为UTF-8编码同时在串口助手里也选UTF-8解码。具体操作在Keil5的Edit菜单里选择Configuration设置Encoding为UTF-8然后每个源文件另存为UTF-8格式。注意在旧版本Keil里UTF-8格式的文件有时编译会多几个字节的BOM头程序里字符串的指针会偏移需要在编译器选项里关闭BOM或把文件统一成不带BOM的UTF-8。还有一个更隐蔽的坑如果字符串里有\r\n大部分串口助手会正确显示换行但有些助手的“显示Hex”和“显示文本”模式混着开会把0x0D 0x0A显示成“..”看起来像乱码。遇到这种情况先检查串口助手配置再怀疑编码。4.3 坑三调用printf后程序卡死或者进入HardFault中断现象程序初始化串口后执行一条printf就掉进HardFault_Handler单步时发现卡在某个库函数内部。原因通常是重定向不完整。在没有勾选MicroLIB的情况下直接实现fputc而不关闭半主机模式标准库的printf会尝试调用半主机相关的_sys_open、_sys_write等函数这些函数在裸机上没有对应实现链接器虽然不报错但运行时跳到非法地址。另一种情况是fputc里使用了HAL_UART_Transmit但串口句柄还没初始化比如在MX_USART1_UART_Init之前调用。解决如果使用MicroLIB在Target页勾选Use MicroLIB问题一般就消失。使用标准库则按前面第3.3节补全半主机桩函数和__stdout定义。检查串口初始化顺序确保打印语句放在所有MX_*_Init之后。此外还可以在HardFault_Handler里读取SCB-HFSR和SCB-CFSR寄存器确认HardFault的具体原因但裸机调试中大家通常先检查重定向。另一个容易忽略的细节是有些教程会让在中断服务函数里调用printf调试这种方式在裸机上偶尔能工作但一旦中断优先级嵌套fputc在同一个时间点被两次调用会破坏C库内部的状态。如果一定要在中断里输出建议用自定义封装而且格式化之前先关中断。4.4 坑四上电后第一次打印总是丢失后面的打印才正常现象在main函数开头直接打印启动信息比如printf(System Boot\r\n)板子上电后串口助手收到的是System Boot丢失或者只有后半截程序复位重新跑一遍又好了。原因这是硬件和时序问题。STM32F103C8T6上电瞬间串口线路上的电压和电平还没稳定此时往外发送数据第一个字节的起始位可能被接收端当成噪声。另外如果板子的复位电路有RC延迟MCU已经开始执行代码但串口芯片还没完成上电复位也会丢前几个字节。解决在工程里加一个HAL_Delay(50)放在打印前让串口芯片和电源稳定。更彻底的方案是在发送启动信息前先手动把TX引脚拉高一段时间再执行printf。我在代码里习惯这样写/* 启动时等待外部串口芯片稳定 */ HAL_Delay(50); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_9, GPIO_PIN_SET); HAL_Delay(10); printf(System Boot\r\n);GPIO_PIN_SET让TX线保持空闲电平串口接收端不会误认为有数据。这个办法对于使用CH340之类USB转串口芯片的板子很有效。有些板子还会在串口线上加ESD保护二极管上电时二极管的反向恢复时间也会使电平抖动延时同样有效。如果丢失的不是前两个字节而是第一个字符串的整个前几行还需要检查CubeMX生成的初始化代码里有没有对串口进行复位。一些BootLoader跳转过来的工程串口寄存器里可能有残留数据在初始化前先执行__HAL_UART_DISABLE(huart1)再初始化能清掉残留状态。4.5 坑五printf输出时好时坏甚至出现数据交叉错位现象在主循环里连续打印多个变量偶尔出现两行数据粘在一起或者变量数值错位用示波器看波形发现字节中间有停顿。原因这种问题多半是发送和接收的时序不匹配或者软件中多个地方同时调用UART发送函数。F103的HAL_UART_Transmit是阻塞发送但如果项目中还开了串口中断接收或者有两个任务交替打印没有互斥锁就会相互打断。特别是使用DMA发送时如果前一次发送还没完成就调用下一次发送DMA缓冲会被覆盖产生错位数据。解决如果只用printf输出调试信息加一个简单的全局标志互斥static volatile uint8_t uart_busy 0; int fputc(int ch, FILE *f) { while (uart_busy) ; uart_busy 1; HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); uart_busy 0; return ch; }但这种逐字符加锁的效率很低连续printf几百字节时关键点在于HAL_UART_Transmit内部本身已经轮询等待TC标志位多个发送调用不会真正并行反而可能因为中断处理函数占用时间过长导致发送完成事件被延迟。更好的做法是把putc发送改成每字符会等待串口波特率高了后问题不大如果依然错位检查NVIC优先级不要让UART中断打断主循环里的打印流程。把这些优先级理清后输出就稳定了。还有一个很少人提到的原因printf格式化字符串里如果用了%d但对应的参数是uint8_t可变参数会提升为int这没问题但如果用的是%x参数却是uint16_t并且没有强制转换成unsigned int在F103这种小端模式上打印出来的可能是高16位乱码。这种错位不是串口问题而是格式化占位符和参数类型不匹配属于比较隐蔽的坑。如果数据错位只发生在长时间运行后还要怀疑是HAL_UART_Transmit的超时处理没有做。默认阻塞发送在发送失败后会置位错误标志但不会自动清除下一次调用会直接返回错误。我一般在重定向函数里加上__HAL_UART_CLEAR_OREFLAG(huart1)之类清除操作保证每次发送都能重新开始。5. 进阶技巧让printf输出可控可溯加时间戳与日志等级当串口输出已经不是“能不能跑通”而是“怎么用得顺手”时我通常会做两个小改造。第一个是给打印信息加系统运行时间戳用来定位问题发生时机。F103的HAL库已经用SysTick维护了一个毫秒计数器通过HAL_GetTick()读取封装时可以把这个值和原始信息一起格式化出来void UART_Println(const char *func, int line, const char *fmt, ...) { char buf[160]; int len; va_list args; len snprintf(buf, sizeof(buf), [%lu] %s:%d , HAL_GetTick(), func, line); va_start(args, fmt); vsnprintf(buf len, sizeof(buf) - len, fmt, args); va_end(args); HAL_UART_Transmit(huart1, (uint8_t *)buf, strlen(buf), 100); } #define LOG_INFO(...) UART_Println(__FUNCTION__, __LINE__, __VA_ARGS__)调用时只需要LOG_INFO(distance%d cm, dist);输出就变成[12345] main:67 distance12 cm时间戳单位是毫秒配合串口助手的接收时间还能交叉验证。注意snprintf和vsnprintf混用时长度计算要小心缓冲区余量不够会截断。第二个是日志等级过滤通常定义LOG_DEBUG、LOG_INFO、LOG_WARN、LOG_ERROR几档用宏控制哪些输出能进串口哪些被剪掉。比如产品发布时只保留ERROR级平时冲调试信息。这个方案相较printf重定向是用一点运行时代价换可用性的做法在F103上非常实用。我后来在一个需要控制成本的量产设备上发现自定义封装比printf重定向更可控但同时也有个教训缓冲区在栈上申请日志打印频率高时会拉高栈顶STM32F103C8T6的RAM只有20KB默认栈大小1KB如果同时打印多行UART_Println的160字节缓冲连续嵌套几次就可能溢出。从那以后我每次调整打印内容都强制走一遍栈内存核算确认最深层调用路径的总栈需求没有压到启动文件里定义的Stack_Size边缘。这套封装让我把串口从玄学变成了一个可预期的调试手段希望帮到你。本文还有配套的精品资源点击获取