Android 的 so 檔案載入機制

請叫我大蘇發表於2019-03-03

最近碰到一些 so 檔案問題,順便將相關知識點梳理一下。

提問

本文的結論是跟著 System.loadlibrary() 一層層原始碼走進去,個人對其的理解所整理的,那麼開始看原始碼之前,先來提幾個問題:

Q1:你知道 so 檔案的載入流程嗎?

Q2:裝置存放 so 的路徑有 system/lib,vendor/lib,system/lib64,vendor/lib64,知道在哪裡規定了這些路徑嗎?清楚哪些場景下系統會去哪個目錄下尋找 so 檔案嗎?還是說,所有的目錄都會去尋找?

Q3:Zygote 程式是分 32 位和 64 位的,那麼,系統是如何決定某個應用應該執行在 32 位上,還是 64 位上?

Q4:如果程式跑在 64 位的 Zygote 程式上時,可以使用 32 位的 so 檔案麼,即應用的 primaryCpuAbi 為 arm64-v8a,那麼是否可使用 armeabi-v7a 的 so 檔案,相容的嗎?

Q2,Q3,Q4,這幾個問題都是基於裝置支援 64 位的前提下,在舊系統版本中,只支援 32 位,也就沒這麼多疑問需要處理了。

原始碼

準備工作

由於這次的原始碼會涉及很多 framework 層的程式碼,包括 java 和 c++,直接在 AndroidStudio 跟進 SDK 的原始碼已不足夠檢視到相關的程式碼了。所以,此次是藉助 Source Insight 軟體,而原始碼來源如下:

android.googlesource.com/platform/

我並沒有將所有目錄下載下來,只下載瞭如下目錄的原始碼:

我沒有下載最新版本的程式碼,而是選擇了 Tags 下的 More 按鈕,然後選擇 tag 為: android-5.1.1 r24 的程式碼下載。所以,此次分析的原始碼是基於這個版本,其餘不同版本的程式碼可能會有所不一樣,但大體流程應該都是一致的。

分析

原始碼分析的過程很長很長,不想看過程的話,你也可以直接跳到末尾看結論,但就會錯失很多細節的分析了。

那麼下面就開始來過下原始碼吧,分析的入口就是跟著 System.loadlibrary() 走 :

//System#loadlibrary()
public static void loadLibrary(String libName) {
    Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader());
}

//Runtime#loadLibrary()
void loadLibrary(String libraryName, ClassLoader loader) {
    //1. 程式中通過 System.loadlibrary() 方式,這個 loader 就不會為空,流程走這邊
    if (loader != null) {
        //2. loader.findLibrary() 這是個重點,這個方法用於尋找 so 檔案是否存在
        String filename = loader.findLibrary(libraryName);
        if (filename == null) {
             throw new UnsatisfiedLinkError(loader + " couldn't find \"" + System.mapLibraryName(libraryName) + "\"");
        }
        //3. 如果 so 檔案找到,那麼載入它
        String error = doLoad(filename, loader);
        if (error != null) {
            //4. 如果載入失敗,那麼拋異常
            throw new UnsatisfiedLinkError(error);
        }
        return;
    }
    
	//1.1 以下程式碼的執行場景我不清楚,但有幾個方法可以蠻看一下
    //mapLibraryName 用於拼接 so 檔名的字首:lib,和字尾.so
    String filename = System.mapLibraryName(libraryName);
    //...省略
    //1.2 mLibPaths 儲存著裝置存放 so 檔案的目錄地址
    for (String directory: mLibPaths) {
        String candidate = directory + filename;
        candidates.add(candidate);
        if (IoUtils.canOpenReadOnly(candidate)) 
            // 1.3 呼叫 native 層方法載入 so 庫
            String error = doLoad(candidate, loader);
            if (error == null) {
                return; // We successfully loaded the library. Job done.
            }
            lastError = error;
        }
    }
	//...省略
}
複製程式碼

所以,其實 System 的 loadlibrary() 是呼叫的 Runtime 的 loadLibrary(),不同系統版本,這些程式碼是有些許差別的,但不管怎樣,重點都還是 loadLibrary() 中呼叫的一些方法,這些方法基本沒變,改變的只是其他程式碼的優化寫法。

那麼,要理清 so 檔案的載入流程,或者說,要找出系統是去哪些地址載入 so 檔案的,就需要梳理清這些方法:

  • loader.findLibrary()
  • doLoad()

第一個方法用於尋找 so 檔案,所涉及的整個流程應該都在這個方法裡,如果可以找到,會返回 so 檔案的絕對路徑,然後交由 doLoad() 去載入。

java.library.path

但在深入去探索之前,我想先探索另一條分支,loader 為空的場景。loader 什麼時候為空,什麼時候不為空,我並不清楚,只是看別人的文章分析時說,程式中通過 System.loadlibrary() 方式載入 so,那麼 loader 就不會為空。那,我就信你了,不然我也不知道去哪分析為不為空的場景。

既然程式不會走另一個分支,為什麼我還要先來探索它呢?因為,第一個分支太不好探索了,先從另一個分支摸索點經驗,而且還發現了一些感覺可以拿來講講的方法:

  • System.mapLibraryName()

用於拼接 so 檔名的字首 lib,和字尾 .so

  • mLibPaths

在其他版本的原始碼中,可能就沒有這個變數了,直接就是呼叫一個方法,但作用都一樣,我們看看這個變數的賦值:

//Runtime.mLibPaths
private final String[] mLibPaths = initLibPaths();

//Runtime#initLibPaths()
private static String[] initLibPaths() {
    String javaLibraryPath = System.getProperty("java.library.path");
    //...省略
}
複製程式碼

最後都是通過呼叫 System 的 getProperty() 方法,讀取 java.library.path 的屬性值。

也就是說,通過讀取 java.library.path 的系統屬性值,是可以獲取到裝置存放 so 庫的目錄地址的,那麼就來看看在哪裡有設定這個屬性值進去。

System 內部有一個型別為 Properties 的靜態變數,不同版本,這個變數名可能不一樣,但作用也都一樣,用來儲存這些系統屬性值,這樣程式需要的時候,呼叫 getProperty() 讀取屬性值時其實是來這個靜態變數中讀取。而變數的初始化地方在類中的 static 程式碼塊中:

//System
static {
    //...省略
    //1.初始化一些不變的系統屬性值
    unchangeableSystemProperties = initUnchangeableSystemProperties();
    //2.將上述的屬性值以及一些預設的系統屬性值設定到靜態變數中
    systemProperties = createSystemProperties();
    //...
}

//System#initUnchangeableSystemProperties()
private static Properties initUnchangeableSystemProperties() {
    //...省略一些屬性值設定
    p.put("java.vm.vendor", projectName);
    p.put("java.vm.version", runtime.vmVersion());
    p.put("file.separator", "/");
    p.put("line.separator", "\n");
    p.put("path.separator", ":");
    //...
	
    //1.這裡是重點
    parsePropertyAssignments(p, specialProperties());

    //...
    return p;
}

//System#createSystemProperties()
private static Properties createSystemProperties() {
    //1.拷貝不可變的一些系統屬性值
    Properties p = new PropertiesWithNonOverrideableDefaults(unchangeableSystemProperties);
    //2.設定一些預設的屬性值
    setDefaultChangeableProperties(p);
    return p;
}

//System#setDefaultChangeableProperties()
private static void setDefaultChangeableProperties(Properties p) {
    p.put("java.io.tmpdir", "/tmp");
    p.put("user.home", "");
}
複製程式碼

static 靜態程式碼塊中的程式碼其實就是在初始化系統屬性值,分兩個步驟,一個是先設定一些不可變的屬性值,二是設定一些預設的屬性值,然後將這些儲存在靜態變數中。

但其實,不管在哪個方法中,都沒找到有設定 java.library.path 屬性值的程式碼,那這個屬性值到底是在哪裡設定的呢?

關鍵點在於設定不可變的屬性時,有呼叫了一個 native 層的方法:

//System
/**
* Returns an array of "key=value" strings containing information not otherwise
* easily available, such as #defined library versions.
*/
private static native String[] specialProperties();
複製程式碼

這方法會返回 key=value 形式的字串陣列,然後 parsePropertyAssignments() 方法會去遍歷這些陣列,將這些屬性值填充到儲存系統屬性值的靜態變數中。

也就是說,在 native 層還會設定一些屬性值,而 java.library.path 有可能就是在 native 中設定的,那麼就跟下去看看吧。

System 連同包名的全名是:java.lang.System;那麼,通常,所對應的 native 層的 cpp 檔名為:java_lang_System.cpp,到這裡去看看:

//platform/libcore/luni/src/main/native/java_lang_System.cpp#System_specialProperties()
static jobjectArray System_specialProperties(JNIEnv* env, jclass) {
    std::vector<std::string> properties;

    //...
	
    //1. 獲取 LD_LIBRARY_PATH 環境變數值
    const char* library_path = getenv("LD_LIBRARY_PATH");
#if defined(HAVE_ANDROID_OS)
    if (library_path == NULL) {
        //2.如果 1 步驟沒獲取到路徑,那麼通過該方法獲取 so 庫的目錄路徑
        android_get_LD_LIBRARY_PATH(path, sizeof(path));
        library_path = path;
    }
#endif
    if (library_path == NULL) {
        library_path = "";
    }
    //3.設定 java.library.path 屬性值
    properties.push_back(std::string("java.library.path=") + library_path);

    return toStringArray(env, properties);
}
複製程式碼

沒錯吧,對應的 native 層的方法是上述這個,它乾的事,其實也是設定一些屬性值,我們想要的 java.library.path 就是在這裡設定的。那麼,這個屬性值來源的邏輯是這樣的:

  1. 先讀取 LD_LIBRARY_PATH 環境變數值,如果不為空,就以這個值為準。但我測試過,貌似,程式執行時讀取的這個值一直是 null,在 Runtime 的 doLoad() 方法註釋中,Google 有解釋是說由於 Android 的程式都是通過 Zygote 程式 fork 過來,所以不能使用 LD_LIBRARY_PATH 。應該,大概,可能是這個意思吧,我英文不大好,你們可以自行去確認一下。
  2. 也就是說,第一步讀取的 LD_LIBRARY_PATH 值是為空,所以會進入第二步,呼叫 android_get_LD_LIBRARY_PATH 方法來讀取屬性值。(進入這個步驟有個條件是定義了 HAVE_ANDROID_OS 巨集變數,我就不去找到底哪裡在什麼場景下會定義了,看命名我直接猜測 Android 系統就都有定義的了)

那麼,繼續看看 android_get_LD_LIBRARY_PATH 這個方法做了些什麼:

//platform/libcore/luni/src/main/native/java_lang_System.cpp
#if defined(HAVE_ANDROID_OS)
extern "C" void android_get_LD_LIBRARY_PATH(char*, size_t);
#endif
複製程式碼

emmm,看不懂,頭疼。那,直接全域性搜尋下這個方法名試試看吧,結果在另一個 cpp 中找到它的實現:

//platform/bionic/linker/dlfcn.cpp
void android_get_LD_LIBRARY_PATH(char* buffer, size_t buffer_size) {
  ScopedPthreadMutexLocker locker(&g_dl_mutex);
  do_android_get_LD_LIBRARY_PATH(buffer, buffer_size);
}
複製程式碼

第一行估計是加鎖之類的意思吧,不管,第二行是呼叫另一個方法,繼續跟下去看看:

//platform/bionic/linker/linker.cpp
void do_android_get_LD_LIBRARY_PATH(char* buffer, size_t buffer_size) {
  //...
  char* end = stpcpy(buffer, kDefaultLdPaths[0]);
  *end = ':';
  strcpy(end + 1, kDefaultLdPaths[1]);
}

static const char* const kDefaultLdPaths[] = {
#if defined(__LP64__)
  "/vendor/lib64",
  "/system/lib64",
#else
  "/vendor/lib",
  "/system/lib",
#endif
  nullptr
};
複製程式碼

還好 Source Insight 點選方法時有時可以支援直接跳轉過去,呼叫的這個方法又是在另一個 cpp 檔案中了。開頭省略了一些大小空間校驗的程式碼,然後直接複製了靜態常量的值,而這個靜態常量在這份檔案頂部定義。

終於跟到底了吧,也就是說,如果有定義了 __LP64__ 這個巨集變數,那麼就將 java.library.path 屬性值賦值為 "/vendor/lib64:/system/lib64",否則,就賦值為 "/vendor/lib:/system/lib"。

也就是說,so 檔案的目錄地址其實是在 native 層通過硬編碼方式寫死的,網上那些理所當然的說 so 檔案的存放目錄也就是這四個,是這麼來的。那麼,說白了,系統預設存放 so 檔案的目錄就兩個,只是有兩種場景。

而至於到底什麼場景下會有這個 __LP64__ 巨集變數的定義,什麼時候沒有,我實在沒能力繼續跟蹤下去了,網上搜尋了一些資料後,仍舊不是很懂,如果有清楚的大佬,能夠告知、指點下就最棒了。

我自己看了些資料,以及,自己也做個測試:同一個 app,修改它的 primaryCpuAbi 值,呼叫 System 的 getProperty() 來讀取 java.library.path,它返回的值是會不同的。

所以,以我目前的能力以及所掌握的知識,我是這麼猜測的,純屬個人猜測:

__LP64__ 這個巨集變數並不是由安卓系統程式碼來定義的,而是 Linux 系統層面所定義的。在 Linux 系統中,可執行檔案,也可以說所執行的程式,如果是 32 位的,那麼是沒有定義這個巨集變數的,如果是 64 位的,那麼是有定義這個巨集變數的。

總之,通俗的聯想解釋,__LP64__ 這個巨集變數表示著當前程式是 32 位還是 64 位的意思。(個人理解)

有時間再繼續研究吧,反正這裡清楚了,系統預設存放 so 檔案的目錄只有兩個,但有兩種場景。vendor 較少用,就不每次都打出來了。也就是說,如果應用在 system/lib 目錄中沒有找到 so 檔案,那麼它是不會再自動去 system/lib64 中尋找的,兩者它只會選其一。至於選擇哪個,因為 Zygote 是有分 32 位還是 64 位程式的,那麼剛好可以根據這個為依據。

findLibrary

該走回主線了,在支線中的探索已經摸索了些經驗了。

大夥應該還記得吧,System 呼叫了 loadlibrary() 之後,內部其實是呼叫了 Runtime 的 loadLibrary() 方法,這個方法內部會去呼叫 ClassLoader 的 findLibrary() 方法,主要是去尋找這個 so 檔案是否存在,如果存在,會返回 so 檔案的絕對路徑,接著交由 Runtime 的 doLoad() 方法去載入 so 檔案。

所以,我們想要梳理清楚 so 檔案的載入流程,findLibrary() 是關鍵。那麼,接下去,就來跟著 findLibrary() 走下去看看吧:

//ClassLoader#findLibrary()
protected String findLibrary(String libName) {
    return null;
}
複製程式碼

ClassLoader 只是一個基類,具體實現在其子類,那這裡具體執行的是哪個子類呢?

//System#loadlibrary()
public static void loadLibrary(String libName) {
    Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader());
}
複製程式碼

所以這裡是呼叫了 VMStack 的一個方法來獲取 ClassLoader 物件,那麼繼續跟進看看:

native public static ClassLoader getCallingClassLoader();
複製程式碼

又是一個 native 的方法,我嘗試過跟進去,沒有看懂。那麼,換個方向來找出這個基類的具體實現子類是哪個吧,很簡單的一個方法,打 log 輸出這個物件本身:

ClassLoader classLoader = getClassLoader();
Log.v(TAG, "classLoader = " + classLoader.toString());
//輸出
// classLoader = dalvik.system.PathClassLoader[dexPath=/data/app/com.qrcode.qrcode-1.apk,libraryPath=/data/app-lib/com.qrcode.qrcode-1]
複製程式碼

以上打 Log 程式碼是從 Java中System.loadLibrary() 的執行過程 這篇文章中擷取出來的,使用這個方法的前提是你得清楚 VMStack 的 getCallingClassLoader() 含義其實是獲取呼叫這個方法的類它的類載入器物件。

或者,你對 Android 的類載入機制有所瞭解,知道當啟動某個 app 時,經過層層工作後,會接著讓 LoadedApk 去載入這個 app 的 apk,然後通過 ApplicationLoader 來載入相關程式碼檔案,而這個類內部是例項化了一個 PathClassLoader 物件去進行 dex 的載入。

不管哪種方式,總之清楚了這裡實際上是呼叫了 PathClassLoader 的 findLibrary() 方法,但 PathClassLoader 內部並沒有這個方法,它繼承自 BaseDexClassLoader,所以實際上還是呼叫了父類的方法,跟進去看看:

//platform/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
public String findLibrary(String name) {
    return pathList.findLibrary(name);
}

private final DexPathList pathList;
複製程式碼

內部又呼叫了 DexPathList 的 findLibrary() 方法,繼續跟進看看:

//platform/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java
public String findLibrary(String libraryName) {
    //1. 拼接字首:lib,和字尾:.so
    String fileName = System.mapLibraryName(libraryName);
    //2. 遍歷所有存放 so 檔案的目錄,確認指定檔案是否存在以及是隻讀檔案
    for (File directory: nativeLibraryDirectories) {
        String path = new File(directory, fileName).getPath();
        if (IoUtils.canOpenReadOnly(path)) {
            return path;
        }
    }
    return null;
}

/** List of native library directories. */
private final File[] nativeLibraryDirectories;
複製程式碼

到了這裡,會先進行檔名補全操作,拼接上字首:lib 和字尾:.so,然後遍歷所有存放 so 檔案的目錄,當找到指定檔案,且是隻讀屬性,則返回該 so 檔案的絕對路徑。

所以,重點就是 nativeLibraryDirectories 這個變數了,這裡存放著 so 檔案儲存的目錄路徑,那麼得看看它在哪裡被賦值了:

//platform/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java
public DexPathList(ClassLoader definingContext, String dexPath, String libraryPath, File optimizedDirectory) {
    //...
    //1. 唯一賦值的地方,建構函式
    this.nativeLibraryDirectories = splitLibraryPath(libraryPath);
}

private static File[] splitLibraryPath(String path) {
    // Native libraries may exist in both the system and
    // application library paths, and we use this search order:
    //
    //   1. this class loader's library path for application libraries
    //   2. the VM's library path from the system property for system libraries
    //   (翻譯下,大體是說,so 檔案的來源有兩處:1是應用自身存放 so 檔案的目錄,2是系統指定的目錄)
    // This order was reversed prior to Gingerbread; see http://b/2933456.
    ArrayList < File > result = splitPaths(path, System.getProperty("java.library.path"), true);
    return result.toArray(new File[result.size()]);
}

//將傳入的兩個引數的目錄地址解析完都存放到集合中
private static ArrayList < File > splitPaths(String path1, String path2, boolean wantDirectories) {
    ArrayList < File > result = new ArrayList < File > ();
	
    splitAndAdd(path1, wantDirectories, result);
    splitAndAdd(path2, wantDirectories, result);
    return result;
}

private static void splitAndAdd(String searchPath, boolean directoriesOnly, ArrayList < File > resultList) {
    if (searchPath == null) {
        return;
    }
    //因為獲取系統的 java.library.path 屬性值返回的路徑是通過 : 拼接的,所以先拆分,然後判斷這些目錄是否可用 
    for (String path: searchPath.split(":")) {
        try {
            StructStat sb = Libcore.os.stat(path);
            if (!directoriesOnly || S_ISDIR(sb.st_mode)) {
                resultList.add(new File(path));
            }
        } catch(ErrnoException ignored) {}
    }
}
複製程式碼

所以,nativeLibraryDirectories 這個變數是在建構函式中被賦值。程式碼不多,總結一下,建構函式會傳入一個 libraryPath 引數,表示應用自身存放 so 檔案的路徑,然後內部會再去呼叫 System 的 getProperty("java.library.path") 方法獲取系統指定的 so 檔案目錄地址。最後,將這些路徑都新增到集合中。

而且,看新增的順序,是先新增應用自身的 so 檔案目錄,然後再新增系統指定的 so 檔案目錄,也就是說,當載入 so 檔案時,是先去應用自身的 so 檔案目錄地址尋找,沒有找到,才會去系統指定的目錄。

而系統指定的目錄地址在 native 層的 linker.cpp 檔案定義,分兩種場景,取決於應用當前的程式是 32 位還是 64 位,32 位的話,則按順序分別去 vendor/lib 和 system/lib 目錄中尋找,64 位則是相對應的 lib64 目錄中。

雖然,so 檔案載入流程大體清楚了,但還有兩個疑問點:

  • 構造方法引數傳入的表示應用自身存放 so 檔案目錄的 libraryPath 值是哪裡來的;
  • 應用什麼時候執行在 32 位或 64 位的程式上;

nativeLibraryDir

先看第一個疑問點,應用自身存放 so 檔案目錄的這個值,要追究的話,這是一個很漫長的故事。

這個過程,我不打算全部都貼程式碼了,因為很多步驟,我自己也沒有去看原始碼,也是看的別人的文章,我們以倒著追蹤的方式來進行追溯吧。

首先,這個 libraryPath 值是通過 DexPathList 的構造方法傳入的,而 BaseDexClassLoader 內部的 DexPathList 物件例項化的地方也是在它自己的構造方法中,同樣,它也接收一個 libraryPath 引數值,所以 BaseDexClassLoader 只是做轉發,來源並不在它這裡。

那麼,再往回走,就是 LoadedApk 例項化 PathClassLoader 物件的地方了,在它的 getClassLoader() 方法中:

//platform/frameworks/base/core/java/android/app/LoadedApk.java
public ClassLoader getClassLoader() {
    synchronized(this) {
        //...
        final ArrayList < String > libPaths = new ArrayList < >();
        //...
        libPaths.add(mLibDir);
		//...
        final String lib = TextUtils.join(File.pathSeparator, libPaths);
		//...
        mClassLoader = ApplicationLoaders.getDefault().getClassLoader(zip, lib, mBaseClassLoader);
		//...
    }
}

public LoadedApk(ActivityThread activityThread, ApplicationInfo aInfo, CompatibilityInfo compatInfo, ClassLoader baseLoader, boolean securityViolation, boolean includeCode, boolean registerPackage) {
   //...
    mLibDir = aInfo.nativeLibraryDir;
   //...
}
複製程式碼

無關程式碼都省略掉了,也就是說,傳給 DexPathList 的 libraryPath 值,其實是將要啟動的這個 app 的 ApplicationInfo 中的 nativeLibraryDir 變數值。

可以看看 ApplicationInfo 中這個變數的註釋:

//ApplicationInfo 
/**
* Full path to the directory where native JNI libraries are stored.
* 存放 so 檔案的絕對路徑
*/
public String nativeLibraryDir;
複製程式碼

通俗點解釋也就是,存放應用自身 so 檔案的目錄的絕對路徑。那麼問題又來了,傳給 LoadedApk 的這個 ApplicationInfo 物件哪裡來的呢?

這個就又涉及到應用的啟動流程了,大概講一下:

我們知道,當要啟動其他應用時,其實是通過傳送一個 Intent 去啟動這個 app 的 LAUNCHER 標誌的 Activity。而當這個 Intent 傳送出去後,是通過 Binder 通訊方式通知了 ActivityManagerServer 去啟動這個 Activity。

AMS 在這個過程中會做很多事,但在所有事之前,它得先解析 Intent,知道要啟動的是哪個 app 才能繼續接下去的工作,這個工作在 ActivityStackSupervisor 的 resolveActivity()

//ActivityStackSupervisor.java
ActivityInfo resolveActivity(Intent intent, String resolvedType, int startFlags, ProfilerInfo profilerInfo, int userId) {
    // Collect information about the target of the Intent.
    ActivityInfo aInfo;
    try {
        ResolveInfo rInfo = AppGlobals.getPackageManager().resolveIntent(intent, resolvedType, PackageManager.MATCH_DEFAULT_ONLY | ActivityManagerService.STOCK_PM_FLAGS, userId);
        aInfo = rInfo != null ? rInfo.activityInfo: null;
    } catch(RemoteException e) {
        aInfo = null;
    }
    //...
}
複製程式碼

不同版本,可能不是由這個類負責這個工作了,但可以跟著 ActivityManagerService 的 startActivity() 走下去看看,不用跟很深就能找到,因為這個工作是比較早進行的。

所以,解析 Intent 獲取 app 的相關資訊就又交給 PackageManagerService 的 resolveIntent() 進行了,PKMS 的工作不貼了,我直接說了吧:

PKMS 會根據 Intent 中目標元件的 packageName,通過一個只有包許可權的類 Settings 來獲取對應的 ApplicationInfo 資訊,這個 Settings 類全名:com.android.server.pm.Settings,它的職責之一是儲存所有 app 的基本資訊,也就是在 data/system/packages.xml 中各 app 的資訊都交由它維護快取。

所以,一個 app 的 ApplicationInfo 資訊,包括 nativeLibraryDir 我們都可以在 data/system/packages.xml 這份檔案中檢視到。這份檔案的角色我把它理解成類似 PC 端上的登錄檔,所有 app 的資訊都註冊在這裡。

那這份 packages.xml 檔案的資料又是從哪裡來的呢,這又得涉及到 apk 的安裝機制過程了。

簡單說一下,一個 app 的安裝過程,在解析 apk 包過程中,還會結合各種裝置因素等等來決定這個 app 的各種屬性,比如說 nativeLibraryDir 這個屬性值的確認,就需要考慮這個 app 是三方應用還是系統應用,這個應用的 primaryCpuAbi 屬性值是什麼,apk 檔案的地址等等因素後,最後才確定了應用存放 so 檔案的目錄地址是哪裡。

舉個例子,對於系統應用來說,這個 nativeLibraryDir 值有可能最後是 /system/lib/xxx,也有可能是 system/app/xxx/lib 等等;而對於三方應用來說,這值有可能就是 data/app/xxx/lib;

也就是說,當 app 安裝完成時,這些屬性值也就都解析到了,就都會儲存到 Settings 中,同時會將這些資訊寫入到 data/system/packages.xml 中。

到這裡,先來小結一下,梳理下前面的內容:

當一個 app 安裝的時候,系統會經過各種因素考量,最後確認 app 的一個 nativeLibraryDir 屬性值,這個屬性值代表應用自身的 so 檔案存放地址,這個值也可以在 data/system/packages.xml 中檢視。

當應用呼叫了 System 的 loadlibrary() 時,這個 so 檔案的載入流程如下:

  1. 先到 nativeLibraryDir 指向的目錄地址中尋找這個 so 檔案是否存在、可用;
  2. 如果沒找到,那麼根據應用程式是 32 位或者 64 位來決定下去應該去哪個目錄尋找 so 檔案;
  3. 如果是 32 位,則先去 vendor/lib 找,最後再去 system/lib 中尋找;
  4. 如果是 64 位,則先去 vendor/lib64 找,最後再去 system/lib64 中尋找;
  5. 系統預設的目錄是在 native 層中的 linker.cpp 檔案中指定,更嚴謹的說法,不是程式是不是 32 位或 64 位,而是是否有定義了 __LP64__ 這個巨集變數。

primaryCpuAbi

我們已經清楚了,載入 so 檔案的流程,其實就分兩步,先去應用自身存放 so 檔案的目錄(nativeLibraryDir)尋找,找不到,再去系統指定的目錄中尋找。

而系統指定是目錄分兩種場景,應用程式是 32 位或者 64 位,那麼,怎麼知道應用是執行在 32 位還是 64 位的呢?又或者說,以什麼為依據來決定一個應用是應該跑在 32 位上還是跑在 64 位上?

這個就取決於一個重要的屬性了 primaryCpuAbi,它代表著這個應用的 so 檔案使用的是哪個 abi 架構。

abi 常見的如:arm64-v8a,armeabi-v7a,armeabi,mips,x86_64 等等。

我們在打包 apk 時,如果不指定,其實預設是會將所有 abi 對應的 so 檔案都打包一份,而通常,為了減少 apk 包體積,我們在 build.gradle 指令碼中會指定只打其中一兩份。但不管 apk 包有多少種不同的 abi 的 so 檔案,在 app 安裝過程中,最終拷貝到 nativeLibraryDir 中的通常都只有一份,除非你手動指定了要多份。

那麼,app 在安裝過程中,怎麼知道,應該拷貝 apk 中的 lib 下的哪一份 so 檔案呢?這就是由應用的 primaryCpuAbi 屬性決定。

而同樣,這個屬性一樣是在 app 安裝過程中確定的,這個過程更加複雜,末尾有給了篇連結,感興趣可以去看看,大概來說,就是 apk 包中的 so 檔案、系統應用、相同 UID 的應用、裝置的 abilist 等都對這個屬性值的確定過程有所影響。同樣,這個屬性值也可以在 data/system/packages.xml 中檢視。

那麼,這個 primaryCpuAbi 屬性值是如何影響應用程式是 32 位還是 64 位的呢?

這就涉及到 Zygote 方面的知識了。

在系統啟動之後,系統會根據裝置的 ro.zygote 屬性值決定啟動哪個 Zygote,可以通過執行 getprop | grep ro.zygote 來檢視這個屬性值,屬性值與對應的 Zygote 程式關係如下:

  • zygote32:只啟動一個 32 位的 Zygote 程式
  • zygote32_64:啟動兩個 Zygote 程式,分別為 32 位和 64 位,32 位的程式名為 zygote,表示以它為主,64 位程式名為 zygote_secondary ,表示它作為輔助
  • zygote64:只啟動一個 64 位的 Zygote 程式
  • zygote64_32:啟動兩個 Zygote 程式,分別為 32 位和 64 位,64 位的程式名為 zygote,表示以它為主,32 位程式名為 zygote_secondary ,表示它作為輔助

而 Zygote 程式啟動之後,會開啟一個 socket 埠,等待 AMS 發訊息過來啟動新的應用時 fork 當前 Zygote 程式,所以,如果 AMS 是發給 64 位的 Zygote,那麼新的應用自然就是跑在 64 位的程式上;同理,如果發給了 32 位的 Zygote 程式,那麼 fork 出來的程式自然也就是 32 位的。

那麼,可以跟隨著 AMS 的 startProcessLocked() 方法,去看看是以什麼為依據選擇 32 位或 64 位的 Zygote:

//ActivityManagerService
private final void startProcessLocked(ProcessRecord app, String hostingType, String hostingNameStr, String abiOverride, String entryPoint, String[] entryPointArgs) {
	//...省略
    //1. 獲取要啟動的 app 的 primaryCpuAbi 屬性值,abiOverride 不知道是什麼,可能是 Google 開發人員寫測試用例用的吧,或者其他一些場景
    String requiredAbi = (abiOverride != null) ? abiOverride: app.info.primaryCpuAbi;
    if (requiredAbi == null) {
        //2. 如果為空,以裝置支援的首個 abi 屬性值,可執行 getprot ro.product.cpu.abilist 檢視
        requiredAbi = Build.SUPPORTED_ABIS[0];
    }
    //...
	
    //3. 呼叫Precess 的 start 方法,將 requiredAbi 傳入
    Process.ProcessStartResult startResult = Process.start(entryPoint, app.processName, uid, uid, gids, debugFlags, mountExternal, app.info.targetSdkVersion, app.info.seinfo, requiredAbi, instructionSet, app.info.dataDir, entryPointArgs);
	//...
}
複製程式碼

AMS 會先獲取要啟動的 app 的 primaryCpuAbi 屬性值,至於這個 app 的相關資訊怎麼來的,跟上一小節一樣,解析 Intent 時交由 PKMS 去它模組內部的 Settings 讀取的。

如果 primaryCpuAbi 為空,則以裝置支援的首個 abi 屬性值為主,裝置支援的 abi 列表可以通過執行 getprot ro.product.cpu.abilist 檢視,最後呼叫 Precess 的 start() 方法,將讀取的 abi 值傳入:

//Process
public static final ProcessStartResult start(final String processClass, final String niceName, int uid, int gid, int[] gids, int debugFlags, int mountExternal, int targetSdkVersion, String seInfo, String abi, String instructionSet, String appDataDir, String[] zygoteArgs) {
	//...
    return startViaZygote(processClass, niceName, uid, gid, gids, debugFlags, mountExternal, targetSdkVersion, seInfo, abi, instructionSet, appDataDir, zygoteArgs);
	//...
}

private static ProcessStartResult startViaZygote(final String processClass, final String niceName, final int uid, final int gid, final int[] gids, int debugFlags, int mountExternal, int targetSdkVersion, String seInfo, String abi, String instructionSet, String appDataDir, String[] extraArgs) throws ZygoteStartFailedEx {
	//...
    //所以 abi 最終是呼叫 openZygoteSocketIfNeeded() 方法,傳入給它使用
    return zygoteSendArgsAndGetResult(openZygoteSocketIfNeeded(abi), argsForZygote);
}
複製程式碼

abi 值又是一層傳一層,最終交到了 Process 的 openZygoteSocketIfNeeded() 方法中使用,跟進看看:

//Process
private static ZygoteState openZygoteSocketIfNeeded(String abi) throws ZygoteStartFailedEx {
    if (primaryZygoteState == null || primaryZygoteState.isClosed()) {
        try {
            //ZYGOTE_SOCKET值為 zygote,
            //通過 ZygoteState 的 connect 方法,連線程式名為 zygote 的 Zygote 程式
            primaryZygoteState = ZygoteState.connect(ZYGOTE_SOCKET);
        } catch(IOException ioe) {
            throw new ZygoteStartFailedEx("Error connecting to primary zygote", ioe);
        }
    }
	//在程式名為 zygote 的 Zygote 程式支援的 abi 列表中,檢視是否支援要啟動的 app 的需要的 abi
    if (primaryZygoteState.matches(abi)) {
        return primaryZygoteState;
    }

    // The primary zygote didn't match. Try the secondary.
    if (secondaryZygoteState == null || secondaryZygoteState.isClosed()) {
        try {
             //SECONDARY_ZYGOTE_SOCKET 的值為 zygote_secondary,
            //通過 ZygoteState 的 connect 方法,連線程式名為 zygote_secondary 的 Zygote 程式
            secondaryZygoteState = ZygoteState.connect(SECONDARY_ZYGOTE_SOCKET);
        } catch(IOException ioe) {
            throw new ZygoteStartFailedEx("Error connecting to secondary zygote", ioe);
        }
    }
	//在程式名為 zygote_secondary 的 Zygote 程式支援的 abi 列表中,檢視是否支援要啟動的 app 的需要的 abi
    if (secondaryZygoteState.matches(abi)) {
        return secondaryZygoteState;
    }

    throw new ZygoteStartFailedEx("Unsupported zygote ABI: " + abi);
}

static ZygoteState primaryZygoteState;
static ZygoteState secondaryZygoteState;
public static final String ZYGOTE_SOCKET = "zygote";
public static final String SECONDARY_ZYGOTE_SOCKET = "zygote_secondary";
複製程式碼

到了這裡,是先獲取程式名 zygote 的 Zygote 程式,檢視它支援的 abi 列表中是否滿足要啟動的 app 所需的 abi,如果滿足,則使用這個 Zygote 來 fork 新程式,否則,獲取另一個程式名為 zygote_secondary 的 Zygote 程式,同樣檢視它支援的 abi 列表中是否滿足 app 所需的 abi,如果都不滿足,拋異常。

那麼,名為 zygote 和 zygote_secondary 分別對應的是哪個 Zygote 程式呢?哪個對應 32 位,哪個對應 64 位?

還記得上述說過的,系統啟動後,會去根據裝置的 ro.zygote 屬性決定啟動哪個 Zygote 程式嗎?對應關係就是這個屬性值決定的,舉個例子,可以看看 zygote64_32 對應的 Zygote 啟動配置檔案:

//platform/system/core/rootdir/init.zygote64_32.rc
service zygote /system/bin/app_process64 -Xzygote /system/bin --zygote --start-system-server --socket-name=zygote
    class main
    socket zygote stream 660 root system
    onrestart write /sys/android_power/request_state wake
    onrestart write /sys/power/state on
    onrestart restart media
    onrestart restart netd

service zygote_secondary /system/bin/app_process32 -Xzygote /system/bin --zygote --socket-name=zygote_secondary
    class main
    socket zygote_secondary stream 660 root system
    onrestart restart zygote
複製程式碼

這份程式碼前半段的意思就表示,讓 Linux 啟動一個 service,程式名為 zygote,可執行檔案位於 /system/bin/app_process64,後面是引數以及其他命令。

所以,名為 zygote 和 zygote_secondary 分別對應的是哪個 Zygote 程式,就取決於裝置的 ro.zygote 屬性。

而,獲取 Zygote 支援的 abi 列表是通過 ZygoteState 的 connect() 方法,我們繼續跟進看看:

//Process$ZygoteState
public static ZygoteState connect(String socketAddress) throws IOException {
    //...

    String abiListString = getAbiList(zygoteWriter, zygoteInputStream);
    Log.i("Zygote", "Process: zygote socket opened, supported ABIS: " + abiListString);

    return new ZygoteState(zygoteSocket, zygoteInputStream, zygoteWriter, Arrays.asList(abiListString.split(",")));
}
複製程式碼

發現沒有,原始碼內部將 Zygote 支援的 abi 列表輸出日誌了,你們可以自己嘗試下,過濾下 TAG 為 Zygote,然後重啟下裝置,因為如果本來就連著 Zygote,那麼是不會走到這裡的了,最後看一下相關日誌,如:

01-01 08:00:13.509 2818-2818/? D/AndroidRuntime: >>>>>> START com.android.internal.os.ZygoteInit uid 0 <<<<<<
01-01 08:00:15.068 2818-2818/? D/Zygote: begin preload
01-01 08:00:15.081 2818-3096/? I/Zygote: Preloading classes...
01-01 08:00:15.409 2818-3097/? I/Zygote: Preloading resources...
01-01 08:00:16.637 2818-3097/? I/Zygote: ...preloaded 343 resources in 1228ms.
01-01 08:00:16.669 2818-3097/? I/Zygote: ...preloaded 41 resources in 33ms.
01-01 08:00:17.242 2818-3096/? I/Zygote: ...preloaded 3005 classes in 2161ms.
01-01 08:00:17.373 2818-2818/? I/Zygote: Preloading shared libraries...
01-01 08:00:17.389 2818-2818/? D/Zygote: end preload
01-01 08:00:17.492 2818-2818/? I/Zygote: System server process 3102 has been created
01-01 08:00:17.495 2818-2818/? I/Zygote: Accepting command socket connections
01-01 08:00:32.789 3102-3121/? I/Zygote: Process: zygote socket opened, supported ABIS: armeabi-v7a,armeabi
複製程式碼

系統啟動後,Zygote 工作的相關內容基本都打日誌出來了。

最後,再來稍微理一理:

app 安裝過程,會確定 app 的一個屬性值:primaryCpuAbi,它代表著這個應用的 so 檔案使用的是哪個 abi 架構,而且它的確定過程很複雜,apk 包中的 so 檔案、系統應用、相同 UID 的應用、裝置的 abilist 等都對這個屬性值的確定過程有所影響。安裝成功後,可以在 data/system/packages.xml 中檢視這個屬性值。

每啟動一個新的應用,都是執行在新的程式中,而新的程式是從 Zygote 程式 fork 過來的,系統在啟動時,會根據裝置的 ro.zygote 屬性值決定啟動哪幾個 Zygote 程式,然後開啟 socket,等待 AMS 傳送訊息來 fork 新程式。

當系統要啟動一個新的應用時,AMS 在負責這個工作進行到 Process 類的工作時,會先嚐試在程式名為 zygote 的 Zygote 程式中,檢視它所支援的 abi 列表中是否滿足要啟動的 app 所需的 abi,如果滿足,則以這個 Zygote 為主,fork 新程式,執行在 32 位還是 64 位就跟這個 Zygote 程式一致,而 Zygote 執行在幾位上取決於 ro.zygote 對應的檔案,如值為 zygote64_32 時,對應著 init.zygote64_32.rc 這份檔案,那麼此時名為 zygote 的 Zygote 就是執行在 64 位上的。

而當上述所找的 Zygote 支援的 abi 列表不滿足 app 所需的 abi 時,那麼再去名為 zygote_secondary 的 Zygote 程式中看看,它所支援的 abi 列表是否滿足。

另外,Zygote 的相關工作流程,包括支援的 abi 列表,系統都有列印相關日誌,可過濾 Zygote 檢視,如沒發現,可重啟裝置檢視。

abi 相容

so 檔案載入的流程,及應用執行在 32 位或 64 位的依據我們都梳理完了,以上內容足夠掌握什麼場景下,該去哪些目錄下載入 so 檔案的判斷能力了。

那麼,還有個問題,如果應用執行在 64 位上,那麼此時,它是否能夠使用 armeabi-v7a 的 so 檔案?

首先,先來羅列一下常見的 abi :

  • arm64-v8a,armeabi-v7a,armeabi,mips,mips64,x86,x86_64

其中,執行在 64 位的 Zygote 程式上的是:

  • arm64-v8a,mips64,x86_64

同樣,執行在 32 位的 Zygote 程式上的是:

  • armeabi-v7a,armeabi,mips,x86

你們如果去網上搜尋如下關鍵字:so 檔案,abi 相容等,你們會發現,蠻多文章裡都會說:arm64-v8a 的裝置能夠向下相容,支援執行 32 位的 so 檔案,如 armeabi-v7a。

這句話沒錯,64 位的裝置能夠相容執行 32 位的 so 檔案,但別隻看到這句話啊,良心一些的文章裡還有另一句話:不同 cpu 架構的 so 檔案不能夠混合使用,例如,程式執行期間,要麼全部使用 arm64-v8a 的 so 檔案,要麼全部使用 armeabi-v7a 的 so 檔案,你不能跑在 64 位程式上,卻使用著 32 位的 so 檔案。

我所理解的相容,並不是說,64 位的裝置,支援你執行在 64 位的 Zygote 程式上時仍舊可以使用 32 位的 so 檔案。有些文章裡也說了,如果在 64 位的裝置上,你選擇使用 32 位的 so 檔案,那麼此時,你就丟失了專門為 64 位優化過的效能(ART,webview,media等等 )。這個意思就是說,程式啟動時是從 32 位的 Zygote 程式 fork 過來的,等於你在 64 位的裝置上,但卻只執行在 32 位的程式上。

至於程式如何決定執行在 32 位還是 64 位,上面的章節中也分析過了,以 app 的 primaryCpuAbi 屬性值為主,而這個屬性值的確定因素之一就是含有的 so 檔案所屬的 abi。

如果,你還想自己驗證,那麼可以跟著 Runtime 的 doLoad() 方法跟到 native 層去看看,由於我下載的原始碼版本可能有些問題,我沒找到 Runtime 對應的 cpp 檔案,但我找到這麼段程式碼:

//platform/bionic/linker/linker_phdr.cpp
bool ElfReader::VerifyElfHeader() {
  //...
  //1.讀取 elf 檔案的 header 的 class 資訊
  int elf_class = header_.e_ident[EI_CLASS];
#if defined(__LP64__)
  //2. 如果當前程式是64位的,而 elf 檔案屬於 32 位的,則報錯
  if (elf_class != ELFCLASS64) {
    if (elf_class == ELFCLASS32) {
      DL_ERR("\"%s\" is 32-bit instead of 64-bit", name_);
    } else {
      DL_ERR("\"%s\" has unknown ELF class: %d", name_, elf_class);
    }
    return false;
  }
#else
    //3. 如果當前程式是32位的,而 elf 檔案屬於 64 位的,則報錯
  if (elf_class != ELFCLASS32) {
    if (elf_class == ELFCLASS64) {
      DL_ERR("\"%s\" is 64-bit instead of 32-bit", name_);
    } else {
      DL_ERR("\"%s\" has unknown ELF class: %d", name_, elf_class);
    }
    return false;
  }
#endif
複製程式碼

載入 so 檔案,最終還是交由 native 層去載入,在 Linux 中,so 檔案其實就是一個 elf 檔案,elf 檔案有個 header 頭部資訊,裡面記錄著這份檔案的一些資訊,如所屬的是 32 位還是 64 位,abi 的資訊等等。

而 native 層在載入 so 檔案之前,會去解析這個 header 資訊,當發現,如果當前程式執行在 64 位時,但要載入的 so 檔案卻是 32 位的,就會報 xxx is 32-bit instead of 64-bit 異常,同樣,如果當前程式是執行在 32 位的,但 so 檔案卻是 64 位的,此時報 xxx is 64-bit instead of 32-bit 異常。

這個異常應該也有碰見過吧:

java.lang.UnsatisfiedLinkError: dlopen failed: "libimagepipeline.so" is 32-bit instead of 64-bit
複製程式碼

所以說,64 位裝置的相容,並不是說,允許你執行在 64 位的程式上時,仍舊可以使用 32 位的 so 檔案。它的相容是說,允許你在 64 位的裝置上執行 32 位的程式。

其實,想想也能明白,這就是為什麼三方應用安裝的時候,並不會將 apk 包中所有 abi 目錄下的 so 檔案都解壓出來,只會解壓一種,因為應用在安裝過程中,系統已經確定你這個應用是應該執行在 64 位還是 32 位的程式上了,並將這個結果儲存在 app 的 primaryCpuAbi 屬性值中。

既然系統已經明確你的應用所執行的程式是 32 位還是 64 位,那麼只需拷貝對應的一份 so 檔案即可,畢竟 64 位的 so 檔案和 32 位的又不能混合使用。

以上,是我的理解,如果有誤,歡迎指點下。

總結

整篇梳理下來,雖然梳理 so 的載入流程不難,但要掌握知其所以然的程度,就需要多花費一點心思了。

畢竟都涉及到應用的安裝機制,應用啟動流程,系統啟動機制,Zygote 相關的知識點了。如果你是開發系統應用的,建議還是花時間整篇看一下,畢竟系統應用的整合不像三方應用那樣在 apk 安裝期間自動將相關 so 檔案解壓到 nativeLibraryDirectories 路徑下了。三方應用很少需要了解 so 的載入流程,但開發系統應用還是清楚點比較好。

不管怎麼說,有時間,可以稍微跟著過一下整篇,相信多少是會有些收穫的,如果發現哪裡有誤,也歡迎指點。沒時間的話,那就看看總結吧。

  • 一個應用在安裝過程中,系統會經過一系列複雜的邏輯確定兩個跟 so 檔案載入相關的 app 屬性值:nativeLibraryDirectories ,primaryCpuAbi ;
  • nativeLibraryDirectories 表示應用自身存放 so 檔案的目錄地址,影響著 so 檔案的載入流程;
  • primaryCpuAbi 表示應用應該執行在哪種 abi 上,如(armeabi-v7a),它影響著應用是執行在 32 位還是 64 位的程式上,進而影響到尋找系統指定的 so 檔案目錄的流程;
  • 以上兩個屬性,在應用安裝結束後,可在 data/system/packages.xml 中檢視;
  • 當呼叫 System 的 loadLibrary() 載入 so 檔案時,流程如下:
  • 先到 nativeLibraryDirectories 指向的目錄中尋找,是否存在且可用的 so 檔案,有則直接載入這裡的 so 檔案;
  • 上一步沒找到的話,則根據當前程式如果是 32 位的,那麼依次去 vendor/lib 和 system/lib 目錄中尋找;
  • 同樣,如果當前程式是 64 位的,那麼依次去 vendor/lib64 和 system/lib64 目錄中尋找;
  • 當前應用是執行在 32 位還是 64 位的程式上,取決於系統的 ro.zygote 屬性和應用的 primaryCpuAbi 屬性值,系統的 ro.zygote 可通過執行 getprop 命令檢視;
  • 如果 ro.zygote 屬性為 zygote64_32,那麼應用啟動時,會先在 ro.product.cpu.abilist64 列表中尋找是否支援 primaryCpuAbi 屬性,有,則該應用執行在 64 位的程式上;
  • 如果上一步不支援,那麼會在 ro.product.cpu.abilist32 列表中尋找是否支援 primaryCpuAbi 屬性,有,則該應用執行在 32 位的程式上;
  • 如果 ro.zygote 屬性為 zygote32_64,則上述兩個步驟互換;
  • 如果應用的 primaryCpuAbi 屬性為空,那麼以 ro.product.cpu.abilist 列表中第一個 abi 值作為應用的 primaryCpuAbi;
  • 執行在 64 位的 abi 有:arm64-v8a,mips64,x86_64
  • 執行在 32 位的 abi 有:armeabi-v7a,armeabi,mips,x86
  • 通常支援 arm64-v8a 的 64 位裝置,都會向下相容支援 32 位的 abi 執行;
  • 但應用執行期間,不能混合著使用不同 abi 的 so 檔案;
  • 比如,當應用執行在 64 位程式中時,無法使用 32 位 abi 的 so 檔案,同樣,應用執行在 32 位程式中時,也無法使用 64 位 abi 的 so 檔案;

參考資料

1.Android -- 系統程式Zygote的啟動分析

2.Android應用程式程式啟動過程(前篇)

3.如何查詢native方法

4.Android中app程式ABI確定過程

5.Android 64 bit SO載入機制


大家好,我是 dasu,歡迎關注我的公眾號(dasuAndroidTv),如果你覺得本篇內容有幫助到你,可以轉載但記得要關注,要標明原文哦,謝謝支援~

dasuAndroidTv2.png

相關文章