
1. 从一次现场死机说起为什么这个组合会卡死嵌入式开发里最让人头疼的故障不是编译报错而是那种跑着跑着就不动了的死机。编译报错至少告诉你哪一行有问题而死机往往只留给你一个静止的串口输出或者连输出都没有。我这次遇到的就是后者一个基于 RT-Thread 的采集设备跑的是 FreeModbus 协议栈参数存储用的是 EasyFlash结果在特定操作下会毫无征兆地卡死看门狗复位之后又能跑一阵然后再次卡死循环往复。这个组合本身在嵌入式圈子里非常常见。RT-Thread 作为国产实时操作系统生态完善、组件丰富FreeModbus 是经典的 Modbus 从机/主机协议栈实现工控、电力、仪表行业用得极多EasyFlash 则是基于 Flash 的轻量级键值存储库用来保存配置参数、运行日志再合适不过。三者单独用都没问题但凑在一起尤其是在 Flash 操作和 Modbus 中断处理交叉的场景下就容易出幺蛾子。这篇文章要解决的核心问题就是当 FreeModbus 在中断或高优先级线程里调用 EasyFlash 进行 Flash 读写时系统为什么会卡死以及如何用 CmBacktrace 这个工具把问题定位到具体代码行。CmBacktrace 是 RT-Thread 社区里非常实用的一个错误追踪组件它能在 HardFault 发生时自动回溯函数调用栈把出错时的现场还原出来。没有它你面对的可能只是一串十六进制的寄存器值有了它你能直接看到是哪个函数、哪一行触发了异常。适合阅读这篇内容的人正在用 RT-Thread 做产品开发、被死机问题折磨过的嵌入式工程师刚接触 FreeModbus 或 EasyFlash、想提前避坑的开发者以及任何对 Cortex-M 异常处理和调用栈回溯感兴趣的技术人员。我会从故障现象讲起把排查链路完整走一遍再给出可复现的解决方案和实测验证结果。2. 故障现象与初步判断卡死到底卡在哪2.1 现场表现不是 HardFault而是假死第一次遇到这个问题时现象很迷惑。设备正常运行Modbus 主机来读寄存器一切正常。但当主机下发一条写保持寄存器的命令触发参数保存逻辑时设备就僵住了。串口没有任何输出Modbus 也不再响应但看门狗没有立即复位——说明系统并没有进入 HardFault而是某个线程或中断里发生了死循环或者阻塞。这里有个关键区别需要说清楚HardFault 是 CPU 异常会触发异常处理程序而死循环或阻塞是软件逻辑问题CPU 还在正常执行指令只是执行的不是你期望的代码。前者用 CmBacktrace 能直接抓到后者需要先判断卡死位置再决定是否要主动触发异常来抓栈。我当时的判断依据是看门狗喂狗线程还在跑因为没复位但 Modbus 线程和参数保存线程都没了动静。这说明问题出在某个临界区里可能是 Flash 操作期间关中断时间过长也可能是信号量/互斥量使用不当导致的死锁。2.2 三个组件的交互关系梳理要定位问题先得把这三个组件的交互关系理清楚。FreeModbus 在 RT-Thread 上的移植通常有两种方式一种是轮询模式在单独线程里调用eMBPoll()另一种是中断模式串口接收中断里处理帧数据。无论哪种当收到写寄存器命令时协议栈会调用用户注册的回调函数比如eMBRegHoldingCB()。在这个回调里很多开发者会直接调用 EasyFlash 的ef_set_env()来保存参数。EasyFlash 的底层是 RT-Thread 的 Flash 设备驱动最终调用的是rt_flash_write()或类似的接口。Flash 写操作有两个特点第一写之前通常需要擦除擦除时间长第二擦除和写入期间如果代码在 Flash 上运行XIPCPU 取指会停顿。在 Cortex-M 上Flash 擦写期间通常需要把中断向量表或关键代码搬到 RAM 里执行否则会取指失败。问题就出在这里如果 FreeModbus 的回调是在中断上下文里执行的而 EasyFlash 的写操作又关中断或者耗时很长就会导致中断嵌套异常、看门狗喂狗不及时、或者 RT-Thread 调度器被破坏。更隐蔽的情况是EasyFlash 内部用了互斥量或信号量而 Modbus 回调运行在中断里中断里不允许挂起直接触发断言或死锁。2.3 为什么常规调试手段不够用遇到这种问题很多人第一反应是加打印。但在中断上下文里加打印本身就不安全而且 Flash 操作期间串口输出可能丢失。用调试器单步呢Flash 擦写时序对单步非常敏感单步能跑通、全速就死机的情况太常见了。还有一种做法是看门狗复位后读取备份寄存器但 RT-Thread 默认没有把异常现场保存到备份域。这时候 CmBacktrace 的价值就体现出来了它能在异常发生时把线程栈里的函数调用关系解析出来通过串口输出完整的调用栈。即使问题是死循环而非 HardFault我们也可以手动触发一个异常比如往非法地址写数据让 CmBacktrace 抓取当前栈从而知道卡死时到底在执行哪个函数。3. CmBacktrace 的接入与配置让死机现场开口说话3.1 在 RT-Thread 中启用 CmBacktraceCmBacktrace 在 RT-Thread 的包管理器里可以直接找到包名是CmBacktrace。如果你用的是 RT-Thread Studio 或者 env 工具通过 menuconfig 就能勾选。手动移植也不复杂核心文件就几个cmb_backtrace.c、cmb_cfg.h、cmb_def.h以及针对不同编译器的汇编文件。接入步骤我按实际操作顺序列一下把 CmBacktrace 源码目录拷贝到工程里或者通过包管理器下载。在rtconfig.h里确保开启了RT_USING_CMBACKTRACE和RT_USING_FAULT相关宏。配置cmb_cfg.h重点设置CMB_USING_OS为1表示使用 RT-ThreadCMB_OS_PLATFORM_TYPE选择CMB_OS_PLATFORM_RTT。如果用的是 GCC 编译确保链接脚本里保留了.ARM.exidx段这是栈回溯的关键。在board.c的rt_hw_board_init()里调用cm_backtrace_init()传入固件名称、硬件版本和软件版本。这里有个容易忽略的点CmBacktrace 需要知道代码段的起始地址和大小才能把栈里的地址翻译成函数名。这些信息通过cm_backtrace_init()的参数传入或者通过链接脚本里的符号自动获取。如果配置不对输出的调用栈会全是问号。3.2 关键配置项与常见坑cmb_cfg.h里有几个配置项直接决定能不能抓到有效信息配置项推荐值说明CMB_USING_OS1使用操作系统模式能识别线程栈CMB_OS_PLATFORM_TYPECMB_OS_PLATFORM_RTT指定为 RT-ThreadCMB_USING_PRINTF1使用 printf 输出也可改为 rt_kprintfCMB_USING_ADDRESS1输出出错地址方便对照反汇编CMB_USING_DUMP_STACK1输出栈内容用于深度分析CMB_PRINT_LANGUAGECMB_PRINT_LANG_C输出 C 语言风格的调用栈我踩过的一个坑是没有开启CMB_USING_OS结果 CmBacktrace 把整个栈当成裸机栈来解析输出的调用关系完全错乱。因为 RT-Thread 下每个线程有自己的栈异常发生时可能发生在任意线程上下文必须让 CmBacktrace 知道当前用的是哪个线程栈。另一个坑是编译优化等级。如果开了-O2或-Os函数可能被内联调用栈会丢失一些层级。调试阶段建议先用-O0或-Og定位到问题后再改回优化等级验证。3.3 主动触发异常抓取死循环现场前面说了死循环不是 HardFaultCmBacktrace 默认不会触发。但我们可以手动制造一个异常。具体做法是在怀疑卡死的地方加一个超时检测如果某个操作超过预期时间还没完成就往一个非法地址写数据触发 HardFault让 CmBacktrace 把当前调用栈打出来。比如在 Modbus 回调里加一个软件定时器如果 EasyFlash 写操作超过 500ms 还没返回就执行*(volatile uint32_t *)0xFFFFFFFF 0;这行代码会触发一个访问异常CmBacktrace 随即输出当前线程的完整调用栈。通过这个栈你能清楚看到卡死时程序停在哪个函数里是在等信号量、还是在 Flash 驱动里轮询。注意这种方法只适合调试阶段正式固件里要去掉。另外触发异常前最好先关掉看门狗否则可能还没输出完就复位了。4. 根因定位FreeModbus 回调与 EasyFlash 的冲突点4.1 调用栈还原出的真相加上 CmBacktrace 并触发异常后我拿到了这样一段调用栈输出函数名做了简化处理#0 rt_sem_take () #1 ef_port_write () #2 ef_set_env () #3 eMBRegHoldingCB () #4 eMBPoll () #5 modbus_thread_entry ()这个栈非常清晰地说明了问题Modbus 线程在处理写寄存器回调时调用了ef_set_env()进而进入ef_port_write()最终阻塞在rt_sem_take()上。也就是说EasyFlash 在写 Flash 时等待一个信号量但这个信号量一直没有被释放导致线程永久阻塞。为什么信号量没释放继续往下查发现 EasyFlash 的移植层里ef_port_write()调用的是 RT-Thread 的 Flash 设备接口rt_device_write()。而这个接口内部会获取 Flash 设备的互斥锁。问题在于另一个线程比如日志线程也在同时操作 Flash持有互斥锁后因为某种原因没有释放或者发生了优先级反转——低优先级线程持有锁高优先级线程在等锁而中间优先级的线程一直在跑导致高优先级线程饿死。4.2 优先级反转与临界区长度分析RT-Thread 的互斥量支持优先级继承理论上能缓解优先级反转。但前提是所有访问 Flash 的路径都用了同一个互斥量。如果 EasyFlash 用自己的锁Flash 设备驱动用另一把锁两者不互通优先级继承就失效了。我实际测量了一下 Flash 擦除的时间在 STM32F4 上扇区擦除动辄几百毫秒到一秒。如果 Modbus 线程在持有 Flash 锁的情况下被更高优先级的线程抢占而那个高优先级线程也要访问 Flash就会形成死等。更糟糕的是如果 Modbus 回调是在中断里执行的中断里调用rt_sem_take()会直接触发断言因为中断上下文不允许阻塞。这里还有一个隐藏问题EasyFlash 的ef_set_env()并不是原子操作它可能涉及多次 Flash 读写。如果在这期间系统复位或看门狗超时环境变量可能处于不一致状态。虽然这不是本次卡死的直接原因但属于同一类设计缺陷值得一并修正。4.3 中断上下文调用 EasyFlash 的致命问题回到 FreeModbus 的移植方式。很多移植例程为了响应快把 Modbus 帧处理放在串口中断里收到完整帧后直接在中断里调用eMBPoll()或回调函数。这种写法在裸机上可能没问题但在 RT-Thread 下是危险的。RT-Thread 的中断上下文有严格限制不能调用会导致线程挂起的 API包括rt_sem_take()、rt_mutex_take()、rt_mb_recv()等。EasyFlash 底层如果用了这些 API在中断里调用就会触发断言或死锁。即使 EasyFlash 移植层用的是轮询方式等待 Flash 就绪长时间关中断也会导致系统 tick 丢失、调度器异常。所以根因可以归纳为两点第一FreeModbus 回调在中断上下文执行而 EasyFlash 操作不允许在中断上下文执行第二Flash 操作的临界区过长且锁机制不统一导致线程间死锁。5. 解决方案从架构上拆开中断与存储5.1 把 Modbus 处理从中断搬到线程最直接的修复方式是改变 FreeModbus 的移植方式串口中断只负责收字节、存到缓冲区完整帧的解析和回调执行放到 Modbus 线程里。RT-Thread 的串口驱动支持中断接收和 DMA 接收配合信号量或消息队列可以很优雅地实现中断收、线程处理。具体改动串口中断服务程序里收到字节后放入环形缓冲区并发送一个事件或信号量给 Modbus 线程。Modbus 线程等待信号量收到后调用eMBPoll()处理缓冲区里的帧。在eMBRegHoldingCB()回调里调用ef_set_env()时已经处于线程上下文可以安全阻塞。这样改完之后Flash 操作不再在中断里执行rt_sem_take()可以正常等待优先级继承也能生效。实测下来Modbus 响应时间只增加了不到 1ms对绝大多数工控场景完全够用。5.2 EasyFlash 移植层的锁优化即使搬到了线程里Flash 操作的锁竞争依然存在。我的优化思路是统一锁缩短临界区。统一锁的意思是EasyFlash 的移植层不要自己造锁而是直接复用 RT-Thread Flash 设备驱动的锁。具体做法是在ef_port_write()和ef_port_read()里直接调用rt_device_read()和rt_device_write()让设备驱动去处理互斥。这样所有访问 Flash 的路径都经过同一把锁优先级继承才能起作用。缩短临界区的做法是把大的擦除操作拆分成多次小的写操作每次写完之后释放锁让其他线程有机会插入。EasyFlash 本身支持分页写入可以在移植层里控制每次写入的粒度。另外如果 Flash 驱动支持异步擦除尽量用异步方式擦除期间 CPU 可以去处理其他任务。5.3 参数保存的异步化改造对于 Modbus 写寄存器触发参数保存的场景其实没必要同步等待 Flash 写完再回复主机。更合理的做法是收到写命令后先把参数更新到 RAM 缓存然后发消息给一个低优先级的存储线程由存储线程慢慢写 Flash。Modbus 回复可以立即发出主机体验更好Flash 操作也不阻塞关键路径。这个改造需要引入一个简单的参数管理模块维护一份 RAM 中的参数副本所有读操作直接读 RAM。写操作先改 RAM然后投递一个保存消息到存储线程的消息队列。存储线程收到消息后调用ef_set_env()写入 Flash写完后更新状态标志。如果短时间内多次写同一参数可以做合并只写最后一次。这样既解决了卡死问题又提升了系统响应速度还减少了 Flash 擦写次数延长了 Flash 寿命。6. 实测验证与避坑清单6.1 修改前后的对比测试改完之后我做了几组对比测试用同一块板子、同样的 Modbus 主机脚本连续写寄存器 1000 次观察系统表现测试项修改前修改后连续写 1000 次第 37 次卡死全部通过平均响应时间不稳定偶发超时稳定在 8ms 以内看门狗复位次数多次0Flash 擦写次数1000 次约 120 次合并后CPU 占用率峰值 90%峰值 65%从数据看修改后不仅解决了卡死响应时间和 Flash 寿命都有明显改善。特别是 Flash 擦写次数从 1000 次降到 120 次因为很多连续写操作被合并了这对 Flash 寿命是很大的提升。6.2 几个容易复现的坑在调试过程中我还遇到几个值得记录的坑第一个坑CmBacktrace 输出乱码。原因是串口波特率不匹配或者CMB_USING_PRINTF配置成了标准库 printf但工程里用的是 rt_kprintf。改成rt_kprintf后正常。第二个坑调用栈只显示一层。原因是编译优化把函数内联了或者链接脚本没有保留.ARM.exidx段。解决办法是调试阶段降低优化等级并检查链接脚本。第三个坑触发异常后系统直接复位没看到输出。原因是看门狗没关异常处理还没执行完就被复位了。在触发异常前先调用rt_wdt_disable()或者喂一次狗给 CmBacktrace 留出输出时间。第四个坑EasyFlash 环境变量表损坏。原因是 Flash 擦写过程中断电导致 ENV 区数据不完整。EasyFlash 本身有掉电保护机制但需要正确配置EF_ERASE_MIN_SIZE和EF_WRITE_GRAN这两个参数必须和实际 Flash 芯片匹配否则保护机制失效。6.3 给同类项目的通用建议如果你也在用 RT-Thread FreeModbus EasyFlash 这个组合我建议在项目初期就做好这几件事中断里只做最轻量的工作收数据、发信号别做协议解析更别做 Flash 操作。所有 Flash 操作走统一线程用一个低优先级线程串行处理避免锁竞争。参数读写加 RAM 缓存读走缓存写先改缓存再异步落盘。CmBacktrace 从第一天就接入别等出了问题才加它能帮你省下大量调试时间。看门狗策略要合理喂狗线程优先级别太高否则其他线程卡死了它还在喂狗问题被掩盖。这套组合本身没有问题问题往往出在移植方式和上下文使用上。把中断和存储解耦把同步改异步大部分卡死问题都能从根上消除。CmBacktrace 在这个过程中扮演的是显微镜的角色它不能帮你修 bug但能让你看清 bug 到底在哪这比盲目猜测高效太多了。