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