调电机 FOC、调通信协议栈、调 FreeRTOS 任务调度的时候最让人抓狂的往往不是 Bug 有多难找而是一暂停就全乱。电流环还在转你刚按下 Stop电机直接过流保护串口刚好收到半包数据停下来一看状态机已经跑到错误分支RTOS 里三个任务抢着跑你想看一眼某个全局变量的变化过程暂停那一瞬间现场早就被调度器改得面目全非。这些场景下暂停程序再看变量这种最常规的调试手段基本等于废了。Keil 里其实藏着一套平时很少被认真对待的调试手段。Command 窗口View → Command Window绝大多数人只用来敲go、halt、load但配合表达式求值、FUNC 脚本、LOG 重定向再加上 ITM/SWO 的 Debug (printf) Viewer完全可以在程序全速飞跑的时候持续打印变量值不用暂停也不用额外占用一路串口。这篇文章我会先讲清楚为什么程序不暂停也能读内存这个底层逻辑然后给出三种从轻到重的实操方案最后把我实际项目中踩过的坑交代一遍。如果你也在搞 STM32 这类 ARM Cortex-M 实时系统的调试这篇应该能帮你省下不少折腾时间。1. 调实时系统最难受的事一暂停Bug 就消失了1.1 三种典型的暂停即翻车现场第一种电机控制。我调试永磁同步电机的 FOC 算法时经常要看speed、iq_ref这些变量在启动瞬间的响应曲线。程序全速跑得好好的一按暂停PWM 输出马上停电流环失去给定值母线电压直接泵升轻则触发硬件过压保护重则烧驱动。你看到的是一个已经跳变到零或者错误饱和值的变量根本没有参考价值。第二种通信协议状态机。比如在调一个自定义的 Modbus 解析器或者 CAN 报文的收发逻辑。程序可能正处在接收一个多字节帧的中间状态你暂停下来想看看当前解析到第几个字节结果调试器一停串口 FIFO 里的数据继续涌入或者超时出错等你看完寄存器回来状态机已经完全不在地图上了。你甚至无法确定这个状态是本来就错的还是被你暂停暂停坏的。第三种RTOS 调度问题。我调过不少 FreeRTOS 项目最怕的就是任务优先级翻转或者某个任务一直得不到执行。你想在任务 A 里设置断点看任务 B 的数据一旦停在断点调度器就停了任务 B 当然不会被调度。你以为发现了 Bug实际上只是你的调试动作本身制造了假象。这三种现场有个共同点程序一停整个实时性就被破坏所有你想观察的动态信息都会失真。这也是为什么暂停调试这种思路在实时系统领域越来越吃不开。1.2 暂停到底动了什么很多人没仔细想过调试器按下暂停之后目标芯片内部到底发生了什么。简单说至少有三件事会变CPU 内核停在当前指令后续代码逻辑完全冻结如果恰好停在临界区恢复运行后可能会死锁。外设模块并不会跟着停定时器、DMA、看门狗、通信外设仍然按自己的时钟跑程序状态和外设状态在暂停期间会产生不可预知的分裂。如果你还开着寄存器窗口自动刷新之类的功能调试器会批量读取内核和外设寄存器这些操作本身也会对总线时序造成扰动。最典型的例子是看门狗。程序暂停了看门狗还在计数几十毫秒后系统复位你的调试现场直接被清空。很多老工程师第一次遇到这个坑时都一脸懵明明只是暂停了一下为什么程序自己重启了。1.3 我们真正需要的调试能力从这些痛点可以总结出实时系统调试真正需要的不是停下来看清楚而是跑起来还能看清楚。具体来说有三个诉求程序照常全速运行不要打断控制环路和通信时序。感兴趣的变量能持续输出最好能看清变化过程和趋势。尽量不额外占用 UART、SPI 这类本来就紧张的外设资源。这就是后面要讲的几种方案要解决的核心问题。2. 不暂停也能读内存靠的是调试接口的旁路通道2.1 调试接口不只是暂停/继续这么简单很多嵌入式初学者对调试器的理解就是能下断点、能单步、能暂停这个理解太浅了。ARM Cortex-M 内核的调试架构叫 CoreSight它的设计思路是让调试访问尽量走独立于 CPU 执行的路径不能一上来就把 CPU 停住。CoreSight 体系里有一个很关键的组件叫 DAPDebug Access Port调试访问端口它分成好几个 APAccess Port访问端口其中负责内存访问的是 AHB-AP。AHB-AP 直接挂在芯片内部的 AHB 总线矩阵上也就是说调试器通过 SWD/JTAG 接口发过来的内存读写请求是直接在总线层面完成的不需要 CPU 内核停下来配合。这个特性就是不暂停程序读变量的物理基础。2.2 AHB-AP 读变量的过程举个例子你在 Keil 的 Command 窗口里敲? speed想知道这个全局变量当前的值。调试器拿到这个请求后要做的事大致是这样通过调试信息里记录的内存地址编译时 DWARF 信息里就有找到变量speed在 SRAM 里的地址。通过 SWD 协议发送一条读内存的事务到 DAP。DAP 里的 AHB-AP 把这条事务翻译成一次 AHB 总线读操作数据返回到调试器。调试器把读到的值格式化显示在 Command 窗口里。整个过程中CPU 内核一直在照常取指、执行指令它甚至感知不到这次访问发生过。就好像你家小区门口有一条独立的快递通道快递员送包裹进来不需要惊动住在楼上的你。2.3 这个方案有什么限制理论归理论实际用的时候有几个边界条件还是要清楚。第一低功耗模式下可能读不到。如果芯片进入了 STOP 模式或者 SRAM 供电被关闭总线访问自然就失败了。比如 STM32 进 Stop 模式后如果你还想靠调试器读变量得先确保调试时钟和 SRAM 电源还在。第二总线繁忙时访问会有延迟。如果 CPU 和 DMA 正在高频访问总线AHB-AP 发起的读请求可能需要等待总线仲裁这时候调试器界面看起来会有点卡但程序本身不会停。第三并非所有调试器都支持运行态内存访问。这个功能在 J-Link、ULINK2、ST-Link 这类主流调试器上基本都支持但个别早期型号或兼容性不好的山寨调试器可能在程序运行时执行内存访问会出现异常。所以如果遇到命令敲下去没反应的情况先换官方调试器试试。3. Command 窗口被低估的玩法表达式查询、FUNC 脚本与 LOG 重定向3.1 全速运行时直接敲? 变量名我先把最简单的操作说出来。程序go起来之后不要让程序停下来直接点一下 Command 窗口在命令行里输入? speed回车窗口里立刻会打印出speed当前的值。你再敲一次又是一个新值因为程序还在跑。连续敲几次你就能看到一个变量的动态变化范围。想看数组里某个元素也行? motor_state[3] ? speed第二个speed是打印变量的内存地址配合 Debug 菜单里的 Memory 窗口可以直接看一片连续内存的原始数据。这个操作我之前给团队里几个人演示过他们的第一反应都是哦真的没暂停。确实是真没暂停程序的控制环路在后台照跑只是调试接口在总线上做了一次快速读操作对程序时序的影响几乎可以忽略。不过这里有一个使用习惯问题。手动敲命令适合偶尔瞥一眼如果你想盯着数据变化看总不能一直手动敲回车。这时候可以上 FUNC 脚本。3.2 用 FUNC 定义自己的轮询脚本Command 窗口其实内置了一个简单的脚本解释器支持用FUNC关键字定义函数。你可以先定义好一段打印脚本然后调用它让变量按你想要的格式连续输出。在 Command 窗口里粘贴这么一段FUNC void Dump(void) { printf(speed%d, iq_ref%d\n, speed, iq_ref); } Dump()回车之后窗口中每隔一会儿就会输出一行带变量名的完整信息。这个printf是 µVision 调试脚本自带的输出位置就是 Command 窗口本身和 C 代码里的 printf 不是同一个东西。如果在支持软件延时的调试环境里还可以写循环加延时FUNC void DumpLoop(void) { int i; for (i 0; i 10; i) { printf(tick%d, adc%d\n, tick, adc_val); swatch(0.1); } } DumpLoop()swatch这个延时函数在 Keil 的模拟器Simulator模式下非常好用硬件调试时部分调试器也支持但我不建议你在硬件上依赖它做长时间轮询原因后面讲。对于硬件板子更好的方案是第五节要讲的 ITM/SWO。3.3 配合 LOG 命令把输出落盘Command 窗口还有一个容易被忽略的命令LOG。它能把 Command 窗口的输出重定向到一个文本文件里。配合 FUNC 脚本就能实现程序不暂停、变量自动记录到文件的效果。操作方式LOG D:\dump.log DumpLoop() LOG OFF执行完D:\dump.log里就保存了刚才脚本输出的所有内容。这在抓取偶发异常时很有用你可以让脚本循环跑长一点时间然后去翻日志文件找规律。不过要说清楚这个方案记录的是调试器通过总线读到的值记录频率受调试器带宽限制数据量大了之后会产生比较大的刷新间隔所以它适合记录低频、缓慢变化的变量比如温度曲线的采样点、电池电压的波动不适合抓高速控制环路的瞬态。3.4 Command 窗口方案的局限变量尽量加volatile。如果变量被编译器优化进寄存器或者被优化掉了调试符还在但你读到的地址可能不对值也可能是存下来的旧值。实际项目中我见过好多次?输出一个奇怪数值最后发现是编译器优化捣的鬼。不要在表达式里做有副作用的操作。比如? i这种看上去只是查个值实际上它真的会往内存里写回自增后的结果极可能把程序逻辑带偏。高频输出不要用这个方案。脚本循环如果跑得太快调试接口会被持续占用反而干扰程序实时性。手动操作适合短时间确认落盘日志适合低频记录高频实时监控必须用下面要讲的硬件级方案。4. 让 Watch 窗口在运行中自己刷新Periodic Window Update4.1 这个选项藏得比较深很多人打开 Watch 窗口添加变量之后发现程序全速跑的时候变量值纹丝不动一定要等程序暂停才会更新于是得出结论Watch 窗口必须暂停才能看变量。其实 Keil 给过你开关只是藏得有点深。路径是Options for Target → Debug 选项卡 → 右侧调试器设置区域往下找有一个复选框叫Periodic Window Update。勾上它程序全速运行的时候Watch 窗口会通过调试器周期性刷新目标变量的值。注意这个选项不是默认开启的很多项目的默认配置里它就没勾。这也是为什么很多人用了几年 Keil 都不知道还有这功能。4.2 勾选之后的效果勾选后把speed、adc_val这些变量添加到 Watch1 窗口全速运行你会看到变量值在自动变化。数组变量特别直观你可以展开看整个数组在运行过程中每个元素的变化这在分析数据缓冲区、环形队列这类数据结构时非常有用。结构体变量也可以展开看成员比如电机控制代码里的Motor_TypeDef motor在 Watch 窗口里展开能看到它的speed、current、position等成员在实时跳动。这个体验比在 Command 窗口敲?更接近仪表盘的感觉。4.3 代价周期刷新会干扰时序但是天下没有免费的午餐。Periodic Window Update 的原理是调试器周期性发起内存读事务每次刷新都会占用一点总线带宽和调试时钟周期。如果你的程序本来就对时序极其敏感比如 20kHz 电流环或者高速 DMA 传输这个刷新动作可能会插入总线仲裁竞争带来几微秒的抖动。我在一个采样率为 1kHz 的音频板上试过打开 Periodic Window Update 之后I2S 的数据传输偶尔会出现毛刺关掉之后立刻恢复正常。所以这个方案适合快速观察一下变量的变化趋势不适合长时间挂在上面调实时性要求很高的程序。我的建议是平时保持关闭需要观察变化趋势时临时打开看完马上关掉。这个开关应该在调试效率和控制时序之间做好平衡。5. 真正的零暂停打印ITM/SWO Debug (printf) Viewer5.1 ITM 打印和 printf 重定向的原理如果说前面两种方案是调试器主动来读那 ITM/SWO 这套方案就是程序主动往外发而且发送过程几乎不占用 CPU 时间也不需要 UART。ITMInstrumentation Trace Macrocell仪器化跟踪宏单元是 Cortex-M 内核里的一个调试单元。它内部有多个 32 位的 stimulus port程序可以往这些端口写数据写操作本质是一次内存映射的寄存器写入速度极快。ITM 模块拿到数据后会通过 SWO 引脚SWV 调试接口的一部分按照一定协议把数据串行发出来。Keil 的 Debug (printf) Viewer 窗口负责接收并显示这些数据。这个方案最关键的地方在于CPU 只是往寄存器里扔数据不需要像 UART 一样等待一个字节一个字节地移位发送所以即使你在中断服务函数里调用printf也很难拖垮有严格时间要求的代码。而且 SWO 是调试接口的独立引脚不影响 SWD 的调试通信相当于下载和监控同时进行。5.2 硬件连线和 Keil 配置先说硬件。SWO 引脚在不同芯片上位置不一样常见的是叫TRACESWO或SWO在 STM32 上通常复用自PB3。你需要把这个引脚连到调试器的 SWO 输入端J-Link、ST-Link V2、ULINK2 都有这个引脚。配置步骤打开 Options for Target → Debug确认当前用的是支持 SWO 的调试器点右边的 Settings 按钮。在调试器设置界面里找到 Trace 选项卡有些界面叫 Trace/SWV打开 Trace Enable。填写 Core Clock这个必须填芯片的实际运行主频比如 STM32F407 跑 168MHz就填 168填错会导致 SWO 波特率计算不准输出乱码。勾选 ITM Stimulus Port 0这个端口就是给printf用的。SWO 频率可以选 Auto但如果输出不稳定建议手动选择比如 2MHz 或 4MHz具体要看调试器支持范围。如果你用的是 STM32F1 系列还有一个额外步骤。F1 的 PB3 默认被 JTAG 功能占用而GPIO_Remap_SWJ_JTAGDisable这个重映射函数可以把 JTAG 关掉只保留 SWD从而释放 PB3 给 SWO 使用。初始化代码里加一句GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);不释放 PB3 的话就算你硬件连线接对了SWO 也出不了数据。5.3 代码里重定向 printf打开 Options for Target → Target 选项卡勾选 Use MicroLIB这样能省下大量标准库资源也简化了fputc的重定向逻辑。然后在代码里重定向#include stdio.h int fputc(int ch, FILE *f) { // 检查 ITM 端口 0 是否可写避免未连接调试器时卡死 if (ITM-PORT[0].u32 1UL) { ITM-PORT[0].u8 (uint8_t)ch; } return ch; }之后你在任何地方调用printf数据就会通过 SWO 飞到 Keil 的 Debug (printf) Viewer 窗口。打开方式View → Serial Windows → Debug (printf) Viewer。程序全速跑起来打印内容实时刷新不暂停、不占串口。如果你想把printf既重定向到串口又输出到 ITM可以在fputc里同时扔给 UART 和 ITM实测完全可以只是要注意两边都完成后才算一次输出会稍微增加一点延迟。5.4 常见翻车现场这个方案我用过很多次每次分享出去都会有人遇到下面几个坑我一起列出来排雷。打开 Debug (printf) Viewer 后没有任何输出。最常见的三个原因Trace Enable 没勾、Core Clock 填错、SWO 线没接。按顺序排查基本每次都能解决。输出全是乱码。Core Clock 不对或者 SWO 速率不匹配。改成手动设置把速率和调试器匹配起来乱码立即消失。程序卡死在fputc里。如果你用了while (ITM-PORT[0].u32 1UL 0);这种死等写法当 SWO 没连接调试器时ITM 端口状态永远不对程序就会卡住。一定要用上面那种先判断可写再写的写法。打印几次之后程序卡死。可能是 ITM 缓冲被填满而调试器那边接收不够快。解决办法是降低打印频率或者缩短字符串长度。引脚冲突。PB3 被复用成普通 GPIO、定时器通道等外设功能后SWO 就废了。检查芯片手册上的TRACESWO引脚定义必要时做引脚重映射。6. 三种方案怎么选方案本身没有绝对好坏关键是看使用场景。我整理了一张对比表方便你决策。方案程序是否真正不暂停对程序时序影响需要改代码吗额外硬件需求适合场景Command 窗口?表达式基本不暂停很小占用少量总线带宽不需要无任何调试器都行偶尔手动确认某个变量当前值FUNC 脚本 LOG 落盘基本不暂停脚本跑快了会增加总线负载不需要无短时间记录低频变化量配合离线分析Periodic Window Update Watch不暂停周期刷新会带来明显总线占用不需要无快速观察数组、结构体变量的变化趋势ITM/SWO Debug (printf) Viewer不暂停极小程序只需做寄存器写入需要重定向fputc需要 SWO 引脚连线且调试器支持高频、长期、实时的变量打印输出我的选择习惯是这样能提前预判变量范围的话我一般直接上 ITM/SWO因为它输出稳定、对时序影响最小。如果只是临时想确认一个变量的值懒得改代码就用?表达式查一下。要在电路板上调试数组缓存这种比较大块的数据我会临时开一下 Periodic Window Update确认完马上关掉。顺带说一句Keil 里其实还有 Event Recorder 组件它走的是 RTE 框架也能利用 ITM 做带时间戳的事件记录还可以配合 RTOS 分析任务调度。如果你的项目已经用了 CMSIS-RTOS这个组件值得试试。不过它配置门槛相对高普通 LED 点灯、呼吸灯级别的调试需求不需要用到它。7. 我在实际项目里摸索出的几条经验7.1 变量要加 volatile不加会读到假值这个坑我踩过不止一次。编译器优化开 -O2 之后一个变量可能被放在寄存器里不写回内存或者被改成短命生命周期优化掉。你在调试器里看到的地址读出来可能只是某个历史快照不是真实值。所以关键调试变量我建议直接在定义处加volatile。如果不想改全局代码可以用指针绕过优化比如定义一个新变量指向目标地址强制按地址读取。但最粗暴有效的还是volatile没有之一。7.2 打印频率一定要做分频控制用 ITM 打印虽然开销小也不是零开销。如果在高频中断里每个循环都printfITM 缓冲和 SWO 传输速率迟早扛不住。我一般这样做if ((tick % 1000) 0) { printf(adc%d, speed%d\n, adc_val, speed); }这样把打印频率分摊到毫秒级既能看到变化趋势又不至于打爆 SWO。7.3 ITM 端口不只是给 printf 用ITM 有 32 个 stimulus portKeil 默认把端口 0 留给printf其他端口可以用来做更灵活的事件标记。比如你可以在中断入口用ITM_SendChar配合端口号或者直接写寄存器标记事件时刻然后用逻辑分析仪窗口观察时间线。这个玩法比我一开始只拿它打printf高级得多适合分析任务切换时序这类问题。7.4 调试器实时功能影响时序时先全部关掉有一次我调一个 PWM 输出占空比波形总是不稳定查了半天硬件都正常。最后怀疑到调试器头上把所有实时刷新功能全部关掉Periodic Window Update、Trace、Watch 自动更新全关。波形立刻干净了。从那以后我养成了一个习惯遇到诡异时序问题先排除调试器带来的干扰再怀疑程序和电路。7.5 日志落盘用 Command 窗口的 LOG适合事后分析很多人在线盯屏看变量但偶发问题不是盯就能盯出来的。我用 Command 窗口的LOG重定向到文件之后让程序跑一个晚上第二天翻日志找异常点效率比实时盯屏高得多。值得提醒的是日志文件里记录的是调试器通过总线读到的值所以采样率不要设太高否则文件会大到打开都很困难。7.6 多方案结合是最终解法实际项目中我不会只用某一种调试手段。ITM/SWO 用来输出关键控制状态量Watch 窗口临时观察结构体数组Command 窗口处理偶尔的快速查询LOG 做离线记录各干各的活。比如调伺服驱动时我用 ITM 打印速度环的给定和反馈用 Watch 观察 PI 调节器的积分项用 Command 窗口临时查一下某个故障码几套配合下来调试效率比纯靠暂停和断点高了一个量级。如果你也正在被暂停就崩的实时性调试困扰建议从 ITM/SWO 这套方案入手。把 SWO 引脚连上Trace 配置好fputc重定向写好后面所有带实时性质的调试工作都会顺畅很多。这大概是我在 Keil 调试器里觉得最值得投入时间搞定的一项隐藏功能。