用 REA 读 logcat 和 bugreport,排查自己 App 的崩溃
本页目录
测试同事的手机上 App 闪退了,你手里只有一句“崩了”。其实 Android 已经把经过记下来了:Java 或 Kotlin 崩溃会以 FATAL EXCEPTION 写进日志;C/C++ 原生崩溃会生成 tombstone,里面有信号、出错地址和调用栈。本文演示如何用 REA(morluto 的开源项目)把这两类信息收集起来,让 AI agent 以 Evidence 的形式读取,帮你定位到出错的那行代码。
下面所有日志和文件清单都是真实结果:来自我们为本文写的一个小测试 App,运行在 Android 14 模拟器上,用 REA 6.4.0 采集。
适合谁读: 想让 agent 帮忙分诊崩溃的 Android 开发者,以及纠结过“该看 logcat 还是 bugreport”的人。
你会学到:
- 把 REA 接到
adb并检查设备 - 用
start-adb-app和start-adb-activity复现崩溃 - 读取
crash缓冲区,看懂FATAL EXCEPTION - 读原生崩溃,以及为什么默认行数会把它截断
- 收集 bugreport,在里面找到 tombstone
- 把 Java 和原生的堆栈帧对应回源码
先确认授权。 只调试你自己构建或获准测试的 App,只在你拥有或可控的设备上操作。
start-adb-app、start-adb-activity和install-adb-package会改变设备状态。日志里可能有其他 App 的个人数据,bugreport 要当作敏感文件对待。请先阅读 REA 的 DISCLAIMER.md。
前置条件
- REA 6.4.0(
npm install --global rea-agents)。接入 Claude Code、Cursor、Codex 的方法见 REA MCP 安装指南。 - Android platform-tools 里的
adb。 REA 不会帮你安装。它使用PATH里的adb,或者REA_ADB_PATH指定的绝对路径。 - 打开了 USB 调试的设备,或者一个模拟器。 REA 不会启动模拟器。
- App 的 debug 包,有原生库的话再准备未 strip 的
.so,开了 R8 的话准备mapping.txt。
第 1 步:让 REA 找到 adb 并选定设备
export REA_ADB_PATH="$HOME/Library/Android/sdk/platform-tools/adb" # macOS 上 SDK 的默认路径
rea inspect-adb-client --json
rea list-adb-devices --json
inspect-adb-client 只执行 adb version,不会连接设备,也不会启动 adb server。我们这次的结果是 "version": "37.0.1-15733141"、"path_source": "environment"、"contacted_adb_server": false。找不到 adb 时,命令会报 The selected adb binary was not found,并提示把 REA_ADB_PATH 设成绝对路径;相对路径会被拒绝。
注意: 6.4.0 的 rea doctor 检查的是 Ghidra、IDA 这类引擎,不检查 adb。检查 adb 用的是 inspect-adb-client。
list-adb-devices 按 adb devices -l 的原样返回序列号,我们的是 emulator-5560。下面每条命令的第一个参数都用这个字符串。
第 2 步:复现崩溃
测试 App com.example.crashlab 的启动 Activity 里有个 bug:读取一个可选的 intent extra,没做空判断就调用了 .trim():
static int titleLength(String title) {
return title.trim().length(); // bug: title is null when the extra is missing
}
安装并启动:
rea install-adb-package emulator-5560 ./crashlab.apk --json
rea start-adb-app emulator-5560 com.example.crashlab --json
start-adb-app 先用 cmd package resolve-activity --brief 解析启动 Activity,再用 am start -n 启动。REA 返回了解析出的 "component": "com.example.crashlab/.MainActivity"。
为什么让 REA 启动,而不是手动点图标: 启动这件事本身会成为记录的一部分。agent 清楚地知道启动了什么、在什么时候启动,几秒后出现的日志就能和这个动作对应起来。
第 3 步:读取 crash 缓冲区
Android 有好几个日志缓冲区。main 是普通应用日志,crash 只存崩溃报告,所以先看它最快:
rea read-adb-logcat emulator-5560 --buffer crash --count 200 --json
这条命令在设备上执行 logcat -d -b crash -t 200:一次性导出最新的 200 行,不会持续读取,也从不清空缓冲区。结果是 "coverage": "complete",内容如下(中间有删节):
--------- beginning of crash
10-14 12:11:24.913 4084 4084 E AndroidRuntime: FATAL EXCEPTION: main
10-14 12:11:24.913 4084 4084 E AndroidRuntime: Process: com.example.crashlab, PID: 4084
10-14 12:11:24.913 4084 4084 E AndroidRuntime: java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.crashlab/com.example.crashlab.MainActivity}: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String java.lang.String.trim()' on a null object reference
10-14 12:11:24.913 4084 4084 E AndroidRuntime: at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3782)
...
10-14 12:11:24.913 4084 4084 E AndroidRuntime: Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String java.lang.String.trim()' on a null object reference
10-14 12:11:24.913 4084 4084 E AndroidRuntime: at com.example.crashlab.MainActivity.titleLength(MainActivity.java:24)
10-14 12:11:24.913 4084 4084 E AndroidRuntime: at com.example.crashlab.MainActivity.onCreate(MainActivity.java:20)
怎么读 FATAL EXCEPTION:
- FATAL EXCEPTION 后面的
main是线程名。 主线程在onCreate里崩溃,意味着界面根本没出来。 - 最上面的异常往往只是外壳。
RuntimeException: Unable to start activity是框架在报告你的 Activity 启动失败,真正的原因在最后一个Caused by:段。 - 找第一个属于你自己包名的帧。
MainActivity.titleLength(MainActivity.java:24)就是抛异常的那一行,onCreate(MainActivity.java:20)调用了它。 - PID 就在日志行里。 第三列的
4084就是进程 ID。
按进程 ID 过滤,以及它的局限
--pid 会加上 logcat --pid,只保留一个进程的日志。用它可以看到 App 崩溃前最后打了什么:
rea read-adb-logcat emulator-5560 --buffer main --pid 4084 --count 50 --json
最后几行是 App 自己的日志和退出过程:
10-14 12:11:24.911 4084 4084 I CrashLab: started, mode=null
10-14 12:11:24.911 4084 4084 D AndroidRuntime: Shutting down VM
10-14 12:11:24.923 4084 4084 I Process : Sending signal. PID: 4084 SIG: 9
有两点要注意。第一,PID 只在进程活着时存在。我们在启动 3 秒后调用 list-adb-processes,ps -A 的列表里已经没有 com.example.crashlab 了,所以 PID 要从崩溃日志里取,而不是从进程列表里取。第二,重新启动会分配新的 PID,过滤时要用你正在排查的那一次运行的 PID。
第 4 步:读原生崩溃
原生崩溃长得不一样。App 的 JNI 库里也埋了一个空指针 bug:
struct note { int id; const char *title; };
static int title_len(const struct note *n) {
int len = 0;
while (n->title[len]) len++; /* bug: n is NULL */
return len;
}
我们通过 intent extra 触发它,用的是 REA 的带类型 --extra 格式 type:key:value:
rea start-adb-activity emulator-5560 android.intent.action.MAIN \
--component com.example.crashlab/.MainActivity \
--extra string:mode:native --json
然后用一个较小的行数读取 crash 缓冲区:
rea read-adb-logcat emulator-5560 --buffer crash --count 80 --json
结果是 "coverage": "partial",只包含 96 帧调用栈里 #16 之后的部分。最关键的开头被截掉了。 行数受限时 REA 保留的是最新的行,而且只要返回的行数正好等于你要的数量,就会标记为 partial。原生崩溃报告很长,要多读一些:
rea read-adb-logcat emulator-5560 --buffer crash --count 1000 --json
这次返回了 146 行,"coverage": "complete"。原生报告的开头:
F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
F DEBUG : Build fingerprint: 'google/sdk_gphone64_arm64/emu64a:14/UE1A.230829.050/12077443:userdebug/dev-keys'
F DEBUG : ABI: 'arm64'
F DEBUG : Cmdline: com.example.crashlab
F DEBUG : pid: 4154, tid: 4154, name: xample.crashlab >>> com.example.crashlab <<<
F DEBUG : signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000008
F DEBUG : Cause: null pointer dereference
...
F DEBUG : backtrace:
F DEBUG : #00 pc 0000000000004668 /data/app/~~Pq2iQPV_V9juLGVrCkhp8w==/com.example.crashlab-jyUevmgde3WC0F0xFRoHNg==/lib/arm64/libcrashlab.so (title_len+20)
F DEBUG : #01 pc 0000000000004644 /data/app/~~Pq2iQPV_V9juLGVrCkhp8w==/com.example.crashlab-jyUevmgde3WC0F0xFRoHNg==/lib/arm64/libcrashlab.so (Java_com_example_crashlab_MainActivity_nativeCrash+28)
(为了排版,这里去掉了时间戳和两列 PID。)
SIGSEGV、SEGV_MAPERR:代码读了一个没有映射的地址。- 出错地址是
0x8而不是0x0,这是个线索。在 arm64 上,title位于struct note偏移 8 字节处,通过空指针n读n->title就会访问地址 8。出错地址很小,基本就是通过空的结构体指针读了某个字段。 #00是崩溃的位置,在libcrashlab.so的title_len+20。pc是库内偏移,符号化时要用的就是它。
不要用 App 的 PID 过滤原生崩溃。 这些行是 Android 的崩溃处理程序 crash_dump 写的,它是一个独立进程。我们的日志里 DEBUG 行的 PID 是 4181,而崩溃的 App 是 4154。用 --pid 4154 过滤,整份报告都会被藏掉。
第 5 步:收集 bugreport,找到 tombstone
日志缓冲区是环形的,旧的崩溃会被挤掉。/data/tombstones 下的 tombstone 文件保存得更久,但普通设备上你读不了这个目录。我们试了一下:
rea list-adb-directory emulator-5560 /data/tombstones --json
REA 返回了 unsupported_target 错误,并附上设备给出的原因 ls: /data/tombstones: Permission denied。拿这些文件的正规途径是 bugreport:
rea collect-adb-bugreport emulator-5560 --output-directory "$PWD/bugreports" --json
REA 在这个目录里执行 adb bugreport,返回 zip 的路径、大小和 SHA-256。我们的是 8,002,276 字节。模拟器上几秒就完成了,真机可能要好几分钟,REA 最多等十分钟。REA 不解析 zip,它只保存设备生成的归档并计算指纹,之后 agent 可以准确引用这个文件。
看看里面有什么:
unzip -l bugreports/bugreport-*.zip | grep -E 'tombstone|anr'
我们的归档里有 FS/data/tombstones/tombstone_13 和 tombstone_13.pb,还有同一个模拟器镜像以前留下的旧 tombstone(其中有些是空文件),以及 FS/data/anr/ 下的两个文件。要挑对 tombstone。 打开最新的那个,确认 pid: 和 Cmdline: 与你正在排查的崩溃一致。tombstone_13 开头的 pid: 4154 和 fault addr 0x0000000000000008 与 logcat 里的报告完全相同。.pb 是同一个 tombstone 的 protobuf 版本。zip 顶层那个很大的 bugreport-*.txt 是完整的 dumpstate 文本,里面也包含各个日志缓冲区。
logcat 还是 bugreport?
read-adb-logcat |
collect-adb-bugreport |
|
|---|---|---|
| 速度 | 几秒 | 几秒到几分钟 |
| 内容 | 一个缓冲区的最新 N 行 | dumpstate、dumpsys、日志、tombstone、ANR trace |
| 较早的崩溃 | 缓冲区写满后丢失 | tombstone 留在设备上 |
| 大小 | KB 级 | MB 级 |
先看 crash 缓冲区。崩溃是原生的、发生在一段时间之前,或者需要整台设备的状态时,再收集 bugreport。
第 6 步:把堆栈帧对应回源码
原生帧。 把 tombstone 和未 strip 的库交给 NDK 的 ndk-stack:
unzip -o bugreports/bugreport-*.zip 'FS/data/tombstones/tombstone_13' -d tomb
$ANDROID_NDK_HOME/ndk-stack -sym /path/to/unstripped/libs/arm64-v8a \
-i tomb/FS/data/tombstones/tombstone_13
-sym 指向崩溃 ABI 对应的未 strip .so 所在目录。Gradle 项目里它们在 app/build/intermediates/ 下面;APK 里的那份通常已经 strip 过了。对我们的测试包,ndk-stack 把 #00 解析为 title_len,位于 crashlab.c:8:15,也就是那个 while 循环;#01 解析为 crashlab.c:16:5。只查一个地址的话,llvm-addr2line -f -C -e libcrashlab.so 0x4668 也给出了相同的结果。
Java 帧。 如果 release 包经过 R8 混淆,堆栈会是 a.b.c(Unknown Source) 这种样子。先用 Android 命令行工具里的 retrace 配合该版本的 mapping.txt 还原,再去读。然后在反编译器里打开对应的类,确认字节码到底做了什么。JADX MCP 配置指南 介绍了怎么让 agent 替你完成这一步。
交给 agent:
用 REA。在 emulator-5560 上启动 com.example.crashlab,然后用 1000 行读取
crash 缓冲区。告诉我异常或信号是什么、第一个属于我自己代码的帧是哪个,
并给出这次日志读取的 Evidence ID。如果结果是 partial,先加大行数重读再回答。
常见问题和排错
read-adb-logcat 读 crash 缓冲区什么都没有。 可能 App 还没崩,或者缓冲区被清过(REA 从不清空它)。再执行一次 start-adb-app,然后马上读。
结果是 partial。 返回的行数正好等于 --count,更早的行可能没读到。加大 --count,上限是 10,000。
读取时报资源错误。 这次导出超过了 REA 单次 logcat 读取 4 MiB 的输出预算。REA 会直接报错,而不是返回截断的结果。减小 --count,或者改读体量小得多的 crash 缓冲区。
start-adb-app 失败。 这个包没有启动 Activity。改用 start-adb-activity 并显式指定 --component。
collect-adb-bugreport 报 “did not report an archive path”。 REA 依赖 adb bugreport 输出 Bug report copied to ... 这一行。只输出纯文本的很老的 Android 版本不受支持。
需要检查安装包本身。 原生库一加载就崩,原因可能出在打包上,比如缺少某个 ABI,或者没有 16 KB 页对齐。浏览器版 APK 分析器 可以直接从 APK 里读出原生库和 16 KB 对齐情况,文件不会上传。
FAQ
REA 能实时持续输出 logcat 吗?
不能。read-adb-logcat 每次调用只读一次有上限的导出。要实时看,在 agent 旁边开个终端跑 adb logcat。
REA 能像 *:E 那样按 tag 或级别过滤吗?
6.4.0 不能,只能按缓冲区和 PID 过滤。看崩溃就读 crash 缓冲区,或者读 main,让 agent 自己筛选返回的行。
REA 会解析 tombstone 或 ANR trace 吗?
不会。它负责收集 bugreport 并计算指纹。读 tombstone 文本、用 ndk-stack 符号化,由你或 agent 来做。
下一步
- 拉取并检查你在调试的 APK:用 REA 分析 Android APK。
- 看看包里到底有什么,包括原生库:AAB 转 APK 与 APK 解包。
- 反编译堆栈里出现的类:JADX MCP 配置指南。
- 阅读 REA 的 ADB 设备分析参考文档。