1. 从一次冬季早晨的冻屏说起
去年冬天,我接手了一个车机项目的问题排查。用户反馈的现象很统一:早上冷启动车辆,中控屏幕亮起来之后,画面就卡在开机Logo或者最后一个界面不动了,触摸没反应,倒车影像也出不来,得等两三分钟甚至更久才恢复正常。北方的用户投诉尤其集中,南方偶尔也有,但频率低很多。
这个现象在车机行业里有个约定俗成的叫法——STR唤醒黑屏。STR是Suspend to RAM的缩写,中文一般叫"挂起到内存"或者"休眠唤醒"。车机不像手机,熄火之后不能直接断电关机,因为用户下次上车希望中控屏能秒亮,倒车影像要立刻可用。所以主流方案是熄火后让系统进入STR低功耗状态,内存供电保持,CPU和外设大部分断电,下次点火时快速恢复现场。理论上这个过程应该在几百毫秒到一两秒内完成,但实际项目里,STR唤醒阶段的黑屏、冻屏、闪屏问题几乎是每个车机团队都会踩的坑。
我拿到日志之后,第一反应是去看唤醒时序。但很快发现一个有意思的事情:用户看到的"冻屏"其实是被遮罩盖住了。系统在STR唤醒过程中会先显示一个开机动画或者黑屏遮罩,等SurfaceFlinger把各个图层准备好之后再撤掉遮罩。用户看到的黑屏,很多时候不是屏幕真的没输出,而是遮罩层一直没被移除。而"系统不给闪屏"这个现象,根因也查清楚了——是显示管线的某个环节在唤醒时没有正确恢复状态,导致系统认为显示还没就绪,于是拒绝提交新的帧。
这篇文章就把这次复盘完整写下来。治本的两步最终没走通,但教训留下来了。如果你也在做车机Android系统开发,或者正在被STR唤醒相关的显示问题折磨,这篇内容应该能帮你少走一些弯路。
2. 车机STR唤醒的整体设计与思路拆解
2.1 为什么车机非要用STR而不是直接关机
先把这个前提说清楚,不然后面的很多设计决策没法理解。车机和手机、平板最大的区别在于使用场景的连续性。用户上车、点火、挂倒挡,这三个动作可能在两秒内完成。如果系统这时候还在走完整的冷启动流程,倒车影像根本来不及出来,这是安全问题,不是体验问题。
STR的核心价值就是保留内存现场。Android系统冷启动要重新加载Zygote、SystemServer、各种HAL服务,整个过程在车机这种算力有限的平台上通常要十几秒甚至更久。而STR唤醒只需要恢复CPU上下文、重新初始化必要的外设、让各个服务从挂起状态恢复,理论上可以做到一秒以内。
但"理论上"这三个字在车机项目里往往意味着大量的工程妥协。内存保持供电意味着功耗不能做到零,车辆长时间停放会导致蓄电池亏电,所以很多项目会设置一个STR超时时间,超过之后转成真正的关机。外设断电再上电的过程,各个芯片的初始化时序、寄存器状态恢复、通信链路重建,每一个环节都可能出问题。
2.2 显示子系统在STR唤醒中的特殊地位
显示子系统在STR流程里是最特殊的一个。其他外设比如音频、蓝牙、CAN,唤醒慢一点用户可能感知不强,但显示是用户第一眼就看到的东西。屏幕不亮,用户就会认为车机坏了。
Android的显示管线大致是这样的:应用通过SurfaceFlinger提交图层,SurfaceFlinger合成之后通过HWC交给显示驱动,显示驱动再通过MIPI DSI或者LVDS把画面送到屏幕。STR唤醒时,这条链路上的每一环都需要恢复:
- Display驱动:需要重新初始化时序、恢复PLL配置、重新建立与屏幕的通信
- HWC:需要重新获取显示设备句柄、恢复图层合成配置
- SurfaceFlinger:需要重新创建显示设备、恢复合成状态
- WindowManager:需要重新计算窗口布局、恢复各个窗口的可见性
任何一环没恢复到位,SurfaceFlinger就可能认为显示设备不可用,从而拒绝提交帧。这时候用户看到的就是黑屏或者冻屏。
2.3 遮罩机制的设计意图与实际副作用
车机系统在STR唤醒时会显示一个遮罩层,这个设计的初衷是好的:在显示管线还没完全恢复之前,用一个静态画面盖住屏幕,避免用户看到花屏、闪屏或者不完整的界面。等系统认为显示就绪之后,再撤掉遮罩,显示真正的桌面或者倒车影像。
但问题就出在"系统认为显示就绪"这个判断上。如果判断条件过于严格,或者某个状态标志没有被正确清除,遮罩就会一直挂着。用户看到的是黑屏,但实际上屏幕是有输出的,只是输出的是遮罩层的内容。这就是我这次排查中发现的第一个关键点:用户看到的冻屏,是被遮罩盖住了。
这个发现很重要,因为它把问题从"显示完全没工作"缩小到了"遮罩撤除逻辑有问题"。排查方向一下子清晰了很多。
3. 核心细节解析与实操要点
3.1 STR唤醒的完整时序拆解
要定位问题,首先得把STR唤醒的时序搞清楚。我在项目里抓了一段正常的唤醒日志,把关键节点标出来:
# 内核层STR唤醒起点 [ 12.345678] PM: suspend exit [ 12.345680] PM: resume from suspend # 显示驱动恢复 [ 12.350123] disp: resume start [ 12.352456] disp: pll restored [ 12.355789] disp: dsi phy ready [ 12.358012] disp: first frame sent # HWC恢复 [ 12.360234] hwc: display device reconnected [ 12.362345] hwc: layer stack restored # SurfaceFlinger恢复 [ 12.365678] SF: display device added [ 12.368901] SF: composition resumed # 遮罩撤除 [ 12.370123] bootanim: exit requested [ 12.372456] SF: boot animation stopped正常情况下的时序是:内核先恢复,然后显示驱动恢复并送出第一帧,接着HWC重新连接显示设备,SurfaceFlinger恢复合成,最后撤除遮罩。整个过程在几十毫秒内完成。
但出问题的日志里,时序是这样的:
[ 12.345678] PM: suspend exit [ 12.350123] disp: resume start [ 12.352456] disp: pll restored [ 12.355789] disp: dsi phy ready [ 12.358012] disp: first frame sent [ 12.360234] hwc: display device reconnected [ 12.362345] hwc: layer stack restored [ 12.365678] SF: display device added [ 12.368901] SF: composition resumed # 然后就没有然后了,遮罩撤除的日志一直没出现遮罩撤除的日志缺失,说明系统认为显示还没就绪。但显示驱动和HWC的日志都显示恢复了,问题出在SurfaceFlinger或者更上层的判断逻辑上。
3.2 遮罩撤除的判断条件分析
Android的遮罩撤除逻辑主要在BootAnimation和SurfaceFlinger里。BootAnimation负责播放开机动画,播放结束后会通知SurfaceFlinger停止遮罩。SurfaceFlinger收到通知后,会检查当前显示设备的状态,如果认为显示就绪,就撤除遮罩。
判断显示就绪的条件通常包括:
- 显示设备已经添加并且处于活跃状态
- 至少有一帧成功提交并显示
- 没有待处理的显示配置变更
我查了SurfaceFlinger的代码,发现它在STR唤醒后会等待一个display ready的信号。这个信号由HWC在完成显示设备恢复后发出。但问题在于,HWC发出信号的时机和SurfaceFlinger等待的时机之间有一个竞态条件。
具体来说,HWC在恢复显示设备后立即发出信号,但SurfaceFlinger可能还没完成自己的恢复流程,导致信号被错过。SurfaceFlinger会一直等这个信号,遮罩就一直不撤。
3.3 系统不给闪屏的根因定位
"系统不给闪屏"这个现象,指的是在STR唤醒过程中,系统拒绝提交新的帧。这个问题的根因和遮罩问题是相关的,但又不完全一样。
我抓了一段SurfaceFlinger的trace,发现它在STR唤醒后会进入一个skip composition的状态。这个状态的设计意图是:在显示管线还没完全恢复之前,跳过合成,避免提交无效帧导致花屏。
但问题在于,这个状态的退出条件同样依赖于display ready信号。由于信号被错过,SurfaceFlinger一直停留在skip composition状态,所有新的帧提交都被拒绝。这就是"系统不给闪屏"的根因。
注意:这个竞态条件在冷启动时不会出现,因为冷启动的时序是串行的,HWC和SurfaceFlinger的初始化顺序是确定的。但STR唤醒是并行的,各个模块同时恢复,时序不确定,所以才会出现信号错过的问题。
4. 实操过程与核心环节实现
4.1 问题复现与日志抓取
要解决这个问题,首先得能稳定复现。我在实验室里搭了一套环境,用可编程电源模拟车辆点火信号,控制车机进入STR和唤醒。复现步骤如下:
- 车机正常启动,进入桌面
- 通过ADB发送命令让系统进入STR:
echo mem > /sys/power/state - 等待10秒,确保系统完全进入STR
- 通过电源控制模拟点火,唤醒系统
- 观察屏幕状态,同时抓取kernel log和logcat
复现的成功率大概在30%左右,不是每次都能触发。这说明竞态条件的窗口很窄,需要多次尝试才能抓到。
抓日志的命令:
# 抓kernel log adb shell dmesg > kernel.log # 抓logcat adb logcat -b all -v threadtime > logcat.log # 抓SurfaceFlinger trace adb shell dumpsys SurfaceFlinger > sf.log # 抓HWC状态 adb shell dumpsys hwc > hwc.log4.2 关键状态标志的追踪
在日志里,我重点追踪了几个状态标志:
| 标志名称 | 正常值 | 异常值 | 含义 |
|---|---|---|---|
display_ready | 1 | 0 | 显示设备是否就绪 |
skip_composition | 0 | 1 | 是否跳过合成 |
bootanim_exit | 1 | 0 | 遮罩是否已撤除 |
hwc_connected | 1 | 1 | HWC是否已连接 |
从日志里可以看到,异常情况下display_ready一直是0,skip_composition一直是1,bootanim_exit一直是0。但hwc_connected是1,说明HWC已经恢复了。
这就验证了我的判断:HWC恢复了,但SurfaceFlinger没有收到display_ready信号。
4.3 竞态条件的代码级分析
我查了HWC和SurfaceFlinger的代码,找到了信号传递的实现。HWC在恢复显示设备后,会通过一个回调通知SurfaceFlinger:
// HWC侧代码 void HwcDisplay::onResume() { // 恢复显示设备 restoreDisplayDevice(); // 通知SurfaceFlinger if (mCallback) { mCallback->onDisplayReady(mDisplayId); } }SurfaceFlinger侧:
// SurfaceFlinger侧代码 void SurfaceFlinger::onDisplayReady(DisplayId displayId) { std::lock_guard<std::mutex> lock(mStateLock); mDisplayReady = true; mCondition.notify_all(); }问题在于,HWC的回调是在HWC的线程里执行的,而SurfaceFlinger的等待是在另一个线程里。如果HWC的回调在SurfaceFlinger开始等待之前就执行了,那么mDisplayReady会被设置为true,但SurfaceFlinger可能还没进入等待状态,或者等待的条件变量已经被重置了。
更具体地说,SurfaceFlinger在STR唤醒后会重置mDisplayReady为false,然后开始等待。如果HWC的回调在这个重置之前执行,那么mDisplayReady会被重置为false,SurfaceFlinger就会一直等下去。
4.4 临时规避方案的实施
找到根因之后,我先做了一个临时规避方案:在SurfaceFlinger的等待逻辑里加一个超时。如果等待超过500毫秒还没收到信号,就强制认为显示就绪,撤除遮罩。
// 临时方案:加超时 bool SurfaceFlinger::waitForDisplayReady() { std::unique_lock<std::mutex> lock(mStateLock); auto timeout = std::chrono::milliseconds(500); if (mCondition.wait_for(lock, timeout, [this] { return mDisplayReady; })) { return true; } // 超时,强制认为就绪 ALOGW("Display ready timeout, force ready"); mDisplayReady = true; return true; }这个方案实测下来很稳,复现率从30%降到了0。但我知道这不是治本,因为超时时间设成多少合适?设短了可能误判,设长了用户体验还是不好。而且这个方案掩盖了真正的竞态问题,以后可能会在其他场景下暴露出来。
5. 治本的两步为什么没走通
5.1 第一步:修复信号传递的竞态条件
治本的第一步,应该是修复HWC和SurfaceFlinger之间的信号传递竞态。正确的做法是让信号的设置和等待在同一个锁的保护下,并且确保重置和等待的顺序是确定的。
我尝试的修改方案是:在SurfaceFlinger重置mDisplayReady之前,先获取HWC的当前状态。如果HWC已经就绪,就直接设置mDisplayReady为true,不再等待。
// 治本方案第一步 void SurfaceFlinger::onResume() { std::lock_guard<std::mutex> lock(mStateLock); // 先查询HWC状态 if (mHwc->isDisplayReady(mDisplayId)) { mDisplayReady = true; } else { mDisplayReady = false; // 开始等待 } }但这个方案有个问题:isDisplayReady这个接口在HWC里没有现成的实现,需要新增。而且HWC的状态查询本身也可能有竞态,因为HWC的恢复是异步的。
我尝试在HWC里加了这个接口,但测试发现,在某些情况下,HWC的恢复还没完成,isDisplayReady返回false,SurfaceFlinger开始等待,然后HWC恢复完成发出信号,但信号又因为锁的问题被错过了。问题只是从"总是错过"变成了"偶尔错过"。
5.2 第二步:统一显示就绪的判断标准
治本的第二步,应该是统一整个系统里"显示就绪"的判断标准。目前的问题是,HWC、SurfaceFlinger、BootAnimation各自有各自的判断逻辑,标准不统一,导致状态不一致。
我设想的方案是:定义一个全局的显示状态机,所有模块都从这个状态机里读取状态,而不是各自维护自己的标志。
// 治本方案第二步:全局显示状态机 class DisplayStateMachine { public: enum State { DISPLAY_OFF, DISPLAY_RESUMING, DISPLAY_READY, DISPLAY_ERROR }; void setState(State state) { std::lock_guard<std::mutex> lock(mLock); mState = state; mCondition.notify_all(); } State getState() { std::lock_guard<std::mutex> lock(mLock); return mState; } bool waitForReady(std::chrono::milliseconds timeout) { std::unique_lock<std::mutex> lock(mLock); return mCondition.wait_for(lock, timeout, [this] { return mState == DISPLAY_READY || mState == DISPLAY_ERROR; }); } private: State mState = DISPLAY_OFF; std::mutex mLock; std::condition_variable mCondition; };这个方案理论上可以彻底解决问题,因为所有模块都从同一个状态机读取状态,不存在状态不一致的问题。但实施起来工作量很大,需要修改HWC、SurfaceFlinger、BootAnimation等多个模块,而且需要重新设计模块间的接口。
5.3 为什么最终没有实施治本方案
治本方案没走通,原因有几个:
时间窗口不够。这个项目已经到了量产前的最后阶段,任何大的修改都需要重新做完整的回归测试,时间上不允许。
风险太高。修改HWC和SurfaceFlinger的核心逻辑,影响面太大,可能会引入新的问题。而且这两个模块的代码复杂度很高,修改之后很难保证没有遗漏。
收益不明显。临时规避方案已经能把复现率降到0,用户体验上已经没问题了。治本方案的收益主要是代码质量上的,对于项目交付来说,优先级不高。
依赖上游。HWC的代码有一部分是芯片厂商提供的,我们只能改自己维护的部分,芯片厂商的那部分改不了。统一状态机的方案需要芯片厂商配合,协调成本太高。
所以最终的决定是:临时规避方案上线,治本方案记录在案,留给下一个项目或者后续的版本迭代。
6. 常见问题与排查技巧实录
6.1 STR唤醒黑屏问题速查表
| 现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 屏幕完全无输出 | 显示驱动未恢复 | 查kernel log里disp resume日志 | 检查显示驱动STR回调 |
| 屏幕有背光但无画面 | 遮罩未撤除 | 查bootanim exit日志 | 检查遮罩撤除条件 |
| 画面卡住不动 | SurfaceFlinger跳过合成 | 查skip composition标志 | 检查display ready信号 |
| 唤醒后闪屏 | 显示时序未恢复 | 查PLL和DSI配置 | 恢复显示时序参数 |
| 倒车影像出不来 | 显示管线未就绪 | 查HWC连接状态 | 检查HWC恢复流程 |
6.2 排查STR问题的通用思路
排查STR相关问题,我总结了一个通用的思路:
先分层,再定位。STR涉及内核、HAL、Framework、应用多个层次,不要一上来就盯着最上层看。先从内核log确认STR是否正常进入和退出,再看各个HAL的恢复日志,最后看Framework层的状态。
先时序,再状态。STR问题的核心往往是时序问题。把正常和异常的时序图都画出来,对比关键节点的先后顺序,往往能发现竞态条件。
先复现,再分析。STR问题很多是概率性的,不能稳定复现就很难分析。想办法提高复现率,比如控制温度、控制电源时序、多次尝试。
先规避,再治本。项目时间紧的时候,先上规避方案保证交付,治本方案可以后续再做。但规避方案一定要记录清楚,不能让它变成技术债。
6.3 几个容易踩的坑
坑一:只看logcat不看kernel log。STR的很多问题出在内核层,logcat里看不到。一定要同时抓kernel log。
坑二:忽略温度影响。低温下STR问题更容易复现,因为芯片的初始化时序会变慢。实验室复现的时候要注意控制温度。
坑三:以为改了就能好。STR问题的修改往往需要多次迭代,改一次不一定能完全解决。要有耐心,每次改完都要做完整的回归测试。
坑四:忽略芯片厂商的差异。不同芯片厂商的STR实现差异很大,别人的解决方案不一定适用于你的平台。要结合自己平台的实际情况来分析。
坑五:不记录规避方案。临时规避方案如果不记录,过一段时间就忘了,后面的人接手会一头雾水。一定要在代码里加注释,说明这是临时方案,以及治本方案应该怎么做。
6.4 实操心得分享
这次排查下来,我最大的心得是:STR问题不要只盯着显示看。显示是最终表现,但根因可能在电源管理、时钟管理、甚至通信链路上。我这次的问题表面上是显示问题,实际上是HWC和SurfaceFlinger之间的信号传递问题,再往深了说,是STR唤醒时各模块并行恢复导致的时序问题。
另一个心得是:日志要抓全。我一开始只抓了logcat,看了半天没头绪。后来把kernel log、SurfaceFlinger dump、HWC dump都抓了,对比着看,很快就定位到了问题。
还有一个心得是:不要怕临时方案。临时方案不是坏事,关键是要清楚它是临时的,并且记录好治本方案。项目交付是第一位的,技术债可以后续还。
7. 这个问题的后续扩展与思考
虽然治本方案没走通,但这个问题给我留下了很多思考。STR唤醒的时序问题不是个例,在车机行业里普遍存在。随着车机功能越来越复杂,参与STR唤醒的模块越来越多,时序问题只会更严重。
我觉得后续可以从几个方向去改进:
建立STR唤醒的时序规范。定义清楚各个模块的恢复顺序和依赖关系,避免并行恢复导致的竞态。
引入统一的电源状态管理。让所有模块都从一个统一的电源状态机读取状态,而不是各自维护自己的标志。
加强STR的自动化测试。在实验室里用自动化设备反复做STR唤醒测试,提高问题的复现率和发现率。
和芯片厂商建立更紧密的合作。很多STR问题需要芯片厂商配合才能解决,建立良好的沟通渠道很重要。
这次的问题虽然治本方案没走通,但至少把根因查清楚了,临时方案也保证了项目交付。教训留下来,下一个项目就能少踩一些坑。如果你也在做车机STR相关的开发,希望这篇复盘能给你一些参考。