ARTICLE DETAIL

资讯详情

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

SystemServer崩溃排查:CompatibilityChange属性设置与JNI异常处理实战

SystemServer崩溃排查:CompatibilityChange属性设置与JNI异常处理实战 遇到过 system_server 神秘重启的朋友都知道那种“概率性崩溃、日志指向不明、重启后又正常”的问题最消耗人。我最近在维护兼容性适配模块时就撞上了一个非常典型的坑CompatibilityChange 设置属性概率性失败紧接着 JNI 抛出的异常没有走到正确的处理分支最终直接干掉了 SystemServer整机重启。这个问题的定位过程绕了不少弯路最后把链路彻底复盘了一遍发现三个缺陷叠加才导致崩溃。这篇就把它完整拆开讲清楚 crash 怎么抓、根因怎么挖、修法怎么落地顺带把 CLion 调 JNI 环境配置和 crash 工具解析的实操也一并分享出来。1. 先还原现场CompatChange 设置属性为什么会扯上 JNI 和 SystemServer1.1 现象描述与影响范围从用户视角来看设备运行一段时间后随机卡死然后直接黑屏重启频率大概每天 1~2 次没有固定复现路径。从系统视角来看/data/anr 和 dropbox 里出现了大量 system_server 崩溃记录崩溃类型是 FATAL EXCEPTION IN SYSTEM PROCESS进程名 system_server用户空间堆栈指向 CompatibilityChange 相关代码。机器重启后短期内看起来完全正常因为很多初始化逻辑只在开机阶段执行问题被“重置”掩盖了。这个问题的直接影响是系统不可用任何关键后台服务都在 system_server 进程内它一挂就是软重启或硬重启。间接影响更麻烦由于是概率性问题稳定性测试和 OTA 回归阶段基本都被它卡住至少浪费了我一个完整迭代周期的验证时间。如果你们也在做 Android 系统定制或大版本升级适配遇到类似“system_server crash but no obvious trigger”的 bug这篇文章里的排查思路可以直接复用。1.2 CompatibilityChange 机制先讲明白CompatibilityChange 是 Android 用来管理平台行为变更的机制核心目的是让不同 targetSdkVersion 的应用在同一系统上获得不同的行为。比如某条 API 在 Android 14 里行为变了但 targetSdk 为 31 的 App 仍按旧逻辑跑这就是通过 CompatibilityChange 标记加运行时判断实现的。系统内部会维护一张“变更标记表”每条变更都有一个 ID 和对应的默认启用状态。应用安装或升级时PackageManager 会根据应用 targetSdkVersion 计算应该启用哪些变更并持久化一份配置。运行时系统服务通过 ID 查询变更是否生效而底层这样查询的载体往往就落到系统属性上。属性名可能是类似compat.change.id或persist.sys.compat.id的形式设置时调SystemProperties.set()读取时调SystemProperties.get()。问题就出在这个“持久化”。属性写入不是无条件的它可能会失败而调用方如果只写不验状态就出现了裂痕。CompatibilityChange 本身并不是新兴机制但它在开机阶段集中初始化、多线程并发访问恰恰是概率性 bug 的天然土壤。1.3 概率性问题最怕的是没有“必然条件”概率性意味着存在竞态窗口不是每次触发而是满足某种时序条件才触发。比如属性服务还没完全 ready、存储 I/O 短暂阻塞、SELinux 权限校验恰好变慢、线程调度出现重排——这些条件交织在一起就会偶发触发。我当时第一次拿到日志时崩溃栈指向 JNI 的native_get或者某个对象的初始化方法第一反应是“JNI 老代码对象引用没管理好”。后来反复抓日志才发现真正的起始点在更早的属性设置环节。这种“前段逻辑埋雷、后段逻辑引爆”的模式在 SystemServer 这种长期运行的大进程里非常典型因为一个服务的异常状态不会立刻崩而要等到另一个服务用到错误状态时才爆。2. 用 crash 工具解析崩溃现场从日志堆栈倒推全链路2.1 先抓全日志别急着看堆栈遇到 SystemServer crash很多人的第一动作是看 fatal exception 的 java stack但其实这不够。我要做的第一件事是抓全三份东西logcat -b all的完整 buffer特别是崩溃前后约 10 秒的片段/data/tombstones/下最新的 tombstone 文件可能有 native 层异常dropbox 目录下 system_server_crash 条目。抓取命令可以这样写adb shell logcat -b all -d /data/local/tmp/log_all.txt adb shell ls -lt /data/tombstones/ adb shell dumpsys dropbox --print /data/local/tmp/dropbox.txt adb pull /data/tombstones/tombstone_XX很多人只抓 debug 级别日志会漏掉属性服务在失败瞬间打印的 warning。我这次真正看到setprop失败痕迹就是靠的一行不起眼的PropertyService: Failed to set property ...它比 java stack 出现得早得多。所以原则是先下载所有日志再统一分析千万不要只盯 FATAL EXCEPTION 那一段。2.2 识别关键堆栈行拿到 SystemServer 崩溃日志后要反着读。崩溃处理时打印的堆栈是“异常抛出点到主线程调用点”的完整链路真正的原因入口是堆栈最深处的第一个业务调用不是最上面的Process.killProcess。举个例子崩溃堆栈可能长这样FATAL EXCEPTION IN SYSTEM PROCESS: main java.lang.RuntimeException: Failed to set compat change: 272333444 at com.android.server.pm.CompatibilityChangeHelper.setChangeState(Native Method) at com.android.server.pm.CompatibilityChangeHelper.updateCompatStates(CompatibilityChangeHelper.java:128) at com.android.server.pm.PackageManagerService.onBootPhase(PackageManagerService.java:5200) at com.android.server.SystemServer.startOtherServices(SystemServer.java:890) ...看到Native Method标识的setChangeState就应该立刻意识到这个RuntimeException是从 JNI 层抛回 Java 层的。再配合onBootPhase这个调用点基本能定位到问题发生在系统启动的 PHASE_BOOT_COMPLETED 前后此时多个系统服务正在并行初始化竞态条件成立。2.3 tombstone 与 native 侧代码的解析方式如果异常发生在 native 侧Java stack 可能只显示到某个native_xxx就断开了这时必须解析 tombstone。tombstone 文件里最关键的是backtrace段和memory map段。backtrace 会给出 native 函数调用序列比如#00 pc 000000000024a78c /apex/com.android.art/lib64/libart.so (art::JNI::ThrowNew(...)) #01 pc 0000000000189a20 /system/lib64/libandroid_runtime.so (android_os_SystemProperties_native_set(...)) #02 pc 0000000000189a84 /system/lib64/libandroid_runtime.so (com_android_server_pm_CompatibilityChangeHelper_setChangeState(...))用ndk-stack可以把地址转换为带函数名的更可读堆栈$ANDROID_NDK/ndk-stack -sym out/target/product/xxx/symbols -dump tombstone_XX这样就锁定了崩溃链路Java 层调用setChangeState- JNI 层进入属性设置 - native 层发现属性设置返回失败 - 直接ThrowNew抛 RuntimeException - Java 层没有捕获 - SystemServer 的 main 线程直接崩。3. 根因定位三个隐藏缺陷叠加出来的系统级崩溃3.1 属性设置失败链路setprop 的返回值为什么会被忽略第一层缺陷出在SystemProperties.set()的返回值处理上。很多人以为set必然成功但底层实现里属性设置要经过 SELinux 权限校验、属性名合法性校验、属性值长度校验以及属性空间是否已满的检查。SELinux 校验失败、读写顺序错误、或属性空间满时set 操作返回 false但不抛异常。而 CompatibilityChange 的属性名往往带数字 ID动态拼接这就要求调用方格外小心。常见的一个典型案例是属性名长度超过系统限制PROP_NAME_MAX通常是 32 字符这时设置静默失败。我们这版属性名是debug.compat_change.enable.id加后续标识长度在某些 ID 很长时接近上限偶尔超长就触发失败。我当时看到代码里这样写的SystemProperties.set(propName, enabled ? 1 : 0); // 没有任何返回值检查直接继续这就是典型的埋雷。修复时我改成了boolean ret SystemProperties.set(propName, enabled ? 1 : 0); if (!ret) { Slog.w(TAG, set compat change property failed: propName); // 走兜底逻辑比如重试或直接通过内存缓存标记 }这层修复不是解决一切的银弹但它至少让“失败”这件事变得可观测并且避免把错误状态带到下游。3.2 JNI 异常处理规范谁该抛异常谁来接第二层缺陷更致命——JNI 代码抛了异常Java 层没接住。JNI 规范里native 代码可以调用ThrowNew抛出一个 Java 异常对象这个异常会在线程返回 Java 层时被处理。但如果 Java 调用方没有任何 try-catch异常就沿着调用栈一路向上抛最终触发线程的UncaughtExceptionHandlerSystemServer 就直接阵亡了。native 侧代码简化后大概是static void setChangeState(JNIEnv* env, jclass, jlong id, jboolean enabled) { std::string name compat_change. std::to_string(id); if (!property_set(name.c_str(), enabled ? 1 : 0)) { env-ThrowNew(env-FindClass(java/lang/RuntimeException), Failed to set compat change); return; } }这段写法本身没问题问题出现在它把底层属性设置失败直接映射为“Java 异常”而上层代码完全没想过setChangeState会抛异常。更合理的做法是 JNI 返回 bool 错误码让 Java 层决策如果一定要抛异常Java 层必须有预案。我实际改动是优先用错误码static jboolean setChangeState(JNIEnv* env, jclass, jlong id, jboolean enabled) { std::string name compat_change. std::to_string(id); bool ok property_set(name.c_str(), enabled ? 1 : 0); return ok ? JNI_TRUE : JNI_FALSE; }Java 侧把这方法定成boolean返回调用时检查返回值并打日志。这样就把“不可控的异常流”变成“可控的错误分支”SystemServer 的健壮性提升一个档次。3.3 竞态窗口属性服务与 SystemServer 启动时序第三层缺陷是启动阶段的碰撞窗口。SystemServer 在 onBootPhase 阶段会去设置非常多属性而 init 进程的属性服务此时也在处理各种模块的 set 操作。属性服务的写操作在并发场景下没有想象的那么快当某个线程在设置属性时持有锁其他线程的 set 请求就会被阻塞叠加超时机制后表现为“概率性失败”。我们的场景恰好发生在PHASE_BOOT_COMPLETEDAMS、PMS、WMS 等关键服务都在这个阶段推进。CompatibilityChange 更新逻辑被 PMS 的子模块调起同时也可能有其他线程在设置相同前缀的属性形成竞争。虽然没有到数据竞争的程度但操作次序一旦交错就会验证到“旧属性值校验失败”从而触发 JNI 抛异常。这第三层缺陷的修复措施我在 4.3 里细讲这里先提结论把所有 CompatibilityChange 状态初始化收敛到单一入口单线程执行并且安排在 PMS 自身关键初始化完成之后而不是在并行启动风暴里硬插一脚。4. 修复方案落地与回归验证4.1 第一层修复属性设置失败的重试与状态校验我落地的是三层递进的防错机制。第一层针对属性设置失败的即时处理核心思路是先校验再设置、失败即重试、重试不过就降级并用内存缓存兜底。具体改动如下private static final int MAX_SET_RETRY 3; private boolean setCompatChangeProperty(String propName, boolean enabled) { String value enabled ? 1 : 0; for (int i 0; i MAX_SET_RETRY; i) { if (SystemProperties.set(propName, value)) { return true; } // 简短退避避免在属性服务繁忙窗口拥塞 SystemClock.sleep(20 * (i 1)); } // 失败记录便于从 log 复盘 Slog.w(TAG, Failed to set compat property after retries: propName); return false; }这里有个很容易被忽略的细节重试本身也要控制节奏sleep 20ms、40ms、60ms是很实用的退避策略。太激进的重试会进一步冲击属性服务反而加剧失败概率。另外重试前要重新读取getProp确认当前值因为有些失败是外部已把值改成目标值不必白白重试。第一层修复解决的是“现象可见”但要彻底消除对 SystemServer 的威胁还要把影响范围从属性写入缩小。我的做法是即使属性设置最终失败系统也不再硬性依赖这个持久化值而是在内存中维护一份mCompatChangeMap后续查询直接从内存读取。4.2 第二层修复JNI 层异常处理显式化第二层修复直面 JNI 异常问题。我不是简单地在 Java 侧加 try-catch而是做了三件事JNI 函数不再主动 ThrowNew。把大部分属性操作改为返回错误码或布尔值对仍然可能抛异常的边界情况在 JNI 入口统一做 ExceptionCheck。因为只做 Java try-catch 依然可能漏掉 native 内部异常转换不完全的场景Java 侧对确实声明抛异常的 native 方法包一层安全调用工具。JNI 侧的关键校验片段可以这样写static jboolean setChangeState(JNIEnv* env, jclass, jlong id, jboolean enabled) { if (env-ExceptionCheck()) { env-ExceptionClear(); // 清除之前残留的异常确保本次调用从干净状态开始 } std::string name buildPropName(id); if (name.empty() || name.size() PROP_NAME_MAX) { return JNI_FALSE; // 入参异常走错误码不抛异常 } bool ok property_set(name.c_str(), enabled ? 1 : 0); return ok ? JNI_TRUE : JNI_FALSE; }注意ExceptionCheck和ExceptionClear的原因JNI 线程如果已经有了挂起异常再调用ThrowNew会覆盖旧异常或者在某些场景下导致不可预期的二次异常。清掉残留异常、返回错误码是避免 JNI 层异常直接顶爆 Java 调用栈的最稳妥做法。Java 侧安全调用模板private boolean safeSetCompatChange(long id, boolean enabled) { boolean result false; try { result setChangeState(id, enabled); } catch (RuntimeException e) { Slog.w(TAG, JNI setChangeState exception, e); } return result; }这样设计后既保留了 JNI 的快速能力又把异常控制权收回 Java 层SystemServer 不再可能被这个路径直接干掉。4.3 第三层修复收敛初始化入口与启动时序第三层修复是治标之后治本。我梳理了所有调用 CompatibilityChange 状态设置的位置发现至少有三个模块在开机早期会写同一批属性。于是把所有初始化逻辑收敛到一个专用类中增加进程级别锁保证同一时刻只有一个线程在设置这批属性。private final Object mCompatLock new Object(); void updateAllCompatStates() { synchronized (mCompatLock) { // 读取一次配置全量更新不允许其他线程中途插入 ListCompatItem items buildCompatItems(); for (CompatItem item : items) { setCompatChangeProperty(item.propName, item.enabled); } } }在 PMS 侧把调用时机从onBootPhase的早期挪到PHASE_THIRD_PARTY_APPS_CAN_START之后。这样保证 PMS 自己的核心数据表已经初始化、属性服务压力相对平稳竞态窗口显著缩小。这个调整看似只是挪了一行调用但实测效果非常明显崩溃日志直接在两个测试周期里降到 0。背后逻辑很简单很多“概率性”问题并不需要神秘修复只要把初始化时机从高竞争窗口移开让前置条件稳定崩溃条件自然消失。4.4 回归验证怎么证明它真的修好了回归验证分三层走。第一层功能验证开机后依次检查所有 CompatChange 相关属性值是否符合预期同时监控 logcat确认setCompatChangeProperty没有产生 warning第二层压力验证连续循环重启 50 次每次启动后立即模拟多线程并发访问系统服务启动风暴下观察是否还有 crash第三层线上灰度先在一个内测机型上跑 72 小时再逐步扩大。这里分享一个压测脚本思路用简单 shell 循环即可for i in $(seq 1 50); do adb reboot adb wait-for-device sleep 90 adb shell logcat -d -b crash | grep -i system_server crash_check.txt adb shell cat /sys/class/android_usb/android0/state done如果没有捕获到system_server进程异常退出或者 FATAL EXCEPTION基本可以判定修复有效。但要注意概率性问题验证不只看 crash 数量还要对比 widget 之间的时序日志确保每次都在同一阶段完成属性初始化。5. 这类 JNI 崩溃的通用排查方法论与 CLion 调试配置5.1 概率性复现三板斧遇到过很多次概率性崩溃后我总结出了一套固定流程现在遇到类似问题直接照做。第一板斧是抓首连日志。崩溃刚发生时设备可能还在优先抓logcat -b all和tombstone不要重启设备。如果设备已经自动重启就把重启前 buffer 里的日志全部导出。第二板斧是构造竞态条件。概率性 bug 通常和并发或初始化时序相关可以用stop和start某个核心服务来放大竞态窗口或者增加系统负载让属性服务更繁忙人为提高出现概率。第三板斧是加临时打点。在可疑代码路径上增加日志重启一轮收集一轮数据不断逼近根因。我见过不少人一上来就怀疑内存泄漏之类的大问题其实像这种“属性设置失败 JNI 异常未捕获”的组合只要把日志级别调低很容易看到那行 warning。不要直接跳入底层调试先把 Java 到 JNI 的边界日志梳理干净一半的问题能靠日志直接定位。5.2 CLion 中配置 JNI 环境的实操要点当你确定问题必须改 native 侧代码或者想更直观地观察 JNI 函数内部行为时CLion 是个很好的工具。很多人卡在环境配置上我自己的配置过程是这样创建 CMake 工程引入 Android 的 NDK 和源码中的 JNI 头文件。CMakeLists 里至少要这样声明cmake_minimum_required(VERSION 3.18.1) project(jni_debug) set(CMAKE_CXX_STANDARD 17) include_directories( ${ANDROID_NDK}/toolchain/llvm/prebuilt/linux-x86_64/sysroot/usr/include ${ANDROID_SOURCE}/frameworks/base/core/jni ${ANDROID_SOURCE}/libnativehelper/include ) add_library(compatchange_jni SHARED compatibility_change_helpers.cpp ) target_link_libraries(compatchange_jni android log )然后设置 Debug 配置选择 Remote Debug指定设备上的 debuggable 进程如果是 SystemServer 调试需要 root 并打开ro.debuggable1符号文件路径填编译产物里带符号的.so。一个容易踩的坑是SystemServer 里跑的 JNI 库都是系统镜像里的必须先 push 一个新版本到设备并重启 system_server 才会加载。调试前记得执行adb root和adb remount。而且JNI 调试最好在 eng 版本或 userdebug 版本上进行user 版通常不开放 ptrace 权限。5.3 调试 JNI 崩溃时的三个加分技巧第一个技巧是临时把 JNI 函数里的property_set返回值打印出来通过__android_log_print输出到 logcat。这比官方 api 文档更直观因为你是在真实环境里观察真实状态而不是依赖理想模型。第二个技巧是给 JNI 函数手动添加参数合法性断言比如if (id 0 || !env-IsInstanceOf(...)) { __android_log_print(ANDROID_LOG_ERROR, CompatJNI, invalid args id%lld, (long long)id); }当年就是靠这条断言发现某个特殊 ID 拼接后属性名长度超过 32 字符才真正抓到根因。第三个技巧是利用 CLion 的条件断点比如在property_set调用前检查属性名长度大于等于 30 时中断直接看到现场变量值比事后看日志省一个来回。6. 写在最后的避坑心得这个问题的完整复盘让我深刻意识到在 Android 系统服务里做跨语言调用最容易忽略的就是“失败如何传递”这个环节。Java 调用方默认 native 不会出问题native 编写者默认 Java 会捕获异常两层默认一叠加最终就由 SystemServer 拿命买单。我个人的建议是凡是 JNI 边界方法内部逻辑尽量不要主动抛 Java 异常能用错误码就用错误码因为异常跨语言边界传递时调用语义会变得模糊。如果设计上确实需要抛异常那调用方必须把 try-catch 当作基本礼仪不存在“不可能抛异常”的方法。同理系统属性设置这种容易碰壁的操作返回值一定要检查重试机制一定要加日志一定要打印这三件事做到位至少能避开一大半“属性相关概率性崩溃”的坑。根据我个人经验修完这类问题之后一定要在下一轮版本里持续观察开机的第一个五分钟日志因为真正的竞态窗口集中在启动阶段。如果你也有类似的 SystemServer 崩溃案例建议按“日志全量抓取-识别调用时序-JNI 边界异常处理-启动时机收敛”的顺序排查大概率能比我在这个 case 上走得快很多。
返回列表