作者:字節跳動終端技術——李翔
前言
靜態鏈接(static linking)是程序構建中的一個重要環節,它負責分析 compiler 等模塊輸出的 .o
、.a
、.dylib
、經過對 symbol 的解析、重定向、聚合,組裝出 executable 供運行時 loader 和 dynamic linker 來執行,有着承上啟下的作用。
對於 iOS 工程而言,目前負責靜態鏈接的主要是 ld64。蘋果對 ld64 加持了一些功能,以適配 iOS 項目的構建,比如:
- 現在在 Xcode 中即使不主動管理依賴的系統動態庫(如 UIKit),你的工程也可以正常鏈接成功
- 提供“強制加載靜態庫中 ObjC class 和 category” 的開關(默認開啟),讓 ObjC 的信息在輸出中完整不丟失
大量特性的實現也在靜態鏈接這一步完成,如:
- 基於二進制重排的啟動速度優化,利用 ld64 的
-order_file
讓 linker 按照指定順序生成 Mach-O - 用
-exported_symbols_list
優化構建產物中 export info 占用的空間,減少包大小
借助組件二進制化、自定義構建系統等優化手段,當前大型工程中增量構建的效率已經顯著提升,但靜態鏈接作為每次必須執行的環節依然“貢獻”了大部分耗時。了解 ld64 的工作原理能輔助我們加深對構建過程的理解、尋找提升鏈接速度的方法、以及探索更多品質和體驗優化的可能性。
目錄
- 歷史背景
- 概念鋪墊
- ld64 命令參數
- ld64 執行流程
- ld64 on iOS
- 其他
一、歷史背景
- GNU ld:GNU ld,或者說 GNU linker,是 GNU 項目對 Unix ld 命令的實現。它是 GNU binary utils 的一部分,有兩個版本:傳統的基於 BFD & 只支持 ELF 的 gold。(gold 由 Google 團隊研發,2008 年被納入 GNU binary utils。目前隨着 Google 重心放到 llvm 的 lld 上,gold 幾乎不怎么維護了)。 ld 的命名據說是來自
LoaDer
、Link eDitor
。 - ld64:ld64 是蘋果為 Darwin 系統重新設計的 ld。和 ld 的最大區別在於,ld64 是 atom-based 而不是 section-based(關於 atom 的介紹后面會展開)。在 macOS 上執行
ld
(/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld
)默認就是 ld64。系統和 Xcode 自帶的版本可以通過ld -version_details
查詢,如 650.9。蘋果在這里 https://opensource.apple.com/tarballs/ld64/ 開放了 ld64 的源碼,但更新不那么及時,始終落后於正式版(如 2021.8 為止開源最新是 609 版本,Xcode 12.5.1 是 650.9) 。zld 等基於 ld64 的項目都是 fork 自開源版的 ld64。
二、概念鋪墊
在介紹 ld64 的執行流程之前,需要先了解幾個概念。
輸入 — .o
、.a
、.dylib
ld64 主要處理 Mach kernel 上的 Mach-O 輸入,包括:
- Object File (
.o
)- 由 compiler 生成,包含元數據(header、LoadCommand 等)、segments & sections(代碼、數據 等)、symbol table & relocation entries。
- object file 之間可能相互依賴(如 A 引用了 B 定義的函數),static linker 做的事情本質上就是把這些信息關聯起來輸出成一個總的有效的 Mach-O 。
- 靜態庫 (
.a
)- 可以視為
.o
的集合,讓工程代碼能模塊化地被組織和復用。 - 其頭部還存儲了 symbol name ->
.o
offset 的映射表,便於 link 時快速查詢某個 symbol 的歸屬。 - 一個靜態庫可能包含多個架構(universal / fat Mach-O),static linker 在處理時會按需選擇目標架構。可以通過
lipo
等工具查看其架構信息。
- 可以視為
- 動態庫 (
.dylib
、.tbd
)- 不同於靜態庫,動態庫由 dyld 在運行時經過 rebase、binding 等過程后加載。static linker 在 link 時僅在處理 undefined symbol 時會嘗試從輸入的動態庫列表中查詢每個動態庫 export 的 symbol。
- iOS 工程中使用的大部分是系統動態庫(UIKit 等),工程也可以以 framework 等形式提供自己的動態庫(需要指定對 rpath 以讓自定義動態庫能被 dyld 正常加載)
.tbd
(text-based dylib stub) 是蘋果在 Xcode 7 后引入的一種描述 dylib 的文件格式,包含支持的架構、導出哪些 symbol 等信息。通過解析.tbd
ld64 可以快速地知道該 dylib 提供了哪些 symbol 可被用於鏈接 & 有哪些其他動態庫依賴,而不用去解析整個解析一遍 dylib。目前大多數系統的 dylib 都采用這種方式。- 如 Foundation:
--- !tapi-tbd
tbd-version: 4
targets: [ i386-ios-simulator, x86_64-ios-simulator, arm64-ios-simulator ]
uuids:
- target: i386-ios-simulator
value: A4A5325F-E813-3493-BAC8-76379097756A
- target: x86_64-ios-simulator
value: C2A18288-4AA2-3189-A1C6-5963E370DE4C
- target: arm64-ios-simulator
value: 81DE1BE5-83FA-310A-9FB3-CF39C14CA977
install-name: '/System/Library/Frameworks/Foundation.framework/Foundation'
current-version: 1775.118.101
compatibility-version: 300
reexported-libraries:
- targets: [ i386-ios-simulator, x86_64-ios-simulator, arm64-ios-simulator ]
libraries: [ '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation',
'/usr/lib/libobjc.A.dylib' ]
exports:
- targets: [ arm64-ios-simulator, x86_64-ios-simulator, i386-ios-simulator ]
symbols: [ '$ld$hide$os10.0$_OBJC_CLASS_$_NSURLSessionStreamTask', '$ld$hide$os10.0$_OBJC_CLASS_$_NSURLSessionTaskMetrics',
....
_NSLog, _NSLogPageSize, _NSLogv, _NSMachErrorDomain, _NSMallocZone,
....]
Symbol & Symbol Table
對 static linker 來說,symbol 是 Mach-O 提供的、link 時需要參考的一個個基本元素。
Mach-O 有一塊專門的區域用於存儲所有的 symbol,即 symbol table。
global function、global variable、class 等都會作為一條條 entry 被放入 symbol table 中。
Symbol 包含以下屬性:
- 名稱:具體生成規則由 compiler 決定。如 C variable
_someGlolbalVar
、C function_someGlobalFunction
、 ObjC class__OBJC_CLASS_$_SomeClass
、 ObjC method-[SomeClass foo]
等。不同的 compiler 有不同的 name mangling 策略。 - 是“定義”還是“引用”:對應函數、變量的“定義”和“引用”。
- visibility:如果是“定義”,還有 visibility 的概念來控制對其他文件的可見性(具體說明見后文「visibility」)、
- strong / weak:如果是“定義”,還有 strong / weak 的概念來控制多個“定義” 存在時的合並策略(具體說明見后文「strong / weak definition」。
Mach-O symbol table entry 具體的數據結構可以參考文檔或源碼
Visibility
Mach-O 中將 symbol 分為三組:
- global / defined external symbol :外部可用的 symbol 定義
- local symbol:該文件定義和引用的 symbol,僅該文件可用(比如被
static
標記) - undefined external symbol:依賴外部的 symbol 引用
屬性 | 說明 | 舉例 |
---|---|---|
global / defined external symbol | 由該文件定義,對外部可見 | int i = 1; |
local symbol | 由該文件定義,對外部不可見 | static int i = 1; |
undefined external symbol | 引用了外部的定義 | extern int i; |
可以通過查看該 Mach-O LoadCommand 中的 LC_DYSYMTAB
來獲取三組 symbol 的偏移和大小
visibility 決定了 symbol definition 在 link 時對其他文件是否可見。上面說的 local symbol 對外不可見,global symbol 對外可見。
global symbol 里又分為兩類:normal & private external。如果是 private external(對應 Mach-O 中 N_PEXT
字段) ,static linker 會在輸出中把該 symbol 轉為 local symbol。可以理解為該 symbol definition 只在這一次 link 過程中對外可見,后續 link 的產物如果要被二次 link,就對外不可見了(體現了 private 的性質)
一個 symbol 是否是 「private external」可以在源碼和編譯期用 __attribute__((visibility("xxx")))
來標識,可選值為 default
(normal)、hidden
(private external)
- 不指定
__attribute__((visibility("xxx")))
的,默認為default
-fvisibility
可以修改默認 visibility (gcc、clang 都支持)
- 指定
__attribute__((visibility("xxx")))
的,visibility 為xxx
舉例:
// test.c
__attribute__((visibility("default"))) int i1Default = 101;
__attribute__((visibility("hidden"))) int i1Hidden = 102;
int i1Normal = 103;
不指定 -fvisibility
:
-fvisibility=hidden
:
Strong / Weak definition
symbol definition 中還有 strong / weak 之分:當 static linker 發現多個 name 相同的 symbol definition 時,會根據 strong/weak 類型執行以下合並策略:
- 有多個 strong => 非法輸入,abort
- 有且僅有一個 strong => 取該 strong
- 有多個 weak,沒有 strong => 取第一個 weak
symbol definition 默認情況基本都是 strong,可以在源碼中通過 __attribute__((weak))
、#pragma weak
標記 weak 屬性,看一個例子:
// main.c
void __attribute__((weak)) foo() {
printf("weak foo called");
}
int main(int argc, char * argv[]) {
foo();
}
// strong_foo.c
void foo() {
printf("strong foo called");
}
生成的 main.o
中該函數對應的 symbol table entry 被標記為了 N_WEAK_DEF
,static linker 據此來區分 strong / weak:
執行后輸出:
strong foo called
要注意的是,分析最終輸出使用了哪個 symbol definition 需要結合實際情況。比如某個 strong symbol 封裝在靜態庫中,始終沒有被 static linker 加載,而同名的 weak symbol 已經被加載了,上述(2)的策略就應當變成(3)了。(關於靜態庫中 symbol 的加載機制見后文)
Tentative definitions / Commons
symbol definition 還可能是 tentative definition(或者叫 common definition)。這個其實也很常見,比如:
int i;
這樣一個未初始化的全局變量就是一個 tentative definition。
更官方一點的定義是:
A declaration of an identifier for an object that has file scope without an initializer, and without a storage-class specifier or with the storage-class specifier static
說的比較繞不要被帶進去了,可以先簡單理解 tentative definition 為「未初始化的全局變量定義」。結合更多的例子來理解:
int i1 = 1; // regular definition,global symbol
static int i2 = 2; // regular definition,local symbol
extern int i3 = 3; // regular definition,global symbol
int i4; // tentative definition, global symbol
static int i5; // tentative definition, local symbol
int i1; // valid tentative definition, refers to 第 1 行
int i2; // invalid tentative definition,visibility 和第 2 行的 static 沖突
int i3; // valid tentative definition, refers to 第 3 行
int i4; // valid tentative definition, refers to 第 4 行
int i5; // invalid tentative definition,visibility 和第 5 行的 static 沖突
tentative definition 在 Mach-O 中屬於 __DATA,__common
這個 section。
Relocation (Entries)
compiler 無法在編譯期確定所有 symbol 的地址(如對外部函數的調用),因此會在 Mach-O 對應的位置“留空”、並生成一條對應的 Relocation Entry。static linker 在鏈接期通過 Relocation Entry 知曉每個 section 中哪些位置需要被 relocate、如何 relocate。
Load Command 中的 LC_SEGMENT_64
描述了各個 section 對應的 Relocation Entries 的數量、偏移量:
Mach-O 中用 relocation_info
表示一條 Relocation Entry:
r_address
:從該 section 頭開始偏移多少位置的內容需要 relocater_extern
&r_symbolnum
r_extern
為 1 表示從 symbol table 的第r_symbolnum
個 symbol 讀取信息r_extern
為 0 表示從第r_symbolnum
個 section 讀取信息
r_type
:relocation 的類型,如X86_64_RELOC_BRANCH
表示 relocate 的是CALL/JMP
指令的內容
字段明細可參考文檔 https://github.com/aidansteele/osx-abi-macho-file-format-reference#relocation_info。
ld64 — Atom & Fixup
ld64 是一種 atom-based linker,atom 是其執行處理的基本單元。atom 可以用來表示 symbol,也可以用來表示其他的信息,如 SectionBoundaryAtom
。ld64 在解析時會把 input files 抽象成各種 atoms,交由 Resolver 統一處理。
相比 section-based linker ,atom-based linker 把處理對象視為一個 atom graph,更細的粒度方便了各種圖算法的應用,也能更直接地實現各種特性。
Atom 有以下屬性:
- name,對應上面 Symbol 的 name
- content
- 函數的 content 是其實現的代碼指令
- 全局變量的 content 是其初始值
- scope,對應上面 Symbol 的 visibility
- definition kind,有四種,通過 Mach-O Symbol Table Entry 的
N_TYPE
字段得來- regular:大多數 atom 是這種類型
- absolute:對應
N_ABS
,ld64 不會修改它的值 - tentative:
N_UNDF
,對應上面 Symbol 的 tentative definition - proxy:ld64 解析階段如果發現某個 symbol 由動態庫提供,會創建一個 proxy atom 占位
一個 atom 旗下可能有一組 fixup,fixup 顧名思義是用於表示在 link 時如何校正 atom content 的一種數據結構。object file 的 Relocation Entries 提供了初始的 fixup 信息,ld64 在執行過程中也可能為 atom 生成額外的 fixup。
fixup 描述了 atom 之間的依賴關系,是 atom graph 中的「邊」,dead code stripping 就需要這些依賴關系來判斷哪些 atom 不被需要、可以移除。
一個 fixup 包含以下屬性:
- kind:fixup 的類型,總共有幾十種,如
kindStoreX86PCRel32
- offset: 對應 Relocation 的 offset
- addend:對應 Relocation 的 addend
- target atom:指向的 atom
- binding type:binding 策略(by-name、by-content、direct、indirect)
類型 | 實現 | 說明 |
---|---|---|
direct | 記錄指向目標 Atom 的 pointer | 一般由同一個 object file 里對一些匿名、不可變的 target atom 的引用生成,如在同一個 object file 里調用 static function |
by-name | 記錄指向目標 Atom name(c-string) 的指針 | 引用 global symbol,比如調用 printf |
indirect | 記錄指向 atom indirect table 中某個 index 的指針 | 非 input file 提供,只能由 linker 在 link 階段生成,可用於 atom 合並后的 case |
看一個簡單的例子:
// Foo.h
extern const int someGlobalVar;
int someGlobalFunction(void);
// Foo.m
const int someGlobalVar = 100;
int someGlobalFunction() {
return 123;
}
// main.m
#import "Foo.h"
int main(int argc, char * argv[]) {
int i = someGlobalVar;
someGlobalFunction();
}
上面的代碼中 main.m
調用了 Foo.h
定義的全局變量 someGlobalVar
和函數 someGlobalFunction
,compiler 生成的 main.o
和 Foo.o
存在以下 symbol:
link 時 ld64 會將其轉換成如下的 atom graph:
其中節點信息(atom)由 main.o
和 Foo.o
的 symbol table 提供,邊信息(fixup)由 main.o
的 relocation entries 提供。
如果涉及 ObjC,引用關系會更復雜一些,后文「-ObjC
的由來」一節會詳細展開。
ld64 — Symbol Table
ld64 內部維護了一個 SymbolTable
對象,里面包含了所有處理過的 symbol,並提供了各種快速查詢的接口。
往 SymbolTable
里增加 atom 時會觸發合並操作,主要分為兩種
- by-name:name 相同的 atom 可以合並為一個,如前面提到的 Strong / Weak & Tentative Definition
- by-content:content 相同的 atom 可以合並為一個,如 string constant
SymbolTable
核心的數據結構是 _indirectBindingTable
,這東西其實就是個存儲 atom 的數組,每個 atom 都會按解析順序被 append 到這個數組上(如果不被合並的話)。
同時 SymbolTable
還維護了多個 mapping,輔助用於外部根據 name、content、references 查詢某個 atom 的各類需求。
class SymbolTable : public ld::IndirectBindingTable
{
private:
// core vector
std::vector<const ld::Atom*>& _indirectBindingTable;
// for by-name query
NameToSlot _byNameTable;
// for by-content query
ContentToSlot _literal4Table;
ContentToSlot _literal8Table;
ContentToSlot _literal16Table;
UTF16StringToSlot _utf16Table;
CStringToSlot _cstringTable;
// fo by-reference query
ReferencesToSlot _nonLazyPointerTable;
ReferencesToSlot _threadPointerTable;
ReferencesToSlot _cfStringTable;
ReferencesToSlot _objc2ClassRefTable;
ReferencesToSlot _pointerToCStringTable;
}
ld64 在 Resolve 階段執行合並、處理 undefined 等操作都是基於該 SymbolTable
來完成。
三、ld64 命令參數
iOS 工程中一般不會主動觸發 ld64,可以在 Xcode build log 中找到 linking 對應的 clang 命令,復制到 terminal 加上 -v
來輸出 clang 調用的 ld 命令。
ld64 命令的參數形式為:
ld files... [options] [-o outputfile]
一個簡單工程的 ld64 參數大致如下:
ld -filelist xxx -framework Foundation -lobjc -o yyy
其中
-o
指定 output 的路徑- input files 的輸入有幾種方式
- 直接作為命令行的參數傳入
- 通過
-filelist
以文件的形式傳入,該文件以換行符分隔每一個 input file - 通過搜索路徑
-lxxx
,告訴 ld64 去 lib 搜索路徑找libxxx.a
或者libxxx.dylib
- lib 搜索路徑默認是
/usr/lib
和/usr/local/lib
- 可以通過
-Lpath/to/your/lib
來增加額外的 lib 搜索路徑
- lib 搜索路徑默認是
-framework xxx
,告訴 ld64 去 framework 搜索路徑找xxx.framework/xxx
- framework 搜索路徑默認是
/Library/Frameworks
和/System/Library/Frameworks
- 可以通過
-Fpath/to/your/framework
來增加額外的 framework 搜索路徑
- framework 搜索路徑默認是
- 如果指定了
-syslibroot /path/to/search
,會給 lib 和 framework 搜索路徑都加上/path/to/search
的前綴(如 iOS 模擬器一般會拼上形如/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.5.sdk
的路徑)
- 其他 options
四、ld64 執行流程
從頂層視角來看,ld64 接收一組 input files 和 options,輸出 executable(注:ld64 也支持 dylib 等其他類型的輸出,下面主要以 executable 為例)
執行邏輯可以分為以下 5 個大階段:
- Command line processing
- Parsing input files
- Resolving
- Passes/Optimizations
- Generate output file
Command Line Processing
第一步是解析命令行參數。比較直觀,就是把命令行參數字符串模型化成內存中的 Options
對象,便於后續邏輯的讀取。
這一步主要做兩件事:
-
把命令行里所有的 input,轉換成 input file paths。上文提到在命令行中為 ld64 指定 input files 的輸入有幾種方式(
-filelist
、各種搜索路徑等等的邏輯)都會在這一步轉換解析成實際 input files 的絕對路徑 -
把其他命令行參數(如
-dead_strip
)存到Options
對應的字段中
具體實現可參考 Options.cpp
中 Options
的構造函數:
// create object to track command line arguments
Options options(argc, argv);
Parsing input files
第二步是解析 input files。遍歷第一步解析出來的 input file paths,從 file system 讀取文件內容進一步分析轉換成
atom、fixup、sections 等信息,供 Resolver 后續使用。
ld::tool::InputFiles inputFiles(options);
上文提到 input files 主要分為 .o
、.a
、.dylib
三類,ld64 在解析不同類型的文件時,會調用該文件對應的 parser 來處理(如 .o
是 mach_o::relocatable::parse
),並返回對應的 ld::File
子類(如 .o
是 ld::relocatable::File
),有點工廠模式的味道。
解析 .o
.o
是 ld64 獲取 section 和 atom 信息的直接來源,因此需要深度地掃描。
mach_o::relocatable::parse
-
讀取 Header 和 Load Command
LC_SEGMENT_64
提供各個 section 的信息(位置、大小、relocation 位置、relocation 條目數等)LC_SYMTAB
提供 symbol table 信息(位置、大小、條目數)LC_DYSYMTAB
提供 symbol table 分類統計- local symbol 個數(該文件定義的 symbol,外部不可見)
- global / defined external symbol 個數(該文件定義的 symbol 且外部可見)
- undefined external symbol 個數(外部定義的 symbol)
LC_LINKER_OPTION
- Mach-O 中用來標識 linker option 的 Load Command,linker 會讀取這些 options 作為補充
- 比如 auto-linking 等特性,就依賴這個 Load Command 來實現(注入類似
-framework UIKit
的參數)
- 其他信息如
LC_BUILD_VERSION
-
對 section 和 symbol 按地址排序:因為 Mach-O 自帶的順序可能是亂的
-
makeSections
:根據LC_SEGMENT_64
創建 Section 數組,存入_sectionsArray
-
處理
__compact_unwind
和__eh_frame
-
創建
_atomsArray
:遍歷_sectionsArray
,把每個 section 的 atom 加入_atomsArray
-
makeFixups
:創建 fixup- 遍歷
_sectionsArray
,讀取該 section 的 relocation entries - 轉換成
FixupInAtom
- 存入
_allFixups
(vector<FixupInAtom>
)
- 遍歷
解析 .o
的邏輯參考 ld::relocatable::File* Parser<A>::parse
。
解析 .a
處理 .a
時一開始只處理 .a
的 symbol table (.a
的 symbol table 存儲的是 symbol name -> .o
offset,僅包含每個 .o
的 global symbols),不需要把內部所有的 .o
挨個解析一遍。Resolver 在 resolve undefined symbol 時會來查找 .a
的 symbol table 並按需懶加載對應的 .o
。
archive::Parser<A>::parse
- 讀取 header 校驗該文件是否是
.a
- 讀取
.a
symbol table header,獲取 symbol table 條目數 - 把 symbol table 的映射存到
_hashTable
中
解析 .dylib / .tbd
mach_o::dylib::parse
-
讀取 Header 和 Load Command(和
.o
類似)LC_SEGMENT_64
、LC_SYMTAB
、LC_DYSYMTAB
等和.o
類似LC_DYLD_INFO
、LC_DYLD_INFO_ONLY
提供 dynamic loader info- rebase info
- binding info
- weak binding info
- lazy binding info
- export info
- 其他信息如
LC_RPATH
、LC_VERSION_MIN_IPHONEOS
-
根據
LC_DYLD_INFO
、LC_DYLD_INFO_ONLY
、LC_DYLD_EXPORTS_TRIE
提供的 symbol 信息,存入_atoms
后續外部來查詢該 dylib 是否 export 某個符號時本質上都是查詢 _atoms
。
如果處理的是 .tbd
,關鍵是要獲取兩個信息:
- 提供哪些 export symbol (如 Foundation 的
_NSLog
) - 該動態庫還依賴哪些其他動態庫(如 Foundation 依賴 CoreFoundation & libobjc)
ld64 會借助 TAPI(https://opensource.apple.com/source/tapi/tapi-1.30/Readme.md)來 parse .tbd
文件,parse 完(其實就是調 yaml 解析庫解析了一遍)可以調接口(tapi::LinkerInterfaceFile
)直接得到結構化的信息。
Fat 文件
ld64 支持 fat 多架構的 Mach-O 解析。
在 InputFiles::makeFile
中可以看到取出目標架構的邏輯:
pthread 多線程處理
- 值得一提的是,考慮到不同 input files 的解析過程是互相獨立的,ld64 使用 pthread 實現了一個 worker pool 來並發處理 input files(worker 數和 CPU 邏輯核數相同)
- pthread 邏輯參考
InputFiles::InputFiles
的構造函數
Resolving
第三步是調用 Resolver
把 input files 提供的所有 atoms 匯總關聯成 atom graph 並處理,是「鏈接」的核心模塊。
實現上這里的邏輯也非常多,挑選核心流程來理解。
1. buildAtomList
這一步負責從解析好的 input files 中提取所有初始的 atom 並加入全局的 SymbolTable
中。
遍歷 inputFiles 並 parse
- 判斷 input file 在 InputFiles::InputFiles 階段是否已經 parse 完
- 已 parse 完,進行下一步
- 沒 parse 完,嘗試啟動一個 pthread worker 處理 inputFile(執行邏輯和第一步「解析 Input」里一樣),並
pthread_cond_wait
等待
加載 .o
的 atoms
parse 階段 ld64 已經從 object file 的 symbol table 和 relocation entries 中抽象出了 _atoms
,這一步挨個處理即可。
Resolver::doAtom
處理單個 atom 的邏輯 :
-
SymbolTable::add
(僅 global symbol & undefined external symbol,local symbol 不處理)- 如果 name 沒出現過,append 到
_indirectBindingTable
(定義見「概念鋪墊 — Symbol Table」 - 如果 name 出現過,考慮 strong / weak 等 symbol definition 沖突解決策略
- 同步更新幾張輔助 mapping 表
NameToSlot
、ContentToSlot
、ReferencesToSlot
- 如果 name 沒出現過,append 到
-
遍歷該 atom 的 fixup,嘗試把 by-name / by-content 的 reference 轉成 by-slot(直接指向對應
_indirectBindingTable
中對應的 atom)
加載 .a
的 atoms
buildAtomList 階段理論上完全不需要處理靜態庫,因為只有在后面 resolve undefined symbol 時才有可能查詢靜態庫里包含的 symbol。但在以下兩種情況下,這一步需要對靜態庫內的 .o
展開處理:
- 如果該
.a
受-all_load
或-force_load
影響,強制 load 所有.o
- 如果 ld64 開啟了
-ObjC
,強制 load 所有包含 ObjC class 和 category 的.o
(symbol name 包含_OBJC_CLASS_
、.objc_c
)
load 過程和前面提到的 object file 的 parse & 加載 atoms 一樣。
靜態庫 File 對象內部還會維護一個 MemberToStateMap,來記錄 .o
的 load 狀態
加載 .dylib
的 atoms
buildAtomList 階段不 add 動態庫的 atoms,但會做一些額外的處理和校驗,包括 bitcode bundle(__LLVM, __bundle
)、 Swift framework 依賴檢查、Swift 版本檢查等。
2. resolveUndefines
此時 SymbolTable
中已經收集了 input files 中的大部分 atom,下一步需要把其中歸屬不明的 symbol 引用關聯到對應的 symbol 定義上去。
-
遍歷
SymbolTable
中 undefined symbol (被 reference 的但是沒有對應 atom 實體的 symbol definition) -
對每一個 undefined symbol ,嘗試去靜態庫 & 動態庫里找
- 靜態庫:前面提到靜態庫維護了一個 symbol name ->
.o
offset 的 mapping,因此要判斷某個 symbol definition 是否屬於該靜態庫只需要去這個 mapping 里查即可。如果查找到了,則解析對應的.o
、並把該.o
的 atoms 加入SymbolTable
中(.o
的加載邏輯參考前文 Parsing input files 和 buildAtomList) - 動態庫:如果匹配到了某個動態庫的 exported symbol,ld64 會為該 undefined atom 創建一個 proxy atom 表示對動態庫中的引用。
- 靜態庫:前面提到靜態庫維護了一個 symbol name ->
-
如果靜態庫 & 動態庫里都沒找到,判斷是否是
section$
、segment$
等 boundary atoms,並手動創建對應的 symbol definition -
處理 tentative symbol
-
如果
-undefined
不是 error(命令行參數控制發現 undefined symbol 時不報錯)、或者命中了-U
(參數控制某些 undefined symbol 不報錯),那么 ld64 會手動創建一個UndefinedProxyAtom
作為其 symbol definition
由於搜索靜態庫和動態庫的過程中有可能引入新的 undefined symbol,因此一次遍歷結束后需要判斷該條件並按需重新遍歷。
3. deadStripOptimize
接下來執行開啟了 -dead_strip
后的邏輯。此時所有的 atom 和它們之間的引用關系已經記錄在了 SymbolTable
中,可以把所有的 atom 抽象成 atom graph 來移除沒有被引用到的無用 atom。
- 初始化 root atoms
- entry point atom(如
_main
) - 所有被
-u
(強制加載某個 symbol,即使在靜態庫中)、-exported_symbols_list
、-exported_symbol
(在 output 中作為 global symbol 輸出) 命中的 atoms - dyld 相關的幾個 stub atom
- 所有被標記為 dont-dead-strip 的 atom(該 atom 對應的 section 在
.o
中被標記為了S_ATTR_NO_DEAD_STRIP
)
- entry point atom(如
- 從 root atoms 開始通過 fixup 遍歷 atom graph,把它們能遍歷到的 atoms 都標記為 live
- 移除 dead atom
4. removeCoalescedAwayAtoms
遍歷一遍 atoms,移除所有被合並的 atom。
(Symbol 的合並參考「概念鋪墊 — Symbol」)
5. fillInInternalState
遍歷一遍 atoms,把它們按照所屬的 section 歸類存放。
Passes/Optimizations
至此,我們已經擁有了寫 output 所需要的完整的、有關聯的信息了(sections & 對應的 atoms)。在輸出之前,還需要執行多輪的「Pass」。一個 Pass 對應實現某一特定特性的代碼邏輯,如
ld::passes::objc
ld::passes::stubs
ld::passes::dylibs
ld::passes::dedup::doPass
- ...
pass 依次執行,個別 pass 之間也會強制要求執行的先后順序以保證輸出的正確性。
每個工程可以結合實際需求調整要執行的 pass。
Generate Output files
最后一步是輸出 output files。ld64 的輸出包括主 output 文件和其他輔助輸出如 link map、dependency info 等。
在正式輸出前,ld64 還執行了一些其他操作,包括:
- ...
synthesizeDebugNotes
buildSymbolTable
generateLinkEditInfo
buildChainedFixupInfo
- ...
其中 buildSymbolTable
負責構建 output file 中的 symbol table。「概念鋪墊 — Symbol」中提到每個 symbol 在 link 階段有自己的 visibility,用來控制 link 時對其他文件的可見性。同理,在 link 結束后輸出的 Mach-O 中這些 symbol 現在隸屬於一個新的文件,此時它們的 visibility 要被 ld64 依據各種處理策略來重新調整:
- 前文提到的被標記為 private extern 的 symbol,這一步被轉換為 local symbol
- ld64 也提供了多種參數來控制這一行為,如
-reexport-lx
、-reexport_library
、-reexport_framework
(指定 lib 的 global symbol 在 output 中繼續為 global)、-hidden-lx
(指定 lib 中的 symbol 在 output 中轉為 hidden)
上述操作都忙完后,ld64 就會拿着 FinalSection
數組愉快地去寫 output file 了,大致邏輯如下:
- 開辟一塊內存,維護一個當前寫入位置的 offset 指針
- 遍歷
FinalSection
數組- 遍歷 atoms
- 如果是動態庫創建的 proxy atom,跳過(不占用輸出文件的空間)
- 把 atom content 寫入當前 offset
- 遍歷 fixups(
applyFixUps
),根據 fixup 的類型修正 atom content 對應位置的內容
- 遍歷 atoms
五、ld64 on iOS
Auto Linking
auto linking 是一種不用主動聲明 -l
、 -framework
等 lib 依賴也能讓 linker 正常工作的機制。
比如:
- 某個源文件聲明依賴了
#import <AppKit/AppKit.h>
- link 時不指定
-framework AppKit
- 編譯生成的
.o
的LC_LINKER_OPTION
中帶有-framework AppKit
又或者:
- 某個源文件聲明了
#import <zlib.h>
/usr/include/module.modulemap
內容
module zlib [system] [extern_c] {
header "zlib.h"
export *
link "z"
}
- link 時不指定
-lz
- 編譯生成的
.o
的LC_LINKER_OPTION
中帶有-lz
實現原理:compiler 編譯 .o
時,解析 import,把依賴的 framework 寫入最后 Mach-O 里的 LC_LINKER_OPTION
(存儲了對應的 -framework XXX
信息)
要注意的是,開啟 Clang module 時(-fmodules
)自動開啟 auto linking 。可以用 -fno-autolink
主動關閉。
-ObjC
的由來
前面提到開啟了 -ObjC
后,ld64 會在解析符號 search lib 時強制加載每個靜態庫內包含 ObjC class 和 category 的 .o
。這么做的原因是什么呢?
經試驗可發現:
- ObjC 的 class 定義對應 symbol 的 visibility 為
global
(自己定義、link 時外部文件可見) - ObjC 的 class 調用對應 symbol 的 visibility 為
undefined external
(外部定義、需要 link 時 fixup) - ObjC 的 method 定義對應 symbol 的 visibility 為
local
(對外部不可見) - ObjC 的 method 調用不會生成 symbol
假設現在有兩個類 ClassA
& ClassB
:
// ClassA.m
#import "ClassB.h"
@implementation ClassA
- (void)methodA
{
[[ClassB new] methodB];
}
@end
// ClassB.m
@implementation ClassB
- (void)methodB
{
}
@end
編譯后,ClassA.o
:
- global symbol:...
- local symbol:...
- undefined external symbol:
_OBJC_CLASS_$_ClassB
ClassB.o
:
- global symbol:
_OBJC_CLASS_$_ClassB
- local symbol:
-[ClassB methodB]
- undefined external:...
雖然 ClassA 調用了 ClassB 的方法,但 Class A 生成的 object file 的 symbol table 中只有 _OBJC_CLASS_$_ClassB
這個對 ClassB 類本身的 reference,根本沒有 -[ClassB methodB]
。這樣的話,按照 ld64 正常的解析邏輯,既不會因為 ClassA
中對 methodB
的調用去尋找 ClassB.m
的定義(壓根沒有生成 undefined external
)、即使想找,ClassB
也沒有暴露這個 method 的 symbol (local symbol 對外部文件不可見)。
既然如此,ObjC 的 method 定義為什么不會被 ld64 認為是 dead code 而 strip 掉呢?
其實是因為 ObjC 的 class 定義會間接引用到它的 method 定義。比如上面 ClassB
的例子中,atom 之間的依賴關系如下:
_OBJC_CLASS_$_ClassB
-> __OBJC_CLASS_RO_$_ClassB
->
__OBJC_$_INSTANCE_METHODS_ClassB
-> -[ClassB methodB]
只要這個 class 定義被引用了,那么它的所有 method 定義也會被一起認為是 live code 而保留下來。
再看看引入 Category 后的情況:
- 假設 B 定義了
ClassB
和methodB
- C 是 B 的 category,定義了
ClassB
的methodBFromCategory
- A 引用了
ClassB
和methodB
、methodBFromCategory
這種情況下:
- 因為 A 引用了 B 的 ClassB,所以 B 要被 ld64 加載。
- 雖然 A 引用了 C 的
methodBFromCategory
,但 A 沒有解析methodBFromCategory
這個符號的需求(沒生成),因此 ld64 不需要加載 C。
為了讓程序能正確執行,C 的 methodBFromCategory
定義必須被 ld64 link 進來。這里需要分兩種情況:
- 如果 C 在主工程中,ld64 需要直接解析 C 生成的 object file,並生成如下 atom 依賴:
objc-cat-list
-> __OBJC_$_CATEGORY_ClassB_$_SomeCategory
-> __OBJC_$_CATEGORY_INSTANCE_METHODS_ClassB_$_SomeCategory
->
-[ClassB(SomeCategory) methodBFromCategory]
其中 objc-cat-list
表示所有 ObjC 的 categories,在 dead code strip 初始階段被標記為 live,因此 methodBFromCategory
會被 link 進 executable 而不被裁剪。
- 如果 C 被封裝在一個靜態庫里,link 時 ld64 沒有動機去加載 C,
methodBFromCategory
沒有被 link 進 executable,導致最終運行時ClassB
沒有加載該 category、執行時錯誤。
所以才有了 -ObjC
這個開關,保證靜態庫中單獨定義的 ObjC category 被 link 進最終的 output 中。
現在的 Xcode 中一般默認都開啟了 -ObjC
,但這種為了兼容 category 而暴力加載靜態庫中所有 ObjC class 和 category 的實現並不是最完美的方案,因為可能因此在 link 階段加載了許多本不需要加載的 ObjC class。理論上我們可以通過人為在 category 定義和引用之間建立引用關系來讓 ld64 在不開啟 -ObjC
的情況下也能加載 category,比如 IGListKit 就曾嘗試手動注入一些 weak 的 dummy 變量(PR https://github.com/Instagram/IGListKit/pull/957) ,但這種做法為了不劣化也會帶來一定維護成本,因此也需要權衡。
ld64 中對 -ObjC
的處理可參考 src/ld/parsers/archive_file.cpp
:
bool File<A>::forEachAtom(ld::File::AtomHandler& handler) const
{
bool didSome = false;
if ( _forceLoadAll || _forceLoadThis ) {
// call handler on all .o files in this archive
...
}
else if ( _forceLoadObjC ) {
// call handler on all .o files in this archive containing objc classes
for (const auto& entry : _hashTable) {
if ( (strncmp(entry.first, ".objc_c", 7) == 0) || (strncmp(entry.first, "_OBJC_CLASS_$_", 14) == 0) ) {
const Entry* member = (Entry*)&_archiveFileContent[entry.second];
MemberState& state = this->makeObjectFileForMember(member);
char memberName[256];
member->getName(memberName, sizeof(memberName));
didSome |= loadMember(state, handler, "-ObjC forced load of %s(%s)\n", this->path(), memberName);
}
}
// ObjC2 has no symbols in .o files with categories but not classes, look deeper for those
const Entry* const start = (Entry*)&_archiveFileContent[8];
const Entry* const end = (Entry*)&_archiveFileContent[_archiveFilelength];
...
}
...
}
六、其他
調試向的命令行參數
ld64 也提供了豐富的參數供開發者查詢其執行過程,可以在 mac 上通過 man ld
查看 Options for introspecting the linker 一欄
-print_statistics
打印 ld64 各大步驟的耗時分布。
ld total time: 2.26 seconds
option parsing time: 6.9 milliseconds ( 0.3%)
object file processing: 0.1 milliseconds ( 0.0%)
resolve symbols: 2.24 seconds
build atom list: 0.0 milliseconds ( 0.0%)
passess: 6.2 milliseconds ( 0.2%)
write output: 10.4 milliseconds ( 0.4%)
-t
打印 ld64 加載的每一個 .o
.a
.dylib
。
-why_load xxx
打印 .a
中 .o
被加載的原因(即什么 symbol 被需要)。
-ObjC forced load of bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTHomeTab/libCommon.a(ArticleTabBarStyleNewsListScreenshotsProvider_IMP.o)
-ObjC forced load of bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTHomeTab/libCommon.a(TTExploreMainViewController.o)
-ObjC forced load of bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTHomeTab/libCommon.a(TTFeedCollectionViewController.o)
-ObjC forced load of bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTHomeTab/libCommon.a(TTFeedCollectionFollowListCell.o)
....
_dec_8i40_31bits forced load of external/TTAudio/Vendor/libopencore-amrnb.a(d8_31pf.o)
_decode_2i40_11bits forced load of external/TTAudio/Vendor/libopencore-amrnb.a(d2_11pf.o)
_decode_2i40_9bits forced load of external/TTAudio/Vendor/libopencore-amrnb.a(d2_9pf.o)
-why_live xxx
打印開啟 -dead_strip
后,某個 symbol 的 reference chain(即不被 strip 的原因)
比如 -why_live _OBJC_CLASS_$_TTNewUserHelper
:
_OBJC_CLASS_$_TTNewUserHelper from external/TTVersionHelper/ios-arch-iphone/libTTVersionHelper_TTVersionHelper_awesome_ios.a(TTNewUserHelper.o)
objc-class-ref from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTPrivacyAlertManager/libNews.a(TTPrivacyAlertManager.swift.o)
+[TTDetailLogManager createLogItemWithGroupID:] from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailLogManager.o)
__OBJC_$_CLASS_METHODS_TTDetailLogManager from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailLogManager.o)
__OBJC_METACLASS_RO_$_TTDetailLogManager from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailLogManager.o)
_OBJC_METACLASS_$_TTDetailLogManager from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailLogManager.o)
_OBJC_CLASS_$_TTDetailLogManager from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailLogManager.o)
objc-class-ref from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/LMCoreKitTTAdapter/libNews.a(LMDetailTechnicalLoggerImpl.o)
___73-[TTDetailFetchContentManager fetchDetailForArticle:priority:completion:]_block_invoke from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailFetchContentManager.o)
-[TTDetailFetchContentManager fetchDetailForArticle:priority:completion:] from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailFetchContentManager.o)
__OBJC_$_INSTANCE_METHODS_TTDetailFetchContentManager from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailFetchContentManager.o)
__OBJC_CLASS_RO_$_TTDetailFetchContentManager from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailFetchContentManager.o)
_OBJC_CLASS_$_TTDetailFetchContentManager from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTDetail/libCommon.a(TTDetailFetchContentManager.o)
objc-class-ref from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/BDAudioBizTTAdaptor/libNews.a(TTAudioFetchableImp.o)
objc-class-ref from bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/BDAudioBizTTAdaptor/libNews.a(TTAudioFetchableImp.o)
-map
(linkmap)
輸出 linkmap 到指定路徑,包含所有 symbols 和對應地址的 map 。
# Path: /Users/bytedance/NewsInHouse_bin
# Arch: x86_64
# Object files:
...
[3203] bazel-out/ios-x86_64-min10.0-applebin_ios-ios_x86_64-dbg-ST-7bf874b56ea0/bin/Module/TTHomeTab/libCommon.a(TTFeedActivityView.o)
...
# Sections:
# Address Size Segment Section
0x100004000 0x0D28B292 __TEXT __text
0x10D28F292 0x00011586 __TEXT __stubs
...
0x10D70B5E8 0x00346BE0 __DATA __cfstring
0x10DA521C8 0x00032170 __DATA __objc_classlist
...
# Symbols:
# Address Size File Name
0x100004590 0x00000020 [ 8] -[NSNull(Addition) boolValue]
...
0x1117EE0C6 0x00000027 [4282] literal string: -[TTFeedGeneralListView skipTopHeight]
...
0x1104B4430 0x00000028 [22685] _OBJC_METACLASS_$_MQPWebService
0x1104B4458 0x00000028 [22685] _OBJC_CLASS_$_APayH5WapViewToolbar
...
0x1114A9CD4 0x0000005C [ 10] GCC_except_table0
0x1114A9D30 0x00000028 [ 14] GCC_except_table12
...
<<dead>> 0x00000008 [3269] _kCoverAcatarMargin
<<dead>> 0x00000008 [3269] _kCoverTitleMargin
...
LTO — Link Time Optimization
LTO 是一種鏈接期全模塊級別代碼優化的技術。開啟 LTO 后 ld64 會借助 libLTO 來實現相關功能。關於 ld64 處理 LTO 的機制后續會單獨另寫一篇文章介紹。
結語
本文從源碼角度分析了 ld64 的主體工作原理,實際應用中工程可結合自身需求對 ld64 進行定制來修復特定問題或者實現特定功能。本文也是系列的第一章內容,后續會帶來更多靜態鏈接器的介紹,包括 zld,lld,mold 等,敬請期待。
參考資料
- https://opensource.apple.com/source/ld64/
- https://opensource.apple.com/source/ld64/ld64-136/doc/design/linker.html
- https://github.com/aidansteele/osx-abi-Mach-O-file-format-reference
關於字節終端技術團隊
字節跳動終端技術團隊(Client Infrastructure)是大前端基礎技術的全球化研發團隊(分別在北京、上海、杭州、深圳、廣州、新加坡和美國山景城設有研發團隊),負責整個字節跳動的大前端基礎設施建設,提升公司全產品線的性能、穩定性和工程效率;支持的產品包括但不限於抖音、今日頭條、西瓜視頻、飛書、瓜瓜龍等,在移動端、Web、Desktop等各終端都有深入研究。
就是現在!客戶端/前端/服務端/端智能算法/測試開發 面向全球范圍招聘!一起來用技術改變世界,感興趣請聯系 chenxuwei.cxw@bytedance.com,郵件主題 簡歷-姓名-求職意向-期望城市-電話。