文件與可執(zhí)行文件:從編譯鏈接到ELF結(jié)構(gòu)詳解)
1. 從一行編譯命令說(shuō)起目標(biāo)文件與可執(zhí)行文件到底在干什么很多人寫了好幾年代碼天天敲gcc main.c -o main但真被問到“.o文件和最終的可執(zhí)行文件到底差在哪”“鏈接器憑什么能把幾個(gè)零散的目標(biāo)文件拼成一個(gè)能跑的程序”往往就卡殼了。這篇內(nèi)容就是把這個(gè)鏈條徹底拆開講清楚目標(biāo)文件Object File是編譯器把單個(gè)源文件翻譯成機(jī)器碼后產(chǎn)出的中間產(chǎn)物可執(zhí)行文件Executable File是鏈接器把若干目標(biāo)文件加上庫(kù)、按規(guī)則拼裝并完成地址分配后的最終成品。搞懂這兩者的結(jié)構(gòu)差異、生成流程和內(nèi)部布局你才能明白為什么會(huì)出現(xiàn)“undefined reference”、為什么靜態(tài)庫(kù)順序會(huì)影響鏈接結(jié)果、為什么同樣的代碼換個(gè)編譯選項(xiàng)體積差好幾倍。這篇適合誰(shuí)看剛學(xué)完 C/C 語(yǔ)法、開始接觸多文件工程的新手能從里面拿到完整的實(shí)操路徑和可復(fù)現(xiàn)的命令已經(jīng)工作但一直把編譯鏈接當(dāng)黑盒的開發(fā)者能補(bǔ)齊“符號(hào)表、重定位、段布局”這些關(guān)鍵概念做嵌入式或者對(duì)二進(jìn)制體積敏感的人也能從段合并和鏈接腳本的角度找到優(yōu)化切入點(diǎn)。我不會(huì)只丟概念而是帶著你把一個(gè)多文件小工程從源碼一步步編譯、反匯編、查看段表、最后鏈接成可執(zhí)行文件每一步都告訴你“為什么這么做”和“這里容易踩什么坑”。先給一個(gè)最樸素的心智模型源文件是“原材料”目標(biāo)文件是“按圖紙加工好的零件”可執(zhí)行文件是“組裝完成、能通電運(yùn)轉(zhuǎn)的整機(jī)”。零件階段還不知道自己會(huì)被裝到整機(jī)的哪個(gè)位置所以里面留著一堆“待填的地址空位”這就是重定位的由來(lái)整機(jī)組裝時(shí)鏈接器負(fù)責(zé)把這些空位填上并決定每個(gè)零件最終擺在內(nèi)存的哪個(gè)偏移。理解了這個(gè)類比后面所有細(xì)節(jié)都是往這個(gè)骨架上掛肉。2. 目標(biāo)文件到底是什么ELF 結(jié)構(gòu)逐層拆解2.1 三種目標(biāo)文件形態(tài)與它們的區(qū)別嚴(yán)格來(lái)說(shuō)“目標(biāo)文件”是個(gè)統(tǒng)稱實(shí)際有三種形態(tài)很多人混著叫導(dǎo)致概念混亂。可重定位目標(biāo)文件relocatable object file通常.o是編譯器直接產(chǎn)物里面含代碼、數(shù)據(jù)、符號(hào)表和重定位信息地址還沒最終確定可執(zhí)行目標(biāo)文件executable是鏈接完成的成品所有地址已分配可以直接加載運(yùn)行共享目標(biāo)文件shared object.so是一種特殊的可重定位文件能在加載或運(yùn)行時(shí)被動(dòng)態(tài)鏈接。三者格式上是同源的Linux 下都是 ELFExecutable and Linkable Format只是頭部字段和段布局不同。我見過不少人以為.o就是“半個(gè)可執(zhí)行文件”其實(shí)更準(zhǔn)確的說(shuō)法是“一個(gè)自包含但地址未定的模塊”。它已經(jīng)是可以被機(jī)器執(zhí)行的機(jī)器碼了只是里面引用的外部符號(hào)比如調(diào)用了printf地址還是占位符直接拿去跑會(huì)崩。這個(gè)認(rèn)知很關(guān)鍵因?yàn)樗忉屃藶槭裁茨繕?biāo)文件不能直接執(zhí)行、為什么必須經(jīng)過鏈接。2.2 ELF 頭與段表先看“目錄”再看“內(nèi)容”拿到一個(gè).o文件第一件事不是反匯編而是看它的 ELF 頭和段表section header table。ELF 頭里記錄了文件類型ET_REL表示可重定位、ET_EXEC表示可執(zhí)行、ET_DYN表示共享對(duì)象、目標(biāo)架構(gòu)、入口地址、段表在文件中的偏移和條目數(shù)量。段表則是整個(gè)文件的“目錄”每一項(xiàng)描述一個(gè)段的名字、類型、在文件中的偏移、大小、對(duì)齊要求。用readelf -h看頭部用readelf -S看段表這是排查二進(jìn)制問題的起手式。我習(xí)慣先掃一眼段表里有沒有.text、.data、.bss、.rodata、.symtab、.rela.text這幾個(gè)關(guān)鍵段缺哪個(gè)基本就能定位問題方向。比如沒有.symtab說(shuō)明被 strip 過調(diào)試信息沒了.rela.text特別大說(shuō)明這個(gè)文件外部引用很多鏈接時(shí)依賴較重。2.3 代碼段、數(shù)據(jù)段、BSS 段的分工邏輯.text放機(jī)器指令通常只讀可執(zhí)行.data放已初始化的全局變量和靜態(tài)變量可讀寫.bss放未初始化或初始化為零的全局/靜態(tài)變量它在文件里不占實(shí)際空間只記錄大小加載時(shí)由系統(tǒng)清零。這個(gè)設(shè)計(jì)很巧妙如果一個(gè)程序聲明了一個(gè) 1MB 的全局?jǐn)?shù)組但不初始化.bss只記 1MB 這個(gè)數(shù)字文件不會(huì)真的大 1MB。我實(shí)測(cè)過一個(gè)聲明char buf[1048576];的源文件編譯出的.o只有幾 KB用size命令看.bss卻是 1MB 多這就是段設(shè)計(jì)的價(jià)值。.rodata放只讀數(shù)據(jù)比如字符串字面量、const全局變量。把字符串放只讀段有個(gè)實(shí)際好處多個(gè)進(jìn)程共享同一份只讀頁(yè)省內(nèi)存壞處是如果你試圖改字符串字面量會(huì)觸發(fā)段錯(cuò)誤這也是“為什么char *s abc; s[0]x;會(huì)崩”的底層原因。2.4 符號(hào)表與重定位表鏈接器的“施工圖紙”符號(hào)表.symtab記錄了這個(gè)目標(biāo)文件定義了什么符號(hào)函數(shù)名、全局變量名、引用了什么外部符號(hào)、每個(gè)符號(hào)在段內(nèi)的偏移和類型。重定位表.rela.text、.rela.data等則記錄了“代碼里哪些位置需要鏈接器來(lái)填地址”每條重定位項(xiàng)包含偏移、符號(hào)索引和重定位類型。這兩張表是鏈接能工作的根本。我打個(gè)比方符號(hào)表是“通訊錄”告訴你誰(shuí)在哪重定位表是“待辦清單”告訴你哪塊地址還空著、該填誰(shuí)的地址。鏈接器讀所有目標(biāo)文件的符號(hào)表建立全局符號(hào)表再逐條處理重定位表把空位填上。理解這一點(diǎn)你就能自己解釋“undefined reference to xxx”了——就是某個(gè)重定位項(xiàng)指向的符號(hào)在所有輸入文件里都找不到定義。3. 從源碼到可執(zhí)行文件完整實(shí)操鏈路3.1 四階段編譯流程與每步產(chǎn)物一個(gè) C 源文件到可執(zhí)行文件要經(jīng)過預(yù)處理、編譯、匯編、鏈接四個(gè)階段。預(yù)處理gcc -E展開宏、處理#include、條件編譯產(chǎn)出.i文件編譯gcc -S把預(yù)處理后的代碼翻譯成匯編產(chǎn)出.s匯編gcc -c把匯編翻譯成機(jī)器碼產(chǎn)出可重定位目標(biāo)文件.o鏈接gcc不帶-c把多個(gè).o和庫(kù)拼成可執(zhí)行文件。我建議新手把這四步拆開手動(dòng)跑一遍而不是一條命令到底。拆開跑你能清楚看到每一步的輸入輸出出問題時(shí)也知道該在哪一步排查。比如宏展開錯(cuò)了是預(yù)處理階段的事語(yǔ)法錯(cuò)誤在編譯階段報(bào)符號(hào)找不到是鏈接階段的事分清楚能省大量時(shí)間。# 預(yù)處理 gcc -E main.c -o main.i # 編譯成匯編 gcc -S main.i -o main.s # 匯編成目標(biāo)文件 gcc -c main.s -o main.o # 鏈接成可執(zhí)行文件 gcc main.o utils.o -o app3.2 一個(gè)多文件工程的完整復(fù)現(xiàn)我準(zhǔn)備了一個(gè)最小可復(fù)現(xiàn)的工程main.c調(diào)用utils.c里的函數(shù)utils.c里有個(gè)全局變量。這樣能同時(shí)演示函數(shù)符號(hào)和變量符號(hào)的鏈接過程。// utils.h #ifndef UTILS_H #define UTILS_H extern int g_counter; int add(int a, int b); #endif // utils.c #include utils.h int g_counter 10; int add(int a, int b) { g_counter; return a b; } // main.c #include stdio.h #include utils.h int main(void) { int r add(3, 4); printf(result%d counter%d\n, r, g_counter); return 0; }分別編譯成目標(biāo)文件gcc -c main.c -o main.o、gcc -c utils.c -o utils.o。此時(shí)用nm main.o會(huì)看到U add、U g_counter、U printfU表示未定義引用用nm utils.o會(huì)看到T add、D g_counterT表示代碼段符號(hào)、D表示已初始化數(shù)據(jù)段符號(hào)。這個(gè)對(duì)比非常直觀地展示了“誰(shuí)定義、誰(shuí)引用”。然后鏈接gcc main.o utils.o -o app。鏈接器把main.o里對(duì)add和g_counter的引用解析到utils.o的定義上對(duì)printf的引用解析到 C 標(biāo)準(zhǔn)庫(kù)。跑起來(lái)輸出result7 counter11說(shuō)明全局變量確實(shí)被修改并共享了。3.3 用工具“透視”目標(biāo)文件內(nèi)部光跑通不夠得會(huì)看內(nèi)部。我常用的工具組合是file看文件類型size看各段大小nm看符號(hào)objdump -d反匯編看指令readelf -r看重定位項(xiàng)objdump -s -j .rodata看只讀段內(nèi)容。這套組合基本能覆蓋日常排查。拿main.o舉例objdump -d main.o反匯編后你會(huì)看到調(diào)用add的那條指令后面跟著一個(gè)占位地址通常是call到當(dāng)前指令的下一條附近偏移為 0旁邊注釋寫著R_X86_64_PLT32 add-0x4之類的重定位提示。這就是“地址還沒填”的直接證據(jù)。鏈接成app后再反匯編那條call就變成了真實(shí)地址。親眼看到這個(gè)變化比重定位概念本身更有說(shuō)服力。3.4 鏈接順序?yàn)槭裁磿?huì)影響結(jié)果靜態(tài)庫(kù)鏈接有個(gè)經(jīng)典坑鏈接器從左到右掃描輸入遇到靜態(tài)庫(kù)時(shí)只提取“當(dāng)前已出現(xiàn)未定義符號(hào)”所需的成員。如果庫(kù) A 依賴庫(kù) B但你把 A 寫在 B 后面鏈接器掃到 A 時(shí)還沒看到 B 的符號(hào)需求掃到 B 時(shí) A 的需求已經(jīng)處理完了結(jié)果就是undefined reference。正確順序是被依賴的庫(kù)放后面比如gcc main.o -lA -lB。我踩過這個(gè)坑一個(gè)工程里-lfoo -lbar怎么都鏈接不過調(diào)換順序成-lbar -lfoo立刻通過。當(dāng)時(shí)不知道原理以為是玄學(xué)。后來(lái)明白鏈接器是單遍掃描、按需提取順序本質(zhì)上是“依賴關(guān)系必須從使用者指向被使用者”。這個(gè)經(jīng)驗(yàn)值一條鏈接報(bào)未定義符號(hào)時(shí)先懷疑庫(kù)順序再懷疑庫(kù)路徑最后才懷疑符號(hào)真不存在。4. 可執(zhí)行文件與目標(biāo)文件的關(guān)鍵差異4.1 段布局從“節(jié)”到“段”的合并目標(biāo)文件里是一堆細(xì)粒度的“節(jié)”section比如每個(gè)函數(shù)可能單獨(dú)成節(jié)開了-ffunction-sections時(shí)??蓤?zhí)行文件里這些節(jié)會(huì)被合并成粗粒度的“段”segment按權(quán)限分組代碼段只讀可執(zhí)行、數(shù)據(jù)段可讀寫、只讀數(shù)據(jù)段只讀。用readelf -l app看程序頭program header能看到這些段的映射關(guān)系用readelf -S app看節(jié)表能看到節(jié)被歸到了哪個(gè)段。這個(gè)合并的意義在于內(nèi)存頁(yè)權(quán)限管理。操作系統(tǒng)按頁(yè)設(shè)置權(quán)限代碼頁(yè)設(shè)只讀可執(zhí)行能防止程序意外改寫指令也防了一部分攻擊數(shù)據(jù)頁(yè)設(shè)可讀寫但不能執(zhí)行。目標(biāo)文件階段不需要這個(gè)因?yàn)樗€不打算被加載運(yùn)行。4.2 地址從“相對(duì)偏移”到“虛擬地址”目標(biāo)文件里符號(hào)地址都是段內(nèi)偏移比如add在.text段偏移 0x0。可執(zhí)行文件里所有地址都變成了虛擬地址空間里的絕對(duì)地址或者相對(duì)于加載基址的偏移取決于是否 PIE。鏈接器做地址分配時(shí)會(huì)按鏈接腳本默認(rèn)腳本可以用ld --verbose看把各段擺到虛擬地址空間的合適位置然后據(jù)此計(jì)算每個(gè)符號(hào)的最終地址再回填所有重定位項(xiàng)。這里有個(gè)容易忽略的點(diǎn)現(xiàn)代系統(tǒng)默認(rèn)生成 PIE位置無(wú)關(guān)可執(zhí)行文件地址是相對(duì)的加載時(shí)由動(dòng)態(tài)鏈接器加基址。如果你用-no-pie編譯地址就是固定的絕對(duì)地址。兩者在readelf -h里的類型字段不同ET_DYNvsET_EXEC排查某些老代碼的地址相關(guān) bug 時(shí)這個(gè)區(qū)別很關(guān)鍵。4.3 入口點(diǎn)與運(yùn)行時(shí)初始化可執(zhí)行文件頭里有個(gè)入口地址entry point但它通常不是你的main函數(shù)而是 C 運(yùn)行時(shí)庫(kù)提供的啟動(dòng)代碼如_start。這段代碼負(fù)責(zé)初始化棧、準(zhǔn)備argc/argv、調(diào)用全局構(gòu)造函數(shù)、最后才調(diào)mainmain返回后再負(fù)責(zé)清理和退出。用objdump -d app | grep -A20 _start能看到這段啟動(dòng)邏輯。理解這一點(diǎn)能解釋很多現(xiàn)象為什么全局對(duì)象的構(gòu)造函數(shù)在main之前執(zhí)行、為什么main的返回值會(huì)變成進(jìn)程退出碼、為什么在main之前打斷點(diǎn)會(huì)停在啟動(dòng)代碼里。這些都不是魔法是鏈接進(jìn)來(lái)的啟動(dòng)代碼在干活。4.4 動(dòng)態(tài)鏈接與依賴記錄可執(zhí)行文件如果用了動(dòng)態(tài)庫(kù)會(huì)記錄依賴的庫(kù)名和需要的符號(hào)運(yùn)行時(shí)由動(dòng)態(tài)鏈接器解析。用ldd app看依賴、用readelf -d app看動(dòng)態(tài)段里的NEEDED條目。目標(biāo)文件階段沒有這些因?yàn)樗€沒決定跟誰(shuí)鏈接。這里有個(gè)實(shí)操經(jīng)驗(yàn)ldd顯示“not found”不一定是庫(kù)沒裝可能是庫(kù)路徑?jīng)]配LD_LIBRARY_PATH或rpath沒設(shè)對(duì)。我習(xí)慣用readelf -d看RPATH/RUNPATH條目確認(rèn)程序自己聲明的搜索路徑比盲目改環(huán)境變量靠譜。5. 常見問題排查與避坑實(shí)錄5.1 未定義符號(hào)與重復(fù)定義的兩類報(bào)錯(cuò)鏈接期報(bào)錯(cuò)基本就兩大類undefined reference to xxx和multiple definition of xxx。前者是重定位項(xiàng)找不到定義排查順序是符號(hào)名拼寫對(duì)不對(duì)、定義所在的源文件有沒有參與編譯、庫(kù)順序?qū)Σ粚?duì)、C/C 混編有沒有加extern C。后者是同一個(gè)符號(hào)在多個(gè)目標(biāo)文件里都有強(qiáng)定義常見于頭文件里直接定義了全局變量應(yīng)該用extern聲明、在某個(gè).c里定義或者兩個(gè)庫(kù)都提供了同名符號(hào)。C/C 混編那個(gè)坑特別隱蔽C 編譯器會(huì)對(duì)函數(shù)名做名稱修飾name manglingC 代碼里定義的foo在 C 看來(lái)是_Z3foov之類自然找不到。解決辦法是在 C 側(cè)用extern C包住聲明。我用nm對(duì)比過 C 和 C 編譯出的符號(hào)名差異一目了然建議你也親手對(duì)比一次比記結(jié)論深刻。5.2 段錯(cuò)誤與只讀段寫入程序跑起來(lái)段錯(cuò)誤除了空指針一個(gè)高頻原因是往只讀段寫數(shù)據(jù)。字符串字面量在.rodataconst全局變量也在只讀段試圖修改就崩。還有一種情況是函數(shù)指針指向了數(shù)據(jù)段數(shù)據(jù)段不可執(zhí)行調(diào)用時(shí)觸發(fā)保護(hù)。排查這類問題用gdb跑起來(lái)看崩潰地址再用readelf -S對(duì)照該地址落在哪個(gè)段基本能定位。5.3 二進(jìn)制體積異常膨脹的排查路徑可執(zhí)行文件突然變大常見原因有幾個(gè)開了調(diào)試信息-g沒在發(fā)布時(shí)去掉、靜態(tài)鏈接了整個(gè)庫(kù)但只用了幾個(gè)函數(shù)、模板或內(nèi)聯(lián)導(dǎo)致代碼膨脹、.bss里的大數(shù)組被誤初始化成了非零值從.bss挪到了.data文件里真占空間。排查用size app看各段大小用nm --size-sort app按符號(hào)大小排序找“大戶”用objdump -h看各節(jié)占比。我遇到過一次.data異常大最后發(fā)現(xiàn)是一個(gè)全局?jǐn)?shù)組初始化成了{(lán)1}編譯器把它從.bss挪到了.data改回不初始化或初始化為零后文件立刻瘦身。5.4 常見問題速查表現(xiàn)象可能原因排查手段解決方向undefined reference符號(hào)未定義/庫(kù)順序錯(cuò)/未加 extern “C”nm查符號(hào)、調(diào)換庫(kù)順序補(bǔ)定義、調(diào)順序、加 extern “C”multiple definition頭文件定義變量/重復(fù)鏈接nm找重復(fù)符號(hào)改 extern 聲明、去重運(yùn)行時(shí)段錯(cuò)誤寫只讀段/空指針/棧溢出gdb看崩潰地址、readelf -S對(duì)照段改數(shù)據(jù)存放位置、修邏輯文件體積過大調(diào)試信息/靜態(tài)鏈接/誤初始化size、nm --size-sort去-g、改動(dòng)態(tài)鏈接、修正初始化庫(kù)找不到路徑未配/rpath 缺失readelf -d看 RUNPATH配路徑或設(shè) rpath5.5 幾條踩坑換來(lái)的實(shí)操心得第一條養(yǎng)成看nm和readelf的習(xí)慣。鏈接報(bào)錯(cuò)時(shí)別急著搜先自己用工具確認(rèn)符號(hào)到底在不在、在哪個(gè)文件、是什么類型十有八九能自己定位。第二條發(fā)布構(gòu)建一定要 strip。調(diào)試符號(hào)對(duì)開發(fā)有用對(duì)發(fā)布是純負(fù)擔(dān)strip app能顯著減小體積但記得保留一份帶符號(hào)的用于事后分析崩潰。第三條靜態(tài)庫(kù)順序不是玄學(xué)是規(guī)則理解單遍掃描按需提取就不會(huì)再被順序問題折磨。第四條改編譯選項(xiàng)后對(duì)比段大小-O2和-Os的產(chǎn)物差異、開不開-ffunction-sections的差異用size一對(duì)比就清楚比看文檔直觀。6. 進(jìn)階視角鏈接腳本與段布局控制6.1 默認(rèn)鏈接腳本長(zhǎng)什么樣鏈接器不是隨便擺段的它按鏈接腳本的規(guī)則來(lái)。ld --verbose能打印出默認(rèn)腳本里面定義了各段的起始地址、對(duì)齊、合并規(guī)則。比如.text通常從某個(gè)對(duì)齊地址開始.data跟在后面.bss再后面。嵌入式開發(fā)里經(jīng)常要自己寫鏈接腳本把代碼放到特定地址比如 Flash 起始地址、把變量放到特定 RAM 區(qū)域這時(shí)候?qū)Χ尾季值睦斫饩蛷摹爸馈弊兂伞氨仨殨?huì)用”。6.2 用段屬性做內(nèi)存布局優(yōu)化一個(gè)實(shí)用技巧把頻繁訪問的熱點(diǎn)數(shù)據(jù)集中放到一起提高緩存命中率把冷數(shù)據(jù)單獨(dú)放減少熱數(shù)據(jù)被換出的概率。GCC 支持__attribute__((section(name)))把變量或函數(shù)放到自定義段再配合鏈接腳本安排位置。這在性能敏感場(chǎng)景有用但別濫用段太多反而增加管理復(fù)雜度。我一般只在明確有性能瓶頸、且 profiling 證實(shí)是緩存問題時(shí)才動(dòng)這個(gè)。6.3 位置無(wú)關(guān)代碼與重定位類型PIE 和共享庫(kù)要求代碼位置無(wú)關(guān)編譯器生成的是相對(duì)尋址指令重定位類型也變成相對(duì)類型如R_X86_64_PC32、R_X86_64_PLT32。理解這些重定位類型的含義能幫你看懂鏈接器報(bào)的“relocation truncated to fit”之類錯(cuò)誤——通常是目標(biāo)地址超出指令編碼能表示的范圍需要換編譯選項(xiàng)如-mcmodellarge或調(diào)整布局。7. 把這條鏈路變成自己的調(diào)試能力我個(gè)人在實(shí)際操作中的體會(huì)是目標(biāo)文件和可執(zhí)行文件這塊知識(shí)看十遍文檔不如親手把一個(gè)多文件工程拆開編譯一遍、用工具把每個(gè)產(chǎn)物透視一遍。概念是抽象的但nm輸出的U和T、objdump里那條從占位到實(shí)址的call、size里.bss不占文件空間的事實(shí)都是具體可驗(yàn)證的。你驗(yàn)證過一次下次遇到鏈接報(bào)錯(cuò)、體積異常、運(yùn)行時(shí)崩潰腦子里會(huì)自動(dòng)浮現(xiàn)“該看哪個(gè)段、該查哪張表”。最后分享一個(gè)小技巧給自己建一個(gè)“二進(jìn)制排查工具箱”的筆記把file、size、nm、objdump、readelf、ldd、strip這幾個(gè)命令的常用參數(shù)和典型輸出記下來(lái)遇到問題按“先看類型和段、再看符號(hào)、最后看重定位和依賴”的順序過一遍大部分鏈接和加載問題都能在幾分鐘內(nèi)定位。這個(gè)內(nèi)容后續(xù)還可以往兩個(gè)方向擴(kuò)展一是深入動(dòng)態(tài)鏈接的符號(hào)解析和版本控制二是結(jié)合具體平臺(tái)的加載器講程序從磁盤到內(nèi)存的完整映射過程那又是另一條值得拆透的鏈路了。