ARTICLE DETAIL

资讯详情

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

彻底搞懂Keil MDK中printf重定向到串口:原理、配置与踩坑

彻底搞懂Keil MDK中printf重定向到串口:原理、配置与踩坑 1. 折腾了三天仿真器不如一条printf实在做嵌入式开发这几年我最深的体会就是调试手段的优先级往往决定了项目的开发效率。仿真器断点、变量监视、逻辑分析仪这些工具确实专业但在很多真实场景下它们反而成了负担。尤其是当你调试的是带电机驱动的板子、通信协议栈或者跑着RTOS的系统时仿真器一停下来外设状态就全变了时序也乱了很多偶发问题完全没法在断点下复现。反倒是串口打印最直接——把关键变量、函数入口、执行分支用printf打到串口助手上板子该跑的跑该转的转代码在什么状态下一目了然。这也是为什么我身边的老工程师第一件事就是把printf重定向到串口把串口调试当成基本功。不过很多刚入坑的朋友在用Keil MDK做printf重定向时常常会遇到两堵墙要么勾了MicroLIB还是打印不出东西要么中文乱码、浮点数打不出来、程序卡死。这些问题网上答案很零散有的说要用fputc有的说要用__stdout有的说必须勾Use MicroLIB但很少有人讲清楚这几个东西之间的关系导致大家抄了代码也不知道为什么出了岔子更不知道怎么查。这篇文章就把这件事一次讲透。我会从重定向的底层原理说起再给出完整的Keil MDK配置步骤和可直接抄的代码最后把我实际踩过的坑、排查思路一并列出来。内容不挑芯片型号用的例子基于STM32标准库但原理和步骤在F1/F4/GD32/MM32这些板子上全部通用。适合谁看一种是刚开始用Keil MDK写单片机程序、还在用点亮LED配合delay判断程序走到哪了的朋友另一种是已经会串口收发但想彻底搞明白printf重定向原理、想解决中文乱码和浮点打印问题的开发者。看完全文你应该能一次性配好环境并且以后遇到类似问题能自己定位。2. 重定向的本质让库函数知道“串口往哪写”很多教程上来就甩给你一段fputc代码然后让你勾选MicroLIB但最多只说一句“勾上就行了”。这导致不少人在换芯片、换工程时照样翻车。要真正搞定printf重定向得先搞明白Keil MDK里printf这条链路到底是怎么走的。2.1 printf串起的三层关系在C语言里printf是个标准库函数它负责把格式化后的文本输出到标准输出流。在PC上标准输出可以理解成“屏幕”但在单片机上标准输出并没有默认设备——芯片不知道要把东西写给谁。这就需要我们程序员自己实现“往哪里写”这个环节。Keil MDK的C库把这件事拆成了三层printf函数处理格式、组装字符串最终调用底层字符输出接口底层字符输出接口在标准库里的名字是fputc输出到流或_ttywrch在semihost模式下逐字符输出实际硬件驱动比如往串口的数据寄存器里写一个字节或者把字节丢给DMA。所谓重定向本质上就是重写第二层。也就是自己实现一个fputc让库函数调用它时不是去访问什么不存在的显示器而是把字符一个个塞进串口发送寄存器。2.2 MicroLIB到底解决了什么问题MicroLIB全称Micro Library是Keil MDK针对ARM嵌入式应用优化的一套轻量级C运行库。它和标准C库的主要区别有三个对比项标准C库MicroLIB代码体积完整实现体积大裁剪优化体积小浮点格式化打印默认支持需要额外勾选FPU选项或加代码semihosting机制默认可能调用调试器通道完全不依赖semihosting适用场景资源充足的应用单片机、资源受限场景如果你不勾选MicroLIB默认用的是标准C库那么fputc在缺省情况下会被连接到File结构体对应的底层输出——在ARM MDK环境里这往往和semihosting挂钩也就是调试器通道。如果不关掉semihosting程序哪怕跑起来了串口也什么都打不出来因为字符根本没往串口送。而且semihosting在没有调试器连接时会产生一个HardFault程序直接崩掉。勾选MicroLIB之后情况就彻底变了库函数不再默认依赖semihosting也没有复杂的文件系统逻辑代码精简了很多此时重写fputc就变得干净直接。这也是为什么绝大多数STM32工程、正点原子野火的例程都默认勾MicroLIB。2.3 别混淆fputc、__stdout和PUTCHAR_PROTOTYPE很多教程里出现过三种写法int fputc(int ch, FILE *f)int __stdout(char ch, FILE *f)#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)乍一看像是三种重定向方式其实说的是同一个东西。PUTCHAR_PROTOTYPE是ST官方老例程里的一个宏定义本质就是为了让你在不同编译环境下切换函数原型用的最终落地的还是fputc。__stdout只是部分早期库的命名习惯新工程里标准写法就是fputc。所以别被各种写法绕晕记住核心就一个实现int fputc(int ch, FILE *f)把ch发到串口然后返回ch。3. 完整实操Keil MDK工程配置到串口驱动一步步来这一节就是手把手实操了。我以STM32F103C8T6经典板子加标准外设库为例其他芯片参照改一下时钟初始化的部分即可。3.1 建工程与勾选MicroLIB打开Keil MDK新建一个标准工程选好自己的芯片型号。然后点击魔术棒Options for Target进入Target选项卡在Code Generation区域里勾选Use MicroLIB。这步是后面一切的前提。接着检查Debug选项卡里是否勾选了Use Simulator。如果你用的是实际开发板建议选Use: ST-Link Debugger或者J-LINK不要勾模拟器否则程序下载和运行行为会和预期不一致。还有一个很多新手容易忽略的配置在C/C选项卡的Define输入框里标准库工程通常写STM32F10X_MD容量型号不同会有不同宏定义。这个宏要和你的芯片对应否则外设头文件的条件编译会出错串口初始化自然跑不起来。3.2 串口GPIO和USART初始化重定向printf之前串口本身必须能正常工作。这里给出一个最小可用的USART1初始化代码波特率设为1152008位数据位1位停止位无校验void USART1_InitConfig(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; /* 打开时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); /* TX: PA9 - 推挽复用输出 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); /* RX: PA10 - 浮空输入 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); /* USART1 参数配置 */ USART_InitStructure.USART_BaudRate 115200; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE); }这里有几个细节值得多说两句PA9和PA10只是USART1的默认引脚如果你的板子把串口复用到别的引脚上就得额外配置GPIO_PinRemapConfig比如GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE)同时把引脚改成PB6/PB7。GPIO速度不是越高越好。串口这种低速外设设为50MHz完全够用但GPIO速度设置过慢有时会影响波形边沿导致通信不稳所以一般折中选GPIO_Speed_50MHz。如果只发不收可以不开启RX。但很多场景下调试时需要回传数据给板子比如后面要配合串口助手做交互控制所以建议USART_Mode_Rx | USART_Mode_Tx一次都配上。3.3 实现fputc重定向函数在串口初始化完成的基础上新建一个retarget.c文件输入以下代码#include stdio.h #include stm32f10x.h int fputc(int ch, FILE *f) { /* 等待发送数据寄存器为空 */ while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET) ; /* 发送一个字节 */ USART_SendData(USART1, (uint8_t)ch); return ch; }如果你用的是HAL库写法换成int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }然后是主函数里调用#include stdio.h int main(void) { SystemInit(); USART1_InitConfig(); printf(Hello, UART! \r\n); printf(Value %d \r\n, 2024); while (1) { /* 业务代码 */ } }编译下载之后打开串口调试助手选对端口号波特率设为115200数据位8停止位1无校验你就能在串口助手接收区看到输出。这里特别说一个容易翻车的地方初始化顺序。printf必须在串口初始化之后才能调用否则打印出来的内容是空的或者乱码。因为fputc里等待的USART_FLAG_TXE标志在串口外设还没使能时根本不工作。3.4 使用串口调试助手要注意的硬件接线软件层面配好了硬件翻车也是新手常遇到的问题。你的开发板如果自带USB转串口芯片比如CH340、CP2102这些直接用USB线连接电脑然后在设备管理器里看端口号是COM几。如果板子没有集成转串口芯片就要自行接一个USB转TTL模块。接线规则很简单板子的TXPA9接模块的RX板子的RXPA10接模块的TXGND必须共地而且最好是先接GND再接信号线。别小看共地这件事不共地时偶尔也能打印出来但内容全是乱码而且板子强一点的电平会把模块的接口芯片烧掉。我用CH340模块测试过很多次两个系统不共地时串口助手里显示的几乎都是0xFF或者空白这个问题排查起来非常隐蔽。如果板子上电后摸芯片发现发热先查电源和接线别急着用串口调试助手避免USB转串口芯片或者MCU烧毁。4. 一个极容易被忽略的“先决条件”重定向与printf缓冲区机制代码写好了能打印了但很多人在这个阶段还没完——因为我前面说的只是“能用”的八字诀实际项目里printf重定向之后往往会遇到一系列更诡异的现象printf之后程序卡死、只打印一半、串口输出被后续代码干扰、RTOS里打印错乱。这些问题不解决你的调试工具随时可能变成新的“烦恼源”。4.1 printf有缓冲别忽略输出时机很多人以为调用printf时字符串会立刻出现在串口助手上。事实上在你重定向之后标准库可能还是会做缓冲处理——MicroLIB的缓冲区虽然很浅但依然有缓存逻辑。如果程序在printf之后立刻while(1)空转数据大概率还在缓冲区里没被及时刷出去。最稳妥的做法是在调试阶段给fputc加一个发送完成的等待比如在USART_SendData之后加一句while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET) ;TXE标志只代表数据从寄存器搬到了移位寄存器TC标志才代表这一帧数据真正发送完成了。如果你在fputc里只判断TXE并且之后马上关掉串口时钟或者让MCU进入低功耗模式很可能最后一两个字节还没发完就被截断了。这也是很多人反复调试发现有时打印完整、有时少字符串尾部的真正原因。4.2 重定向与断言的冲突工程里很多人喜欢用assert_param来做入参检查。在标准库配置下如果断言失败会调用assert_failed函数。很多人没实现这个函数或者实现里用了printf——于是发生断言失败时会陷入一个特复杂的调用链看起来就像是程序莫名其妙死机了。所以在把printf重定向到USART1之后我强烈建议把断言输出也重定向或者禁用。标准库的方式是void assert_failed(uint8_t *file, uint32_t line) { printf(Wrong parameters value: file %s on line %d\r\n, file, line); while (1) { } }这样断言失败时你能直接在串口看到是哪个文件哪一行出错而不是去翻代码找一堆HardFault。4.3 多串口工程的重定向切换有些工程不止用USART1还有USART2、USART3分别接不同的外设。此时fputc固定写USART1会把所有打印消息堆积到同一路不太利于区分业务日志和协议日志。我的做法是做一个可切换的打印通道static USART_TypeDef *debug_uart USART1; void SetDebugUart(USART_TypeDef *uart) { debug_uart uart; } int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(debug_uart, USART_FLAG_TXE) RESET) ; USART_SendData(debug_uart, (uint8_t)ch); return ch; }然后在需要切换的地方调用SetDebugUart(USART2)。要注意的是切换前对应串口必须先初始化否则同样会卡死在等待标志位处。5. 中文乱码、浮点丢失、卡死——三个实战踩坑与排查链路这部分是我最想写的。因为重定向printf的“标准答案”其实四处都是但实际项目里翻车的点远不止“有没有勾MicroLIB”这一处。我挑三个几乎人人都会遇到的典型问题把排查过程完整还原出来。5.1 中文乱码问题多半不在代码而在两边配置很多人串口打印英文一切正常一打中文就变成测试或者????。其实这里的重定向本身没问题问题出在字符编码。Keil MDK默认保存源文件时可能是GB2312或者UTF-8而串口调试助手解析时也各用一套。最典型的组合是源文件是UTF-8编码串口助手却按GBK解码中文自然变成一团乱码。常规解决办法是全部统一成UTF-8。在Keil MDK的Edit→Configuration→Editor里设置Encoding为UTF-8然后把源文件保存为UTF-8格式。串口调试助手这一端也把编码格式切换成UTF-8。不过这里有个更隐蔽的坑微库对中文字面量的支持不完整。有几次我确认两边编码都是UTF-8了依然会出错。后来发现是编译器的字符集和输出流的中文字节被截断导致的。解决方法是尽量不在printf里直接嵌套中文改用define字符映射#define STR_OK OK #define STR_ERROR ERR如果你的需求非要打印中文比如printf(温度异常\r\n)请确保字符串以\r\n结尾且编码和串口助手一致。实际项目中我通常把中文日志压缩成英文标签数值这样既避免编码问题也方便日志解析。5.2 浮点数打不出来不是代码问题是库裁剪问题MicroLIB为了控制体积默认并不包含完整的浮点格式化支持。假设你写float temp 36.5; printf(temp %f\r\n, temp);然后发现串口助手里只有temp 后面什么都没了或者输出一个不确定的数——基本上就可以断定是浮点格式化没有启用。在标准库不勾MicroLIB下float打印通常是正常的但代码体积大、semihosting的坑多。如果你舍不得MicroLIB的小体积就必须在配置里打开FPU或完整浮点支持。实际操作上有两个路线**路线一在Target选项里勾选Use Single PrecisionF4以上内核才有。**路线二使用编译器选项--no_multibyte_chars或者在工程里加一行#pragma import(__use_no_semihosting)这个宏会让链接器把semihosting相关的浮点格式化函数包含进来。不过说实话我这两年已经很少用MicroLIB打印浮点了。更推荐的方式是用整数定点打印float temp 36.5; int temp_int (int)temp; int temp_dec (int)((temp - temp_int) * 10); printf(temp %d.%d\r\n, temp_int, temp_dec);这样做唯一的好处是彻底绕开浮点格式化任何库配置下都能稳定输出。缺点是小数位需要自己定。如果项目里浮点打印很频繁直接在fputc层做一个简单的浮点转字符串函数更彻底。5.3 printf之后程序卡死八成卡在等待标志位如果你遇到的现象是程序跑进printf之后出不来或者打印完第一次第二次进printf就死了大概率就是fputc里的等待循环没退出来。我复盘过一个实际案例第一次printf正常第二次程序卡死。原因特别搞笑——因为是HAL库工程第一次发送时用了HAL_UART_Transmit且超时时间设成了HAL_MAX_DELAY发送完后没做任何处理第二次发送时串口外设其实处于Busy状态而HAL库的HAL_UART_Transmit内部会判断gState如果上一次调用没有把状态清零会直接返回HAL_BUSY而我重定向的fputc里又没检查返回值业务代码里还误以为数据已经发出去了。排查这种卡死问题的思路通常有两步在fputc里加上超时机制不要无限等待。比如int fputc(int ch, FILE *f) { uint32_t timeout 0xFFFF; while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET) { if (--timeout 0) { break; } } USART_SendData(USART1, (uint8_t)ch); return ch; }这样即使串口出问题程序也不会死锁打印会丢字符但系统能继续跑。调试日志偶尔丢一两帧没关系系统死锁才是大事。检查硬件连接。单片机发不出来很多情况下是因为TX引脚被外部设备拉低或者串口助手没打开正确的串口导致数据无处可去。这一点在USB转串口模块质量不好时尤其常见。5.4 串口助手选型配置混乱时先从工具侧排查排除了代码问题还是乱码或没输出时我建议换一个串口助手交叉验证。我自己常用的有XCOM、SSCOM和SerialTest它们都支持UTF-8和GBK切换波特率设置也比较直观。不过它们之间有一个很小但致命的差别默认的换行处理不同。有的串口助手把收到的0x0A自动转成0x0D 0x0A有的不会。如果你在单片机里只发了\n在某些串口助手里就换行异常显示效果像“乱码”。所以我在所有printf里都强制加\r\n确保任何助手都能正常显示。另外串口号不对是新手最常见的“假故障”。CH340驱动装好后设备管理器里会显示COM号。如果之前用过蓝牙模块或者别的USB设备COM号可能被占占。在串口助手打开串口的时候如果提示“端口被占用”去设备管理器里把幽灵串口禁用或者直接拔插USB线让Windows重新枚举。6. 进阶思路DMA方式与环形缓冲怎么让printf更抗造等你对printf重定向很熟练了会发现单片机的调试输出其实还有很大的优化空间。尤其是当你开始做通信协议、音频采集或快闭环控制时串口打印本身的耗时可能会反噬业务逻辑。例如我在一个高速采样项目里main循环每秒钟要执行上千次如果每次都调用printf去打印几十个字符USART1波特率就算跑到460800耗时依然明显系统实时性大幅下降。6.1 DMA队列让printf变成“异步”的思路很简单fputc里不直接阻塞等待发送完成而是把字符丢进一个软件FIFO由DMA或中断后台搬运。用STM32标准库的USART1加DMA1通道4USART1_TX初始化如下void USART1_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)uart_tx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize strlen((char *)uart_tx_buf); DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel4, DMA_InitStructure); DMA_Cmd(DMA1_Channel4, ENABLE); }然后在fputc里不再等待发送完成只是把字符拷贝到发送缓冲区然后触发一次DMA传输。注意这里要处理“上次DMA还没发完又来新数据”的情况否则数据会待在缓冲区里被覆盖。如果发送不频繁DMA方式能完美解决printf调用立即返回业务代码继续跑串口在后台慢慢发。6.2 环形缓冲区的写法更通用的方案是维护一个发送环形缓冲区fputc负责压数据中断或DMA完成中断负责弹出数据。核心部分如下#define TX_BUF_SIZE 512 static uint8_t tx_buf[TX_BUF_SIZE]; static volatile uint16_t tx_head 0; static volatile uint16_t tx_tail 0; static int is_tx_buf_empty(void) { return (tx_head tx_tail); } static int is_tx_buf_full(void) { return ((tx_head 1) % TX_BUF_SIZE) tx_tail; } int fputc(int ch, FILE *f) { while (is_tx_buf_full()) ; tx_buf[tx_head] (uint8_t)ch; tx_head (tx_head 1) % TX_BUF_SIZE; return ch; }配合一个周期性的串口发送函数或者DMA空闲中断就能实现高性能日志输出。这里有个工程上的细节缓冲区满时选择阻塞还是丢弃取决于日志的重要程度。调试阶段我选择阻塞保证每条日志都要发出去产品阶段我选择丢弃保证系统实时性不被日志拖垮。6.3 实时系统的特殊考虑如果你的工程片跑了RTOS比如FreeRTOS那么串口打印还要考虑多任务竞争。多个任务同时调用printf会互相打断导致输出交错。最粗暴的办法是全局关中断但会影响实时性。更好的办法是给printf加互斥锁或者用任务级信号量包一层printf_wrappervoid DebugPrintf(const char *format, ...) { va_list args; va_start(args, format); if (xSemaphoreTake(printf_mutex, portMAX_DELAY) pdTRUE) { vprintf(format, args); xSemaphoreGive(printf_mutex); } va_end(args); }这个封装能保证同一时刻只有一个任务在打印日志不会交错乱七八糟。实际使用中我在三个不同优先级任务同时打印时用这个方案彻底解决了输出错乱问题。7. 写在最后调试手段要分层printf不是万能的printf重定向确实是嵌入式调试的黄金技能我自己也靠它解决过不少疑难杂症但做了这么多年也想提醒各位一句不要把printf当作唯一调试手段。有时候你printf打出来的内容是“对的”但程序行为依然不对比如变量被意外修改、栈溢出、时序问题这些都不是简单打印能查出来的。此时该上仿真器断点就得上该看反汇编就得看甚至是逻辑分析仪也能帮上大忙。我的实际习惯是串口打印负责“面”仿真器负责“点”。系统跑起来不稳定时先用printf确认大方向——哪段代码没走、哪个状态机切错了锁定范围之后再借助仿真器在怀疑的代码段打几个断点仔细查变量和寄存器。两者配合比单纯依赖任何一边都高效得多。最后再分享一个我每次新建工程的固定动作在main函数最开始调一行printf(Build at %s %s\r\n, __DATE__, __TIME__);。这个写法叫编译时自动打时间戳好处是——板子上跑的到底是哪一版固件一开机就知道再也不会出现“我记得我下载了最新代码但板子行为还是老样子”的尴尬情况。还有一个个人非常推荐的小技巧在断言函数里挂一个全局变量error_code当你抓到错误时把它记录下来同时printf输出这个错误码再用编译地址转换成具体位置。这样哪怕现场的板子没有连接调试器用户反馈说“指示灯红了”你也知道是哪个模块出了问题。调试不是只能靠工具有时候靠工程纪律更可靠。printf重定向这件事说难不难说简单也踩过这么多坑。希望这篇内容能帮你一次性配好环境也帮你理解它背后的链路。真正掌握之后你会发现自己调代码的速度会明显快一截至少不用再靠“LED闪几下”猜程序状态了。
返回列表