1. 程式人生 > >Linker載入so失敗問題分析

Linker載入so失敗問題分析

WeTest 導讀

近期測試反饋一個問題,在舊版本微視基礎上覆蓋安裝新版本的微視APP,首次開啟拍攝頁錄製視訊合成時高概率出現crash。

那麼我們直奔主題,看看日誌:

另外復現的日誌中還出現如下資訊:

'/data/data/com.tencent.weishi/appresArchiveExtra/res1bodydetect/bodydetect/libxnet.so: strtab out of bounds error

後經過測試,發現覆蓋安裝後首次使用美體功能也會出現crash,日誌如下:

由於出現問題的場景都是覆蓋安裝首次使用,並且涉及到人體檢測相關的so,似乎存在某種共同的原因。

因此Abort異常比起fault addr類問題更容易分析,先從前面Linker出現Abort異常的位置開始著手。

Linker是so連結和載入的關鍵,屬於系統可執行檔案,因此分析起來比較棘手。好在手上正好有一臺剛刷完自己編譯的Android AOSP的Pixel,做一些實驗變得更輕鬆了。

出現異常的Linker程式碼linker_soinfo.cpp如下:

const char* soinfo::get_string(ElfW(Word) index) const { if (has_min_version(1) && (index >= strtab_size_)) {   async_safe_fatal("%s: strtab out of bounds error; STRSZ=%zd, name=%d",       get_realpath(), strtab_size_, index); } return strtab_ + index;}bool soinfo::elf_lookup(SymbolName& symbol_name,                       const version_info* vi,                       uint32_t* symbol_index) const { uint32_t hash = symbol_name.elf_hash(); TRACE_TYPE(LOOKUP, "SEARCH %s in %
[email protected]
%p h=%x(elf) %zd",            symbol_name.get_name(), get_realpath(),            reinterpret_cast<void*>(base), hash, hash % nbucket_); ElfW(Versym) verneed = 0; if (!find_verdef_version_index(this, vi, &verneed)) {   return false; } for (uint32_t n = bucket_[hash % nbucket_]; n != 0; n = chain_[n]) {   ElfW(Sym)* s = symtab_ + n;   const ElfW(Versym)* verdef = get_versym(n);   // skip hidden versions when verneed == 0   if (verneed == kVersymNotNeeded && is_versym_hidden(verdef)) {       continue;   }   if (check_symbol_version(verneed, verdef) &&       strcmp(get_string(s->st_name), symbol_name.get_name()) == 0 &&       is_symbol_global_and_defined(this, s)) {     TRACE_TYPE(LOOKUP, "FOUND %s in %s (%p) %zd",                symbol_name.get_name(), get_realpath(),                reinterpret_cast<void*>(s->st_value),                static_cast<size_t>(s->st_size));     *symbol_index = n;     return true;   } } TRACE_TYPE(LOOKUP, "NOT FOUND %s in %
[email protected]
%p %x %zd",            symbol_name.get_name(), get_realpath(),            reinterpret_cast<void*>(base), hash, hash % nbucket_); *symbol_index = 0; return true;}

從程式碼上看,是在so的symtab中查詢某個符號時ElfW(Sym)* s的地址出現異常,導致s->st_name獲取到錯誤的資料。

通過復現問題,可以抓到更完整的 /data/tombstone日誌,得到如下完整的資訊:

儘管從tombstone中我們可以看到一些暫存器資料及寄存處地址附近記憶體資料,同時也可以看到crash時的虛擬記憶體對映表,仍然無法獲取有價值的資訊。另外通過幾次復現,發現並不是每次Crash都是SIGABRT,也出現不少SIGSEGV訊號,而呼叫棧和之前都是一樣的,比如這個:

這基本上可以說明,並不是so本身的程式碼存在異常,只可能是載入的so出現了檔案異常。

另外通過在linker中增加日誌,並重新編譯linker替換到/system/lib/linker中:

可以獲取到如下的地址資訊:

通過根據tombstone中的/proc/<poc>/maps的虛擬記憶體地址與日誌列印的地址進行對比,可以發現最為符號表地址的s並沒有指向so檔案在虛擬記憶體中的地址段,因此可以懷疑,so載入確實出現了異常。

因為手機root,可以直接獲取到crash時的so檔案(adb pull /data/data/com.tencent.weishi/appresArchiveExtra/res1bodydetect/bodydetect/libxnet.so),匯出來對比md5,然而發現與正常情況下的so是一模一樣的:

既然前面的這些實驗都沒有得出什麼有意義的結論,那麼我回過頭來分析一下,與問題關聯的so載入到底有什麼特殊性。

實際上,微視為了減包,將一部分so檔案進行下發,由於so也處於不斷迭代的過程中,新版本的微視可能會在後臺更新so檔案,那麼客戶端一旦發現新的版本有新的so,就會去下載so並進行本地替換。

那麼這個過程有什麼問題呢?唯一可能的問題,就是先載入了舊的so,之後下載新的so進行了熱更新。

我們先看下微視中是否有這種現象。要觀察這種現象,我們可以開啟linker自身的除錯開關,開啟so載入的日誌。通過設定系統屬性,我們可以很容易地進行開啟LD_LOG日誌:

adb shell setprop debug.ld.all dlerror,dlopen

當然我們也可以只針對某個應用開啟這個日誌(設定系統屬性debug.ld.app.)。另外,為了開啟linker中更多的日誌,比如DEBUG列印的資訊等,我們只需要在adb shell中設定環境變數:

export LD_DEBUG=10

那麼,我們重新復現問題,可以看到如下so載入過程:

這個過程表明:舊的so先被載入了,然後下載了新版本的so,並進行了替換。

這個過程有什麼問題呢?根據《理解inode》一文我們可以得知,linux的檔案系統使用的inode機制支援了so檔案的熱更新(動態更新),即每個檔案都有一個唯一的inode號,開啟檔案後使用inode號區分檔案而不是檔名:

八、inode的特殊作用

由於inode號碼與檔名分離,這種機制導致了一些Unix/Linux系統特有的現象。

1. 有時,檔名包含特殊字元,無法正常刪除。這時,直接刪除inode節點,就能起到刪除檔案的作用。

2. 移動檔案或重新命名檔案,只是改變檔名,不影響inode號碼。

3. 開啟一個檔案以後,系統就以inode號碼來識別這個檔案,不再考慮檔名。因此,通常來說,系統無法從inode號碼得知檔名。

第3點使得軟體更新變得簡單,可以在不關閉軟體的情況下進行更新,不需要重啟。因為系統通過inode號碼,識別執行中的檔案,不通過檔名。更新的時候,新版檔案以同樣的檔名,生成一個新的inode,不會影響到執行中的檔案。等到下一次執行這個軟體的時候,檔名就自動指向新版檔案,舊版檔案的inode則被回收。

但是問題就出在這裡,如果替換檔案使用的是cp這樣的操作,會導致原來的so檔案截斷,然後重新寫入資料,但是inode並沒有更新號,磁碟與記憶體中的資訊出現不一致,這種情況在linux中很常見,比如這篇文章就進行了分析:

1. cp new.so old.so,檔案的inode號沒有改變,dentry找到是新的so,但是cp過程中會把老的so截斷為0,這時程式再次進行載入的時候,如果需要的檔案偏移大於新的so的地址範圍會生成buserror導致程式core掉,或者由於全域性符號表沒有更新,動態庫依賴的外部函式無法解析,會產生sigsegv從而導致程式core掉,當然也有一定的可能性程式繼續執行,但是十分危險。

2. mv new.so old.so,檔案的inode號會發生改變,但老的so的inode號依舊存在,這時程式必須停止重啟服務才能繼續使用新的so,否則程式繼續執行,使用的還是老的so,所以程式不會core掉,就像我們在第二部分刪除掉log檔案,而依然能用lsof命令看到一樣。

還有更深入的解釋:

Linux由於Demand Paging機制的關係,必須確保正在執行中的程式映象(注意,並非檔案本身)不被意外修改,因此核心在啟動程式後會繫結 記憶體頁 到這個so的inode,而一旦此inode檔案被open函式O_TRUNC掉,則kernel會把so檔案對應在虛存的頁清空,這樣當執行到so裡面的程式碼時,因為實體記憶體中不再有實際的資料(僅存在於虛存空間內),會產生一次缺頁中斷。Kernel從so檔案中copy一份到記憶體中去,a)但是這時的全域性符號表並沒有經過解析,當呼叫到時就產生segment fault , b)如果需要的檔案偏移大於新的so的地址範圍,就會產生bus error。

那麼問題基本清晰了。我們在回去看看微視的程式碼,這裡下載了so之後直接unzip到原來的路徑,並沒有先進行rm操作。

更近一步,我們自己寫個demo測試下剛才的問題(2個按鈕,一個載入指定so,一個呼叫so中的native方法):

程式碼不能再簡單了:

正常載入so然後執行native方法都是ok的,使用rm+mv替換或者adb push替換也都是ok的,最後再按照錯誤的方法操作,步驟為:

1. 啟動app,點選載入so;

2. 通過cp命令替換so;

3. 點選執行native方法;

結果確實是crash了:

日誌如下,是不是很最開始的日誌資訊一樣呢:

到此,我們有兩種解決辦法:

1. 如果so有升級,先不載入舊的so,等新的so下載完成之後再載入;

2. 可以先載入舊的so,但是下載了新的so之後,要刪除舊的so,再進行替換。

目前,“自動化相容測試” 提供雲端自動化相容服務,提交雲端百臺真機,並行測試。快速發現遊戲/應用相容性和效能問題,覆蓋安卓主流機型。

如果使用當中有任何疑問,歡迎聯絡騰訊WeTest企業QQ:2852350015