☰
Keil5下STM32串口printf重定向:从fputc原理到MicroLIB配置与避坑指南
2026/10/15 3:04:11 网站建设 项目流程

1. 串口调试为什么绕不开printf重定向

搞嵌入式开发的人都有一个共同的肌肉记忆:程序跑不起来,第一反应就是打日志。而在裸机或者RTOS环境下,最顺手的日志出口就是串口。但问题来了,C标准库里的printf默认是往标准输出设备写的,单片机哪来的显示器?所以我们必须把printf的底层输出通道“接”到串口上,这个过程就是所谓的重定向。

我见过太多新手在这一步卡住:代码里明明写了printf("hello\r\n"),串口助手却一片空白,或者只收到乱码。其实问题往往不在串口配置本身,而在于printf背后的fputc函数没有被正确接管。Keil MDK作为ARM Cortex-M开发的主流IDE,它默认使用的是ARM Compiler(AC5或AC6),不同编译器版本对printf重定向的处理方式有细微差别,这也是很多人换了个Keil版本就翻车的原因。

这篇文章面向所有用Keil5开发STM32、GD32、NXP等Cortex-M芯片的工程师,不管你是刚点亮第一个LED的新手,还是想梳理底层细节的老手,我都会把重定向这件事从原理到代码、从配置到排错,完整地拆一遍。核心关键词就三个:Keil5、printf重定向、串口输出。读完你不仅能一次跑通,还能明白每一行代码为什么这么写。

2. 重定向的整体设计思路与方案选型

2.1 为什么是fputc而不是直接改printf

C标准库的设计其实很聪明。printf负责格式化字符串——把整数、浮点数、字符串按格式拼成一条完整的字符流,而真正把字符“送出去”的动作,是交给底层函数完成的。在ARM Compiler的C库(MicroLIB或标准库)里,这个底层函数就是fputc。

所以重定向的本质,就是用自己的串口发送函数去覆盖库里的fputc。当printf格式化完一个字符,就会调用fputc把它输出,我们只要在fputc里把字符塞进串口数据寄存器就行。这个设计的好处是:你不需要动printf一行代码,也不用关心浮点数怎么格式化,只专注“怎么把字节发出去”。

注意:有些教程会让你重写__io_putchar或者_write,那是ARM Compiler 6(AC6)或者GCC的做法。Keil5默认装的是AC5,用fputc最稳。如果你用的是AC6,fputc依然可用,但需要额外处理一些细节,后面会讲。

2.2 MicroLIB到底开不开

这是Keil5里最经典的纠结。MicroLIB是ARM专门为嵌入式优化的精简C库,体积小、不依赖操作系统,但功能有裁剪。开不开它,直接影响重定向的写法和最终效果。

我的建议很明确:裸机项目、RTOS项目,只要不用复杂的locale和文件系统,一律开MicroLIB。原因有三点:第一,MicroLIB的printf不依赖半主机模式(semihosting),不会因为没接调试器就卡死;第二,代码体积能小好几KB;第三,重定向写法最简单,一个fputc搞定。

如果你不开MicroLIB,标准库的printf会尝试用半主机方式输出,也就是通过调试器把字符传到Keil的Debug窗口。这时候如果你没在Options for Target里勾选Use MicroLIB,也没禁用半主机,程序可能一调用printf就停在BKPT指令上,表现为“卡死”。这个坑我踩过不止一次。

配置项开MicroLIB不开MicroLIB
代码体积小大
半主机依赖无有,需手动禁用
fputc重定向直接可用需配合禁用半主机
浮点printf支持需勾选Use Float默认支持
推荐场景裸机/RTOS需要完整C库功能

2.3 串口外设的选型与初始化顺序

重定向只是“管道”,真正干活的还是串口外设。以最常见的USART为例,初始化顺序必须是:开时钟 → 配GPIO → 配USART参数 → 使能USART。这个顺序不能乱,尤其是时钟,忘了开GPIO时钟,引脚就是死的,发不出任何波形。

波特率的选择也有讲究。115200是默认推荐值,兼容性好、速度快。如果你用内部RC振荡器(HSI)做时钟源,波特率误差可能偏大,建议降到9600先验证通路,再往上调。外部晶振(HSE)8MHz经PLL倍频后,115200的误差通常在0.1%以内,很稳。

3. 核心细节解析与实操要点

3.1 fputc函数的正确写法

先看最核心的代码。这是基于STM32 HAL库的写法,其他芯片把发送函数换掉即可:

#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

看起来简单,但有几个细节决定成败。第一,ch参数虽然是int,但实际只用了低8位,强转成uint8_t*发送一个字节是对的。第二,超时时间给0xFFFF是保险做法,实际发送一个字节在115200下只要约87微秒,给太大也不会真等那么久。第三,返回值必须是ch,否则printf的返回值会出错,虽然一般不影响输出,但规范上要返回。

如果你用的是寄存器操作,可以写成:

int fputc(int ch, FILE *f) { while((USART1->SR & 0x40) == 0); // 等待发送数据寄存器空 USART1->DR = (ch & 0xFF); return ch; }

这种写法效率更高,不依赖HAL库,适合对性能敏感的场景。但要注意,while等待必须有超时保护,否则串口硬件故障时会死循环。

3.2 必须包含的头文件与编译器差异

fputc的原型在stdio.h里,所以#include <stdio.h>不能少。但光有这个还不够,FILE结构体的定义也在stdio.h中,所以参数类型FILE *f才能被识别。

AC5和AC6的差异在这里体现。AC5下,上面的写法直接编译通过。AC6下,fputc可能被标记为__attribute__((weak)),你的定义会覆盖它,但有些AC6版本要求函数签名完全一致,否则链接时报重复定义。如果遇到这种情况,可以改用__io_putchar:

int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

提示:AC6下如果同时定义了fputc和__io_putchar,可能冲突。选一个即可,推荐AC5用fputc,AC6用__io_putchar。

3.3 浮点数打印的坑与解决方案

printf("%f", 3.14)在MicroLIB下默认是不输出的,或者输出为空。因为MicroLIB为了省空间,默认不链接浮点格式化模块。解决办法是在Keil的Options for Target → Target里勾选Use MicroLIB旁边的Use Float in printf(有些版本叫“Use IEEE 754 Float”)。

勾选后代码体积会增加约2-4KB,但浮点打印就正常了。如果你实在不想增加体积,可以自己写定点转换,把浮点数乘以1000转成整数打印,再手动加小数点。这个方法在资源极紧张的芯片上很实用。

另外,%f默认打印6位小数,如果只要2位,用%.2f。但注意MicroLIB对%.2f的支持也有裁剪,实测AC5+MicroLIB下%.2f是正常的,%e和%g可能不支持。

4. 完整实操流程与关键环节实现

4.1 从零搭建一个可复现的工程

我以STM32F103+USART1为例,把完整流程走一遍。你照着做,十分钟内能看到串口输出。

第一步,新建Keil工程,选好芯片型号,在Manage Run-Time Environment里勾选CMSIS → CORE和Device → Startup,HAL库的话勾选Device → STM32Cube HAL下的RCC、GPIO、USART。

第二步,配置时钟。在SystemInit里把系统时钟设到72MHz,APB2给USART1提供72MHz时钟。这一步用CubeMX生成最省事,但手动写也不难:

RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_USART1EN;

第三步,配GPIO。PA9是TX,配成复用推挽输出,50MHz;PA10是RX,配成浮空输入或上拉输入。

GPIOA->CRH &= ~(0xFF << 4); GPIOA->CRH |= (0x0B << 4) | (0x04 << 8);

第四步,配USART。波特率115200,8位数据,1位停止,无校验。

USART1->BRR = 72000000 / 115200; // 约625 USART1->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;

第五步,加入fputc重定向代码,勾选MicroLIB,编译下载。

第六步,打开串口助手,选对COM口,波特率115200,在main里写:

printf("System Init OK\r\n"); printf("Tick: %d\r\n", HAL_GetTick());

如果看到输出,恭喜你,通了。

4.2 参数计算:BRR寄存器到底怎么算

很多人直接抄72000000/115200,但BRR寄存器的格式不是简单的除法。它分整数部分和小数部分:高12位是整数,低4位是小数(1/16为单位)。

以72MHz、115200为例:

  • 理论分频值 = 72000000 / (16 × 115200) = 39.0625
  • 整数部分 = 39 = 0x27
  • 小数部分 = 0.0625 × 16 = 1 = 0x1
  • BRR = (0x27 << 4) | 0x1 = 0x271

所以直接写USART1->BRR = 0x271比72000000/115200更精确。后者算出来是625,即0x271,刚好一样,但换个波特率就不一定了。比如9600:

  • 72000000 / (16 × 9600) = 468.75
  • 整数468 = 0x1D4,小数0.75×16=12=0xC
  • BRR = 0x1D4C

而72000000/9600 = 7500 = 0x1D4C,又刚好一样。这是因为72MHz对常见波特率的误差很小。但如果你用8MHz时钟,差异就出来了。所以养成用公式算的习惯,别偷懒。

4.3 中断发送与DMA发送的进阶玩法

HAL_UART_Transmit是阻塞发送,每发一个字节CPU都要等。如果你在printf里打大量日志,会严重拖慢主循环。这时候可以改成中断或DMA。

中断方式:把fputc改成把字符塞进环形缓冲区,然后启动中断发送。主循环只管往缓冲区写,中断负责往外发。这样printf几乎不耗时。

DMA方式:更省CPU,但要注意DMA发送完成前不能修改缓冲区。对于日志输出,我一般用中断+环形缓冲,够用且简单。

#define BUF_SIZE 256 static uint8_t tx_buf[BUF_SIZE]; static volatile uint16_t tx_head = 0, tx_tail = 0; int fputc(int ch, FILE *f) { uint16_t next = (tx_head + 1) % BUF_SIZE; while(next == tx_tail); // 缓冲区满则等待 tx_buf[tx_head] = (uint8_t)ch; tx_head = next; USART1->CR1 |= USART_CR1_TXEIE; // 开TXE中断 return ch; } void USART1_IRQHandler(void) { if(USART1->SR & USART_SR_TXE) { if(tx_tail != tx_head) { USART1->DR = tx_buf[tx_tail]; tx_tail = (tx_tail + 1) % BUF_SIZE; } else { USART1->CR1 &= ~USART_CR1_TXEIE; // 发完关中断 } } }

这段代码实测在115200下打日志几乎不影响主循环,推荐在RTOS任务里用。

5. 常见问题与排查技巧实录

5.1 串口助手一片空白怎么办

这是最高频的问题。排查顺序我总结成一张表:

现象可能原因排查方法
完全无输出串口助手COM口选错设备管理器确认
完全无输出波特率不匹配双方都设115200
完全无输出TX/RX接反交换杜邦线
完全无输出没开MicroLIB且卡在半主机勾选MicroLIB
输出乱码时钟频率不对检查SystemInit
输出乱码波特率误差大换外部晶振
只输出一次程序卡在fputc加超时保护
输出一半缓冲区溢出加大缓冲或降速

我遇到最多的是“没开MicroLIB导致卡死”。现象是程序下载后不跑,或者跑一下就停。这时候在Keil的Debug模式下按暂停,看PC指针是不是停在BKPT附近。如果是,勾选MicroLIB立刻解决。

5.2 重定向后程序变慢甚至卡死

HAL_UART_Transmit的最后一个参数是超时。如果你给0xFFFF,在串口硬件故障时最多等65535毫秒,约65秒,看起来就像卡死。更糟的是,如果你在中断里调用printf,而fputc又是阻塞的,中断会卡很久,导致其他中断丢失。

解决办法:第一,超时给合理值,比如100毫秒;第二,绝对不要在中断里用阻塞式printf,要用环形缓冲;第三,如果必须在中 interrupt 里打日志,用DMA或只置标志位,在主循环里打。

注意:RTOS里多个任务同时printf会竞争串口,导致输出交错。加一个互斥锁(Mutex)保护fputc,或者每个任务用独立缓冲区。

5.3 AC6编译器下的特殊处理

AC6下,fputc可能不生效,因为AC6的C库默认使用__io_putchar或者_write。我实测AC6.16下,定义fputc依然有效,但需要确保没有同时定义__io_putchar。如果链接报错“multiply defined”,说明两个都定义了,删掉一个。

另外,AC6下MicroLIB的浮点打印需要勾选Use Float in printf,否则%f输出为空。这个和AC5一样。

还有一个坑:AC6默认使用-nostdlib时,printf可能被优化掉。检查Options for Target → Linker里有没有勾选Use Memory Layout from Target Dialog,以及是否误加了--no_scanlib。

5.4 独家避坑技巧三条

第一条,先验证串口发送,再验证printf。写一个USART_SendByte(0x55),用示波器或串口助手看有没有波形。有波形再上printf,能省一半调试时间。

第二条,在fputc里加个GPIO翻转。每次调用fputc翻转一个空闲引脚,用示波器看有没有翻转。有翻转说明printf调用了fputc,问题在串口;没翻转说明printf根本没走到fputc,问题在库配置。

第三条,用sizeof检查MicroLIB是否生效。开MicroLIB前后编译,看Code大小变化。如果没变化,说明MicroLIB没勾上,或者工程里有多余的库文件冲突。

6. 不同芯片平台的移植要点

6.1 STM32 HAL库与标准库的差异

HAL库用HAL_UART_Transmit,标准库用USART_SendData。标准库的写法:

int fputc(int ch, FILE *f) { while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

标准库不需要开MicroLIB也能用,但开了更小。注意标准库的USART_FLAG_TXE和HAL的UART_FLAG_TXE名字不同,别抄错。

6.2 GD32、NXP等国产与非ST芯片

GD32的库和STM32高度兼容,把USART1换成USART0(GD32的命名),寄存器操作基本一样。NXP的LPC系列用LPUART,发送函数不同,但fputc的框架不变,把发送那行换掉即可。

关键点:不管什么芯片,重定向的核心永远是“在fputc里把字符写到串口数据寄存器”。外设初始化可以照抄厂商例程,但fputc必须自己写。

6.3 RTOS环境下的线程安全处理

FreeRTOS下,多个任务调用printf会竞争。最简单的办法是加一个互斥信号量:

SemaphoreHandle_t printf_mutex; int fputc(int ch, FILE *f) { xSemaphoreTake(printf_mutex, portMAX_DELAY); HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); xSemaphoreGive(printf_mutex); return ch; }

但这样每次一个字符都拿放信号量,开销大。更好的做法是在printf外层加锁,或者用前面说的环形缓冲+中断发送,天然线程安全(只要缓冲区操作原子)。

我在实际项目里用的是环形缓冲方案,主任务往缓冲写,中断往外发,printf耗时从毫秒级降到微秒级,效果立竿见影。

7. 性能优化与日志分级实践

7.1 用宏定义控制日志开关

产品发布时不想带调试日志,但又不想删代码。用宏:

#define DEBUG_LOG 1 #if DEBUG_LOG #define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define LOG(fmt, ...) #endif

这样发布版把DEBUG_LOG设0,所有LOG语句被预处理器删掉,不占空间。注意##__VA_ARGS__是GCC扩展,AC5/AC6下用__VA_ARGS__即可,空参数时可能报错,可以写成printf(fmt, ##__VA_ARGS__),AC6支持。

7.2 日志分级与颜色输出

串口助手支持ANSI颜色的话,可以给日志加颜色:

#define LOG_INFO(fmt, ...) printf("\033[32m[INFO]\033[0m " fmt, ##__VA_ARGS__) #define LOG_WARN(fmt, ...) printf("\033[33m[WARN]\033[0m " fmt, ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) printf("\033[31m[ERROR]\033[0m " fmt, ##__VA_ARGS__)

这样错误日志红色、警告黄色、信息绿色,一眼就能定位问题。实测在SecureCRT和MobaXterm下都正常,某些简易串口助手可能不支持,会显示乱码,那就去掉颜色码。

7.3 减少printf对实时性的影响

printf的格式化本身也耗时,尤其是浮点和长字符串。如果主循环对实时性要求高,可以只在关键节点打日志,或者用snprintf先格式化到缓冲区,再用DMA发出去,主循环完全不阻塞。

我做过一个测试:115200下打一条30字符的日志,阻塞方式耗时约2.6毫秒,中断+缓冲方式耗时约8微秒。差了300多倍。所以如果你的系统有电机控制、音频处理等实时任务,千万别用阻塞printf。

8. 我踩过的那些坑与最终建议

重定向这件事,说穿了就是一层窗户纸,但没捅破之前能折腾一整天。我印象最深的一次是换了台电脑,同样的工程,串口就是没输出。查了半天发现新装的Keil5默认没勾MicroLIB,而旧电脑的工程配置里勾了。所以工程配置跟着工程文件走,换电脑要重新检查。

还有一次,客户反馈产品偶尔死机,最后定位到是fputc里的while等待没有超时,串口线接触不良时死循环。加了超时保护后问题消失。所以任何while等待都要有退出条件,这是嵌入式铁律。

最后分享一个小技巧:如果你不确定printf到底有没有被调用,可以在fputc里对一个空闲GPIO取反,然后用LED看闪烁。LED闪说明fputc在跑,不闪说明printf没走到这。这个方法不需要示波器,一根LED就能定位问题,特别适合现场调试。

这个内容后续还可以这样扩展:把日志通过SEGGER RTT输出,不占串口;或者用SWO引脚输出,速度更快。但那是另一个话题了,串口重定向作为最基础、最通用的调试手段,值得每个嵌入式工程师牢牢掌握。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询