Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

NDK Native 调试与 Crash 定位 ☆

Native 能力是你的差异化强项, 但面试要讲成 “稳定性与诊断能力”, 不是炫底层. 本章把 JNI 引用, 线程 attach, RegisterNatives, so 加载, ABI/CMake, tombstone, addr2line, ndk-stack, RAII 和 native 泄漏串成一套线上排障语言.

符号化前提: 工具路径按本机 NDK 安装位置解析, 不绑定单一 host tag. 必须保存与线上制品相同 build-id 的未裁剪符号; PIE/ASLR 场景要使用 tombstone 已归一化的相对 PC, 或先结合模块加载基址做地址归一化.

一, JNI 引用与线程模型复盘

Native 崩溃和泄漏里很大一部分来自 JNI 生命周期误用. 引用类型要和生命周期严格匹配.

引用类型生命周期常见用途风险
Local Reference当前 native 调用或 local frame临时对象, 字符串, 数组元素循环中不释放会引用表溢出; 不能跨线程缓存.
Global ReferenceDeleteGlobalRef 前有效缓存 jclass, 跨线程回调对象忘删会泄漏 Java 对象和 Activity.
Weak Global Reference对象未被 GC 前可观察缓存 owner, 监听对象使用前要提升为 Local, 否则可能已被回收.

JNIEnv* 是线程私有的, 不能跨线程保存; JavaVM* 进程唯一, 可以保存后在 native 线程里 AttachCurrentThread 获取当前线程的 JNIEnv*, 线程结束前 DetachCurrentThread.

class ScopedAttach {
public:
    explicit ScopedAttach(JavaVM* vm) : vm_(vm) {
        if (vm_ == nullptr) return;
        const jint state = vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6);
        if (state == JNI_EDETACHED) attached_ = vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK;
        else if (state != JNI_OK) env_ = nullptr;
    }
    ~ScopedAttach() {
        if (attached_) vm_->DetachCurrentThread();
    }
    bool ok() const { return env_ != nullptr; }
    JNIEnv* env() const { return env_; }
private:
    JavaVM* vm_ = nullptr;
    JNIEnv* env_ = nullptr;
    bool attached_ = false;
};

调用处先判断 scoped.ok(); false 时不得解引用 env(), 而应返回失败或放弃本次 Java 回调. wrapper 只 detach 自己成功 attach 的线程; GetEnv 的其他错误和 attach 失败均不会伪造可用环境. JNI 调用产生 pending exception 时, 若要传播给 Java 就立即返回并保留异常; 只有 native 已记录并转换 / 恢复后才 ExceptionClear.

面试高频追问是 “为什么不能保存 JNIEnv 到全局变量”:因为它绑定当前线程, 换线程使用会导致未定义行为甚至崩溃.

二, RegisterNatives, so 加载与初始化顺序

Native 方法注册分静态注册和动态注册. 工程 SDK 更常用 RegisterNatives, 在 JNI_OnLoad 中集中绑定 Java 方法和 C/C++ 函数.

环节关键点常见问题
System.loadLibrary加载 libxxx.so, 触发 JNI_OnLoad库名不匹配, ABI 缺失, 依赖 so 未找到.
JNI_OnLoad保存 JavaVM, 查找类, 注册 native 方法FindClass 失败, 签名错误, pending exception 未处理.
RegisterNatives方法名 + 签名 + 函数指针绑定混淆后未 keep, 签名写错, 返回值未检查.
初始化顺序Java 层先加载 so 再调用 native多 so 依赖顺序, 懒加载导致首次调用失败.

UnsatisfiedLinkError 常见原因包括: 没有打包对应 ABI 的 so, System.loadLibrary 名称错误, native 方法签名不匹配, R8 混淆改了 Java native 方法名, 依赖库加载失败. 定位时先看 logcat 的 linker 错误和 ABI 路径, 再查 JNI_OnLoad 返回值和注册日志.

三, ABI, CMake 与构建产物

ABI 决定 so 能在哪类 CPU 上运行. 现代 Android 主流是 arm64-v8a, 但模拟器, 老设备和第三方 SDK 可能需要其他 ABI.

cmake_minimum_required(VERSION 3.22)
project(native_demo)

add_library(native_demo SHARED native_demo.cpp)
find_library(log-lib log)
target_link_libraries(native_demo ${log-lib})
维度面试回答
ABI 选择通常优先 arm64-v8a; 是否保留 armeabi-v7a/x86 看用户设备和 SDK 依赖.
符号保留线上 so 可 strip 减体积, 但 CI 必须保存同 build-id 的未裁剪符号文件.
CMake 配置关注 add_library, target_link_libraries, 编译选项和 STL 选择.
依赖管理多 so 依赖要保证打包完整, 避免运行时 linker 找不到.
体积优化ABI 拆分, 只打必要架构, 裁剪无用符号和资源.

构建产物治理很重要: 没有符号文件, 线上 native crash 只能看到地址, 无法高效定位到源码行.

四, tombstone 与 native crash 现场

Android native crash 通常由信号触发, 例如 SIGSEGV, SIGABRT, SIGBUS. 系统会生成 tombstone 或在 logcat 中输出 crash 现场.

一份 tombstone 重点看:

  1. signal: 崩溃类型, 如 SIGSEGV 空指针 / 非法地址访问, SIGABRT 主动 abort.
  2. fault addr: 访问的非法地址, 0x0 附近通常是空指针.
  3. thread name / tid: 崩溃线程, 判断是主线程, 渲染线程还是业务线程.
  4. registers: 寄存器现场, 可辅助判断参数和访问地址.
  5. backtrace: so 名称, 偏移地址, 函数符号.
  6. memory map: 模块加载基址, 用于符号化和判断版本.
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
backtrace:
  #00 pc 0000000000012344  /data/app/.../lib/arm64/libdemo.so (foo+24)
  #01 pc 0000000000015678  /data/app/.../lib/arm64/libdemo.so (bar+80)

分析时先确认 so 版本和 build-id 是否对应, 再做符号化. 不要只凭函数名猜结论, 要结合入参, 线程, 最近业务行为和复现路径.

五, addr2line / ndk-stack 符号化流程

符号化的目标是把 tombstone 中的 pc 地址映射到函数, 源码文件和行号. 常用工具包括 addr2line, ndk-stack, Crashpad/Breakpad 符号化工具.

# 使用未 strip 的同版本 so,将 pc 偏移映射到源码行
# HOST_TAG 由当前主机决定, 例如 darwin-arm64/linux-x86_64; 不要写死
"$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin/llvm-addr2line" \
  -f -C -e obj/local/arm64-v8a/libdemo.so 0000000000012344

# 对包含 backtrace 的日志整体符号化
ndk-stack -sym obj/local/arm64-v8a -dump tombstone.txt
工具适用场景注意事项
addr2line / llvm-addr2line单个地址快速定位输入应是相对 so 的 pc 偏移, so 版本必须匹配.
ndk-stack对 tombstone/logcat 批量符号化-sym 指向未 strip 符号目录.
Breakpad/Crashpad线上 minidump 平台化处理需要符号服务器和 build-id 管理.
IDE Debugger本地复现调试适合断点, 变量观察, 不替代线上符号化.

如果符号化结果对不上, 优先检查: 线上包和符号文件是否同一版本, ABI 是否一致, 地址是否需要减去加载基址, so 是否被重新打包或裁剪.

从崩溃平台到源码行的可操作闭环

下面 tombstone 是结构示例, 地址, 进程和函数名均为虚构, 仅用于标注字段含义:

Build fingerprint: 'vendor/device:14/BUILD/123:user/release-keys'
BuildId: 'f00dbabe1234567890abcdef1234567890abcdef'
ABI: 'arm64'
pid: 1234, tid: 1260, name: worker-1  >>> com.example.demo <<<
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000000
    x0  0000000000000000  x1  0000007a10000000  ... registers omitted ...
backtrace:
      #00 pc 0000000000012344  /data/app/.../lib/arm64/libdemo.so (demo::use(Node*)+20)
      #01 pc 0000000000015678  /data/app/.../lib/arm64/libdemo.so (demo::run()+48)
      #02 pc 000000000000abcd  /apex/.../lib64/libc.so (__pthread_start(void*)+208)
memory map:
      0000007a20000000-0000007a20040000 r-xp ... /data/app/.../lib/arm64/libdemo.so

这个现场先说明 worker-1 因访问接近零地址而触发 SEGV_MAPERR; #00 的 pc 是待映射的 libdemo.so 偏移. 它是空指针假设的证据之一, 不是根因结论, 还需检查 Node* 的所有权, 竞态和调用路径.

  1. 归档: CI 为每个 variant/ABI 归档 APK/AAB, mapping, 未裁剪 .so 或独立 debug symbol, 构建提交, 版本号和 ELF build-id. 符号目录按 build-id 检索, 不按 “最新 release” 猜测.
  2. 接收: 崩溃平台保存原始 tombstone/minidump, app version, ABI, build-id, 崩溃线程和脱敏 breadcrumbs; 平台先用 build-id 自动匹配符号, 匹配失败标记为不可符号化, 不使用相近版本代替.
  3. 符号化: tombstone 标注的 pc 通常已是 module-relative PC, 可直接交给匹配 so 的 llvm-addr2line. 绝对 PC 不能机械减 maps 起点: 结合 maps 的虚拟地址, file offset 和 ELF PT_LOAD 的 p_vaddr/p_offset 计算 load bias, 再归一化为模块地址.
  4. 定位和验证: 以源码行, 反混淆后的调用链, 寄存器/参数线索和复现路径形成假设; 修复后在相同 ABI/variant 复现或做目标测试, 并检查新版本是否携带可匹配的符号. 没有复现证据时记录为假设, 不虚构 “已验证”.
# 命令模板:变量必须替换为本机构建产物;PC 来自同一份 tombstone.
"$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin/llvm-readelf" -n "$SYMBOL_SO"
"$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin/llvm-addr2line" -f -C \
  -e "$SYMBOL_SO" "$RELATIVE_PC"

上面的两条命令应分别执行; 第一条输出中的 Build ID 是匹配依据, 第二条才输出函数和源码行. ndk-stack 与 addr2line 结果若不一致, 优先保留原始 tombstone 并核对 build-id, ABI, 地址种类和符号是否被再次 strip.

学完能做什么与练习

你应能从原始现场走到可追溯源码假设. 练习: 为虚构 BuildId 建立符号索引, 再输入 module-relative PC 与含 maps file offset 的绝对 PC; 预期证据是只匹配同 BuildId/ABI 符号, 绝对地址按 ELF load bias 归一化, 符号不匹配时拒绝猜测源码行.

六, RAII 与 native 资源安全

Native 代码没有 Java/Kotlin GC 帮你管理所有资源. RAII (Resource Acquisition Is Initialization) 的思想是 “资源在构造时获取, 析构时释放”, 用对象生命周期减少异常路径和提前 return 的泄漏.

资源推荐管理方式易错点
malloc/new 内存std::unique_ptr, std::vector, 智能指针裸指针多分支释放容易漏.
文件描述符自定义 RAII fd wrapper忘记 close 导致 fd 泄漏.
JNI GlobalRefunique_ptr + 自定义 deleter析构时要拿当前线程 JNIEnv.
mutexstd::lock_guard / std::unique_lock手动 lock/unlock 容易死锁.
native handle封装 owner class所有权不清导致 double free.

RAII 还能提升面试表达: 你不仅会写 native, 还知道如何让 native 在异常, 崩溃和复杂生命周期下更稳定.

七, native 泄漏与调试方法

Native 泄漏包括内存泄漏, fd 泄漏, 线程泄漏, JNI 全局引用泄漏和图形 / 音视频资源泄漏. 它们不一定出现在 Java heap 中, 所以只看 LeakCanary 不够.

排查思路:

  1. 确认现象: RSS/PSS 持续增长, fd 数增长, 线程数增长, 崩溃前内存压力.
  2. 区分 Java/native: Android Studio Profiler, dumpsys meminfo, heapprofd, Perfetto.
  3. 定位分配点: debug 包可用 malloc 调试, ASan/HWASan, heapprofd 采样.
  4. 检查 JNI 引用: 全局引用是否释放, 循环局部引用是否 DeleteLocalRef.
  5. 检查所有权: 对象是否 double free, use-after-free, 跨线程释放.
  6. 修复后回归: 压测相同路径, 观察内存/fd/线程曲线回落.

线上治理要保存符号, 增加 native 关键路径日志, 对高风险模块做灰度, 并用崩溃率和内存指标观察修复效果.


高频面试题

Q1: JNIEnv 和 JavaVM 有什么区别? JavaVM 进程唯一, 可以跨线程保存; JNIEnv 是线程私有的, 只能在当前线程使用. native 自建线程要调用 Java, 必须先 AttachCurrentThread 获取 JNIEnv, 退出前 DetachCurrentThread.

Q2: RegisterNatives 有什么好处? 它在 JNI_OnLoad 中集中注册 Java native 方法与 C/C++ 函数指针, 避免静态注册的长函数名和包名暴露, 重构更可控. 工程上要检查 FindClass, 签名和 RegisterNatives 返回值, 并配置混淆 keep.

Q3: 拿到 tombstone 后怎么定位 native crash? 先看 signal, fault addr, 崩溃线程和 backtrace; 确认 so 版本, ABI 和 build-id 匹配; 再用 addr2line 或 ndk-stack 将 pc 地址符号化到函数和源码行, 最后结合入参, 业务路径和最近日志分析根因.

Q4: 为什么线上一定要保存未 strip 的 so? 线上包为了体积会 strip 符号, crash 里常只有 so 偏移地址. 没有同版本未 strip 符号文件, addr2line / ndk-stack 无法准确映射源码行, 定位效率会大幅下降.

Q5: RAII 在 NDK 中解决什么问题? RAII 把资源释放绑定到对象析构, 可以避免 return 分支, 异常路径或错误处理遗漏释放. 它适合管理 native 内存, fd, mutex, JNI GlobalRef 等资源, 能显著降低泄漏和 double free 风险.

易错点 / 追问

  • 不要跨线程缓存和使用 JNIEnv*, 跨线程只保存 JavaVM*.
  • 不要忘记删除 GlobalRef, 也不要把 LocalRef 存到全局变量里.
  • 不要只看 tombstone 函数名就下结论, 必须确认符号文件, ABI, build-id 和业务上下文匹配.
  • 追问 “addr2line 结果不对怎么办”:检查 so 版本是否一致, 地址是否为相对偏移, ABI 是否匹配, 符号是否被裁剪.
  • 追问 “native 泄漏怎么发现”:看 PSS/RSS, fd, 线程, heapprofd/Perfetto/ASan, 并结合 JNI 引用和所有权审查.