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

上文分析了 Mach-O 文件的總體結構,那麼 Mach-O 文件是怎麼來的呢?其中一個重要的過程就是靜態連接,連接器將全部輸入的 「.o」 文件打包輸出可執行文件,能夠簡單理解這個可執行文件就是 Mach-O 文件,由於本篇主要分析靜態連接,因此暫且理解爲靜態連接後生成了最終的可執行文件。git

假設咱們只有兩個模塊,「a.c」 和 「b.c」,它們的代碼定義以下:程序員

/* a.c */
extern int shared;
int main() {
    int a = 100;
    swap(&a, &shared);
}

/* b.c */
int shared = 1;
void swap( int* a, int* b) {
    *a ^= *b ^= *a ^= *b;
}
複製代碼

以 x86 架構爲例,首先使用 clang 命令將 「a.c」 和 「b.c」 分別編譯成目標文件 「a.o」 和 「b.o」:github

clang -fmodules -c a.c b.c -o a.o b.o
複製代碼

通過編譯後,咱們就獲得了 a.o b.o 這兩個目標文件。從代碼能夠看到,b.c 中共定義了兩個全局符號,「shared」 和 「swap」,a.c 裏面定義了一個全局符號 「main」,a.c 裏面引用到了 b.c 裏面的 「shared」 和 「swap」,接下來要作的就是把 a.o 和 b.o 兩個目標文件連接成 「ab」 可執行文件。數組

函數和變量被統稱爲符號。markdown

空間與地址分配

連接器會先掃描全部輸入的目標文件,獲取它們各個段的長度、屬性和位置,將全部的符號表收集起來統一放到一個全局符號表。這一步連接器將全部目標文件進行段合併,計算出合併後的長度和位置,創建映射關係。架構

實際上目標文件與可執行文件 Mach-O 結構一致。函數

符號解析與重定位

在分析符號解析與重定位以前先看看 a.o 裏面是怎麼使用兩個外部符號 「shared」 和 「swap」 的: 利用 MachOView 工具能夠看到 a.o 的代碼段反彙編結果: 工具

最左邊的那列是每條在虛擬內存中的偏移量,每一行表明了一條指令。紅框標出的就是兩個引用了 「shared」 和 「swap」 的位置,其中 shared 使用 mov 指令,這條指令佔用了 3 個字節,swap 調用使用的是 call 指令,其中的 0x488B35 和 0xE8 操做碼都是近址相對位移調用指令,後面的四個字節就是被調用函數相對於調用指令的下一條指令的偏移量。實際上 0x1E3 和 0x1F5 存放的只是 「shared」 和 「swap」 的臨時假地址,由於編譯器在編譯的時候並不知道它們的真正地址。編譯器將這兩條指令的地址暫時用 0x00000000 代替着。連接器在空間與地址分配後就能夠肯定全部符號的虛擬地址了,以後對每一個須要重定位的指令進行修正。下面將 a.o b.o 連接成可執行文件 ab:oop

clang a.o b.o -o ab
複製代碼

再對 ab 進行反彙編看一下連接後的代碼段和 a.o 對比一下變化: post

通過修整後,「shared」 和 「swap」 的地址分別爲 0x000000A1 和 0x0000000F (小端模式),以 swap 爲例,這個 call 指令是一條近址相對位移調用指令,它後面跟的是相對於下一條指令 xor 的偏移量,也就是 0xF71 + 0x0F 求和結果正好是 0xF80,0xF80 恰好是 swap 函數的地址。

重定位表

那麼連接器怎麼知道哪些指令須要被調整呢?事實上在目標文件中有個重定位表,專門保存與重定位相關的符號,它被定義在了目標文件的 Relocations 段中。a.o 的 Relocations 段定義以下:

每一個要被重定位的地方叫作一個重定位入口,能夠看到 a.o 裏面在 __TEXT,__text 段有兩個重定位入口,對照前面 a.o 的反彙編分析,這裏的 0x1D 和 0xB 正好是代碼段中的 call 指令和 mov 指令的地址部分。

重定位表能夠理解爲是一個裝有重定位入口的數組,重定位入口的結構體被定義在了mach-o 的 reloc.h 中,它的結構以下:

struct relocation_info {
   int32_t	r_address;	/* offset in the section to what is being
				   relocated */
   uint32_t     r_symbolnum:24,	/* symbol index if r_extern == 1 or section
				   ordinal if r_extern == 0 */
		r_pcrel:1, 	/* was relocated pc relative already */
		r_length:2,	/* 0=byte, 1=word, 2=long, 3=quad */
		r_extern:1,	/* does not include value of sym referenced */
		r_type:4;	/* if not 0, machine specific relocation type */
};
複製代碼

兩個重要的字段 r_addressr_symbolnum,r_address 對應該符號在該段中的偏移,經過 r_address 就能夠找到要重定位的位置,r_symbolnum 對應該符號在符號表中的下標,經過 r_symbolnum 就能夠找到該符號在符號表中的位置。

符號解析

一般觀念裏,之因此要連接是由於目標文件中用到的符號被定義在了其餘目標文件中,因此要將他們連接起來,例如咱們直接連接 「a.o」 連接器發現 shared 和 swap 兩個符號未被定義,沒有辦法完成連接工做:

Undefined symbols for architecture x86_64:
  "_shared", referenced from:
      _main in a.o
  "_swap", referenced from:
      _main in a.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
複製代碼

這也是編寫程序遇到最多的錯誤之一,就是連接時符號未定義。從程序員的角度來看,符號解析佔據了連接過程的主要內容。

其實重定位過程也伴隨着符號解析過程,每一個目標文件均可能定義一些符號或者引用到定義在其餘目標文件中的符號,例如 a.o 引用到了 b.o 的 「shared」 和 「swap」。符號都被定義在了符號表數組中,它的結構體定義在 mach-o 下的 loader.h 中:

struct nlist_64 {
    union {
        uint32_t  n_strx; /* index into the string table */
    } n_un;
    uint8_t n_type;        /* type flag, see below */
    uint8_t n_sect;        /* section number or NO_SECT */
    uint16_t n_desc;       /* see <mach-o/stab.h> */
    uint64_t n_value;      /* value of this symbol (or stab offset) */
};
複製代碼
  • n_strx:字符串表中的下標。
  • n_sect:第幾個 section。
  • n_value:符號地址。

好比 a.o 的符號表:

能夠看到除了 main 函數定義在了代碼段以外,其餘符號都是 N_UNDF 類型,即 「undefined」 類型,實際上這種未定義的符號都可以在重定位表中找到。在連接器掃描完全部的輸入文件後,這些未定義的符號都應該可以在全局符號表中找到,不然連接器就會報符號未定義錯誤。可經過下圖對比出連接先後,符號表中「shared」和「swap」符號的變化:

目標文件 a.o 中未定義的類型在連接成可執行文件 ab 後,未定義的部分就都變得有值了。

重定位

重定位時,重定位表中的每一個重定位入口的 r_address 都是對一個符號的引用,當連接器對引用的某個符號進行重定位時,它就要肯定這個目標符號的地址,這時候就會經過重定位入口的下標 r_symbolnum 去全局符號表中查找這個符號,找到後再將這個符號的地址按照必定的規則(例如相對位移調用指令的方式),回填入調用該符號的位置,這樣一個重定位過程就結束了。

靜態插樁 hook objc_msgSend 分析

所謂靜態插樁就是在靜態連接期間實現 objc_msgSend 方法替換。具體實現方案就是在主工程用匯編的方式實現 hook_msgSend 函數,再將靜態庫中字符串表中的 objc_msgSend 替換爲 hook_msgSend,例如替換某個 Pod 庫中的 objc_msgSend,用來監控 OC 方法調用。

字符串表

每一個目標文件或者說靜態庫裏面都有專門用來爲符號表服務的字符串表,存儲着好比段名、變量名、函數名等。由於字符串的長度是不定的,因此沒有像符號那樣的結構體來表示它。全部的字符串都被集中起來存放到一個表中,而後使用字符串在表中的偏移來引用字符串,符號表正是經過這個偏移 n_strx 值來索引到它的符號名稱。

咱們仍是以 ab 可執行文件的 main 函數爲例,經過 MachOView 分析下符號表經過索引查找對應符號名的過程:

紅框內的值就是符號表中的 n_strx,符號表中 main 符號在字符串表中的偏移爲 0x16,換算成十進制是 22。而後在看一下字符串表中的結構: 紅框內的 16 進制翻譯成 ASCII 正好是 _main 字符串,在字符串表中的偏移也正好是 22 位,這也對應上了在符號表中的偏移。

由於 main 函數並不是定義在外部,因此它的 value 是有值的,若是是一個外部函數,例如 objc_msgSend 這個 value 會是 0,由於 objc_msgSend 是屬於 runtime 的庫函數,這是一個動態庫,動態庫中函數地址的肯定是在動態連接的時候進行綁定的,關於動態連接後面章節會講到。在生成可執行文件 Mach-O 時 objc_msgSend 的真實地址是未知的,在靜態連接的過程不會對這個符號進行重定位。若是在主工程和靜態庫連接前,將 objc_msgSend 修改成 hook_msgSend 字符串,連接後符號表中的 value 就變成了 hook_msgSend 函數的地址。

由於靜態庫自己就是一組目標文件的集合,靜態庫與庫之間在連接的過程與目標文件之間的連接並沒有二異。經過上面的分析咱們能夠知道,當目標文件也就是 .o 文件引用了外部符號後,這些外部符號在全局符號表中的狀態都是 N_UNDF 類型,而且同時會在重定位表中增長這個符號的重定位入口。 在空間與地址分配後,符號表中的未知符號在虛擬地址中的偏移也就隨之肯定了。在這以後會遍歷全部的重定位入口,對須要重定位的位置進行修正,也就是將調用 objc_msgSend 指令的地方都修正爲對 hook_msgSend 的調用。

關於具體代碼實現能夠參考這個開源工具: KKMagicHook

參考

《程序員的自我修養》

juejin.cn/post/684490…

github.com/maniackk/KK…

相關文章
相關標籤/搜索