ARTICLE DETAIL

资讯详情

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

Keil软件仿真下printf输出配置与重定向实战

Keil软件仿真下printf输出配置与重定向实战 玩嵌入式的人十有八九都是printf走天下我也不例外。写STM32代码时随手printf一下变量、状态、运行路径比用调试器一步步点寄存器痛快多了。但很多人不知道就算手头没有开发板、没有ST-Link只要电脑装了Keil照样能在软件debug模式下把printf输出看得清清楚楚。这里的软件debug指的是Keil自带的Simulator软件仿真完全不用接硬件靠PC模拟一颗ARM芯片跑程序。可不少人勾选了Use Simulator也写了printf却怎么都看不到输出。这篇文章就专门解决这件事从原理到实操全部走一遍。1. 软件debug是个啥为啥要在这里面看printf1.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) Viewer2.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 为什么必须重定向printfC标准库的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已经被正确初始化、时钟已被使能。如果代码在串口初始化之前就执行了printffputc会去读USART1-SR寄存器此时寄存器的值可能是未定义的随机值。最典型的情况是TXE位一直不为1while循环永远循环下去仿真直接卡死。加上超时保护后就算初始化顺序有问题程序也不会无休止地卡在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函数入口处停下。这两项都勾上之后你按CtrlF5程序就直接停在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)”。接下来按CtrlF5启动调试。程序会停在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 #1Viewer空白且程序卡死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 Hardware4.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字节序列存进FlashViewer窗口按同样的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写成了9600Viewer里照样能正常显示字符串。但如果你在仿真里加入了延时函数比如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重定向到UART1Debug (printf) Viewer在仿真的场景下使用硬件调试时我直接把PA9接到USB转串口工具上用PC串口助手看输出。这样仿真和硬件两套环境代码层面完全不用动只需要在调试模式上切换一下就够。说到底printf走天下这句话在嵌入式开发里确实不虚但关键在于让它“通”到你能看到的地方。Keil的软件debug模式加上正确配置的串口重定向能让你在没有开发板的环境下也拥有完整的日志调试能力。这套工具链用熟了效率和体验都不输给那些昂贵的硬件调试方案。
返回列表