ARTICLE DETAIL

资讯详情

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

嵌入式Linux下Qt界面卡死排查实战:从冻结现场到定位根因

嵌入式Linux下Qt界面卡死排查实战:从冻结现场到定位根因 先交代一下背景。我们有一台基于Cortex-A系列处理器的嵌入式设备跑的是精简过的Linux系统应用层用Qt做界面。前阵子客户现场反复报一个问题设备运行一段时间后触摸和物理按键都没反应屏幕上画面就停在最后一个状态像被按了暂停键。重启之后又好跑几个小时又犯。这类“卡死”问题开发阶段几乎不会出现一到现场就冒出来而且复现时间不确定排查起来特别费劲。干嵌入式Linux的人多少都遇到过这种Qt应用莫名其妙不动的情况。它不像PC上程序崩溃那样会弹个窗口告诉你“xx已停止工作”嵌入式设备上往往就是整块屏幕冻结没有日志、没有弹窗、没有core dump只剩一颗“钉死”的系统。这篇文章就把我这次排查的思路、用的工具、踩过的坑整理出来尤其是那些常规文档里不会写的判断方法和实操细节希望能给正在被类似问题折磨的人一些参考。1. 先别急着重启把卡死问题分清楚类型很多人一上来就怀疑自己的业务代码哪里写得不对然后埋头翻代码。我的习惯是先别动手花几分钟把“卡死”到底是什么状态搞清楚。同样是界面不动背后的原因可能差着十万八千里有的是CPU被打满导致界面刷不动有的是多个线程互相等锁导致全部阻塞还有的是内核态驱动卡住把整个进程拖死。1.1 从表现上先做个粗分类我把遇到过的Qt卡死现象大致分成四类每一类的排查方向完全不一样整机完全无响应网络也ping不通基本是内核或驱动层面出问题应用层再怎么做都是白搭。界面冻结但系统还在运转网络通、日志能打印多半是Qt主线程或事件循环被阻塞。界面还能显示但刷新极慢画面偶尔跳一下往往是资源耗尽比如内存持续增长导致系统不断swap或者CPU被某个线程吃光了。一部分控件能点、一部分没反应这种经常是某个模态操作把局部事件循环卡住了。这个分类看起来简单但真的能帮我们少走弯路。我见过有人对着一个内核死锁的问题在应用层排查了两天最后发现是sdio wifi驱动的中断处理函数里有一个无限等待跟Qt代码毫无关系。1.2 嵌入式平台要额外留意的干扰项如果是普通的桌面LinuxQt应用卡死了直接gdb attach上去就能看状态。但在嵌入式平台上有几个额外的干扰项排查前最好先排除显示后端的问题。嵌入式Qt经常用linuxfb、eglfs这种直接操作framebuffer或GPU的后端如果显示驱动本身有问题表现就是屏幕不动但应用逻辑其实还在正常跑。输入设备节点异常。触摸屏驱动如果卡在读取上报事件Qt的事件处理循环会一直被堵住界面自然也刷不出来。系统内存紧张触发OOM。嵌入式设备内存普遍不大应用吃紧的时候内核会频繁回收内存进程被卡在不可中断的睡眠状态这种状态很难通过应用层日志看出来。所以在开始追代码之前我建议先把“进程还活着吗”“系统资源还够吗”“内核有没有异常”这三件事确认掉再决定要不要往应用层深挖。2. 定位卡死位置先抓现场再分析代码很多人在现场设备出问题之后第一反应是赶紧重启这是最可惜的做法。卡死问题不像崩溃它会留给我们很多现场线索但这些线索都是“易失”的一旦重启就什么都没了。正确的做法是先让设备保持原样然后尽可能多地采集运行状态。2.1 永远要在现场保留的四个信息我最常做的就是四件事看进程状态、看系统日志、看内存和CPU、抓线程调用栈。进程状态看的是进程是不是还在正常运行。如果发现进程进入D状态不可中断睡眠那基本可以断定是内核驱动的问题反复在应用层改代码没用。如果进程处于S状态但CPU占用接近0说明它是在等某个资源典型情况是等锁或者等IO。如果CPU占用很高那要去看是哪个线程在空转。系统日志主要看内核有没有报错比如内存申请失败、驱动超时、IO错误之类的。这些信息往往能直接指出故障方向。内存和CPU信息用来判断是否存在资源耗尽。线程调用栈是最有价值的现场信息它能直接告诉我们程序当时停在哪个函数里用gdb attach上去执行thread apply all bt就能拿到。2.2 用一份命令脚本把现场快速“冻结”下来现场出问题的时候往往大家心情都比较急容易漏掉信息。我把常用命令整理成了一个脚本出问题直接跑一遍把输出保存到一份带时间戳的文件里后面分析就有据可查了#!/bin/sh TS$(date %Y%m%d_%H%M%S) DUMP_DIR/tmp/crash_dump_$TS mkdir -p $DUMP_DIR # 1. 进程基本信息 ps -ef $DUMP_DIR/ps_ef.txt ps -eLo pid,tid,stat,comm,wchan:30 $DUMP_DIR/ps_thread.txt # 2. 系统整体负载和内存 top -b -n 1 $DUMP_DIR/top.txt cat /proc/meminfo $DUMP_DIR/meminfo.txt # 3. 内核日志 dmesg $DUMP_DIR/dmesg.txt # 4. 关键进程的线程调用栈 PID$(pidof my_app) if [ -n $PID ]; then echo $PID $DUMP_DIR/app_pid.txt cat /proc/$PID/status $DUMP_DIR/app_status.txt cat /proc/$PID/smaps $DUMP_DIR/app_smaps.txt for TID in $(ls /proc/$PID/task); do echo TID $TID $DUMP_DIR/threads.txt cat /proc/$PID/task/$TID/stack $DUMP_DIR/threads.txt 2/dev/null cat /proc/$PID/task/$TID/wchan $DUMP_DIR/threads.txt 2/dev/null echo $DUMP_DIR/threads.txt done fi sync echo dump saved to $DUMP_DIR这里有一个容易被忽略的点ps -eLo输出的wchan列和/proc/$PID/task/$TID/wchan都能告诉我们线程在内核里等什么东西。比如显示为wait_for_completion或者mutex_lock之类的函数名就直接指向了内核驱动里的锁等待。这个信息在应用层很难挖到。2.3 gdb attach配自动生成backtrace效率翻倍如果设备上有gdb直接attach上去看调用栈是最直观的。但现场不一定有交互式终端所以我习惯写一个gdb命令脚本让它自动执行并退出gdb -p $PID -batch -x backtrace_cmds.txtbacktrace_cmds.txt的内容set pagination off set print thread-events off thread apply all bt full detach quit注意bt full比bt多打印局部变量这对于判断是哪个分支走进死路很有帮助但输出量也大建议保存到文件里慢慢看。我在实际项目里都是让脚本把时间戳写进文件名每一次现场抓到的栈都保留下来后面比对多次故障时的共性比单看一次的结果要可靠得多。3. 从调用栈反推卡死原因带着问题看代码拿到了一份完整的线程调用栈之后真正的分析才算开始。这一步的功夫全在经验的积累上但也有一些通用的规律可以拿出来分享。3.1 主线程卡死最常见的几种栈形态嵌入式Qt应用主线程的调用栈通常会停留在几个比较典型的位置对应着几类高频问题第一种是停在QApplication::exec里面不深的位置比如QEventLoop::processEvents。这种说明事件循环还在跑但一直处理某个事件很可能是某个槽函数里写了死循环。我遇到过一个人把while循环条件写错循环里做复杂处理事件循环就卡在这个槽里出不来。第二种是停在poll或select这类系统调用上。这种表面看是阻塞等事件实际要看等的是什么。如果是触摸屏设备的文件描述符就要怀疑驱动是不是出问题了。第三种是栈底在pthread_cond_wait或pthread_mutex_lock。这个非常明确就是在等一把锁。这时候要去看等锁的线程是谁拿着锁不释放往往能顺藤摸瓜找到死锁现场。第四种比较隐蔽栈停在malloc或free上。嵌入式内存碎片化严重堆管理在高负载下会变慢特别是Qt程序频繁创建删除小对象卡在分配内存里也是真实发生过的案例。3.2 死锁问题的快速定位思路死锁是Qt多线程程序里最让人头疼的问题之一特征就是两个或多个线程互相等待对方持有的锁。判断方法其实很机械把每个线程的调用栈里锁相关的位置列出来做一个“谁在等谁的锁”的对比表。举个例子我遇到过一次典型死锁线程A拿着数据库连接的互斥锁去等待一个数据采集线程的信号量而数据采集线程先获取了信号量又试图去拿同一个互斥锁。两边都等对方就死锁了。从调用栈上看两个线程一个停在cond_wait一个停在mutex_lock把锁的名字打出来一对照问题就清楚了。解决这类问题我从那以后定了一条规矩所有加锁和解锁的代码必须成对出现在同一个函数里禁止一个函数内部解锁后还调用可能加锁的业务函数。这条规矩执行之后项目里死锁类问题基本绝迹。3.3 一个容易被忽略的Qt信号槽陷阱Qt的信号槽机制做得确实好用但在嵌入式场景里有个暗坑如果使用阻塞式连接Qt::BlockingQueuedConnection发送者会一直等待接收者的槽函数执行完才返回。如果接收者线程因为其他原因卡住了发送者线程也会被连带卡住。我在一次排查中就遇到过这样的场景采集线程通过阻塞式连接把一个大数据块传给界面线程刷新结果界面线程正在处理密集绘图任务迟迟没有进入事件循环接收跨线程信号。采集线程就一直等在那里导致整个采集链路阻塞最终界面也慢慢冻结了。从调用栈看采集线程停在QMetaCallEvent::placeMetaCall相关的位置一下就能定位到问题。4. 案例复盘三个真实卡死场景的完整排查过程前几部分讲的是方法这部分我挑三个从项目里实际遇到的典型场景把排查过程完整走一遍。这里的代码片段做了脱敏改造但思路和现象都是原汁原味的现场情况。4.1 现象一界面周期性假死系统资源却很正常这个故障表现为设备工作一段时间后Qt界面冻结但系统的网络连接正常ssh登上去也流畅。我用脚本抓现场发现应用进程还在正常运行CPU占用接近于0各线程状态都在等待。继续深入查看发现线程都阻塞在一个pthread_cond_wait上这个是Qt内部的事件等待。再仔细看阻塞的这个线程是我们自己创建的一个后台刷新线程它通过定时器每秒从传感器采集数据然后发信号给主界面更新。问题就出在这个线程里有一个QMutexLocker锁着一个全局配置文件的操作。排查代码发现主界面某个按钮的槽函数里读配置文件时需要加同一把锁这个槽函数内部又调用了QMessageBox::information做提示。这就是一个典型的“自己把自己弄死”的操作。槽函数持锁弹框而弹框需要事件循环处理窗口关闭事件但事件循环的某个路径又需要去访问那把锁形成了一种非典型死锁。解决办法是把弹框放到解锁之后再执行或者把锁的颗粒度调小。4.2 现象二运行越久越卡最后彻底僵住这个故障的特征非常明显设备刚启动时一切正常运行两三个小时后操作开始变慢再过一段时间界面彻底不动。我用top观察发现应用的内存在不断增长但又不像是传统意义上的内存泄漏因为增长到一定程度后会突然回落接着再涨上去呈现一种锯齿状。这里要提到嵌入式Linux的一个特性Qt程序频繁申请内存后进程的堆空间不会立刻还给操作系统。当内存碎片化严重时malloc耗时就会显著增加程序表现为整体性能越来越差。我用cat /proc/PID/smaps检查堆和内存映射区发现大量的虚拟内存碎片。定位到具体代码后问题出在一个绘图相关的类每次界面重绘都会新建一个QPainterPath对象用来画传感器波形。波形数据点很多这个对象需要频繁申请内存。我把这个对象改成了成员变量复用同时在数据量大的时候做了降采样减少单个路径的点数。改完之后内存锯齿消失了连续跑了一周没有再出现卡死。4.3 现象三触摸屏幕没反应但界面能自动刷新这个现象比较奇怪界面上的波形数据还在实时更新说明Qt的定时器和绘图都是正常的但手指触摸屏幕完全没反应按键也没有任何反馈。从表现上看应用的“逻辑线程”没问题出问题的是“输入通道”。我用strace跟踪应用的输入事件读取发现应用停在一个read系统调用上没有返回。这就奇怪了触摸事件如果没有输入read应该一直阻塞等待这本身就是正常的。问题在于Qt内部的输入处理部分它把触摸事件转换成鼠标事件后需要发送给当前焦点控件。如果某个控件的hit-test逻辑特别耗时触摸事件的处理就会非常慢表现为“触摸卡死”。最终定位到一个重写了mousePressEvent的绘图控件它在事件处理函数里做了复杂的坐标反算和碰撞检测数据量大的时候单次处理要几百毫秒。这个时间在用户看来就是“点了没反应”。优化计算逻辑、把碰撞检测放到后台线程后问题解决。4.4 排查全程的关键判断点总结这三个案例走下来能提炼出一个共性方法卡死问题排查的关键不是“找到那行代码写错了”而是“确定卡在哪个环节”。是事件处理环节、是渲染绘图环节、还是业务逻辑环节每一步都在缩小范围直到最后锁定到具体函数。在实际项目中我发现很多嵌入式Qt的“卡死”最终都指向几个相同的根因事件循环被长时间阻塞、锁使用不当导致死锁、内存相关操作耗时过长、输入端到端的处理延迟过高。这四类问题的定位手段各不相同但都离不开第一时间抓线程调用栈这个核心动作。5. 从代码层面预防卡死项目里的几条硬规矩吃了几次亏之后我在项目里推行了一些硬性规范从源头减少卡死出现的概率。这些规范不太复杂但执行到位的效果非常明显。5.1 主线程绝不碰耗时操作我要求所有耗时超过十毫秒的操作都不允许放在Qt主线程里执行。文件读写、数据库操作、网络请求这些统统扔到工作线程主线程只负责接收结果并刷新界面。这个约束看上去简单但真正执行起来需要反复审查代码。我常用的手段是代码评审时专门盯住每个槽函数函数体如果发现里面有循环或者IO调用就直接打回。5.2 锁的使用要遵循明确规则项目里我定了几条关于锁的硬规矩用RAII方式管理锁的获取和释放禁止手动lock/unlock。一个函数内最多只允许持有一把锁避免嵌套锁带来的死锁概率。如果确实需要多处持锁必须保证所有线程加锁的顺序完全一致。禁止在持锁状态下调用信号发射函数特别是跨线程信号。这几条都是踩过坑之后总结出来的。特别是最后一条Qt的信号槽在发送跨线程信号时本身会加锁如果你持有一把自定义锁再去发信号而接收方恰好在等这把自定义锁就会造成死锁。5.3 内存策略要主动控制嵌入式平台内存本来就紧张Qt程序又特别喜欢动态分配这里我推动了两项改动一是把所有高频创建的控件和对象池化复用一个实例而不是频繁创建销毁二是限制需要高频更新的界面数据规模超过阈值就做降采样。这两项改动加起来应用的内存波动幅度至少下降了七成。5.4 建立主动监控和自动恢复机制代码层面防住了大部分问题但还是要防最后一手万一真的卡了怎么办。我给项目加了一个看门狗机制一个独立的后台线程每秒检查Qt事件循环的响应时间如果连续几次超过阈值就认为界面线程异常自动把现场信息保存后强制重启应用。这个设计最初有争议认为自动重启会掩盖问题但从实际效果看它既保证了设备的可用性也让我们能收到完整的现场日志反而更有利于后续修问题。6. 排查工具与问题类型对照一份能直接打印的资料最后整理了一份对照表把前面所有内容浓缩成可以直接拿去用的速查资料。我建议把这张表打印出来贴到工位上排查问题时按图索骥比临时想思路要高效得多。排查工具/命令关注哪类卡死获取的信息ps -eLo pid,tid,stat,wchan所有类型线程状态、等待的内核函数top -H -p PID资源耗尽型具体哪个线程吃CPU/内存cat /proc/PID/smaps内存相关堆和虚拟内存碎片情况dmesg内核/驱动异常内核报错、OOM记录strace -p PIDIO阻塞型卡在哪个系统调用gdb attach bt full所有类型完整线程调用栈cat /proc/PID/task/*/stack内核态等待线程在内核里的等待点Qt日志(自定义)事件循环响应超时主线程是否被阻塞使用这张表有个小技巧先跑第一行的命令看到整个进程的所有线程状态如果大部分线程都在S状态等待立刻判断是不是锁等待如果有线程在R状态且CPU占用高先排查是不是死循环。这两种情况的后续分析路径完全不一样。排查卡死问题最忌讳的就是一上来就改代码。我见过太多人觉得“这行代码看着不对劲”就改了试试改了一整天也没解决真正的故障。正确的顺序永远是先冻结现场、再收集状态、然后看调用栈、最后才动代码。顺序反了问题只会越查越乱。我自己这几年的体会是嵌入式Qt的卡死问题虽然看起来千奇百怪但九成以上都逃不开前面提到的几个根因。把现场信息采集这套动作练熟把常见栈形态和代码模式的对应关系记在心里大多数问题都能在半小时内锁到具体范围。剩下的那部分疑难杂症靠的就是现场日志的完整度和一点点耐心了。
返回列表