Crash 是每一个 Android 应用都会遇到的问题。通常来说,应用层的 Crash 比较好查问题,直接查看崩溃日志就行,都是直观的应用层业务源代码,哪怕混效过的源代码也能通过打包生成的对应 Mapping 文件还原回来。
相反,Native 层的 Crash 比较棘手,log 信息都是一堆 C++ 的指针地址,虽然也提供 Crash 发生时调用的依赖库 so 文件信息,却没有给到具体的 C++ 类、函数和代码行数信息,需要开发人员借助一些工具,譬如 NDK 安装包里面的 ndk-stack 工具,来反编译这些让人一时摸不着头脑的日志信息。
为了讲清楚 ndk-stack 的使用和分析方法,文章的开始需要先利用 Android Studio 开发工具建立一个简单的工程,并编写一段产生错误的 C++ 源代码,然后利用 ndk-build 工具生成对应的 so 文件进行打包测试。
接下来的内容太多,捡重点说。
准备工作做起来。
建立工程,通过 SDK Manager 下载 NDK 软件包,并配置到工程里面,比较简单:
local.properties:
ndk.dir=/Users/ccsa/Library/Android/sdk/ndk-bundle
sdk.dir=/Users/ccsa/Library/Android/sdk
项目右键 app/src/main 目录(java 同级目录),依次选择 New -> Folder -> JNI Folder 选项,AS 工具会自动生成一个名为 jni 的目录,用于存放 C++ 源代码等。
接下来我们将本文中用到的命令行操作做成 AS 快捷键的方式,提升开发效率。
JDK 提供的 javah 工具能够根据 java 代码生成对应的 C++ 头文件,我们把这个过程做成 AS 开发工具中的 External Tools 快捷键。
打开 AS 设置窗口,找到 Toos -> External Tools 选项,点击 + 选项添加名为 javah-jni 的快捷操作:
javah-jni 名字可以随意取值,主要是 Tool Settings 中这几个命令参数设置,不能有错。
Program: javah 工具的安装路径;
$JDKPath$/bin/javah
Arguments: 指定 C++ 头文件输出目录,即上一步新建的 jni 目录;以及依赖的 java 类信息,采用 $FileClass$ 配置;
-jni -encoding UTF-8 -d $ModuleFileDir$/src/main/jni $FileClass$
Working Directory: 指定命令执行时所处的工作目录,也是固定值;
$ModuleFileDir$/src/main/java
设置完成,保存即可。
备注:这里的 $JDKPath$、$ModuleFileDir$ 和 $FileClass$ 通配地址都可以在设置窗口对应选项右侧 Insert Macros 中直接选择。
还是类似上一步的操作,ndk-bundle 名字可以随意取值,重点还是配置命令行工具的路径参数:
Program: 配置第一步下载的 NDK 安装包中 ndk-build 工具地址,按需修改;
/Users/ccsa/Library/Android/sdk/ndk-bundle/ndk-build
Working Directory: 工作目录
$ModuleFileDir$/src/main
这两步配置完成之后,右键项目,弹出的快捷窗口 External Tools 选项中都可以看到对应名字的快捷操作选项,非常方便。
接下来进入正式的 NDK 开发工作。
编写测试代码,创建一个应用层 Activity 调用 native 方法的例子,比如:
public class MainActivity extends AppCompatActivity {
static {
System.loadLibrary("fengtest");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
((TextView) findViewById(R.id.textView)).setText(stringFromJNI());
}
public native String stringFromJNI();
}
这里主要包括两个部分,加载名为 “libfengtest” 的 so 库(这是后面步骤我们打包到 apk 安装包中的自定义库文件),以及一个 native 的测试函数。
注意:System.loadLibrary 加载 so 文件时,会自动添加 lib 前缀和 .so 后缀,所以这里加载 libfengtest.so 文件,参数名只需要 “fengtest” 部分即可。
接下来利用 javah 工具生成对应 MainActivity.java 类和其中包含的 native 方法的 C++ 头文件。
右键 MainActivity 文件,在弹出的 External Tools 窗口中点击前面创建的 javah-jni 快捷命令,工具会自动在 jni 包下生成名以 MainActivity 类的包名和类名命名的头文件,内容如下:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include
/* Header for class com_feng_sample_MainActivity */
#ifndef _Included_com_feng_sample_MainActivity
#define _Included_com_feng_sample_MainActivity
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: com_feng_sample_MainActivity
* Method: stringFromJNI
* Signature: ()Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_com_feng_sample_MainActivity_stringFromJNI
(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif
这个自动生成的头文件无需任何修改,接下来手动编写 cpp 源码部分,创建一个名为 FengTest.cpp 的 C 语言源代码文件:
#include "com_feng_sample_MainActivity.h"
JNIEXPORT jstring JNICALL Java_com_feng_sample_MainActivity_stringFromJNI
(JNIEnv *env, jobject) {
return env->NewStringUTF("Content From C");
}
主要实现给应用层 java 类中调用的 native 方法,注意方法名字满足特定格式,别写错了。
然后是创建 Android.mk 和 Application.mk 这两个配置文件。
Android.mk
# 当前路径
LOCAL_PATH := $(call my-dir)
# 清除LOCAL_XXX变量
include $(CLEAR_VARS)
# 原生库名称
LOCAL_MODULE := libfengtest
# 原生代码文件
LOCAL_SRC_FILES =: FengTest.cpp
# 编译动态库
include $(BUILD_SHARED_LIBRARY)
Application.mk
# 原生库名称
APP_MODULES := libfengtest
# 指定机器指令集
APP_ABI := all
接着是在 app/build.gradle 配置文件中指定关联信息:
android {
compileSdkVersion 28
......
externalNativeBuild {
ndkBuild {
path 'src/main/jni/Android.mk'
}
}
}
到这一步,开发工作就完成了。可以直接 run 起来测试,MainActivity 界面能够成功调用 cpp 文件中的函数并拿到返回结果就可以了。
这时可以直接在 Android Studio 中打开 build/outputs/apk/debug/app-debug.apk 安装包,能够看到 lib 目录下有对应 CPU 架构的 so 文件。
上面步骤中,我们通过 gradle 编译的方式直接采取 run 操作将 so 文件打包生成的 apk 文件里。还可以通过执行前面配置的 ndk-build 快捷键来生成我们想要的 so 文件。
右键 jni 目录,选择 External Tools 中的 ndk-build 快捷方式,ndk-build 工具会帮我们编译生成我们配置文件里面指定的 CPU 架构对应 so 文件。
注意:ndk-buid 工具编译生成的 so 文件有两种:一种存放于 libs 目录下,用于打包进 apk 文件中使用的;另一种位于 obj/local 目录中,Google 称之为未剥离版共享库。
比如前面我们在 Application.mk 指定 APP_ABI 为 all,就会生成常见 arm 和 x86 的所有架构 so 文件,按需使用即可。
文章开头说了,要使用 ndk-stack 反编译 native 层的错误日志信息。我们稍微修改一下 cpp 部分代码,手动制造一个 C++ native 层的空指针异常:
#include "com_feng_sample_MainActivity.h"
JNIEXPORT jstring JNICALL Java_com_feng_sample_MainActivity_stringFromJNI
(JNIEnv *env, jobject) {
int * p = NULL;
*p = 100;
return env->NewStringUTF("Hello from C++");
}
再次运行工程时,应用就会崩溃,在 logcat 工具中可以找到对应的 crash 日志信息:
--------- beginning of crash
A/libc: Fatal signal 11 (SIGSEGV), code 1, fault addr 0x0 in tid 14147 (com.feng.sample)
A/DEBUG: *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
A/DEBUG: Build fingerprint: 'HUAWEI/MLA-UL00/HWMLA:7.0/HUAWEIMLA-UL00/C17B364:user/release-keys'
A/DEBUG: Revision: '0'
A/DEBUG: ABI: 'arm64'
A/DEBUG: pid: 14147, tid: 14147, name: com.feng.sample >>> com.feng.sample <<<
A/DEBUG: signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
A/DEBUG: x0 0000000000000000 x1 0000007fcaf975f4 x2 0000000000000000 x3 0000007f7e0a2a00
A/DEBUG: x4 0000007fcaf97a68 x5 0000007f7bf6e8b1 x6 0000000073c81ca0 x7 0000000000000000
A/DEBUG: x8 0000007f7bcd66d4 x9 0000000000000064 x10 0000000000430000 x11 0000000000000000
A/DEBUG: x12 000000000000018c x13 0000000000000188 x14 0000007f820d4c30 x15 64a565931cb3ad17
A/DEBUG: x16 0000007f804aa588 x17 0000007f7bcd664c x18 0000000012db5cb8 x19 0000007f7e0a2a00
A/DEBUG: x20 0000007f7ce806d0 x21 0000007f7e0a2a00 x22 0000007fcaf9789c x23 0000007f7bf6e8b1
A/DEBUG: x24 0000000000000004 x25 e76051a728484594 x26 0000007f7e0a2a98 x27 e76051a728484594
A/DEBUG: x28 0000000000000001 x29 0000007fcaf975d0 x30 0000007f7c06e194
A/DEBUG: sp 0000007fcaf975b0 pc 0000007f7bcd6678 pstate 0000000020000000
A/DEBUG: backtrace:
A/DEBUG: #00 pc 0000000000000678 /data/app/com.feng.sample-2/lib/arm64/libfengtest.so (Java_com_feng_sample_MainActivity_stringFromJNI+44)
A/DEBUG: #01 pc 0000000000278190 /data/app/com.feng.sample-2/oat/arm64/base.odex (offset 0x23f000)
可以看到 crash 所在的 so 库,却看不到具体的源代码行数信息。如果代码量很大的话,根据这些内存地址信息几乎找不到对应的错误源头。
这时利用 ndk-stack 工具可以很方便地帮助我们反编译这些日志,转化成更具可读性的日志信息。这里我们需要借助 ndk-build 工具生成的 so 文件帮助我们反编译 native 日志,完整命令如下:
adb logcat | /Users/ccsa/Library/Android/sdk/ndk-bundle/ndk-stack -sym /Users/ccsa/SampleApp/app/build/intermediates/ndkBuild/debug/obj/local/arm64-v8a
注意,这里用的不是 libs 目录中打包进 apk 文件里面的那个 so 库,而是编译生成的 obj 目录下对应 CPU 架构里面的 so 文件。Gradle 编译打包产生的 build 目录文件也指明了当前调试连接设备的 ABI 类型,其实还可以通过 adb 命令查看(先进入 shell,再获取):
adb shell
cat /proc/cpuinfo
执行 logcat 命令,既可以将设备本地记录的之前的 log 信息进行转换打印,也可以重新操作复现问题,实时打印新的日志。
回到正题,执行 ndk-stack 工具命令,我们就可以将只有指针地址信息的 native 日志转换成更具可读性的日志信息。还是上面的例子,我们看下转换过后的日志:
********** Crash dump: **********
Build fingerprint: 'HUAWEI/MLA-UL00/HWMLA:7.0/HUAWEIMLA-UL00/C17B364:user/release-keys'
#00 0x0000000000000678 /data/app/com.feng.sample-1/lib/arm64/libfengtest.so (Java_com_feng_sample_MainActivity_stringFromJNI+44)
Java_com_feng_sample_MainActivity_stringFromJNI
/Users/ccsa/SampleApp/app/src/main/jni/FengTest.cpp:6:8
#01 0x0000000000278190 /data/app/com.feng.sample-1/oat/arm64/base.odex (offset 0x23f000)
可以看到,Crash 所在位置具体到 so 库的哪一个类哪一个方法以及哪一行代码都历历在目,这样排查 Native 层的 Crash 问题就非常方便了。
推荐阅读
多少人栽在 yyyy-MM-dd 上面了?阿里钉钉赢来了成年人,败给了小学生
长按识别二维码,关注我,没事和你叨叨