一次 system_server 崩溃循环排查实录:从 tombstone 到根因的完整方法论
目录
- 预备知识:tombstone 是什么,里面有什么
- 第一步:读头部——谁、在哪、怎么死的
- 第二步:读调用栈——上面看死法,下面看来源
- 第三步:看内存快照——ASCII 列里的"案发字符串"
- 第四步:用源码验证猜测
- 第五步:从 logcat 找触发方
- 第六步:排除干扰项、建立对照组
- 根因定论与完整因果链
- 修复建议
- 方法论总结:新 tombstone 分析 Checklist
- 附录:本例涉及的源码位置与工具
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 + ASCII | ASCII 列经常直接吐出"案发字符串" |
| 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
能读出四条关键信息:
- 死者是 system_server,线程是
binder:15590_7——一个 binder 工作线程,说明崩溃发生在处理一次跨进程调用的过程中,而且这次调用来自外部进程(tombstone 里看不到对端,后面要用 logcat 找)。 signal 11 (SIGSEGV), SEGV_MAPERR, fault addr 0x0,Cause 直接写明 null pointer dereference——空指针,且是最"纯粹"的那种:对地址 0 解引用。Process uptime: 87s——system_server 死时只活了 87 秒。结合"反复重启"的现象,这暗示崩溃循环:上次崩溃 → 重启 → 87 秒后又崩。这个字段在排查"偶发还是循环"时非常有用。- 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):
- 收到一条来自车辆 HAL 的方向盘拨杆按键事件(自定义属性
0x21402146,原始键值 65544,btnState 3 = UP); - 打印"Key event listener not found for event 65544"——当前没有应用注册这个键的监听;
- system_server 应声崩溃。
键值(65544)、动作(UP)、时序(毫秒级)与 tombstone 的证据链完全咬合:CarService 在"无人监听"时走了某个兜底分支,把携带原始自定义键值的 KeyEvent 原样注入了输入系统——注入用的正是调用栈里那个厂商定制接口 injectInputEventToTarget。
6.3 用统计学钉死因果:1:1 对应
单次巧合不足为凭。对 6 分钟日志做去重统计:
| # | "listener not found" 事件 | 键值 | system_server Fatal signal | 间隔 |
|---|---|---|---|---|
| 1 | 19:27:10.745 | 65545 | 19:27:10.748(pid 896) | 3ms |
| 2 | 19:28:40.629 | 65544 | 19:28:40.631(pid 15590,即 tombstone_04) | 2ms |
| 3 | 19:28:59.489 | 65545 | 19:28:59.526 | 37ms |
| 4 | 19:30:00.666 | 65544 | 19:30:00.669 | 3ms |
| 5 | 19:30:30.694 | 65545 | 19:30:30.698 | 4ms |
| 6 | 19:30:49.684 | 65545 | 19:30:49.704 | 20ms |
| 7 | 19:31:30.707 | 65544 | 19:31:30.710 | 3ms |
| 8 | 19:31:50.634 | 65544 | 19:31:50.638 | 4ms |
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. 修复建议
按见效速度分层:
-
业务层止血(CarService,改动最小) :兜底分支禁止注入原始 0x10000+ 键值——优先复用已有的键值转换表(本项目中 65544→MEDIA_PREVIOUS/NEXT、65545→FAST_FORWARD/REWIND 的转换逻辑已经存在,正常路径就是这么干的),转换无门则丢弃并打日志。
-
平台层根修(frameworks/native,双保险) :
Input.cpp的operator<<(KeyEvent)对getLabel()判空,查不到输出<unknown>;validateInputEvent()(或定制注入接口的 Java 层)增加 keyCode 范围校验,非法键值直接拒绝并返回错误,而不是放进 native。
-
配置层收敛:量产版本关闭
injectInputEvent这类逐事件 DEBUG 日志(debugInboundEventDetails()一族)。它不是根因,但它是这条崩溃路径的必经点。 -
测试侧:修复验证用例可以直接复用本案例的触发条件——台架模拟方向盘拨杆键 + 确保监听应用未注册;修复前该用例 100% 复现(本例 8/8)。
10. 方法论总结
拿到一个新的 tombstone,我的固定动作顺序:
- 读头部:进程/线程(主线程?binder 线程?)、信号与 fault addr(0 → 空指针;小地址 → 野指针偏移;栈顶附近 → 栈溢出)、
Process uptime(判断是否崩溃循环)。 - 读调用栈,两个方向:栈顶 3 帧定性死法(
strlen/memcpy+ fault 0 = 找 NULL 来源);栈底找入口(binder 帧意味着去 logcat 找对端进程)。 - 认出定制代码:帧里出现 AOSP 没有的方法/so/jar → 定制点即嫌疑点,找厂商要源码与符号。
- 扫
memory near的 ASCII 列:找半截字符串/结构体残留,"拼到哪里断,哪里就是现场";寄存器逐个对照语义(本例 x0=参数、x8=键值)。 - 源码逐行验证:so → AOSP 模块 → 对应版本 tag;既看炸点,也看校验为什么没拦住。
- 时间对齐找触发方:以 logcat 的
Fatal signal行为锚(tombstone Timestamp 是落盘时间),看崩溃前毫秒级最后一条业务日志是谁打的。 - 统计学闭环:N 次触发 vs N 次崩溃 1:1?有无对照组(同样的输入走了不同分支就没事)?刺激停止崩溃是否停止?
- 修复分层:业务层止血 + 平台层根修 + 配置层收敛,并把触发条件固化成复测用例。
工具提示:帧偏移的精确行号用 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.cpp | 4513–4517 | 注入入口日志(案发现场) |
| 同上 | 227–246 | validateInputEvent(只校验 action) |
frameworks/native/libs/input/Input.cpp | 266 / 299 / 347 | operator<<(InputEvent) / getLabel / operator<<(KeyEvent)(炸点) |
frameworks/native/libs/input/InputEventLabels.cpp | 467–472 | getLabelByKeyCode(nullptr 诞生地) |
frameworks/native/libs/input/InputEventLabels.cpp | KEYCODES 表 | 原生键值表(无 0x10000+ 段) |