☰
RT-Thread下FreeModbus与EasyFlash死锁排查:CmBacktrace实战定位
2026/10/15 21:25:19 网站建设 项目流程

1. 问题现场还原与排查思路

1.1 卡死现象的具体表现

先说清楚我遇到的现场。项目基于 RT-Thread 操作系统,跑的是 FreeModbus 协议栈做从机,参数存储用的是 EasyFlash。正常运行时一切平稳,但一旦触发参数写入操作,比如上位机下发指令让从机保存新的波特率或从机地址,系统就会在某个不确定的时刻彻底卡死。串口不再响应任何 Modbus 请求,调试串口也没有任何异常打印,看门狗如果没开就是无限期挂起,开了就是不断复位。

这种卡死最让人头疼的地方在于它的不确定性。有时候连续写十几次都没事,有时候第一次写就挂。用调试器挂上去看,发现主线程停在某个循环里出不来,但调用栈信息因为优化等级和栈回溯的限制,根本看不全。一开始怀疑是 Flash 操作时间过长导致 Modbus 超时,但把超时时间放大后问题依旧,说明根因不在协议层。

1.2 为什么常规调试手段失效

在裸机环境下,HardFault 通常有明确的异常向量入口,可以打印 LR、PC 等寄存器定位。但在 RT-Thread 这种多线程环境下,问题变得复杂得多。线程栈是独立的,异常发生时 CPU 可能正在执行任意一个线程的代码,而 RT-Thread 的线程切换又会让现场变得难以还原。

更麻烦的是,EasyFlash 的擦写操作会关中断或者进入临界区,如果此时恰好有中断触发或者线程调度被阻塞,就会形成死锁。传统的 printf 调试在这里基本没用,因为卡死的时候连打印都出不来。用 J-Link 单步调试又因为时序敏感,往往单步能过、全速就挂,典型的海森堡 bug。

1.3 引入 CmBacktrace 的决策逻辑

我需要一个能在现场还原调用栈的工具,而不是靠猜。CmBacktrace 是 RT-Thread 社区里比较成熟的栈回溯组件,它能在 HardFault 或者断言失败时,自动打印出函数调用关系、当前线程信息、甚至局部变量的地址。这比单纯看寄存器值直观太多了。

选它的理由很直接:第一,它和 RT-Thread 集成度高,直接通过软件包就能拉进来;第二,它支持 ARM Cortex-M 系列的内核,正好匹配我用的芯片;第三,它输出的信息足够详细,能直接定位到是哪个函数、哪一行触发了异常。相比自己写 HardFault 处理函数去手动解析栈帧,CmBacktrace 省去了大量底层汇编的适配工作。

注意:CmBacktrace 依赖编译时生成的符号表文件(通常是 .axf 或 .elf),如果编译时开了高等级优化,函数可能被内联,栈回溯的准确度会下降。建议在排查阶段把优化等级降到 -O0 或 -Og。

2. CmBacktrace 的移植与配置要点

2.1 在 RT-Thread 中集成 CmBacktrace

集成过程本身不复杂,但有几个坑点需要提前说清楚。在 RT-Thread Studio 或者 Env 工具里,通过 menuconfig 进入软件包列表,找到 CmBacktrace 并勾选。它会自动把源码加入工程,但串口输出设备需要手动指定。

/* 在 rtconfig.h 中确认以下宏定义 */ #define PKG_USING_CMBACKTRACE #define CMBACKTRACE_USING_DEVICE #define CMBACKTRACE_USING_DEVICE_NAME "uart1"

这里的uart1要换成你实际用于调试打印的串口设备名。如果写错了,异常发生时什么都不会输出,你会以为 CmBacktrace 没生效,其实是打印通道没配对。

另外,CmBacktrace 需要一个非易失存储区域来保存异常现场,方便复位后读取。通常做法是在 Flash 中划出一小块区域,通过 FAL 分区表来管理。如果项目里已经用了 EasyFlash,可以直接复用它的分区,但要注意不要和参数存储区冲突。

2.2 关键配置项与参数解读

CmBacktrace 的配置项不多,但每一个都影响回溯效果。下面这张表是我实际调试后总结的推荐值:

配置项推荐值说明
CMBACKTRACE_PRINTF_LINE1打印行号,定位更精确
CMBACKTRACE_PRINTF_MSG1打印自定义断言信息
CMBACKTRACE_PRINTF_ELF1打印 ELF 文件路径,方便 addr2line
CMBACKTRACE_PRINTF_STACK1打印栈内容,用于深度分析
CMBACKTRACE_USING_OSRT-Thread指定操作系统类型

其中CMBACKTRACE_PRINTF_STACK会输出一大段十六进制数据,看起来乱,但当你需要分析栈溢出或者野指针时,这些原始数据就是救命稻草。我一般只在深度排查时打开,平时关掉以减少串口输出量。

2.3 符号表与地址解析的配合

CmBacktrace 输出的调用栈是一串地址,比如0x08001A3C,你需要用工具把这些地址翻译成函数名和行号。RT-Thread 提供了addr2line工具,在 Env 环境下可以直接调用:

addr2line -e rtthread.elf -f -p 0x08001A3C

输出会类似HardFault_Handler at .../context_rvds.S:123,这样你就知道异常发生在哪个函数了。如果地址解析出来是??,说明符号表不匹配,大概率是编译后没有重新生成 elf 文件,或者烧录的固件和 elf 不是同一次编译的产物。

实操心得:每次修改代码后,务必保存对应的 elf 文件,并按版本号归档。我吃过亏,现场抓到的地址用新编译的 elf 去解析,结果完全对不上,白白浪费了半天时间。

3. 卡死根因的定位与代码分析

3.1 抓取异常现场的第一手数据

配置好 CmBacktrace 后,重新复现卡死。这次串口输出了关键信息:

[HardFault] Current thread: tshell Stack frame: 0x0800A1B2 0x0800C3D4 0x0800E5F6 ...

看到Current thread: tshell时我愣了一下,因为 Modbus 从机线程是独立的,为什么异常发生在 shell 线程?继续往下看调用栈,发现最顶层的地址解析后指向easyflash_env_set函数,再往下是fal_flash_write,然后是rt_mutex_take。

这就很清楚了:EasyFlash 在写入时尝试获取一个互斥量,但这个互斥量已经被其他线程持有,且持有者无法释放。而 shell 线程恰好也在操作 EasyFlash,两者形成了竞争。

3.2 FreeModbus 与 EasyFlash 的线程竞争分析

FreeModbus 的从机线程在收到写参数指令后,会调用 EasyFlash 的 API 保存数据。EasyFlash 内部使用 RT-Thread 的互斥量来保护 Flash 操作。问题在于,FreeModbus 的线程优先级和 shell 线程优先级设置不当。

我的配置里,Modbus 线程优先级是 12,shell 线程是 20(数值越小优先级越高)。正常情况下 Modbus 线程会抢占 shell 线程,不会出问题。但 EasyFlash 在擦除 Flash 时会关闭中断并进入临界区,此时如果 shell 线程正在等待同一个互斥量,而 Modbus 线程又在临界区内等待一个被 shell 线程持有的资源,就形成了死锁。

更隐蔽的是,EasyFlash 的env_set操作不是原子的,它可能分多次写入。如果在两次写入之间发生了线程切换,而切换后的线程又去读同一个环境变量,就会读到中间状态,触发断言失败,进而进入 HardFault。

3.3 从调用栈到代码行的精确定位

用 addr2line 解析出具体行号后,我定位到 EasyFlash 的ef_env_set函数中有一处:

/* 伪代码示意 */ rt_mutex_take(&ef_lock, RT_WAITING_FOREVER); /* 擦除操作 */ fal_flash_erase(...); /* 写入操作 */ fal_flash_write(...); rt_mutex_unlock(&ef_lock);

问题出在RT_WAITING_FOREVER上。如果互斥量因为某种原因没有被释放,这里就会永久阻塞。而 FreeModbus 线程在等待这个互斥量时,又持有 Modbus 协议栈的内部锁,导致整个协议栈瘫痪。

进一步排查发现,shell 线程在某个命令中调用了ef_get_env,而ef_get_env内部也去拿同一个互斥量。但 shell 线程在拿锁之前,已经持有了控制台输出的锁。Modbus 线程在写 Flash 时,如果恰好触发了日志输出,就会去拿控制台锁,形成AB-BA 死锁。

4. 解决方案与代码修改实录

4.1 互斥量超时机制的引入

最直接的修复是给所有 EasyFlash 的互斥量操作加上超时,而不是RT_WAITING_FOREVER。修改后的代码:

if (rt_mutex_take(&ef_lock, rt_tick_from_millisecond(500)) != RT_EOK) { rt_kprintf("EasyFlash lock timeout!\n"); return -RT_ETIMEOUT; }

这样即使发生竞争,也不会永久阻塞,而是返回错误码,让上层决定重试还是放弃。500ms 这个值是根据 Flash 擦除时间估算的,一般扇区擦除在几十毫秒量级,留足余量即可。

注意:超时时间不能设得太短,否则在 Flash 操作繁忙时会频繁超时,影响正常功能。也不能太长,否则失去了防止死锁的意义。

4.2 线程优先级与临界区的调整

光加超时还不够,得从根源上减少竞争。我把 Modbus 线程的优先级调整为 8,shell 线程调整为 15,确保 Modbus 操作 Flash 时不会被 shell 打断。同时,在 EasyFlash 的擦写操作外层,用rt_enter_critical和rt_exit_critical包起来,禁止线程调度:

rt_enter_critical(); ef_set_env("baudrate", "115200"); rt_exit_critical();

但这里有个权衡:临界区太长会影响系统实时性。所以我把一次大的写入拆成多次小的写入,每次写入后退出临界区,让其他线程有机会运行。

4.3 参数写入的原子性保障

EasyFlash 本身提供了ef_set_env的原子性保证,但前提是调用者不并发操作。我在应用层加了一个写入队列,所有参数修改请求先入队,由一个专门的低优先级线程串行处理。这样彻底避免了多线程同时写 Flash 的情况。

/* 简化的队列处理逻辑 */ while (1) { if (rt_mb_recv(&ef_mb, &msg, RT_WAITING_FOREVER) == RT_EOK) { rt_mutex_take(&ef_lock, RT_WAITING_FOREVER); ef_set_env(msg.key, msg.value); rt_mutex_release(&ef_lock); } }

这个改动虽然增加了代码量,但稳定性提升非常明显。实测连续写入一千次参数,没有再出现卡死。

4.4 验证与压力测试结果

修改完成后,我做了三轮测试。第一轮是正常功能测试,Modbus 读写参数各一百次,全部通过。第二轮是并发压力测试,用脚本同时触发 shell 命令和 Modbus 写参数,持续运行两小时,没有死锁。第三轮是异常注入测试,人为在 Flash 操作时触发中断,观察系统是否能恢复,结果 CmBacktrace 正确捕获了异常并打印了调用栈,系统复位后正常工作。

测试过程中,CmBacktrace 的输出帮我确认了每次异常的具体位置,没有它的话,这些测试只能靠“跑通了”和“跑挂了”来判断,效率低太多。

5. 常见问题速查与避坑指南

5.1 CmBacktrace 输出乱码或不输出

这是最常见的问题,原因通常有三个。第一,串口波特率不匹配,检查CMBACKTRACE_USING_DEVICE_NAME对应的设备是否初始化正确。第二,中断优先级配置错误,HardFault 的优先级如果低于串口中断,打印会被阻塞。第三,栈空间不足,CmBacktrace 本身需要一定的栈来格式化输出,如果异常发生时栈已经溢出,它也无法工作。

排查方法:先在正常运行时调用cm_backtrace_init并手动触发一次cm_backtrace_assert,看能否正常输出。如果正常,说明配置没问题,问题出在异常现场本身。

5.2 地址解析出来是问号

地址解析失败,九成是 elf 文件不匹配。确认三点:编译时是否生成了 elf 文件;烧录的固件是否来自这次编译;addr2line 的路径是否指向正确的 elf。如果用的是 RT-Thread Studio,可以在项目属性里勾选“生成调试信息”,确保-g编译选项开启。

另外,如果函数被内联或者优化掉了,地址可能指向一个不存在的符号。这时候把优化等级降到 -O0 重新编译,再复现一次,通常就能解析出正确的函数名。

5.3 卡死问题反复出现但调用栈不同

这种情况说明系统存在多个竞争点,不是单一 bug。我的经验是,先把所有涉及共享资源的操作列出来,包括 Flash、串口、全局变量、互斥量,然后逐一检查是否有嵌套加锁的情况。特别要注意在持有锁的时候调用可能阻塞的函数,比如rt_kprintf、rt_malloc、rt_thread_delay,这些都是死锁的常见诱因。

如果资源太多理不清,可以先用 CmBacktrace 抓几次异常,看看调用栈有没有共同的函数。通常竞争会集中在某几个热点函数上,优先解决这些,问题就能缓解大半。

5.4 EasyFlash 写入耗时过长导致 Modbus 超时

EasyFlash 的擦除操作在有些 Flash 芯片上需要几百毫秒,而 Modbus 的超时时间通常只有几十毫秒。这时候即使没有死锁,也会因为响应太慢而被上位机判定为超时。

解决办法有两个:一是把参数写入放到后台线程,Modbus 线程立即返回“写入中”的状态,写完后再通知;二是使用 EasyFlash 的ef_set_env_blob批量写入,减少擦除次数。我采用的是第一种方案,配合一个状态机来管理写入进度,上位机轮询查询写入结果。

5.5 常见问题速查表

现象可能原因排查方法解决措施
CmBacktrace 无输出串口设备名错误检查 rtconfig.h修改为正确设备名
地址解析为 ??elf 不匹配核对编译时间重新编译并保存 elf
卡死在互斥量死锁查看调用栈加超时、调整优先级
写入后系统复位栈溢出检查线程栈大小增大栈或减少局部变量
Modbus 超时Flash 擦除慢测量擦除时间异步写入或批量写入

最后分享一个我踩过的坑:CmBacktrace 的栈回溯依赖编译器生成的栈帧信息,如果用了-fomit-frame-pointer优化,回溯会失败。排查阶段务必关闭这个选项,等定位到问题后再根据实际情况决定是否开启。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询