前置知識
符號
靜態(tài)鏈接
匯編基礎(chǔ)
虛擬內(nèi)存
跳轉(zhuǎn)指令的編碼:PC相對地址與絕對地址
匯編跳轉(zhuǎn)指令:直接跳轉(zhuǎn)與間接跳轉(zhuǎn)
正文開始
靜態(tài)庫有兩個主要缺點:
- 一份代碼在所有的進程和可執(zhí)行文件中都有一份拷貝,極大浪費磁盤和內(nèi)存空間
- 給程序的更新、發(fā)布帶來很多麻煩。例如:假如
UIKit
是個靜態(tài)庫,蘋果爸爸更新了UIKit
修改了個小bug,那么所有用到UIKit
的app都得用更新后的UIKit
庫重新鏈接產(chǎn)生一個新的app,然后上傳AppStore.....否則你的app中用的老舊的UIKit
。但是現(xiàn)實中并沒有那么麻煩,系統(tǒng)每次升級都可能改了你項目中用到系統(tǒng)庫,但是并不需要重新鏈接生成新app,升級系統(tǒng)后啟動app時才會去查找系統(tǒng)中的動態(tài)庫,利用動態(tài)連接器動態(tài)加載到內(nèi)存中供app使用。完美解決靜態(tài)庫的第二個缺點
第一個缺點也好解決:假如多個進程都依賴于同一個庫,那么只需要在系統(tǒng)中放一份,用到的時候加載到內(nèi)存中,另一個進程也需要用的時候直接使用已經(jīng)加載到內(nèi)存的庫代碼即可。例如UIKit
。在系統(tǒng)中放一份動態(tài)庫代碼簡單,但是內(nèi)存中多個進程共享一份庫代碼怎么做到呢?
難點在于:1. 進程中用到了動態(tài)庫里的符號,進程需要知道去哪里找這個符號 2. 動態(tài)庫內(nèi)部有函數(shù)調(diào)用和變量常量等符號的引用,動態(tài)庫編譯鏈接時就需要給符號的引用方明確符號定義地址,以便在運行中去尋找。如果動態(tài)庫每次加載到一個固定的地址就好了,這樣進程知道動態(tài)庫的符號的地址,編譯鏈接動態(tài)庫時也能把所有引用符號的地方都填上固定的地址。
這種方法很簡單,但是它也造成了一些嚴重的問題。它對地址空間的使用效率不高,因為即使一個進程不使用這個庫,那部分空間還是會被分配出來。它也難以管理。我們必須保證沒有片會重疊。每當一個庫修改了后,我們必須確認已分配給它的片還適合它的大小。如果不適合,必須要找一個新的片。并且如果創(chuàng)建了一個新的庫,我們還必須為它尋找空間。隨著時間的發(fā)展,假設(shè)在一個系統(tǒng)中有了成百上千個庫和庫的各個版本庫,就很難避免地址空間分裂成大量小的、未使用而又不能使用的小洞。更糟的是,對每個系統(tǒng)而言,庫在內(nèi)存中的分配都是不同的,這就引起了更多令人頭痛的管理問題。
如果利用虛擬內(nèi)存,多個進程使用同一個共享庫時,系統(tǒng)控制不同進程下將該共享庫映射到隨意位置,這需要動態(tài)鏈接器在加載的時候修正進程中所有共享庫符號的引用,將符號的真實地址修正至引用的內(nèi)存中。但是這樣本來只讀可運行的代碼段就要變?yōu)榭勺x可寫可運行的了,不符合安全需求也會導致加載時大量修改造成進程變慢。
要避免這些問題,現(xiàn)在系統(tǒng)以這樣一種方式編譯共享模塊的代碼段,使得可以把他們加載到內(nèi)存的任何位置而無需鏈接器修改。使用這種方法 ,無限多個進程可以共享一個共享模塊的代碼段的單一副本。(當然,每個進程仍然會有它自己的讀/寫數(shù)據(jù)塊,因為不同進程可能會給不同全局變量分配不同的值,所以能共享的只是代碼段,對于共享模塊的數(shù)據(jù)段在多個進程中還是有個多個副本的)
這種可以加載而無需重定位的代碼成為位置無關(guān)代碼(Position-Independent Code, PIC)。用戶對GCC使用-fpic選項指示GUN編譯系統(tǒng)生成PIC代碼。共享庫的編譯必須總是使用該選項。
編譯器通過運用一下這個有趣的事實來生成全局變量的PIC引用:無論我們在內(nèi)存中的何處加載一個目標模塊(包括運行模塊和共享目標模塊),數(shù)據(jù)段與代碼段的距離總是保持不變。因此代碼段中任何指令和數(shù)據(jù)段中的任何變量之間的距離都是一個運行時常量,即:當前指令的地址加上某個常量可以指向任何數(shù)據(jù)段中的符號地址。
上面講到本來想在加載共享庫的時候修改代碼段中對于共享庫定義全局符號的引用地址,由于代碼段是不可寫而不能這么做。那么我們可以在數(shù)據(jù)段建立一個條目A
,條目中存放共享庫定義的全局符號symbolA
的地址,讓代碼段對于該符號的引用都指向A
。編譯時A
默認為0,運行加載動態(tài)庫時根據(jù)動態(tài)庫加載位置修改A
的存放內(nèi)容為symbolA
的地址。那么一次加載時修改所有符號對應的數(shù)據(jù)段條目造成的效率低下問題怎么解決呢?可以先不修改,直到第一次用到該符號的時候才去數(shù)據(jù)段里讀取該條目的內(nèi)容,如果發(fā)現(xiàn)為0就觸發(fā)查找符號真實地址的邏輯,找到后填寫到A
中,方便以后的調(diào)用。
以上就是動態(tài)鏈接的基本過程,當然不同系統(tǒng)會有些差異或者更復雜一下,但是基本思路是一致的。
MachO與動態(tài)鏈case
首先,有一個文件 say.c:
#include <stdio.h>
char *kHelloPrefix = "Hello";
void say(char *prefix, char *name)
{
printf("%s, %s\n", prefix, name);
}
該模塊很簡單,定義了兩個符號:常量字符串kHelloPrefix,以及函數(shù)say。使用 gcc 把say.c編譯成 dylib:
gcc -fPIC -shared say.c -o libsay.dylib
# 生成 libsay.dylib
再定義一個使用 say 模塊的 main.c:
void say(char *prefix, char *name);
extern char *kHelloPrefix;
int main(void)
{
say(kHelloPrefix, "Jack");
return 0;
}
把 main.c 編譯成可重定位中間文件(只編譯不鏈接):
gcc -c main.c -o main.o
# 生成可重定位中間文件:main.o
此時的 main.o 是不可執(zhí)行的,需要使用鏈接器 ld 將 sayHello 鏈接進來:
ld main.o -macosx_version_min 10.14 -o main.out -lSystem -L. -lsay
# -macosx_version_min 用于指定最小系統(tǒng)版本,這是必須的
# -lSystem 用于鏈接 libSystem.dylib
# -lsay 用于鏈接 libsay.dylib
# -L. 用于新增動態(tài)鏈接庫搜索目錄
# 生成可執(zhí)行文件:main.out
這樣就生成了可執(zhí)行文件 main.out,執(zhí)行該文件,打印「Hello, Jack」。此時若使用xcrun dyldinfo -dylibs查看 main.out 的依賴庫,會發(fā)現(xiàn)有兩個依賴庫:
xcrun dyldinfo -dylibs main.out
attributes dependent dylibs
/usr/lib/libSystem.B.dylib
libsay.dylib
這兩個動態(tài)庫的依賴在 Mach-O 文件中對應兩條 type 為LC_LOAD_DYLIB的 load commands,使用otool -l查看如下:
Load command 12
cmd LC_LOAD_DYLIB
cmdsize 56
name /usr/lib/libSystem.B.dylib (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 1252.200.5
compatibility version 1.0.0
Load command 13
cmd LC_LOAD_DYLIB
cmdsize 40
name libsay.dylib (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 0.0.0
LC_LOAD_DYLIB命令的順序和 ld 的鏈接順序一致。
LC_LOAD_DYLIB命令參數(shù)描述了 dylib 的基本信息,結(jié)構(gòu)比較簡單:
struct dylib {
union lc_str name; // dylib 的 path
uint32_t timestamp; // dylib 構(gòu)建的時間戳
uint32_t current_version; // dylib 的版本
uint32_t compatibility_version; // dylib 的兼容版本
};
無論是靜態(tài)鏈接,還是動態(tài)鏈接,符號都是最重要的分析對象;來看看 main.out 的符號表(symbol table):
可以看到,symbol table 中有三個未綁定的外部符號:
_kHelloPrefix
、_say
、dyld_stub_binder
;本文接下來對 Mach-O 文件結(jié)構(gòu)的分析將圍繞這 3 個符號進行展開。
結(jié)構(gòu)分析
先將 Mach-O 中與動態(tài)鏈接相關(guān)的結(jié)構(gòu)給羅列出來:
- Section
- __TEXT __stubs
- __TEXT __stub_helper
- __DATA __nl_symbol_ptr
- __DATA __got
- __DATA __la_symbol_ptr
- Load Command
- LC_LOAD_DYLIB
- LC_SYMTAB
- LC_DYSYMTAB
- Symbol Table
- Indirect Symbol Table
- Dynamic Loader Info
- Binding Info
- Lazy Binding Info
涉及若干個 sections、load commands,以及 indirect symbol table、dynamic loader info 等。其中LC_LOAD_DYLIB
這個命令上文已經(jīng)提到,它描述了鏡像依賴的 dylibs。LC_SYMTAB
定義的符號表(symbol table)是鏡像所用到的符號(包括內(nèi)部符號和外部符號)的集合。
Indirect Symbol Table
每一個可執(zhí)行的鏡像文件,都有一個 symbol table,由LC_SYMTAB
命令定義,包含了鏡像所用到的所有符號信息。那么 indirect symbol table 是一個什么東西呢?本質(zhì)上,indirect symbol table 是 index 數(shù)組,即每個條目的內(nèi)容是一個 index 值,該 index 值(從 0 開始)指向到 symbol table 中的條目。Indirect symbol table 由LC_DYSYMTAB
定義,后者的參數(shù)類型是一個dysymtab_command
結(jié)構(gòu)體,詳見dysymtab_command,該結(jié)構(gòu)體內(nèi)容非常豐富,目前我們只需要關(guān)注indirectsymoff
和nindirectsyms
這兩個字段:
struct dysymtab_command {
uint32_t cmd; /* LC_DYSYMTAB */
uint32_t cmdsize; /* sizeof(struct dysymtab_command) */
// ...
uint32_t indirectsymoff; /* file offset to the indirect symbol table */
uint32_t nindirectsyms; /* number of indirect symbol table entries */
// ...
};
indirectsymoff
字段定義了 indirect symbol table 在MachO文件中的偏移,nindirectsyms
定義了 indirect symbol table 每一個條目是一個 4 bytes 的 index 值。這個index指的是symbol table
的下標,表明當前條目指的是定義在symbol table
中下標為index的符號。
main.out的五個條目如下(標注部分Data為 indirect symbol table 的真實內(nèi)容):
__text 里的外部符號
回到上文提到的 main.out,查看 main.out 代碼段的反匯編內(nèi)容下:
上述是 main 函數(shù)的反匯編代碼,注意第 5 行和第 9 行,這兩行的指令分別引用了_kHelloPrefix和_say符號;這兩個符號未綁定,如果是靜態(tài)鏈接,這倆處的地址值是 0;但此處是動態(tài)鏈接,符號目標地址值分別指向的是偏移 0x99 和 0x09,本文所在環(huán)境,采用的 PC 相對地址,所以_kHelloPrefix和_say的目標地址分別是:
_kHelloPrefix 的目標地址 = 0xf6f + 0x99 = 0x1008
_say 的目標地址 = 0xf85 + 0x09 = 0xf8e
0x1008
和0xf8e
分別對應 main.out 中的哪個結(jié)構(gòu)呢?答案是 section(__DATA __got) 和 section(__TEXT __stubs):
Mach-O 的代碼段對 dylib 外部符號的引用地址,要么指向到__got
,要么指向到__stubs
。什么時候指向到前者,什么時候指向到后者呢?
站在邏輯的角度,符號有兩種:數(shù)據(jù)型和函數(shù)型;前者的值指向到全局變量/常量,后者的值指向到函數(shù)。在動態(tài)鏈接的概念里,對這兩種符號的綁定稱為:non-lazy binding
、lazy binding
。對于前者,在程序運行前(加載時)就會被綁定;對于后者,在符號被第一次使用時(運行時)綁定。
section(__DATA __got)
對于程序段__text里的代碼,對數(shù)據(jù)型符號的引用,指向到了__got;可以把__got看作是一個表,每個條目是一個地址值。
在符號綁定(binding)前,__got里所有條目的內(nèi)容都是 0,當鏡像被加載時,dyld 會對__got每個條目所對應的符號進行重定位,將其真正的地址填入,作為條目的內(nèi)容。換句話說,__got各個條目的具體值,在加載期會被 dyld 重寫,這也是為啥這個 section 被分配在 __DATA segment 的原因。
問題來了,dyld 是如何知道__got
中各個條目對應的符號信息(譬如符號名字、目標庫等)呢?每個 segment 由LC_SEGMENT
命令定義,該命令后的參數(shù)描述了 segment 包含的 section 信息,是謂 section header,對應結(jié)構(gòu)體(x86_64架構(gòu))是section_64:
struct section_64 { /* for 64-bit architectures */
char sectname[16]; /* name of this section */
char segname[16]; /* segment this section goes in */
// ...
uint32_t reserved1; /* reserved (for offset or index) */
uint32_t reserved2; /* reserved (for count or sizeof) */
uint32_t reserved3; /* reserved */
};
對于__got、__stubs、__nl_symbol_ptr、__la_symbol_ptr這幾個 section,其reserved1描述了該 list 中的符號在間接符號表(IndirectSymbolTable)的起始index:__got中第一個條目在間接符號表(IndirectSymbolTable)的下標為reserved1,第二個條目的下標為reserved1+1......
由上文知間接符號表(IndirectSymbolTable)中存儲的內(nèi)容也是index,指向符號表的下標,由此可得:
index0 = IndirectSymbolTable[got.section_64.reserved1];
symbolTable[index0] 就是got數(shù)據(jù)段的第一個符號。
index1 = IndirectSymbolTable[got.section_64.reserved1+1];
symbolTable[index1] 就是got數(shù)據(jù)段的第二個符號。
...依次類推
看我們的例子:
__got section64 header指明__got在間接符號表(IndirectSymbolTable)的起始index為2:
__got中共有兩個條目:
__got在間接符號表(IndirectSymbolTable)的起始index為2,__got中共有兩個條目,則__got在IndirectSymbolTable中的對應條目如下 :
__got最終在符號表中對應的符號:
總之一句話,__got為 dyld 服務,用來存放 non-lazy 符號的最終地址值。
section(__TEXT __stubs)
對于程序段__text里的代碼,對函數(shù)型符號的引用,指向到了__stubs。和__got一樣,__stubs也是一個表,每個表項是一小段jmp代碼,稱為「符號樁」。和__got不同的是,__stubs存在于 __TEXT segment 中,所以其中的條目內(nèi)容是不可更改的。
查看__stubs里的反匯編內(nèi)容:
$ otool -v main.out -s __TEXT __stubsM
main.out:
Contents of (__TEXT,__stubs) section
0000000100000f8e jmpq *0x84(%rip)
那么具體跳轉(zhuǎn)地址是多少呢?是 *(0000000100000f8e + 0x84 + 當前跳轉(zhuǎn)指令長度)),
查看原始數(shù)據(jù)得知當前指令長度為6
jmp地址為 *(0000000100000f8e + 0x84 + 6) = *(0x100001018), 注意這里是間接跳轉(zhuǎn)
0x100001018是哪個部分呢?答案是 section(__DATA __la_symbol_ptr)...
section(__DATA __la_symbol_ptr)
__la_symbol_ptr內(nèi)容:
所以__stubs第一個 stub 的 jump 目標地址是 0xFA4。該地址位于 section(__TEXT __stub_helper)。
section(__TEXT __stub_helper)
這幾條匯編代碼比較簡單,可以看出,代碼最終會跳到0xFD9的位置;之后該何處何從?
不難計算,0xFD9的跳轉(zhuǎn)目標地址是 0x1010 (0xfa3 + 0x6d)存儲的內(nèi)容,0x1010 在哪里呢?0x1010 坐落于 section(__DATA __got)。是__got的第二個條目:dyld_stub_binder:
dyld_stub_binder是一個函數(shù),為什么它被當作non-lazy symbol 處理?這是因為它是所有l(wèi)azy bingding的基礎(chǔ),lazy symbol都要靠dyld_stub_binder去查找符號的真實地址,如果dyld_stub_binder也是lazy symbol要通過什么函數(shù)去查找呢,總不能通過自己去找把:遞歸死循環(huán)了!
dyld_stub_binder
dyld_stub_binder
也是一個函數(shù),定義于dyld_stub_binder.S,由 dyld 提供。
Lazy binding symbol 的綁定工作正是由 dyld_stub_binder 觸發(fā),通過調(diào)用dyld::fastBindLazySymbol來完成。
Lazy Binding 分析
上文結(jié)合 main.out 實例,對 Mach-O 與動態(tài)鏈接相關(guān)的結(jié)構(gòu)做了比較全面的分析。Non-lazy binding 比較容易理解,這里稍微對如上內(nèi)容進行整合,整體對 lazy binding 基本邏輯進行概述。
對于__text代碼段里需要被 lazy binding 的符號引用(如上文 main.out 里的_say),訪問它時總會跳轉(zhuǎn)到 stub 中,該 stub 的本質(zhì)是一個 jmp 指令,該 stub 的跳轉(zhuǎn)目標地址存儲于__la_symbol_ptr。(因為是間接跳轉(zhuǎn):先計算PC相對地址的真實值,找到__la_symbol_ptr中的條目,取出條目中存儲的地址addr,跳轉(zhuǎn)到這個地址addr)
首次訪問符號A流程:
后續(xù)訪問符號A流程:
有如下代碼:
void test() {
printf("Hello ");
printf("world!\n");
}
看兩次調(diào)用printf
函數(shù)時,stub跳轉(zhuǎn)地址的差異:
因為第一次已經(jīng)找到了
printf
的地址并記錄到了__la_symbol_ptr
中,所以第二次調(diào)用可以直接獲取printf
的地址
結(jié)語
最終MachO通過以上方式實現(xiàn)可以加載而無需重定位無需修改代碼的方式實現(xiàn)了PIC,動態(tài)庫可以加載到任意位置。一般數(shù)據(jù)符號會在動態(tài)庫加載的時候解析,函數(shù)符號用到的時候才解析,因為數(shù)據(jù)符號的數(shù)量一般不會太多,多了動態(tài)庫和可執(zhí)行文件的耦合就嚴重了。
參考
本文大部分抄自Mach-O 與動態(tài)鏈接
iOS程序員的自我修養(yǎng)-MachO文件動態(tài)鏈接(四)
感謝前方的諸位大佬分享,傳道授業(yè)