態(tài)庫(kù)符號(hào)可見(jiàn)性:同名類實(shí)現(xiàn)串門(mén)的定位與根治)
先講一次讓我印象深刻的排查經(jīng)歷。內(nèi)部某個(gè)工具在測(cè)試環(huán)境跑了近兩周都好好的突然某天開(kāi)始頻繁崩潰backtrace里來(lái)來(lái)回回都是同一段邏輯兩個(gè)獨(dú)立模塊各自封裝了一份字符串處理和配置類類名都叫ConfigManager頭文件幾乎相同但內(nèi)部實(shí)現(xiàn)是兩份不一樣的。結(jié)果幾乎所有調(diào)用都走進(jìn)了先加載的那個(gè)實(shí)現(xiàn)讀取配置時(shí)偶爾拿到A模塊的路徑校驗(yàn)時(shí)卻走B模塊的邏輯最后內(nèi)存被按不同布局讀寫(xiě)直接掛掉。一開(kāi)始我懷疑是內(nèi)存踩踏排查到懷疑人生后來(lái)用LD_DEBUG看了一眼符號(hào)綁定才確認(rèn)根子正是Linux動(dòng)態(tài)庫(kù)的符號(hào)默認(rèn)全局可見(jiàn)同名類實(shí)現(xiàn)之間發(fā)生了“串門(mén)”。這類問(wèn)題如果沒(méi)抓住“符號(hào)可見(jiàn)性”這個(gè)點(diǎn)排查起來(lái)很容易繞遠(yuǎn)路。這篇文章就把這個(gè)問(wèn)題的原理、定位手段和根治方案完整講一遍適合維護(hù)大型C/C項(xiàng)目的同學(xué)也適合剛接觸Linux動(dòng)態(tài)庫(kù)、被插件系統(tǒng)里莫名其妙行為搞到頭禿的人。1. 為什么Linux下會(huì)“只認(rèn)一個(gè)實(shí)現(xiàn)”1.1 現(xiàn)場(chǎng)還原兩個(gè)同名類各寫(xiě)各的假設(shè)主程序同時(shí)加載了libA.so和libB.so兩個(gè)動(dòng)態(tài)庫(kù)都定義了ConfigManager這個(gè)類頭文件聲明幾乎一致內(nèi)部實(shí)現(xiàn)卻是兩套獨(dú)立演進(jìn)出來(lái)的東西。庫(kù)A里的實(shí)現(xiàn)走的是舊配置格式字段是8字節(jié)對(duì)齊庫(kù)B里的實(shí)現(xiàn)用新格式字段多了兩個(gè)成員整體結(jié)構(gòu)體變大了一圈。正常情況下每個(gè).so內(nèi)部調(diào)用自己的ConfigManager井水不犯河水。但Linux的動(dòng)態(tài)鏈接器不這么想。它在進(jìn)程全局維護(hù)一張動(dòng)態(tài)符號(hào)解析表遇到同名符號(hào)時(shí)往往采取“先到先得”的策略誰(shuí)先被加載誰(shuí)的定義就作為全局統(tǒng)一符號(hào)。后面庫(kù)里的引用會(huì)被解析到前面那個(gè)庫(kù)的實(shí)現(xiàn)上。結(jié)果就是libB編譯時(shí)按自己頭文件生成的代碼在運(yùn)行時(shí)卻調(diào)用了libA的對(duì)象布局和函數(shù)體而libA的對(duì)象實(shí)際又被libB的代碼按新偏移寫(xiě)字段。這種ABI不一致一旦發(fā)生表現(xiàn)就是離奇的崩潰、間歇性的數(shù)據(jù)錯(cuò)亂以及“明明代碼邏輯沒(méi)問(wèn)題卻總在某些環(huán)境下掛”。1.2 源頭在ELF動(dòng)態(tài)符號(hào)表默認(rèn)全部可見(jiàn)要理解這個(gè)問(wèn)題得先回到ELF的機(jī)制。GCC和Clang在編譯動(dòng)態(tài)庫(kù)時(shí)默認(rèn)會(huì)把所有非static的全局函數(shù)和全局變量都放進(jìn)動(dòng)態(tài)符號(hào)表.dynsym。也就是說(shuō)只要一個(gè)函數(shù)不是寫(xiě)在文件內(nèi)部的static函數(shù)它就會(huì)被子進(jìn)程看到并且可以被其他模塊接管或覆蓋這是Linux與Windows非常大的一個(gè)差異。Windows上寫(xiě)DLL時(shí)需要顯式用__declspec(dllexport)導(dǎo)出不寫(xiě)就不導(dǎo)出默認(rèn)是隱藏的。Linux剛好反過(guò)來(lái)默認(rèn)全導(dǎo)出只有主動(dòng)隱藏才不導(dǎo)出。這種設(shè)計(jì)在早期 Unix 生態(tài)里有一定的歷史原因允許不同模塊自由共享符號(hào)實(shí)現(xiàn)類似全局插樁的效果。但代價(jià)就是當(dāng)多個(gè)模塊各自攜帶同名類實(shí)現(xiàn)時(shí)會(huì)發(fā)生符號(hào)介入進(jìn)程里最終只保留一個(gè)全局有效的定義。正因?yàn)長(zhǎng)inux和Windows的默認(rèn)行為完全相反很多跨平臺(tái)經(jīng)驗(yàn)比較豐富的團(tuán)隊(duì)在遷移到Linux時(shí)反而更容易踩坑在Windows上從來(lái)沒(méi)有這類問(wèn)題因?yàn)闆](méi)主動(dòng)導(dǎo)出就不會(huì)被外面看到到Linux一看動(dòng)態(tài)符號(hào)表里躺著一堆內(nèi)部實(shí)現(xiàn)符號(hào)沖突是必然的。1.3 類實(shí)現(xiàn)錯(cuò)亂的底層鏈路具體到C場(chǎng)景問(wèn)題鏈路通常是這樣的每個(gè).so里都有自己的類實(shí)現(xiàn)包括成員函數(shù)、虛函數(shù)表vtable和typeinfo符號(hào)這些符號(hào)都被放進(jìn)了動(dòng)態(tài)符號(hào)表也就是全局可見(jiàn)范圍內(nèi)。主程序加載庫(kù)時(shí)動(dòng)態(tài)鏈接器在全局范圍內(nèi)做符號(hào)解析。遇到多個(gè)相同符號(hào)名就按一定順序選一個(gè)作為最終綁定目標(biāo)。庫(kù)內(nèi)部代碼里對(duì)該類方法的調(diào)用發(fā)現(xiàn)全局已經(jīng)有了綁定目標(biāo)就不再綁定自己內(nèi)部那份而是跳到選中的那個(gè)實(shí)現(xiàn)。因?yàn)檎{(diào)用方編譯時(shí)用的是自己的頭文件布局與被調(diào)用的實(shí)現(xiàn)版本不一致ABI就崩了。這里面還有一個(gè)容易被忽略的細(xì)節(jié)vtable符號(hào)和typeinfo符號(hào)也是全局符號(hào)同樣會(huì)被介入。結(jié)果就是dynamic_cast、異常處理、甚至簡(jiǎn)單的虛函數(shù)調(diào)用都可能走錯(cuò)門(mén)比普通函數(shù)沖突更隱蔽。2. 先實(shí)錘三個(gè)命令把“串門(mén)”釘死2.1 第一步用nm和readelf看動(dòng)態(tài)符號(hào)表要確認(rèn)是不是符號(hào)沖突先看每個(gè).so都導(dǎo)出了什么。最直接的就是用nm只看動(dòng)態(tài)符號(hào)nm -D libA.so | cfilt | grep -i ConfigManager nm -D libB.so | cfilt | grep -i ConfigManager大寫(xiě)T表示全局可見(jiàn)的text段代碼符號(hào)D是已初始化數(shù)據(jù)B是bss段R是只讀數(shù)據(jù)。只要兩個(gè)庫(kù)都導(dǎo)出了同名符號(hào)就能看到重復(fù)的類名出現(xiàn)。如果覺(jué)得nm輸出太簡(jiǎn)單可以用readelf看更完整的符號(hào)表信息readelf -Ws libA.so | grep ConfigManagerreadelf -Ws輸出的內(nèi)容會(huì)包含符號(hào)所在的段、大小、綁定屬性能更清楚看到哪些是GLOBAL、哪些是LOCAL。如果兩個(gè)庫(kù)里都出現(xiàn)GLOBAL DEFAULT的同名符號(hào)那就要警惕了。這里還有個(gè)更高效的組合命令可以一次性找出兩個(gè)動(dòng)態(tài)庫(kù)動(dòng)態(tài)符號(hào)的重復(fù)交集comm -12 \ (nm -D --defined-only libA.so | awk {print $3} | sort -u) \ (nm -D --defined-only libB.so | awk {print $3} | sort -u)輸出里出現(xiàn)的每一個(gè)符號(hào)名都是潛在的沖突源。這個(gè)命令我強(qiáng)烈建議放到腳本里作為每個(gè)版本發(fā)布前的常規(guī)檢查。2.2 第二步用LD_DEBUG讓鏈接器自己承認(rèn)查看符號(hào)表只能說(shuō)明“有這個(gè)可能”要實(shí)錘“到底綁到了誰(shuí)”最好的辦法是讓動(dòng)態(tài)鏈接器打印綁定過(guò)程LD_DEBUGbindings ./demo_app 21 | grep -i ConfigManager輸出里會(huì)出現(xiàn)類似這樣的信息binding file libB.so [0x00...] to libA.so [0x00...]: ConfigManager::getConfig() normal symbol注意看binding file這一行的方向libB.so里某個(gè)符號(hào)綁定到了libA.so。這一條信息基本就是鐵證直接說(shuō)明了“串門(mén)”發(fā)生的位置。還可以配合files模式看加載順序LD_DEBUGfiles ./demo_app 21 | grep so確認(rèn)到底誰(shuí)先被加載以及先到先得的順序關(guān)系。2.3 第三步崩潰棧交叉驗(yàn)證如果現(xiàn)場(chǎng)已經(jīng)有core dump或gdb棧同樣可以做交叉驗(yàn)證。用addr2line把崩潰地址換算成源碼位置addr2line -f -C -e libB.so 0x0000xxxx正常情況下棧幀應(yīng)該落在libB自己的源碼里。如果地址換算出來(lái)指向libA的源碼或者gdb里info symbol顯示符號(hào)屬于另一個(gè)庫(kù)那問(wèn)題就實(shí)錘了。我在實(shí)際排查中還發(fā)現(xiàn)一個(gè)規(guī)律這類崩潰的棧幀符號(hào)名通常會(huì)“看起來(lái)特別正?!?。比如類名對(duì)、方法名對(duì)、行號(hào)也有但行為完全不對(duì)。如果遇到這種“??粗鴽](méi)啥問(wèn)題卻崩潰”的詭異現(xiàn)場(chǎng)先別急著懷疑編譯器優(yōu)化或者內(nèi)存踩踏先花兩分鐘查一下符號(hào)綁定往往能省掉幾天時(shí)間。3. “錯(cuò)亂”的三種典型形態(tài)3.1 形態(tài)一虛函數(shù)表混亂同名類各自帶有虛函數(shù)時(shí)vtable符號(hào)也是全局可見(jiàn)的。兩個(gè)庫(kù)的vtable都進(jìn)了全局符號(hào)表最終保留一份可能是A的也可能是B的。運(yùn)行時(shí)通過(guò)基類指針調(diào)虛函數(shù)實(shí)際跳轉(zhuǎn)到的可能是另一個(gè)版本的函數(shù)體。這種場(chǎng)景最迷惑人的地方在于代碼調(diào)用關(guān)系本身沒(méi)變但實(shí)際執(zhí)行的邏輯是錯(cuò)的版本而且還不一定崩潰可能只是行為偏差。比如A版本里對(duì)輸入做了二次校驗(yàn)B版本沒(méi)做。用戶數(shù)據(jù)從B模塊入口進(jìn)來(lái)經(jīng)過(guò)另一個(gè)庫(kù)的虛表間接調(diào)用時(shí)可能部分走了A的校驗(yàn)邏輯部分走了B的邏輯最終的數(shù)據(jù)處理結(jié)果處在一個(gè)“兩頭都不靠”的狀態(tài)。3.2 形態(tài)二對(duì)象布局不一致導(dǎo)致臟寫(xiě)這是最危險(xiǎn)的一種。假設(shè)A版本的Record結(jié)構(gòu)是12字節(jié)B版本因?yàn)榧恿俗侄巫兂?0字節(jié)。B模塊的函數(shù)按新版本20字節(jié)的偏移寫(xiě)第16字節(jié)處的字段但這個(gè)對(duì)象實(shí)際是A模塊按12字節(jié)分配出來(lái)的對(duì)象后面的4字節(jié)屬于別的數(shù)據(jù)。這種臟寫(xiě)不會(huì)馬上崩潰可能過(guò)很久才在完全不相干的地方爆出來(lái)。我之前遇到的一個(gè)典型案例就是配置結(jié)構(gòu)體在庫(kù)A按舊版本分配庫(kù)B按新版本寫(xiě)入結(jié)果每次運(yùn)行到特定分支時(shí)堆上的相鄰對(duì)象被改寫(xiě)表現(xiàn)為偶發(fā)的“配置讀取結(jié)果被神秘篡改”。想要快速排查這類問(wèn)題可以把兩個(gè)版本的類各自做一個(gè)sizeof輸出動(dòng)態(tài)打印出來(lái)對(duì)比。如果發(fā)現(xiàn)同一個(gè)類名在不同庫(kù)里的sizeof不一致就要立刻檢查符號(hào)綁定方向。3.3 形態(tài)三靜態(tài)狀態(tài)與單例漂移類的靜態(tài)成員變量、內(nèi)部static局部變量同樣存在多份副本的情況。兩個(gè)庫(kù)各有一份靜態(tài)狀態(tài)但全局符號(hào)解析會(huì)決定進(jìn)程實(shí)際使用哪一份。最典型的癥狀是日志開(kāi)關(guān)改了一個(gè)庫(kù)的靜態(tài)變量另一個(gè)庫(kù)卻完全不理會(huì)或者計(jì)數(shù)器本應(yīng)在A模塊里自增結(jié)果讀到的是B模塊那份初始值。還有inline函數(shù)里的靜態(tài)局部變量在C17以前的多庫(kù)場(chǎng)景里很容易出現(xiàn)副本分裂。如果這個(gè)inline函數(shù)所在的符號(hào)被全局可見(jiàn)地導(dǎo)出了多個(gè)庫(kù)綁定到不同副本時(shí)狀態(tài)就徹底分裂了。這類問(wèn)題編碼階段幾乎無(wú)法避免必須靠符號(hào)可見(jiàn)性策略做系統(tǒng)性防護(hù)。4. 根治方案符號(hào)隱藏、版本腳本、架構(gòu)收斂4.1 最基礎(chǔ)的一步編譯期統(tǒng)一隱藏符號(hào)我推薦的第一道防線就是在編譯動(dòng)態(tài)庫(kù)時(shí)統(tǒng)一加上-fvisibilityhidden參數(shù)g -fvisibilityhidden -shared -o libB.so b.cpp這個(gè)參數(shù)告訴編譯器默認(rèn)情況下所有符號(hào)都設(shè)為隱藏只有顯式標(biāo)記了visibility(default)的符號(hào)才能進(jìn)入動(dòng)態(tài)符號(hào)表。配合的常見(jiàn)做法是在公共頭文件里定義一個(gè)導(dǎo)出宏#if defined(_WIN32) #define API_EXPORT __declspec(dllexport) #else #define API_EXPORT __attribute__((visibility(default))) #endif class API_EXPORT ConfigManager { public: ConfigManager(); std::string getConfig(const std::string key); };這樣類的公共接口被顯式導(dǎo)出類內(nèi)部的輔助函數(shù)、私有方法、模板實(shí)例化細(xì)節(jié)默認(rèn)全部隱藏動(dòng)態(tài)符號(hào)表里只剩下一張面很干凈的導(dǎo)出面。我特別強(qiáng)調(diào)一點(diǎn)這個(gè)改動(dòng)需要覆蓋倉(cāng)庫(kù)里所有模塊不能只改出問(wèn)題的那個(gè)。一個(gè)庫(kù)隱藏了另一個(gè)沒(méi)隱藏沖突照樣存在。最好把編譯參數(shù)寫(xiě)進(jìn)公共構(gòu)建預(yù)設(shè)文件讓所有模塊默認(rèn)繼承。也可以加上-Werrorvismismatch在編譯階段就攔截聲明和定義可見(jiàn)性不一致的問(wèn)題。4.2 更嚴(yán)格的做法用版本腳本做最終閘門(mén)-fvisibilityhidden已經(jīng)足夠解決大部分沖突但它還是會(huì)讓所有“顯式標(biāo)記了default”的符號(hào)進(jìn)入動(dòng)態(tài)符號(hào)表。如果想把導(dǎo)出面收斂到極致可以用鏈接器版本腳本來(lái)把關(guān)先寫(xiě)一個(gè)exports.map{ global: api_*; ConfigManager; _ZN7network*; local: *; };然后鏈接時(shí)加上g -shared -fPIC -Wl,--version-scriptexports.map -o libB.so b.cpp版本腳本的效果比編譯屬性更徹底它會(huì)明確告訴鏈接器除了global段列出的符號(hào)名其他一律不導(dǎo)出相當(dāng)于給動(dòng)態(tài)庫(kù)的對(duì)外接口畫(huà)了一條紅線。這里要特別提醒C的使用者global段里如果要寫(xiě)類名或命名空間需要用nm -D --defined-only看一下mangled后的符號(hào)名再?zèng)Q定寫(xiě)哪一層通配。比如命名空間network下的類可能需要寫(xiě)成_ZN7network*的形式而不是直接寫(xiě)network。用版本腳本的另一個(gè)好處是可以做符號(hào)版本化symbol versioning。同一個(gè)函數(shù)在不同版本里可以定義不同的導(dǎo)出名這屬于更大工程里接口兼容性的玩法但即便暫時(shí)用不上版本化單純用local: *;一條規(guī)則做“默認(rèn)關(guān)閉”已經(jīng)足夠值回票價(jià)。4.3 插件系統(tǒng)的隔離策略插件場(chǎng)景尤其容易踩雷主程序用dlopen加載兩個(gè)插件每個(gè)插件各自靜態(tài)鏈接了一份公共庫(kù)。如果加載插件時(shí)用了RTLD_GLOBAL插件里的全局符號(hào)會(huì)被合并進(jìn)全局符號(hào)表后加載的插件很容易就綁到前面插件的實(shí)現(xiàn)上。哪怕改成RTLD_LOCAL也只解決了一部分問(wèn)題因?yàn)椴寮?nèi)部如果引用同名符號(hào)動(dòng)態(tài)鏈接器依然可能跨插件解析。我現(xiàn)在的建議是三層配合主程序加載插件時(shí)統(tǒng)一使用RTLD_LOCAL不把插件的符號(hào)泄漏到全局。插件自身也開(kāi)啟-fvisibilityhidden插件對(duì)外只暴露約定好的幾個(gè)入口函數(shù)內(nèi)部符號(hào)全部隱藏。如果多個(gè)插件都需要同一個(gè)公共庫(kù)不要在各自插件里靜態(tài)鏈接一份而是單獨(dú)編譯成一個(gè)公共依賴.so讓所有插件都鏈接同一個(gè)文件。這套組合拳打下來(lái)插件之間基本就隔離開(kāi)了。公共依賴的動(dòng)態(tài)庫(kù)只認(rèn)一個(gè)版本不會(huì)再出現(xiàn)多份副本爭(zhēng)奪符號(hào)的情況。4.4 架構(gòu)層的長(zhǎng)期收斂符號(hào)隱藏和版本腳本解決的是“符號(hào)怎么導(dǎo)出”的問(wèn)題。但如果架構(gòu)上允許每個(gè)模塊各自內(nèi)置一份公共代碼那么沖突源會(huì)不斷出現(xiàn)。比較理想的收斂方式是把公共代碼拆到一個(gè)獨(dú)立的公共依賴庫(kù)所有模塊統(tǒng)一依賴這個(gè)庫(kù)而不是在各自源碼樹(shù)里拷貝一份實(shí)現(xiàn)。這里的“公共代碼”不單指自己寫(xiě)的工具類也包括可能被不同模塊以不同版本靜態(tài)鏈接的第三方庫(kù)。如果現(xiàn)狀做不到立刻拆分至少要在編譯參數(shù)上保證所有共用符號(hào)面一致的庫(kù)使用同一個(gè)版本并且所有共享庫(kù)都默認(rèn)隱藏符號(hào)。C的namespace其實(shí)也能起到一定的隔離效果但它的作用只是改變符號(hào)的mangled名稱ELF層面還是有同名的可能所以不要把namespace當(dāng)成唯一的防線。5. 實(shí)際踩坑記錄與細(xì)節(jié)補(bǔ)充5.1 開(kāi)啟隱藏后客戶端鏈接不到符號(hào)開(kāi)啟-fvisibilityhidden之后最常見(jiàn)的報(bào)錯(cuò)是鏈接階段出現(xiàn)“undefined reference to xxx”。這個(gè)原因通常是對(duì)外API的聲明和定義里漏掉了導(dǎo)入側(cè)或者導(dǎo)出側(cè)的標(biāo)記。比如頭文件里類聲明加了API_EXPORT但類內(nèi)某一個(gè)成員函數(shù)在實(shí)現(xiàn)時(shí)又單獨(dú)聲明了一次那個(gè)單獨(dú)的成員沒(méi)帶visibility(default)標(biāo)記導(dǎo)出時(shí)就把它隱藏了。解決辦法也很直白在公共導(dǎo)出宏上統(tǒng)一處理各個(gè)平臺(tái)到宏里做適配不要在單個(gè)函數(shù)上零散加attribute。同時(shí)可以打開(kāi)-v選項(xiàng)或者用nm -D --defined-only確認(rèn)一下編譯后的so到底導(dǎo)出了哪些符號(hào)與實(shí)際需要導(dǎo)出的接口做對(duì)比很快就能找出漏網(wǎng)之魚(yú)。5.2 版本腳本的local: *;導(dǎo)致后續(xù)擴(kuò)展失敗版本腳本使用local: *;后新加一個(gè)API但忘記更新腳本鏈接器會(huì)靜默地不導(dǎo)出它調(diào)用方在運(yùn)行時(shí)或鏈接時(shí)找不到這個(gè)符號(hào)。這類問(wèn)題因?yàn)椴粫?huì)報(bào)編譯錯(cuò)誤很容易漏檢。我的習(xí)慣是在構(gòu)建腳本里加一步符號(hào)清單校驗(yàn)每次構(gòu)建后用nm -D --defined-only生成當(dāng)前導(dǎo)出符號(hào)列表和版本腳本里聲明的api_*或類名做比對(duì)。一旦發(fā)現(xiàn)實(shí)際導(dǎo)出的符號(hào)有新增但沒(méi)有出現(xiàn)在允許名單里就觸發(fā)告警。這支腳本的成本很低但能有效避免“上線后才發(fā)現(xiàn)新接口沒(méi)導(dǎo)出”的尷尬局面。5.3 團(tuán)隊(duì)分支編譯參數(shù)不一致問(wèn)題只在部分機(jī)器出現(xiàn)還遇到過(guò)更隱蔽的情況同一個(gè)庫(kù)在A機(jī)器上編譯后運(yùn)行正常在B機(jī)器上就出現(xiàn)符號(hào)沖突。排查到最后發(fā)現(xiàn)兩邊機(jī)器用的構(gòu)建參數(shù)不一樣一個(gè)開(kāi)了-fvisibilityhidden另一個(gè)沒(méi)開(kāi)。這種“機(jī)器相關(guān)”的現(xiàn)象很容易讓人誤判為環(huán)境問(wèn)題實(shí)際上是編譯產(chǎn)物本身就不一致。比較好的做法是把可見(jiàn)性參數(shù)固化到項(xiàng)目的CMake預(yù)設(shè)或公共編譯配置里禁止各成員在自己的目錄里臨時(shí)改編譯選項(xiàng)。同時(shí)配套一個(gè)腳本掃描倉(cāng)庫(kù)里所有動(dòng)態(tài)庫(kù)的導(dǎo)出符號(hào)表凡是有大量非API符號(hào)泄漏的直接標(biāo)紅。5.4 動(dòng)態(tài)加載RTLD_GLOBAL讓沖突擴(kuò)大如果主程序用dlopen加載插件時(shí)習(xí)慣性地傳了RTLD_GLOBAL那么插件里的符號(hào)就會(huì)全部合并到全局符號(hào)表。兩個(gè)插件都靜態(tài)鏈接了同一個(gè)第三方庫(kù)的不同版本時(shí)第三方庫(kù)的內(nèi)部符號(hào)也會(huì)互相覆蓋。這種場(chǎng)景下即使插件內(nèi)部用了隱藏符號(hào)第三方庫(kù)如果是默認(rèn)可見(jiàn)編譯的沖突依然會(huì)存在。所以我在前面的插件方案里強(qiáng)調(diào)公共依賴一定要單獨(dú)編譯成.so并統(tǒng)一版本別讓插件各自攜帶一份靜態(tài)庫(kù)副本。這屬于架構(gòu)層面的取舍短期看要多維護(hù)一個(gè)庫(kù)長(zhǎng)期看能避免非常多符號(hào)糾纏的“鬼故事”。6. 問(wèn)題速查與自查清單6.1 快速判斷表典型癥狀常被誤判的方向確認(rèn)手段處理建議同名類成員函數(shù)行為錯(cuò)亂內(nèi)存踩踏、編譯器BugLD_DEBUGbindings查看綁定目標(biāo)同名庫(kù)統(tǒng)一版本隱藏符號(hào)結(jié)構(gòu)體字段被寫(xiě)入詭異值堆越界、邏輯Bug打印兩個(gè)庫(kù)中sizeof同名類對(duì)比修復(fù)布局版本銜接符號(hào)隱藏虛函數(shù)調(diào)用進(jìn)入非預(yù)期實(shí)現(xiàn)虛表?yè)p壞、驅(qū)動(dòng)問(wèn)題gdb info symbol查看符號(hào)歸屬vtable符號(hào)導(dǎo)出面收斂配置變更對(duì)某個(gè)模塊不生效緩存問(wèn)題、配置中心Bug檢查靜態(tài)變量符號(hào)是否漂移拆公共依賴統(tǒng)一單例來(lái)源插件各自帶一份公共庫(kù)后崩潰插件接口不兼容對(duì)比插件動(dòng)態(tài)符號(hào)交集公共庫(kù)獨(dú)立成.soRTLD_LOCAL6.2 符號(hào)自查清單發(fā)布前建議做這樣一輪檢查用nm -D --defined-only確認(rèn)動(dòng)態(tài)庫(kù)導(dǎo)出的符號(hào)只有預(yù)期API。用comm比較所有相關(guān)動(dòng)態(tài)庫(kù)的動(dòng)態(tài)符號(hào)交集排查重復(fù)定義。確認(rèn)構(gòu)建參數(shù)里-fvisibilityhidden已經(jīng)全局生效。檢查版本腳本允許名單與實(shí)際導(dǎo)出名單是否一致。插件場(chǎng)景下確認(rèn)沒(méi)有用RTLD_GLOBAL加載插件插件自身符號(hào)已隱藏。確認(rèn)公共第三方庫(kù)只以統(tǒng)一版本被所有模塊依賴。6.3 常用工具一行版最后整理一批我平時(shí)常敲的命令方便直接復(fù)用# 查看動(dòng)態(tài)庫(kù)實(shí)際導(dǎo)出的符號(hào) nm -D --defined-only libX.so | cfilt # 查看符號(hào)詳細(xì)信息 readelf -Ws libX.so | grep 符號(hào)名 # 對(duì)比兩個(gè)庫(kù)的重復(fù)導(dǎo)出符號(hào) comm -12 \ (nm -D --defined-only libA.so | awk {print $3} | sort -u) \ (nm -D --defined-only libB.so | awk {print $3} | sort -u) # 讓動(dòng)態(tài)鏈接器打印符號(hào)綁定過(guò)程 LD_DEBUGbindings ./demo_app 21 | grep 類名 # 將崩潰地址轉(zhuǎn)換為源碼位置 addr2line -f -C -e libX.so 0x4006a0我現(xiàn)在的習(xí)慣是給每個(gè)庫(kù)的構(gòu)建流程都會(huì)掛一個(gè)“導(dǎo)出面審計(jì)”的收尾步驟構(gòu)建完自動(dòng)跑一遍導(dǎo)出符號(hào)清單只要清單里有白名單之外的符號(hào)就立刻告警。這個(gè)做法比等人崩潰之后再回頭排查要省太多事了。符號(hào)可見(jiàn)性說(shuō)白了就是動(dòng)態(tài)庫(kù)的接口契約平時(shí)多花幾分鐘守住契約運(yùn)行時(shí)就能少經(jīng)歷幾次“類實(shí)現(xiàn)錯(cuò)亂”的午夜驚魂。