做MTK平台系统稳定性调试的人,应该都遇到过这种让人抓狂的场景:设备在老化房里跑了一晚,第二天一查,黑屏重启了三四次,结果所有常规日志里干干净净,没有kernel panic、没有异常调用栈,只有一行孤零零的“WDT timeout”。如果这时候你不懂hang_detect机制,这件悬案基本就没法往下查了。这些年我在MTK平台上处理过不少类似问题,从系统挂死到偶发重启都有,这篇文章想把hang_detect这套机制从原理到实战完整讲清楚,包括它怎么判断系统挂了、现场信息存在哪、要怎样把这些信息变成可以定位问题的调用栈,以及几个让我印象深刻的排查思路和避坑点。不管你是刚接手MTK项目的新人,还是正被偶发死机折磨的稳定性工程师,都可以把它当作一份上手手册。
1. 为什么MTK平台需要一套独立的hang_detect机制
1.1 一次“死机”背后到底发生了什么
很多新手会把“系统死机”理解成硬件坏了,但在MTK这类嵌入式Linux平台上,绝大多数死机的本质是软件执行流失去控制。可能是某个驱动在关中断状态下跑了太久,可能是某个内核线程拿着自旋锁进入死循环,也可能是内存管理模块里发生了不可恢复的等待。无论哪一种,最终表现都一样:CPU不再按正常节奏调度任务,触摸、按键、显示刷新全部停摆,用户感觉到的就是“死机了”。
关键问题在于,系统挂死时又分为两种情况。一种是被动挂死,也就是内核自己发现异常,主动跳进panic流程,这时我们通常能从日志里拿到漂亮的堆栈打印。另一种是主动挂死,系统就像突然被抽走灵魂一样,静悄悄失去响应,内核根本来不及打日志。后面这种是最痛的,因为没有任何异常现场,除非有一套独立于主CPU运行机制,能在系统挂死后强行截获现场,否则这个bug就只能靠概率和运气去撞了。
MTK平台上的hang_detect机制,就是为这种“静默挂死”准备的兜底座舱。它并不依赖内核调度器正常工作,而是从芯片底层独立运行,专门监视“系统是否还在按预期呼吸”。如果发现系统已经处于濒死状态,它会在重启前做两件事:记录当前各CPU的现场信息,以及推动系统进入异常处理流程。这才是它真正的价值所在。
1.2 软件看门狗和硬件hang_detect的分工
看门狗这个概念在嵌入式领域是老生常谈了,但很多人不知道MTK平台是软件看门狗和硬件hang_detect配合工作的。软件看门狗这块,内核里就有现成的实现,比如每个CPU上的watchdog线程、hung task检测机制,它们能发现某个任务长时间不调度、某个CPU长时间关中断。但这类软件机制有个致命的弱点:检测线程本身跑在被检测的系统里,如果系统已经彻底卡死,检测线程自己也排不上CPU,那就拿不到现场。
硬件hang_detect解决的就是这个“最后一步”的问题。MTK SoC的RGU模块,可以理解成一个完全独立于主CPU核的定时器,它在芯片通电后就一直运行,等着内核或者TEE侧定期来“喂狗”——向特定硬件寄存器写入周期值。一旦超过设定时间没人来喂,RGU就判定系统挂了,随之触发一整套硬件救济流程。这套流程甚至不需要主CPU配合,因为主CPU已经失去响应能力了。
打个比方,软件看门狗像公司内部的考勤机,员工每天自己打卡,哪天有人没打卡,HR能发现异常。而硬件hang_detect更像大楼门口的安保,他根本不管公司内部谁在摸鱼,只知道老板每天应该按固定频率进出,如果连续一段时间见不到人,他会直接启动应急预案。所以这两者不是重复的机制,而是前后补位的两道防线。
2. hang_detect的工作原理与触发链路拆解
2.1 硬件侧的监控与救援动作
MTK平台对hang_detect的硬件实现,核心单元是RGU(Reset Generation Unit),有些内部文档里也叫eRGU或WDT控制器。它本身就是一个32位的硬件定时器,但和普通定时器最大的不同是:它不受软件中断屏蔽寄存器影响,也不依赖CPU时钟,哪怕主CPU因为关中断而停下所有时间片,RGU依然按照自己的节拍走。这个设计非常重要,是它能在系统完全失联时兜底的原因。
喂狗路径因人而异。在比较纯粹的Linux方案里,内核的watchdog线程会周期性调用平台驱动写RGU寄存器;在带TEE或者EVB方案的平台上,也可能由安全世界的固件承担喂狗职责。常见的超时周期有10秒、20秒、30秒几档,我记得有些项目组图省事直接把超时调到最大,这其实非常危险:系统挂死以后要等半分钟才触发恢复,现场寄存器早被各种硬件行为覆盖了。
提示:喂狗周期不是越长越好,也不是越短越好。我在实际项目里的经验是,debug版本可以设置成5~10秒,量产版本保持10~20秒,同时确保喂狗线程优先级稳定,避免因为系统瞬时高负载出现误触发。
当喂狗超时发生后,RGU内部的复位原因寄存器(如RGU_REASON、RST_SOURCE)会被写入对应的超时标志。接着硬件会按配置做两件事:要么直接触发系统软复位,要么先拉高异常引脚通知独立处理器来收集现场。支持后者的平台一般会在这段“尸体还没凉透”的窗口里,把主CPU核当前的PC、LR、SP、关键寄存器组以及一段预定义内存地址范围的数据快照保存下来。这套黑白匣子一样的逻辑,就是后面所有调试分析的原始素材。
2.2 软件侧的配合:喂狗线程与内核检测
虽然RGU是硬件兜底,但软件侧的配合必须足够可靠,否则要么喂狗线程挂了自己都不知道,要么系统明明正常但因为某个周期任务卡顿导致误复位。因此MTK平台在内核侧通常会把看门狗线程做成高优先级内核线程,并且放在每个CPU上运行,每个核各喂各的狗。有些平台还支持不同CPU核使用不同的超时窗口,方便做异构调试。
软件检测方面,Linux内核本身提供的smpboot线程会检查hardlockup和softlockup。softlockup是检测某个任务关抢占时间过长,hardlockup则利用NMI中断检测某个CPU长时间关中断。这些检测触发时一般会直接打印堆栈并且panic。注意它们的触发路径和硬件hang_detect不同,但在用户视角里看到的都是“突然黑屏重启”,所以排查时第一步就得区分是软件主动panic还是RGU超时复位。
具体到代码层面,MTK的watchdog驱动会注册平台看门狗设备,对应的wdt ops里实现了start、stop、ping等回调。喂狗函数通常叫mtk_wdt_restart或者mtk_wdt_ping,调用的核心是往WDT寄存器写一个Magic Key,再写对应计数。如果某个版本上这个写寄存器操作被驱动内部的互斥锁堵住了,或者中断被持续屏蔽,那么即使系统总体还在运行,RGU也会因为喂狗链路被阻塞而触发误复位。这种“假死”案例我在排查中遇到过不止一次。
2.3 现场信息是怎么被保留下来的
MTK平台把挂死现场留在独立分区里,这个分区一般叫expdb,全称是Exception Database。它的设计思路有点像飞机上的黑匣子,平时不占用系统资源,只有在异常复位发生后的冷启动过程中,由Bootloader去把分区里的数据读出来,再交给上层分析工具解析。
expdb里保存的信息包括但不限于:最后复位原因、每个CPU核的PC/LR/SP、异常发生时各通用寄存器值、一段最近的内核日志缓冲区,以及根据项目配置dump下来的指定内存区域快照。如果是full dump配置,整个异常相关的内存段都会被保留,体积会大不少;如果是mini dump,只有精简的寄存器级信息。量产机上跑稳定性测试建议用mini dump,出问题后先做快速定位,不够用再切回full dump复现。
这个分区能不能被正常读写,是调试工作成败的基础。我见过有些项目的expdb分区在打包固件时没被正确初始化,或者升级流程里被格式化掉了,导致每次挂死都查不到现场。建议在项目稳定化初期就手动验证一下expdb的读写流程:人为制造一次panic,重启后看看expdb里有没有对应记录。
3. 实战调试:从抓日志到定位根因
3.1 第一件事:判断这次重启到底是不是hang_detect干的
拿到一台挂死重启过的机器,不要急着翻各种日志,先回答一个问题:这次复位是哪条路径触发的?最快的办法是查看复位原因寄存器,比如在root shell下读取对应的sysfs节点,cat /proc/reboot_reason是比较常见的入口。如果显示的是watchdog复位或者类似字段,说明是hang_detect这条路走到了最后;如果显示的是kernel panic,那说明内核自己先崩掉了,RGU只是兜底复位的那只手。
真正干过调试的人都知道,这一步判断错了后面全是白忙活。因为如果问题根因是某次空指针异常导致panic,你非要去翻看门狗超时链路,方向就完全跑偏了。拿到整机日志后,我最先搜的关键字是“WDT timeout”、“RGU reset reason”、“expdb”,按时间线把复位前后的日志切开。如果能看到内核主动打印task stack,多为软看门狗/hung task触发;如果只有硬件复位标志,那就直奔RGU超时这条线。
日志块里通常会看到类似下面的记录,不同内核版本和平台可能有差异,但字段大意相同:
[ 1234.567890] mtk_wdt: WDT timeout, reset reason: 0x00000003 [ 1234.567901] mtk_hang_detect: hang detected on CPU0 [ 1234.567905] mtk_hang_detect: pc = 0xffffffc000812345 [ 1234.567910] mtk_hang_detect: lr = 0xffffffc000812abc看到“hang detected on CPU0”这类信息,就说明系统确实在RGU超时前被识别为挂死状态了。接下来要做的,是把这里的PC和LR变成你能看懂的调用栈。
3.2 符号化堆栈:把地址变成你能读懂的调用栈
从expdb或者串口日志里拿到的PC值,只是一个虚拟地址,例如0xffffffc000812345。要把这个地址翻译成函数名、文件名和行号,最直接的工具是addr2line。注意一定使用和当前固件完全匹配的vmlinux,符号表对不上时你只会得到一堆乱码或者错误的行号。
基本命令形如:
aarch64-linux-gnu-addr2line -e vmlinux -f -C 0xffffffc000812345输出可能是foo_irq_handler加对应的源码行号。如果手边没有vmlinux,也可以用内核编译产物里的System.map做近似定位,它至少能告诉你这个地址落在哪个函数的地址区间里。再懒一点的方法是把地址直接和/proc/kallsyms里的符号做对比,但要注意KASLR影响,有时候打印出来的虚拟地址并不是链接时的原始地址,需要先做偏移校准。
拿到函数名还不算完,更有效的方法是进一步反汇编。用GDB加载vmlinux之后,对PC地址附近的代码做反汇编:
(gdb) file vmlinux (gdb) x/20i 0xffffffc000812340这样能看到这个PC落在哪条指令上,从而判断当时大概是死循环里、异常分支里还是在等待某个硬件状态的轮询里。我遇到过不少PC落在wfe等待指令上的案例,看起来像死循环,实际是等待中断唤醒,问题核心就变成“这个中断为什么一直不来”。
3.3 用expdb和串口日志交叉定位关键现场
有了PC和调用栈,下一步是把expdb里的信息和串口日志做交叉比对。读取expdb分区的姿势比较多,最简单的方法是通过root shell把分区直接拷贝出来:
adb root adb shell dd if=/dev/block/platform/bootdevice/by-name/expdb of=/data/local/tmp/expdb.bin adb pull /data/local/tmp/expdb.bin拿到expdb.bin后,用MTK配套的解析工具(常见的是FRP或DataLog工具)打开,也能直接用文本方式查看其中保存的几个关键寄存器字段。解析工具不是每家都有,如果没有,可以退而求其次,用串口日志里的最后几十行来还原现场。串口的优势在于它记录了完整的时间线和驱动打印,expdb的优势在于不受系统卡死影响。两者结合,往往能画出比单一数据源更完整的事故现场图。
举个我自己的案例:某外设DMA传输出bug,expdb显示CPU3的PC在一个DMA等待函数里,但单看这个位置完全看不出问题。后来去翻串口日志,发现最后十几行是同一个DMA中断被连续触发了上百次,而驱动的状态机没有正确切换。把这两个信息放在一起,根因立刻清晰:中断风暴导致某个CPU上下文一直出不来,最终被RGU识别为超时。所以我的习惯是,每次拿到expdb,都一定花时间整理最后一屏串口log,交叉比对后再下结论。
4. 高频挂死场景与排查思路实录
4.1 中断风暴:日志里全是同一条中断
中断风暴是MTK平台挂死场景里出现频率最高的原因之一。症状非常有辨识度:系统的hardlockup检测先触发,日志周期性打印某个CPU卡在中断上下文,随后RGU超时复位。PC地址通常落在同一个中断处理函数里,而且你会在.last最后一段日志里看到同一个中断号被反复打印。
排查的第一步是看/proc/interrupts,对比挂死前后各中断号的触发次数增长趋势。如果某个中断的触发次数在几秒内暴涨到几十万次,基本可以确定中断配置有问题。常见原因不外乎三种:中断请求线被恒拉低/拉高导致沿触发不断置起;设备驱动没有正确清除中断挂起位;还有一种是ISR里做了耗时太长的I2C/SPI读操作,导致中断还没处理完,新中断又来了。
修复方向上,硬件修改通常是调整外设的电气逻辑,软件修改则多半在ISR入口加防抖、检查中断状态位是否满足条件再执行后续操作。调试这种问题的关键技巧是:在ISR最开始加一个专用计数器,每次进入加一,并把当前的计数和进入时间点通过trace保存下来。这样即使系统最终挂死,最后一段日志也能清楚还原中断被触发的节奏。
4.2 死锁与锁持有时间过长
死锁和中断风暴的表现完全不同,它往往是安静的。系统还在运行,其他任务可能还能响应,但某个关键路径被永久卡住,比如文件系统、内存管理,最终引发连锁反应。这时候你看到的可能是hung task日志,某个任务的调用栈停留在等待锁的__mutex_lock_slowpath或者wait_for_completion上。
排查死锁问题,我强烈建议直接依赖内核的lockdep机制。只要把CONFIG_PROVE_LOCKING打开,内核会在每次锁操作时检查锁的依赖图。AB-BA死锁这种经典问题,lockdep可以在第一次出现时就打印出异常调用栈,准确指出两把锁的获取顺序冲突点。如果项目里的内核没有开lockdep,那么在稳定性测试前就要额外注意:跑挂死问题复现时,把smp_affinity、CPU调频这些干扰因素先关闭,尽量稳定复现环境。
也有一种比较难缠的“假死锁”:某个驱动在关中断状态下持有锁后去等待硬件,但硬件因为时序问题再也给不了回应,锁被无限期保持。这种问题lockdep并不会报死锁,因为你实际上只有一个持锁者。我的排查习惯是,用echo t > /proc/sysrq-trigger导出所有任务栈,找到那个长期处于D状态的任务,然后去翻它持锁后最后访问的设备寄存器。D状态说明该任务已经完成锁获取,只是在等待某个硬件条件,问题的根子往往在硬件状态没有正确恢复。
4.3 内存踩踏与内核态死循环
内存踩踏导致的挂死是最难从表象判断的。这类问题有时候表现成随机panic,有时候表现成完全看不出关联的挂死重启。最典型的一种是:某驱动用DMA写到缓冲区时越界,把内核某个关键结构体给覆盖了,系统带伤运行一会儿后出现异常行为,随后进入hang_detect兜底复位。
这类问题的排查,第一步要做的是缩小范围。开KASAN(如果平台支持)能在内存访问越界时第一时间报出精确的读写地址和调用栈,这是效率最高的手段。其次是打开CONFIG_PANIC_ON_OOPS,让内核在第一次oops时就主动panic,而不是继续带伤运行,避免后续的现场信息被干扰。page_owner也可以开,用于后续分析内存分配与释放的归属。
另一种情况是内核态死循环,典型的如某驱动在轮询硬件状态时没有添加超时退出逻辑,状态寄存器异常后永远等不到预期值,于是CPU就在一个while里空转。这种问题在expdb里看到的是PC始终落在一个很小的指令区间内,并且是纯计算指令或ldr/cmp/b.ne的组合。修复思路是给所有硬件状态轮询都加上超时上限,并且超时后打印寄存器现场退出,而不是死等。
5. 常见问题与排查技巧速查
5.1 一张表快速对应“现象-原因-动作”
调试排障的时候,一张精炼的对照表比什么教程都好用。按照我在MTK平台上的经验,最常见的几类挂死表现和应对思路整理如下:
| 现象 | 可能原因 | 优先排查动作 |
|---|---|---|
| 重启后日志无panic,只有WDT timeout | 硬件看门狗超时,常见于驱动死循环、中断风暴 | 查复位原因寄存器,抓expdb,看PC/最后日志 |
| 某个任务长时间D状态后触发hung task | 死锁、持锁等待硬件 | echo t > /proc/sysrq-trigger,看调用栈,开lockdep |
| CPU占用100%但系统不挂 | 用户态或内核态死循环 | top定位进程,perf采样,查PC是否落在固定区间 |
| 随机偶发重启,PC每次都不一样 | 内存踩踏、UAF、DMA越界 | 开KASAN、开PANIC_ON_OOPS,按内存问题思路排查 |
| 驱动加载后必现挂死 | 中断配置错误、设备树问题 | 检查interrupts属性,单步验证ISR,查dmesg早期日志 |
这张表不追求覆盖所有可能性,但足够应付大多数偶发或者必现的挂死问题。实际操作中,一张纸、一台串口线、一条adb命令,比一整套高大上的调试平台管用得多。
5.2 调试环境的几个关键开关
调试环境的好坏直接决定问题排查效率,这里有几个开关是我跑稳定性之前必检查的:
kernel.hung_task_panic=1:让hung task直接panic,而不是只打日志。这样能在现场还热的时候立即留证据。kernel.softlockup_panic=1和kernel.unknown_nmi_panic=1:软锁检测和未知NMI触发后主动panic,防止带伤运行。CONFIG_PANIC_ON_OOPS=y:第一次oops就panic,避免后续状态干扰。CONFIG_PROVE_LOCKING=y:刷死锁问题专用产品,有性能开销,但排查价值极高。- expdb完整保留:确保量产版本分区没有被错误格式化,同时确认复位原因能写进分区。
这些开关不一定每个项目都适合无条件打开,比如lockdep在量产机上开着会影响性能,所以我的习惯是:量产版跑兼容性和压力测试,debug版专门跑挂死复现。每次开测前,把开关状态和固件hash记录进测试报告,这样复测时能排除“环境差异导致结果不同”的干扰。
6. 写在最后的调试心得
干MTK系统稳定性调试这几年,我最大的体会是:不要一上来就对着PC地址死磕,先花三分钟把复位路径梳理清楚。很多问题看似随机,实际上有很强的规律性,比如“某个外设跑完一次完整读写后几分钟内必挂”,这种线索价值千金,比任何日志都直观。
现场信息不会骗人,但工具解析会骗人。vmlinux版本不对、KASLR偏移没校准、expdb解析工具版本过旧,都可能把一个正常地址翻译成完全无关的函数。我吃过这个亏,所以现在每次接手一个问题,第一件事就是确认固件hash和符号文件严格对应。
最后分享一个“土办法”:如果你怀疑某个驱动存在死循环,但没有硬件调试器在手边,可以在怀疑的循环体里加一个计数器,定期把这个计数写入一块不会被覆盖的寄存器或者专用日志节点,通过串口观察它是否还在增长。这个办法虽然笨,但往往能在没有GDB、没有完整符号的条件下,把问题范围压缩到很小的区间。调试是个手艺活,工具是辅助,真正起决定作用的还是分析和还原现场的能力。