ARTICLE DETAIL

资讯详情

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

Keil调试实战指南:优化等级、断点、堆栈与变量排查技巧

Keil调试实战指南:优化等级、断点、堆栈与变量排查技巧 Keil调试功能用得好不好很大程度上决定了嵌入式开发效率高不高。很多朋友已经会用Keil写代码、编译、下载但一进Debug模式就不知道怎么下手偶尔遇到个变量不刷新、结构体看不到、栈溢出死机之类的问题只能干瞪眼改代码碰运气。我调了一段时间的STM32之后回头看自己踩过的坑发现大多数所谓难查的Bug其实根源都出在几个固定的地方编译器优化搞的鬼、调试窗口没看对、堆栈污染、以及仿真器和目标板的连接配置不对。这篇笔记就把我在Keil里做程序调试时积累的经验做一个系统性的梳理希望能帮你少走点弯路。1. 开始调试之前先把工程配置和硬件连接查一遍1.1 编译器优化等级为何-O0和-O3调试结果天差地别很多人第一次进Keil的Debug模式会遇到一个特别迷惑的现象程序在某种情况下工作正常换一个优化等级就彻底不工作了或者明明代码里写了某个变量Watch窗口里却看不到它。先说结论调试阶段强烈建议把优化等级设为-O0。打开Options for Target - C/C选项卡Optimization这一级默认通常是Level 2-O2。对于嵌入式开发来说-O2在很多情况下会做这些事情把局部变量优化到寄存器里导致Watch窗口无法读取把只改变一次的值直接替换成常量导致你读到的值和代码逻辑对不上改变判断条件的执行顺序让单步跟踪的顺序和源码不一致我之前碰到过一个很典型的例子一段按键去抖逻辑-O0下运行完全正常一改成-O2就偶尔失灵。排查到最后发现是编译器把两次读取寄存器的操作合并了导致按键抖动状态没有被正确更新。这种问题用-O0调试根本复现不了只有在优化等级更高的发布版本里才偶尔出现。所以我的习惯是调试代码用-O0发布版再用-O2或-Os。如果你必须在优化模式下调试比如复现某个只在发布版才出现的Bug那至少要做好两个准备一是某些变量会被优化掉需要加volatile或在调试时强制查看内存地址二是单步跟踪时看到的汇编可能和源码顺序不一致要结合Disassembly窗口看。另外提醒一下在C/C选项卡里一定要勾选Debug Information否则调试器拿不到符号表和源码行号断点都打不上。1.2 Debug选项卡软件仿真与硬件调试的选择Options for Target - Debug选项卡里左边是Use Simulator软件仿真右边是Use Debugger硬件调试。这个选项非常关键因为它直接决定了你按CtrlF5的时候Keil是去模拟一个虚拟的MCU环境还是去连接实实在在的开发板。软件仿真的特点是不需要开发板、不需要仿真器Keil用PC的算力模拟CPU指令执行和外设寄存器行为。适合做纯算法逻辑的验证比如PID计算、CRC校验、解析协议帧、状态机逻辑等等。它的优点是方便但缺点也很明显——模拟器对UART、I2C、SPI、定时器这类外设的模拟并不完全符合真实硬件时序尤其是中断嵌套、DMA和一些带电气特性的外围仿真结果只能作为参考不能替代实测。硬件调试就是连接ST-Link、J-Link、CMSIS-DAP这类仿真器直接操作目标芯片。这个模式接近真实运行环境也是大部分情况下推荐使用的调试方式。进入硬件调试前Debug选项卡右边要选对调试器。下拉列表里选ST-Link Debugger或J-LINK/J-TRACE然后点旁边的Settings这里有几个容易踩坑的细节Port要选对SW或JTAG。现代STM32基本都走SWD四根线SWDIO、SWCLK、GND、VCC就能调试省IO口。Max Clock不建议一味拉高。连不上或者不稳定时把速率降到1MHz甚至更低试试。很多连接不上的问题都是速率太高导致的。Flash Download选项卡里要勾选Reset and Run这样下载完程序后芯片会自动复位运行不然每次下载完还得手动按复位键次数多了真的很烦。我见过不少人的板子其实能正常下载但每次下载完都没有Reset and Run导致看上去程序没跑起来然后开始怀疑代码。这类问题属于配置层面的假故障排查顺序应该放在最前面。1.3 下载与调试的关联配置Flash Download 等选项Flash Download选项卡我觉得有必要单独说。它解决的核心问题是目标芯片里的Flash算法对不对、下载地址对不对、有没有擦除和编程权限。Programming Algorithm根据具体芯片型号选择比如STM32F103C8T6选STM32F10x Med-density Flash。Erase Full Chip和Erase Sectors的区别全片擦除慢但干净扇区擦除快日常调试够用。如果遇到擦除失败或下载失败可以先试试Erase Full Chip。Reset and Run勾选后下载完成自动复位运行。还有一个容易被忽略的点如果目标板上有外部看门狗硬件看门狗芯片在调试时可能频繁复位目标板导致断点根本停不下来。这时可以考虑在Debug模式下通过仿真器禁用看门狗或者在调试时先把看门狗相关代码注释调。属于工程调试常见的环境干扰问题。2. 看懂Keil的调试窗口寄存器、Watch、Memory与Logic Analyzer2.1 寄存器窗口和Peripherals窗口最直接的硬件观察入口进入Debug模式后View - Registers Window能打开寄存器窗口显示CPU内核寄存器R0-R15、PSR、SP、LR、PC等。这个窗口虽然直观但对新手来说信息量大且抽象很多人看了一眼就关了。其实寄存器窗口在调试汇编级问题是不可替代的。比如你怀疑某个变量被异常改写可以结合内存断点找出写入者这时寄存器窗口能看到发生写入时的指令上下文。不过大多数应用层调试场景我更推荐配套使用Peripherals窗口View - Peripherals Window - System Viewer里面按外设分类列出了GPIO、USART、TIM、RCC等寄存器的当前值和位域状态。它最大的价值是把寄存器的每一位都解析成易懂的描述比如GPIOA-ODR的第5位是1窗口直接显示Pin 5: High。这对排查外设配置是否生效非常直观。举个使用场景你配置完UART之后数据发不出去第一步不是反复调代码而是打开USART外设窗口看UE位UART使能是不是1、TXE位是否置位、BRR寄存器算出来的波特率和预期是否一致。一旦发现这些状态不对问题直接定位到寄存器配置层面比盲改代码快得多。2.2 Watch窗口中结构体变量不显示的常见原因调试助手里面的Debug模式如何显示结构体变量这个问题在社区被问得挺多。很多人进入Debug模式后把一个结构体变量拖到Watch窗口结果发现要么不显示、要么显示not in current scope、要么所有成员值都是问号。这些现象的常见原因有三个第一作用域不对。Watch窗口只对当前执行点所在作用域的变量有有效读取。如果你的程序停在main函数里的某一行那么其他函数内部的局部结构体自然不在当前作用域显示不了。解决方法是把断点刚好停到该结构体所在函数内部或者在函数入口打断点然后Step Over进入。第二优化等级把结构体优化没了。-O2及以上等级下编译器可能把结构体整体放到寄存器里或者干脆内联展开不申请内存空间。这种时候Watch窗口自然看不到。建议调试时至少把优化降到-O0。第三改变了变量视图格式。在Watch窗口选中变量右键选择Format可以切换十六进制、十进制、浮点等显示方式。如果结构体里包含uint8_t数组默认按字节显示还好如果是指针成员可能显示的是地址而不是指向的值。注意展开数组和指针时点开前面的三角形展开树形节点。另外有个小技巧在Watch窗口输入表达式*(MyStruct*)0x20000000这种形式就能直接访问指定内存地址的结构体。比如你知道某个结构体存放在了RAM的0x20000000地址即使它不在当前作用域也能通过这种方式查看到。这在实际排查野指针问题时非常实用。2.3 Memory窗口查数组、查缓冲区、查栈区View - Memory Windows打开后输入起始地址就能查看对应内存区域的字节分布。比如查0x20000000就是RAM起始区域。这个窗口的核心用途有三大类查数组越界定义一个全局数组buf[64]如果怀疑某处写入越界可以在Memory窗口输入buf[0]然后把相邻地址范围内的数据以Hex和ASCII两种方式显示搜索是否有异常数据被写入到buf[64]之后的位置。查缓冲区内容比如串口收到的数据包接收放在rx_buf[100]里Memory窗口输入rx_buf地址就能直观看到每一帧数据在内存里的排列情况排查协议解析的字节序和错位问题。查栈区使用输入栈顶地址通常是SRAM结束地址往下偏移查看栈空间中有多少字节被写入了数据能判断栈使用深度。具体我会在堆栈排查部分专门讲。Memory窗口的显示格式可以在右键菜单里切换1字节、2字节、4字节宽度以及大端还是小端。默认小端模式对STM32这类小端MCU直接看默认格式就行。2.4 逻辑分析仪Logic Analyzer的另类用法Keil MDK自带一个Logic Analyzer窗口在Debug模式下View - Analysis Windows - Logic Analyzer Window。它本质是一个简易的软件逻辑分析仪可以跟踪变量值和某些外设信号的变化不需要外部硬件。很多人不知道它能显示变量只知道波形图。实际上可以通过右上角Setup按钮添加变量或表达式比如添加GPIOA-ODR、某个全局变量、某个计时器计数器的值然后程序全速运行时窗口会实时画出这些信号随时间变化的波形。我调试PWM和传感器数据输出时经常用它不需要把数据通过串口打出来直接用Logic Analyzer观察一个标志位在若干毫秒内是被谁翻转的、翻转频率是否符合预期。这在分析中断触发频率、延时误差、状态机切换时序时特别有效。不过要注意它跟踪的变量如果被编译器优化到寄存器里也是无法显示的所以还是那句调试阶段关闭优化等级。3. 有效使用断点和单步中断、条件断点与调试节奏3.1 软件断点和硬件断点的区别为什么硬件断点数量有限Keil的断点分两种软件断点和硬件断点。软件断点通过在Flash代码中临时插入一条特殊的断点指令如BKPT实现理论数量没有限制只要Flash可写就能设置。但它有个缺点如果代码被放在写保护的Flash区或只读存储区就没法用软件断点。另外在RTOS环境下软件断点对运行时间的影响相对较大。硬件断点则使用芯片内部的调试寄存器例如Cortex-M内核的FPB单元实现不需要改写Flash代码可以在Flash还是只读的情况下使用。但硬件断点数量很有限Cortex-M通常只有4到8个。设置过多断点时会弹提示让你关掉一些再继续。实际调试中我一般遵循这个策略普通代码调试用软件断点就好数量多、灵活遇到Flash写保护或代码在ROM里执行时改用硬件断点在RTOS的任务调度代码里硬件断点往往更可靠因为软件断点插入的指令可能被任务切换过程意外跳过。Keil的Breakpoint窗口View - Breakpoints能看到当前所有断点右键可以禁用、启用、删除也可以在特定断点上设置条件。3.2 条件断点在特定次数或特定数值时停下条件断点是我最常用的调试手段之一尤其适合只在特定条件下才发生的Bug。比如串口收数据只有接收到帧头0xAA、帧尾0x55并且长度字段为85的时候才会进入处理流程平时倒不会出问题。那你直接在包解析函数入口打个断点然后右键选Breakpoint Properties在Expression里写上条件表达式比如len 85或rx_state 2程序只有满足这个条件才会停下来。这样就不用每条数据都打断点了。条件断点还有Count功能比如Count 100表示要命中100次才停下来。调试循环时特别有用比如i 99这种场景。有一点要注意条件断点在硬件断点上实现时每个条件断点会占用一个硬件断点资源数量有限。如果同时需要多个条件断点建议在软件断点模式下使用。还有个小经验条件断点里的表达式如果比较复杂比如涉及函数调用会显著拖慢程序运行速度。尽量用简单的寄存器值和变量值做条件不要把耗时的计算塞进去。3.3 单步执行三类操作的适用选择单步调试的按钮在Debug工具栏上Step IntoF11、Step OverF10、Step OutCtrlF11。三个操作的区别Step Into会进入被调用的函数内部Step Over执行完整个函数但不进入内部Step Out则从当前函数直接跳出回到调用处。实际使用中我经常在刚开始进入一个陌生函数时用Step Into看看它的实现逻辑确认函数没问题后改用Step Over快速跳过一步步看外部流程。在某个深层嵌套的函数里调试到底需要快速回到外面调用处时直接用Step Out。有个细节跳过中断时单步表现比较特殊。如果程序停在主循环的某一行此时一个中断触发你按单步执行可能不会看到中断服务函数被执行因为单步通常不会追踪异步中断的进入。要调中断逻辑直接在ISR入口打一个断点然后让程序全速运行触发中断等它停到断点处再单步。这个习惯能省很多时间进入调试后先规划调试路径。是从入口开始单步走一遍主流程还是设置了条件断点直接全速跑等Bug条件出现再停下来两者的调试效率差别非常大。4. 堆栈与变量异常排查从死机到神秘篡改4.1 栈溢出引发的灵灵异事件嵌入式调试中栈溢出是最难排查的问题之一因为它的症状极其诡诈程序随机死机、变量值凭空被改、函数返回地址错乱、中断莫名其妙跑飞……而且每次复现的位置还不一样。先搞清楚栈是什么栈是RAM中的一块区域用来保存函数调用时的局部变量、参数和返回地址。栈指针SP指向栈顶每次函数调用压栈、返回弹栈。一旦栈使用超过了预设的栈空间大小就会溢出到相邻内存区域把别人的数据踩掉。Keil工程里栈的大小在启动文件中有定义比如startup_stm32f10x_md.s里的Stack_Size EQU 0x400也就是1KB。如果工程用了RTOS或者有深层函数嵌套1KB的栈很容易不够。怎么判断是不是栈溢出有几个实用的检查方法观察栈指针在Debug模式下寄存器窗口看SP的值。如果SP的值已经接近栈底栈区间的最低地址说明栈用得差不多了。末尾填充标记法这是我很推荐的做法。定义一个带初值的数组来模拟栈全填0xCC然后在程序里周期性检查栈空间的末尾标记是否被改写。具体做法是在启动代码里把整个栈区初始化为0xCCKeil的启动文件默认会初始化栈区的某些部分可以自己加一段填充跑一段时间后暂停程序在Memory窗口查看栈区地址范围看末尾标记有没有被破坏。HardFault定位如果程序已经出现HardFault可以打开Peripherals - Core Peripherals - Fault Reports看是否有Stack Overflow标志同时查看PC和LR寄存器的值定位到出错前最后执行的函数。栈溢出最常见的两个来源中断服务函数里定义的局部变量过大。ISR用的是同一个栈空间如果ISR里定义一个很大的局部数组比如uint8_t buf[1024]而栈总共才1KB那中断一进来栈就爆了。所以大的缓冲区尽量不要放在ISR的局部变量里改用全局静态变量或者加大栈空间。递归调用虽然少但一旦出现在嵌入式里就是灾难。嵌入式开发基本要避免递归因为栈空间本来就有限。如果确认是栈溢出解决方案有三条路一是把大局部变量改成全局变量或static变量不占栈二是调整启动文件里的Stack_Size三是如果是RTOS环境要注意任务栈的大小FreeRTOS里每个任务都有自己的任务栈任务函数里的大数组也要谨慎。4.2 变量被优化掉后该怎么观察前面讲优化等级时提过编译器-O2以上可能把变量优化掉导致Watch窗口看不到。但有时候你不得不调试一个-O2下才能复现的Bug这时该怎么办几个办法给关键变量加volatile。volatile告诉编译器这个变量可能被外部因素修改不要优化掉。对于中断服务函数和主循环共享的变量、寄存器映射变量、多任务共享变量加上volatile是必须的。但如果只为了调试加volatile调完记得评估是否删除。通过Memory窗口固定地址查看。如果变量是全局的并且你知道它的地址可以直接在Memory窗口输入地址查看。比如全局变量g_tick在链接后地址是0x20000034即使Watch窗口不刷新Memory窗口还是能看到那个地址里数值的变化。利用Watch窗口输入表达式。某些情况下Watch窗口支持输入“(volatile unsigned long)0x20000034”这样的表达式强行按地址读取。如果你知道变量的存储位置这种办法能够绕过优化带来的符号丢失问题。查看反汇编窗口。Optimization为-O2时源码行号和汇编可能对不上但Disassembly窗口能准确显示当前执行的汇编指令。通过分析指令能看出编译器是不是真的把某个变量优化成了常量。从汇编层面定位优化问题的方法是嵌入式调试的高级能力遇到顽固问题很有用。4.3 中断与主循环共享变量volatile和临界区的处理这个问题的典型场景主循环里判断一个标志位中断里置位该标志。结果主循环判断不到或者标志位被读成了0和1之间的混乱值先说volatile的必要性。如果一个变量只在主循环里被读、从来不去写它除了中断里写编译器在不了解中断机制的情况下可能会优化成“每次从同一个寄存器读”或“干脆直接替换成常量”。这就导致主循环根本拿不到中断更新的值。加上volatile后编译器每次从内存地址重新读取问题就解决了。但volatile解决不了多字节变量的原子性问题。比如一个32位变量在32位MCU上读取一般是原子的但如果变量是结构体或数组中断和主循环同时访问就可能出现撕裂读写。这个时候需要临界区或关中断来保证访问的原子性。Cortex-M上可以用__disable_irq()/__enable_irq()RTOS里可以用taskENTER_CRITICAL()。调试这类问题最有效的工具是断点加Watch窗口的组合在中断里修改变量的位置设个断点在主循环判断的位置设个断点观察变量在两种上下文里的值变化确认是否存在竞争条件。5. 几个常见报错与异常现象的排查链路5.1 No ULINK Device Found 排查这个报错在Keil MDK里很常见。看到它首先要区分是设备没连上还是调试器驱动/Keil配置问题。排查链路如下检查硬件连接仿真器的SWDIO、SWCLK、GND、VCC四根线是否都接对了。尤其是VCC有些仿真器需要从目标板取参考电平没接VCC会导致检测不到目标芯片。检查仿真器状态灯ST-Link和J-Link都有状态指示灯。如果灯不亮检查USB口供电如果灯亮但检测不到目标多半是目标芯片没上电或复位脚被拉死了。降低调试时钟频率在Debug选项卡的Settings里把Max Clock从默认的几MHz降到几百kHz很多时候是高频干扰导致通信失败。确认驱动正常在设备管理器里看仿真器是否枚举为正常设备。如果设备上有感叹号重装仿真器驱动。确认目标板供电正常很多目标板从仿真器取电时电流不够尤其带LCD或传感器负载时电压跌落会导致芯片进入欠压复位状态根本连不上。确认没有其他工具占用如果之前用别的串口工具、烧录工具打开了同一个调试器接口Keil可能访问不到设备。还有一个隐蔽原因芯片的SWD引脚被复用为GPIO程序跑起来后把调试口关了。这种情况下把BOOT0拉高进入系统存储器模式再连接调试器擦除程序恢复SWD功能。这个操作在开发阶段遇到过好多次值得记住。5.2 debug模式下结构体变量显示不出来的完整处理链路上面2.2节讲了一部分原因这里把完整的排查链路列一遍第一步确认程序停在了正确的位置。如果程序在全速运行Watch窗口不一定实时刷新。先把程序暂停按停止按钮再看Watch窗口。第二步确认变量在当前作用域。把鼠标悬停在代码里的结构体变量上如果提示“not in current scope”说明当前执行点不在该变量的可见范围内。要么把断点移到使用该变量的代码行要么用表达式强制指定内存地址。第三步确认变量没有被优化掉。查看优化等级调试阶段用-O0。如果不想全局改可以在单个函数前面用__attribute__((optimize(O0)))单独临时关闭优化。第四步看编译器的符号表是否完整。在C/C选项卡里勾选Debug Information重新编译再调试。第五步如果你用的是软件仿真模式有些外设相关的变量在仿真模式下读取值异常建议切到硬件调试验证。这套链路其实适用于“任何变量显示不出来”的问题不只是结构体。记住顺序暂停→作用域→优化→调试符号→仿真/硬件差异。5.3 浮点打印相关R6002问题的处理Error R6002对应的信息是floating point support not loaded通常出现在你使用printf输出浮点数%f格式时。Keil的默认C运行库在设计上出于代码体积考虑默认不加载浮点打印支持当你调用printf(%f, ...)时就触发这个错误。解决的办法比较直接勾选Use MicroLIB在Options for Target - Target选项卡右下角勾选Use MicroLIB。MicroLIB是Keil提供的一个精简C运行库它默认支持浮点printf同时体积更小。缺点是某些全功能C库的接口支持不完整一般情况下够用。避免直接printf浮点数把浮点数拆成整数部分和小数部分分别输出或者用sprintf格式化到字符串buffer再输出。这样不依赖浮点打印库的支持。检查启动代码如果用的是标准C库需要确保启动文件里正确初始化浮点环境。有些裁剪过的工程启动文件缺少FPU初始化也会导致浮点相关报错。Cortex-M4F/M7这种带FPU的内核还要确认编译选项里FPU正确开启。顺便说一句如果你用printf调试时总觉得打印出来的浮点数不对比如2.50打印成2.499999这是浮点表示法的正常现象不要以为是Bug根据精度需求调整格式化字符串就行。5.4 调试中断言失败和HardFault的初步定位断言失败和HardFault是嵌入式调试里最难啃的骨头。我的定位套路一般是这样的查看Fault ReportsDebug状态下打开Peripherals - Core Peripherals - Fault Reports能看到上次发生HardFault时CPU各状态位的值。重点关注是否有总线错误BUSFAULT、内存管理错误MMFAULT、未定义指令UNDEFINSTR。查看LR寄存器的值在Cortex-M中LR寄存器R14在异常返回时会携带EXC_RETURN值通过它判断是从线程模式还是处理模式返回也能间接判断中断上下文。查看栈上的PC和LRHardFault发生时被打断的现场会被压入栈中。在Memory窗口查看SP寄存器指向的栈顶区域按照Cortex-M的异常栈帧布局R0、R1、R2、R3、R12、LR、PC、xPSR找到PC值该值基本就是出错的指令地址。在HardFault_Handler里打断点让程序全速运行触发HardFault后停在断点处再通过上面的方式回溯。对于大型工程我建议在启动文件或调试代码里加一个“故障打印”HardFault发生时把PC、LR、栈指针等关键值记录下来通过串口或日志输出。这样脱离调试器也能拿到关键线索。最后一个我认为最有用的调试小习惯写了这么多最后分享一个我实际用的最多的习惯给工程里的关键函数都加好输入输出检查点。这里的检查点不是print而是一个全局的调试结构体专门记录每个函数的进入次数、退出码、关键参数抽样值。调试时用Watch窗口和Memory窗口直接查看这个结构体配合Logic Analyzer观察标志位变化趋势往往一次就能定位问题在哪一层。Keil的调试功能不是万能的但它把寄存器、内存、外设、堆栈、断点这些硬核要素都整合到了一个界面里。熟练之后你会发现排查问题不再是盲猜-改代码-重烧-验证的死循环而是有章法的系统工程排查。希望这篇笔记能帮助你把Keil调试用得更顺手。
返回列表