Android-system_server奔溃问题排查

27 阅读16分钟

一次 system_server 崩溃循环排查实录:从 tombstone 到根因的完整方法论


目录

  1. 预备知识:tombstone 是什么,里面有什么
  2. 第一步:读头部——谁、在哪、怎么死的
  3. 第二步:读调用栈——上面看死法,下面看来源
  4. 第三步:看内存快照——ASCII 列里的"案发字符串"
  5. 第四步:用源码验证猜测
  6. 第五步:从 logcat 找触发方
  7. 第六步:排除干扰项、建立对照组
  8. 根因定论与完整因果链
  9. 修复建议
  10. 方法论总结:新 tombstone 分析 Checklist
  11. 附录:本例涉及的源码位置与工具

1. 预备知识

tombstone 是什么:Android 上进程收到致命信号(SIGSEGV/SIGABRT/SIGBUS 等)时,由 debuggerd 抓取的"死亡现场"——寄存器、调用栈、关键内存、进程 maps、以及内嵌的一段 logcat。默认存放在 /data/tombstones/(也随 bugreport 导出)。它回答的是"这个进程死的那一刻在干什么"。

一份 tombstone 的结构,和每一段的用途:

段落内容用途
头部build fingerprint、时间戳、进程存活时长、Cmdline、pid/tid/线程名、uid、信号与 fault addr、Cause谁、在哪、怎么死的,第一眼必看
寄存器全量通用寄存器 + pc/lr/sp配合反汇编定炸点;有些寄存器直接就是线索(见后文 x0/x8)
backtrace崩溃线程调用栈,带 so/jar/oat 路径、符号名、偏移、BuildId死法(栈顶)+ 来源(栈底)
memory near xx崩溃相关寄存器/栈指针附近的内存 dump,hex + ASCIIASCII 列经常直接吐出"案发字符串"
memory map进程地址空间映射定位地址属于哪个映射(栈/堆/匿名 mmap)
log:内嵌的一段 logcat(很多人不知道有这段)崩溃前后系统视角的最后输出

两个容易踩的坑先说在前面:

  • tombstone 头部的 Timestamp 是 debuggerd 落盘时间,不是信号发生时间。真实的崩溃时刻要去 logcat 里找 Fatal signal ... 那一行。本例两者差了约 340ms。
  • 调用栈只包含崩溃线程。多线程因果(比如"另一个进程通过 binder 触发")必须靠 logcat 或其他材料补。

2. 第一步:读头部

拿到 tombstone 先读前 30 行,逐项回答:谁(进程/线程)、在哪(哪个信号/哪个地址)、怎么死的( Cause) 。

本例的头部(节选):

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: userdebug/dev-keys'
Revision: '0'
ABI: 'arm64'
Timestamp: 2026-10-08 19:28:40.970513008+0800
Process uptime: 87s
Cmdline: system_server
pid: 15590, tid: 15907, name: binder:15590_7  >>> system_server <<<
uid: 1000
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000000
Cause: null pointer dereference

能读出四条关键信息:

  1. 死者是 system_server,线程是 binder:15590_7——一个 binder 工作线程,说明崩溃发生在处理一次跨进程调用的过程中,而且这次调用来自外部进程(tombstone 里看不到对端,后面要用 logcat 找)。
  2. signal 11 (SIGSEGV), SEGV_MAPERR, fault addr 0x0,Cause 直接写明 null pointer dereference——空指针,且是最"纯粹"的那种:对地址 0 解引用。
  3. Process uptime: 87s——system_server 死时只活了 87 秒。结合"反复重启"的现象,这暗示崩溃循环:上次崩溃 → 重启 → 87 秒后又崩。这个字段在排查"偶发还是循环"时非常有用。
  4. ABI arm64、userdebug 版本——后面会看到 userdebug 的调试日志开关是这个案子的"帮凶"之一。

方法论:头部先给出三个锚点——崩溃线程类型(主线程/binder 线程/工作线程,决定你往哪个方向找触发方)、信号与 fault addr(野指针/空指针/栈溢出各不相同)、uptime(判断是否循环崩溃)。fault addr = 0 且 Cause 明确写 null deref 的,接下来重点找"哪个指针是 NULL、为什么会是 NULL"。


3. 第二步:读调用栈

调用栈要从两个方向读:

  • 自上而下(栈顶→) :死法。最上面几帧是直接死因。
  • 自下而上(←栈底) :来源。最下面几帧是这趟调用是从哪进来的(线程入口 / binder 入口 / 信号回调),中间的帧往往就是业务路径。

本例完整调用栈(28 帧,全部保留,这是本文最重要的证据之一):

backtrace:
      #00 pc 0000000000062910  /apex/com.android.runtime/lib64/bionic/libc.so (__strlen_aarch64+16) (BuildId: a017f07...)
      #01 pc 0000000000080b48  /system/lib64/libinput.so (android::operator<<(std::__1::basic_ostream<...>&, android::KeyEvent const&)+184) (BuildId: d9e6b75...)
      #02 pc 0000000000080a10  /system/lib64/libinput.so (android::operator<<(std::__1::basic_ostream<...>&, android::InputEvent const&)+80) (BuildId: d9e6b75...)
      #03 pc 00000000000ca084  /system/lib64/libinputflinger.so (android::inputdispatcher::InputDispatcher::injectInputEvent(...)+3924) (BuildId: 5a2cd97...)
      #04 pc 000000000008820c  /system/lib64/libandroid_servers.so (android::nativeInjectInputEvent(_JNIEnv*, _jobject*, _jobject*, unsigned char, int, int, int, int)+268)
      #05 pc 0000000000227430  /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+144)
      #06 pc 000000000020bf04  /apex/com.android.art/lib64/libart.so (nterp_helper+11140)
      #07 pc 00000000001648b0  /system/framework/services.jar (com.android.server.input.InputManagerService.injectInputEventToTarget+132)
      #08 pc 000000000020a2d4  /apex/com.android.art/lib64/libart.so (nterp_helper+3924)
      #09 pc 00000000001af33e  /system/framework/framework.jar (android.hardware.input.IInputManager$Stub.onTransact+2522)
      #10 pc 00000000006225d8  /system/framework/arm64/boot-framework.oat (android.os.Binder.execTransactInternal+872)
      #11 pc 00000000006220b8  /system/framework/arm64/boot-framework.oat (android.os.Binder.execTransact+408)
      #12 pc 00000000002109a4  /apex/com.android.art/lib64/libart.so (art_quick_invoke_stub+612)
      ...(以下全是 binder 线程池的标准帧,略)
      #22 pc .../libbinder.so (android::IPCThreadState::joinThreadPool(bool)+112)
      #26 pc .../apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+204)
      #27 pc .../apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64)

我把 28 帧分成三组读:

A 组(#00~#02):死法——"打日志打死"

#00 __strlen_aarch64+16          ← libc 的 strlen,死了
#01 operator<<(ostream&, KeyEvent const&)+184   ← libinput:把 KeyEvent 序列化进 ostream
#02 operator<<(ostream&, InputEvent const&)+80  ← libinput:按事件类型分发

strlen 出现在调用栈顶,且 fault addr 是 0——经典的对 NULL 字符串指针做 strlen。谁传了 NULL 给它?上一帧是"把 KeyEvent 写进输出流"。写过 C++ 的人会立刻反应过来:ostream << (const char*)nullptr 的实现就是 strlen(ptr)。所以初步假设是:序列化 KeyEvent 的过程中,把一个 NULL 的 const char* 塞给了 << 。

B 组(#03~#04):业务路径——"注入输入事件"

#03 InputDispatcher::injectInputEvent(...)+3924   ← inputflinger:注入一个输入事件
#04 nativeInjectInputEvent(...)                    ← JNI 桥

injectInputEvent 是输入事件的注入入口(自动化测试、无障碍、模拟按键触摸都走这里)。偏移 +3924 非常大,说明这一帧内联了大量代码(包括下面会看到的日志语句和 operator<<)。

C 组(#05~#11):来源——"一次跨进程 binder 调用"

#07 services.jar: InputManagerService.injectInputEventToTarget   ← Java 服务端方法
#09 framework.jar: IInputManager$Stub.onTransact                 ← binder 服务端入口

到这里来源清楚了:某个外部进程通过 binder 调用了 IInputManager 的一个方法,system_server 在服务端处理时崩了。

方法论:

  • 栈顶三帧定性死法;strlen/memcpy/strcmp + fault addr 0 → 找 NULL 从哪来。
  • 栈底找入口:binder 帧(execTransact → xxx$Stub.onTransact)说明是 IPC 触发,要去找调用方进程。
  • 每帧的 +偏移 配上带符号的 so 可以用 llvm-symbolizer / addr2line 还原到具体行号;本例直接用 AOSP 对应版本源码 + 函数名即可定位(见第四节)。

4. 第三步:看内存快照

寄存器 dump 和 memory near xx 最容易被忽视,但常常是破案点。读法:左边两列是 hex,右边一列是同样字节按 ASCII 解读——把这一列当字符串看。

本例崩溃时 x0 = 0(strlen 的第一个参数,就是那个 NULL),而真正给出"案发字符串"的是 x5 附近的堆内存(scudo 分配器区域,也就是 ostream 正在拼接的缓冲区):

memory near x5 ([anon:scudo:primary]):
    0000f6e35d5086f0 4579654b3d746e65 61207b20746e6576  ent=KeyEvent { a
    0000f6e35d508700 50553d6e6f697463 646f6379656b202c  ction=UP, keycod
    0000f6e35d508710 2834343535363d65 0000000000000000  e=65544(
    0000f6e35d508720 0000000000000000 0000000000000000  ................

读出来的东西非常有戏剧性:

  • 缓冲区里是一句拼到一半的日志:...ent=KeyEvent { action=UP, keycode=65544(;
  • 句子在 keycode=65544( 的左括号后面戛然而止——正在往括号里填东西的时候死了;
  • 结合 A 组调用栈(operator<<(KeyEvent) + strlen(NULL)),还原出的现场就是:代码先写入了 "keycode=65544(",接着要输出某个 const char*(括号里的内容),而这个指针是 NULL,ostream << nullptr → strlen(NULL) → 死。

另一个佐证在寄存器里:x8 = 0x0000000000010008 = 65544(0x10008) ,正是正被处理的键值。

这里还能再推一步:65544 是个非法键值。AOSP 的 keycode 上限约 300(KeyEvent 键值表大小),65544 远超范围。而括号里要填的多半是这个键值的可读名字(label)——查不到名字,返回 NULL,一切就都对上了。这个猜测下一节用源码验证。

方法论:memory near 的 ASCII 列要优先扫一遍,找"半截字符串"——字符串拼到哪里断,哪里就是案发现场。寄存器也别跳过:本例 x0=0(strlen 参数)、x8=键值,一个给死因一个给物证。


5. 第四步:用源码验证猜测

到这一步为止都是推理,必须用源码坐实。定位源码的路径:so 路径 → 所属 AOSP 模块 → 与设备对应的大版本 tag。本例:

  • /system/lib64/libinput.so → frameworks/native/libs/input/
  • /system/lib64/libinputflinger.so → frameworks/native/services/inputflinger/
  • 设备是 Android 14 → 对照 android-14.0.0_r1 源码

(厂商定制版本的源码可能有小改动,但可以先对照原版验证机制;帧内偏移对不上时再找厂商要对应符号/源码。本例 operator<<(KeyEvent)+184、injectInputEvent+3924 的偏移与原版完全吻合,说明这几处代码未被厂商改动。)

源码 ①:注入入口的日志——案发现场

frameworks/native/services/inputflinger/dispatcher/InputDispatcher.cpp(android-14.0.0_r1,4513~4517 行):

InputEventInjectionResult InputDispatcher::injectInputEvent(
        const InputEvent* event, std::optional<int32_t> targetUid,
        InputEventInjectionSync syncMode, std::chrono::milliseconds timeout,
        uint32_t policyFlags) {
    Result<void> eventValidation = validateInputEvent(*event);
    if (!eventValidation.ok()) {
        LOG(INFO) << "Injection failed: invalid event: " << eventValidation.error();
        return InputEventInjectionResult::FAILED;
    }
​
    if (debugInboundEventDetails()) {
        LOG(DEBUG) << __func__ << ": targetUid=" << toString(targetUid)
                   << ", syncMode=" << ftl::enum_string(syncMode) << ", timeout=" << timeout.count()
                   << ", ms policyFlags=0x" << std::hex << policyFlags << std::dec
                   << ", event=" << *event;          // ← ★ 崩溃点:把事件对象流式输出
    }
    ...

injectInputEvent() 一进来就有一条 DEBUG 日志,把整个事件对象 *event 塞进流里。本项目的 userdebug 版本上这个开关是打开的(logcat 里有大量同源 D InputDispatcher: injectInputEvent: ... 日志为证)。这条日志就是案发现场。

源码 ②:事件合法性校验——为什么非法键值能进来

同一个文件,227 行起:

Result<void> validateInputEvent(const InputEvent& event) {
    switch (event.getType()) {
        case InputEventType::KEY: {
            const KeyEvent& key = static_cast<const KeyEvent&>(event);
            const int32_t action = key.getAction();
            return validateKeyEvent(action);        // ← ★ 只校验了 action
        }
        case InputEventType::MOTION: {
            ...
            return validateMotionEvent(action, actionButton, pointerCount, pointerProperties);
        }
        default: {
            return {};
        }
    }
}

对 KeyEvent 只校验 action,完全不校验 keyCode。所以一个 keyCode=65544 的 KeyEvent 畅通无阻地走到了日志打印。

源码 ③:序列化——NULL 的诞生地

frameworks/native/libs/input/Input.cpp(android-14.0.0_r1):

KeyEvent 的序列化(347 行起,对照内存里那半截字符串,逐字吻合):

std::ostream& operator<<(std::ostream& out, const KeyEvent& event) {
    out << "KeyEvent { action=" << KeyEvent::actionToString(event.getAction());
​
    out << ", keycode=" << event.getKeyCode() << "(" << KeyEvent::getLabel(event.getKeyCode())
        << ")";                       // ← ★ "keycode=65544(" 之后取 label,getLabel 返回 nullptr
    ...
}

按类型分发(266 行起,对应栈帧 #02):

std::ostream& operator<<(std::ostream& out, const InputEvent& event) {
    switch (event.getType()) {
        case InputEventType::KEY: {
            const KeyEvent& keyEvent = static_cast<const KeyEvent&>(event);
            out << keyEvent;
            return out;
        }
        ...

label 的来源(299 行起):

const char* KeyEvent::getLabel(int32_t keyCode) {
    return InputEventLookup::getLabelByKeyCode(keyCode);
}

最后一环,frameworks/native/libs/input/InputEventLabels.cpp(467 行起)——NULL 就诞生在这里:

const char* InputEventLookup::getLabelByKeyCode(int32_t keyCode) {
    if (keyCode >= 0 && static_cast<size_t>(keyCode) < KEYCODES.size()) {
        return lookupLabelByValue(KEY_NAMES, keyCode);
    }
    return nullptr;    // ← ★ keyCode 超出原生键值表范围(约 0~283),直接返回 nullptr
}

串起来

注入 KeyEvent(keyCode=65544)
  → validateInputEvent:只查 action,放行
  → LOG(DEBUG) << ... << ", event=" << *event        [InputDispatcher.cpp:4514]
  → operator<<(ostream&, const InputEvent&)           [Input.cpp:266]
  → operator<<(ostream&, const KeyEvent&)             [Input.cpp:347]
      out << "keycode=" << 65544 << "("
      << KeyEvent::getLabel(65544)                    [Input.cpp:350]
  → InputEventLookup::getLabelByKeyCode(65544)        [InputEventLabels.cpp:467]
      65544 >= KEYCODES.size() → return nullptr
  → ostream << (const char*)nullptr
  → std::char_traits<char>::length(nullptr) == strlen(nullptr)
  → __strlen_aarch64 读取地址 0 → SIGSEGV(fault addr 0x0)

与 tombstone 的每一帧、内存里的半截字符串、x0=0、x8=0x10008 全部对上。机制层面结案:给系统注入一个携带非法键值的 KeyEvent,system_server 必死。

顺带两个补充结论:

  • 这段代码在 android-15.0.0_r1 的 libinput 里原样存在,上游同样存在此缺陷(后文修复建议里给规避方法)。
  • LOG(DEBUG) 这类调试日志在量产版若被关闭,同样的注入不会经这条路径崩溃——但关日志是掩盖,不是修复。

方法论:函数名级别的猜测必须回到源码逐行验证;注意"校验放行了什么"和"炸在哪"同样重要(本例 validateInputEvent 的缺环就是根因的一半)。找源码时先对 AOSP 原版,偏移/行为对不上再找厂商定制差异。


6. 第五步:从 logcat 找触发方

到上一节为止,"怎么死的"已经清楚,但"谁干的"还没有答案——tombstone 的调用栈只到 IInputManager$Stub.onTransact 为止,binder 的对端进程不在栈里。这时候轮到 logcat 出场。

6.1 先用 tombstone 自带的 log 段

tombstone 末尾内嵌了一段 logcat(很多人不知道),先扫它。本例在内嵌日志里看到:崩溃前后 system_server 多个 binder 线程在持续接收触摸事件(MotionEvent)注入,坐标成串、间隔规律——典型的自动化测试特征;前台应用是某聚合媒体 HMI(com.xx.hmi.media)。同时注意到一个蹊跷点:日志里有大量 MotionEvent 注入成功,却找不到任何一次 KeyEvent 注入的日志——因为唯一的那次 KeyEvent 注入,在拼日志字符串时就死了(正是 tombstone 记录的那次)。

6.2 时间对齐,精确到毫秒

拿外部完整 logcat(本例两段,覆盖 19:27:08–19:33:07),先找 Fatal signal 行——那才是真实崩溃时刻:

10-08 19:28:40.631 15590 15907 F libc: Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR),
                               fault addr 0x0 in tid 15907 (binder:15590_7), pid 15590 (system_server)

(pid 15590、tid 15907 与 tombstone 完全一致;tombstone 头部的 19:28:40.970 是 debuggerd 落盘时间。)

然后看崩溃前几毫秒各进程在干什么。答案立刻浮出水面:

10-08 19:28:40.628 15956 15994 I CAR.INPUT: hal event:0x21402146, btnState:3
10-08 19:28:40.629 15956 15994 D CAR.INPUT: Key event listener not found for event 65544
10-08 19:28:40.631 15590 15907 F libc: Fatal signal 11 (SIGSEGV), ... pid 15590 (system_server)

崩溃前 3 毫秒,车机 CarService(com.android.car)的输入服务(tag CAR.INPUT):

  1. 收到一条来自车辆 HAL 的方向盘拨杆按键事件(自定义属性 0x21402146,原始键值 65544,btnState 3 = UP);
  2. 打印"Key event listener not found for event 65544"——当前没有应用注册这个键的监听;
  3. system_server 应声崩溃。

键值(65544)、动作(UP)、时序(毫秒级)与 tombstone 的证据链完全咬合:CarService 在"无人监听"时走了某个兜底分支,把携带原始自定义键值的 KeyEvent 原样注入了输入系统——注入用的正是调用栈里那个厂商定制接口 injectInputEventToTarget。

6.3 用统计学钉死因果:1:1 对应

单次巧合不足为凭。对 6 分钟日志做去重统计:

#"listener not found" 事件键值system_server Fatal signal间隔
119:27:10.7456554519:27:10.748(pid 896)3ms
219:28:40.6296554419:28:40.631(pid 15590,即 tombstone_04)2ms
319:28:59.4896554519:28:59.52637ms
419:30:00.6666554419:30:00.6693ms
519:30:30.6946554519:30:30.6984ms
619:30:49.6846554519:30:49.70420ms
719:31:30.7076554419:31:30.7103ms
819:31:50.6346554419:31:50.6384ms

8 次触发、8 次崩溃,毫秒级间隔,无一遗漏、无一多余——Key event listener not found for 65544/65545 出现几次,system_server 就死几次。反过来,19:31:50 之后台架不再模拟拨杆键(VHAL 上再无 0x2140214x 事件),system_server 也就再没崩过。因果闭环。

方法论:

  • 触发方在 binder 对端时,用 logcat 的 Fatal signal 行做毫秒级时间对齐,看崩溃前最后一条业务日志是谁打的——那条日志的进程十有八九就是调用方。
  • 单次对齐可能撞上巧合,用去重统计 + 1:1 对应 + 停止条件(刺激停止则崩溃停止)把"相关"升级成"因果"。
  • 别忘了 tombstone 内嵌的 log 段,有时候只有它覆盖了崩溃前那一瞬。

7. 第六步:排除干扰项、建立对照组

崩溃现场往往不止一个"嫌疑人"。本例 logcat 里同时跑着一个自动化压力测试(经 adbd 由台架发起):

adbd: shell: monkey -p com.xx.radio --ignore-crashes --ignore-timeouts
      --pct-touch 70 --pct-motion 25 --pct-appswitch 5 -s 50 --throttle 800
      --pct-syskeys 0 --pct-nav 0 --pct-majornav 0 --pct-anyevent 0 ... 20000000

monkey 是不是凶手?读参数就能排除:事件配比 touch 70% + motion 25% + appswitch 5% = 100%,其余所有类型(syskeys/nav/majornav/anyevent/trackball…)全是 0——这个 monkey 只注入触摸和滑动,从不注入按键。所以满屏的 MotionEvent 注入日志全是 monkey 的(它们也都成功了),而携带 65544 的 KeyEvent 与 monkey 无关。两个"嫌疑人"同场,但手法(事件类型)完全不同。

对照组同样有价值。方向盘拨杆还有一个 OK 键(键值 65546,VHAL 0x21402148) ,它同样没有应用监听,却从不崩溃。为什么?

10-08 19:27:30.667 15956 15994 I CAR.INPUT: hal event:0x21402148, btnState:3
10-08 19:27:30.667 15956 15994 D CAR.INPUT: onPriorityKeyEvent: no client registered for keyCode: 65546
10-08 19:27:30.668 15956 15956 I CAR.INPUT: dispatchKeyEvent: KEYCODE_STICK_OK with action: 1
10-08 19:27:30.732 16699 16928 I CarInputManager: onKeyEvent: keyType=5,
        keyEvent=KeyEvent { action=ACTION_UP, keyCode=65546, ..., displayId=-1 }

65546 走的是另一条分支:onPriorityKeyEvent → dispatchKeyEvent,通过 CarService 自己的回调总线(CarInputManager.onKeyEvent)分发给客户端应用,全程不经过 InputDispatcher 注入。证据有三:其一,dispatchKeyEvent 出现的所有时刻附近没有任何 InputDispatcher: injectInputEvent 日志(而这个入口日志对所有注入都会打印);其二,应用收到的事件 displayId=-1(服务总线标志),而真正注入输入系统的事件都是 displayId=0;其三,全部日志里 Key event listener not found 从未出现 65546。

也就是说,厂商代码里只有左/右拨杆(65544/65545) 写有"无监听 → 原样注入原始键值"的兜底分支,OK 键的"无客户端"是打条日志继续走回调分发。65546 不崩是路径上的侥幸,不是键值上的豁免——若哪天它也被原样注入,会以一模一样的方式崩溃(原生键值表对整个 0x10000+ 段都没有 label)。

方法论:排查要多问两句——"同场的其他程序能不能排除(手法对不上)?""有没有干同样的事却没事的对照组(路径差异在哪)?"。对照组能帮你把根因从"注入自定义键值"精确到"哪个分支注入自定义键值"。


8. 根因定论

完整因果链(一图流):

台架周期性模拟方向盘拨杆键(VHAL 自定义属性 0x21402146/0x21402147,
原始自定义键值 65544/65545 = 0x10008/0x10009,厂商 Java 层有定义、原生键值表没有)
        │
        ▼
CarService(CAR.INPUT):当前无应用注册该键监听
  → 打印 "Key event listener not found for event 6554X"
  → 兜底分支:把【原始键值】KeyEvent 经厂商定制接口
    IInputManager.injectInputEventToTarget 注入输入系统          ← 缺陷①:不该原样注入非法键值
        │
        ▼
InputDispatcher::injectInputEvent()
  → validateInputEvent() 只校验 action,keyCode=65544 放行      ← AOSP 缺陷②:不校验 keyCode
  → 入口 LOG(DEBUG) << ... << ", event=" << *event              ← userdebug 调试日志开启是前提
  → operator<<(KeyEvent):<< KeyEvent::getLabel(65544)
  → InputEventLookup::getLabelByKeyCode(65544):超范围 → nullptr  ← AOSP 缺陷③:label 可为 NULL
  → ostream << (const char*)nullptr → strlen(nullptr)            ← AOSP 缺陷④:无判空
  → SIGSEGV (fault addr 0x0),system_server 整体死亡、整机软重启
        │
        ▼
重启后监听应用尚未重新注册 → 下一次拨杆键再次落进兜底分支 → 崩溃循环(8 次 / 5 分钟)

一句话根因:厂商把自定义键值只加进了 Java 键值体系,没加进 libinput 原生键值表;CarService 的兜底逻辑又把未处理的自定义键值原样注入输入系统,踩中 AOSP 序列化 KeyEvent 时不判空 label 的缺陷,一条 DEBUG 日志把 system_server 打死。


9. 修复建议

按见效速度分层:

  1. 业务层止血(CarService,改动最小) :兜底分支禁止注入原始 0x10000+ 键值——优先复用已有的键值转换表(本项目中 65544→MEDIA_PREVIOUS/NEXT、65545→FAST_FORWARD/REWIND 的转换逻辑已经存在,正常路径就是这么干的),转换无门则丢弃并打日志。

  2. 平台层根修(frameworks/native,双保险) :

    • Input.cpp 的 operator<<(KeyEvent) 对 getLabel() 判空,查不到输出 <unknown>;
    • validateInputEvent()(或定制注入接口的 Java 层)增加 keyCode 范围校验,非法键值直接拒绝并返回错误,而不是放进 native。
  3. 配置层收敛:量产版本关闭 injectInputEvent 这类逐事件 DEBUG 日志(debugInboundEventDetails() 一族)。它不是根因,但它是这条崩溃路径的必经点。

  4. 测试侧:修复验证用例可以直接复用本案例的触发条件——台架模拟方向盘拨杆键 + 确保监听应用未注册;修复前该用例 100% 复现(本例 8/8)。


10. 方法论总结

拿到一个新的 tombstone,我的固定动作顺序:

  1. 读头部:进程/线程(主线程?binder 线程?)、信号与 fault addr(0 → 空指针;小地址 → 野指针偏移;栈顶附近 → 栈溢出)、Process uptime(判断是否崩溃循环)。
  2. 读调用栈,两个方向:栈顶 3 帧定性死法(strlen/memcpy + fault 0 = 找 NULL 来源);栈底找入口(binder 帧意味着去 logcat 找对端进程)。
  3. 认出定制代码:帧里出现 AOSP 没有的方法/so/jar → 定制点即嫌疑点,找厂商要源码与符号。
  4. 扫 memory near 的 ASCII 列:找半截字符串/结构体残留,"拼到哪里断,哪里就是现场";寄存器逐个对照语义(本例 x0=参数、x8=键值)。
  5. 源码逐行验证:so → AOSP 模块 → 对应版本 tag;既看炸点,也看校验为什么没拦住。
  6. 时间对齐找触发方:以 logcat 的 Fatal signal 行为锚(tombstone Timestamp 是落盘时间),看崩溃前毫秒级最后一条业务日志是谁打的。
  7. 统计学闭环:N 次触发 vs N 次崩溃 1:1?有无对照组(同样的输入走了不同分支就没事)?刺激停止崩溃是否停止?
  8. 修复分层:业务层止血 + 平台层根修 + 配置层收敛,并把触发条件固化成复测用例。

工具提示:帧偏移的精确行号用 llvm-symbolizer --obj=libinput.so 0x80b48(配合带符号 so;设备侧可 adb shell addr2line);unzip bugreport.zip 里除了 tombstone 还有 FS/data/misc 等现场;logcat 去重统计用 grep ... | sort -u 很顺手。


11. 附录

本例涉及的源码位置(android-14.0.0_r1):

文件关键位置角色
frameworks/native/services/inputflinger/dispatcher/InputDispatcher.cpp4513–4517注入入口日志(案发现场)
同上227–246validateInputEvent(只校验 action)
frameworks/native/libs/input/Input.cpp266 / 299 / 347operator<<(InputEvent) / getLabel / operator<<(KeyEvent)(炸点)
frameworks/native/libs/input/InputEventLabels.cpp467–472getLabelByKeyCode(nullptr 诞生地)
frameworks/native/libs/input/InputEventLabels.cppKEYCODES 表原生键值表(无 0x10000+ 段)