☰
Keil软件仿真下printf输出配置与重定向实战
2026/10/1 16:39:47 网站建设 项目流程

玩嵌入式的人,十有八九都是printf走天下,我也不例外。写STM32代码时,随手printf一下变量、状态、运行路径,比用调试器一步步点寄存器痛快多了。但很多人不知道,就算手头没有开发板、没有ST-Link,只要电脑装了Keil,照样能在软件debug模式下把printf输出看得清清楚楚。这里的软件debug,指的是Keil自带的Simulator软件仿真,完全不用接硬件,靠PC模拟一颗ARM芯片跑程序。可不少人勾选了Use Simulator,也写了printf,却怎么都看不到输出。这篇文章就专门解决这件事,从原理到实操全部走一遍。

1. 软件debug是个啥,为啥要在这里面看printf

1.1 软件debug模式的本质

Keil MDK从很老的版本开始就内置了Simulator仿真器。打开工程的Options for Target,切到Debug标签页,上半部分会看到两个选项:Use Simulator和Use Debugger。前者就是软件仿真,完全不需要真实芯片;后者是配合ST-Link、J-Link这类硬件调试器使用的。

软件仿真做的事情,是在你的PC上模拟一颗完整的ARM芯片,包括Cortex-M内核、内存、外设寄存器。你编译出来的hex文件被加载进这个虚拟芯片里,PC的CPU负责一条条执行指令,遇到外设操作时,模拟器会按照芯片手册的时序去更新寄存器状态。所以从软件角度来看,程序以为自己跑在真实芯片上,实际上跑在一个高度抽象的模型里。

它能模拟的外设比大多数人想象的多。GPIO翻转、定时器计数、UART发送、DMA搬运、甚至是中断触发逻辑,都能在一定程度上模拟。我在编译完代码后,如果手头没板子,经常会先用Simulator把核心逻辑跑一遍,确认没有明显逻辑错误再烧板。这个习惯帮我省掉了大量反复烧录固件的时间。

1.2 什么场景下适合用软件仿真看printf

最典型的使用场景有三类。

第一种,写算法、状态机、协议解析这类和硬件耦合度比较低的代码。这种代码的调试重点在逻辑,不在时序。仿真跑起来又快又干净,printf输出日志比用示波器去抓波形直观得多。比如你写一个Modbus协议解析,输入数据靠代码里硬编码进去,跑仿真就能看到每一帧的解析结果。

第二种,硬件还没到、板子送修、出差在外摸不到板子的情况。我去年有一回出差,客户现场说程序有bug,但开发板留在公司了。我直接用酒店电脑装了Keil,把工程切到Simulator,通过printf输出和Watch窗口,硬是把一个数组越界问题找了出来。没有软件仿真,这种事想都别想。

第三种,调试某些不好打断点但又想确认执行路径的分支逻辑。比如一个状态机的十几个状态,你逐个打断点太累,直接在每个状态切换处printf一行,然后全速跑仿真,看输出的状态跳转日志就够了。这种场景下,printf比调试器效率高一个量级。

当然,软件仿真也有明显的边界。它模拟不了真实外设的电气特性,读不到外部传感器的真实数据,模拟不了复杂多引脚时序的精确抖动。逻辑正确性能帮你兜底,但芯片实测永远是最后一公里。理解了这一点,你就知道软件仿真应该用在哪儿了。

2. printf要往哪“写”:重定向与Debug (printf) Viewer

2.1 Debug (printf) Viewer是干什么的

很多人卡住的第一个地方,是不知道printf的输出到底去了哪里。在PC上写C语言,printf天然会往控制台打印,这是因为标准库背后连接着操作系统提供的标准输出。但在嵌入式环境里,没有操作系统帮你接住这个输出,所以你必须自己告诉标准库:“往哪里写”。

Keil在View菜单下有一个Serial Windows子菜单,里面可以看到UART #1、UART #2、UART #3等选项。点开UART #1,会弹出一个叫Debug (printf) Viewer的窗口。这个窗口就是软件仿真里的“虚拟串口终端”。

它的工作机制是这样的:当你选中Use Simulator后,Keil会模拟一个完整的UART外设。代码往UART的数据寄存器USARTx->DR写一个字节时,模拟器捕获到这次写操作,把这个字节直接推送到对应的Viewer窗口里显示出来。换句话说,Debug (printf) Viewer就是仿真UART的“屏幕”,只要你的printf最终能把字节送进某个UART的发送寄存器,这个窗口就能显示出来。

这里有一个关键认知:Debug (printf) Viewer并不是一个通用的IDE终端窗口,它是串口UART的虚拟显示器。所以你不能指望printf不经过串口就直接蹦到窗口里,必须把printf的输出通道接到UART上。

2.2 为什么必须重定向printf

C标准库的printf函数,内部最终会调用fputc或者putchar这类底层字符输出函数。在PC上,这些函数默认把数据写到标准输出文件流stdout,进而显示到控制台。在嵌入式平台上,没有控制台这个概念,标准库也不知道该把数据送到哪里。

所以我们需要改写fputc函数,让它把字符写到我们指定的外设上,这就是“重定向retarget”。嵌入式领域最常见的重定向目标有两个:

一是重定向到UART串口。printf的每个字符逐个通过串口发送,无论是真实硬件上的串口工具,还是软件仿真里的Debug (printf) Viewer,都能收到数据。这是最通用的方案。

二是重定向到ITM/SWO。这是ARM CoreSight调试架构提供的调试通道,速度比串口快得多,配合ST-Link、J-Link这类硬件调试器在硬件调试时非常好用。但在纯软件仿真里,ITM的模拟支持并不稳定,所以我不推荐在Simulator场景下用ITM方式。

在软件仿真里,UART方式是唯一稳妥的选择。原因很简单:Keil的Simulator对UART外设的模拟已经非常成熟,每个寄存器位的语义都模拟得很到位,只要代码里正确初始化了UART,发送数据就一定能被Viewer捕获。

2.3 重定向到UART的模板代码

下面这段fputc重定向代码,是我在标准外设库工程里反复使用的版本。用HAL库的读者把底层发送函数换成HAL_UART_Transmit即可,思路完全一样。

#include <stdio.h> int fputc(int ch, FILE *f) { /* 等待发送数据寄存器空,加上超时保护防止仿真卡死 */ uint32_t timeout = 0; while (!(USART1->SR & USART_FLAG_TXE)) { if (++timeout > 1000000) { break; } } USART1->DR = (uint8_t)ch; return ch; }

这里有两个细节值得展开说一下。

第一个细节是等待标志位。USART_FLAG_TXE表示发送数据寄存器为空,可以写入下一个字节。如果没有等待就直接写DR,可能会出现丢字节的情况,因为上一个字节还没移出移位寄存器。这在真实硬件和仿真中都会发生,属于UART发送的标准操作。

第二个细节是超时保护。为什么要加超时?因为软件仿真虽然能模拟UART,但前提是UART已经被正确初始化、时钟已被使能。如果代码在串口初始化之前就执行了printf,fputc会去读USART1->SR寄存器,此时寄存器的值可能是未定义的随机值。最典型的情况是TXE位一直不为1,while循环永远循环下去,仿真直接卡死。加上超时保护后,就算初始化顺序有问题,程序也不会无休止地卡在fputc里,而是会继续往下跑,方便你定位问题。

3. 手把手实操:从工程配置到看到输出

3.1 第一步:把工程切到Simulator模式

打开你的Keil工程,点魔术棒图标进入Options for Target,切到Debug标签页。在右上角的设置区选中Use Simulator,注意不要选成下面的Use Debugger。然后确保勾选Load Application at Startup和Run to main()两项。

Load Application at Startup的作用是启动调试时自动加载编译出的镜像文件到模拟内存里。Run to main()的作用是让程序在启动调试后自动执行到main函数入口处停下。这两项都勾上之后,你按Ctrl+F5,程序就直接停在main函数入口,不用手动去加载文件、不用手动在反汇编窗口里找入口地址,效率高很多。

这个页面还有一个很容易被忽略的细节:Dialog DLL Parameter。有些工程需要在这里填仿真参数,比如硬件仿真时填的是一些调试器的DLL名称。软件仿真时我一般保持默认,如果遇到仿真器起不来、报错说DLL加载失败,才需要检查这里是否配置了正确的Simulator DLL参数。

3.2 第二步:配置芯片型号和时钟源

在Options for Target的Target标签页里,首先要确认Device标签页已经选对了芯片型号。比如你用的是STM32F103C8T6,那Device里就要选STMicroelectronics下的STM32F103C8。选错芯片会导致外设寄存器映射完全不对,仿真结果毫无参考意义。

然后是Xtal (MHz)这个参数,它代表你板子上外部晶振的频率。以STM32F103系列为例,大部分开发板用的是8MHz晶振,代码里的SystemInit函数会根据这个频率去计算PLL倍频系数,最终得到72MHz的系统时钟。你在仿真里如果填了8,代码里也按8算,模拟器就能准确模拟出各个外设的工作频率。

这里有个很多人踩过的坑:Target页的Xtal写8,但代码里的SystemInit或者HAL库里的HSE_VALUE宏被改成了25MHz。这样编译器在计算UART波特率分频值时,用的是25MHz晶振对应的PLL参数,而模拟器以为你用的是8MHz,两边一错位,仿真的时序就全乱了。所以配置时钟时,一定保证Target页、HSE_VALUE宏、实际晶振三者一致。

3.3 第三步:添加重定向代码和串口初始化

把上面fputc重定向代码加入到你的工程文件里,注意必须include stdio.h。然后在main函数里初始化USART1。

我常用的初始化片段(标准外设库版)如下:

void uart1_tx_init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = 115200; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_Tx; USART_Init(USART1, &usart); USART_Cmd(USART1, ENABLE); }

main函数里的调用顺序:

int main(void) { uart1_tx_init(); printf("Hello embedded world!\r\n"); while (1) { /* 业务逻辑 */ } }

注意这里的顺序不能反。必须先把UART初始化好,再调用printf,否则fputc会在等待TXE标志位时卡死或读到未定义值。我在工程里一般会把串口初始化放在main函数最前面,保证任何模块在任何位置printf都不会遇到初始化顺序问题。

3.4 第四步:打开Debug (printf) Viewer并运行

这一步最容易被忽略。很多人代码写对了,仿真也跑起来了,但就是没看到输出,原因是没有打开Viewer窗口。菜单栏找到View -> Serial Windows -> UART #1,点击后会弹出一个窗口,标题栏显示“UART #1 (Debug (printf) Viewer)”。

接下来按Ctrl+F5启动调试。程序会停在main函数入口,然后按F5全速运行。如果一切正常,Debug (printf) Viewer窗口里会出现“Hello embedded world!”这一行字。

如果没显示,先不要慌,按F11单步执行,逐步看程序走到哪一步卡住了。如果发现卡在fputc的while循环里,重点检查UART的RCC时钟是否已使能、串口是否已初始化。如果在仿真一开始就直接进入HardFault_Handler,那问题多半出在时钟配置或者芯片型号选择上。关于这些问题,我在下一章专门写了一份排查清单。

4. 避坑指南:仿真调试中那些让人抓狂的问题

4.1 常见问题速查表

这里我把实战中最常踩的坑整理成一张速查表,每一个都是我自己或身边同事真实遇到过并排查过的。初次上手软件仿真printf的读者,建议先把这张表存下来。

现象常见原因解决方式
Viewer窗口空白没有打开UART #1窗口,或打开的不是Debug (printf) ViewerView -> Serial Windows -> UART #1
Viewer空白且程序卡死fputc里等待发送完成标志位,但串口时钟没使能或串口未初始化初始化RCC和USART后再调用printf,或加超时保护
中文乱码Keil编辑器编码与Viewer解析编码不一致统一使用UTF-8编码,或打印纯英文字符串
%f输出不了Keil标准库默认不包含浮点数printf支持在Target页勾选Use MicroLIB
仿真速度极慢每次printf触发大量调试事件降低打印频率,选择性打印关键路径
程序进入HardFaultSystemInit或时钟配置和Target页不匹配检查芯片型号、Xtal频率、SystemInit实现
printf没有重定向工程里没有实现fputc,默认走了半主机模式添加fputc重定向到UART或ITM
编译报错FPU相关芯片配置了FPU但工程没启用Target页勾选Floating Point Hardware

4.2 中文乱码问题

这个问题在Keil里出现的频率比我预期的要高得多。Keil 5默认的编辑器编码是UTF-8,而老版本Keil 4以及一些从其他IDE迁移过来的工程,源码文件用的是ANSI编码,也就是GBK/GB2312。如果源码文件里直接写了中文字符串,编译器会把字符串常量按照源码文件的编码存进内存,然后通过UART逐字节发出来。Debug (printf) Viewer再按自己的编码规则去解析,前后编码不一致,必然乱码。

最常见的表现是:源码里明明是“温度正常”,Viewer里显示出一串乱码,或者是问号。这种情况很多人以为是字符集问题,其实根子在源码文件的编码格式上。

我的处理方法是这样的:团队工程统一要求源码文件使用UTF-8编码。在Keil里,通过Edit -> Configuration -> Editor -> Encoding,把Encoding设置成Encoding with UTF-8。这样源码里的中文字符串常量以UTF-8字节序列存进Flash,Viewer窗口按同样的UTF-8解析,显示就正常了。如果工程里混着GBK编码的旧文件,我会先用文本编辑器把它转成UTF-8再放进工程。

当然,最省事的方案是printf的字符串尽量别用中文,用英文加编号。调试日志本来就是给自己看的,英文字符串在编码问题上永远是最安全的。我现在的习惯是日志全部用英文,注释和命名用中文,两者互不干扰。

4.3 看不到输出,先查这四步

如果你照着前面的步骤做了一遍,依然没有输出,按下面的顺序排查,百分之九十能在两分钟内定位问题。

第一步,检查调试模式。打开Options for Target -> Debug,确认选中的是Use Simulator,而不是Use Debugger。很多人之前用ST-Link调试过板子,Debug页面停留在硬件调试器设置上,切到软件仿真时忘了改,导致程序根本没进Simulator。

第二步,确认fputc重定向代码参与编译。最直接的方法是在fputc函数里设置一个断点,启动仿真后全速运行,看断点有没有被命中。如果断点一直没命中,说明printf根本没有调用到你的fputc,可能是重定向代码所在的c文件没有被编进来,或者编译器优化把它内联和丢弃了。检查一下fputc所在的文件是否在工程里,编译输出里有没有出现对应的obj文件名。

第三步,验证串口初始化是否在printf之前执行。可以在main函数的第一行、串口初始化之前临时加一个printf,如果程序卡死,说明fputc等标志位卡住了,串口还没初始化。把printf挪到串口初始化之后,问题就解决了。

第四步,确认Viewer窗口选对了UART编号。你fputc里操作的是USART1,那Viewer菜单里要打开的就是UART #1,而不是UART #2或UART #3。这点看起来基础,但确实有人因为开了UART #2然后奇怪为什么没输出。

4.4 浮点数打印不了

标准C库的printf为了控制代码体积,默认不支持%f的浮点格式化输出。这是Keil MDK的经典问题,几乎每个用STM32的都遇到过。具体表现是:printf("%f", 3.14) 编译能通过,程序跑起来却打印出一堆乱码,或者直接打印空串。

解决方法很简单:在Options for Target -> Target标签页底部,Code Generation区域有一个Use MicroLIB的勾选框,把它勾上。MicroLIB是ARM提供的精简C运行时库,它支持printf的浮点格式化功能,体积上也比标准库更小,非常适合嵌入式MCU使用。

不过要提醒一点,MicroLIB虽然支持%f,但它支持的格式化特性和标准库不是完全一致的。比如一些比较精细的小数位控制组合,在MicroLIB下偶尔会有精度偏差。如果你发现浮点输出精度异常,优先查一下是不是MicroLIB的格式化精度和你的预期不一致,必要时可以通过sprintf把浮点转成字符串后再拼接输出,绕开这个问题。

4.5 仿真跑飞和HardFault的排查思路

软件仿真里程序跑到HardFault_Handler里,第一步是在HardFault_Handler处打断点,然后打开View -> Call Stack窗口查看函数调用栈,看到底是从哪个函数、哪条指令跳进去的。这个思路和硬件调试完全一致,仿真反而更方便,因为寄存器窗口和内存窗口可以直接查看。

最常见的HardFault原因是时钟配置不一致。比如Target页的Xtal填8,代码里的SystemInit却按照25MHz来配置PLL,导致模拟器的外设时钟树计算出错误的分频值。排查方法是打开Peripherals -> Power and Reset Controller,查看RCC相关寄存器的实际值,对比代码配置是否符合预期。

第二个常见原因是外设寄存器访问时序错误。比如在未使能GPIOA时钟时就操作GPIOA相关寄存器,在仿真里读到的值可能是随机数,后续的操作自然不可预测。仿真器不会像真实芯片那样对这些访问做严格容错,表现就是各种奇怪的现象,最终归结到HardFault。

第三个原因和printf本身有关。如果你的fputc重定向实现出了问题,比如操作了不存在的寄存器地址,或者直接对空指针解引用,程序会立刻跑飞。所以fputc的实现一定要简单,等待标志位加写寄存器即可,不要在里面堆复杂业务代码。

4.6 仿真的时间精度和波特率问题

还有一个容易被忽略的点:在软件仿真里,UART的波特率是否设置正确,并不会直观影响Debug (printf) Viewer的显示。因为Viewer本质上只是把写到DR寄存器的字节按顺序显示出来,它不关心这些字节在真实时间轴上是什么时候发送的。所以哪怕你把波特率从115200写成了9600,Viewer里照样能正常显示字符串。

但如果你在仿真里加入了延时函数,比如Delay_ms,或者使用了定时器做超时控制,那么波特率设置、时钟频率这些参数就变得重要了。因为仿真的时间推进是按照模拟时钟算的,定时器计数的快慢、延时函数的实际时长,都会直接影响程序逻辑。我建议在写仿真的串口初始化时,还是按照真实硬件配置来写,保持一致。这样在仿真验证通过后,直接切换到硬件调试不用改任何代码。

如果你发现仿真里Delay的时间明显不对,比如本该延时100ms但实际看起来不到1ms,优先去检查Target页的Xtal和代码里的SystemInit时钟配置是否一致。这是仿真时间精度最常见的误差来源。

5. 进阶玩法:把printf用出花来

5.1 自定义日志宏,加上优先级和时间戳

printf裸奔虽然好用,但日志一多,满屏都是不分主次的输出,找关键信息就变得头疼。我习惯包一层日志宏,给每条输出加上等级前缀:

#define LOG_ERR(fmt, ...) printf("[ERR] " fmt "\r\n", ##__VA_ARGS__) #define LOG_WARN(fmt, ...) printf("[WARN] " fmt "\r\n", ##__VA_ARGS__) #define LOG_INFO(fmt, ...) printf("[INFO] " fmt "\r\n", ##__VA_ARGS__) #define LOG_DEBUG(fmt, ...) printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__)

调用时只要写LOG_INFO("state = %d", state),输出就会自动带上前缀。需要看更详细的信息时,还可以在旁边加一条时间戳打印。Cortex-M内核里通常会在SysTick中断里维护一个毫秒计数的tick变量,printf输出时把这个tick带出来,就能看到每条日志之间的时间间隔。这个功能在软件仿真里也完全可用,因为SysTick是内核自带的定时器,仿真器能准确模拟。

5.2 用Watch窗口配合printf查看结构体变量

软件仿真里另一个被低估的利器是Watch窗口。printf适合输出离散的执行路径记录和单个变量值,但如果你要观察的是一个结构体的整体变化,或者跨函数的缓冲区数据,用打断点加Watch窗口比printf高效得多。

操作方法是:启动调试后,打开View -> Watch Windows -> Watch 1,在Name列直接输入结构体变量名,回车后变量会出现在窗口里,展开左侧的加号就能看到每一个成员值。程序全速运行时,还可以把结构体变量添加到Watch窗口后右键选择“Enable Auto Update”,这样结构体成员变化会实时刷新,不用手动暂停。

我在调协议解析时经常用这套组合拳:printf打印每次状态跳转的记录,Watch窗口盯着协议上下文结构体,双管齐下,很多藏在数据转换里的bug一眼就能看出来。

5.3 软件仿真里看GPIO波形和逻辑分析

除了Debug (printf) Viewer和Watch窗口,Keil的软件仿真还有一个Logic Analyzer窗口。它可以观察引脚电平随时间的变化,类似一个简易的虚拟示波器。

使用方法是:启动仿真后,菜单栏View -> Analysis Windows -> Logic Analyzer,在打开的窗口里右键添加变量,输入你关心的GPIO寄存器地址,或者直接用PA9这类引脚变量。全速运行后,窗口会绘制这些引脚的电平波形。这个功能在验证软件延时、PWM占空比、按键消抖算法时非常好用。

比如你写了一个按键消抖程序,不确定延时顺序对不对,可以同时观察按键引脚和标志位的波形,通过时间轴对比,一眼就能看出消抖逻辑是否符合预期。这个在纯软件环境下就能完成,不需要接任何硬件。

5.4 仿真和硬件调试的切换节奏

最后说说我在实际工作流里的配合方式。写完一版代码后,先在软件仿真里跑一遍基础功能,把能查的逻辑问题、越界问题、状态机跳转问题全部过滤一遍。这个阶段printf、Watch窗口、Logic Analyzer组合使用,速度比硬件调试快很多,因为没有烧录和连接硬件的额外开销。

仿真验证通过后,再切换到硬件调试。怎么切换?还是Options for Target -> Debug,把Use Simulator改回Use Debugger,并选择对应的ST-Link或J-Link调试器型号。这里有一个小细节值得注意:硬件调试时,如果你的printf还是重定向到UART,那你需要真实的串口工具接在开发板的TX引脚上看输出;如果你用的是ST-Link和ITM方式,那Debug (printf) Viewer窗口在硬件调试时同样能用。

我个人的习惯是:工程里fputc重定向到UART1,Debug (printf) Viewer在仿真的场景下使用;硬件调试时,我直接把PA9接到USB转串口工具上,用PC串口助手看输出。这样仿真和硬件两套环境,代码层面完全不用动,只需要在调试模式上切换一下就够。

说到底,printf走天下这句话在嵌入式开发里确实不虚,但关键在于让它“通”到你能看到的地方。Keil的软件debug模式加上正确配置的串口重定向,能让你在没有开发板的环境下,也拥有完整的日志调试能力。这套工具链用熟了,效率和体验都不输给那些昂贵的硬件调试方案。

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

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

立即咨询