
先说结论如果你打算让 STM32N657L0 的代码一直躺在外部 NOR flash 里跑 XIP再把系统切到 LRUN那大概率会踩坑。我们这个项目是电池供电的采集节点主控用的 STM32N657L0板子上挂了一片 Macronix 的 MX25LM51245G代码直接放在 NOR flash 上 XIP 执行低功耗阶段用 LRUN。结果功耗还没降下来设备先隔三差五 HardFault最短几十秒一次最长几分钟必现。这篇文章就是完整的排错回顾包含复现方法、根因定位链路、最终修复方案以及两个至今还没完全解决的遗留坑。给做低功耗 XIP 的同行一个参考也帮还没踩到的人提前避雷。1. LRUN 一进就翻车故障现象与最小复现1.1 项目背景和硬件配置这个节点的需求很直白每 5 秒采集一次传感器数据通过无线模块发走其余时间 CPU 要保持最低功耗。我们选 STM32N657L0 主要是因为它的算力余量足后续要上本地推理同时它支持从外部存储器启动正好公司之前囤了一批 MX25LM51245G 的 NOR flash容量 512Mbit1.8V 电压带 DTR 和 DQS 的 OctoSPI 接口。电路上NOR flash 挂在 MCU 的 XSPI 接口上工作在 memory-mapped 模式。所谓 memory-mapped简单说就是 CPU 可以直接在地址映射区读代码、读数据XSPI 外设会在背后自动生成读命令序列软件层看起来就像在访问内部存储器一样。应用程序、中断向量表、字面量池全在里面正常运行模式下没有任何问题跑了几周都很稳。低功耗这块我们没用 Stop 模式因为唤醒太快反而不好做业务更希望 CPU 降频继续跑所以选了 LRUN也就是低功耗运行模式。按照 STM32 系列的套路LRUN 下内核时钟会降到一个很低的频率电压也会下调整体功耗能省下来不少但外设时钟树会跟着大改。1.2 故障表现三种崩法问题在第一次把系统切进 LRUN 后立刻暴露。故障不是固定的某一种而是三种崩法轮着来最常见的是 HardFault看门狗没来得及喂复位重启起来后因为 RAM 还在能看到之前的错误现场。其次是挂死CPU 卡在一条指令上调试器暂停后 PC 停在 XIP 地址段 0x9000xxxx 附近单步也走不动。第三种最隐蔽从 LRUN 唤醒回正常模式后跑到某个函数时开始出乱码表现是计算结果完全不对但还没有触发异常。前两种崩法大多发生在进入 LRUN 之后的几十秒内第三种则在唤醒阶段。一开始我们以为是电压不稳或者晶振问题排查了很久才意识到所有故障都有一个共同点PC 要么停在外部 NOR flash 映射区域要么正在从该区域取指。1.3 最小复现只剩 Flash 读也会死为了缩小问题范围我把业务代码全部剥掉只留了一个最小循环进入 LRUN 后不断从 NOR flash 的 XIP 映射区读一个 16 字节的固定 pattern和一个已知值比较不一致就翻转一个 GPIO。结果非常稳定不到 30 秒就会触发一次读数据错误紧接着 HardFault。这个结果直接把问题锁定在了这么几项里XSPI 外设配置、内核与 XIP 之间的总线路径、时钟树在 LRUN 下的变化。CPU 核心本身、DMA、外部传感器等都排除了嫌疑。而且重点是最小复现里读的是固定的数据不涉及缓存一致性问题说明底层硬件链路在 LRUN 下确实出了问题。测试条件结果正常模式循环读 XIP长时间运行稳定数据无错误LRUN 循环读 XIP约 20~30 秒内出现错误 bit随后 HardFaultLRUN 循环读内部 SRAM稳定无故障LRUN 循环读 XIP关 I-Cache/D-Cache仍然故障且出现时间更早关掉缓存反而更早出错这个现象很有意思说明问题不在缓存一致性而在更底层的存储访问路径上。2. 根因定位从 PC 指针到 Flash 时序的五个关键证据2.1 第一个怀疑对象cache 被干净利落地排除了代码 XIP 执行最容易被怀疑的就是 CPU 的 I-Cache 和 D-Cache。在 Cortex-M55 上缓存 miss 之后会向总线发起访问如果总线侧返回错误缓存不会缓存错误数据但异常状态会被记录。所以第一步我就把两个 cache 全关了想看看是不是缓存被 LRUN 下的什么操作污染了。结果上面已经说了关掉缓存后故障没有消失反而更快出现。这说明访问链路本身就不正常和缓存没有关系。于是我把目标转向总线互连重点检查哪个环节可能在 LRUN 下丢请求。2.2 让 PC 和错误寄存器说话我让故障发生瞬间进 HardFault handler在 handler 里把现场寄存器保存下来然后人为触发一次软件复位回到正常模式后把保存的 PC、Cfsr、Hfsr、Bfar 全部打出来。结果非常有指向性PC 停在 0x9000xxxx正好在 XSPI memory-mapped 区域。CFSR 里的 BUSFAULT 位被置上且是 IMPRECISERR说明总线错误不是当前指令立即触发的而是之前某次访问的延迟报告。BFAR 有效位为 0符合 imprecise bus fault 的特征没有具体地址信息。这就直接确认了CPU 因为外部存储器无法返回数据触发了总线错误。而且因为 XSPI 请求是流水线式的错误往往在几条指令之后才爆出来所以表现上看起来非常随机很难直接抓到现场。2.3 XSPI 的内核时钟在 LRUN 下消失了接下来查时钟。STM32N657L0 的 XSPI 外设有独立的 kernel clock可以通过 RCC 里的时钟源选择寄存器接到不同的时钟源比如 PLL、系统时钟或者某个内部 RC。正常运行模式下我把 XSPI kernel clock 接在 PLL 上跑到了比较高的频率。问题就在这进入 LRUN 时功耗控制逻辑会自动把 PLL 关掉。而 XSPI 的 kernel clock 还挂在 PLL 上于是时钟源直接归零。XSPI 外设自己没时钟它在 memory-mapped 模式下根本没法完成任何读命令但 CPU 侧的 AHB 请求已经发出去了于是请求就挂在总线上一直等最终触发 bus timeout升级为 HardFault。这一步是根因链路里最核心的一环。为什么故障不是马上出现而是几十秒后因为 XSPI 内部和总线桥之间通常有超时机制读请求挂起后要等超时计数器耗尽才报错这个过程在低频率下被拉得很长看起来就像设备随机抽风。2.4 示波器抓到 XSPI CLK 没信号为了验证这个判断我在调试器里手动把系统切到 LRUN然后在一片独立的 GPIO 上触发一次 XIP 区域读操作用示波器同时量 XSPI 的 CLK 引脚和这个 GPIO。结果非常干净GPIO 正常翻转XSPI CLK 全程没有任何跳变。这说明 XSPI 完全没有工作。如果只是 flash 时序不够CLK 至少会有波形最多是频率偏快或偏慢。现在 CLK 直接没有基本可以断定是外设时钟源断了。为了进一步排除 flash 侧的问题我又退出 LRUN把 XSPI kernel clock 留在 PLL 上但手动关掉 PLL复现结果完全一致。到这里证据链已经闭环了。2.5 NOR flash 的时序容限和低压条件叠加但只找到时钟源消失还不足以解释全部问题。因为在我们最初的设想里如果 XSPI 没时钟应该第一次 XIP 访问就挂而不该等几十秒。后来查了 MX25LM51245G 的数据手册和 STM32N657 的参考手册才发现LRUN 模式下不只是 PLL 关闭供电电压也会降一档Flash 控制器以及 IO 驱动电路的时序余量都会缩水。也就是说即便你把 XSPI kernel clock 换到一个在 LRUN 下仍然存在的时钟源也不代表就万事大吉。XSPI 访问外部 NOR flash 需要满足建立时间、保持时间、dummy cycle 等条件这些参数在低电压、低频率下都会变化尤其是在 memory-mapped 模式连续读的时候XSPI 需要配置正确的 dummy cycle 来匹配 flash 内部的读延迟。如果只是把时钟源切到 MSI但保持原来的分频系数和 dummy cycle 配置XSPI 实际频率可能和正常模式下的预期频率对不上导致 flash 无法在规定的 dummy cycle 内把数据放到数据总线上一样会偶发读错。这解释了为什么后面我们做了彻底修复之后唤醒瞬间仍然出现过一次零星错误。3. NOR 的 XIP 和 LRUN 之间为什么天生相冲3.1 XIP 不是简单的读 flash写软件的人很容易把 XIP 想成CPU 像读 RAM 那样直接读 flash但硬件上完全不是这么回事。XIP 的本质是 CPU 发起一个地址访问XSPI 外设把这个访问翻译成一组符合 NOR flash 协议的命令序列包括片选拉低、发送读命令、发送地址、等 dummy cycle、再把数据采样回来。整个过程对 CPU 是透明但对时序的要求极其严格。NOR flash 的读命令本身有固定开销比如常见的 0x03 命令是普通读0x0B 是带 dummy cycle 的快读0xEB 是四线/八线快读。命令不一样dummy cycle 不一样能支持的最大频率也不一样。XSPI 的 memory-mapped 模式会在地址映射建立时就锁死一套配置后续 CPU 取指、读数据都走这套配置不会根据频率动态调整。这就意味着只要运行频率或者工作电压变了原本合理的配置就可能变成非法配置。LRUN 恰恰把这两个变量全改了。3.2 LRUN 改变的不只是内核频率LRUN 在 STM32 低功耗体系里属于CPU 继续执行指令但把功耗压到最低的模式。为了省电它会把系统时钟从高频率切换到低频率的内部 RC同时下调内核电压、关闭部分 PLL、门控部分外设时钟。对 XIP 来说这个操作等于在 CPU 还在从外部 flash 取指令的时候把脚下的传送带突然换成了慢速传送带而且操作工XSPI 外设可能根本不知道传送带换了。你这个时侯如果还挂着 PLL 作为 XSPI kernel clockPLL 一关传送带直接停了。这是我们在第 2 章里定位到的最终根因。但即使你避开了 PLL 这个问题LRUN 还会带来次级影响电压下降导致 IO 翻转速率变慢信号边沿变缓低频下如果需要重新计算分频XSPI 的 kernel clock 和 AHB 总线时钟之间又出现新的相位关系。这些因素叠加起来会让 XIP 变成低功耗模式里最脆弱的一环。3.3 NOR 和 NAND 在 XIP 场景的根本差异聊到这里正好解释为什么大家对 NAND 和 NOR 的 XIP 能力有截然不同的预期。很多人会问既然都是 flash为什么代码必须放 NOR不能放 NAND其实不是不能而是 NAND 的接口天然不适合 XIP 这种访问模型。维度NOR flashNAND flash读模型支持随机字节/字读取地址直接映射按页读取无法随机按字节访问XIP 友好度高MCU 可直接映射取指低通常需要先把代码拷贝到 RAM坏块管理基本不需要必须处理坏块和 ECC否则数据不可信可靠性读操作稳定时序模型简单位翻转概率高需要 ECC低功耗下问题XIP 时对时序敏感时钟切换易挂不做 XIP低功耗影响相对小所以在 XIP 场景里NOR 是唯一现实的选择。但 NOR 的简单是协议层面的简单不是工程层面的简单。你把 600MHz 的 CPU 和一颗外部 NOR 通过 XSPI 连在一起本身就是把一个高速总线请求强行塞进一个异步外设里任何时钟域变化都可能让这个映射关系断裂。4. 修复方案先保活再根治4.1 短期止血向量表和关键代码迁到 SRAM拿到根因后第一件事不是立刻去配寄存器而是让设备先稳定下来。我们做了个最快的止血方案把中断向量表、启动代码、低功耗切换代码、以及几个最关键的中断处理函数全部放到 SRAM 里执行。具体做法是给这些函数加 link section 属性把它们挪到 RAM 段。以 GCC 为例链接脚本里新建一个.ramfunc段然后在代码里用__attribute__((section(.ramfunc)))标记系统启动后从 flash 拷贝到 SRAM再执行。#define RAMFUNC __attribute__((section(.ramfunc))) RAMFUNC void App_EnterLowPowerRun(void) { // 关闭无关外设喂狗然后切到 LRUN App_PrepareForLRUN(); HAL_PWR_EnterLowPowerRunMode(); } RAMFUNC void HardFault_Handler(void) { // 低功耗阶段也保证 HardFault handler 本身可执行 App_SaveFaultContext(); NVIC_SystemReset(); }这里最容易被忽略的是向量表。默认向量表在 flash 开头如果异常发生时 CPU 还要从 XIP 区取向量而 XSPI 已经因为时钟问题挂死了那 HardFault handler 也跑不起来。所以要把SCB-VTOR指向 SRAM 里的新向量表并且保证这个向量表在系统启动早期就拷贝完成。// 在 main 最开始调用 void App_RelocateVectorTable(void) { extern uint32_t __ram_vector_start__; extern uint32_t __ram_vector_end__; extern uint32_t __vector_table_flash_start__; uint32_t len (uint32_t)__ram_vector_end__ - (uint32_t)__ram_vector_start__; memcpy(__ram_vector_start__, __vector_table_flash_start__, len); SCB-VTOR (uint32_t)__ram_vector_start__; }止血方案跑了一周没有出现一次故障。代价是 SRAM 被占掉一小块低功耗下的功耗比理论值高了大概几十微安对电池影响可以接受。但这毕竟不是长久之计因为应用代码越来越大不可能全部塞进 RAM。4.2 根治方案重配 XSPI 时钟与 dummy cycle止血的同时我们把根治方案落地了。核心思路是一句话进入 LRUN 之前主动把 XSPI 的 kernel clock 切换到 LRUN 下仍然有效的时钟源并根据这个新频率重新计算 dummy cycle退出 LRUN 后再切换回来恢复原来的高频配置。第一步是时钟源切换。我把 XSPI kernel clock 从 PLL 切到系统的低功耗内部 RC具体 API 以你使用的 HAL 版本为准关键是确认进入 LRUN 后这个 RC 不会被关掉。void App_ConfigXspiForLRUN(void) { // 1. 把 XSPI 内核时钟切换到低功耗模式下仍存在的 RC 时钟 __HAL_RCC_XSPI_CONFIG(CLK_SOURCE_MSI); // 2. 关闭 XSPI 的 cache 预取避免读取过程中发生配置切换 XSPI-CR ~XSPI_CR_APRE; XSPI-CR ~XSPI_CR_DCACHE; // 3. 按新时钟频率重新计算 dummy cycle XSPI-DCR2 ~XSPI_DCR2_DCYC_MASK; XSPI-DCR2 | (APP_XSPI_DUMMY_LRUN XSPI_DCR2_DCYC_POS); // 4. 重新使能 XSPI XSPI-CR | XSPI_CR_EN; }第二步是 dummy cycle 的计算。这里不能拍脑袋。MX25LM51245G 数据手册里会给出一个最大频率和对应 dummy cycle 的表。你需要在设计阶段确认 LRUN 下 XSPI kernel clock 的真实频率然后用这个频率去反推需要多少个 dummy cycle。公式不复杂时钟周期 1 / XSPI kernel clock 频率需要满足的最小时间 flash 内部固定读延迟时间最小 dummy cycle 数 ceil(最小时间 / 时钟周期)宁多勿少。dummy cycle 配多了顶多是效率低配少了就是随机读错。我们在 16MHz XSPI 时钟下最终选了 4 个 dummy cycle比理论值多留了 1 个余量。第三步是退出 LRUN 后的恢复。同样要先把 XSPI 切回 PLL 源然后重新配置高频下的 dummy cycle最后把 XSPI 的 cache 打开。顺序不能反否则配置切换的间隙来一次取指还是会挂。void App_ConfigXspiForRun(void) { XSPI-CR ~XSPI_CR_EN; __HAL_RCC_XSPI_CONFIG(CLK_SOURCE_PLL); XSPI-DCR2 ~XSPI_DCR2_DCYC_MASK; XSPI-DCR2 | (APP_XSPI_DUMMY_RUN XSPI_DCR2_DCYC_POS); XSPI-CR | XSPI_CR_EN; }4.3 别漏掉的配套改动时钟和 dummy cycle 是主修复但真正稳定运行还需要几个配套动作保证低功耗阶段喂狗的代码不在 XIP 区域否则 LRUN 里主循环被 XSPI 卡住看门狗会把设备反复复位。进入 LRUN 前把 XSPI 的 cache clean 掉避免 LRUN 阶段使用残留的 cache 行导致唤醒后读到旧数据。如果 NOR flash 支持 deep power-down一定要确认你没有在 LRUN 阶段把 flash 切到 DPD。XIP 模式下这是自杀行为flash 进了 DPD 后CPU 取指会直接挂死在总线上。考虑在唤醒后立刻 invalidate I-Cache 和 D-Cache防止低频段留下的缓存污染高频段执行。4.4 代码流程与实测验证最终进入 LRUN 的完整流程是App_RelocateVectorTable()确保向量表在 SRAM。App_ConfigXspiForLRUN()切换 XSPI 时钟源并重配 dummy cycle。确认关键中断不在 XIP 区域执行。进入 LRUN。唤醒后第一时间执行App_ConfigXspiForRun()恢复高频配置。恢复完 XSPI 之后再去访问原来的 XIP 数据区。这个流程实测跑了两周频繁做高低频切换没有出现一次 HardFault也没有再出现读数据错误。5. 实测结果与遗留坑5.1 三种配置下的实测对比为了说明问题我把三种配置放在一起做了长时间稳定性测试每 10 秒做一次正常模式和 LRUN 的切换持续 8 小时。配置故障次数平均电流LRUN 阶段备注原始配置XSPI 时钟挂 PLLXIP 执行47 次 HardFault约 65 µA典型故障止血方案代码在 SRAMXSPI 时钟问题仍在0 次约 62 µA稳定但占用大量 SRAM根治方案XSPI 时钟切换 dummy cycle 重配0 次约 58 µA稳定且功耗更低根治方案的功耗反而比止血方案低了一点原因也简单SRAM 里的代码也要从 SRAM 取指SRAM 的功耗不是零而把执行放回 XIP 后整体内存访问模式更加规整。5.2 坑一LRUN 阶段 DMA 访问 XIP 区域会静默失败根治方案上线后我们遇到了一个全新的问题系统切到 LRUN 后DMA 从 XIP 区域搬运数据到 SRAMDMA 的传输完成标志迟迟不置位也不报错误。现象是数据没传完但软件不知道。原因还是那个老问题DMA 走的是 AHB 总线矩阵如果 XSPI 在 LRUN 下的时序配置没问题DMA 本身可以访问 XIP 区域但 DMA 请求对等待周期的容忍度和 CPU 不同一旦配置边缘不够DMA 会一直 stuck 在等待 flash 数据的状态而不像 CPU 那样触发总线错误。解决方式是所有 DMA 操作至少在进入 LRUN 前完成或者在 LRUN 阶段不要对 XIP 区域发起 DMA 请求。真需要在低功耗下处理 flash 数据只能先把数据搬到 SRAM。5.3 坑二唤醒后第一次读 flash 偶发错误还有一个到现在仍然偶发的现象从 LRUN 唤醒后如果立刻执行一次 XIP 区域的读操作大概每几百次里会有一次读到错误值。我们的临时方案是在唤醒后强制等待一小段稳定时间再做一次无效读然后再进入正常业务。void App_WakeupFromLRUN(void) { App_ConfigXspiForRun(); // 等待 XSPI 和 flash 从低频稳定到高频 volatile uint32_t dummy; dummy *(volatile uint32_t *)APP_XIP_BASE; __DSB(); // 如果读回来和预期不符再读一次 if (dummy ! APP_XIP_MAGIC) { dummy *(volatile uint32_t *)APP_XIP_BASE; } }这不是一个优雅的修复更像是给硬件行为打的补丁。我怀疑根子还是在电源电压切换的瞬态过程XSPI 的高频配置虽然已经恢复但 flash 侧的电源或 IO 电平还没有完全稳定。如果你也遇到类似偶发问题建议在唤醒后用一个延时函数先等几十微秒再做第一次 XIP 访问大概率能压下去。我个人实际操作中的体会是这类问题最怕的不是硬件本身而是随机 HardFault这种表象太迷惑人。你可能会怀疑内存、怀疑编译器、怀疑堆栈溢出但只要抓住 PC 指针和总线错误寄存器这两条线索很多看似诡异的问题都能顺藤摸瓜找到根。最后再分享一个小技巧调试这种 XIP 问题一定要在故障现场把CFSR和BFAR完整保存下来不要急着复位这两个寄存器的值能帮你少走一晚上弯路。