资讯动态

SystemServer软重启排查:属性写入失败与JNI异常链路

发布时间:2026/10/6 16:40:00 来源:尧图企业网站定制
先说结论这个问题的排查过程非常磨人但最后修复起来只有十几行代码。如果你也维护过 Android Framework 层应该对这类场景不陌生——设备在用户完全无感知的情况下悄悄重启没有按键、没有 OTA、没有低温只有一行FATAL EXCEPTION: main孤独地躺在system_server进程的日志里。上周我在处理一个运营商定制固件时就摊上了这么一桩CompatibilityChange相关代码里设置系统属性偶发失败JNI 层抛出的异常没人接住最终把SystemServer整个拖死手机直接软重启。这个案例不是最复杂的但排查链路特别典型——从兼容开关机制到属性服务再到 JNI 异常跨语言传递最后用 crash 工具解析还原现场一条线全串起来了。值得完整复盘一次。1. 崩溃现场与第一轮信息收集1.1 现象不是死机是软重启用户反馈手机在刷视频时突然卡在开机 logo没有摔、没有进水、没有低电量。拿回实验室用adb bugreport拉日志发现system_server进程已经被杀掉了。logcat 里躺着这样一条记录03-11 09:23:45.321 1234 1234 E AndroidRuntime: FATAL EXCEPTION: main 03-11 09:23:45.321 1234 1234 E AndroidRuntime: Process: system_server, PID: 1234 03-11 09:23:45.322 1234 1234 E AndroidRuntime: java.lang.RuntimeException: failed to set system property 03-11 09:23:45.322 1234 1234 E AndroidRuntime: at android.os.SystemProperties.set(Native Method) 03-11 09:23:45.322 1234 1234 E AndroidRuntime: at com.android.server.compat.CompatConfig.writeProperty(CompatConfig.java:388) 03-11 09:23:45.322 1234 1234 E AndroidRuntime: at com.android.server.compat.CompatConfig.updateOverridesLocked(CompatConfig.java:354) 03-11 09:23:45.322 1234 1234 E AndroidRuntime: at com.android.server.compat.CompatConfig.onPackageChanged(CompatConfig.java:233) 03-11 09:23:45.322 1234 1234 E AndroidRuntime: at com.android.server.compat.PlatformCompat$1.onPackageChanged(PlatformCompat.java:120)第一眼看上去set property抛异常不是什么大事可它偏偏发生在system_server的主线程上。主线程一旦有未捕获异常RuntimeInit里的KillApplicationHandler会直接把进程干掉接着Zygote重启整个系统服务栈。这不是死机是典型的软重启。这里先给不熟的同学补个背景SystemServer的主线程承担着大量系统初始化任务包括ActivityManager、PackageManager这类核心服务的启动。主线程上的Looper.loop()并不会帮你捕获异常任何逃逸出来的异常都会走到RuntimeInit的最后一道防线。所以 Framework 代码里只要有一行SystemProperties.set()没有做保护就等于在主干道上埋雷。1.2 现场外围信息这崩溃有规律但没逻辑我把过去一周的崩溃记录都拉了出来整理成一张表维度观察结果崩溃频率7 次集中在凌晨 2 点到 6 点崩溃线程全部是 system_server 的 main 线程崩溃前的操作有platform_compat相关日志但没有明显的包安装/升级动作SELinux 日志几乎没有 AVC denial 记录和 OTA 的关系固件升级后崩溃频率明显上升是否必现否完全概率性最让人头疼的就是最后一行概率性。同一个操作十次里九次成功一次失败。这种问题靠常规复现基本无效只能从代码路径和系统状态反推。当时我把可疑范围锁定在三块CompatibilityChange的触发逻辑、SystemProperties的 JNI 实现、以及属性服务底层的写入机制。这三块恰好构成了一条完整链路接下来逐个拆。2. CompatibilityChange 到底是什么它为什么要写系统属性2.1 它是一套面向 targetSdk 的兼容开关现在不少做应用开发的同学对CompatibilityChange可能有点陌生但做系统服务的对它再熟悉不过。Android 每个大版本都会引入一批行为变更比如存储权限收紧、前台服务限制、广播限制。问题是这些变更没法一刀切——老应用如果 targetSdk 还停在 29系统就不该强制启用新行为新应用 targetSdk 已经到了 34就该立刻生效。于是 Android 11 开始引入了这套机制系统把所有行为变更注册成一个个 ChangeId每个 ChangeId 都有默认状态、受影响的 SDK 版本范围以及应用粒度的覆盖开关。运行时由PlatformCompat服务来裁决某个应用当前该用哪个版本的行为逻辑。你可以把它理解成一张大表应用 A 在 Android 14 上依旧用旧行为应用 B 却必须开启新行为。这张表的管理者是系统服务但应用层也能通过ApplicationCompat间接感知到变化。2.2 SystemServer 里是谁在维护这张表具体到代码层面PlatformCompatService是暴露给外部 Binder 调用的门面真正的数据仓库在内部的CompatConfig里。CompatConfig启动初期会做两件事扫描系统内置的 ChangeId 定义然后读取持久化存储中每个应用的覆盖状态。它和PackageManager是联动关系监听包安装、卸载、升级事件。每当一个包变更onPackageChanged就会触发把相关 ChangeId 的实际生效值下发到具体模块。这里注意一个细节CompatibilityChange的生效路径分两块。纯 Java 层的行为由各个系统服务在查询时直接读取CompatConfig的判定结果而涉及 native 层的行为比如 GC 策略、内存分配策略就需要通过系统属性透传下去。这就是崩溃链路的起点——CompatConfig.updateOverridesLocked里调用了writeProperty试图把一个兼容开关的值写进系统属性。2.3 为什么要用系统属性而不是 Binder 直接调很多刚接触这套代码的人会问直接通过 Binder 调用 native 进程的接口不行吗技术上行但工程上不划算。系统属性有几个独特优势第一它是全局可见的zygote、app_process、各种 native 守护进程都能直接读不需要维护一套跨进程订阅关系第二它写入后又轻量又快速底层是共享内存映射第三它内建在 init 进程里系统属性在任何服务启动之前就已经可用了。所以我看到CompatConfig写属性的那一刻并没有觉得设计有问题真正的问题在于代码把属性写入当成了一次绝对不会失败的操作。没有任何异常防护没有重试机制连个日志都没有。一旦底层返回失败JNI 抛出的异常就会穿透所有调用栈。3. 概率性失败根因排查三个候选方向3.1 候选一SELinux 策略拒绝查属性写入问题第一个绕不开的就是 SELinux。Android 的每个属性名都有对应的 SELinux 上下文比如sys.前缀、ctl.前缀、ro.前缀都由不同的 te 规则管控。如果某个进程试图写入一条自己没权限的属性logcat里大概率会刷出 AVC denial 记录。我先做了验证adb logcat -b events -d | grep -i avc adb shell dmesg | grep avc | grep compat结果很干净一条 AVC denial 都没有。而且按经验SELinux 拒绝通常是 100% 必现的不符合我们概率性失败的特征。这个候选先划掉。3.2 候选二属性写入的时序竞态再看属性服务底层的实现。Android 的属性区是共享内存映射所有写操作都需要拿锁。正常情况下这个锁的竞争非常短暂但在系统启动初期有个特殊阶段zygote正在预加载资源SystemServer各服务在并行初始化init进程也在按 rc 文件逐个执行命令。如果CompatConfig的写入恰好撞上某个大块写入或者触发了一次映射页缺失在极低概率下写者会拿到失败返回值。这类竞态最难查因为它不留下任何直接痕迹。你的属性值可能已经写成功了但返回值是失败的也可能根本没写进去但下一次启动另一个服务又把同样的值写了一遍。为了确认我给writeProperty临时加了打点用logcat记录每次调用的时间戳和当前属性值连续跑了三天。结果发现一个规律崩溃时间点和PackageManager扫描system分区完成的时刻高度重叠大概就在ACTION_PACKAGE_NEEDLE_VERIFICATION广播前后几十毫秒。这基本坐实了竞态方向。3.3 候选三属性名长度超限还有人可能会忽略一个很蠢的原因属性名长度。android_property的PROP_NAME_MAX是 32 字节PROP_VALUE_MAX是 92 字节。CompatConfig构建属性名的逻辑是直接拼接 ChangeId 和包名包名一长就很容易逼近上限。我看了一眼崩溃现场那条属性名是sys.compat.change_369382633.com.company.someverylongpackagename加前缀后已经超过 30 字节。虽然没超但已经非常接近边界。这种接近边界的场景最容易出概率性问题某些字符集算长度时按 UTF-8 算某些地方按 UTF-16 算一个多字节字符就可能把长度推到临界值。我把候选三也记下来继续往 JNI 层深挖。4. JNI 异常是如何一路畅通到崩溃的4.1 SystemProperties.set() 的跨语言之路SystemProperties.set()在 Java 层是个 native 方法对应的 JNI 实现长这样// frameworks/base/core/jni/android_util_SystemProperties.cpp static void SystemProperties_set(JNIEnv* env, jobject, jstring keyJ, jstring valueJ) { const char* key env-GetStringUTFChars(keyJ, nullptr); const char* value env-GetStringUTFChars(valueJ, nullptr); int result property_set(key, value); env-ReleaseStringUTFChars(keyJ, key); env-ReleaseStringUTFChars(valueJ, value); if (result 0) { jniThrowRuntimeException(env, failed to set system property); } }底层的property_set()是 libc 里的实现返回 0 表示成功负数表示失败。JNI 层拿到失败返回值后调用jniThrowRuntimeException把错误转成一个 Java 异常抛出去。这个 Java 异常一路穿过SystemProperties.java最后到达CompatConfig.writeProperty。JNI 规范里有个特别容易踩的坑C 代码不能直接抛 Java 异常只能通过env-ThrowNew()设置一个 pending exception然后返回调用方。JNI 调用方的 Java 代码如果不在栈上捕获它异常就会继续往上抛。而CompatConfig里恰恰没有try-catch于是异常一路逃到 Looper被RuntimeInit截获。4.2 SystemServer 为什么没有兜住很多人以为SystemServer.main()里应该有个大型try-catch兜底实际完全不是这样。SystemServer的启动流程确实会有阶段性的异常捕获但在进入消息循环之后主线程上任何未捕获异常都会交给Thread.setDefaultUncaughtExceptionHandler设置的处理器。Android Framework 里这个默认处理器是RuntimeInit$KillApplicationHandler它的核心逻辑就是打印日志、调用ActivityManager杀进程、然后自杀。所以崩溃日志里会看到非常标志性的三行E AndroidRuntime: FATAL EXCEPTION: main E AndroidRuntime: Process: system_server, PID: 1234 E AndroidRuntime: Crash of the system_server process到此为止崩溃链完整了property_set 返回失败 - JNI 层检查到 result 0 - jniThrowRuntimeException 设置 pending exception - 异常从 SystemProperties.set 抛出 - CompatConfig 没有捕获 - Looper.loop() 处理不了 - KillApplicationHandler 杀进程 - SystemServer 重启4.3 JNI 异常处理的三层防御位置把这条路反过来看其实有三个地方可以拦下问题防御层级具体位置本案状态C 层property_set()内部提前做合法性校验正常失败来源在底层JNI 层jniThrowRuntimeException后检查 pending exception 状态正常异常已成功设置Java 层CompatConfig.writeProperty捕获异常并降级缺失这是漏洞所在这里要强调一点JNI 层抛出异常本身不是 Bug它是标准的错误报告方式。真正的 Bug 是 Java 调用方把native 方法也可能抛异常这件事忘了。很多系统属性调用方都有这个问题因为他们潜意识里觉得属性写入和普通变量赋值一样不可能失败。实际上属性区的锁竞争、磁盘同步失败、SELinux 拒绝、名称非法任何一个因素都能让property_set返回负数。5. 用 Crash 工具把现场彻底钉死5.1 收集物证的正确姿势排这种概率性问题不能光看一段 logcat 就下结论必须把物证收集全。我一般按这个顺序操作# 第一步抓完整 bugreport adb bugreport bugreport.zip # 第二步拉取所有 tombstone 文件 adb pull /data/tombstones/ tombstones_backup/ # 第三步抓内核日志 adb shell dmesg dmesg.txt # 第四步拉持久化属性 adb shell getprop getprop_all.txtbugreport是最重要的因为它会把 logcat、事件日志、系统信息、dumpstate全部打包在一起。对 system_server 崩溃来说BUGREPORT里的system_log和event_log分段足够完整。5.2 从 tombstone 中读取另一面信息Java 层崩溃最直观的堆栈在 logcat 里但 native 侧的 tombstone 也不能忽略。tombstone_XX里记录了崩溃进程的backtrace、寄存器状态、内存映射和线程列表。当我们看到FATAL EXCEPTION时system_server 可能已经进入自杀流程而这个流程本身也会产生 tombstone。用社区常用的解析脚本可以快速提取关键段python system/core/debuggerd/tombstone_parse.py tombstones/tombstone_04重点看两样东西一是signal info如果是 SIGABRT6说明是主动调用了 abort二是stack trace里有没有关于art::Runtime和android::base的帧这能帮我们确认是不是 Java 未捕获异常触发的 native 自杀。5.3 事件日志给时间线盖章光看崩溃瞬间的堆栈还不够我们还需要回答为什么这一刻会失败。这就要把事件日志里的时间线拼出来。对我这个案例关键是找到platform_compat相关的事件以及在崩溃前的一分钟里有没有包管理相关的广播。实际操作命令adb logcat -b events -d -t 500 | grep -E am_crash|am_proc_died|platform_compat|package如果看到类似am_proc_died记录表示某个进程死了再往前翻package_install或者package_needle_verify事件就能把竞态窗口缩到很小。那一刻系统里发生的并发写入就是元凶候选。5.4 crash 工具解析的一个关键心得很多同学拿到 crash 日志后喜欢先在tombstone里找 native 栈这个习惯对纯 native crash 没问题但对 system_server 的 Java 崩溃会走弯路。Java 崩溃的根因永远在FATAL EXCEPTION的 Java 调用栈里tombstone 只是辅助。正确读法是把 logcat 里的 Java 栈当作主线索tombstone 和 dmesg 当作环境摄像头三者对照才能还原完整场景。我当时对照完三份物证后确认了竞态窗口SystemServer启动后期PackageManager正在并行广播包事件CompatConfig的onPackageChanged回调在同一时刻写属性。属性服务内部锁竞争导致这一次写入失败JNI 打包成 RuntimeException主线程没接住进程原地去世。6. 修复方案与端到端验证6.1 第一道防线Java 层捕获并安全重试根因清楚了修复思路就明确在CompatConfig.writeProperty里把SystemProperties.set()包进try-catch同时设计重试逻辑。这里有几个要点private static boolean writePropertyWithRetry(String key, String value, int maxRetries) { for (int attempt 1; attempt maxRetries; attempt) { try { SystemProperties.set(key, value); return true; } catch (RuntimeException e) { Slog.w(TAG, set property key failed, attempt attempt, e); if (attempt maxRetries) break; // 等待一小段时间再重试避免在同一个竞态窗口里反复撞墙 SystemClock.sleep(50L * attempt); } } Slog.e(TAG, set property key still failed after maxRetries attempts); return false; }重试机制有几个细节要注意。第一不要无限重试最多三次就够再多次会拖慢启动流程第二重试之间要有间隔否则你会在一个锁竞争窗口内连续拿到失败白白浪费资源第三终极失败后不要 panic因为属性写入往往不是唯一路径失败时可以选择跳过本次同步等下次包变更再补写。对CompatChange这类开关即使某一次没有写成功系统重启后会重新读取持久化存储最终一致性是有保证的。6.2 第二道防线key 长度与合法性校验重试解决的是偶发问题但我们还应该主动规避那些可预防的失败原因。我在修复里加了一个辅助函数专门校验属性名长度private static final int PROP_NAME_MAX 32; private static boolean isValidPropertyKey(String key) { if (key null || key.length() 0 || key.length() PROP_NAME_MAX) { Slog.w(TAG, invalid property key: length (key null ? 0 : key.length())); return false; } for (int i 0; i key.length(); i) { char c key.charAt(i); // 属性名不允许空格、控制字符 if (c 0x20 || c 0x7E) return false; } return true; }这个校验和我前面怀疑的候选三直接对应。实际改动中我把属性名的构造逻辑从直接拼接包名和 ChangeId改成了使用短哈希后缀把长度彻底降下来。这样即使以后出现超长包名也不会再踩长度安全线。6.3 第三道防线JNI 侧补充异常状态检查有些团队会怀疑 JNI 层是否也需要加固。如果在 JNI 函数里调用其他 native 代码后没有主动检查 pending exception那么后续的 native 操作可能处于不一致状态。我们这次检查了 AOSP 最新代码SystemProperties_set在jniThrowRuntimeException之前没有其他可能产生 pending exception 的调用所以 JNI 侧本身没问题。不过为了让代码更严谨我建议在所有调用env-ThrowNew的 JNI 函数里都遵循一个固定习惯抛出后主动调用一次ExceptionCheck再决定是否尽早返回。if (result 0) { jniThrowRuntimeException(env, failed to set system property); if (env-ExceptionCheck()) { return; } }多这一行不影响性能但能防止异常已经 pending 但调用方继续往下跑的诡异情况。6.4 验证方案从压测到回归修复写完不能拍拍手就算完事概率性问题必须用压力手段来提高复现率。我的验证流程是这样搭的# 模拟并发包变更触发 onPackageChanged 高频调用 for i in $(seq 1 100); do adb shell pm install -r /data/local/tmp/test_app.apk adb shell cmd platform_compat enable-change $i com.example.test done压测脚本同时开着 logcat 结果过滤adb logcat -c # 压测结束后 adb logcat -d | grep -E CompatConfig|FATAL EXCEPTION|failed to set连续跑三轮每轮 200 次变更确认没有任何FATAL EXCEPTION然后重启设备模拟开机过程再交叉验证持久化属性值是否和CompatConfig数据仓库一致。6.5 修复结果复盘修复上线后观察了两周崩溃从每周 7 次降到 0 次。整个修复改动量确实很小一个try-catch一个长度校验加上一个 JNI 侧异常检查加起来不到三十行。但排查过程花了四天。这就是概率性系统崩溃的典型特点定位难修复易。真正的成本全在建立正确的因果链上。7. 从这次排错沉淀下来的三个经验7.1 永远不要把 SystemProperties.set() 当成不会失败的调用这句我写在团队 Wiki 的最顶部。系统属性虽然底层实现很轻但它毕竟是一次跨进程、跨语言、需要拿锁和校验权限的写操作。任何写操作都有失败的可能。特别是 SystemServer 进程里一个看似无害的 native 方法调用可以直接导致整个系统重启。给团队定规矩Framework 代码里凡是调用SystemProperties.set()一律要加try-catch或至少打日志。7.2 排查 system_server crash 先看线程身份收到system_server crash报告后第一件事不是看堆栈细节而是看崩溃发生在哪个线程。system_server里有四个关键线程需要区分main主线程、android.fg前台线程、android.bg后台线程和各种Binder:xxx线程。主线程崩溃影响最大因为它会直接拖垮所有核心服务Binder线程崩溃影响相对可控但如果是系统关键服务的 Binder 线程一样可能导致系统不稳定。线程身份决定了我们需要用多高的优先级去对待这个 Bug。7.3 crash 工具解析要成体系不能只盯一段日志回到标题里提到的 crash 工具解析我强烈建议每个人建立自己的排查 SOP拿到崩溃先看FATAL EXCEPTION的 Java 栈再对照 tombstone 确认 native 侧行为然后用事件日志还原时间线。三样缺一不可。只看 Java 栈你永远不知道是不是有 native 竞态在搞鬼只看 tombstone你会浪费大量时间在非主导路径上只看事件日志你很难定位具体的异常点。把三份物证拼在一起概率性问题的因果链才能完整呈现。最后再分享一个小技巧遇到概率性崩溃别急着改代码先把所有崩溃日志按时间戳排成一个全局时间线。你会发现大部分毫无规律的异常其实都有隐性的周期或触发条件——时间线一拉开规律自己就冒出来了。我这个案例就是这么破局的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑