
如果你在 STM32N6570-DK 上部署过自定义的 ST YOLOX 模型大概率遇到过这种诡异的场景摄像头画面流畅跑了几帧检测框也都正常突然整个系统像被按了暂停键屏幕定格同时板载的一颗 LED 莫名其妙亮了起来。这颗 LED 不是电源灯也不是你自己控制的用户指示灯它亮起的时间点几乎和系统冻结完全同步看起来就像某个“隐藏程序”在最后一刻接管了系统。我这次遇到的项目就是一个自定义 5 类 ST YOLOX 模型跑在 STM32N6570-DK 上输入源是摄像头任务是对产线上的五种零件做实时分类检测。前几帧推理结果非常正常FPS 能稳定在 30 左右但大约 5 到 8 帧之后画面定格板载 LD4 红灯突然亮起只能按复位键恢复。复位后重复出现位置几乎不变。这篇文章我就把完整的排查过程写下来包括如何从“一颗灯”顺藤摸瓜找到真正的病根以及最后怎么修复。1. 现象复现与平台环境从一块板子和一个模型说起1.1 部署链路与模型来源先交代一下硬件和软件环境因为后面很多排查步骤都和具体版本有关。我手头这块板子是 ST 官方的 STM32N6570-DK主控是 STM32N6570双核架构应用侧是 Cortex-M55还带了一个内部集成的 Neural-ART 硬件加速器 NPU专门跑 AI 推理。板载摄像头接口是 DCMI配的是一颗 OV5640 摄像头模组显示部分用的是一块 4.3 寸 RGB LCD 屏幕。软件侧我用的 STM32CubeIDE 1.15.0AI 转换工具是 X-CUBE-AI 9.0其实现在新版叫 ST Edge AI命令行工具名是 stedgeai。模型本身是 YOLOX 的微型变体我在服务器上用 PyTorch 训练了一个 5 类检测模型输入分辨率 640×640导出 ONNX 后再通过 stedgeai 转换生成部署代码。整个部署链路可以整理成下面这个表项目版本 / 参数开发板STM32N6570-DKIDESTM32CubeIDE 1.15.0AI 工具链stedgeai / X-CUBE-AI 9.0神经网络YOLOX 微型变体自定义 5 类输入尺寸640×640×3摄像头OV5640DCMI 接口显示RGB LCD直接内存映射注意一点STM32N6570 和以前做 STM32H7 的老套路不太一样。H7 上用STM32Cube.AI模型大概率是跑 Cortex-M 内核的 CMSIS-NN而 N6 系列如果你不显式指定--target npu工具链可能会退回到 Cortex-M55 上纯 CPU 推理这样性能差很多而且内存行为也完全不同。我这次是加了--target npu参数模型权重会放在外部 PSRAM激活缓冲和输出缓冲放在内部 SRAM。1.2 “几帧之后”到底发生了什么这个现象最头疼的地方在于“几帧之后”这个不确定性。它不是一启动就崩也不是固定跑 100 帧才崩而是每次都在大约 5 到 8 帧之间。我开始以为是随机问题后来在串口日志里加了帧计数和推理耗时打印发现每次都是在某一帧的推理返回之后主循环还没来得及处理完显示刷新整个程序就卡住了。串口打印大概长这样[INFO] Frame 0001 inference done, time28.3ms, det1 [INFO] Frame 0002 inference done, time27.9ms, det0 [INFO] Frame 0003 inference done, time28.1ms, det0 [INFO] Frame 0004 inference done, time28.6ms, det0 [INFO] Frame 0005 inference done, time28.0ms, det0到了第 5 帧之后串口再没有新的输出。刚开始我怀疑是不是摄像头 DMA 停了导致主循环一直等frame_ready信号后来把显示线程和推理线程各自加 GPIO 翻转发现是推理线程内部卡死而不是摄像头。这个线索很重要让我把注意力从采集侧转移到了 AI 模型运行本身。2. 从板载 LED 顺藤摸瓜先搞清楚灯为什么亮2.1 原理图上的 LED 映射与点亮条件在嵌入侵战里一颗不该亮的 LED 往往是系统留给我们的最后一张便签。STM32N6570-DK 的原理图上LD4 对应 PF14 引脚高电平点亮连接的是板子上一颗红色 LED。硬件上是通过一个三极管驱动的不是直接推挽输出所以 GPIO 只要输出高电平灯就会亮。问题是我的应用代码里根本没有初始化 PF14 为输出更没写过这个引脚。那么它亮了大概率是三种情况系统发生了某种异常错误处理代码点亮了 LEDNPU 或者总线 master 访问了错误的地址把整个 GPIOB 或 GPIOF 的寄存器值改了复位之后 GPIO 处于默认状态刚好 PF14 被配置为复用功能某个外设把它拉高了。排查的时候不要先猜直接拿示波器看 PF14 的波形。我抓到的现象是PF14 从低电平跳到高电平跳变时刻与串口打印停止的时刻完全一致误差在几十微秒以内。这说明 LED 点亮不是电源噪声或者悬空而是某个程序路径主动写了这个寄存器。2.2 调试器锁死现场确认 Fault 类型接下来用 ST-LINK 调试器挂上去复现。程序卡住后不要着急复位先点 IDE 的 Suspend然后看 PC 指针。我这边 PC 停在HardFault_Handler这是最直观的结果系统真的崩了不是死循环也不是看门狗复位。再进一步读寄存器。Cortex-M55 的异常机制和 M4/M7 类似在 HardFault 之前通常有一个更具体的 fault 类型会被记录在 System Control Block 里。我读取了下面几个关键寄存器SCB-CFSR // 可配置错误状态寄存器 SCB-HFSR // HardFault 状态寄存器 SCB-MMFAR // MemManage 错误地址寄存器 SCB-BFAR // BusFault 错误地址寄存器实测结果是CFSR里IBUSERR和BFARVALID都没有置位反而是MMARVALID为 0HFSR的FORCED位为 1表示 HardFault 确实由某个可配置 fault 升级而来。让我没想到的是CFSR里的MSTKERR位是 1——这是栈指针在进入异常时发生错误说明问题可能出在栈上。重新检查链接脚本后发现我给 AI 任务分配的栈空间确实偏小但只有 2KB理论上一个推理调用链还不至于吃满所以我反而在怀疑是不是有别的代码把栈顶写穿了。为了拿到更多信息我在HardFault_Handler里加了现场保存把PSP或者MSP、LR、PC、以及几个关键寄存器的值通过串口打印出来。结果发现触发 HardFault 时LR指向的是某个.c文件里的后处理函数而不是 NPU 驱动代码。这说明 NPU 本身执行完了崩在了 CPU 侧的 YOLOX 后处理上。void HardFault_Handler(void) { __disable_irq(); volatile uint32_t cfsr SCB-CFSR; volatile uint32_t hfsr SCB-HFSR; volatile uint32_t pc __get_PC(); volatile uint32_t lr __get_LR(); /* 这里把现场信息通过串口打印 */ printf([FAULT] CFSR0x%08lX HFSR0x%08lX PC0x%08lX LR0x%08lX\r\n, cfsr, hfsr, pc, lr); /* 点灯用于现场指示 */ HAL_GPIO_WritePin(GPIOF, GPIO_PIN_14, GPIO_PIN_SET); while (1) { } }也正是从这一步开始我确认了“LED 会亮”其实是我自己在项目早期顺手在错误处理代码里加上去的。真正该问的问题是为什么后处理函数会触发 HardFault。2.3 看门狗与复位源排除“被复位”的假象还有一种常见情况是系统冻结其实是 CPU 触发了 HardFault 后卡死在异常处理里而板子上的 LED 由看门狗超时复位后的启动代码点亮。为了排除这个可能我查了 RCC 的复位标志寄存器RCC-CSR看看有没有 IWDG 复位标志。我当时没有启用独立看门狗所以这块板子不存在喂狗超时的问题。但如果你自己也遇到了“跑几帧后 LED 亮 系统卡死”我强烈建议先查一遍复位源。因为有些 LED 在启动阶段会先亮一下然后软件初始化之后再灭掉如果系统不停复位你看到的就会是“LED 常亮”或者“一亮一灭”的效果和真正的 HardFault 点灯完全不同。所以这一段排查的结论很明确系统不是被看门狗复位打回原形而是真的在某个后处理函数中触发了异常并且最终进入了 HardFault。3. 反复冻结的幕后从内存到 NPU 的逐层排查3.1 内存布局谁动了我的 RAM确定是 HardFault 之后我开始怀疑内存布局问题。STM32N6570 的内部 SRAM 虽然不小但有多个物理块NPU 访问内部 SRAM 时还受总线矩阵限制。stedgeai 生成的代码会为模型权重和激活 buffer 分配静态数组这些数组是否对齐、是否放在 NPU 可访问的地址空间直接决定了运行稳定性。我在 CubeIDE 的 .map 文件里查了几个关键段的大小主要关注三个东西内存段用途大小NN_WEIGHT_BUF模型权重放在外部 PSRAM~6.8 MBNN_ACT_BUFNPU 激活中间数据内部 SRAM~1.2 MBNN_OUT_BUF模型输出张量内部 SRAM~16 KB看到 16KB 的输出缓冲区时我心里咯噔一下。如果是 640×640 的 YOLOX 输出三个尺度的特征图加起来可能远远不止 16KB。当然这个 16KB 可能只是 stedgeai 转换器优化后的最终输出毕竟后处理被合到了模型里。但这时候我还不能确定因为如果输出大小真的不对前几帧应该也会崩。为了验证我把NN_OUT_BUF后面故意填充成 0xA5然后继续跑。等到程序 HardFault 之后在调试器里查看这段内存发现有几个字节已经变成了非 0xA5。这说明后处理函数的写入确实超过了输出缓冲区边界覆盖了相邻内存。但这个覆盖发生在 HardFault 之前还是 HardFault 本身导致的还不好说。需要更精细的手段来定位。3.2 NPU 推理与后处理的状态机时序第二个排查方向是 NPU 推理状态机。STM32N6570 的 NPU 是独立硬件单元CPU 向它提交任务后可以通过轮询或者中断获得完成信号。stedgeai 生成的运行时函数通常叫ai_run内部会先判断 NPU 是否空闲如果前一次推理还没完成就开始下一次就会进入一个等待状态。我原本担心是连续两帧推理发生了重叠导致 NPU 内的状态机卡死。但在ai_run前后加了 GPIO 翻转和时间戳之后发现每次ai_run都能正常返回耗时也稳定在 28ms 左右。也就是说问题不在推理启动阶段而在推理完成之后的 CPU 后处理阶段。这里我也总结一个经验N6 平台的 NPU 更像一个协处理器它本身不会接管你的 CPU所以“系统冻结”不可能单纯因为 NPU 跑太久——CPU 要么在轮询等待要么在中断服务里出不来。看到冻结时先确认 PC 停在哪一层比瞎猜状态机更有效。3.3 摄像头的 DMA 与中断干扰既然 AI 推理本身没问题我开始怀疑是不是摄像头 DMA 和 AI 后处理争抢内存。我的 DCMI 采集的是 RGB565 帧一帧 640×480 大约是 600KB我分配了两个 buffer 做乒乓缓存。主循环里等frame_ready信号然后把这帧图像缩放到 640×640喂给 AI 输入。问题出在缩放和填充输入缓冲这一段。我用的代码原本是从旧项目里拷贝来的里面对 DCMI 缓冲区的指针是硬编码的。如果 AI 后处理线程已经写穿了输出缓冲区而这个地址恰好又和摄像头 DMA 缓冲区相邻那么后续的 DMA 传输就可能读到被篡改的描述符或者直接覆盖后处理要用到的临时数据。虽然这没有直接导致 HardFault但它会让错误现场变得非常隐蔽甚至出现“这次崩的位置和上次不一样”的假象。为了排除 DMA 干扰我临时把摄像头采集线程停掉改成从一张固定的测试图片反复喂给模型看看还会不会崩。结果非常关键固定输入图片同样在第 5 帧左右崩。这基本排除了摄像头 DMA 的因素问题本质落在模型运行时和输出解析上。4. 真正的病根自定义 5 类 YOLOX 的输出配置与缓冲越界4.1 ST YOLOX 后处理与输出张量结构现在把视角拉回到模型本身。YOLOX 和传统 YOLOv5 的一个区别是它的解耦检测头。经过 stedgeai 转换后模型最终的输出通常有 3 个特征图层对应 stride 分别是 8、16、32。对于 640×640 输入三个网格尺寸分别是 80×80、40×40、20×20。如果是 5 类模型每个网格点上的预测向量长度为 4bbox 坐标 1objectness 5类别数 10。stedgeai 生成的代码会把这些输出放到底层数组里但同时还会生成一个结构体ai_net_outputs_get里面有每个输出张量的尺寸和地址。如果你在训练时自定义了 5 类但是转换时没有在工具里正确配置类别数那生成的代码可能仍然按 COCO 80 类去解析输出后处理函数会从每个网格点上读出 4 1 80 85 个浮点数。问题就在这模型实际输出每个点是 10 个浮点数但后处理按 85 个浮点数去索引。当类别置信度分数满足阈值代码会执行类似这样的操作for (int i 0; i num_classes; i) { if (scores[i] conf_threshold) { // 解析 bbox 并写入输出框列表 } }如果scores指向的是模型输出 buffer而每个点的真实长度只有 10那么scores[10]到scores[84]会读到相邻网格点或者干脆越过整个输出 buffer。读取越界可能不会立刻崩但一旦写入检测框结果的时候动了错误地址栈和堆就会被污染。我在 HardFault 现场打印的 LR 指向后处理函数实际回溯时发现它停在遍历类别分数的循环里几乎可以实锤这一点。4.2 前几帧正常是偶然吗置信度与目标数量的影响我一直想不通的“前几帧正常”也终于有了答案。YOLOX 后处理里只有置信度超过阈值的网格点才会触发候选框解析。前几帧画面里可能没有任何目标接近阈值后处理循环只是遍历了一遍没有命中任何写入分支所以内存没有被破坏。到了第 5 帧画面中某个目标突然靠近或者光照变化让某个类别分数超过了阈值后处理就开始往候选框列表里写数据。由于候选框列表本身是静态分配的而输出 buffer 的尺寸被算错了写出的候选框个数超过预留容量直接破坏了相邻内存。最终在某一帧触发 HardFault。也就是说“几帧之后”并不是一个固定规律而是和目标出现的时间有关。为了验证这个猜测我把conf_threshold调到 0.99让前 10 帧几乎检测不到目标程序确实多跑了几帧才崩再把阈值调到 0.01让每个网格点都触发写入程序几乎是立刻崩溃。这个对照实验也进一步说明问题出在后处理对输出长度和候选框数量没有正确约束。4.3 定位越界看门狗喂狗与内存保护单元到这一步其实已经可以认定是输出尺寸配置错了。但为了写这篇排查记录我再用一个更专业的手段验证一下开 MPU 的某个区域为只读把输出缓冲区后面的内存设置为禁止访问。这样一旦后处理越界写CPU 会立刻进入 MemManage Fault而不是等到栈被破坏后再变成 HardFault异常现场会非常干净。ST 的 Cortex-M55 自带 MPU我配置了一个 region 覆盖 NN_OUT_BUF 后面的保留区域属性设为 no-access。修改后的结果是程序跑到同一帧直接在越界写入的地方触发 MemManage FaultPC 精确指向后处理函数里的某一条存储指令。这比之前用 HardFault 和串口日志推算要直观得多。如果你也想复现这种定位方法步骤很简单在 CubeMX 里启用 MPU创建一个 region覆盖输出缓冲区之后的 4KB 内存设置该 region 的访问权限为NO ACCESS在MemManage_Handler里读SCB-MMFAR获取错误地址在调试器里看崩溃调用栈。注意不要让这个 region 覆盖任何实际使用的堆栈或数据否则整个程序连启动都过不了反而干扰排查。5. 修复方案与回归测试不换模型也能救回来5.1 修正输出尺寸和对齐方式定位到根因之后修复就简单了。我重新用 stedgeai 生成了一次模型代码这次在命令行里显式指定了类别数。新版 ST Edge AI 在 YOLOX 转换时支持通过配置参数指定classes5、conf_thresh、iou_thresh和max_boxes生成的头文件里AI_NETWORK_OUTPUTS_IN_1_SIZE会变成正确的值。同时我检查了后处理候选框数组的大小。原来的代码是移植自 COCO 80 类示例最大框数硬编码为 100这对 5 类模型来说有点浪费但也不是不能接受。我改成 32 之后内存占用小幅下降更重要的是在解码循环里加了边界判断if (box_count MAX_BOXES) { break; }这一步看似不起眼但能防止候选框数量超过预留数组上限。即使以后换了更大输入或者更高召回率的模型也不会因为这个原因把栈写穿。5.2 增加错误处理和栈空间除了修根因我还做了一些“防减灾”措施。第一是给 AI 后处理所在的任务栈从 2KB 扩大到 8KB。原因是后处理函数里临时变量比较多栈帧开销比想象中大而且 NPU 驱动在异常情况下还会多压几层栈。第二是在HardFault_Handler里不再直接死循环点灯而是先打印错误信息然后尝试恢复。对生产环境来说更好的做法是让系统重启同时把错误状态存入备份寄存器方便远程诊断。栈空间的大小不要靠猜可以用调试器的 Stack Watermark 功能测量也可以在任务入口把栈区域填成 0xAA运行一段时间后统计还有多少字节没被改写。我这次测下来AI 推理任务实际峰值栈使用大约 3.2KB2KB 必然溢出8KB 则留了足够余量。5.3 实测数据与几点建议修复后我重新跑了一个小时的连续压力测试5 类模型640×640 输入帧率稳定 28~30 FPS没有再出现冻结和 LED 闪亮。串口日志里每一帧都正常打印检测框数量也符合预期。项目修复前修复后连续运行时间约 5~8 帧崩溃60 分钟稳定平均推理耗时28.0ms28.2ms最大检测框数10~2010~20LED PF14 状态异常点亮始终熄灭HardFault 次数每次复现必现0最后说几条我在这个项目里总结出的经验第一AI 模型部署到 MCU 之后输出张量的解析逻辑往往是最容易藏 bug 的地方。训练时改类别数、改 anchor、改阈值部署侧必须同步改否则出问题的时间点看起来完全是随机的。第二不要放过任何一颗“意外亮起的 LED”。大多数嵌入式板子上的错误 LED 都是硬件设计者刻意留给调试者的信号至少能帮你区分系统是死循环、看门狗复位还是 HardFault。第三如果遇到“跑几帧之后才崩”优先怀疑“命中特定条件才触发”的代码路径。这类问题通常和输入数据相关不像初始化错误那样一开机就暴露。通过临时改阈值、改固定输入、开 MPU 保护等手段能把“随机崩溃”变成一个确定性的 bug。这个项目让我对 STM32N 系列上部署 YOLOX 有了更具体的认知模型转换没错不代表部署没错跑通 Demo 也不代表能稳定上线。很多时候一个小小的数组越界就能让整个系统在关键时刻“恰好”崩溃。希望这篇排查记录能帮你少走几步弯路。