制作一个异常的 so 包,并利用 ndk-stack 分析 native 层的 Crash 日志

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

新建 jni 源码目录

项目右键 app/src/main 目录(java 同级目录),依次选择 New -> Folder -> JNI Folder 选项,AS 工具会自动生成一个名为 jni 的目录,用于存放 C++ 源代码等。

接下来我们将本文中用到的命令行操作做成 AS 快捷键的方式,提升开发效率。

新建 javah 快捷键

JDK 提供的 javah 工具能够根据 java 代码生成对应的 C++ 头文件,我们把这个过程做成 AS 开发工具中的 External Tools 快捷键。

打开 AS 设置窗口,找到 Toos -> External Tools 选项,点击 + 选项添加名为 javah-jni 的快捷操作:

制作一个异常的 so 包,并利用 ndk-stack 分析 native 层的 Crash 日志_第1张图片

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 快捷键

还是类似上一步的操作,ndk-bundle 名字可以随意取值,重点还是配置命令行工具的路径参数:

制作一个异常的 so 包,并利用 ndk-stack 分析 native 层的 Crash 日志_第2张图片

Program: 配置第一步下载的 NDK 安装包中 ndk-build 工具地址,按需修改;

/Users/ccsa/Library/Android/sdk/ndk-bundle/ndk-build

Working Directory: 工作目录

$ModuleFileDir$/src/main

这两步配置完成之后,右键项目,弹出的快捷窗口 External Tools 选项中都可以看到对应名字的快捷操作选项,非常方便。

接下来进入正式的 NDK 开发工作。

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 文件。

编译 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 工具

文章开头说了,要使用 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 上面了?阿里钉钉赢来了成年人,败给了小学生

长按识别二维码,关注我,没事和你叨叨

你可能感兴趣的:(制作一个异常的 so 包,并利用 ndk-stack 分析 native 层的 Crash 日志)