1. 从模拟器开机卡在黑屏说起:一条 property 如何决定显示能不能起来
前段时间调一个安卓虚拟机的显示问题,现象特别典型:模拟器能开机、能adb shell、logcat也能刷出日志,但屏幕一直停在开机动画,或者干脆黑屏只有背光。logcat里 SurfaceFlinger 反复重启,报的错大概长这样:
E SurfaceFlinger: Failed to load hwcomposer module E HWComposer: loadHwcModule: Failed to load module F DEBUG : Abort message: 'couldn't find an EGLConfig matching the screen format'折腾了半天,最后定位到的原因既不玄乎也不底层:ro.hardware.hwcomposer这个 property 指向的 HAL 变体名,和虚拟机镜像里/vendor/lib64/hw/目录下实际存在的那个.so名字对不上,差了一个字符。
这件事让我意识到,安卓虚拟机显示驱动 property这串东西,看着像一堆零散的key=value,实际上是安卓显示栈的"接线图"。安卓的图形显示链路不是一个巨大的一体化程序,而是一串按名字插拔的模块:应用把图层交给 SurfaceFlinger,SurfaceFlinger 通过 HWC(Hardware Composer)做合成,用 gralloc 分配图形缓冲区,用 EGL/Vulkan 把东西真正画出来。这四个环节,每一个环节"用哪个实现",都是靠一串ro.hardware.*属性在开机时决定的。
为什么安卓要搞成属性驱动而不是写死?因为同一份系统镜像要跑在手机、平板、车机、电视盒子和虚拟机模拟器上。硬件差异不可能都编进代码,必须外置成配置,让 init 在启动阶段读进来,再动态拼出.so的文件名去dlopen。这串属性就是安卓"硬件描述语言的入口",而显示驱动相关的那几条,是入口里最关键的一类。
这篇文章我会把显示驱动相关的 property 拆开讲清楚:它从哪来、谁在读、写错了会怎么炸、以及怎么用几条命令快速判断虚拟机当前到底走了哪条渲染路径。适合正在折腾安卓模拟器、虚拟机镜像、定制 ROM 或者做显示问题定位的同学,有安卓 HAL 基础会读得更顺,但没基础也能跟着命令一步步走下来。
2. property 在安卓里到底是怎么被读出来的
2.1 property 不是文件,是一块 mmap 出来的共享内存
很多人第一次接触时会以为 property 存在某个文件里,改文件就生效。实际上不是。安卓的属性系统在 init 启动的极早期就初始化了一块共享内存区域,路径在/dev/__properties__/下面。属性读取走的是 mmap 直读,几乎零开销;属性写入则要走/dev/socket/property_service这个 socket 交给 init 处理,是串行化的。
这个设计直接决定了两件事。第一,属性读取非常便宜,系统里到处property_get也没关系,所以显示、音频、相机各种地方都敢在热路径上读属性。第二,属性写入比较贵,不要在高频循环里反复setprop,尤其是调试脚本里那种"每帧改一个属性"的写法,会把 property_service 拖住。
还有两个硬限制值得记一下:属性名最长 32 字节,属性值最长 92 字节(PROP_VALUE_MAX)。所以别指望往属性里塞一长串 GPU 参数或者复杂的逗号分隔列表,塞不进去。更麻烦的是超长的名字被截断时不会有明确报错,你只会看到属性莫名其妙设不上,然后花半天怀疑人生。我踩过一次,一个带前缀的调试属性名写了 34 个字符,怎么设都设不进去,getprop里连影子都没有。
2.2 谁在什么时候把 ro.hardware.* 写进去
ro.hardware这一类属性的来源有好几条路,按加载时间排序大概是:
- init 最早读
bootconfig(安卓 10 以后是/proc/bootconfig,之前是/proc/cmdline),把androidboot.xxx=yyy映射成ro.boot.xxx。 - 然后按顺序加载各个分区的
build.prop:/system/build.prop、/system_ext/build.prop、/vendor/build.prop、/odm/etc/build.prop、/product/build.prop。老版本还有/system/etc/prop.default和/default.prop。 - 接着执行 init 脚本
*.rc里的setprop,以及property_override指令。 - 最后 SurfaceFlinger 等进程启动时去读这些已经定好的值。
注意顺序很重要:后加载的会覆盖先加载的,但只对非ro.属性成立。ro.前缀是一次性写入,谁先写成谁赢。所以如果你在vendor/build.prop里写了ro.hardware.egl,而 init 的early-init阶段已经在init.ranchu.rc里设过同一个名字,那你的 build.prop 是白写的,而且不会有任何警告。
2.3 所谓"只读"的 ro. 到底有多只读
ro.属性的"只读"不是"文件系统权限只读",而是 property_service 层面的规则:只有 init 域(以及被 SELinux 策略放行的上下文)能写ro.前缀的属性,并且一个ro.属性在整个开机生命周期里只能被成功写入一次。
直接后果就是:
adb shell setprop ro.hardware.egl emulation # Failed to set property 'ro.hardware.egl' to 'emulation'这条命令在绝大多数情况下会失败。哪怕你已经adb root、甚至把 SELinux 设成 permissive,ro.属性也设不进去——因为拦你的不是 SELinux,是 property_service 里"ro.只允许写一次"的硬逻辑。真正能改它的,只有"在它被第一次写入之前介入",也就是改 build.prop 或者改 init 脚本。
相比之下debug.*和persist.*就友好得多。debug.*是运行时可写的调试开关,persist.*会落到/data/property/目录下持久化,跨重启保留。所以做显示调试时,凡是能用debug.sf.*、debug.hwui.*表达的需求,都别去动ro.属性。
2.4 SELinux 给每条属性都贴了标签
还有一层很多人忽略的东西:property_contexts。安卓 8 以后属性访问是受 SELinux 管控的,每条属性必须先在plat_property_contexts、vendor_property_contexts这类文件里有匹配规则,否则写入会被拒,logcat里出现avc: denied才算暴露出来。
典型场景是你自己造了一条新属性persist.vendor.display.foo,在init.rc里setprop失败,日志里却只有一句含糊的错误。这时候要去确认vendor_property_contexts里有没有persist.vendor.display.这个前缀的规则。加规则本身不难,难的是不知道有这回事,会误以为是属性名写错了。
3. ro.hardware.hwcomposer 这个"典型样本"的完整生命周期
3.1 它的值是怎么被消费掉的
拿ro.hardware.hwcomposer当样本,因为它是显示驱动属性里最有代表性的一条。传统 HAL 加载逻辑大致是这样:调用方传一个模块类名(比如hwcomposer),加载器先拼一个属性名ro.hardware.<类名>去查,查到了就用这个值当"变体"后缀,拼出hwcomposer.<变体>.so;查不到就退回全局的ro.hardware;两个都没有,就走hwcomposer.default.so。
也就是说这里有一个三级回退链:
| 优先级 | 属性 | 拼出的模块名 | 说明 |
|---|---|---|---|
| 1 | ro.hardware.hwcomposer | hwcomposer.<值>.so | 精确指定,最常用 |
| 2 | ro.hardware | hwcomposer.<值>.so | 全局兜底,影响所有 HAL |
| 3 | 无 | hwcomposer.default.so | 默认模块 |
三级回退的设计初衷是"同一块板上多个 HAL 共享同一个变体名",比如全是ranchu。但它带来一个很隐蔽的坑:ro.hardware是所有 HAL 类别的共同兜底,你改它一个,音频、摄像头、蓝牙、传感器全跟着变。我在一个镜像上把ro.hardware从一个奇怪的值改成ranchu,显示确实好了,结果音频 HAL 加载失败了——因为音频那个变体名的.so在镜像里根本不存在。这条经验我建议刻在脑子里:能改ro.hardware.hwcomposer就别改ro.hardware。
另外要说明的是,安卓 8 之后大量 HAL 转向 HIDL/AIDL,改成向 servicemanager 注册服务,由init.rc拉起,VINTF manifest 声明接口。这时候ro.hardware.*可能依然存在,但它是不是真的还在参与动态库加载,得单独确认。别看到属性在就以为加载路径没变——这两套机制在过渡期的镜像里经常是并存的,gralloc 走老路、HWC 走新路,完全可能。
3.2 虚拟机上这几条属性的典型取值
下面这张表是我在几个不同安卓版本的虚拟机镜像上getprop抓到的典型结果。再次强调,具体值随版本、随宿主平台的-gpu参数变化,下面只当作参照坐标,不要照抄:
| 属性 | 典型取值 | 它决定了什么 |
|---|---|---|
ro.hardware | ranchu | 所有 HAL 的全局变体兜底 |
ro.hardware.hwcomposer | ranchu | 合成器 HAL 变体 |
ro.hardware.gralloc | default或minigbm | 图形缓冲区分配器变体 |
ro.hardware.egl | emulation、angle、swiftshader | EGL 驱动实现 |
ro.hardware.vulkan | ranchu、emulated,也可能为空 | Vulkan 驱动实现 |
ro.opengles.version | 196608(即 0x30000,表示 GLES 3.0) | 上报给应用的 GLES 版本 |
ro.kernel.qemu | 1 | 标识"我跑在虚拟机里" |
ro.kernel.qemu.gles | 1或2 | guest 侧 GLES 翻译模式 |
qemu.gles | 1或2 | 与上面配对使用 |
ro.boot.hardware | ranchu | 来自 bootconfig,只读 |
看ro.opengles.version这个值的写法就能看出属性系统的局限:它是个整数编码,0x00030002表示 GLES 3.2,0x00030000表示 3.0。用整数是因为属性值只有 92 字节、类型系统又极简陋,只能靠编码来表达版本。所以当你发现某个应用在虚拟机上降级渲染或者报"不支持 GLES 3.x",第一件事就是查这条属性,而不是先怀疑 GPU 驱动。
3.3 属性为空和属性写错,后果完全不一样
这是我在排障中总结出来的一条经验性判断法则,能省很多时间:
属性为空或者没定义,会走回退链,也许还有个default模块恰好能撑住,表现是"能起来但性能差/花屏"。属性写错(指向一个不存在的变体名),回退链会被打断,dlopen直接失败,HWC 初始化失败,SurfaceFlinger 起不来,表现是"黑屏 + 反复重启"。
所以看到黑屏 + SurfaceFlinger 崩溃,优先怀疑属性"写错了";看到能起来但画面诡异,优先怀疑属性"指向的实现不对"。这两种现象的排查方向完全不同,先分类再动手,效率差好几倍。
4. 用几条命令把显示驱动到底走了哪条路摸清楚
4.1 getprop 的正确筛法
最朴素的命令是adb shell getprop,但输出几百行,眼睛会瞎。我的习惯是分两步筛:
# 第一步:只看显示相关的关键属性 adb shell getprop | grep -E "\[ro\.(hardware|opengles|boot)" # 第二步:单独确认几个配对使用的值 adb shell getprop ro.hardware.hwcomposer adb shell getprop ro.hardware.gralloc adb shell getprop qemu.gles adb shell getprop ro.kernel.qemu.gles这里有个小技巧:grep的时候加个\[前缀能把搜索范围限制在属性名上,否则像ro.hardware=这种片段会命中一堆无关行。另外如果版本支持,getprop -Z可以直接看到每条属性的 SELinux 上下文,排查写入被拒的时候非常好用;老版本没有这个选项,只能去翻property_contexts。
4.2 dumpsys SurfaceFlinger 里的自述信息
属性告诉你"打算走哪条路",dumpsys告诉你"实际走了哪条路"。这两者不一致的情况非常常见,一定要交叉验证:
adb shell dumpsys SurfaceFlinger | head -80在输出开头通常能找到 GLES 自述那一行,格式大概是GLES: <厂商>, <渲染器名>, <版本>。如果渲染器名里出现SwiftShader、Emulation这类字样,说明当前走的是 guest 侧软渲染或者翻译层;如果是宿主的真实 GPU 型号,说明是直通模式。
合成方式也在同一份输出里,找HWC相关的字段,能看出当前是硬件合成还是回退到 GPU 合成。这个信息量很大:属性指向宿主直通,但 dumpsys 显示走的是软渲染,说明中间某一环回退了,这时候再往下查具体在哪一环回退。
4.3 看进程真正 dlopen 了哪个 so
这是最接近真相的手段,因为它不依赖任何自述:
# 看目录里到底有哪些模块 adb shell ls -l /vendor/lib64/hw/ | grep -E "gralloc|hwcomposer" # 看表面上真正加载了哪个 adb shell "cat /proc/$(adb shell pidof surfaceflinger)/maps | grep -E 'gralloc|hwcomposer|libEGL'"/proc/<pid>/maps里出现的就是这个进程实际映射进来的动态库,做不了假。我第一次用这个方法的时候,发现dumpsys里报的是一个渲染器,maps里加载的却是另一个版本的libEGL.so,原因是镜像里同时存在/vendor/lib64/和/system/lib64/两份同名库,加载顺序和我想的不一样。这种问题不看maps基本查不出来。
4.4 logcat 里绕不开的那几个标签
配合上面两条,再看日志:
adb logcat -b all -d | grep -iE "hwcomposer|gralloc|libEGL|gfxstream|vulkan|ranchu"-b all是为了把 main、system、crash 几个缓冲区一起捞出来。显示类问题经常是 native crash 先发生在 system 缓冲区,主缓冲区里反而干干净净,只捞 main 会漏掉关键信息。
5. 想改一条显示 property,有三条路,代价差很多
5.1 改 build.prop:最干净,但要动镜像
能改ro.属性的第一条路就是改build.prop。在虚拟机上的操作流程大概是:
emulator -avd <名字> -writable-system adb root adb remount adb pull /vendor/build.prop # 修改后推回去 adb push build.prop /vendor/build.prop adb shell stop && adb shell start-writable-system是关键,不加这个参数/system和/vendor都是只读挂载,adb remount会失败。改动生效需要重启框架(stop+start)或者整机重启,因为ro.属性在 init 阶段就定下来了。
这里有个真实体验要提醒:-writable-system下的改动在我的环境里重启后还能保留,但换安卓版本、wipe data 或者换个 AVD 就没了。别把宿主机上的临时改动当成正式方案,要长期稳定还是得重新打包镜像或者用 init 脚本固化。
5.2 init.rc 里 setprop:适合"启动早期就必须定下来"的属性
第二条路是在init.<hardware>.rc里写setprop,格式大概是这样:
on early-init setprop ro.hardware.hwcomposer ranchu setprop ro.opengles.version 196608这里最容易翻车的是时机窗口。early-init阶段太早,某些属性服务可能还没完全就绪,而且 SELinux 策略可能还没加载完,写入会被拒;boot阶段太晚,SurfaceFlinger 早就启动了,属性设了也白设,只影响下一次。显示类的属性一般放在on init到on early-init之间比较稳,具体哪个阶段能成功,得靠logcat里 init 的日志去验证。
我个人的经验是:优先用 build.prop,只有在需要按条件动态决定属性值的时候才用 init.rc。因为 build.prop 是声明式的、好审查,init.rc 里能写逻辑,也就意味着能写出难查的条件分支。
5.3 adb setprop:只能改非 ro. 的调试属性
第三条路是运行时改,也就是setprop。它只对debug.*、persist.*和一些非ro.的系统属性有效:
adb root adb shell setprop debug.hwui.renderer skiagl adb shell setprop debug.sf.latch_unsignaled 1debug.hwui.renderer这条挺实用,可以切换应用侧的渲染后端(比如 OpenGL 还是 Vulkan 后端),虚拟机上 Vulkan 支持不全的时候用它切回 GL 后端起效很快。persist.前缀的属性会写进/data/property/,重启保留,做长期实验比较方便。
注意:改完这些
debug.属性,通常要重启对应的进程才生效。改渲染后端要杀掉应用进程重开,改 SurfaceFlinger 相关的一般要adb shell stop && adb shell start,光改属性不重启是看不到变化的。
5.4 property_override:唯一能"后悔"的机制
安卓 10 以后 init 提供了property_override,可以在 init 阶段覆盖一个已经被写过的ro.属性:
on init property_override ro.hardware.egl swiftshader这是唯一能在"ro.已经写过了"之后仍然改掉它的正规手段,但限制也很明确:只能在 init 阶段用,不能从adb shell触发;而且覆盖的仍然是ro.属性那套一次性语义,只是把"第一次"往后挪了。用它之前先确认镜像的 init 版本支持这个指令,老版本会直接报语法错误导致 init 启动失败,那就不是黑屏而是连 adb 都连不上了。
6. property 完全正确但画面还是不对,往哪查
6.1 属性对了,so 不存在:先看 32/64 位目录
ro.hardware.hwcomposer=ranchu完全正确,但/vendor/lib64/hw/里只有hwcomposer.default.so,一样会加载失败。更隐蔽的是 32/64 位混装:/vendor/lib/hw/(32 位)里有hwcomposer.ranchu.so,/vendor/lib64/hw/里没有,而 SurfaceFlinger 是 64 位进程,它只会去lib64找。
表现形式是dlopen失败但错误信息很含糊,可能只报"找不到模块"。所以排查顺序上,看属性之前先确认模块文件在两个目录下都存在且架构匹配,这一条能过滤掉相当一部分问题。
6.2 so 存在也加载了,但依赖缺了
模块加载成功不代表能用。用readelf -d看它的NEEDED依赖:
adb shell "readelf -d /vendor/lib64/hw/hwcomposer.ranchu.so | head -30"显示类模块常见的缺失依赖是libEGL.so、libgbm、libc++.so这几个。虚拟机镜像裁剪的时候很容易漏掉某个间接依赖,因为dlopen报的错只会提到直接依赖,间接依赖缺失往往表现为运行时某个函数指针为空然后崩掉。
6.3 加载都正常,画面却是斜条纹或者撕裂
这是我遇到过的、最有"迷惑性"的一类问题:属性对、模块对、依赖齐,画面却是斜着一道道条纹。原因是 gralloc 和 HWC 对 buffer stride(行跨距)的对齐要求不一致。gralloc 按 4 字节对齐算 stride,HWC 按 64 字节对齐去读,读出来的每一行偏移都错一点,累积起来就是斜纹。
这类问题的排查线索是:画面中静止的部分也在斜,说明不是时序问题,是数据布局问题。反过来如果只有运动物体撕裂,那才是垂直同步或者 buffer 数量的问题,方向完全不同。
6.4 -gpu 参数和 guest 属性打架
虚拟机宿主侧的-gpu参数和 guest 里的qemu.gles、ro.kernel.qemu.gles是配对的。如果你在 AVD 配置里写了-gpu host,但 guest 镜像里的属性还是 guest 软渲染那套,两边就会互相不理解,表现为画面能出但性能极低,或者 Vulkan 相关应用直接崩。
验证方法是把这几条值摆在一起看是否自洽:
| 宿主设置 | 期望的 guest 属性 | 不一致时的表现 |
|---|---|---|
-gpu host | qemu.gles=2,GLES 走直通 | 性能异常低,像软渲染 |
-gpu guest | qemu.gles=1,guest 侧翻译 | 偶发花屏,宿主负载很低 |
-gpu swiftshader_indirect | EGL 指向软渲染实现 | 与属性不一致时启动慢但不崩 |
-gpu off | 无显示加速 | 应用报 GLES 不可用 |
6.5 一张"现象到属性"的对照表
排障的时候最耗时间的其实是"从现象想到该查什么"。把我常用的对照关系整理成表,贴在工位上挺有用:
| 现象 | 优先查的属性 | 常见根因 |
|---|---|---|
| 黑屏且 SurfaceFlinger 反复重启 | ro.hardware.hwcomposer | 变体名写错,模块不存在 |
| 开机动画能过但应用全黑 | ro.hardware.egl、ro.opengles.version | EGL 实现缺失或版本上报错误 |
| 画面斜条纹 | ro.hardware.gralloc | 分配器与合成器对齐要求不一致 |
| 性能极低像软渲染 | qemu.gles、ro.kernel.qemu.gles | 宿主直通参数与 guest 属性不匹配 |
| 应用报不支持 Vulkan | ro.hardware.vulkan | 属性为空或指向不存在的实现 |
| 属性设置无任何反应 | 属性名前缀与property_contexts | ro.已写过,或 SELinux 未放行 |
7. 几个我反复踩到的坑,以及一点个人体会
第一个坑是用 diff 的方式批量改属性。我早期的做法是把两台机器的getprop输出导出,逐条比对差异然后照搬。结果越改越乱。原因是属性之间有耦合,比如ro.kernel.qemu和qemu.gles是一组,ro.hardware.*和ro.boot.hardware又是一组,单独搬一条过去,等于破坏了另一台的内部一致性。后来我改成"分组看、成组改",先把显示相关的属性按链路分成分配、合成、渲染三组,再决定动哪一组。
第二个坑是属性值里带空格或者特殊字符。有的属性看着能设进去,但读出来被截断或者后半段消失。属性系统对值的处理非常粗糙,别指望它做转义。有复杂配置需求的话,写文件、读文件,别硬塞进属性。
第三个坑,也是我觉得最值得说的一条:改动ro.hardware之前先确认所有 HAL 的变体模块都换了名字。我前面提到的音频那次翻车就是这么来的。后来我的习惯是,只要涉及ro.hardware,就先把/vendor/lib64/hw/和/vendor/lib64/目录完整列一遍,确认所有以变体名结尾的模块都存在,再动手。这个前置检查花不了一分钟,能省掉一整晚。
最后一个体会是关于心态的。显示问题看起来玄学,其实链路非常确定:属性选模块、模块提供能力、能力决定渲染路径,一步扣一步。真正难的不是技术,是在信息量爆炸的日志里先分类再定位。我现在的固定顺序是:先看现象属于"起不来"还是"起来但不对",再看属性属于"为空"还是"写错",最后用dumpsys和/proc/<pid>/maps验证实际路径。按这个顺序走,绝大多数问题都能在三轮以内收敛到具体那一条属性上。