音视频NDK开发指南一

·  阅读 981

1.概要

工欲善其事必先利其器,本文着重在应用的基础上讲解原因,所以我们一切围绕“实用主义”展开,NDK开发就是音视频开发中一个非常基础的工具,光会Java开发是学不深音视频的,深入到图片和音频的本质,都需要了解其结构、原因,还有音视频的核心库基本上都是C、C++开发的,所以交叉编译使我们必须掌握的。

平时我们熟知的一些音视频库,如FFmpeg、VLC、ijkplayer、gstreamer、openssl、libx264等等无一不是使用C或者C++开发的,想将它们应用到Android或者iOS上,必须学会交叉编译,然后掌握cmake编译流程,最终才能将它们集成到项目中。 学会本文,你会明白:

  • 什么是交叉编译?
  • CMake运行流程
  • JNI开发中有哪些关键点
  • 如何分析一个native crash
  • 大型项目怎么做交叉编译

2.交叉编译

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

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

2.1 交叉编译

做过jni开发的同学都知道jni代码是使用ndk工具链编译的,ndk工具中就包含交叉编译工具链,我们先看一下ndk的目录结构: 01.png 我们所说的交叉编译工具链就在这个toolchains文件夹中,可以深入进去看一下: 02.png 这些目录表示针对不同CPU架构的编译工具链,例如arm-linux-androideabi-4.9表示arm架构,x86-4.9表示x86架构。

  • arm-linux-androideabi-4.9
  • aarch64-linux-android-4.9
  • mipsel-linux-android-4.9
  • mips64el-linux-android-4.9
  • x86-4.9
  • x86_64-4.9

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

2.1.1 ARM指令集

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

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

2.1.2 X86指令集

X86是intel主导设计的一个微处理器体系结构的指令架构,PC端主要称霸的是X86架构,与ARM不同,X86采用的是CISC架构,就是复杂指令集计算机,CISC与RISC不同,程序中指令是按照顺序串行执行的,每条指令中的操作也是顺序串行的。

顺序执行优点是控制比较简单,但是利用效率较低,执行速度也不太快。

2.1.3 MIPS指令集

MIPS是采用RISC指令集的架构,全称是Microprocessor without interlocked piped stages architecture,由MIPS推出,其基本特点是:

  • 包含大量的寄存器、指令集和字符
  • 可视化的管道延时处理
  • 能耗非常想小。

但是很可惜,最近听说MIPS已经开始拥抱ARM了,市场上的选择变少了,不得不说是一种悲哀。

2.2 交叉编译工具链

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

  • 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 : 可以将多个重定位的目标模块归档为一个函数库文件。

交叉编译有一个完整的过程: 10.png 从交叉编译的工程来看,其实和正常的编译没什么不一样,只不过有两点:

  • 交叉编译使用的是交叉编译工具
  • 交叉编译链接的库或者头文件必须明确指定

例如我们正常gcc编译的过程,有一些库函数已经指定在系统的PATH路径中了,我们不必要单独指定,但是交叉编译的时候则必须要明确指定。

下面通过一个例子来加深对交叉编译的理解:

一个很简单的c程序

#include <stdio.h>

int main(int argc, char** argv) {
	printf("Hello, jeffmony\n");
	return 0;
}
复制代码

输出的结果如下:

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,编译指令如下:

$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-android
复制代码

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

2.3 独立工具链

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

12.png 创建独立工具链:

$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,使用的就是独立工具链,控制非常灵活。不过说实话我不太建议大家使用独立工具链编译,主要会生成很多额外的文件,而且链接起来没有多方便,写了很多额外的代码,容易把人绕迷糊了,不过也是给大家提供了一种选择。

3.CMake

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

什么是CMake呢?

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

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

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

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

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

# 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组织到项目中的核心枢纽。

// 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

4.JNI全面剖析

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

4.1 JNI中数据类型

4.1.1 基本类型

Java中类型Native中类型占位
booleanjbooleanunsigned 8 bits
bytejbytesigned 8 bits
charjcharunsigned 16 bits
shortjshortsigned 16 bits
intjintsigned 32 bits
longjlongsigned 64 bits
floatjfloat32 bits
doublejdouble64 bits
voidvoidN/A

jni.h中还定义了一些特殊的变量:

#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 */
复制代码

4.1.2 引用类型

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

JNI中名称对应Java名称
jobjectjava.lang.Object对象
jclassjava.lang.Class对象
jstringjava.lang.String对象
jthrowablejava.lang.Throwable对象

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

JNI中名称对应Java名称
jarray通用数组引用类型
jobjectArrayobject 数组
jbooleanArrayboolean 数组
jbyteArraybyte 数组
jcharArraychar 数组
jshortArrayshort 数组
jintArrayint 数组
jlongArraylong 数组
jfloatArrayfloat 数组
jdoubleArraydouble 数组
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对象的引用的字段,需要执行下列操作:

  • 使用 FindClass 获取类的类对象引用
  • 使用 GetFieldID 获取字段的字段ID
  • 使用适当函数获取字段的内容,例如GetIntFieldID

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

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");
复制代码

4.1.3 类型签名

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

类型签名对应的Java类型
Zboolean
Bbyte
Cchar
Sshort
Iint
Jlong
Ffloat
Ddouble

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

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

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

4.1.4 JavaVM和JNIEnv

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

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即可。

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*/
};

复制代码

4.2 什么是静态和动态注册

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

4.2.1 动态注册

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

  • 通过RegisterNatives显示注册原生方法
  • 运行时使用dlsym动态查找

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

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved);
JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved);

复制代码

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

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;
    }

复制代码

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

4.2.2 静态注册

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

package com.jeffmony.media; 


class Test { 


  private native int func1(double d);

  private static native int func2(double d); 

} 
复制代码

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

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方法。
但是如果出现方法名相同怎么办?
方法名相同不可能里面的参数类型或者参数个数也相同的,那就在签名的方法名中标记区别一下就行了。一般而言都会在方法名中将参数的类型加上。上面的类中如果再增加一个函数:


class Test { 


  private native int func1(double d);

  private static native int func2(double d); 

  private static native int func2(int n);

} 
复制代码

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

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语言是没有函数重载的,不能出现同样函数名的函数,所以通过在函数名上带上不同的函数参数以示区分。

4.3 JNI中的全局引用和局部引用

4.3.1 局部引用

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

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

4.3.2 全局引用

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

jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

/**

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

复制代码

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

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

4.4 JNI中如何实现多线程

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

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

    if((javaVM->AttachCurrentThread(&env,NULL))!=JNI_OK)
    {
        LOGI("%s AttachCurrentThread error failed ",__FUNCTION__);
        return NULL;
    }

复制代码

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

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

    if((javaVM->DetachCurrentThread())!=JNI_OK)
    {
        LOGI("%s DetachCurrentThread error failed ",__FUNCTION__);
    }

复制代码

具体大家在实践中多尝试几次就可以理解这个意思了。

4.5 JNI多线程问题剖析

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

#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)

复制代码

Abort message是:

JNI ERROR (app bug): attempt to use stale Global 0x3a6a (should be 0x3a62)
复制代码

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

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

_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/sources/OpenGL/OpenGL/gl/opengl.cc:197

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

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

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

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

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

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

复制代码

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

    jclass GetObjectClass(jobject obj)
    { return functions->GetObjectClass(this, obj); }
复制代码

这个functions->GetObjectClass会直接调用到libart.so中,我们可以通过这个调用判断出具体在我们自己的代码中什么地方调用的。沿着这个思路分析,发现我们最后调用到jni.h的地方在下面的740行代码中
06.png 这是音视频SDK中拍照回调的地方,这个take_photo_listener_是一个java层传入的接口对象jobject,正常情况下我们会想到两个点。

  • take_photo_listener_是不是空指针
  • take_photo_listener_存不存在多线程调用

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

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

  • 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

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

cpp中怎么保证线程安全:

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

5.如何自动解栈

上面分析了JNI异常的完整分析流程,对于初学音视频开发的同学,解栈是必备的技能,但是包括官方文档在内的技术文章都有一定的门槛,我这边直接放上解栈的工具,帮忙大家一秒钟进入状态。
解栈需要那些工具了?
首先需要是addr2line工具,这个工具在NDK中,大家翻到上面讲解交叉编译的章节可以看到addr2line是如何工作的。
需要unstripped 的so,就是我们编译出来的动态库,有两个,一个是stripped的so,相当于压缩之后去掉符号表的库文件;还有一个是没有去掉符号表的,就是我们需要的unstripped so
最重要的肯定是崩溃栈啦,崩溃栈的结构如下:

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

有一个pc地址,还有具体崩溃的so地址,这是崩溃栈的核心信息。
至于崩溃栈是怎么手机的,建议大家了解一下google-breakpad的开源库,这儿贴一下,大家有兴趣了解一下:
github.com/google/brea…
其核心思想就是linux的终端就是通过signal发生给系统的,系统接收到崩溃的中断信号,就知道当前发生了不可扭转的问题,开始收集堆栈信息。

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

一个shell脚本addr2line_tools.sh

# !/bin/sh

python parse_crash.py crash

复制代码

parse_crash.py如下:

# -*- 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就可以解栈了。

分类:
Android
收藏成功!
已添加到「」, 点击更改