用调试器 halt 看 MCU,为什么会看到「系统死了」?

halt 会一起冻结内核、定时器计数与 DMA 请求链:GPIO 停止更新、周期计数不再增长,而且 halt 后读到的外设寄存器常是 0 或旧值。改用 RTT、DTCM 环形缓冲、GPIO 心跳三种非侵入手段,才能看到真实状态。

因为 halt 停的不只是 CPU。定时器计数、DMA 请求链、以及由 DMA 搬运的输出更新,全都依赖内核继续运行;内核一停,整条链路断在半路。此时你在调试器里看到的 GPIO 输出寄存器、DMA 使能位,往往已经是「被冻住的中间态」——用它判断系统死活,结论一定是错的。

现象

一套 100 μs 周期的控制固件,正常运行时有三个可观察量:周期计数在增长、GPIO 每周期翻转、DMA 每周期搬运一次。用调试器 halt 之后:

观察点 halt 后看到 直觉结论 实际含义
周期计数(样本数) 不再增长 引擎挂了 内核停了,当然不再计数
GPIO 输出寄存器 读回 0 或旧值 输出断了 halt 改变了外设状态,读数不可信
DMA 使能位 有时读出 0 DMA 配置被破坏 冻结瞬间的中间状态
定时器计数寄存器 不再变化 定时器坏了 定时器被调试器一起停了

四个现象指向同一个原因,很容易被误判成「四个独立故障」。

原因

三板斧,逐层递进。

1. 内核停 → 定时器停。 调试器 halt 会停住内核时钟域,依赖它推进的定时器计数随之停止。没有计数更新,就没有比较匹配事件。

2. 没有匹配事件 → 没有 DMA 请求。 输出更新链是「定时器比较匹配 → 触发 DMA → 搬影子缓冲到输出寄存器」。这条链的第一环断了,后面全不发生。

3. 调试访问本身会干扰总线。 用调试器高频轮询读内存或外设寄存器,会占用总线并插入访问周期;即使不 halt,被测系统的时序也已经变了。所以「用调试器实时看」这个做法,本质上是在测量一个被自己改变了的系统。

一句话:观测手段的侵入性,决定了观测结果的可信度。 想知道系统在真实运行时是什么状态,就不能让它停下来,也不能频繁打断它。

怎么做

三种非侵入手段,按推荐顺序排列。

① RTT:内存读写,不打断内核。

固件在 RAM 里放一个控制块,通过它往环形缓冲写日志;主机侧只读这块内存,不需要 halt。

# -a 是搜索起点,-s 是搜索长度,两者共同覆盖控制块地址
py -3 -m pyocd rtt -t stm32h723xx -a 0x20008000 -s 0x1000

这里有一个常被误解的地方:控制块在 0x20008800,命令却写 0x20008000。两个地址不矛盾——-a 给的是搜索窗口的起点,-s 给窗口长度,窗口范围是 0x20008000 ~ 0x20009000,0x20008800 落在其中。RTT 的做法是在窗口内找控制块魔数,而不是直接假定地址。

固件侧每 100 ms 上报一次状态,主机侧看到的是一行:

S=183000  P=23978..24022  R=40  E=1

S 是累计周期数,P 是周期区间(单位是 DWT 周期,换算要除以当时主频),R 是活跃路由数,E 是引擎运行标志。

② DTCM 环形缓冲:事后取证。

中断里每隔 N 个周期写一条记录到固定缓冲区,写满即停;之后一次性读出。记录期间系统完全按正常节奏运行,不被打断。

/* 中断入口:一次 32 位写,开销约 1 个时钟周期 */
if (rec_enable && rec_idx < REC_N) {
    rec_buf[rec_idx++] = DWT_CYCCNT;
    if (rec_idx >= REC_N) rec_enable = 0;   /* 满了自动停,避免覆盖 */
}

缓冲区放在 DTCM(零等待),典型配置是 128 条记录。这个方法特别适合抓「偶发异常」:先让它跑,跑完再取出原始样本做统计分析,比实时观察更接近真实。

③ GPIO 心跳 + 逻辑分析仪:物理真值。

中断入口翻转一个固定引脚,外部接逻辑分析仪。

GPIOE_ODR ^= (1u << 2);   /* 每进一次中断翻转一次 */

这条路径不经过任何软件缓冲,也不受调试器影响——它给出的是物理世界里真实发生的电平变化,是三者里最不可能骗人的一个。代价是占用一个引脚,且只能看出「有没有在跑、节奏如何」,看不出内部数值。

验证

判据是「同一套固件,两种观测方式是否给出一致结论」。

判断方式 改前:halt 后读数 改后:RTT 或心跳
引擎是否在跑 样本数不增长 → 判为「死机」 样本数持续增长(100 μs 周期下每秒约 +10,000)
输出是否更新 输出寄存器读回 0 → 判为「断路」 逻辑分析仪可见每周期一个边沿
是否有偶发超时 看不到 环形缓冲里可直接统计离群样本占比

100 μs 周期意味着每秒 10,000 次中断,这个数量级本身也是交叉校验:RTT 上报的 S 在 10 秒内增长约 100,000,说明节奏正确;增长明显偏慢则可能真的掉周期。

链路图:halt 之后从内核到 GPIO 逐级停止

边界

  • 内核主频很高时(例如 544 MHz),SWD 时钟与内核时钟域差异变大,读写可能整体超时;此时只能降低主频调试,或完全依赖板上的日志与心跳。
  • RTT 需要固件预留一块不可被覆盖的 RAM 与控制块结构;没有预留时该手段不可用。
  • 环形缓冲深度固定,写满即停;它适合抓短窗口内的细节,不适合长时间连续记录。
  • GPIO 心跳只能证明「中断在发生」,不能证明「计算结果正确」;要验证数值仍需读 WIRE 或通过通信口上报。
  • 三种手段都要求固件主动配合,属于「设计时就要留出观测通道」,不能事后临时加。

最后验证于 2026-10-29 · STM32H723ZG 型号(LQFP144,RM0468)· pyOCD · SEGGER RTT

用调试器 halt 看 MCU,为什么会看到「系统死了」?

作者 JimmyZ,嵌入式 / 确定性控制方向。GitHub · 掘金