ARTICLE DETAIL

资讯详情

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

STM32H563 + ThreadX 嵌套中断随机崩溃:排查与修复

STM32H563 + ThreadX 嵌套中断随机崩溃:排查与修复 最近在调试一块基于 STM32H563 的板子ThreadX 跑着二十多个线程外设中断也有七八路平时都非常稳。结果加了一路高优先级中断之后系统开始随机 HardFault。最诡异的地方在于只要这路高优先级中断嵌套进另一路低优先级中断就时不时崩一下频率不高可能跑十几分钟才出现一次但非常致命。这篇文章就把我排查“STM32H563 ThreadX 嵌套中断随机崩溃”的完整过程写出来。标题里的问题“whats the likely culprit”最后我会给出我的结论真正的元凶通常不是一个而是好几个问题叠在一起。下面按我实际排查的顺序来聊每个环节都会说清楚原理、判断方法和修改建议。1. 崩溃现场与复现条件先确认它真的是嵌套中断触发的1.1 现象描述刚开始拿到问题的时候现象特别没规律。系统有时跑几分钟崩一次有时跑一两个小时才崩一次崩的时候 CPU 停在 HardFault_Handler 里有时干脆直接复位。用调试器看现场HFSR的 FORCED 位为 1说明确实进了 HardFault但CFSR里的具体错误类型每次都不一样有时候是 INVSTATE非法指令状态有时候是 STKERR栈错误偶尔还有 MMFAR 相关的访存违例。这种“错误类型不固定”的表现往往意味着不是单点问题而是某个资源被破坏后在任意一个后续时序点爆出来。进一步缩小范围时我发现了几条规律单独关掉新增的那路高优先级中断连续跑 20 小时没有任何问题。单独关掉某一两路低优先级中断新增高优先级中断保持开启同样没有问题。两边的中断都打开崩溃概率明显上升而且用逻辑分析仪抓中断时序时崩溃几乎都发生在高优先级中断抢占低优先级中断的那一小段时间窗内。到这一步就可以基本确定这不是主循环逻辑问题也不是某一个外设单独工作不正常而是嵌套中断机制本身触碰到了某个边界条件。1.2 为什么“加断点”会失效很多新手遇到这种问题时喜欢在 HardFault_Handler 加断点或者在中断处理函数里加断点想用单步追踪来定位。这个方法在这种随机崩溃场景下基本行不通原因有两个。第一断点本身会改变中断时序。你打断点的那一瞬间中断嵌套关系已经和正常运行不一样了原本能够稳定复现的崩溃可能就复现不出来或者反而在另一个地方崩。第二HardFault 发生时现场已经被破坏了你在断点处看到的寄存器值可能不是真正的第一现场。比如主栈溢出后破坏了一个结构体真正写坏内存的代码可能在几分钟前就执行完了你现在看到的只是“受害者”不是“凶手”。所以我的建议是第一件事不要急着加断点先把 Fault 状态寄存器的值和 MSP/PSP 的值记录下来用串口或调试器存到内存里再来分析。下面几章就是围绕这些现场数据展开的。2. 第一嫌疑主栈MSP被嵌套中断撑爆2.1 Cortex-M33 的两套栈指针ThreadX 只盯住了其中一套很多人刚开始用 ThreadX 时会有个误解以为系统跑起来后所有栈都是 RTOS 在管理。实际上 ThreadX 做线程切换时每个线程跑在独立的线程栈上用的是 PSP而所有中断服务程序ISR跑在 Handler 模式下用的是 MSP 主栈这个主栈通常就是启动文件或链接脚本里定义的那一块和 ThreadX 没有直接关系。Cortex-M33 和 M4 一样拥有 MSP 和 PSP 两个栈指针。只要进入异常/中断硬件会自动切换到 MSP只有退回到线程模式才继续使用 PSP。也就是说中断嵌套的深度、每个 ISR 内部的局部变量大小全部消耗在主栈 MSP 上。ThreadX 提供了一个TX_ENABLE_STACK_CHECKING编译选项专门用来检测线程栈溢出每次上下文切换时检查当前线程的栈指针是否越过边界。但这个检查只对线程栈有效对主栈完全不起作用。如果你的主栈溢出了ThreadX 是感知不到的只能等崩溃后再从现场去反推。这是一个非常容易踩的坑你辛辛苦苦把每个线程栈都调得很大甚至开了栈检查结果系统还是随机崩因为崩的根本不是线程栈而是中断主栈。2.2 栈用量一算就明白默认 1KB 主栈为什么不够先算一下一次普通中断压栈需要多少空间。Cortex-M33 进入中断时硬件会自动把 xPSR、PC、LR、R12、R3、R2、R1、R0 这 8 个寄存器压到当前栈上也就是最少 32 字节。如果你的中断里用了 FPU 浮点运算并且 FPU 上下文处于激活状态硬件还要额外压入 S0-S15、FPSCR 以及对齐字总共会变成 104 字节比普通中断多了 72 字节。再叠加嵌套的情况。高优先级中断抢占低优先级中断时低优先级中断已经压了一帧高优先级中断再压一帧。两级嵌套的情况下光是硬件压栈就可能消耗 64 字节到 208 字节。这还没算中断处理函数里调用子函数时的压栈、局部变量、编译器为了函数调用而预留的栈空间。我见过一个实际案例某路串口空闲中断的 ISR 里定义了一个uint8_t buf[256]的局部数组用来暂存接收数据。平时主栈有 1KB单层中断时绰绰有余但一旦高优先级中断嵌套进来256 字节的数组加上两层中断的硬件压栈直接把主栈底部冲破写坏了一片全局变量区域导致系统在完全不相干的地方崩溃。很多 STM32 工程的默认主栈大小只有 0x400也就是 1KB。对于带 ThreadX 的项目尤其是有嵌套中断的项目这确实偏小。我的建议是至少设到 2KB 到 4KB如果中断里用了较大局部变量还要再往上加。主栈占用的是 RAM对 H563 这种内置 RAM 不小的芯片来说多花的几百字节换来的是调试上的安稳很值。2.3 怎么判断当前到底是不是主栈溢出在 HardFault_Handler 里你可以通过__get_MSP()拿到当前主栈指针然后和链接脚本里定义的主栈边界做比较。假如你用的是标准启动文件主栈底可以这样估算// 假定启动文件里 Stack_Size 定义为 0x1000 // 链接脚本中的 __initial_sp 指向主栈顶栈向下增长 extern uint32_t __initial_sp; extern uint32_t __Stack_Size; // 部分链接脚本导出了这个符号 void HardFault_Handler(void) { uint32_t msp __get_MSP(); uint32_t psp __get_PSP(); uint32_t cfsr SCB-CFSR; // 栈顶地址 __initial_sp // 栈底地址 __initial_sp - Stack_Size if (msp (uint32_t)__initial_sp - (uint32_t)__Stack_Size) { // msp 越过了栈底基本可以判定主栈溢出 } while (1); }另外还要看CFSR里有没有STKERR或MSTKERR。这两个错误标志分别表示异常入栈时栈错误、异常返回时栈错误。如果出现了这些标志基本可以直接锁定到栈问题。不过要注意MSP在 HardFault 现场可能已经是一个完全离谱的值也可能恰好没有越界只是因为内存被踩坏而间接崩溃。所以主栈溢出不是唯一判定条件还要结合下面几章的内容继续排查。3. 第二嫌疑中断优先级与 ThreadX 临界区屏蔽阀值错配3.1 ThreadX 的临界区不是简单地“关中断”这是 ThreadX 在 Cortex-M 上非常经典的一个机制。很多 RTOS 进入临界区时用的是PRIMASK也就是直接关掉所有可屏蔽中断但 ThreadX 用的是BASEPRI寄存器只屏蔽优先级数值大于等于某个阈值的中断更高优先级的中断依然可以响应。ThreadX 在 Cortex-M 端口里有一个配置项TX_PORT_BASEPRI在很多移植版本里默认值是 0x80。BASEPRI 的屏蔽规则是屏蔽所有优先级数值 BASEPRI 值的中断。注意Cortex-M 的优先级数值越大实际优先级越低所以设置 0x80 意味着屏蔽掉优先级 0x80 到 0xFF 这一片较低优先级的中断而优先级 0x00 到 0x7F 这些更高优先级的中断则完全不受 ThreadX 临界区保护。STM32H563 使用的 Cortex-M33 虽然支持 8 位优先级寄存器但实际实现只用了高 4 位所以可设置的优先级是 0x00、0x10、0x20、0x30、0x40、0x50、0x60、0x70、0x80、0x90、0xA0、0xB0、0xC0、0xD0、0xE0、0xF0 这 16 个等级。默认的 TX_PORT_BASEPRI 0x80 恰好是一个合法值它会把数值大于等于 0x80 的中断挡住但挡不住数值小于 0x80 的高优先级中断。3.2 嵌套中断崩溃的经典时序现在把场景串起来。假设一路低优先级中断 A 的优先级是 0x90一路高优先级中断 B 的优先级是 0x40。当 ThreadX 在中断 A 里调用某个内核 API 时它首先通过 BASEPRI 进入临界区。由于 0x90 的优先级数值大于 0x80ThreadX 可以把 A 屏蔽掉但此时如果更高优先级的中断 B 来了因为 0x40 小于 0x80BASEPRI 根本拦不住它B 会直接抢占进来。如果中断 B 的服务程序里也调用了 ThreadX API而它调用时操作的正好是同一个内核对象那么问题就来了。比如低优先级中断 A 正在往某个事件标志组里设置事件位已经更新了标志位、正打算唤醒等待线程高优先级中断 B 插进来也往同一个事件标志组里设置事件位同时改变了等待线程链表。两个中断上下文同时修改内核链表没有任何互斥保护本质上是数据竞争。等 B 退出A 继续执行时链表可能已经被改得面目全非后续遍历就会访问到非法指针直接 HardFault。这种崩溃往往发生在嵌套退出之后不久症状很随机。这就是“嵌套中断”会成为 ThreadX 崩溃热点的核心原因不是嵌套本身有问题而是 ThreadX 的临界区保护没有覆盖到所有中断而你偏偏在穿过了临界区的那个中断里调用了内核 API。3.3 怎么排查和修复排查方法很简单临时把所有中断优先级都改成等于 0x80 或更低数值更大也就是让所有中断都能被 BASEPRI 屏蔽掉然后跑一段时间。如果崩溃不再出现基本就能确认和这个机制有关。修复有两个方向。第一个方向是调整中断优先级让所有会调用 ThreadX API 的中断优先级数值都大于等于 0x80。这样 ThreadX 的临界区可以屏蔽住它们避免数据竞争。第二个方向是调整TX_PORT_BASEPRI比如改成 0xF0让临界区能屏蔽掉几乎全部中断只有 NMI 和 HardFault 这类异常能穿透。但这样做会增加中断关断时间因为临界区变长了实时性会受影响。大部分情况下我们还是希望让最高优先级留给真正的紧急外设但这些紧急外设的服务程序里不应该调用 ThreadX API。我最后在项目里采用的做法是把真正需要极高实时性的中断优先级设置成 0x40 以下但在它的 ISR 里只做最小处理比如读状态寄存器、清标志、置一个裸变量然后发一个 ThreadX 信号量或事件标志给线程处理。发信号量这个操作本身会调用_tx_semaphore_put这就有风险。所以更干净的做法是连_tx_semaphore_put都不在这个高优先级中断里调用而是用一个普通的 GPIO 中断或从机中断去触发一个中等优先级的处理中断再由这个中等优先级中断去调用 ThreadX API。由于中等优先级的数值大于 0x80ThreadX 能屏蔽它就安全了。4. 第三嫌疑嵌套中断里调用了不该用的 ThreadX 服务4.1 并非所有 ThreadX API 都允许在中断上下文调用ThreadX 的 API 分成好几类有一部分明确标注为“可从 ISR 调用”比如_tx_semaphore_put_tx_event_flags_set_tx_queue_send_tx_thread_resume部分场景这些 API 的共同特点是不会阻塞调用者只是修改内核对象的状态并可能触发调度。但另一部分 API 是绝对不能在中端上下文里调用的比如_tx_thread_sleep_tx_mutex_put_tx_mutex_get_tx_thread_wait_abort_tx_event_flags_get如果设置了等待选项这些 API 要么会让当前执行流阻塞等待要么涉及线程所有权、优先级继承等复杂机制在中断上下文里根本没有当前线程的概念调用后会发生什么完全是未定义行为。嵌套中断场景下还需要格外小心一个问题即使你调用的 API 是允许从中断调用的如果这个中断本身是高优先级穿透中断它嵌套进入了另一个正在执行 ThreadX 内核代码的中断那么两个中断上下文共同操作内核对象依然可能造成破坏。这和上面第 3 章描述的临界区穿透问题是一样的。4.2 为什么 ThreadX 没有拦住你的非法调用ThreadX 有一个编译配置项TX_CALLER_CHECKING开启后 API 会做调用来源检查如果发现是在非法上下文调用会直接返回错误码而不是真正执行危险操作。但这个检查不是默认开启的因为会带来额外开销。很多开发者在 CubeMX 里生成 ThreadX 工程后并没有去配置这个选项所以非法调用根本不会被拦截。你在中断里调用了_tx_thread_sleep它可能不会立刻报错而是进入一段错误的数据操作最后以 HardFault 收场。而且由于崩溃点不在调用点排查起来特别困难。我的建议是在调试阶段打开TX_CALLER_CHECKING。如果崩溃前串口打印出了某个 API 的返回错误码那定位就快多了。4.3 一种推荐的模板ISR 里只放标志位业务全部移到线程我见过很多运行稳定的 ThreadX 工程它们有一个共同的特点中断服务函数非常短短到几乎不调用任何 ThreadX API。中断里做的事情只有三类读硬件状态寄存器清除中断标志。把需要处理的数据写入一个裸数组或 DMA 缓冲区。设置一个简单的裸变量标志volatile 类型或者仅仅唤醒一个线程。真正的业务处理比如解析协议、处理数据、发送信号量全部放在线程上下文中完成。线程之间通过 ThreadX 的信号量、事件标志组、消息队列来同步。中断的服务函数本身不触碰内核对象这样无论中断怎么嵌套都不会出现内核数据竞争。这个模式在某些人看来可能显得“多了一道工序”但它能避免一整类随机故障。对于实时性要求极高的裸中断处理完全可以在中断里直接操作外设寄存器处理完后调用一个通知线程的标志如果连这个通知都不想调那就用中断触发另一个可屏蔽优先级的中断由那个中断去和 ThreadX 交互。我整理了一个简单的对照表帮助检查自己的中断服务代码场景是否可以这样操作说明中断里调用_tx_semaphore_put允许但不建议在高优先级穿透中断里调用中断里调用_tx_event_flags_set允许同上中断里调用_tx_thread_sleep禁止会阻塞中断上下文必然出问题中断里调用_tx_mutex_put禁止mutex 有所有权概念不能在中断释放中断里调用_tx_thread_resume有条件需要确认目标线程确实处于挂起状态定时器回调里调用阻塞 API禁止定时器上下文同样不能阻塞5. 被忽略的 M33 特性TrustZone 隔离、FPU 上下文与中断服务程序入口约定5.1 TrustZone 开启后的“伪 HardFault”STM32H563 用的是 Cortex-M33 内核支持 TrustZone 安全扩展。如果你的工程启用了 TrustZone线程跑在非安全侧ThreadX 也在非安全侧那么中断向量表、中断处理函数、所有被中断访问的资源都要仔细检查安全属性。一个很常见的坑是非安全中断服务程序访问了一个被标记为 Secure 的外设寄存器或内存区域这时候会触发 SecureFault而不是普通的 HardFault。但如果你没有实现 SecureFault_Handler或者错误处理逻辑没有打印出明确信息它看起来就像普通 HardFault。加上嵌套中断的时序影响很多人会误以为还是栈问题绕了不少弯路。遇到这种情况去看SCB-CFSR里的安全错误状态位以及 SAUSecurity Attribution Unit和 MPU 的配置基本能定位到是不是安全属性访问违规。如果项目不需要 TrustZone就直接关闭它或者保证所有代码和数据都放在非安全区域不要混合使用安全和非安全状态。5.2 FPU 的惰性压栈在嵌套中断里放大了主栈消耗Cortex-M33 的 FPU 压栈是“惰性”的。简单说并不是你只要开了 FPU每次中断都会压浮点寄存器而是当当前上下文确实使用了 FPU 时异常入口才会压入完整的浮点栈帧。这个机制节省了普通中断的栈开销但它在嵌套中断场景下会放大主栈消耗。假设低优先级中断 A 正在执行浮点运算此时高优先级中断 B 抢占进来。由于 A 使用了 FPUA 在入栈时就已经把浮点上下文压到主栈上了占用了 104 字节。B 的 ISR 如果也使用了 FPUB 会在自己的中断入口再压一份 104 字节的浮点上下文。两级嵌套就是 208 字节加上函数调用和局部变量主栈很容易被击穿。所以如果你在主栈本来就偏小的情况下又让多个中断里都用了浮点运算嵌套崩溃的概率会明显上升。排查方法是临时把中断服务程序里的浮点运算全部去掉或者用整数运算替代再跑一下看看是否还崩。如果不再崩说明主栈容量或 FPU 上下文压栈时机有问题。我的推荐做法是中断服务函数里一律不用浮点。H563 有 FPU但它的优势主要体现在线程里的数值计算而不是中断响应。ISR 里的必要数值处理可以改成定点运算或者把原始数据先搬到内存回到线程后再做浮点处理。5.3 中断向量必须经过 ThreadX 的上下文保存/恢复包装ThreadX 在 Cortex-M 上运行时要求每个中断处理函数都要经过tx_thread_context_save和tx_thread_context_restore这两个汇编入口而不是直接裸写一个 IRQHandler。这两个入口负责保存当前线程的上下文、区分嵌套中断和顶层中断、并在退出时判断是否需要进行线程切换。如果你用 CubeMX 新生成的工程它一般会把这些包装自动生成好但如果你是从旧的工程迁移过来或者自己写了精简版的中断入口就容易出问题。嵌套中断场景下tx_thread_context_save会根据当前是否已经在异常上下文来决定保存方式。如果入口被省略ThreadX 就不知道当前已经被中断打断了一次等嵌套退出时它可能误以为这是顶层中断进而错误地执行了线程切换逻辑导致当前线程栈指针被写乱。判断自己的中断入口是否符合要求就看中断处理函数里有没有调用tx_thread_context_save以及退出时是否调用了tx_thread_context_restore。如果这两个函数缺失那就是重大嫌疑点先补上再说。6. 完整定位链路把 HardFault 现场和 ThreadX 内部状态串起来6.1 第一步把 Fault 现场完整保存下来所谓定位不是看一眼 HardFault 寄存器就完事而是要把现场保存下来和 ThreadX 的内部状态交叉比对。我的习惯是在HardFault_Handler入口处先把关键寄存器存到全局变量然后通过串口打印或日志记录。volatile uint32_t fault_msp; volatile uint32_t fault_psp; volatile uint32_t fault_hfsr; volatile uint32_t fault_cfsr; volatile uint32_t fault_pc; volatile uint32_t fault_lr; void HardFault_Handler(void) { fault_msp __get_MSP(); fault_psp __get_PSP(); fault_hfsr SCB-HFSR; fault_cfsr SCB-CFSR; // 从当前栈帧中读取故障前的 PC 和 LR // 根据异常是否使用 MSP 选择基址 uint32_t *frame (uint32_t *)fault_msp; fault_pc frame[6]; // 栈帧中 PC 的位置 fault_lr frame[5]; // 栈帧中 LR 的位置 // 打印到串口或者存储在内存中 while (1); }注意这里PC和LR的偏移是假设异常帧从当前栈顶开始实际使用时要确认自己在异常入口处有没有额外压栈。更严谨的做法是用一个 naked 汇编函数在一进入 HardFault 时就把寄存器保存下来避免嵌套调用干扰栈帧。但对于快速定位上面的代码已经足够。6.2 第二步从 CFSR 和返回地址缩小范围CFSR给出了硬件的直接线索如果CFSR里出现了STKERR或MSTKERR优先查栈问题特别是主栈。如果出现了INVSTATE说明 PC 指向了一个非法的 Thumb 状态地址往往是函数指针被破坏要往“内存越界踩踏”的方向查。如果出现了MMFAR相关的访存错误说明访问了一个不存在的地址很可能是链表指针被改坏。然后看故障前的 PC 在哪个函数里。对 Cortex-M33 来说故障前的 PC 已经从栈帧里取出来了
返回列表