如何使用 gcc 構建 c/c++ 項目,大家都很熟悉了,甚至對鏈接器、靜態庫、共享庫等概念,大家也略知一二。然而,對于 ld 鏈接器、linux 操作系統(OS)及應用程序(exec)之間的詳細交互流程,估計就有點懵了。接下來,我將從單個源文件編譯、編譯期鏈接、程序運行期這三個階段入手,揭開應用程序運行背后的奧秘。
單個源文件編譯
單個 c/cpp 文件可以被 gcc 編譯成目標文件(.o 文件),這部分就不過多贅述,大家應該都很熟悉了。二進制目標文件中的 section 有很多,詳細內容可以打開匯編代碼詳細研究下,下圖列出了其中比較常見的段。
這里的目標文件包括 .o 文件及后面提到的庫文件
符號表的作用是什么?
- 記錄該目標文件中定義的全局變量及函數;
- 記錄該目標文件中引用的全局變量及函數;
Func 是源文件中引用的外部符號,a 是源文件中定義的全局變量
.rela.* 的作用是什么?
全稱 relocation(重定位),記錄編譯器在編譯時不確定的符號地址——針對引用的外部符號。
dynamic 段中保存了可執行文件依賴哪些動態庫。
GOT 段記錄了需要引用的外部符號的地址。
編譯期鏈接
多個 .o 文件可以通過鏈接器(ld)被打包在一起,組合成庫文件。
庫文件又分為靜態庫(.a 文件)和共享庫(.so 文件)。
什么是 ld 呢?它本身也是可執行文件,屬于 GNU 的一部分,將一堆目標文件通過符號表鏈接成最終的目標文件、庫文件和可執行文件。
.a 文件如何生成?
ld 直接將涉及的所有目標文件打包進靜態庫文件。
.so 文件如何生成?
在鏈接生成共享庫文件的過程中,并不拷貝目標文件中涉及的代碼段,只記錄它需要引用的外部符號位置(在哪些目標文件中)。
所有的目標文件、庫文件和可執行文件都有統一的格式,即 ELF,Executable and Linking Format(可執行鏈接格式)。
libstdc++.so 是標準庫文件
上圖中,多個 .o 文件鏈接在一起形成 .a 文件,多個 .o 和 .so 文件也可以鏈接形成 .so 文件,可執行文件也可以由 .a 文件、.so、.o 文件鏈接而成。
程序運行期
如果可執行文件沒有使用共享庫,那么該程序就可以獨立運行,因為它內部所有的符號都有對應的二進制機器碼。這種情況比較簡單,我們這里主要討論下面這種程序運行方式。
如果可執行文件要使用共享庫,那么該程序就不能獨立運行,它在運行時需要使用共享庫的代碼,且對應的兩種使用方式,分別是運行時動態鏈接和運行時動態加載。
可執行文件的組成
ld-linux.so:不是一個可執行程序,只是一個 shell 腳本。作為解釋器,寫在 elf 文件(可執行文件)中,ld-linux.so 先于 main 函數工作,用于查找主程序所依賴的共享庫,實際上可以直接執行 ld-linux.so. 還有另外一種比較常見的是 ld.so,它是個符號鏈接,指向 ld-linux.so.(通過命令 ln -s ld.so ld-linux.so 創建)。
為什么這里使用解釋器呢?
解釋器的特點是動態特性和可移植性,比如在解釋器執行時可以動態改變變量的類型、對程序進行修改以及在程序中插入良好的調試診斷信息等。而將解釋器移植到不同的系統上,則程序不用改動就可以在移植了解釋器的系統上運行。
同時解釋器也有很大的缺點,比如執行效率低,占用空間大,因為不僅要給用戶程序分配空間,解釋器本身也占用了寶貴的系統資源。
動態鏈接和動態加載的區別
動態加載和動態鏈接都是在程序運行時發生,并將所需代碼拷貝到內存,這點很重要!
關鍵區別是:動態鏈接的流程是 OS 直接把共享庫的代碼拷貝到內存,而動態加載由人工指定(代碼中的 dlopen() 接口)。
動態鏈接需要 OS 的特殊支持,通過動態鏈接方式拷貝到內存的庫代碼可以在各個進程之間共享。而對動態加載而言,可以在各自進程中打開共享庫代碼。
其他概念
ldconfig:這是個可執行程序,隸屬于 GNU,作用是在默認搜尋目錄(/lib和/usr/lib)以及共享庫配置文件 /etc/ld.so.conf 內所列的目錄下,搜索出共享庫文件(lib*.so*),進而創建出 ld-linux.so 所需要的鏈接和緩存文件。緩存文件默認為 /etc/ld.so.cache,此文件保存已排好序的共享庫名字列表。更新緩存使新添加的庫生效,當然系統啟動時會自動運行 ldconfig。
ldd:這是 Linux 內核中自帶的腳本,可以用來查看可執行文件鏈接了哪些共享庫
strip <可執行文件名> 去除符號表,可以給可執行文件瘦身
使用 objdump、readelf、nm 等命令可以查詢目標文件的詳細內容。
gcc -print-search-dirs 可以查看 gcc 在編譯、鏈接過程中的共享庫搜索路徑。