ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32 printf重定向串口:原理、代码、踩坑一次讲透

STM32 printf重定向串口:原理、代码、踩坑一次讲透 干了这么多年嵌入式我拿到一块新板子的老规矩还是那两件事先点灯再让 printf 能往串口吐字。点灯是证明最小系统活着printf 重定向串口则是让自己终于能“看见”程序在跑什么。说实话把 printf 搬上 STM32 这件事并不难核心就是几行代码但几乎每周都有人在群里问为什么 printf 没输出、为什么中文乱码、为什么一调用就死机。这次干脆把原理、代码、踩坑一次性聊透不论你用 Keil MDK 还是 STM32CubeIDE看完应该都能直接上手。1. 先弄清原理printf 是怎么“跑”到串口上的1.1 一个 printf 调用经历的完整旅程很多人把 printf 重定向理解成“写个魔法函数”其实它的本质很简单printf 是 C 标准库提供的一个格式化输出函数它内部负责把格式串和参数拼成一段字符串但拼完之后把这段文字送到哪里去标准库本身说了不算它需要依赖平台提供的底层输出接口。在 PC 上这个底层接口连着显示器在单片机上标准库不知道你的串口寄存器在哪也不知道波特率是什么所以它默认会走“调试通道”或者直接是空函数。我们把输出改到串口其实就是把标准库内部的“最终投递员”换掉我不往屏幕上写了我往你指定好的 UART 外设发送寄存器里塞字节。可以打个比方printf 像公司前台信件都是它整理好的但快递交给谁需要你自己去跟快递公司谈。重定向做的就是这件事——谈好之后所有信件统一从 UART1 这个门口寄出去。1.2 编译器不同钩子函数也不同这里要先建立一个认知不同编译工具链标准库底层钩子的名字不一样。很多人踩坑就是因为照着别人的代码抄结果工具链不一样改了一个根本不会被调用的函数。Keil MDK 用的通常是 ARMCCAC5或者 ARMCLANGAC6这套工具链下printf 最终会逐字符调用一个叫 fputc 的函数所以我们要重写的就是 fputc。GCC 工具链则是另一套体系比如 STM32CubeIDE 或者 CLion 里用的 arm-none-eabi-gcc它的 newlib 库最终走的是 _write 这个函数或者是 CubeMX 生成的 syscalls.c 里的 __io_putchar 函数。如果你在 CubeIDE 里抄了 Keil 的 fputc 重定向代码大概率是不生效的。理解这一点之后后面所有代码你都不会再觉得是“玄学”了。1.3 MicroLIB、标准库、newlib 到底选哪个Keil 里有个“Use MicroLIB”的选项很多人不太清楚它到底做了什么。MicroLIB 是 ARM 提供的一个精简版 C 运行库裁剪了不少标准库特性但它对嵌入式场景非常友好占用 Flash 小printf 的浮点支持也默认能工作最重要的是重定向非常简单只要重写 fputc 就不会有半主机semihosting那些破事。如果你不勾选 MicroLIB用的是标准库那么 printf 默认走半主机调试通道这意味着你的程序运行到 printf 时会尝试跟调试器沟通如果没有调试器程序很可能直接卡死或者进 HardFault。所以用标准库的同学必须额外处理半主机相关符号这个我后面会给完整代码。我的习惯是Keil 下调试阶段的小工程几乎一律勾 MicroLIB省心。如果项目对 C 库特性依赖较多或者后续要移植复杂中间件再切换标准库但半主机处理那段代码要准备好。2. 实操从 CubeMX 到一个能 printf 的工程2.1 先花两分钟搭一个串口工程就算你已经有一个现成的工程我也建议按这个流程快速验证一下排除硬件和配置问题。打开 STM32CubeMX选好芯片找到 USART1也可以是其他串口把 Mode 设置成 Asynchronous波特率设成 115200其他参数默认就可以。时钟树可以先不管CubeMX 默认配置足够让串口工作。生成代码的时候工程类型选 MDK-ARM V5 或者 STM32CubeIDE看你自己习惯。有一点值得注意阻塞式发送其实不需要开启串口中断NVIC 里的 USART1 global interrupt 可以不勾。因为 HAL_UART_Transmit 是轮询发送中断对它没什么帮助反而可能干扰其他功能。等后面你打算用 DMA 或者中断发送时再开不迟。2.2 Keil MicroLIB 的经典写法这是最省事的一条路。生成工程后在 Keil 的 Options for Target 里勾选 Use MicroLIB然后新建一个 debug.c 或者直接写在 main.c 里#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }然后在 main 函数里调用printf(Hello STM32\r\n);下载运行打开串口助手波特率 115200应该就能看到字符串了。这段代码很简单但我还是想多解释几句。fputc 的形参 ch 是 int 类型原因是 C 标准库考虑到 EOF 这种特殊值但我们需要发送的其实是一个 8 位字节所以通过(uint8_t *)ch拿到了这个字节的地址再交给 HAL_UART_Transmit 发送。整个函数执行完后要返回 ch否则库会认为发送失败也容易引发后续怪异行为。HAL_UART_Transmit 的最后一个参数是超时时间我这里写了 HAL_MAX_DELAY意思是无限等待。很多教程喜欢写 0xFFFF但我建议直接写 HAL_MAX_DELAY原因后面在“踩坑现场”里会详细算一笔时间账。2.3 不勾 MicroLIB 的标准库写法有的项目因为中间件或者浮点运算精度问题不想用 MicroLIB那也可以用标准库。前提是必须把半主机模式禁掉并且把相关符号补齐。完整代码长这样#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x x; while (1); } void _ttywrch(int ch) { ch ch; } int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }第一行#pragma import(__use_no_semihosting)是告诉链接器这个程序不使能半主机模式。如果不加这一句即使你写了 fputc标准库内部的某些函数仍然会引用半主机相关的实现常见报错有一堆 undefined symbol比如__stdout或者_sys_exit找不到。补齐这些符号之后链接器就满意了。有人会问这里的while (1)会不会导致死循环理论上_sys_exit是程序异常退出时调用的正常运行的代码根本走不到。写死循环只是为了满足链接器对符号存在性的要求实际操作中不会触发。不过如果触发了通常是栈溢出或者内存被踩这已经是另一个故事了。2.4 STM32CubeIDE / GCC 工具链的重定向姿势如果你用的是 STM32CubeIDE或者 VS Code arm-none-eabi-gcc再抄 fputc 就不管用了。CubeMX 生成的工程里有一个 syscalls.c 文件里面已经准备了一个弱符号的 _write 函数它的默认实现是空转所以 printf 编译能过但没有任何输出。你只需要在 main.c 或者其他源文件里补一个强符号的__io_putcharint __io_putchar(int ch) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }因为 syscalls.c 里的_write会逐字节调用__io_putchar所以补完这个函数printf 就能输出了。另一种更直接的做法是重写整个_write一次性发送一整段数据效率更高也避免逐字节调用带来的性能浪费int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }注意这里的_write不能返回 HAL_StatusTypeDef必须返回实际发送的字节数 len否则库会认为写入失败后续输出可能中断。3. 把 printf 用得更顺手多串口、加时间戳、换 DMA3.1 多串口场景一个 printf 不够用怎么办有些板子上一共引出了好几个串口USART1 接调试串口USART2 接蓝牙模块USART3 接传感器。全部都用 printf 肯定不现实因为这些外设共用一套 stdout。最常用的做法是自己封装一个uart_printf函数。思路是先用 vsnprintf 把格式化结果写到一个缓冲数组里再指定发给哪一个串口#include stdio.h #include stdarg.h int uart_printf(UART_HandleTypeDef *huart, const char *fmt, ...) { char buf[128]; va_list args; va_start(args, fmt); int len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len 0) { HAL_UART_Transmit(huart, (uint8_t *)buf, len, HAL_MAX_DELAY); } return len; }调用方式变成uart_printf(huart1, system tick: %lu\r\n, HAL_GetTick()); uart_printf(huart2, temp: %.2f\r\n, temp);这个封装的优点是既能指定串口又不会破坏标准 printf 的格式化能力。注意 buf 数组大小要根据日志长度留够但也不要动不动开个 1024 字节的大数组放在普通函数里比较吃栈。128 字节对于调试日志来说已经够用了。3.2 给调试日志加时间戳和日志开关裸机项目调试时经常需要记录某段程序运行到哪个位置、距离上次执行过了多久。最常见的就是打印 HAL_GetTick() 的时间戳。可以直接用标准 printf 实现printf([%lu ms] adc value: %d\r\n, HAL_GetTick(), adc_val);如果嫌每次写格式串太啰嗦可以再包一层void log_info(const char *fmt, ...) { printf([%lu ms] , HAL_GetTick()); char buf[128]; va_list args; va_start(args, fmt); vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); printf(%s\r\n, buf); }这样调用的代码是log_info(motor speed: %d rpm, speed);实际输出会像这样[12345 ms] motor speed: 3200 rpm日志开关的做法也很简单在头文件里定义一个宏比如#define DEBUG_ENABLE 1 #if DEBUG_ENABLE #define log_info(...) printf([%lu ms] , HAL_GetTick()), printf(__VA_ARGS__), printf(\r\n) #else #define log_info(...) #endif这样发布版本时把 DEBUG_ENABLE 置 0所有日志代码就自动消失了不用满工程去找 printf 删。3.3 重定向到 DMA 发送这件事没那么简单有些朋友追求性能想把 fputc 里的 HAL_UART_Transmit 改成 HAL_UART_Transmit_DMA让 printf 不阻塞 CPU。思路没错但坑非常深。直接改成 DMA 发送后最容易出现的问题是第一次 printf 正常第二次开始没输出或者输出乱码。原因通常是上一次 DMA 传输还没结束你下一次就启动了新的传输把数据覆盖了。HAL 库在底层会设置 BUSY 标志如果检测到串口忙新的 Transmit 请求会直接返回 HAL_BUSY数据就被丢了。想要稳定使用 DMA 发送最稳妥的方案是用一个发送完成标志位或者在 DMA 传输完成中断里置一个标志fputc 里等待上一个发送完成再启动下一个。这样实际上又变成了阻塞只是阻塞时间变短了。真正高性能的玩法是引入环形缓冲区 空闲中断把日志全部丢进队列由中断后台慢慢发。这个复杂度已经超出“printf 重定向”的范畴对于大部分调试场景我觉得用阻塞发送完全够用。115200 波特率下一个字节大概 0.09ms打印一行一百字符的日志也就 9ms在 main 循环里根本不影响什么。4. 踩坑现场printf 问题排查速查表4.1 完全没输出先从这五个地方查printf 没输出是出现率最高的问题主要也就那么几个原因。第一个串口助手端口选错或者波特率不对。这个听起来低级但别笑我用 CH340 的板子接过无数次陌生 USB 口Windows 分配 COM 号经常变串口助手打开的是 COM3板子其实在 COM5自然什么都收不到。先重新插拔再去设备管理器看一眼端口号。第二个编译没问题但下载的固件根本不是当前代码。很多人忘记点下载或者下载失败然后对着串口助手发呆。Keil 里顺手把烧录信息栏打开每次确认一下 Flash Download 成功了再查别的地方。第三个printf 里没有带换行符并且库里是行缓冲模式。不过我在 STM32 的多数工具链里没遇到过“必须加 \n 才输出”的严格缓冲行为但如果你的 printf 一直不输出试着在末尾加 \r\n 看看花不了十秒钟。第四个源文件里没有#include stdio.h。这种问题在 Keil AC5 下会看到 warning #223-D: function printf declared implicitly虽然工程能通过编译但函数声明不匹配可能导致数据传输出错我在老版本 AC5 上遇到过 printf 输出一堆异常字符的情况。4.2 中文乱码到底是谁的锅中文乱码这个问题九成不是重定向代码的问题而是编码不匹配。串口通信本身就是字节流printf 把“你好”按照某种编码形式变成几个字节发出去串口助手显示时又按照另一种编码规则来解码两边对不上就变成乱码了。这里要分两层看。第一层是源文件的编码Keil 5 默认编辑器编码是 ANSI也就是 GBK/GB2312而很多人在 VSCode 里写代码保存成 UTF-8两种编码对同一个汉字产生的字节完全不同。第二层是串口助手的解码方式有的软件默认 GBK有的默认 UTF-8。你代码里是 UTF-8 的“你好”串口助手按 GBK 解码出来的就是“浣犲ソ”这种天书。最简单的统一方案全工程源文件都用 ANSI/GBK 保存Keil 里不用改任何设置串口助手选 GBK 解码或者全工程源文件都用 UTF-8 保存串口助手选 UTF-8 解码。怕就怕源码是 UTF-8串口助手又选了 GBK。另外终端软件方面MobaXterm、Putty 都有编码设置VSCode 的串口监视插件 Serial Monitor 右下角也能选编码。这个事跟配置 CH340 驱动一样属于“看起来很小但能让程序员崩溃一下午”的问题。4.3 一调用 printf 就死机或者进 HardFault这个问题有几个高频元凶。第一个是没有禁用半主机。Keil 里用标准库但只写了 fputc没加#pragma import(__use_no_semihosting)也没补_sys_exit、_ttywrch这些符号运行时一调用 printf 就会去访问调试器相关资源在板子上直接卡死。这也是我为什么建议新手先勾 MicroLIB因为能完美绕开这个问题。第二个是 printf 格式符和参数类型不匹配。比如你用%d去打印一个 float 变量因为浮点在参数寄存器里的传递规则和整数不同轻则显示错误数值重则堆栈错乱直接 HardFault。嵌入式里尤其要注意想打印的数据正确格式符常见错误写法8/16/32 位整数%d, %u无长整数%ld, %lu%d浮点数%f%d指针地址%p%x十六进制%x / %X%d还有一点MDK 里如果你没有勾选 MicroLIB标准库的 printf 默认可能不支持浮点打印%f 会输出空白或者 0。这种情况在工程里勾上 Use MicroLIB 基本就能解决。4.4 关于串口相关的小毛病驱动、烧写失败串口调试还经常遇到两个外围问题CH340 驱动异常和烧写失败。CH340 这类 USB 转串口芯片Win10/Win11 一般插上就能识别但如果你用的是特别老的版本或者之前装过山寨驱动可能设备管理器显示正常实际一打开就报“端口被占用”或者打开失败。解决办法是去设备管理器把设备卸载掉勾选“删除此设备的驱动程序软件”重插一次让它重新装驱动然后换个 COM 号。还有烧写失败这跟 printf 没直接关系但排查“printf 不输出”的时候经常会遇到“程序根本下不进去”的问题。很多 STM32 最小系统板默认是串口下载如果你上一秒还在用串口调试下一秒突然烧写失败有可能是串口被其他软件占用了或者 DTR/RTS 电平不对进不了 Boot 模式。最直接的做法把串口助手全关掉按下板子上的复位键或者手动切换 BOOT0 到高电平再重新下载。我的经验是这种问题八成是软件端口占用两成是线材问题——很多 USB 线只能充电不能传数据这个坑能卡住一半的纯新手。4.5 常用问题排查速查表现象可能原因解决方法printf 完全没输出串口号/波特率不对没 include stdio.h下载失败重新插拔看设备管理器加 #include确认烧录成功输出乱码或乱字符编码不匹配驱动异常统一源文件和串口助手编码重装 CH340 驱动printf 卡死/HardFault半主机未禁用格式符不匹配栈溢出勾 MicroLIB 或补半主机符号核对 printf 参数检查栈空间一调用 DMA 发送就丢字符上一次 DMA 未完成加 busy 标志或改用阻塞发送中文乱码源文件编码与终端解码不一致全部统一为 UTF-8 或 GBK并对齐串口助手解码格式串口能收到但一直有额外数据串口助手开了 Hex 显示切回文本模式5. 提升调试体验的几个小习惯5.1 中断里不要直接 printf很多人一开始都会犯这个错在定时器中断或者外部中断回调里加一行 printf结果整个系统开始卡顿甚至死机。原因其实在上面分析过阻塞式串口发送要占用 CPU 时间而中断环境里最忌讳的就是长时间阻塞。万一你正在中断里 printf同一时间另一个更高优先级的中断来了它会被活活堵住系统实时性瞬间崩塌。正确的做法是中断里只置一个标志位比如volatile uint8_t adc_data_ready 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { adc_data_ready 1; }然后在主循环里检查这个标志再执行 printf。这样中断服务程序保持极短printf 再慢也只是在主循环里拖慢一点速度不会破坏整个中断系统。5.2 RTOS 多任务下printf 数据会“打架”用 FreeRTOS 之后会遇到一个新的现象两个任务同时调用 printf日志串行输出变成了“你中有我、我中有你”。比如任务 A 打印Hello A任务 B 打印Hello B结果可能变成HelHello Alo B这种。原因就是 printf 内部格式化完成后fputc 是一个字符一个字符向外发的可能在发送几个字符之后任务调度切到了另一个任务另一个任务又开始发自己的字符数据就交错在一起了。解决办法是在封装函数里加互斥锁。先格式化到局部缓冲再在临界区里一次性发送整段数据void task_safe_printf(const char *fmt, ...) { char buf[128]; va_list args; va_start(args, fmt); int len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); osMutexAcquire(printf_mutex, osWaitForever); HAL_UART_Transmit(huart1, (uint8_t *)buf, len, HAL_MAX_DELAY); osMutexRelease(printf_mutex); }这个封装的关键在于先格式化、后加锁而不是直接锁住 printf 本身。因为 printf 的格式化比较耗时如果锁住 printf 整个函数其他高优先级任务会被长时间阻塞影响实时性。先把格式化成字符串加锁只保护串口发送那一段占用时间极短互不干扰又相对公平。5.3 我个人目前的固定套路做了这么多项目之后我现在对 printf 重定向这件事已经有了一套固定做法。调试期在 Keil 下一定是勾 Use MicroLIB重写 fputc串口用阻塞发送波特率固定 115200。项目里准备一个 debug.c所有日志打印都通过自定义的 log 宏来控制开关发布版本一键关闭。换到 STM32CubeIDE 时默认补 __io_putchar 就能跑从来不纠结哪套工具链更好。如果你刚接触 STM32我建议不要一上来就玩 DMA printf 队列那套炫技方案先把最基础的阻塞式 printf 用熟踩一踩乱码、断连、误改波特率这些坑再去优化也不迟。毕竟调试工具存在的意义是帮你看清代码在干什么如果工具本身比代码还难伺候那就本末倒置了。
返回列表