Android系統原始碼分析-JNI

Jensen95發表於2018-01-16

序言

因為在接下來的原始碼分析中將涉及大量的Java和Native的互相呼叫。當然對於我們的程式碼分析沒有什麼影響,但是,這樣一個黑盒子擺在面前,對於其實現原理還是充滿了好奇心。本篇將從JNI最基本的概念到簡單的程式碼例項和其實現原理逐步展開。

JNI

JNI(Java Native Interface,Java本地介面)是一種程式設計框架使得Java虛擬機器中的Java程式可以呼叫本地應用/或庫,也可以被其他程式呼叫。 本地程式一般是用其它語言C,C++或組合語言編寫的, 並且被編譯為基於本機硬體和作業系統的程式。在Android平臺,為了更方便開發者的使用和增強其功能性,Android提供了NDK來更方便開發者的開發。

JNI工作

為什麼要有JNI?

JNI允許程式設計師用其他程式語言來解決用純粹的Java程式碼不好處理的情況, 例如, Java標準庫不支援的平臺相關功能或者程式庫。也用於改造已存在的用其它語言寫的程式, 供Java程式呼叫。許多基於JNI的標準庫提供了很多功能給程式設計師使用, 例如檔案I/O、音訊相關的功能。當然,也有各種高效能的程式,以及平臺相關的API實現, 允許所有Java應用程式安全並且平臺獨立地使用這些功能。Java層可以用來負責UI功能實現,而C++負責進行計算操作。

JNI框架允許Native方法呼叫Java物件,就像Java程式訪問Native物件一樣方便。Native方法可以建立Java物件,讀取這些物件, 並呼叫Java物件執行某些方法。當然Native方法也可以讀取由Java程式自身建立的物件,並呼叫這些物件的方法。

Hello World

這裡,我們先通過一個簡單的Hello World例項來對JNI的呼叫流程有一個直觀的印象,然後針對其中的實現原理和細節做分析。

1. 在Java檔案中定義native函式

在此方法宣告中,使用 native 關鍵字的作用是告訴虛擬機器,函式位於共享庫中(即在原生端實現)。

private native String helloWorld();
複製程式碼

2.利用Javah生成標頭檔案

對於native方法的命名規則,函式名根據以下規則構建:

  • 在名稱前面加上 Java_。
  • 描述與頂級源目錄相關的檔案路徑。
  • 使用下劃線代替正斜槓。
  • 刪掉 .java 副檔名。
  • 在最後一個下劃線後,附加函式名。

按照這些規則,此示例使用的函式名為 Java_com_example_hellojni_HelloJni_stringFromJNI。 此名稱描述 hellojni/src/com/example/hellojni/HelloJni.java 中一個名為 stringFromJNI()的 Java 函式。我們想通過更簡單的方式,讓寫native函式如同和寫java函式沒有這一步的轉化,那麼可以通過javah來實現。

javah -d ../jni -jni com.chenjensen.myapplication.MainActivity
複製程式碼
  • d :標頭檔案輸出目錄
  • jni:生成jni檔案

3.根據Javah生成的標頭檔案,實現相應的native函式

JNIEXPORT jstring JNICALL Java_com_chenjensen_myapplication_MainActivity_helloWorld
  (JNIEnv *, jobject);
複製程式碼

標頭檔案中生成了我們的java檔案中定義的native方法,也做好了型別轉化,我們只需要新建一個cpp檔案來實現相應的方法即可。

4.cpp檔案

JNIEXPORT jstring JNICALL Java_com_chenjensen_myapplication_MainActivity_helloWorld
        (JNIEnv *env, jobject)
{
    char *str = "Hello world";
    return (*env).NewStringUTF(str);
}
複製程式碼

5.build檔案中編譯支援指定的平臺(arm,x86等)

ndk {
     moduleName "hello"       //生成的so檔名字,呼叫C程式的程式碼中會用到該名字
     abiFilters "armeabi", "armeabi-v7a", "x86" //輸出指定三種平臺下的so庫
}
複製程式碼

這裡指定了生成so檔案的name之後,編譯系統就會從JNI目錄下去尋找相應的c/cpp檔案,來生成相應的so檔案。

6.執行

在Java程式碼中,native方法的執行之前,要提前載入相應的動態庫,然後才可以執行,一般會在該類中通過靜態程式碼塊的方式來載入。應用啟動時,呼叫此函式以載入 .so 檔案。

static {
   System.loadLibrary("hello");
}
複製程式碼

這個時候,我們在Java程式碼中呼叫相應的native程式碼就會生效了。

那麼在C/C++檔案中如何呼叫Java呢,這裡的呼叫方式和Java中通過反射查詢一個類的呼叫相似。核心函式為以下幾個。

FindClass(), NewObject(), GetStaticMethodID(), 
GetMethodID(), CallStaticObjectMethod(), CallVoidMethod()
複製程式碼

找到相應的類,相應的方法,呼叫相應的類和方法。這裡不在給出具體的程式碼示例。可參考文章末尾給出的相應連結。

如何呼叫

通過上述6個步驟,我們便實現了Java呼叫native函式,藉助了相應的工具,我們可以很快的實現其互相呼叫,但是,工具也遮蔽掉了大量的實現細節,讓這個過程變成黑盒,不瞭解其實現。這個過程中, 當JVM呼叫這些函式,傳遞了一個JNIEnv指標,一個jobject的指標,任何在Java方法中宣告的Java引數。

一個JNI函式看起來類似這樣:

JNIEXPORT void JNICALL Java_ClassName_MethodName
  (JNIEnv *env, jobject obj)
{
    /*Implement Native Method Here*/
}
複製程式碼

Java和C++之間的呼叫,Java的執行需要在JVM上,因此在呼叫的時候,JVM必須知道要呼叫那一個本地函式,本地函式呼叫Java的時候,也必須要知道應用物件和具體的函式。

JNI中C++和Java的執行是在同一個執行緒,但是其執行緒值是不相同的。 JNIEnv是JNI的使用環境,JNIEnv物件是和執行緒繫結在一起的,在進行呼叫的時候,會傳遞一個JavaVM的指標作為引數,然後通過JavaVM的getEnv函式得到JNIEnv物件的指標。在Java中每次建立一個執行緒,都會生成新的JNIEnv物件。

在分析系統原始碼的時候,我們可以看到很多的java對於native的呼叫,通過對於原始碼的分析,我們發現在系統開機之後,就會有許多的Service程式被啟動,這個時候,而其很多實現都是通過native來實現的,這個時候如何呼叫,讓我們迴歸到系統的啟動過程中。在Zygote程式中首先會呼叫啟動VM。

系統啟動JNI註冊流程

if (startVm(&mJavaVM, &env, zygote) != 0) {
   return;
}

onVmCreated(env);

if (startReg(env) < 0) {
  return;
}
複製程式碼
int AndroidRuntime::startReg(JNIEnv* env)
{
    if (register_jni_procs(gRegJNI, NELEM(gRegJNI), env) < 0) {
        env->PopLocalFrame(NULL);
        return -1;
    }
    ....
    return 0;
}
複製程式碼
static int register_jni_procs(const RegJNIRec array[], size_t count, JNIEnv* env)
{
    for (size_t i = 0; i < count; i++) {
        if (array[i].mProc(env) < 0) {
            return -1;
        }
    }
    return 0;
}
複製程式碼
static const RegJNIRec gRegJNI[] = {
    REG_JNI(register_com_android_internal_os_RuntimeInit),
    REG_JNI(register_android_os_SystemClock),
    REG_JNI(register_android_util_EventLog),
    REG_JNI(register_android_util_Log),
    .....
}
複製程式碼

array[i]是指gRegJNI陣列, 該陣列有100多個成員。其中每一項成員都是通過REG_JNI巨集定義。

 #define REG_JNI(name)      { name }
複製程式碼
struct RegJNIRec {
        int (*mProc)(JNIEnv*);
 };
複製程式碼

呼叫mProc,就等價於呼叫其引數名所指向的函式。 例如REG_JNI(register_com_android_internal_os_RuntimeInit).mProc也就是指進入register_com_android_internal_os_RuntimeInit方法,進入這些方法之後,就會是對於該類中的一些native方法和java方法的對映。

int register_com_android_internal_os_RuntimeInit(JNIEnv* env) {
    return jniRegisterNativeMethods(env, "com/android/internal/os/RuntimeInit",
        gMethods, NELEM(gMethods));
}
複製程式碼
//gMethods:java層方法名與jni層的方法的一一對映關係
static JNINativeMethod gMethods[] = {
    { "nativeFinishInit", "()V",
        (void*) com_android_internal_os_RuntimeInit_nativeFinishInit },
    { "nativeZygoteInit", "()V",
        (void*) com_android_internal_os_RuntimeInit_nativeZygoteInit },
    { "nativeSetExitWithoutCleanup", "(Z)V",
        (void*) com_android_internal_os_RuntimeInit_nativeSetExitWithoutCleanup },
};
複製程式碼

至此就完成了對於native方法和Java方法的對映關聯。

  • 另一種載入方式

對於JNI方法的註冊無非是通過兩種方式一個是上述啟動過程中的註冊,一個是在程式中通過System.loadLibrary的方式進行註冊,這裡,我們以System.loadLibrary來分析其註冊過程。

public static void loadLibrary(String libname) {
  Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);
}
複製程式碼
public static Runtime getRuntime() {
   return currentRuntime;
}
複製程式碼
synchronized void load0(Class fromClass, String filename) {
    if (!(new File(filename).isAbsolute())) {
        throw new UnsatisfiedLinkError(
            "Expecting an absolute path of the library: " + filename);
    }
    if (filename == null) {
        throw new NullPointerException("filename == null");
    }
    String error = doLoad(filename, fromClass.getClassLoader());
    if (error != null) {
        throw new UnsatisfiedLinkError(error);
    }
}
複製程式碼
String librarySearchPath = null;
if (loader != null && loader instanceof BaseDexClassLoader) {
    BaseDexClassLoader dexClassLoader = (BaseDexClassLoader) loader;
    librarySearchPath = dexClassLoader.getLdLibraryPath();
}
        synchronized (this) {
    return nativeLoad(name, loader, librarySearchPath);
}
複製程式碼

經過層層呼叫之後來到了nativeLoad方法,這裡對於這段程式碼的分析,目的是為了瞭解,整個JNI的註冊過程和呼叫的時候,JVM是如何找到相應的native方法的。

對於nativeLoad執行的內容,會轉交到classLoader,最終會轉化為系統的呼叫,呼叫dlopen和dlsym函式。

  • 呼叫dlopen函式,開啟一個so檔案並建立一個handle;
  • 呼叫dlsym()函式,檢視相應so檔案的JNI_OnLoad()函式指標,並執行相應函式。

簡單的說,dlopen、dlsym提供一種動態轉載庫到記憶體的機制,在需要的時候,可以呼叫庫中的方法。

在Java位元組碼中,普通的方法是直接把位元組碼放到code屬性表中,而native方法,與普通的方法通過一個標誌“ACC_NATIVE”區分開來。java在執行普通的方法呼叫的時候,可以通過找方法表,再找到相應的code屬性表,最終解釋執行程式碼。

在將動態庫load進來的時候,首先要做的第一步就是執行該動態庫的JNI_OnLoad方法,我們需要在該方法中宣告好native和java的關聯,系統中的相關類因為沒有提供該方法,因此需要手動呼叫了各自相應的註冊方法。而在我們寫的demo中,編譯器則為我們做了這個操作,也不需要我們來做。寫好對映關係之後,呼叫registerNativeMethods方法來將這些方法進行註冊。具體的函式對映和註冊方式如上Runtime所示。

在編譯成的java程式碼中,普通的Java方法會直接指向方法表中具體的方法,而對於native方法則是做了特殊的標記,在執行到native方法時,就會根據我們之前載入進來的native的方法對應表中去查詢相應的方法,然後執行。

參考文章

Android JNI原理分析 Native呼叫Java Java JNI實現原理初探

相關文章