干Android显示系统这行,最绕不开的就是SurfaceFlinger、HWC(Hardware Composer)和显示驱动这三个角色。很多人对SurfaceFlinger的Layer管理和BufferQueue机制比较熟,但一说到HWC到驱动这一截就有点发虚,总觉得无非是“硬件能合成就让硬件合成,不行就GPU兜底”。这句话只讲对了一半。真正需要弄清楚的是:HWC在SurfaceFlinger和显示驱动之间到底扮演什么角色?合成决策是怎么一步步做出来的?驱动侧的Plane又是怎么跟HWC的Layer对应上的?这篇文章我打算按实际项目里从头到尾调试链路的方式,把一条buffer从App侧画完,到SurfaceFlinger做合成,再到HWC做合成决策,最后通过显示驱动落到屏幕的完整轨迹理清楚。内容偏实战,适合正在啃Android源码、移植显示驱动、或者被显示性能问题折磨的工程师参考。
1. 先建立全局视图:谁负责合成、谁负责显示
1.1 上层调度、策略决策与底层执行的边界
要理解HWC,第一步得先搞清楚Android显示系统里三个角色的职责边界。
SurfaceFlinger是Android framework层的核心服务,它管的是“合成调度”。系统里每个可见窗口都会对应一个Surface,SurfaceFlinger把它们统一封装成Layer对象,维护一份全屏可见图层列表,然后根据VSYNC节奏决定什么时候去取这些图层、怎么合成、什么时候送显示。但SurfaceFlinger本身不直接操作屏幕硬件,它只能通过HWC暴露出来的接口去“下发”合成任务。
HWC的全称是Hardware Composer,它是一个HAL层模块。它处于SurfaceFlinger和内核驱动之间,职责很明确:向SurfaceFlinger暴露合成能力,比如支持多少个硬件overlay层、支不支持旋转、支不支持某些像素格式,然后按照SurfaceFlinger的请求,把可硬件合成的图层配置到显示控制器上,把不可硬件合成的图层回退给GPU。HWC是一个厂商实现的so库,最终通过libdrm或者业务私有接口去操作内核驱动。
显示驱动承担的是最底层落地的部分。在Linux内核里,这几年主流方案已经是DRM/KMS框架。显示驱动负责初始化CRTC、Encoder、Connector、Plane这些显示资源,按HWC配置好的参数把多个Plane的数据混合成一路像素流,经DSI、DP或者HDMI接口输出到屏幕。
用一句话类比:SurfaceFlinger是总包甲方,负责排计划;HWC是包工头,负责调度硬件资源;驱动是施工队,负责真正把像素送到屏幕。很多人调显示问题一上来就翻内核log,其实大多数问题在SurfaceFlinger和HWC这一层就已经能定位了。
1.2 为什么显示链路离不开硬件合成
先算一笔账。一块1080P分辨率、RGB888格式的buffer,一帧大小大概是1920x1080x3,约6.22MB。60Hz刷新率下,一秒就有120帧级别的读写动作:GPU读多个图层、混合、写回一块新buffer,带宽轻轻松松就到GB/s量级。如果全屏有5个可见图层,GPU先读5块buffer、再写1块buffer,一秒钟光这块数据流量就奔着几个GB走了。功耗、发热、延迟全都会上去。
硬件合成器解决的就是这个问题。显示控制器内部往往自带多个overlay plane,每个plane可以直接绑定一块buffer。CRTC把所有plane的数据实时混合成最终画面,不需要先经过GPU写回显存。这一下就把“N块buffer读出来混合再写回”变成了“N块buffer直接喂给显示控制器硬件混合”,省掉的不是一点半点,功耗和帧延迟都会明显改善。
所以HWC的核心价值就在这里:能用硬件平面直接合成的图层,尽量不碰GPU。只有HWC能力不够、或者图层属性太复杂(比如某些非标准旋转、特殊混合模式)的时候,才把一部分图层丢回SurfaceFlinger,让GPU合成到一块client target,再交给HWC去显示。
2. 全程链路拆解:一个图层从App到屏幕发生了什么
2.1 App生产buffer与BufferQueue的三方交接
链路的最上游其实是App。每个App窗口在SurfaceFlinger侧对应一个BufferQueue,App是生产者,SurfaceFlinger是消费者,HWC和驱动则是在更后面接着消费最终结果。
App需要画一帧时,通过dequeueBuffer从BufferQueue里取一块空闲buffer,交给CPU或者GPU渲染,完成后再queueBuffer把它交还给BufferQueue,同时带出一个fence表示“我这帧画完了,消费者可以读了”。SurfaceFlinger在VSYNC回调里检查各Layer的BufferQueue有没有新buffer可用,有的话通过acquireBuffer取走,之后就会进入合成流程。
这里有一个很容易被忽略的点:这块GraphicBuffer的内存,底层一般来自ION或dma-buf分配器,是物理连续的或者至少是能映射到显示控制器地址空间的内存。HWC和驱动要合成它,必须能访问到同一块物理内存。所以HWC层看到的buffer_handle_t,说到底是一个可以翻译成dma-buf fd的句柄。理解了这一点,后面看HWC和DRM之间怎么传递fb_id就会很顺。
fence在链路里也极其重要。Android图形栈的buffer到处都带着acquire fence和release fence。acquire fence表示“消费者要等这个fence signal之后才能读buffer”,release fence表示“生产者要等这个fence signal之后才能继续写buffer”。GPU画完一帧后,release fence会signal,SurfaceFlinger/HWC拿到buffer后就知道内容可用了。如果fence处理不对,最常见的现象就是画面卡死第一帧或者出现撕裂。
2.2 SurfaceFlinger如何拍板“GPU合”还是“HWC合”
每个VSYNC周期,SurfaceFlinger都会对当前所有可见Layer做一次合成决策。它不是自己死记硬背,而是把候选Layer集合交给HWC去“表态”。
具体决策依据主要是这几个维度:Layer的Z序、位置、尺寸、透明度和混合模式、像素格式,以及HWC是否支持这块图层的旋转与缩放。HWC的validate结果才是最终判断标准:能支持的Layer标记为Device合成,由HWC硬件平面做;不支持的Layer标记为Client合成,意味着SurfaceFlinger要先用GPU把它们全部合成到一块buffer上。
不少看过SurfaceFlinger日志的人应该见过这样的输出:一个Layer显示的是“Device composition”还是“Client composition”。如果屏幕上状态栏、导航栏、主界面都标注为Device,说明HWC基本接住了大多数图层,整体效率高。如果整个列表几乎全是Client,那就要警惕了,大概率HWC能力不足或者驱动哪里有缺陷。
这里有个容易误解的地方:Client合成不是把每个Layer单独合成一次,而是把多个Client Layer按Z序连续混合到一块临时的client target buffer里。这块buffer在HWC眼里就当成普通一整个Layer来处理。所以HWC最终看到的Layer数里,可能包含一个巨大的client target再加上若干个Device Layer。
2.3 HWC的validate/present:一次合成请求的完整生命周期
HWC 1.x时代,接口非常直白:prepare阶段和set阶段。prepare时SurfaceFlinger把全部Layer交给HWC,HWC逐个“表态”,能合就置为HWC_OVERLAY,不能合就置为HWC_FRAMEBUFFER。set阶段,SurfaceFlinger先把标记为FRAMEBUFFER的图层用GPU合成好,再调一次set把整个Layer列表(包括GPU合成后的framebuffer target)交给HWC送显。
到了HWC 2.x以后,接口模型变成了对象化的IDevice、IDisplay、Layer,流程也更精细。整个生命周期大致是这样的:
SurfaceFlinger先把每个Layer的信息设置到HWC对应的Display对象上,包括layer的buffer、合成类型、显示区域、裁剪区域、变换矩阵、混合模式等。全部设置完以后,调用validateDisplay让HWC做一次校验。HWC会用自身能力逐项检查这组Layer能不能在硬件上合成,能就把合成类型定为DEVICE,不能就打回CLIENT,并通过getChangedCompositionTypes把需要回退的Layer列表返回给SurfaceFlinger。
SurfaceFlinger处理完这些回退Layer(也就是GPU合成到client target),再重新把设置刷新一遍,再一次validate,如此反复直到所有Layer都达到可提交状态。最后调用presentDisplay把最终Layer列表和fence一起交出去,HWC在合适的VSYNC时机把它们配置给显示控制器,屏幕上就能看到画面了。
这个过程中最考验实现质量的是fence的传递和同步。present时HWC要等acquire fence signal,确保buffer内容已经画好,然后再触发显示控制器去读。等显示控制器真正读完以后,release fence再signal,buffer才能还给App继续画。这一套同步链条任何一个环节卡住,都会直接表现成卡顿、花屏或者屏幕静止。
3. HWC HAL开发实战:从接口实现到驱动调用
3.1 HWC 1.x到2.x/3.x:接口模型为什么越分越细
如果只做应用开发,其实不太需要关心HWC版本演进。但只要是碰framework或者移植显示系统,就躲不开这个问题。
HWC 1.x把所有能力塞在hwc_composer_device_1上,结构体里一堆函数指针,Layer列表用数组往下传。好处是简单,坏处是扩展性差:虚拟显示(比如投屏)支持不好,多显示器管理也困难,任何新增能力都得改一堆函数指针。
HWC 2.x引入的IDevice、IDisplay、Layer对象模型是一次比较大的重构。每个Display独立管理,虚拟显示和物理显示都能用同一套逻辑描述,Layer的合成类型、fence、dataspace这些属性都有了明确归属。到Android 10之后,HWC又改为通过AIDL的Composer HAL(也就是常说的HWC 3.x,composer@3)来暴露,底层还是类似的IDevice/IDisplay概念,只是把接口定义切到了AIDL语言,配合Treble架构让系统升级更干净。
从实现者的视角看,HWC 2.x和3.x的核心要处理的事情其实没变:对外正确上报能力,对内正确把Layer映射成DRM的Plane配置。接口变了,思路没变。
3.2 最小可用的composer HAL骨架
讲骨架之前先说清楚:完整实现一个商用HWC的工作量很大,这里只是把最关键的方法剥出来,让你知道SF调HWC时到底会落在哪些函数上。
以AIDL化的composer HAL为例,核心类大概是这样的结构:
// MyComposerHal.h using android::hardware::graphics::composer3::IComposerHal; class MyComposerHal : public IComposerHal { public: Error getDisplayCapabilities(DisplayId display, DisplayCapabilities* outCaps) override; Error setLayerCompositionType(DisplayId display, LayerId layer, Composition composition) override; Error validateDisplay(DisplayId display, const std::vector<LayerId>& layers, std::vector<Composition>* outChangedTypes, std::vector<LayerId>* outRequestedLayers) override; Error presentDisplay(DisplayId display, const std::vector<LayerId>& layers, int32_t* outPresentFence) override; };getDisplayCapabilities里要如实上报这块显示控制器支持的能力,比如最大图层数、支持哪些变换、支持哪些像素格式、有没有硬件双缓存。这一项不要为了跑分虚报,后面validate不匹配的时候还是会露馅。
setLayerCompositionType就是一个单纯的“记录”动作:把SF期望的composition存下来,顺便做一轮能力检查。如果SF想设成DEVICE但当前硬件资源不够,可以在这里先记一个标记,等validate阶段统一返回。
validateDisplay是核心。这里要做的事包括:统计Layer数量是否超过可用Plane数量;检查每个Layer的格式、尺寸、旋转、缩放是否被硬件支持;如果发现不支持,就把对应Layer的合成类型改成CLIENT,塞进outChangedTypes。这里有一个实现细节:validate返回后,SF通常会重新设置这些Layer的属性再调用validate,所以validate必须能处理“上次说不行、这次改好了”的状态变化。
presentDisplay是真正要“干活”的地方。大部分现代平台都走DRM atomic commit,我简单写一下基本套路:
// MyComposerDisplay.cpp int32_t MyDisplay::present(const std::vector<Layer>& layers) { drmModeAtomicReqPtr req = drmModeAtomicAlloc(); int planeIdx = 0; for (auto& layer : layers) { if (layer.compositionType == Composition::CLIENT) continue; // client target在另一个单独的commit里处理 auto& plane = mPlanes[planeIdx++]; // 把layer的buffer映射为drm framebuffer uint32_t fbId = framebufferFor(layer.buffer); drmModeAtomicAddProperty(req, plane.id, mPlaneFbIdProp, fbId); // src和crtc矩形 drmModeAtomicAddProperty(req, plane.id, mPlaneSrcProp, srcRectValue(layer)); drmModeAtomicAddProperty(req, plane.id, mPlaneCrtcProp, crtcRectValue(layer)); // 关联crtc drmModeAtomicAddProperty(req, plane.id, mPlaneCrtcIdProp, mCrtcId); } int ret = drmModeAtomicCommit(mDrmFd, req, DRM_MODE_ATOMIC_NONBLOCK, nullptr); drmModeAtomicFree(req); return ret == 0 ? Error::NONE : Error::BAD_PARAMETER; }这里还没展开fence、layer的acquire/release处理,以及client target的上屏路径,但你已经能看到整条链路的“接缝”:HWC把SF的Layer对象翻译成DRM的Plane属性,commit下去之后,剩下的就是内核的事情。
3.3 这里的坑最深:fence、composition状态和buffer所有权
第一类坑是fence不signal。最常见的情况是HWC实现里没有把一个fd正确dup,导致多路引用时fence被提前关闭,内核里那个sync object再也等不到signal,画面就卡死在第一帧。排查的时候用systrace抓SF的acquireFence和presentFence,能看到fence迟迟不signal。这个问题几乎每个没有成熟经验的HWC实现团队都会踩一次。
第二类坑是composition状态不一致。HWC在validate阶段把某些Layer标记为CLIENT,但SF重新提交时,HWC内部还留着旧的DEVICE缓冲状态。等present的时候新旧状态混淆,屏幕输出不是花屏就是图层错位。解决办法是在validate通过之后,clear掉旧的Layer状态,强制以最新的set结果为准。
第三类坑是buffer所有权交接。HWC把buffer交给DRM去显示后,内核和HWC都必须明确谁在什么时候能把buffer的所有权释放。尤其要注意release fence的生成时机:必须等到显示控制器真正读完buffer之后再signal。如果提前signal,App下一帧就会写到一块还在被显示控制的buffer上,画面撕裂就在所难免。
第四类坑是能力上报不实。比如硬件明明只有两个overlay plane,HWC却上报支持四层设备合成。结果就是SF高高兴兴把四个Layer都标成DEVICE提交过来,HWC在validate时又只能打回一部分CLIENT,来回多跑几轮,帧率直接被拉低。能力上报的原则永远是“保守”,上报前最好实际测一圈旋转、缩放、格式的组合。
4. 驱动侧落地:drm/kms如何承接HWC的合成请求
4.1 CRTC、Plane、Connector:drm为HWC准备的硬件抽象
内核侧如果走DRM/KMS,HWC要操作的对象就非常明确了。DRM把显示硬件抽象成几个对象:CRTC对应显示控制器,负责把buffer里的像素数据变成输出时序;Plane对应硬件叠加层,可以把多块buffer直接送进CRTC做混合;Encoder负责把像素流编码成接口协议(比如DSI的包格式);Connector代表物理接口,比如eDP、HDMI、DSI,它连接着panel。
HWC与DRM对象的对应关系在实战中是非常明确的:SF里的一个Layer,对应HWC里的一个Layer,再对应DRM里的一个Plane。SF里合成出来的client target,在DRM里通常挂在Primary Plane上。CRTC最终把Primary Plane和所有Overlay Plane混合成一路输出,经Encoder和Connector发到面板。
所以当你在dumpsys SurfaceFlinger里看到某个Layer是“Device composition”时,心里应该立刻想到:这个Layer后面一定有一个DRM Plane,并且那个Plane的fb_id一定指向这块Layer的buffer。反过来,如果你在drm状态里看到某个Plane被disable,但SF却把它标成了Device合成,那说明HWC和驱动之间的状态已经对不上了。
实际调试时,/sys/kernel/debug/dri/0/state这个debugfs节点非常有用,能看到每个Plane的fb_id、crtc_id、src/crtc矩形、rotation属性是不是处于预期状态。曾经有客户报告“某个画面里视频区域屏幕闪烁”,最后就是通过对比dumpsys和drm state,发现视频那层实际被当成Client合成写到了Primary Plane,而驱动里Primary Plane没开带fence的异步更新,画面刷新自然就有撕裂。
4.2 DSI链路与显示时序参数的计算
移动平台绝大多数屏幕走的是MIPI DSI接口。DSI分两种工作模式:Command Mode和Video Mode。Command Mode下,面板自带GRAM,主机把图像数据写入面板的GRAM,面板自己负责刷新,适合低功耗、低刷新率的屏幕,比如智能手表;Video Mode下,SoC源源不断把像素流推给屏幕,面板只是个实时显示终端,手机、平板的主力屏基本都是这种模式。
调试DSI屏幕时,最基础的工作是确认时序参数。一个常见场景是换屏后显示花屏或黑屏,第一反应就该检查时序里的h_active、h_back_porch、h_sync_pulse、h_front_porch、v_active这些值是不是跟屏规格书一致。DRM侧对应的是drm_display_mode,HWC本身一般不直接改这些参数,但屏幕刷新率的最终效果跟它强相关。
如果只是粗略估算DSI时钟需求,可以这么算:byte_clock = (h_total) x (v_total) x fps x bpp / lanes / 8。我拿一个1080P、24bpp、60Hz、4 lane的屏幕举例,假设h_total约2200、v_total约1125,那么每秒总像素约2200x1125x60≈148.5Mpixel,乘以24bit就是约3.56Gbps,除以4条lane,每条lane大约需要890Mbps。这个数值折成byte clock就是约110MB/s的DSI时钟频率。实际还要加上DSI包头的ECC和CRC开销,所以配置时往往要留一点余量。
这种计算在HWC调试中通常不会直接去做,因为驱动层的panel驱动已经配好了,但排查“刷新率只有标称一半”“屏幕闪烁”这类问题时,懂这个计算能帮你更快怀疑到带宽或时钟配置上。我曾经见过一个案子:h_total和v_total里两个blanking值没配对,屏幕能亮,但刷新率从60掉到30,还伴随水平方向的细纹,后来就是把blanking恢复成规格书数值才解决。
4.3 驱动侧常见显示问题与排查方向
黑屏是驱动侧最常见也最让人头疼的问题。拿到一个黑屏报告,先分清是完全没有背光,还是背光亮但没有图像,还是图像一闪而过。完全没有背光,优先查面板供电和背光使能GPIO;背光亮但没有图像,优先查DSI video mode的clock lane和data lane配置;如果是开机logo正常、进入Android后黑屏,那就要把注意力放到HWC和SurfaceFlinger的合成路径上,很可能是Layer提交后HWC没有正确commit CRTC,或者urfaceFlinger的client target没有正确上屏。
花屏和撕裂通常是时序或者buffer同步问题。花屏重点检查像素格式是否匹配,尤其注意RGB888和RGB565混用的场景;撕裂则十有八九是fence没等住,或者display controller在buffer还没写完时就去读了。
刷新率异常的问题,经常出现在VRR或者高刷新率屏上。如果屏幕标称120Hz但实际跑在60Hz,第一件事是用dumpsys display看当前activeMode是不是真的切到了120Hz模式,再看DSI时钟是否足够支撑120Hz的带宽。带宽不够时,面板端会表现为闪屏、细纹甚至直接黑掉。
5. 实战调试三板斧:抓合成决策、盯fence、验证显示帧率
5.1 dumpsys与Perfetto:把合成决策和性能一次看清
做HWC相关调试,我一般会固定用几个工具,它们在定位问题时的作用各不相同。
第一个是dumpsys SurfaceFlinger。这个命令会把当前所有Layer、合成方式、buffer状态、fence状态都打出来。看的时候重点看每个Layer后面跟着的composition类型,以及底部有没有报HWC错误。如果所有Layer都变成Client composition,优先怀疑HWC的能力上报或者validate实现有问题。
第二个是dumpsys SurfaceFlinger --latency。它会输出最近帧的时间戳样本,能直观算出当前帧率是不是稳定,有没有周期性掉帧。这个命令在验证刷新率配置和VSYNC调度是否正常时特别有效。
第三个是Perfetto。它可以把BufferQueue的dequeue/queue、SurfaceFlinger的vsync和合成、HWC的validate/present整个执行链拉成一条可视化时间线。fence卡住、等待时间过长、HWC提交太慢这些问题,在Perfetto里都是一眼能看出来的。内核侧配合一个简单的ftrace就能抓到drm_atomic_commit的耗时。
第四个是debugfs下的drm state文件,可以直接看到每个Plane的state,包括fb_id、crtc矩形、src矩形、rotation、active状态。这个文件和dumpsys SurfaceFlinger对照着看,能很快确定HWC下发的Layer有没有真正变成DRM Plane的配置。
5.2 典型问题速查:现象、原因、处理手段
我整理了一张比较实用的速查表,基本都是实际项目里反复出现的几类情况:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 画面卡在第一帧不动 | fence未signal或buffer所有权没有正确交接 | Perfetto抓acquire/present fence,检查是否出现pending fence超时 |
| 全屏图层回退到GPU合成 | HWC overlay不足、能力上报不实、validate逻辑过早放弃 | dumpsys SurfaceFlinger看composition类型,对照drm state看plane数量 |
| 花屏 | 像素格式不匹配、Plane的stride配置错误、SRC/CRTC矩形错位 | 检查drm state中fb的format和plane矩形,再对比buffer的stride |
| 闪烁 | VSYNC丢失、fence等待超过刷新周期、DSI clock余量不足 | Perfetto看SF/HWC帧间隔,再用DSI clock估算余量 |
| 刷新率减半 | mode没切成功、blanking参数错误、带宽不够 | dumpsys display确认activeMode,结合clock估算公式验算 |
| 低亮度时屏幕微闪 | 背光PWM频率过低、产生可见频闪 | 调整背光驱动PWM频率,避开与刷新率的倍频关系 |
| 投屏画面异常 | Virtual Display相关能力未实现、dataspace转换遗漏 | 单独跑virtual display用例,检查Display的type和capabilities |
这类问题里很大一部分,多花五分钟在dumpsys和drm state上排查,能省下后面好几个小时的猜谜时间。尤其是合成方式回退这类问题,如果能在dumpsys输出里看到到底哪一层被标记为Client,就已经解决了八成。
最后再说一个我自己的习惯:做HWC开发时,永远把“当前合成决策”作为第一个观测点。不管来的是什么显示异常,先把dumpsys SurfaceFlinger拍下来,再抓一份drm state,两边一对照,是SF给错了、HWC传错了、还是驱动没配上,基本立见分晓。这个习惯帮我避开过大量没有头绪的排障过程,也推荐给所有正在折腾Android显示链路的朋友。