
在Keil里用软件debug功能看printf输出结果这事我在不止一个项目里折腾过。最典型的场景是手里一时没有开发板或者程序逻辑还没跑通不想反复烧固件这时候打开Keil自带的仿真调试不接任何硬件照样能把变量、状态、运行路径全部用printf打出来。网上搜“Keil printf重定向”十篇有九篇讲的是往串口寄存器里写数据那是给真实开发板用的到了软件仿真模式下这套就抓瞎了。今天把Simulator模式下跑通printf的完整套路讲一遍包括配置、重定向代码、调试窗口位置以及我踩过的几个坑。这篇内容很适合这几类人看刚学STM32、还没买开发板的同学写协议解析、状态机、纯逻辑算法想先不碰硬件就把框架验证一遍的工程师以及出差在外只带了笔记本、临时想确认一段代码行为的老手。全文以MDK 5 STM32F103C8T6为例但思路对Cortex-M系列通用。1. 软件debug模式解决什么问题仿真环境中的printf输出1.1 仿真调试和烧录调试的区别Keil MDK里的Debug功能有两条路一条是“Use Simulator”一条是“Use CMSIS-DAP / ULINK / ST-Link”这类真实调试器。前者就是今天的主角完全在电脑上模拟CPU、内存和外设寄存器的行为不需要连接芯片。很多人对软件仿真有误解觉得它鸡肋、不准。实际上对于不涉及GPIO外部时序、不依赖真实传感器信号、不测量实时性的代码Simulator的准确度相当高。它能把C语言层面的执行流程、变量变化、函数调用关系完整跑出来尤其适合验证状态机跳转、协议帧解析、环形缓冲区读写这一类纯逻辑代码。我就用它在没有板子的情况下调通过一个Modbus解析模块最后烧到真机上只改了串口初始化就一次跑通。它的天然短板也很明确真实的引脚电平、中断响应延迟、模拟量采样、看门狗喂狗时序这些在仿真环境里体现得不充分。所以先把“能做什么”想清楚才不会在里面瞎折腾。软件debug的价值在于“快速验证逻辑”而不是“替代硬件验证”。1.2 为什么在仿真里不能照搬“串口重定向”的写法网上关于printf重定向的教程绝大多数是这种写法int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }或者直接操作寄存器int fputc(int ch, FILE *f) { while ((USART1-SR USART_SR_TXE) 0); USART1-DR ch; return ch; }这套代码在真机上完全没毛病因为USART外设真的存在数据会通过TX引脚发到串口助手。但在软件仿真模式下Simulator对STM32的USART外设模拟非常有限很多情况下寄存器状态都不会按预期变化HAL_UART_Transmit甚至会一直等待发送完成标志。结果就是printf的字符根本没地方落地程序还可能卡死在底层。还有更隐蔽的问题就算USART寄存器在仿真器里能被写入你也没有一个“虚拟串口接收窗口”去看这些数据除非你打开Keil的UART #1窗口但那个窗口对ARM内核的兼容性并不稳定我实测经常不显示。所以Simulator下必须换一种思路让printf把字符不是“发给串口”而是“发给调试器本身”。这就是Keil的Debug (printf) Viewer机制底层走的是ARM CoreSight的ITMInstrumentation Trace Macrocell通道或者标准C库的Semihosting半主机通道。字符经过这两条路直接出现在Keil的调试窗口里不需要任何真实串口线。1.3 理清printf调用的完整链路要理解上面这个替换得先理清printf背后发生了什么。C库的printf本质是“格式化 字符输出”先把各种类型的数据按格式串转换成字符串然后逐个字符调用底层发送函数。在不同平台上这个底层发送函数名字不同在MCU的C库环境下通常是fputc它的职责是拿一个字符并把它送出去。至于送到哪C库不管它留给开发人员实现。于是就有了“重定向”这个词——你重写fputc把字符送到你想要的通道。真实板子上送到USART仿真环境里就送到ITM或者Semihosting。这两种通道的区别在于库类型printf字符去向是否依赖真实硬件适合场景标准C库默认默认走Semihosting由调试器接收依赖调试器支持仿真可以用但容易引发链接报错MicroLIB默认无输出必须自己重写fputc不依赖仿真和真机都推荐注意MicroLIB是Keil自带的一个精简C运行库体积小很多MCU工程默认就勾选它。勾选MicroLIB后printf的底层发送函数就变成空实现你不重写fputc输出就消失得无声无息。这正是新手在Simulator下最常见的问题代码里写了printf窗口里什么都没有。2. 工程配置与printf重定向三步让输出有地方去2.1 打开Simulator仿真模式第一步很简单打开工程进入Options for Target快捷键AltF7或者工具栏魔法棒图标在Debug选项卡里右上角选择“Use Simulator”。选择后下方会出现一个小的设置区域里面有“Dialog DLL”和“Parameter”两个字段。这里多说一句Parameter的事。通常MDK会根据你选择的芯片自动填好比如ST官方包里STM32F103C8对应的参数可能是-pSTM32F103C8Dialog DLL是DARMSTM.DLL。这个参数决定了Simulator加载哪种外设模型一般不要乱动。如果选的是裸的Cortex-M3内核则可能是DARMCM.DLL -pCM3。有网友把这里改成自己的芯片型号后外设仿真异常排查半天就是参数不匹配。我的建议除非你明确知道自己在干什么否则保持默认。同一选项卡里还有个“Run to main()”复选框建议勾上。这样进入调试后会直接帮你跑到main函数起始位置省得在启动文件里单步半天。另外右下角的时钟设置指的是仿真模式下的系统时钟它和代码里SystemCoreClock可能不完全一致。仿真时如果你用HAL_Delay这类依赖SysTick的延时函数实际等待时长和真实芯片会有差异后面我会再提。2.2 换用MicroLIB并重定向fputc第二步是关键的代码层配置。我推荐的组合是勾选MicroLIB 重写fputc 往ITM通道写数据。这是我在Simulator下最稳的方案没有之一。先勾选MicroLIB在Options for Target的Target选项卡往下找Code Generation分组勾上“Use MicroLIB”。然后在代码里放一个fputc重定向典型的写法如下#include stdio.h #include stm32f10x.h int fputc(int ch, FILE *f) { // Simulator模式下将字符送往ITM由Debug (printf) Viewer接收 ITM_SendChar((uint32_t)ch); return ch; }ITM_SendChar是CMSIS提供的一个函数位于core_cm3.h等内核头文件中直接操作ITM寄存器。它的作用是往ITM的stimulus port 0写一个字节并等待发送完成标志。在Keil的Simulator模式下这个通道被模拟字符最终会出现在Debug (printf) Viewer窗口里。如果不想包含整个STM32标准外设库也可以直接用寄存器地址操作但说实话既然工程已经选了芯片干脆把核心头文件带上更省事。那如果不勾选MicroLIB、坚持用标准C库呢也不是不行但需要手动关闭Semihosting并补几个底层函数。标准C库在裸机环境下默认会使用Semihosting机制和调试器通信这会导致链接时报“__stdout”未定义或者程序运行到printf时卡死在某个断点上。你需要这样处理#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; FILE __stdin; void _sys_exit(int x) { x x; } void _ttywrch(int ch) { (void)ch; } int fputc(int ch, FILE *f) { ITM_SendChar((uint32_t)ch); return ch; } int fgetc(FILE *f) { return -1; }这里__use_no_semihosting告诉链接器我不要半主机功能。然后手动提供一个假的__stdout、_sys_exit、_ttywrch等桩函数把printf对底层环境的依赖全部补齐。这段代码在Keil社区流传很广但它比MicroLIB方案啰嗦。两种方式的取舍我做了个对比项目MicroLIB 重定向fputc标准C库 关闭Semihosting配置复杂度低中高代码体积小较大浮点格式化支持有限%f可能异常完整踩坑率低中符号报错常见推荐场景日常嵌入式开发必须用复杂浮点/数学库时大部分用STM32做控制类、通信类项目的朋友直接用MicroLIB方案就好。如果发现%f打印浮点数不正常再考虑临时切回标准库。2.3 在调试界面打开Debug (printf) Viewer代码写完编译通过后还有最后一步知道去哪看输出。点击Debug菜单里的Start Debug Session或者按CtrlF5Keil会进入调试界面。这时候菜单栏的View菜单下会多出很多调试相关选项。找到View - Serial Windows - Debug (printf) Viewer点击打开。窗口出现后直接按F5全速运行printf的内容就会一行行出现在这里面。需要注意Debug (printf) Viewer只有在调试会话中才能打开编译完不进入调试状态是找不到这个窗口的。还有个小技巧在窗口内部右键可以执行Clear Window清空内容或者调整是否自动滚动。仿真打印频率很高的时候建议清空后再看避免混入上一次调试的残留信息。View菜单下除了Debug (printf) Viewer还有UART #1、UART #2、UART #3这些窗口。它们的设计初衷是用来模拟8051这类芯片的虚拟串口对于STM32的Simulator模式我的体验是兼容性一般不如Debug (printf) Viewer稳定。所以别在窗口选择上纠结认准Debug (printf) Viewer即可。3. 从零跑通一次Simulator模式下看到“Hello World”3.1 验证代码设计理论说清楚接下来实际操作一遍。我拿一个最典型的STM32F103C8T6工程示范工程新建过程就不展开了网上大把教程。关键是主函数和fputc这部分代码。为了让演示更有说服力我不只打印“Hello World”还顺带验证了整型、十六进制、指针地址、字符串、循环计数等各种格式控制符这样能一次性确认printf的整个通路是不是都正常。#include stdio.h #include stm32f10x.h int fputc(int ch, FILE *f) { ITM_SendChar((uint32_t)ch); return ch; } int main(void) { int count 0; char *chipName STM32F103C8T6; printf([Simulator] Hello, Keil Software Debug!\r\n); printf(Chip: %s\r\n, chipName); printf(fputc address: 0x%08X\r\n, (unsigned int)fputc); while (1) { printf([%d] square%d\r\n, count, count * count); count; if (count 10) { count 0; } // 仿真时不要做长时间延时这里只写个简单循环意思一下 for (volatile int i 0; i 100; i) { } } }代码里面fputc的地址用0x%08X打印出来主要是验证指针类型和十六进制输出是否正常。volatile int i那个循环是为了防止编译器把空循环优化掉同时也告诉读者仿真环境下不要用大延时否则跑起来像卡死一样。有个容易被忽略的地方ITM_SendChar需要一个条件编译或声明如果工程没有包含stm32f10x.h直接写会编译报错。但我强烈建议在工程里保留CMSIS核心头文件因为后续调试、操作寄存器都要用。3.2 编译、进入调试、运行观察输出的完整流程实际操作时步骤是这样的编译工程确认0 Error0 Warning。有警告时最好也看一下尤其是关于#pragma的警告它可能影响重定向。保持Options for Target里的Use Simulator处于选中状态点击Debug - Start Debug Session。如果勾了Run to main()调试器会自动停在main函数入口。此时先别急着全速运行从View菜单打开Debug (printf) Viewer窗口。按F5运行或者点击调试工具栏的全速运行按钮。程序跑起来后Debug (printf) Viewer里会依次出现上面那些printf内容。从按下F5到看到内容基本是瞬间的事。如果窗口里没有东西优先检查fputc是否真的写进了工程、Debug Viewer是否打开、是否处于Simulator模式而不是硬件调试模式。运行过程中我习惯把运行按钮旁边的时钟周期计数打开在调试状态栏能看到Cycle Counter这能帮你估算每条语句执行了多少个时钟周期。对于纯逻辑代码的性能摸底这个数据很有价值真机上都未必能这么准。3.3 仿真中的时钟、延时和优化等级仿真模式下有一个大坑代码里的时间概念不可信。比如你用HAL_Delay(1000)它会依赖SysTick中断而Simulator对中断和外设时钟的模拟精度和芯片真实跑起来差得很远。哪怕它真的延时了1000ms那也是仿真器里的虚拟时间不是真实世界的时间。所以我给个实用建议在Simulator下调逻辑时把延时注释掉或者换成纯软件的计数循环。上面代码里我用了一个小的volatile循环就是干这个。等验证完状态机、协议逻辑到了真机上再重新开启延时函数。优化等级也值得单独说。Keil默认在新工程里可能是Level 0-O0适合调试但很多人下载的开源工程是Release配置优化等级可能到Level 2甚至Level 3。在这种设置下局部变量会被优化掉你在Watch窗口看不到变量的实时值printf语句也可能被重排或裁剪。所以我通常会把Options for Target - C/C - Optimization里的Level改成Level 0-O0并且勾选“One ELF Section per Function”旁边的调试选项保证调试信息完整。3.4 中文编码问题很多朋友拿到这套方案后第一件事就是把printf里的字符串改成中文比如“你好串口”。结果迎面撞上一堵墙乱码。这里面的根源是编码不一致。Keil MDK 5默认源码文件可能保存成UTF-8编码也可能根据系统区域设置保存成GB2312/GBK而Debug (printf) Viewer在解析时不一定按你预期的编码来解码。字符在编译后变成字节流如果字节流和窗口的解析方式对不上中文就变成一堆“锟斤拷”或者“烫烫烫”之类的怪字符。最省心的解决办法是仿真相下优先用英文字符串。英文ASCII字符不管在哪个编码方案下都是兼容的绝不会乱码。如果业务上必须显示中文我见过有人用UTF-8编码的字节数组硬编码也有人把编译器输出文件调成特定的编码但每个版本的MDK行为都略有差异。说到底仿真调试是为了看程序逻辑不是为了让日志面板变得好看先用英文把通路验证通才是最优先的目标。4. 高频翻车现场printf没输出、乱码、卡死的排查清单4.1 编译报错semihosting相关符号问题我在评论区见过最多的报错是这种Error: L6915E: Library reports error: __use_no_semihosting was requested, but _ttywrch was referenced翻译过来就是你明明说要关闭Semihosting但库函数里还是引用了_ttywrch这个半主机相关函数结果就是冲突。这通常是用了标准C库、又没有补全桩函数导致的。解决办法有两条要么在代码里把所有缺的桩函数补齐别用标准库要么干脆勾上MicroLIB走第一条路让这个报错根本不发生。我本人更倾向后者因为省心。另一个常见报错是Error: L6218E: Undefined symbol __stdout这个就是标准C库找不到stdout定义。和上面同理MicroLIB方案不会碰到它。真要用标准库就把前面的struct __FILE、FILE __stdout那套桩全贴上。4.2 Debug Viewer窗口一片空白如果程序编译、运行都正常但Debug (printf) Viewer里什么也没有我建议按下面这个表格逐项排查检查项操作方式常见原因是否处于Debug Session确认在调试界面且不是编译后才停住没进入Simulator调试模式是否打开Debug ViewerView - Serial Windows - Debug (printf) Viewer打开的是别的窗口fputc是否被链接在fputc里临时设断点看是否断住工程里有多个fputc或没被调用是否勾选MicroLIBOptions - Target - Use MicroLIB标准库和桩代码冲突优化是否裁剪代码把优化改成Level 0高优化下printf被剥离是否使用了自定义的printf宏搜索#define printf工程里宏禁用了printf这里我多说一点“fputc是否被链接”。多文件工程里如果某个目录下的源文件没有参与编译或者被#if 0包住了fputc实际不存在。你可以在fputc函数第一行打一个断点全速运行后如果断点没触发说明字符根本没走到这个函数问题在链接或库配置如果断点触发了输出还是没显示那问题就在ITM通道或窗口本身。这个二分法排查思路比瞎调半天管用得多。4.3 程序卡死在BKPT指令在仿真环境里你有时会看到程序莫名其妙停在一个奇怪的地址反汇编窗口显示一条BKPT 0xAB指令这就是Semihosting调用的标志。标准C库在没有关闭Semihosting时printf会触发这条指令把控制权交给调试器。Keil的Simulator虽然能处理一部分Semihosting调用但配合不当就会卡在这里。对策很简单禁用Semihosting。要么用MicroLIB方案要么用标准C库时加上#pragma import(__use_no_semihosting)那套桩函数。我见过有人写while (1)配合断点硬跳过去那是治标不治本换一台电脑换个环境立刻复发。4.4 结构体变量在Watch窗口显示不全有个热词是“debug模式下如何显示结构体变量”顺手说下。软件仿真时想查看结构体内容不需要依赖printf直接利用调试器的Watch窗口更直观。右键点击结构体变量名添加进Watch 1窗口然后点变量左边的箭头逐层展开成员。如果看到某个值显示optimized out别慌这是编译器把那个变量优化到寄存器里了把优化等级调到Level 0并且勾选“Debug information”即可。如果你拿到的是一个结构体指针比如pMsg直接添加到Watch窗口只能看到地址看不到内容。此时需要手动输入*(TypeName *)pMsg比如*(MsgFrame *)pMsg强制解引用Keil就会把整个结构体内容展开。这个技巧对排查协议帧解析特别有用省去你挨个字节对偏移量。4.5 scanf在仿真模式下的误区既然聊到printf就顺带说说它的老搭档scanf。网上很多人尝试在Simulator下用scanf从键盘读数据结果发现Debug Viewer窗口根本不接收输入。这真不是操作问题而是Keil的Debug (printf) Viewer本质上是个“显示面板”不是终端控制台它不支持交互式输入。在仿真阶段想测试scanf相关逻辑最实用的做法是给变量直接赋测试值或者用变量观察窗口在断点处手工修改变量值。等程序移植到真实开发板上再用串口助手发数据。别再为Simulator下scanf输入浪费时间去查API了我也算是在这里交过学费的。另外网上那个《告别printf调试用letter shell打造stm32交互式命令行》的思路很好但它在仿真环境里同样跑不顺。因为命令行交互依赖真实的字符输入通道和精确的外设中断Simulator本身的定位就不适合这类应用。这类工具是“真机调试利器”等工程脱离仿真再接它不迟。4.6 看门狗、浮点打印等边角问题还有一个容易忽视的地方如果你在工程里初始化了独立看门狗或窗口看门狗Simulator下它一样会工作。看门狗一超时整个仿真就重启了printf输出刚闪一下就被刷掉看起来就像“程序一直在复位”。解决方式是在仿真时通过宏开关屏蔽看门狗初始化或者把喂狗代码放到主循环合适的位置保证每次喂狗间隔远小于看门狗超时时间。浮点数的%f打印在MicroLIB下也可能出问题。有些老版本ARMCC的嵌入式printf不支持浮点格式输出或者需要用额外链接选项。如果你printf(%f, 3.14)输出是空白、0或者没有先别怀疑ITM通道试一下把库换成标准C库用法就回来了。这也是我为数不多建议客户从MicroLIB切回标准库的场景。5. 软件仿真调试的边界与几条实战建议5.1 哪些场景别用软件debug死磕软件debug很方便但它的边界我必须说清楚免得你在上面浪费大半天。Simulator帮你模拟的是CPU指令、内存读写、寄存器行为和一定程度的片上外设状态但真实芯片上的电气行为它管不了GPIO引脚翻转速度、外部中断的实际触发电平、I2C/SPI时序、ADC采样值、DMA与总线之间的竞争这些在仿真环境里都是失真的。举个例子你在仿真里调好的软件I2C读取传感器程序上了真机可能因为上拉电阻、总线电容、时钟延时不匹配而读不到数据。这不代表你的软件debug做错了只是Simulator没有模拟外部物理世界。同理想验证定时器PWM输出的占空比和频率仿真只能看到寄存器值变化示波器该上还是要上。所以我建议的“分工”是纯逻辑层用Simulator快速迭代驱动层和系统层用真实硬件验证。两者结合效率才是最高的。5.2 从“打印日志”到“交互式命令行”的下一步等你在Simulator下把printf这套用熟了其实已经掌握了嵌入式调试中最核心的利器。接下来可以往两个方向进阶一是设计日志分级系统把调试信息按ERROR、WARN、INFO、DEBUG分级通过宏开关控制不同模块的打印输出很多商业项目就是这么组织日志的二是类似letter shell那样的命令行交互比如在真机上通过串口输入命令、动态修改变量、查询寄存器这在调试复杂协议栈时效率极高。值得强调的是这些进阶玩法里软件仿真通常只承担前期语法和流程验证真正的调试价值要在真实硬件上发挥。我的经验是先把Simulator下的printf输出跑通证明框架逻辑正确再把这套fputc重定向在真机上无缝切换成串口发送整个开发节奏会非常顺。5.3 我现在的使用习惯这个内容后续还可以这样扩展把上面这套Simulator Debug (printf) Viewer的思路迁移到不同芯片厂家的IDE里。比如瑞萨的e² studio、TI的CCS都有类似的仿真调试和printf实现机制。原理相通只是窗口名称和DLL参数不同。你只要掌握了“printf - 格式化 - fputc - 调试通道 - 查看窗口”这条主线换工具就是换个界面位置而已。我个人现在养成的习惯是算法类、协议解析类、状态机类的代码先在Simulator里把printf输出跑顺涉及外设驱动、实时性、时序的地方再上板子验证。软件debug只要能正常打印就说明代码框架已经立住了如果连仿真里都打不出东西那多半是重定向配置或代码逻辑本身有问题。这种“先仿真后上板”的流程帮我省了大量烧录调试的时间。