Android NDK开发完全剖析

2022-05-25 14:47:26 浏览数 (1)

之前的两篇文章主要介绍了音视频SDK中的线程设计和消息队列,其实对那些想从Android转向音视频开发的同学来说,NDK方面的知识是不得不提的“前置条件”,因为音视频开发的主要是C/C 开发,也许有些同学会反驳,Android不是提供了很多音视频相关的工具吗?比如MediaCodec、MediaExtractor等等,且不说这些版本的兼容性,单单是这些工具的格式支持度如何呢?如果遇到不支持的音视频格式怎么办呢?这些工具我们应该学会怎么使用,但是它并不能支持我们深入学习音视频技术,很多跨平台和使用广泛的库都是C/C 的,所以NDK开发是音视频技术学习的“门槛”,本文的目的就是带你从0开始开始学习NDK相关的知识点。

NDK全称是Native Development Kit,是Android上实现C/C 开发的工具集,我们在Android项目中编写C 代码,然后通过交叉工具将C 代码编译成so,上层使用System.loadLibrary加载这些so,可以实现Java层和Native层的互相调用。

交叉编译

交叉编译是什么?对于没有做过嵌入式开发的人来说,也许很陌生,一些Android的开发,如果没有过多涉及JNI方面,也不太清楚什么是交叉编译,通俗来讲,交叉编译就是在一个平台上生成另外一个平台可以执行的代码。例如Windows上可执行的文件是.exe,但是.exe文件是不能在Android上面运行的,我如果想编译一个库文件,让这个库文件在Android平台上被加载,那这个编译的过程就是交叉编译。

交叉编译在音视频开发者真的这么重要吗?可以明确的说,非常重要,因为音视频的核心开发逻辑都在native层,java层只是一个接口api和简单的封装,所以jni和native交互不可避免,而且音视频中大量用到一些流行的库,例如FFmpeg、VLC、ijkplayer、gstreamer、openssl、libx264等等,这些库想要生成Android平台上可以加载的库,就需要交叉编译。关于这些库怎么交叉编译的?本文的后面会讲到。

做过jni开发的同学都知道jni代码是使用ndk工具链编译的,ndk工具中就包含交叉编译工具链,我们先看一下ndk的目录结构:

这些目录表示针对不同CPU架构的编译工具链,例如arm-linux-androideabi-4.9表示arm架构,x86-4.9表示x86架构。

代码语言:javascript复制
arm-linux-androideabi-4.9
aarch64-linux-android-4.9
x86-4.9
x86_64-4.9

这其实是针对不同的CPU架构平台,我们熟知的是arm平台,Android手机基本上都是基于arm平台的,x86主要是PC,mips架构是Microprocessor without interlocked piped stages architecture的缩写,是一种采用精简指令集的处理器架构,主要用在一些个人娱乐装置上,上面三个指令集各有优劣。

ARM全称是Advanced RISC Machine,它是一个精简的指令集,ARM处理器的特点是:

代码语言:javascript复制
1.体积小,低功耗,低成本,高性能,目前ARM也是嵌入式设备中使用最广泛的芯片架构
2.大量使用到了寄存器,指令执行速度更快,它的大多数数据操作都在寄存器中执行
3.寻址方式灵活简单,执行效率高
4.指令长度固定
5.流水线的处理方式

X86是intel主导设计的一个微处理器体系结构的指令架构,PC端主要称霸的是X86架构,与ARM不同,X86采用的是CISC架构,就是复杂指令集计算机,CISC与RISC不同,程序中指令是按照顺序串行执行的,每条指令中的操作也是顺序串行的。顺序执行优点是控制比较简单,但是利用效率较低,执行速度也不太快。

上面介绍完了不同架构的区别,现在可以看看有什么具体的交叉编译工具,可以选择arm平台进去看看:

  • arm-linux-androideabi-gcc : 编译c文件的交叉编译器,和gcc类似,不同的是arm-linux-androideabi-gcc的头文件是/urs/include/stdio.h,下面编译能看出来,我们要定义sysroot来链接到头文件。
  • arm-linux-androideabi-g : 编译cpp文件的交叉编译器
  • arm-linux-androideabi-addr2line : 反解出堆栈的工具,Android上的Native crash堆栈都是通过addr2line反解出来的
  • arm-linux-androideabi-ld : 交叉链接器,可以将编译出来的文件链接成在arm平台上运行的文件
  • arm-linux-androideabi-readelf : 查看.elf文件的工具,编译程序运行不了的原因主要看处理器的大小端跟编译的程序的大小端是否对应,可以使用这个工具来查看一下。
  • arm-linux-androideabi-objdump : 将可执行文件反汇编后输入保存到文本中,可以查看底层的汇编代码。
  • arm-linux-androideabi-ar : 可以将多个重定位的目标模块归档为一个函数库文件。

交叉编译有一个完整的过程:

从交叉编译的过程来看,其实和正常的编译没什么不一样,只不过有两点:

代码语言:javascript复制
交叉编译使用的是交叉编译工具
交叉编译链接的库或者头文件必须明确指定

例如我们使用gcc编译的过程,有一些库函数已经指定在系统的PATH路径中了,我们不必要单独指定,但是交叉编译的时候则必须要明确指定。下面通过一个例子来加深对交叉编译的理解:一个很简单的c程序

代码语言:javascript复制
#include <stdio.h>

int main(int argc, char** argv) {
  printf("Hello, jeffmonyn");
  return 0;
}

输出的结果如下:

代码语言:javascript复制
jeffli@admindeMacBook-Pro 02-files % gcc hello.c -o hello
jeffli@admindeMacBook-Pro 02-files % ./hello 
Hello, jeffmony

gcc编译出来的可执行文件只能在当前架构的平台上执行,如果我想在Android上执行这个程序就需要使用arm-linux-androideabi-gcc来编译hello.c,编译指令如下:

代码语言:javascript复制
$ANDROID_NDK/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-gcc 
--sysroot=$ANDROID_NDK/platforms/android-24/arch-arm hello.c -o hello-androi

使用的是arm平台的交叉编译工具arm-linux-androideabi-gcc, --sysroot指定的就是include库文件的位置。里面放着include和lib文件夹,分别表示Android平台下的头文件的库文件,我们编译的任何文件都可能会引用到这个文件架下面的库。这个链接是不能少的。编译出来的hello-android是可以在Android手机上运行的。

当然交叉编译也可以使用NDK提供的独立工具链,现在已经很少谈到独立工具链了,但是对于一些大型的项目,独立工具链还是有它独特的优势的,因为独立工具链真的很灵活。NDK提供了make_standalone_toolchain.py 脚本,以便您通过命令行执行自定义工具链安装。脚本位于 $ANDROID_NDK/build/tools/目录中:

代码语言:javascript复制
$ANDROID_NDK/build/tools/make_standalone_toolchain.py 
    --arch arm --api 21 --install-dir /tmp/my-android-toolchain

此命令创建一个名为 /tmp/my-android-toolchain/ 的目录,其中包含 android-21/arch-arm sysroot 的副本,以及适用于 32 位 ARM 目标的工具链二进制文件的副本。

请注意,工具链二进制文件不依赖或包含主机专属路径。换言之,您可以将其安装在任意位置,甚至可以视需要改变其位置。

为什么特别提到了独立工具链? 因为我们熟知的很多大型项目,例如ijkplayer,使用的就是独立工具链,控制非常灵活。不过说实话我不太建议大家使用独立工具链编译,主要会生成很多额外的文件,而且链接起来没有多方便,写了很多额外的代码,容易把人绕迷糊了,不过也是给大家提供了一种选择。

CMake编译

如果大家在Android5.0做过NDK编程的话,当时是使用ndk-build工具进行编译的,还需要配置Android.mk和Application.mk。和现在的CMake编译还是不太一样的。现在的CMake结构整体看上去还是比较简单一些,降低了NDK开发的门槛。

CMake 是一个开源、跨平台的工具系列,旨在构建、测试和打包软件。CMake用于使用简单的平台和编译器独立配置文件来控制软件编译过程,并生成可在您选择的编译器环境中使用的本机 makefile 和工作区。CMake 工具套件由 Kitware 创建,以响应对 ITK 和 VTK 等开源项目的强大跨平台构建环境的需求。

形象地来看,CMake其实是一套项目整理的工具,可以整合完整的编译流程,Android也引入了这套机制,非常好用。

创建一个包含native代码的工程,主要关注这两个结构:

main目录下创建了cpp和java文件夹,cpp就是写native代码的,java就是上层代码,其中cpp文件夹下面有一个CMakeLists.txt文件,这个文件就是组织cpp文件的一个工具。

下面看一个CMakeLists.txt的例子:

代码语言:javascript复制
# Sets the minimum version of CMake required to build the native
# library.
cmake_minimum_required(VERSION 3.4.1)

# Creates the project's shared lib: libnative-lib.so.
# The lib is loaded by this project's Java code in MainActivity.java:
#     System.loadLibrary("native-lib");
# The lib name in both places must match.
add_library( native-lib
             SHARED
             src/main/cpp/native-lib.cpp )

find_library(log-lib
             log )

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in the
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
                       native-lib

                       # Links the target library to the log library
                       # included in the NDK.
                       ${log-lib} )

add_library里面包括我们cpp中所有的native代码,部分头文件可以不用附上,native-lib是我们目标库的名称,编译出来的目标库是libnative-lib.so,target_link_libraries后面参数指明了编译出libnative-lib.so需要链接哪些库。大型的音视频项目可能会链接一些额外的库,接下来我们开发音视频项目的时候大家自然就会明白的。build.gradle就是将CMakeLists.txt组织到项目中的核心枢纽。

代码语言:javascript复制
// Sets up parameters for both jni build and cmake.
// For a complete list of parameters, see
// developer.android.com/ndk/guides/cmake.html#variables
externalNativeBuild {
   cmake {
       // cppFlags are configured according to your selection
       // of "Customize C   Support", in this codelab's
       //    "Create a Sample App with the C   Template",
       //    step 6
       cppFlags "-std=c  17"
   }
......

// Specifies the location of the top level CMakeLists.txt
// The path is relative to the hosting directory
// of this build.gradle file
externalNativeBuild {
   cmake {
       path "src/main/cpp/CMakeLists.txt"
       version "3.10.2"

   }
}

CMakeLists.txt文件可以放在任意位置,只要在build.gradle特殊指定就行了。同时别忘了在Java层System.loadLibrary这个库。我们编译完成后,在build目录中会生成对应的so。

JNI基本知识

JNI是我们接触音视频开发首先要了解的一部分知识,本小节会由浅入深地给大家讲解JNI相关的知识,帮忙快速了解JNI的相关核心知识。JNI是全称是Java native interface,是Android提供的Java和Native代码(C和C )交互和联系的方式。

基本类型:

Java类型

Native类型

占位

boolean

jboolean

unsigned 8 bits

byte

jbyte

signed 8 bits

char

jchar

unsigned 16 bits

short

jshort

signed 16 bits

int

jint

signed 32 bits

long

jlong

signed 64 bits

float

jfloat

32 bits

double

jdouble

64 bits

void

void

N/A

代码语言:javascript复制
#define JNI_FALSE   0
#define JNI_TRUE    1

#define JNI_VERSION_1_1 0x00010001
#define JNI_VERSION_1_2 0x00010002
#define JNI_VERSION_1_4 0x00010004
#define JNI_VERSION_1_6 0x00010006

#define JNI_OK          (0)         /* no error */
#define JNI_ERR         (-1)        /* generic error */
#define JNI_EDETACHED   (-2)        /* thread detached from the VM */
#define JNI_EVERSION    (-3)        /* JNI version error */
#define JNI_ENOMEM      (-4)        /* Out of memory */
#define JNI_EEXIST      (-5)        /* VM already created */
#define JNI_EINVAL      (-6)        /* Invalid argument */

#define JNI_COMMIT      1           /* copy content, do not free buffer */
#define JNI_ABORT       2           /* free buffer w/o copying back */

Java中的引用类型大家都很清楚,JNI作为Java和native交互层,肯定也有类似的引用类型。

JNI中名称

Java中名称

jobject

java.lang.Object

jstring

java.lang.String

jclass

java.lang.Class

jthrowable

java.lang.Throwable

上面是通用的引用类型,还有一些数组引用类型:

JNI中名称

Java中名称

jarray

通用数组

jobjectArray

object数组

jbooleanArray

boolean数组

jbyteArray

byte数组

jcharArray

char数组

jshortArray

short数组

jintArray

int数组

jlongArray

long数组

jfloatArray

float数组

jdoubleArray

double数组

代码语言:javascript复制
class _jobject {};
class _jclass : public _jobject {};
class _jstring : public _jobject {};
class _jarray : public _jobject {};
class _jobjectArray : public _jarray {};
class _jbooleanArray : public _jarray {};
class _jbyteArray : public _jarray {};
class _jcharArray : public _jarray {};
class _jshortArray : public _jarray {};
class _jintArray : public _jarray {};
class _jlongArray : public _jarray {};
class _jfloatArray : public _jarray {};
class _jdoubleArray : public _jarray {};
class _jthrowable : public _jobject {};

typedef _jobject*       jobject;
typedef _jclass*        jclass;
typedef _jstring*       jstring;
typedef _jarray*        jarray;
typedef _jobjectArray*  jobjectArray;
typedef _jbooleanArray* jbooleanArray;
typedef _jbyteArray*    jbyteArray;
typedef _jcharArray*    jcharArray;
typedef _jshortArray*   jshortArray;
typedef _jintArray*     jintArray;
typedef _jlongArray*    jlongArray;
typedef _jfloatArray*   jfloatArray;
typedef _jdoubleArray*  jdoubleArray;
typedef _jthrowable*    jthrowable;
typedef _jobject*       jweak;

jclass、jmethodID、jfieldID 如果在native代码中访问Java对象的引用的字段,需要执行下列操作:

代码语言:javascript复制
使用 FindClass 获取类的类对象引用
使用 GetFieldID 获取字段的字段ID
使用适当函数获取字段的内容,例如GetIntFieldID

如果需要调用类对象中的方法,有类方法和实例方法,对于实例方法,首先需要获取类对象的引用,然后获取方法ID,方法ID就是内部运行时数据结构的指针,一般通过字符串查找的方法来找到对应的方法的,可以通过jmethodId链接到对应Java层的方法。

代码语言:javascript复制
auto array_list_class = env->FindClass("java/util/ArrayList");
auto array_list_init_id = env->GetMethodID(array_list_class, "<init>", "()V");
auto array_list_obj = env->NewObject(array_list_class, array_list_init_id);
auto array_list_add_method_id = env->GetMethodID(array_list_class, "add", "(Ljava/lang/Object;)Z");

类型签名就是JNI和上层交互时的类型标识,不同的字符标识不同的类型。

JNI类型签名

对应Java类型

Z

boolean

B

byte

C

char

S

short

I

int

J

long

F

float

D

double

如果针对类类型的,表示结构是: "L类名;",例如: Ljava/lang/String;表示String类型

函数方法的表示是: (参数类型)返回类型,例如针对一个int getResult(int argv, String argv)可以写成: (ILjava/lang/String;)I

例如我们定义了一个类VideoInfo,在包名com.jeffmony.video下面,那在JNI中其对应的是Lcom/jeffmony/video/VideoInfo;

JavaVM/JNIEnv:

JNI定义了两个关键的数据结构,就是JavaVM和JNIEnv,两者本质上都是指向函数表的二级指针,JavaVM与进程强相关,Android程序下一个进程只能有一个JavaVM对象,JavaVM提供了一系列接口函数。我们这边需要着重记住的就是JavaVM在一个进程中只存在一个,这个很重要,JNI多线程需要这个作为基础。

代码语言:javascript复制
struct _JavaVM {
    const struct JNIInvokeInterface* functions;

#if defined(__cplusplus)
    jint DestroyJavaVM()
{ return functions->DestroyJavaVM(this); }
    jint AttachCurrentThread(JNIEnv** p_env, void* thr_args)
{ return functions->AttachCurrentThread(this, p_env, thr_args); }
    jint DetachCurrentThread()
{ return functions->DetachCurrentThread(this); }
    jint GetEnv(void** env, jint version)
{ return functions->GetEnv(this, env, version); }
    jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args)
{ return functions->AttachCurrentThreadAsDaemon(this, p_env, thr_args); }
#endif /*__cplusplus*/
};

JNIEnv 提供了大部分 JNI 函数。您的原生函数都会收到 JNIEnv 作为第一个参数。JNIEnv 对应的是线程,一个线程对应一个JNIEnv对象,在JNI多线程操作中,一定要注意切换到当前线程的JNIEnv,因为JNIEnv用于线程本地存储,无法在线程之间共享JNIEnv, 那怎么获取当前线程的JNIEnv, 答案是通过JavaVM的GetEnv获取当前线程的JNIEnv, 具体的细节下面会讲解的。这儿大家了解即可。下面是JNIEnv的源码,比较多,大家记住常用的就行了,其他需要用到的时候查询对应的API即可。

代码语言:javascript复制
struct _JNIEnv {
    /* do not rename this; it does not seem to be entirely opaque */
    const struct JNINativeInterface* functions;

#if defined(__cplusplus)
    jclass GetObjectClass(jobject obj)
{ return functions->GetObjectClass(this, obj); }

    jobject NewGlobalRef(jobject obj)
{ return functions->NewGlobalRef(this, obj); }

    void DeleteGlobalRef(jobject globalRef)
{ functions->DeleteGlobalRef(this, globalRef); }

    void DeleteLocalRef(jobject localRef)
{ functions->DeleteLocalRef(this, localRef); }

    jboolean IsSameObject(jobject ref1, jobject ref2)
{ return functions->IsSameObject(this, ref1, ref2); }



//此处省略N多行
#endif /*__cplusplus*/
};

静态注册和动态注册:

我们知道Android中加载的库都是动态库,需要在Java层代码中System.loadLibrary("native-lib");最终会生成libnative-lib.so 初始化加载动态库,native函数一般有两种注册方法,动态注册和静态注册。面试中问得最多的就是Android静态注册和动态注册的区别。 动态注册就是在运行时将JNI类和方法注册进来,静态注册就是通过某种特定的规则,不需要显式注册的前提下,只需要通过一些特定的方法名就可以定位JNI函数了。

动态注册就是运行时加载库方法,有两种加载方法:

代码语言:javascript复制
通过RegisterNatives显示注册原生方法
运行时使用dlsym动态查找

当然RegisterNatives的优势在于,可以预先检查符号是否存在,通过JNI_OnLoad回调方法获取规模更小、速度更快的共享库。

代码语言:javascript复制
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved);
JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved);

JNI提供了注册so的回调方法,就是JNI_OnLoad,在JNI_OnLoad回调中,可以使用RegisterNatives 注册所有的原生方法。

代码语言:javascript复制
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
        JNIEnv* env;
        if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
            return JNI_ERR;
        }

        // Find your class. JNI_OnLoad is called from the correct class loader context for this to work.
        jclass c = env->FindClass("com/example/app/package/MyClass");
        if (c == nullptr) return JNI_ERR;

        // Register your class' native methods.
        static const JNINativeMethod methods[] = {
            {"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)},
            {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)},
        };
        int rc = env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod));
        if (rc != JNI_OK) return rc;

        return JNI_VERSION_1_6;
}    

音视频开发中还是建议使用动态注册的方法,因为静态注册的很多签名问题,可能需要调用的时候才能发现,不利于查找问题。还是动态注册比较好用,代码简洁,而且注册效率高。

静态注册的话原生函数需要按照特定的命名规则来命名,下面我们用个例子来详细了解一下这个命名规则:

代码语言:javascript复制
package com.jeffmony.media; 


class Test { 


  private native int func1(double d);

  private static native int func2(double d); 

}

在JNI中生成的对应方法名是:

代码语言:javascript复制
JNIEXPORT jint JNICALL Java_com_jeffmony_media_Test_func1(JNIEnv* env, jobject object, jdouble d);

JNIEXPORT jint JNICALL Java_com_jeffmony_media_Test_func2(JNIEnv* env, jclass object, jdouble d);

看出来具体的区别了,因为我们在方法名中已经将当前native方法的路径记录在里面,所以搜索的时候可以直接根据方法找到对应的native方法。

但是如果出现方法名相同怎么办?

方法名相同不可能里面的参数类型或者参数个数也相同的,那就在签名的方法名中标记区别一下就行了。一般而言都会在方法名中将参数的类型加上。上面的类中如果再增加一个函数:

代码语言:javascript复制
class Test { 


  private native int func1(double d);

  private static native int func2(double d); 

  private static native int func2(int n);

}

那两个func2的写法就会变成:

代码语言:javascript复制
JNIEXPORT jint JNICALL Java_com_jeffmony_media_Test_func2D(JNIEnv* env, jclass object, jdouble d);

JNIEXPORT jint JNICALL Java_com_jeffmony_media_Test_func2I(JNIEnv* env, jclass object, jint n);

大家能看出区别来吗?

C语言是没有函数重载的,不能出现同样函数名的函数,所以通过在函数名上带上不同的函数参数以示区分。

全局引用/局部引用:

传递给原生方法的每个参数,以及 JNI 函数返回的几乎每个对象都属于“局部引用”。这意味着,局部引用在当前线程中的当前原生方法运行期间有效。在原生方法返回后,即使对象本身继续存在,该引用也无效。

这适用于 jobject 的所有子类,包括 jclass、jstring 和 jarray。就和我们所说的局部变量有点像。

如果你希望长时间保留某个引用,必须使用全局引用。获取全局引用的方法是通过NewGlobalRef和NewWeakGlobalRef函数,我们在JNI的开发中,将局部引用作为参数得到调用NewGlobalRef得到全局引用。使用完了之后,记得调用DeleteGlobalRef删除全局引用。

代码语言:javascript复制
jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

/**

中间可以使用这个全局应用
**/
env->DeleteGlobalRef(localClass);

大家千万别误解了全局引用,多次调用NewGlobalRef得到的全局引用的值可能不同,可以使用IsSameObject来判断是否引用同一个对象。千万别使用==判断。JNI开发不同于Java开发,大家使用了变量一定要记得释放,千万别出现内存泄露的问题。

全局引用有它的应用场景,如果不是需要全局共享的对象,最好不要使用全局引用,因为忘记释放了可能会造成很严重的问题。

JNI多线程:

我们经常遇到的一个场景是,在JNI中可能会开启一个线程,如何回调到Java层来?还记得我们上面说到了JavaVM,在Android中一个进程保持一个JavaVM实例,说明JavaVM是进程间共享的,那我们在JNI_OnLoad的时候需要将当前进程的JavaVM保存起来。JNIEnv是和线程绑定的,每个线程的JNIEnv都不同,但是都可以通过JavaVM获取当前线程的JNIEnv实例,具体怎么做?

首先通过调用javaVM->AttachCurrentThread来获取当前线程的JNIEnv

代码语言:javascript复制
if((javaVM->AttachCurrentThread(&env,NULL))!=JNI_OK)
{
    LOGI("%s AttachCurrentThread error failed ",__FUNCTION__);
    return NULL;
}

调用成功了JNIEnv就是和当前线程相关的对象。然后我们就可以通过JNIEnv对象调用JNI方法。

调用结束后记得javaVM->DetachCurrentThread来解绑JNIEnv

代码语言:javascript复制
if((javaVM->DetachCurrentThread())!=JNI_OK)
{
    LOGI("%s DetachCurrentThread error failed ",__FUNCTION__);
}

音视频开发中,会遇到很多Native Crash问题,下面介绍一种非常常见的JNI异常问题,具体堆栈如下:

代码语言:javascript复制
#00 pc 0000000000089908 /apex/com.android.runtime/lib64/bionic/libc.so (abort 168)
#01 pc 0000000000552abc /apex/com.android.art/lib64/libart.so (_ZN3art7Runtime5AbortEPKc 2260)
#02 pc 0000000000013990 /system/lib64/libbase.so (_ZZN7android4base10SetAborterEONSt3__18functionIFvPKcEEEEN3$_38__invokeES4_ 76)
#03 pc 0000000000012fb4 /system/lib64/libbase.so (_ZN7android4base10LogMessageD1Ev 320)
#04 pc 00000000002f8044 /apex/com.android.art/lib64/libart.so (_ZN3art22IndirectReferenceTable17AbortIfNoCheckJNIERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE 224)
#05 pc 0000000000389d70 /apex/com.android.art/lib64/libart.so (_ZNK3art22IndirectReferenceTable10GetCheckedEPv 444)
#06 pc 0000000000385324 /apex/com.android.art/lib64/libart.so (_ZN3art9JavaVMExt12DecodeGlobalEPv 24)
#07 pc 00000000005a6f68 /apex/com.android.art/lib64/libart.so (_ZNK3art6Thread13DecodeJObjectEP8_jobject 144)
#08 pc 000000000039ac7c /apex/com.android.art/lib64/libart.so (_ZN3art3JNIILb0EE14GetObjectClassEP7_JNIEnvP8_jobject 612)
#09 pc 00000000000d7a74 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so
#10 pc 00000000000ed098 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so
#11 pc 00000000000f0c04 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so
#12 pc 00000000000d7118 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so
#13 pc 00000000000d56e4 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so
#14 pc 00000000000d3360 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so
#15 pc 00000000000d32b4 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so
#16 pc 00000000000d3008 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so
#17 pc 00000000000eb504 /apex/com.android.runtime/lib64/bionic/libc.so (_ZL15__pthread_startPv 64)
#18 pc 000000000008bb0c /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread 64)
代码语言:javascript复制
Abort message是:
JNI ERROR (app bug): attempt to use stale Global 0x3a6a (should be 0x3a62)

这是一种非常典型的问题,下面的堆栈报在我们自己的libav_media.so中,上面挂在系统里面了,这时候不能轻易断言说挂在系统了,还需要仔细分析一下,很大可能使我们自己的误用导致挂在系统中了。

第一步当然是解栈了,使用上面的介绍的addr2line开始解栈,解出来的堆栈如下:

代码语言:javascript复制
_ZN7_JNIEnv14GetObjectClassEP8_jobject
/Users/jeffli/Library/Android/sdk/ndk/21.1.6352462/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/include/jni.h:585

_ZN6effect6OpenGL12ProcessImageEjPKfS2_Pf
/Users/jeffli/poizon/OpenGL/OpenGL/gl/opengl.cc:197

_ZN6effect11FrameBuffer12ProcessImageEjiiPKfS2_Pfl
/Users/jeffli/poizon/OpenGL/OpenGL/gl/frame_buffer.cc:96

_ZN5media11VideoRecord11OnTakePhotoEjii
/Users/jeffli/poizon/androidvideoeditor/library/src/main/cpp/record/video_record.cc:362

_ZN5media11VideoRecord11OnDrawFrameEv
/Users/jeffli/poizon/androidvideoeditor/library/src/main/cpp/record/video_record.cc:454

_ZN5media7Message7ExecuteEv
/Users/jeffli/poizon/androidvideoeditor/library/src/main/cpp/message/message_queue.cc:31

_ZN5media7Handler14ProcessMessageEv
/Users/jeffli/poizon/androidvideoeditor/library/src/main/cpp/message/handler.cc:83

_ZN5media7Handler18MessageQueueThreadEPv
/Users/jeffli/poizon/androidvideoeditor/library/src/main/cpp/message/handler.cc:94

我们先查看一下jni.h-->585行是什么?

这个functions->GetObjectClass会直接调用到libart.so中,我们可以通过这个调用判断出具体在我们自己的代码中什么地方调用的。沿着这个思路分析,发现我们最后调用到jni.h的地方在下面的740行代码中

这是音视频SDK中拍照回调的地方,这个take_photo_listener_是一个java层传入的接口对象jobject,正常情况下我们会想到两个点。

代码语言:javascript复制
take_photo_listener_是不是空指针
take_photo_listener_存不存在多线程调用

空指针一般不太可能,因为空指针的话和Abort message不太符合,空指针肯定直接就指定了是Null Pointer了,art功能这个强大,这点提醒还是没有问题的。 那就剩下后一种可能性,就是多线程调用。现在验证我们的猜想,take_photo_listener_是什么时候被设置进去的。

设置的地方和调用的地方不在一个线程,理论上肯定存在多线程问题的。但是光这样猜想不行,还是要有理论支撑的。 我们先分析一下这个Crash的源头就不难得到这个结论了。 推荐一个android源码查询的站点cs.android.com,下面分析一下GetObjectClass调用链路。 jni相关的加载代码都在android源码中的art/runtime/jni目录中:

代码语言:javascript复制
art/runtime/jni/jni_internal.cc---> GetObjectClass
art/runtime/scoped_thread_state_changeinl.h---> ScopedObjectAccessAlreadyRunnable::Decode
art/runtime/thread.cc---> Thread::DecodeJObject
art/runtime/jni/java_vm_ext.cc---> JavaVMExt::DecodeGlobal
art/runtime/indirect_reference_table.h---> SynchronizedGet
art/runtime/indirect_reference_table.h---> IndirectReferenceTable::Get
art/runtime/indirect_reference_table.h---> IndirectReferenceTable::CheckEntry

最终因为什么报错了?是因为在indirectRef表中没有找到当前jobject对应的索引,导致报错了,为什么找不到这个索引,这个jobject还没有被定义为GlobalObject,这就和上面的分析对应起来了,在赋值的时候,因为多线程,还没有执行env->NewGlobalRef(take_photo_listener)代码,导致在索引表中找不到对应的数据。通过上面的分析来看,只要在global ref做好线程安全的保护即可。

cpp中怎么保证线程安全:使用pthread_mux_lock实现互斥锁,保证同样的代码块或者变量互斥访问,不会出现多线程问题。例如上面的解决方案可以采用这样的处理方式。

JNI基本知识

上面分析了JNI异常的完整分析流程,对于初学音视频开发的同学,解栈是必备的技能,但是包括官方文档在内的技术文章都有一定的门槛,我这边直接放上解栈的工具,帮忙大家一秒钟进入状态。

解栈需要那些工具了?首先需要是addr2line工具,这个工具在NDK中,大家翻到上面讲解交叉编译的章节可以看到addr2line是如何工作的。 需要unstripped 的so,就是我们编译出来的动态库,有两个,一个是stripped的so,相当于压缩之后去掉符号表的库文件;还有一个是没有去掉符号表的,就是我们需要的unstripped so,最重要的肯定是崩溃栈啦,崩溃栈的结构如下:

代码语言:javascript复制
#16 pc 00000000000d3008 /data/app/~~yvshfyvSUZ46EK1lhAhiTQ==/com.jeffmony.media-aLxfQNnnePl3Xieaq6E1uQ==/lib/arm64/libav_media.so

有一个pc地址,还有具体崩溃的so地址,这是崩溃栈的核心信息。至于崩溃栈是怎么手机的,建议大家了解一下google-breakpad的开源库,这儿贴一下,大家有兴趣了解一下。

其核心思想就是linux的终端就是通过signal发生给系统的,系统接收到崩溃的中断信号,就知道当前发生了不可扭转的问题,开始收集堆栈信息。

提供了pc地址和包含符号表的unstripped so,我们要使用ndk中的addr2line开始解栈,我直接贴一下自动化的脚本吧,大家使用的时候直接用就行了。

一个shell脚本addr2line_tools.sh

代码语言:javascript复制
# !/bin/sh

python parse_crash.py crash
代码语言:javascript复制
# -*- coding:UTF-8 -*-
# Author : jeffmony@163.com
# Date : 28/07/21

import sys
import os
import re

NDK_HOME = '/Users/jeffli/tools/android-ndk-r16b'

ADDRLINE = '/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin/aarch64-linux-android-addr2line'

ADDRLINE_PATH = NDK_HOME   ADDRLINE

SO_PATH = 'XXXX'

file_name = sys.argv[1]
file_object = open(file_name, 'rU')
file_info = ''

try:
    for line in file_object:
        stack = ''
        so_name = ''
        tempStr = line.strip('n')
        start = tempStr.find('pc')
        tempStr = tempStr[start   3:]
        tempStr = re.sub('  ',' ', tempStr)
        end = tempStr.find(' ')
        ## 找到具体的pc地址
        stack = tempStr[:end]
        tempStr = tempStr[end   1:]
        end = tempStr.find(' ')
        if end != -1:
            tempStr = tempStr[:end]
        ## 找到so的名称,要求必须在特定的目录下
        so_name = tempStr[tempStr.rfind('/')   1:]
        so_path = SO_PATH   '/'   so_name
        result = os.popen(ADDRLINE_PATH   ' -f -e '   so_path   ' '   stack).read()
        print result
finally:
    file_object.close()

还需要在当前目录下新建一个crash文件,将对应的crash堆栈写入crash文件中,直接执行sh addr2line_tools.sh就可以解栈了。

本文篇幅比较长,但也只是介绍了NDK开发的基本知识,NDK开发还有很多疑难的点,我们留到后面吧。

0 人点赞