一個iOS程序員的自我修養(五)Mach-O文件動態連接

爲何要動態連接

有了靜態連接,爲何還須要動態連接?程序員

在靜態連接的狀況下,好比有兩個程序 Program1 和Program2,而且他們還共用一個 Lib.o 外部模塊,因此在輸出的可執行文件 Program1 和 Program2 中有兩個副本,當同時運行 Program1 Program2 時,Lib.o 同時在內存中和磁盤中都有兩份副本,當內存中存在大量的像 Lib.o 這樣的目標文件時,極大的浪費了內存空間。另外一個問題是靜態連接對程序的更新、部署、發佈也會帶來不少問題,好比 Lib.o 是一個第三方廠商提供的,當三方廠商更新 Lib.o 的時候,Program1 就要從新拿到 Lib.o 從新連接在發佈給用戶,這樣致使 Lib.o 有任何一個小的改動都要用戶從新下載整個程序。數組

要解決空間浪費和更新困難這兩個問題最簡單的辦法就是把程序的模塊互相分割開來,造成獨立的文件,而不是再將他們靜態連接在一塊兒,簡單的講,就是不對那些組成程序的目標文件進行連接,等到運行時再進行連接,這就是動態連接的基本思想。markdown

簡單的動態連接例子

// ViewController.m
#import "ViewController.h"
#import "TestDyld.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    [TestDyld testPrint];
    swap(10, 20);
    NSString *aString = bString;
    NSString *enterBG = UIApplicationDidEnterBackgroundNotification;
}
@end

// TestDyld.m
#import "TestDyld.h"
NSString *bString = @"test";
@implementation TestDyld
+ (void)testPrint {
    NSLog(@"測試");
}
void swap(int a, int b) {
    a+b;
}
@end
複製代碼

以上代碼引用了 UIKit Foundation 等多個動態庫,而後經過 xcrun 命令將 maim.m、ViewController.m 和 TestDyld.m 合併成名爲 testdyld 的 Mach-O 文件,經過 MachOView 對其進行反彙編探索一下動態連接的原理。架構

如上圖所示,LC_LOAD_DYLIB 加載命令專門用來在動態連接的時候加載程序主模塊依賴的動態庫。LC_LOAD_DYLIB 命令的參數描述了 dylib 的基本信息:函數

struct dylib {
    union lc_str  name;             // dylib 的 path
    uint32_t timestamp;             // dylib 構建的時間戳
    uint32_t current_version;       // dylib 的版本
    uint32_t compatibility_version; // dylib 的兼容版本
};
複製代碼

在動態連接的過程當中,操做系統會經過 LC_LOAD_DYLINKER 加載命令加載 dyld 動態連接器,經過 dyld 再去加載其餘的 dylib 庫,而後在對各類庫進行綁定和重定位工做,好比說圖中的 Foundation 和 UIKit。oop

地址無關代碼(PIC)

Foundation 或者 UIKit 等動態庫在被 dyld 裝載時,如何肯定它們在進程虛擬地址空間中的位置?post

在 iOS 上全部的應用程序幾乎都用到了 Foundation 和 UIKit,在靜態連接部分咱們知道程序的指令和數據中可能會包含一些絕對地址的引用,那麼就要肯定動態庫被裝載的地址,若是不一樣的庫被裝載到了同一個地址就會產生目標地址衝突。爲了解決這個裝載地址固定問題,首先想到的就是可以在裝載時再對動態庫進行重定位,可是裝載時重定位也並不適合用來解決這個問題,動態庫在被裝載映射至虛擬空間後,指令部分是要在多個進程之間共享的,而不像靜態庫同樣每一個程序都有一份副本。因爲重定位須要修改庫內的指令跳轉目標地址,這就致使共享庫指令部分沒有辦法被多個進程共享,這就失去了動態連接節省內存的大優點。性能

爲了讓共享的指令部分在裝載時不須要由於地址的改變而改變,就須要把指令中那些須要被修改的部分分離出來,跟數據部分放在一塊兒,這樣指令部分就能夠保證不變,而數據部分能夠在每一個進程中擁有一個副本,這就是地址無關代碼技術。測試

模塊中的各類類型的地址引用方式有以下四種狀況:ui

  1. 模塊內部的函數調用、跳轉。
  2. 模塊內部的數據訪問,好比模塊內部定義的全局變量、靜態變量。
  3. 模塊外部的函數調用、跳轉。
  4. 模塊外部的數據訪問,好比其餘模塊中定義的全局變量。

模塊內部指令跳轉

因爲同一模塊內的相對位置是固定的,因此模塊內跳轉、函數調用能夠是相對地址調用。指令跳轉在 x86 使用的是 call 指令,指令碼是 E8,在靜態連接中提到過這是近址相對位移調用指令,這種指令是不須要重定位的。在 arm64 架構上尋址方式有些區別,arm64 跳轉使用的是 bl 指令,指令碼是 94 或者 97,97 表示向前跳轉 94 表示向後跳轉,它的偏移計算公式爲:(目標地址 - 指令地址)/ 4。經過 MachOView 探究下 swap 跳轉的尋址過程: 經過查找符號表能夠知道 swap 的目標地址正是 0x10007EC4。

模塊內部數據訪問

模塊內部的數據訪問也能夠不包含絕對地址的引用,可是相對於模塊內部跳轉的 bl 指令複雜些,arm 經過 adrp+add 的組合來獲得數據的地址。以下圖所示是訪問 bString 字符串的彙編指令:

在瞭解 adrp 指令以前,首先要了解 adr 指令。

  1. adr 指令:

小範圍的地址讀取指令。adr 指令將基於 PC 相對偏移的地址值讀取到寄存器中。將有符號的 21 位的偏移,加上 PC, 結果寫入到通用寄存器,可用來計算 +/- 1MB 範圍的任意字節的有效地址。

  1. adrp 指令:

以頁爲單位的大範圍的地址讀取指令。符號擴展一個 21 位的 offset(immhi+immlo), 向左移動 12 位,PC 的值的低 12 位清零,而後把這二者相加,結果寫入到 x8 寄存器,用來獲得一塊含有 bString 的 4KB 對齊內存區域的 base 地址(也就是說 bString 所在的地址,必定落在這個 4KB 的內存區域裏), 可用來尋址 +/- 4GB 的範圍(2^33次冪)。

通俗來說,adrp 指令就是先進行 PC+imm(偏移值)而後找到 bString 所在的一個 4KB 的頁,而後取得 bString 的基址,再經過 add 指令加上偏移去尋址。

adrp+add 對 bString 的尋址過程以下:

0xB0000008 = 1011 0000 0000 0000 0000 0000 0000 1000
immlo = 01
immhi = 0000 0000 0000 0000 000
imm = immlo + immhi = 0x01
imm << 12 = 0x1000
PC = 0x10007EAC
PC 低 12 位清零 = 0x10007000
0x10007000 + 0x1000 = 0x10008000 aString 所在的基地址。
經過 add 指令補全在基地址中的偏移:
目標地址:0x10008000 + 0x2A0 = 0x100082A0
複製代碼

能夠看到 0x100082A0 正是數據段中存放的數據。

模塊間數據訪問

模塊間的數據訪問比模塊內部稍微麻煩一點,由於模塊間的數據訪問目標地址要等到裝載時才決定。好比上面例子的 UIApplicationDidEnterBackgroundNotification 被定義在了 UIKit 中,而且該地址在裝載時才能肯定。咱們前面提到要使得代碼地址無關,基本思想就是把地址相關的部分放到數據段裏面。Mach-O 裏面有一個全局偏移表(Global Offset Table,GOT),當代碼須要引用該全局常量時,能夠經過 GOT 間接引用,基本機制以下圖:

當指令中須要訪問變量 b 時,程序會先找到 GOT,而後根據 GOT 中變量所對應的項找到變量的目標地址。以下圖所示:

能夠看到訪問常量 UIApplicationDidEnterBackgroundNotification 的地址是 0x10008000,具體尋址方式和模塊內部尋址方式相同,這個地址位於 GOT 中紅框標註的位置,而這個符號的真實地址仍是0x0,須要在裝載的時候才能肯定。GOT 段相對於當前指令的偏移是在編譯期就能夠肯定了的,GOT 中每一個地址對應哪一個變量或常量是由編譯期決定的,好比第一個地址對應着 UIApplicationDidEnterBackgroundNotification 常量。GOT 段自己是放在數據段的,因此它能夠在模塊裝載時被修改,而且每一個進程均可以有獨立的副本,相互不受影響,這樣模塊間的數據訪問也變得與地址無關了。

模塊間調用、跳轉

模塊間的調用和跳轉也是採用經過 GOT 間接跳轉的方法解決,基本和模塊間數據訪問一致,只不過 GOT 中相應的項保存的是目標函數的地址。

總結 PIC 實現以下:

延遲綁定(PLT)

背景

動態連接相比於靜態連接是以犧牲了一部分性能爲代價的。主要緣由是動態連接下對於全局和靜態的數據訪問和模塊之間的調用都要進行復雜的 GOT 定位,而後間接尋址,如此一來程序的運行速度一定會很慢。還有一個緣由是動態連接器要對全部的動態庫進行符號地址查找和重定位等工做,這樣勢必會減慢程序的啓動速度。

延遲綁定的實現

基本思想就是當函數第一次用到時才進行綁定(符號查找和重定位過程)。調用外部模塊函數一般作法是經過 GOT 中相應的項進行間接跳轉,在 x86 架構中,PLT 爲了實現延遲綁定在這個過程當中又增長了一層間接跳轉。例如在某個動態庫中有一個 bar() 函數,這個函數在 PLT 中的地址稱之爲 bar@plt,在 Linux 下的 ELF 可執行文件實現以下:

bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve
複製代碼

bar@GOT 表示 GOT 段中 bar() 相應的項,若是該項有值,也就是說被綁定過了,那麼直接跳轉到 bar() 的位置。實際上爲了延遲綁定連接器並無將 bar() 的地址填入該項,而後就會進入 push n 將一個數字 n 壓入堆棧中,這個數字對應着在重定位表中的下標。再而後將模塊 ID 壓入堆棧,再跳轉到 _dl_runtime_resolve。實際上這就是找到 bar() 在哪一個模塊叫什麼方法而後調用 _dl_runtime_resolve(iOS 上調用的 dyld_stub_binder) 進行重定位的過程,在下一次調用就能夠直接跳轉了。

Mach-O 動態連接過程分析

在動態連接過程當中還有個重要的加載命令 LC_DYSYMTAB,動態符號表,它是符號表的子集,裏面只包含動態連接相關的符號。本質上是 index 數組,即每一個條目的內容是一個 index 值,該 index 值(從 0 開始)指向到符號表中對應的符號。

除了 LC_DYSYMTAB 這個 Segment 外,Mach-O 中還有兩個重要的 Section __got__stubs,Mach-O 的代碼段對 dylib 外部符號的引用地址,要麼指向到 got,要麼指向到 stubs,前者主要存儲的是全局變量或常量,後者儲存的是對函數的引用。常量或變量在模塊間的引用相對較少,引用過多會產生必定的耦合,而函數在模塊間的調用很是頻繁,因此這兩種符號的綁定方式又分爲 Non-Lazy 和 Lazy 兩種,前者在動態連接的過程當中進行符號的重定位與綁定,然後者是在第一次被使用時進行綁定。

got

當鏡像文件被加載時,dyld 動態連接器會對 got 段中每一個條目所對應的符號進行重定位,將其真正的地址填入。那麼 dyld 是如何找到 got 中的符號在符號表中的位置的呢?每一個 segment 由LC_SEGMENT 命令定義,該命令後的參數描述了 segment 包含的 section 信息,對應結構體是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,reserved1 描述了該 list 中條目在 dynamic symbol table 中的起始 index,第二條 index+1...index+n,再根據 ynamic symbol table 返回的下標去全局符號表中查找該符號,這個過程的僞代碼以下:

__got[0]->symbol = symbolTable[indirectSymbolTable[__got.sectionHeader.reserved1]]
// -> __got.sectionHeader.reserved1 == 2
// -> indirectSymbolTable[2] == 2
// -> symbolTable[2] = Symbol(_kHelloPrefix)
// -> __got[0]->symbol = Symbol(_kHelloPrefix)

同理
__got[1]->symbol = symbolTable[indirectSymbolTable[__got.sectionHeader.reserved1 + 1]]
// -> __got.sectionHeader.reserved1 + 1 == 3
// -> indirectSymbolTable[3] == 4
// -> symbolTable[2] = Symbol(dyld_stub_binder)
// -> __got[0]->symbol = Symbol(dyld_stub_binder)
複製代碼

下面用 MachOView 反彙編看下 UIKit 中 UIApplicationDidEnterBackgroundNotification 符號的重定位過程:

stubs、la_symbol_ptr、stub_helper

Mach-O 中代碼段對函數的引用最終都會指向 stubs 段,以下圖是代碼段對 NSLog 的調用指令,0x100007f08 正好在 stubs 段:

使用 otool 命令對 stubs 段進行反彙編以下:

0x100008010 位於 __la_symbol_ptr 段,不止 NSLog,全部引用外部的函數最後都會跳轉到 __la_symbol_ptr 段的相應項上,下面再看看 __la_symbol_ptr 裏面是什麼。

__la_symbol_ptr 中的全部項都指向了 __stub_helper 中的一段彙編指令,這段彙編代碼最終都會跳轉到第六行的 br 指令,也就是目標地址 0x100008008,經過上圖能夠看出這個地址存儲的是 section(__DATA __got) 裏面的 dyld_stub_binder 函數。轉了一大圈,實際上全部引用的外部函數最終都會跳轉到 dyld_stub_binder,這是一個尋找外部函數地址的函數,Lazy binding symbol 的綁定工做正是由 dyld_stub_binder 觸發,必須提早綁定好,因此它和常量和全局變量放在了一個段。最終將 NSLog 指令的真實地址回填到 __la_symbol_ptr 段。整個過程總結以下:

首次訪問 NSLog 時:

  1. NSLog 對應的 __la_symbol_ptr 條目內容指向到 __stub_helper。
  2. __stub_helper 裏的代碼邏輯,經過各類展轉最終調用 dyld_stub_binder 函數。
  3. dyld_stub_binder 函數經過調用 dyld 內部的函數找到 NSLog 符號的真實地址。
  4. dyld_stub_binder 將地址寫入 __la_symbol_ptr 對應函數中。
  5. dyld_stub_binder 跳轉到 NSLog 符號的真實地址。
  6. 以後再次訪問 NSLog 時,跳轉到 __la_symbol_ptr 段後直接跳轉符號的真實地址。

引用

  1. 《程序員的自我修養》
  2. blog.csdn.net/liao392781/…
  3. www.jianshu.com/p/9e4ccd3cb…
相關文章
相關標籤/搜索