庫動態(tài)庫實戰(zhàn))
編譯報錯里我最熟悉的一句就是undefined reference to std::cout。說它熟悉是因為幾乎每個 C/C 初學(xué)者都會被它攔住一次更離譜的是很多人查了半天代碼發(fā)現(xiàn)自己寫得一點問題都沒有。后來才明白問題壓根不在代碼而在編譯和鏈接這兩個階段上。所以我想把 C/C 編譯鏈接這一整套鏈路以及基本庫這個概念徹底拆開講明白編譯到底分幾步、鏈接器在做什么、靜態(tài)庫和動態(tài)庫怎么選、環(huán)境怎么搭、報錯怎么查。內(nèi)容不做太多理論糾纏全部是實操里沉淀下來的經(jīng)驗適合剛?cè)腴T C/C以及正在跟各種鏈接錯誤較勁的同學(xué)。1. 從源碼到目標文件編譯過程的四道工序與報錯定位1.1 四道工序各自在干什么很多人在終端里敲一句gcc hello.c -o hello管這叫編譯。這句話確實能幫你生成可執(zhí)行文件但它背后發(fā)生的事情遠比一條命令復(fù)雜。加上-v參數(shù)運行一遍你能看到驅(qū)動程序依次調(diào)用了cc1真正的編譯器、as匯編器、collect2和ld鏈接器。也就是說這條命令最少拆成四個階段才完整。第一階段是預(yù)處理。運行g(shù)cc -E hello.c -o hello.i做的是頭文件展開、宏替換、條件編譯分支選擇。你寫了一句#include stdio.h預(yù)處理階段會把stdio.h的完整內(nèi)容原樣粘貼到源文件里。一個普通的 hello 程序預(yù)處理之后文件體積能瞬間膨脹到幾千行。如果你看到的報錯是某個頭文件找不到多半就在這一步翻車。第二階段是編譯。gcc -S hello.i -o hello.s把預(yù)處理后的代碼翻譯成匯編語言同時做嚴格的語法和類型檢查。項目里最耗時的優(yōu)化動作-O2、-O3也發(fā)生在這一步幾十萬行的工程編譯半天大頭全在這里。編譯報錯的表現(xiàn)是報錯信息里帶有具體的源碼行號還會用^指向出問題的位置這類錯誤是語法或類型層面的直接改代碼就行。第三階段是匯編。gcc -c hello.s -o hello.o把匯編代碼翻譯成機器指令生成目標文件。Linux 下這個文件是 ELF 格式Windows 下是 COFF 格式。但此時的hello.o到處都是坑它引用了printf、memcpy這類外部符號卻還不知道這些符號在最終程序里的內(nèi)存地址。第四階段是鏈接。把多個.o文件、庫文件拼裝成最終可執(zhí)行文件完成符號解析和地址重定位。gcc hello.o -o hello跑的就是這一步。絕大多數(shù)讓新手頭皮發(fā)麻的報錯比如undefined reference、multiple definition都是在這一階段丟出來的。1.2 按報錯信息快速判斷問題出在第幾步這四步對應(yīng)的報錯形態(tài)差別非常大學(xué)會一眼判斷能省下大量無用的排查。預(yù)處理錯誤通常是fatal error: xxx.h: No such file or directory解決方案是檢查-I參數(shù)和頭文件路徑。編譯錯誤報錯帶源碼行號內(nèi)容多為syntax error、類型不匹配、變量未聲明直接改代碼。匯編錯誤日常業(yè)務(wù)代碼中很少見基本出現(xiàn)在內(nèi)聯(lián)匯編寫法不對或者目標平臺不支持的指令集上。鏈接錯誤報錯帶符號名但不帶源碼行號比如undefined reference to bar、cannot find -lfoo、multiple definition of bar。看到這類報錯請先停止檢查語法轉(zhuǎn)去檢查你有沒有把正確的庫喂給鏈接器。提示編譯錯誤是你話沒說對鏈接錯誤是你需要的工具沒帶齊。記住這句話報錯定位會快很多。鏈接階段要去找?guī)爝@也是基本庫這個概念登場的時機。庫分運行時庫、靜態(tài)庫、動態(tài)庫每一種在鏈接鏈路里的作用都不一樣。2. 基本庫到底是個什么東西運行時庫、靜態(tài)庫與動態(tài)庫的分工2.1 運行時庫程序出生前就必須存在的底座基本庫在不同語境下指的東西不一樣但最核心的永遠是運行時庫。C 語言有 libcLinux 上通常是 glibc資源受限的嵌入式環(huán)境里常見 muslC 有 libstdcGCC 配套和 libcClang 配套Windows 上還有 UCRT、MSVC CRT 這類東西。只要你寫 C/C這些庫就是繞不開的底座。printf、malloc、new、delete、std::vector、std::cout的實現(xiàn)全部封裝在里面。這里有一個初學(xué)者必踩的經(jīng)典坑gcc和g的區(qū)別。用gcc去編譯一個.cpp文件編譯階段沒問題到鏈接階段就會報undefined reference to std::cout一類錯誤原因是gcc這個驅(qū)動在鏈接時默認不帶 C 運行時庫。正確做法是編譯 C 用g。g本質(zhì)上也是調(diào)用同一個編譯器只是會在鏈接命令里自動追加-lstdc。你不信的話給編譯命令加上-v看看它傳給鏈接器的參數(shù)就明白了。2.2 靜態(tài)庫與動態(tài)庫兩種封裝形態(tài)的取舍庫本身又以兩種形態(tài)存在。靜態(tài)庫在 Linux 下是.a文件Windows 下是.lib。它本質(zhì)上是多個目標文件打包在一起的壓縮包用ar rcs libcalc.a add.o sub.o就能生成。鏈接器在處理靜態(tài)庫時會把用到的目標文件完整復(fù)制進可執(zhí)行文件所以最終程序不需要依賴外部庫文件。動態(tài)庫在 Linux 下是.soWindows 下是.dllmacOS 下是.dylib。鏈接階段只會檢查符號是否存在并記錄依賴關(guān)系和符號偏移真正的代碼加載發(fā)生在程序運行時。好處是多個程序可以共享同一份庫文件更新庫只需要替換文件壞處是會出現(xiàn)所謂依賴地獄——換一臺機器可執(zhí)行文件就可能因為找不到某個.so而直接拒絕啟動。對比項靜態(tài)庫動態(tài)庫常見后綴.a/.lib.so/.dll/.dylib鏈接期行為目標文件復(fù)制進程序只登記符號依賴運行期依賴無依賴庫文件存在且版本匹配可執(zhí)行文件體積偏大偏小更新庫需要重新編譯程序替換庫文件即可部署復(fù)雜度低高選擇的標準其實很樸素追求獨立部署、環(huán)境不可控優(yōu)先靜態(tài)鏈接追求體積小、更新靈活選動態(tài)庫。另外-static參數(shù)可以強制全程靜態(tài)鏈接。我在排查動態(tài)庫沖突問題時經(jīng)常臨時編一個靜態(tài)版本做對照組如果靜態(tài)版一切正常基本可以斷定問題出在動態(tài)庫加載的環(huán)節(jié)。2.3 親手做出并鏈接一個基本庫命令行里創(chuàng)建庫并不神秘。假設(shè)你寫了一個計算器提供add和sub兩個函數(shù)# 編譯生成目標文件 gcc -c add.c sub.c # 創(chuàng)建靜態(tài)庫 libcalc.a ar rcs libcalc.a add.o sub.o # 編譯主程序并鏈接靜態(tài)庫 gcc main.c -L. -lcalc -o main-L.告訴鏈接器在當(dāng)前目錄搜索庫文件-lcalc告訴它找一個叫l(wèi)ibcalc.a或libcalc.so的文件。注意 Linux 庫的命名規(guī)則文件必須叫l(wèi)ib加庫名再加后綴寫-l時既不帶lib前綴也不帶后綴。如果你把庫文件名起成calc.a而不是libcalc.a就算路徑對了鏈接器照樣找不到。動態(tài)庫的命令稍稍多一點gcc -c -fPIC add.c sub.c gcc -shared -fPIC -o libcalc.so add.o sub.o gcc main.c -L. -lcalc -o main-fPIC表示生成位置無關(guān)代碼這是動態(tài)庫的硬性要求。原因在于動態(tài)庫在運行時被映射到進程地址空間的哪個位置不確定代碼內(nèi)部的跳轉(zhuǎn)和引用不能寫死絕對地址。3. 鏈接器的工作細節(jié)符號解析、庫順序與搜索路徑3.1 符號表、重定位與目標文件里的坑鏈接器干的事情概括起來就兩件符號解析和重定位。符號解析是把代碼里所有對外部符號的引用和某個目標文件或庫里的定義對上。每個目標文件都有一張符號表用nm main.o就能看到。輸出里的T表示已經(jīng)定義的全局代碼符號U表示未定義、等著鏈接器去外面找的符號。我用這個命令排查過大量 undefined reference 問題把一個符號在它聲稱依賴的庫文件里跑一遍nm如果顯示為T說明它確實定義在這里如果到處都找不到那要么庫沒鏈接要么鏈接順序不對要么符號被 C 名字修飾過了。重定位則是在把所有目標文件合并到一起后把代碼里那些懸空的符號引用替換成最終運行時確定的虛擬內(nèi)存地址。這也是為什么一個簡單的程序也要經(jīng)過鏈接才能跑起來——目標文件里的代碼地址都是空的程序無法直接被操作系統(tǒng)加載。3.2 庫鏈接順序為什么是 GNU 工具鏈的經(jīng)典坑GNU ld 在處理靜態(tài)庫時有一個小脾氣它只會提取能夠解決當(dāng)前未定義符號的目標文件而且默認從左到右只掃描一遍。這就導(dǎo)致庫的書寫順序會直接影響鏈接成敗。假設(shè)libbar.a引用了libfoo.a里的符號鏈接命令必須寫成gcc main.o -lbar -lfoo -o app如果你顛倒次序?qū)懗?lfoo -lbar鏈接器掃描libfoo.a時發(fā)現(xiàn)沒有任何人引用它的符號直接跳過掃到libbar.a時才發(fā)現(xiàn)需要libfoo.a里的東西可這時libfoo.a已經(jīng)被處理完了于是報出 undefined reference。解決的辦法有兩個一是把依賴別人的庫寫在前面二是用--start-group和--end-group把多個庫包起來讓鏈接器反復(fù)掃描直到?jīng)]有新符號被解析出來gcc main.o -Wl,--start-group -lbar -lfoo -Wl,--end-group -o app我在真實項目里見過有人為了這種順序問題折騰一整天最后發(fā)現(xiàn)只是兩個-l參數(shù)調(diào)換一下順序。如果你用 CMake 管理項目這類問題會被工具鏈自動處理掉這也是我推薦 CMake 的原因之一。3.3 -I、-L、-l 與運行時搜索路徑的職責(zé)邊界-I是頭文件搜索路徑編譯時用-L是庫文件搜索路徑鏈接時用-l指定庫名也是鏈接時用。三者各管一段經(jīng)常有人混為一談。頭文件找不到時查-I庫文件找不到時查-L和文件名前綴規(guī)則符號未定義時查-l是否遺漏。比鏈接期搜索路徑更隱蔽的是運行期搜索路徑。開發(fā)機上編譯通過、運行得好好的程序拷到另一臺機器上彈出一句error while loading shared libraries: libcalc.so.1: cannot open shared object file。原因很簡單動態(tài)鏈接器ld.so只會在系統(tǒng)默認目錄和它配置過的目錄里找?guī)旄静粫茨愕漠?dāng)前目錄。Linux 下的解決思路有三條設(shè)置環(huán)境變量LD_LIBRARY_PATH/path/to/libs后運行臨時生效適合調(diào)試。把庫路徑寫入/etc/ld.so.conf.d/下的配置文件然后執(zhí)行l(wèi)dconfig全局生效。在編譯階段就把路徑寫進可執(zhí)行文件叫 rpathgcc main.c -L. -lcalc -Wl,-rpath,$ORIGIN -o main。$ORIGIN表示可執(zhí)行文件所在目錄這個方案對發(fā)布軟件最友好。如果放在 Makefile 里記得寫成$$ORIGIN不然$ORIGIN會被 Make 當(dāng)成變量展開。用ldd ./main可以查看可執(zhí)行文件依賴了哪些動態(tài)庫哪些找到了、哪些顯示為not found一眼便知。這是排查運行時缺庫的第一命令沒有之一。4. 環(huán)境搭建實操命令行、VSCode 與 CMake 的完整鏈路4.1 編譯器選型GCC、Clang、MSVC 還是 MinGW-w64不同平臺、不同用途最順手的編譯器不一樣先看一張對比表編譯器常見平臺默認 C 運行庫適用場景GCC / GLinux、嵌入式libstdc最通用近場部署首選Clang / ClangmacOS、Linuxlibc 或 libstdc報錯提示友好現(xiàn)代特性跟進快MSVCWindowsUCRT / MSVC CRTWindows 原生開發(fā)、大量 Windows SDKMinGW-w64Windowslibstdc 或 libc想在 Windows 上沿用 GCC 命令習(xí)慣一個非常重要的提醒MSVC 和 MinGW-w64 的庫不要混著用。兩者生成的庫雖然都是 Windows 上的二進制格式但各自綁定不同的運行時庫混用時能冒出各種匪夷所思的鏈接錯誤。選定一條路就走到黑Windows 上用 Visual Studio 的同學(xué)堅持 MSVC 工具鏈用命令行習(xí)慣的同學(xué)就堅持 MinGW-w64。這兩個也不要同時在命令行環(huán)境里搶 PATH很亂。4.2 命令行下最快跑通一套開發(fā)環(huán)境以 Linux 或 macOS 為例最快的驗證方式cat hello.cpp EOF #include iostream int main() { std::cout hello, world std::endl; return 0; } EOF g hello.cpp -o hello ./hellomacOS 上有個額外的坑系統(tǒng)自帶的g其實是指向 Clang 的符號鏈接也就是clang。很多時候這并不影響使用但如果你在 GitHub 項目要求必須用 GCC 真身得先確認一下。Windows 上裝好 MinGW-w64 之后最常見的問題是在終端敲g提示不是內(nèi)部或外部命令。這不是編譯器沒裝上而是你沒把安裝目錄下的bin文件夾加進系統(tǒng)的 PATH 環(huán)境變量。加上之后重啟終端就好。4.3 VSCode 配置 C/C 環(huán)境三個配置文件各管什么VSCode 本身不編譯、不運行、不調(diào)試 C/C它只是一個編輯器前端。你先去擴展市場裝一個 C/C 擴展ms-vscode.cpptools沒有它后面提到的cppdbg調(diào)試類型根本不會被識別。所謂配置 C/C 環(huán)境實際上是配好三個配置文件。第一個是tasks.json定義構(gòu)建任務(wù)。按下CtrlShiftB時會執(zhí)行{ version: 2.0.0, tasks: [ { label: build hello, type: shell, command: g, args: [-g, hello.cpp, -o, hello], group: {kind: build, isDefault: true} } ] }第二個是launch.json定義調(diào)試會話。按下F5時它會啟動調(diào)試器{ version: 0.2.0, configurations: [ { name: debug hello, type: cppdbg, request: launch, program: ${workspaceFolder}/hello, args: [], cwd: ${workspaceFolder}, MIMode: gdb } ] }macOS 上默認調(diào)試器是 lldbMIMode要寫成lldb否則會報找不到調(diào)試器。這是不少 mac 用戶在 VSCode 里按 F5 沒反應(yīng)的原因。第三個是c_cpp_properties.json它負責(zé)的是編輯器的 IntelliSense代碼補全、跳轉(zhuǎn)定義、紅波浪線。它只影響編輯體驗不影響實際編譯。很多同學(xué)把 includePath 改來改去以為編譯行為會變化其實編譯只受tasks.json里寫的那條命令控制。includePath 建議直接指到編譯器自帶的頭文件目錄Linux 上是/usr/includeMinGW 的安裝目錄下也有對應(yīng)的include文件夾。4.4 用 CMake 告別手寫鏈接命令一旦項目里出現(xiàn)了多個源文件、多個庫再靠手寫g參數(shù)就有點吃力了CMake 是更穩(wěn)的選擇。一個最小可用的CMakeLists.txtcmake_minimum_required(VERSION 3.16) project(calc_app) add_executable(app main.cpp) add_library(calc add.cpp sub.cpp) target_include_directories(calc PUBLIC .) target_link_libraries(app PRIVATE calc)target_link_libraries會按依賴關(guān)系自動處理鏈接順序這是它對我最大的價值——再也不用手工排列-l的先后次序。使用 CMake 時有一個非常容易出現(xiàn)、又非常好解決的坑改了CMakeLists.txt之后舊有的構(gòu)建緩存不會自動全部失效有時行為會變得詭異。最干凈的處置就是刪掉 build 目錄重新配置一次。新加了源文件、換了庫依賴之后感覺哪都不對先別查代碼刪 build 重來很多情況下問題直接消失。5. 常見鏈接錯誤實戰(zhàn)排查從一條報錯挖到根因5.1 高頻鏈接錯誤速查表報錯信息根因排查方向undefined reference to xxx符號未定義庫是否漏鏈、順序是否顛倒、函數(shù)名是否拼錯、C/C 混編未加 extern Ccannot find -lxxx庫文件找不到-L路徑是否正確、文件名是否帶lib前綴、庫是 32 位還是 64 位multiple definition of xxx符號重復(fù)定義頭文件里是否寫了全局變量定義、多個庫是否都定義了同名符號error while loading shared libraries運行時找不到動態(tài)庫用ldd查看依賴設(shè)置LD_LIBRARY_PATH或 rpathrelocation ... can not be used when making a shared object編譯選項不一致生成動態(tài)庫時是否全程加了-fPICundefined reference to __gxx_personality_v0用 gcc 鏈接了 C 代碼改用g或顯式添加-lstdc這張表不能覆蓋所有情況但能覆蓋我遇到過的 90%。5.2 兩個真實案例第三方庫順序與 C/C 混編第一個案例來自一次第三方 SDK 接入。程序引入了libsdk.a編譯階段一切順利鏈接時冒出一串 undefined reference仔細看報錯符號都指向 OpenSSL 的函數(shù)。當(dāng)時的直覺是-lssl -lcrypto沒加加上之后照樣報錯。后來用nm libsdk.a | grep SSL確認符號確實沒定義又檢查了系統(tǒng)里libssl.so也真實存在。最終定位到問題-lssl -lcrypto寫在了libsdk.a前面鏈接器掃描 OpenSSL 庫時尚未發(fā)現(xiàn)任何未定義符號直接跳過去了。修復(fù)就是把這兩個參數(shù)移動到 SDK 庫之后或者直接用 CMake 聲明依賴關(guān)系。第二個案例是 C 和 C 混編。同事在 C 項目里調(diào)用一個 C 靜態(tài)庫函數(shù)名、路徑、庫文件全都對得上但鏈接器總報undefined reference to my_c_function。用nm查庫文件發(fā)現(xiàn)函數(shù)在庫里明明存在再看末尾C 那邊引用的符號已經(jīng)被名字修飾成了_Z14my_c_functionv兩個名字根本對不上。原因在于 C 編譯器會把函數(shù)名按照一定的規(guī)則攪碎變成帶類型信息的長符號而 C 編譯器不會。修復(fù)方法是在 C 側(cè)把 C 的頭文件用extern C包起來extern C { #include c_api.h }這兩個案例說明同一個道理鏈接錯誤往往不是代碼邏輯不對而是符號對不上。5.3 排查鏈接問題必備的命令行工具nm查看目標文件和庫的符號表判斷符號定義在哪、有沒有被名字修飾。ldd查看可執(zhí)行文件的動態(tài)庫依賴找出運行時缺了誰。readelf -d查看 ELF 文件的動態(tài)段信息搜索路徑、依賴庫版本都能看到。objdump -t以更底層的視角查看符號。LD_DEBUGlibs ./main讓動態(tài)鏈接器打印加載過程能看清它按什么順序搜索了哪些目錄這個調(diào)試手段在定位運行時加載問題時極其好用。strace -e openat ./main在 Linux 上跟蹤文件打開調(diào)用看看程序到底去哪些路徑找過庫文件。把這幾條命令組合起來用鏈接類的報錯基本都能快速定位。我個人的習(xí)慣是遇到鏈接錯誤第一件事永遠不是改代碼而是打開終端跑一條nm和一條ldd先搞清楚問題到底是符號不存在還是定義沒有被找到。這兩個方向?qū)?yīng)的解法截然不同想清楚再動手能節(jié)省大量瞎試的時間。另外如果你的項目將來要在多個平臺之間搬動建議早點從手寫gcc參數(shù)切到 CMake短期看多寫了幾行文件長期看省掉的是無數(shù)個人為排庫順序的深夜。