干了这么多年嵌入式,我拿到一块新板子的老规矩还是那两件事:先点灯,再让 printf 能往串口吐字。点灯是证明最小系统活着,printf 重定向串口则是让自己终于能“看见”程序在跑什么。说实话,把 printf 搬上 STM32 这件事并不难,核心就是几行代码,但几乎每周都有人在群里问:为什么 printf 没输出、为什么中文乱码、为什么一调用就死机。这次干脆把原理、代码、踩坑一次性聊透,不论你用 Keil MDK 还是 STM32CubeIDE,看完应该都能直接上手。
1. 先弄清原理:printf 是怎么“跑”到串口上的
1.1 一个 printf 调用经历的完整旅程
很多人把 printf 重定向理解成“写个魔法函数”,其实它的本质很简单:printf 是 C 标准库提供的一个格式化输出函数,它内部负责把格式串和参数拼成一段字符串,但拼完之后把这段文字送到哪里去,标准库本身说了不算,它需要依赖平台提供的底层输出接口。
在 PC 上,这个底层接口连着显示器;在单片机上,标准库不知道你的串口寄存器在哪,也不知道波特率是什么,所以它默认会走“调试通道”或者直接是空函数。我们把输出改到串口,其实就是把标准库内部的“最终投递员”换掉:我不往屏幕上写了,我往你指定好的 UART 外设发送寄存器里塞字节。
可以打个比方:printf 像公司前台,信件都是它整理好的,但快递交给谁,需要你自己去跟快递公司谈。重定向做的就是这件事——谈好之后,所有信件统一从 UART1 这个门口寄出去。
1.2 编译器不同,钩子函数也不同
这里要先建立一个认知:不同编译工具链,标准库底层钩子的名字不一样。很多人踩坑就是因为照着别人的代码抄,结果工具链不一样,改了一个根本不会被调用的函数。
Keil MDK 用的通常是 ARMCC(AC5)或者 ARMCLANG(AC6),这套工具链下,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_putchar:
int __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 用得更顺手:多串口、加时间戳、换 DMA
3.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 用熟,踩一踩乱码、断连、误改波特率这些坑,再去优化也不迟。毕竟调试工具存在的意义是帮你看清代码在干什么,如果工具本身比代码还难伺候,那就本末倒置了。