ARTICLE DETAIL

资讯详情

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

Keil MDK printf串口重定向:3种方法对比与MicroLIB深度解析

Keil MDK printf串口重定向:3种方法对比与MicroLIB深度解析 做嵌入式开发的人应该都经历过这个场景板子硬件调通初始化完串口想用printf打印点调试信息。于是兴冲冲写了一句printf(hello\r\n)下载运行打开串口助手——空空如也程序还卡住不动。百度搜了一圈答案五花八门有人说勾选Use MicroLIB有人说重写fputc有人让你关半主机还有人说Keil的printf本来就不支持重定向得换STM32CubeIDE。说实话这个问题说大不大但足以让刚接触Keil MDK的同学折腾半天。尤其是“勾了MicroLIB就能跑换标准库就崩”这种现象几乎所有做MCU开发的人都遇到过。这篇文章我就把Keil下printf串口输出的3种重定向方法从头到尾拆一遍讲清楚背后的原理、每种方法的适用场景以及MicroLIB在里边到底扮演什么角色。看完之后你不仅能让printf按你的想法输出到串口还能说出“为什么这么改”的底层逻辑。1. 到底是什么在“重定向”——先把printf的底层讲透1.1 桌面C的printf和单片机上的printf差别在哪在任何C环境里printf干的活本质上只有一件事把格式化之后的字符串写到“标准输出流”stdout。桌面程序里stdout会被操作系统接到控制台窗口或者重定向到一个文件所以我们能看到字符在屏幕上显示。单片机没有操作系统没有控制台窗口甚至很多时候连屏幕都没有。那printf格式化出来的字符去哪了这就要看C运行库runtime library怎么处理标准输出流了。在Keil MDK的ARM编译工具链里默认情况下printf带着一套“半主机”semihosting机制它尝试把字符发送给调试器再由调试器转发给PC端的调试窗口。听起来挺方便但问题是如果调试器没开启对应的半主机服务或者你在脱机运行时调用printf程序就会执行到那条专用的断点指令后卡死表现就是“运行到printf不走了”。打个比方printf就像快递公司它只负责把包裹按面单分类但不会自己开车送货。在PC上开车的司机是操作系统的控制台驱动在单片机上这个司机本来应该是调试器半主机方式如果调试器不接单包裹就卡在中转站了。重定向要做的就是你自己注册一个司机告诉printf“以后输出别找调试器了直接把字符扔给我的串口发送函数”。1.2 半主机模式和那个著名的BKPT 0xABARM半主机semihosting的机制不算复杂目标机执行一条特定的BKPT 0xAB指令同时把操作类型和参数放到寄存器里。执行这条指令时调试器会捕捉下来替目标机完成I/O操作比如往主机终端打印字符、从主机键盘读数据。这在早期开发板没有LCD、没有串口的年代非常实用——一个调试器就能搞定输入输出。但到了STM32时代情况变了几乎每个板子都有串口USB转TTL也便宜得很。大家需要的不是“用调试器看输出”而是“把日志打到串口助手里”。可C运行库默认还是走半主机通道于是问题就来了如果你没有在调试器侧开启半主机程序跑到printf内部的BKPT指令时调试器没有这项中断处理CPU就会进入某种类似陷阱的状态。在Keil里调试时你会看到程序停在一个奇怪的地址反汇编窗口里往往能看到BKPT 0xAB这种语句。脱机运行时更惨直接HardFault或者死循环而且死得莫名其妙。所以重定向的第一要务就是把这个半主机通道堵死或者绕开它让printf的输出目标变成你自己的串口发送函数。1.3 重定向的本质把标准输出流接到你的串口上重定向retarget这个词听起来很高端实际本质就一句话改变C运行库底层的字符输出目标。在Keil里底层字符输出往往由fputc、_write这类函数承接。你做出对应的强函数定义去替换库里的弱定义printf在输出时就会转去调用你的函数也就是你的串口发送函数。另外还有一条路线完全绕开库的stdout机制自己拿vsnprintf做格式化把格式化结果放到自己的缓冲区然后爱怎么发送就怎么发送。这算是“自建printf”但效果和重定向一样用户调用的还是printf风格的API字符最终还是从串口出去。接下来要讲的第3种方法走的正是这条路。2. 方法一最朴素的fputc重定向5分钟跑通2.1 最小实现代码HAL库示例先说最经典、网传最多的做法重写fputc函数。下面是基于STM32 HAL库的写法其他MCU换成对应的串口发送函数就行#include stdio.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 10); return ch; }如果你用的是标准外设库SPL发送单个字符的写法长这样int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); return ch; }然后打开Keil工程选项Options for Target - Target - 勾选“Use MicroLIB”编译下载。只要你的串口初始化没问题printf(hello\r\n)就能在串口助手上看到。这套组合拳是无数人现场验证过的也是新手最容易跑通的方式。关键点在于第一串口必须已经初始化第二Keil里必须勾选MicroLIB第三fputc里的发送函数一定要用对句柄别写成了huart2。2.2 为什么fputc能凭空接管printf输出很多人改了fputc之后心里是虚的我明明没改printf为什么定义个fputcprintf就听我的了这里要稍微提一下C运行库的内部机制。MicroLIB这种精简库设计得很讨巧它内部的printf会把格式化好的字符逐个回调给fputc函数由fputc负责把字符送到目标设备。如果你不定义fputc库里有自己的默认实现——向半主机通道塞字符但由于MicroLIB不实现半主机这里其实是空的或者触发异常。一旦你定义了fputc链接器就会把你这个强定义覆盖掉库内部的弱实现printf的输出路径就从“半主机”变成了“你的串口发送函数”。所以整个链条其实是printf - 格式化 - 逐字符调用fputc - HAL_UART_Transmit - 串口出去。理解了这条链你就能解释很多问题比如“为什么我勾了MicroLIB之后printf才正常”——因为MicroLIB本身就砍掉了半主机路径输出统一走fputc回调你再定义个fputc链路就完整了。2.3 两种编译器下的写法差异AC5/AC6、_writeKeil MDK从旧到新经历过两个主流编译器时代ARM Compiler 5armcc和ARM Compiler 6armclang即LLVM后端。在AC5和AC6下上面的fputc重定义方式基本都兼容区别不大。但如果你把同样的代码搬到STM32CubeIDE或者GCC工具链事情就变了。GCC的实现是采用_write这个系统调用钩子需要你重定义int _write(int fd, char *ptr, int len)一次接收一整个缓冲区而不是一个字符一个字符地来int _write(int fd, char *ptr, int len) { for (int i 0; i len; i) { HAL_UART_Transmit(huart1, (uint8_t *)ptr[i], 1, 10); } return len; }在Keil AC6环境下如果你不喜欢fputc风格用_write风格也是可行的两种接口编译器都认。我的建议是既然在Keil里开发就优先用fputc代码量最小也符合大多数STM32教程的示例风格。但心里要清楚换到GCC工具链时要改成_write这个小知识在跨平台搬运代码的时候特别有用。容易踩的坑是有人把int fputc(int ch)这种只带一个参数的函数名当成标准签名来写结果编译报错。fputc的标准原型是int fputc(int ch, FILE *f)第二个参数是文件流指针即使不用它也要保留。3. 方法二完整FILE流重定向printf与scanf通吃3.1 完整代码fgetc/fputc/__stdout/__stdin如果你不满足于让printf只输出还希望scanf能从串口读取数据比如做一个简单的交互式命令行那就要走“完整FILE流重定向”。严格来说这才是ARM官方文档里说的retarget标准姿势。在标准库不勾选MicroLIB下一套经典的重定向代码长这样#include stdio.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; struct __FILE { int handle; }; FILE __stdout; FILE __stdin; FILE __stderr; int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 10); return ch; } int fgetc(FILE *f) { uint8_t ch; HAL_UART_Receive(huart1, ch, 1, 10); return ch; } int ferror(FILE *f) { return 0; }这段代码干了三件事一是定义了__stdout、__stdin等流符号让库知道标准流指向哪里二是把fputc和fgetc接到了串口printf负责输出scanf负责输入三是补了ferror避免错误处理流程里缺符号。用这套代码你不仅可以printf(temp%.2f\r\n, t)还能写scanf(%d, cmd)从串口读整数非常适合做主从式命令行调试面板。3.2 还要不要禁半主机标准库和MicroLIB的处理方式不勾选MicroLIB时标准库默认带着半主机代码。光定义上面的FILE结构和fputc还不够链接器和运行时仍然可能去调用半主机相关函数导致程序卡死。所以Man手册里的老江湖往往还要在某个.c文件里加上这段#pragma import(__use_no_semihosting) void _sys_exit(int x) { x x; } void _ttywrch(int ch) { (void)ch; }#pragma import(__use_no_semihosting)的意思是告诉链接器这个工程不需要半主机运行环境。_sys_exit和_ttywrch是库在提及退出和调试字符输出时可能会引用的符号需要我们自己兜底实现哪怕实现是空的也得有不然链接阶段会报错。如果你嫌麻烦也可以直接勾选MicroLIB。MicroLIB本身不包含半主机实现自然不存在这个问题。但代价就是浮点格式化输出基本作废——这个代价在第五章详细讲。到这里我多说一句关于组合选择的经验MicroLIB fputc最省事整数/字符串打印没问题浮点打印不行。标准库 fputc __use_no_semihosting浮点可以打印但要多写不少辅助代码Flash占用也上去了。标准库 fputc但没有禁用半主机编译可能过运行时大概率卡死。3.3 面向RTOS与多串口扩展通过IO回调或钩子选择输出通道实际项目里很多情况下不止一个串口。比如调试日志走USART1蓝牙模块接USART2RS485接USART3。如果所有printf都固定发到huart1那显然不够灵活。一种讨巧的做法是引入一个全局变量fputc里根据变量值决定走哪个串口UART_HandleTypeDef *debug_uart huart1; void UART_SetDebugPort(UART_HandleTypeDef *uart) { debug_uart uart; } int fputc(int ch, FILE *f) { HAL_UART_Transmit(debug_uart, (uint8_t *)ch, 1, 10); return ch; }初始化时调用UART_SetDebugPort(huart1)就能把printf输出切到指定串口。这个思路在逻辑上等价于“printf重定向到动态串口”对于多串口日志系统很实用。RTOS环境下需要注意多任务同时调用printf时fputc里没有锁输出可能串行交替、互相夹杂。解决思路有两个一是在fputc外包一层互斥锁比如RTX的osMutexAcquire二是换成第4章讲的缓冲式重定向把发送动作放到专门的任务里统一处理。后者在真实项目里更干净也不容易把任务调度的优先级搞乱。4. 方法三缓冲式重定向printf也能优雅、非阻塞4.1 思路跳过库函数自己包一层vsnprintf前两种方法有个共同点printf每次格式化输出时都逐字节调用fputc也就是每一个字符都会触发一次串口阻塞发送。如果只是打印几行日志还好但如果你要打印1024字节的缓冲区内容串口波特率115200瞬间要命。而且这种逐字节发送在RTOS里非常不友好一个任务正在打印另一个高优先级任务也被迫等待。第三种方法的思路清奇直接绕过printf对stdout的依赖自己调用vsnprintf完成格式化把结果先存进你的缓冲区再一次性地把整段数据发出去。严格来说这不算“重定向”C标准库的stdio更像“自建printf”但实际效果就是printf风格的串口输出而且比直接重定向灵活得多。一个最简单的实现如下#include stdio.h #include stdarg.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; void my_printf(const char *fmt, ...) { char buf[128]; uint16_t len; va_list args; va_start(args, fmt); len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); HAL_UART_Transmit(huart1, (uint8_t *)buf, len, 1000); }调用方式基本不变my_printf(adc%d\r\n, adc_val);。区别在于格式化过程发生在你的buf里发送时是一次性把整段字节交给串口而不是一个字符一个字符地挤。4.2 串口DMA的典型实现缓冲式重定向最大的价值是能和DMA结合。把待发送的数据交给DMA后CPU可以继续去干别的发送完成后通过中断通知你。这就做到了非阻塞高吞吐。典型实现static char print_buf[256]; static volatile uint8_t tx_busy 0; void UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_busy 0; } } void my_printf_dma(const char *fmt, ...) { uint16_t len; va_list args; while (tx_busy); // 等待上一次DMA发送完成 va_start(args, fmt); len vsnprintf(print_buf, sizeof(print_buf), fmt, args); va_end(args); tx_busy 1; HAL_UART_Transmit_DMA(huart1, (uint8_t *)print_buf, len); }需要特别注意的是DMA发送是从内存里读数据发送过程中缓冲区不能被改写。所以在启动下一次发送前必须确认上一次DMA已经结束。上面的while (tx_busy);就是干这个的。tx_busy在DMA发送完成回调里被清零下一次调用才能继续往print_buf里写。这种方式的优势在大日志场景特别明显格式化一次、DMA搬运一次CPU占用极低。4.3 注意事项与RTOS/中断安全问题缓冲式重定向看着美但用起来有讲究我挨个说栈安全。vsnprintf本身是有栈开销的尤其格式化浮点和长字符串时临时变量会比较多。我的习惯是缓冲区尽量用全局静态数组不要在函数里定义大数组不然每次调用都压栈任务栈小了分分钟溢出。如果你必须在函数内部定义那就要把栈空间留足比如用uint8_t stack[2048]这种级别去承受。中断里调用要慎重。高优先级中断服务函数里如果调用了my_printf_dma并且里面有个while (tx_busy)在等上一次发送完成一旦上一次DMA发送被更高优先级的事情卡住你就在中断里死等系统实时的实时性就不要想了。更稳的做法是中断里只把日志内容拷贝到一个队列真正的发送动作放在主循环或者专门的低优先级任务里做。RTOS的互斥问题。多任务同时调用my_printf_dma时print_buf是共享资源不加锁会导致日志穿插覆盖。正确姿势是在封装函数内部加互斥信号量或者用每任务独立的打印缓冲区。RT-Thread的rt_kprintf就是这样它内部有一把锁保证多线程安全。HAL库的发送状态判断。HAL_UART_Transmit_DMA在HAL库里是有状态管理的如果上一次DMA还没发完再次调用会返回HAL_BUSY你的日志就直接丢了。上面的tx_busy标志就是为了避免这种问题。在有些HAL版本里你也可以用__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC)查询发送完成状态但用DMA发送完成回调更直观、更可靠。4.4 顺带一提ITM调试口输出不占串口既然聊到Keil的printf有一个“非串口”的输出方式值得顺带了解ITMInstrumentation Trace Macrocell。它走的是SWO引脚通过调试器把数据送到PCKeil的“Debug (printf) Viewer”窗口可以直接显示printf内容。用这种方式前面的fputc可以写成int fputc(int ch, FILE *f) { ITM_SendChar(ch); return ch; }好处是不占用USART外设不用接USB转TTL调试阶段特别方便。坏处是必须在调试会话中才能看到输出脱机运行没用。属于调试利器生产日志还是得走串口。5. MicroLIB对比用还是不用这是个问题5.1 MicroLIB本质与开启方式MicroLIB是ARM提供的一套精简版C运行库目标非常明确为嵌入式环境压缩代码体积和内存占用。很多MCU入门工程的默认配置就是勾选MicroLIB因为STM32F103C8T6这种芯片Flash只有64KB稍微上点框架就紧张MicroLIB往往能帮你省出10%到20%的空间。开启方式也简单Options for Target - Target - Code Generation - Use MicroLIB打勾。但“精简”是有代价的。MicroLIB不是一个完整实现它主动砍掉了很多在桌面上很常见、在嵌入式里可能用不上的功能。其中影响最大的就是printf/scanf对浮点格式化%f的支持。这是很多人在网上吐槽“MicroLIB打印不了浮点”的根源。5.2 3种方法在标准库/MicroLIB下的表现把前面3种重定向方法套到MicroLIB和标准库下组合起来看会更清楚。MicroLIB fputc单函数重定向跑通最快整数/字符串/十六进制打印都没问题。但%f直接歇菜打印出来可能是空字符串、乱码甚至直接输出0.00。标准库 完整FILE流重定向功能完整printf的浮点、scanf的输入解析都能正常使用。代价是Flash占用变大代码量也更大。缓冲式vsnprintf重定向和库版本强相关。如果配MicroLIBvsnprintf同样不支持浮点格式化如果配标准库浮点没问题。有个常见误区是以为只要改成方法三自己包一层vsnprintf浮点就能用了。实际上格式化引擎仍然来自C运行库所以换汤不换药。想浮点正常最终路径只有一条使用标准库或者换第三方微型printf库。下面这张表可以帮你快速决策方案浮点printfscanf输入Flash占用半主机风险推荐场景MicroLIB fputc不支持有限支持低无资源紧张、只打印整数/字符串标准库 完整FILE重定向支持支持明显增加需禁用半主机正式项目、需要浮点打点标准库 缓冲vsnprintf支持不需要取决于实现无大日志、DMA发送、RTOS5.3 关键决策表什么时候该用哪个结合前面3种方法和MicroLIB的特性我给一个相对务实的选型建议调试期、原型验证MicroLIB fputc省事代码少跑得快。正式量产固件Flash余量充足标准库 完整FILE流重定向浮点可以打scanf也可以用开发体验最接近桌面程序。日志量大、要上DMA、跑RTOS方法三缓冲式vsnprintf配合标准库。Flash非常紧张且浮点需求完全没有MicroLIB不要犹豫。需要浮点打点但Flash也紧张不要硬刚标准库可以把浮点数据拆成整数和小数两部分手动格式化或者使用开源的微型printf库常见的有mpaland、efp等它们支持可选浮点体积比标准库小得多。我个人在项目里的习惯是调试阶段用MicroLIB fputc等到做固件性能优化、准备发布release版本时把浮点打点需求理一遍有浮点就切标准库没有就继续MicroLIB。5.4 我去掉了%f为什么MicroLIB对浮点不友好最后解释一下为什么MicroLIB和%f八字不合。printf的浮点格式化需要在运行时做十进制/浮点转换这套逻辑在标准库里是一大坨代码。MicroLIB为了瘦身直接把浮点格式化功能摘掉设计时就没打算让你打印%f。替代手段最常用的有两种。第一种是整数拆分法把浮点拆成整数部分和小数部分分别打印float v 3.14159f; printf(v %d.%03d\r\n, (int)v, (int)((v - (int)v) * 1000));输出v 3.141虽然不是完整精度的浮点表示但在很多场景下已经完全够用。第二种是换库用支持浮点的微型printf实现代替标准库的printf。这两种方式我都实际用过前者零成本、够用就好后者适合项目对日志格式要求高、又不想付出标准库体积代价的情况。6. 实战中的典型坑与排查技巧6.1 一加printf就卡死半主机模式这个坑这个坑出现频率极高症状统一程序跑着跑着停住不动打开Keil的Debug窗口发现卡在某条BKPT 0xAB指令或者干脆进了HardFault。排查方法很简单看你使用printf的代码路径是否在没有半主机支持的环境下执行。如果你用标准库又没禁用半主机几乎必现。解决方案就是按照方法一勾选MicroLIB或者按方法二里的__use_no_semihosting完整处理。如果你用MicroLIB fputc仍然卡死那就排查串口本身串口初始化代码有没有执行发送函数里的句柄对不对波特率配置是否一致不要一上来就怀疑printf重定向有问题先让串口直接发一个固定字符串测试。6.2 printf中文乱码不一定是串口助手的问题中文乱码的坑比较隐蔽因为根源可能在前端也可能在后端。第一种是源码编码问题。Keil MDK 5较新版本中编辑器默认编码可能和源代码文件实际编码不一致。你用UTF-8编码写的字符串如果工程配置或编辑器按其他编码解析编译出来的字符串字节就会变味。解决方案统一源文件编码为UTF-8或GB2312等并在Keil的Edit - Configuration - Editor里把Encoding设置成和你源文件一致。第二种是串口助手的解码问题。串口助手把字节流按什么编码解析决定了你看到的是中文还是乱码。如果你的固件输出UTF-8而串口助手按GBK解释中文会变成“锟斤拷”一类的东西。反过来也一样。技巧先把内容改成纯ASCII的“hello”确认串口通信本身没问题再确认中文字节流编码。第三种才是硬件问题比如波特率误差过大导致所有字符看着就像乱码。这种情况ASCII也一样乱用示波器看波形或者降低波特率验证。6.3 %f打印不出、%.2f显示0症状很典型printf(%.2f, 1.2)在MicroLIB下输出0.00或者干脆没反应换标准库后又正常。原因就是MicroLIB不支持浮点格式化。排查时先看工程是否勾选了Use MicroLIB如果是先不勾试试。如果项目必须用MicroLIB就按上面讲的整数拆分法处理浮点或者换第三方printf库。另一个容易忽略的坑是栈溢出。printf格式浮点过程中库内部会申请不少临时变量如果任务栈设得太小可能导致数据被截断出现异常输出。这种问题不好定位我的经验是先把栈开大排除可能性再缩小范围。6.4 printf被吞了/没换行不输出表现是明明调用了printf但串口助手迟迟不显示等到程序跑完或复位时才突然蹦出一堆数据。原因通常是标准库对stdout做了缓冲只有在遇到换行符或者缓冲区满时才会真正刷新。嵌入式串口打印的规范做法是每条日志都以\r\n结尾这一个回车换行能让串口助手显示不串行也能让printf内部的缓冲逻辑尽早刷出去。如果只写\n很多串口终端会把它理解为普通换行而不带回车显示效果是每一行都顶着最左侧看起来也像乱。所以日志代码里请保持printf(xxx\r\n)的习惯。6.5 DMA重定向后偶尔发乱码用方法三DMA发送时如果日志偶尔缺字节、乱码或重复八成是DMA缓冲区没有保护好。典型场景上一次DMA还没发完my_printf_dma又被调用格式化函数直接往同一个print_buf里写新数据把DMA正在读的老数据冲掉了。解决方法是发送前必须等待DMA完成标志更稳妥的是用双缓冲两个缓冲区交替使用这次填A区、上次在发B区轮转起来就不会覆盖。这个方案在日志频率比较高的场合下表现很稳定我建议做量产固件直接上双缓冲。再来张速查表症状最常见原因解决办法跑printf卡死标准库半主机未禁用勾MicroLIB或加__use_no_semihosting中文乱码源文件编码/串口助手解码不匹配统一编码或检查ASCII输出%.2f输出0MicroLIB不支持浮点换标准库或手动拆整数/小数输出有延迟/丢失stdout缓冲未刷日志末尾加\r\nDMA日志乱码缓冲区被覆盖等待DMA完成或双缓冲做嵌入式这几年我在printf重定向这件事上踩过的坑不算少最后沉淀下来的习惯其实很简单调试期用MicroLIB fputc图的是跑通快、不费脑子正式固件里只要对浮点打点有要求我一定切标准库 完整FILE流重定向宁可Flash多占几KB也不想在客户现场的日志里看到一串0.00。等到日志量级上来需要DMA和RTOS配合的时候再换成缓冲式vsnprintf 双缓冲的方案。最后再分享一个小技巧如果你只是不想让printf这串字符从串口出去而是想在调试窗口看那就用ITM方式代码里把ITM_SendChar交给fputc就行。调试阶段看日志这种方式比串口省事还不用拔线插线。当然脱机运行就指望不上它了。希望这篇文章能帮你把Keil里的printf彻底驯服以后遇到类似问题不用再靠百度玄学。
返回列表