在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的ITM(Instrumentation 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(快捷键Alt+F7或者工具栏魔法棒图标),在Debug选项卡里,右上角选择“Use Simulator”。选择后下方会出现一个小的设置区域,里面有“Dialog DLL”和“Parameter”两个字段。
这里多说一句Parameter的事。通常MDK会根据你选择的芯片自动填好,比如ST官方包里STM32F103C8对应的参数可能是-pSTM32F103C8,Dialog 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(或者按Ctrl+F5),Keil会进入调试界面。这时候菜单栏的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 Error,0 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 Viewer | View -> Serial Windows -> Debug (printf) Viewer | 打开的是别的窗口 |
| fputc是否被链接 | 在fputc里临时设断点,看是否断住 | 工程里有多个fputc,或没被调用 |
| 是否勾选MicroLIB | Options -> 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只要能正常打印,就说明代码框架已经立住了;如果连仿真里都打不出东西,那多半是重定向配置或代码逻辑本身有问题。这种“先仿真,后上板”的流程,帮我省了大量烧录调试的时间。