深入 iOS 靜態鏈接器(一)— ld64


作者:字節跳動終端技術——李翔

前言

靜態鏈接(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 的命名據說是來自 LoaDerLink 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 類型執行以下合並策略:

  1. 有多個 strong => 非法輸入,abort
  2. 有且僅有一個 strong => 取該 strong
  3. 有多個 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 頭開始偏移多少位置的內容需要 relocate
  • r_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.oFoo.o 存在以下 symbol:

link 時 ld64 會將其轉換成如下的 atom graph:

其中節點信息(atom)由 main.oFoo.o 的 symbol table 提供,邊信息(fixup)由 main.o 的 relocation entries 提供。

如果涉及 ObjC,引用關系會更復雜一些,后文「-ObjC 的由來」一節會詳細展開。

ld64 — Symbol Table

ld64 內部維護了一個 SymbolTable 對象,里面包含了所有處理過的 symbol,並提供了各種快速查詢的接口。

SymbolTable 里增加 atom 時會觸發合並操作,主要分為兩種

  1. by-name:name 相同的 atom 可以合並為一個,如前面提到的 Strong / Weak & Tentative Definition
  2. 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 搜索路徑
      • -framework xxx,告訴 ld64 去 framework 搜索路徑找 xxx.framework/xxx
        • framework 搜索路徑默認是 /Library/Frameworks/System/Library/Frameworks
        • 可以通過 -Fpath/to/your/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 個大階段:

  1. Command line processing
  2. Parsing input files
  3. Resolving
  4. Passes/Optimizations
  5. Generate output file

Command Line Processing

第一步是解析命令行參數。比較直觀,就是把命令行參數字符串模型化成內存中的 Options 對象,便於后續邏輯的讀取。

這一步主要做兩件事:

  1. 把命令行里所有的 input,轉換成 input file paths。上文提到在命令行中為 ld64 指定 input files 的輸入有幾種方式(-filelist、各種搜索路徑等等的邏輯)都會在這一步轉換解析成實際 input files 的絕對路徑

  2. 把其他命令行參數(如 -dead_strip)存到 Options 對應的字段中

具體實現可參考 Options.cppOptions 的構造函數:

// 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 來處理(如 .omach_o::relocatable::parse),並返回對應的 ld::File 子類(如 .old::relocatable::File),有點工廠模式的味道。

解析 .o

.o 是 ld64 獲取 section 和 atom 信息的直接來源,因此需要深度地掃描。

mach_o::relocatable::parse

  1. 讀取 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
  2. 對 section 和 symbol 按地址排序:因為 Mach-O 自帶的順序可能是亂的

  3. makeSections:根據 LC_SEGMENT_64 創建 Section 數組,存入 _sectionsArray

  4. 處理 __compact_unwind__eh_frame

  5. 創建 _atomsArray:遍歷 _sectionsArray,把每個 section 的 atom 加入 _atomsArray

  6. 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

  1. 讀取 header 校驗該文件是否是 .a
  2. 讀取 .a symbol table header,獲取 symbol table 條目數
  3. 把 symbol table 的映射存到 _hashTable

解析 .dylib / .tbd

mach_o::dylib::parse

  1. 讀取 Header 和 Load Command(和 .o 類似)

    • LC_SEGMENT_64LC_SYMTABLC_DYSYMTAB 等和 .o 類似
    • LC_DYLD_INFOLC_DYLD_INFO_ONLY 提供 dynamic loader info
      • rebase info
      • binding info
      • weak binding info
      • lazy binding info
      • export info
    • 其他信息如 LC_RPATHLC_VERSION_MIN_IPHONEOS
  2. 根據 LC_DYLD_INFOLC_DYLD_INFO_ONLYLC_DYLD_EXPORTS_TRIE 提供的 symbol 信息,存入 _atoms

后續外部來查詢該 dylib 是否 export 某個符號時本質上都是查詢 _atoms

如果處理的是 .tbd,關鍵是要獲取兩個信息:

  1. 提供哪些 export symbol (如 Foundation 的 _NSLog
  2. 該動態庫還依賴哪些其他動態庫(如 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 的邏輯 :

  1. SymbolTable::add(僅 global symbol & undefined external symbol,local symbol 不處理)

    • 如果 name 沒出現過,append 到 _indirectBindingTable (定義見「概念鋪墊 — Symbol Table」
    • 如果 name 出現過,考慮 strong / weak 等 symbol definition 沖突解決策略
    • 同步更新幾張輔助 mapping 表 NameToSlotContentToSlotReferencesToSlot
  2. 遍歷該 atom 的 fixup,嘗試把 by-name / by-content 的 reference 轉成 by-slot(直接指向對應 _indirectBindingTable 中對應的 atom)

加載 .a 的 atoms

buildAtomList 階段理論上完全不需要處理靜態庫,因為只有在后面 resolve undefined symbol 時才有可能查詢靜態庫里包含的 symbol。但在以下兩種情況下,這一步需要對靜態庫內的 .o 展開處理:

  1. 如果該 .a-all_load-force_load 影響,強制 load 所有 .o
  2. 如果 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 定義上去。

  1. 遍歷 SymbolTable 中 undefined symbol (被 reference 的但是沒有對應 atom 實體的 symbol definition)

  2. 對每一個 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 表示對動態庫中的引用。
  3. 如果靜態庫 & 動態庫里都沒找到,判斷是否是 section$segment$ 等 boundary atoms,並手動創建對應的 symbol definition

  4. 處理 tentative symbol

  5. 如果 -undefined 不是 error(命令行參數控制發現 undefined symbol 時不報錯)、或者命中了 -U(參數控制某些 undefined symbol 不報錯),那么 ld64 會手動創建一個 UndefinedProxyAtom 作為其 symbol definition

由於搜索靜態庫和動態庫的過程中有可能引入新的 undefined symbol,因此一次遍歷結束后需要判斷該條件並按需重新遍歷。

3. deadStripOptimize

接下來執行開啟了 -dead_strip 后的邏輯。此時所有的 atom 和它們之間的引用關系已經記錄在了 SymbolTable 中,可以把所有的 atom 抽象成 atom graph 來移除沒有被引用到的無用 atom。

  1. 初始化 root atoms
    1. entry point atom(如 _main
    2. 所有被 -u(強制加載某個 symbol,即使在靜態庫中)、-exported_symbols_list-exported_symbol(在 output 中作為 global symbol 輸出) 命中的 atoms
    3. dyld 相關的幾個 stub atom
    4. 所有被標記為 dont-dead-strip 的 atom(該 atom 對應的 section 在 .o 中被標記為了 S_ATTR_NO_DEAD_STRIP
  2. 從 root atoms 開始通過 fixup 遍歷 atom graph,把它們能遍歷到的 atoms 都標記為 live
  3. 移除 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 依據各種處理策略來重新調整:

  1. 前文提到的被標記為 private extern 的 symbol,這一步被轉換為 local symbol
  2. 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 對應位置的內容

五、ld64 on iOS

Auto Linking

auto linking 是一種不用主動聲明 -l-framework 等 lib 依賴也能讓 linker 正常工作的機制。

比如:

  • 某個源文件聲明依賴了 #import <AppKit/AppKit.h>
  • link 時不指定 -framework AppKit
  • 編譯生成的 .oLC_LINKER_OPTION 中帶有 -framework AppKit

又或者:

  • 某個源文件聲明了 #import <zlib.h>
  • /usr/include/module.modulemap 內容
module zlib [system] [extern_c] {
 header "zlib.h"
 export *
 link "z"
}
  • link 時不指定 -lz
  • 編譯生成的 .oLC_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 定義了 ClassBmethodB
  • C 是 B 的 category,定義了 ClassBmethodBFromCategory
  • A 引用了 ClassBmethodBmethodBFromCategory

這種情況下:

  • 因為 A 引用了 B 的 ClassB,所以 B 要被 ld64 加載。
  • 雖然 A 引用了 C 的 methodBFromCategory,但 A 沒有解析 methodBFromCategory 這個符號的需求(沒生成),因此 ld64 不需要加載 C。

為了讓程序能正確執行,C 的 methodBFromCategory 定義必須被 ld64 link 進來。這里需要分兩種情況:

  1. 如果 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 而不被裁剪。

  1. 如果 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,郵件主題 簡歷-姓名-求職意向-期望城市-電話


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM