iOS程序員的自我修養-MachO文件動態連接(四)

目錄

👍了麼?

要是以爲寫得還能夠,看了有收穫。不要吝嗇👍,記得給一個👍呀html

動態連接要比靜態連接複雜多了,我要是直接分析MachO文件動態連接的具體實現,會讓讀者知其然不知其因此然。因此本文分紅2部分,第一部分先講理論知識,基本解答了以下幾個問題:ios

  1. 動態連接產生緣由、基本思想、工做過程。
  2. position-independent code (PIC 地址無關代碼)產生緣由和原理。
  3. 爲何要有相對尋址和間接尋址。
  4. 延遲綁定。

掌握了這些理論知識,再看第二部分講MachO文件動態連接的具體實現,就容易理解蘋果這麼作的緣由了。千萬不要跳過第一部分,否則你會以爲生澀難懂,看了理論,第二部分其實很簡單的。程序員

動態連接的理論知識

爲何要動態連接

軟件工程發展

  1. 遠古時代,全部源代碼都在一個文件上(想象下開發一個App,全部源代碼都在main.m上,這個main.m得有幾百萬行代碼。多人協同開發、如何維護、複用、每次編譯幾分鐘....)。
  2. 爲了解決上面問題,因而有了靜態連接。極像咱們平時開發了,每一個人開發本身的模塊功能,最後編譯連接在一塊兒。解決了協同開發、可維護、可複用、編譯速度也很快了(未改動的模塊用編譯好的緩存)。
  3. 靜態連接好像已經很完美了。那咱們平時開發App,都會用到UIKit、Foundation等等許多系統庫。假如都是經過靜態連接的,咱們iPhone手機裏的微信、淘寶...全部App,每一個App都包含了一份這些系統庫,那每一個App包體積是否是變大了,佔用磁盤空間;咱們一邊微信聊天一邊淘寶購物,那是否是每一個App都要在內存裏有這些庫,佔用了內存。還有UIKit裏某個函數有bug,須要更新,那全部App是否是也要從新靜態連接最新的UIKit庫,而後發版。爲了解決這些問題,因而乎,產生了動態連接。

動態連接基本思想

把程序的模塊分割開來,不是經過靜態連接在一塊兒,並且推遲到程序運行時候連接在一塊兒。數組

好比微信用到UIKit系統庫,等到咱們點擊微信App,微信開始運行以前去連接依賴的UIKit,連接完成再運行App。那微信和淘寶是否是不須要在包裏有UIKit,UIKit只需存一份在手機裏,等App快運行時候,發現依賴UIKit,而後把UIKit加載到內存裏,連接在一塊兒。假如UIKit已經存在內存了,是否是直接連接就能夠了。這個就作到了磁盤和內存裏,都只有一份UIKit。一樣的,升級也很是簡單了,UIKit的bug解決了,直接在手機裏存放新的UIKit,覆蓋舊的,下次App運行時候,就加載這個新的UIKit了。這個連接和靜態連接的工做原理很是相像,也是符號解析、地址重定位等。緩存

動態連接基本實現

名稱解析:bash

  1. dyld:the dynamic link editor 。後面dyld表示動態連接器
  2. dylib:動態連接庫或者稱共享對象

靜態連接和動態連接都是把程序分割成一個個獨立的模塊,可是靜態連接是運行前就用ld連接器連接成一個完整的程序;動態連接是程序主模塊被加載時候,對應的Mach-O文件裏有dyld加載命令,經過這個dyld而後去找依賴的dylib(Mach-O有動態連接庫加載命令),把dylib加載到內存(若是對應的dylib不在內存),而後將程序中全部未決議的符號綁定到相應的 dylib中,並進行重定位工做。dyld和dylib加載命令以下:微信

//dyld加載命令
struct dylinker_command {
	uint32_t	cmd;		/* LC_ID_DYLINKER, LC_LOAD_DYLINKER or
					   LC_DYLD_ENVIRONMENT */
	uint32_t	cmdsize;	/* includes pathname string */
	union lc_str    name;		/* dynamic linker's path name */ }; //在dyld加載命令中,offset爲sizeof(cmd)+sizeof(cmdsize)+sizeof(offset)=12; ptr表示dyld的路徑。表示偏移12位置是dyld的路徑 //在加載命令中,假若有字符串,那都用lc_str表示,lc_str僅僅告訴去相對於加載命令頭部多少的偏移位置取字符串,這個字符串都是放在加載命令結構體最後。 union lc_str { uint32_t offset; /* offset to the string */ #ifndef __LP64__ char *ptr; /* pointer to the string */ #endif ====================================== //dylib加載命令 struct dylib_command { uint32_t cmd; /* LC_ID_DYLIB, LC_LOAD_{,WEAK_}DYLIB, LC_REEXPORT_DYLIB */ uint32_t cmdsize; /* includes pathname string */ struct dylib dylib; /* the library identification */ }; //name 放在加載命令最後,lc_str是告訴偏移位置。加載命令是4字節倍數,字符串填充後,不知足這要求,填充0來知足4字節倍數。 struct dylib { union lc_str name; /* library's path name 名字*/
    uint32_t timestamp;			/* library's build time stamp 構建的時間戳*/ uint32_t current_version; /* library's current version number 版本號*/
    uint32_t compatibility_version;	/* library's compatibility vers number 兼容的版本號*/ }; }; 複製代碼

舉個動態連接🌰

//print.c 文件
#include <stdio.h>

char *global_var = "global_var";

void print(char *str)
{
    printf("wkk:%s\n", str);
}

=======================================

//main.c 文件 

void print(char *str);
extern char *global_var;

int main()
{
    print(global_var);
    return 0;
}

=========================================
//1. 編譯main.c 
xcrun -sdk iphoneos clang -c main.c -o main.o -target arm64-apple-ios12.2

//2. 編譯print.c 成動態庫libPrint.dylib
xcrun -sdk iphoneos clang -fPIC -shared print.c -o libPrint.dylib -target arm64-apple-ios12.2

//3. 連接main.o 和 libPrint.dylib 成可執行文件main
xcrun -sdk iphoneos clang main.o -o main -L . -l Print -target arm64-apple-ios12.2

-target arm64-apple-ios12.2 ==> 運行的目標版本號iOS12.2
-l Print ==> 連接libPrint.dylib
-L . ==> libPrint.dylib在當前路徑尋找(.表明當前路徑)
複製代碼

上面說過動態連接跟靜態連接區別就是連接時機推遲到程序被加載時候,可是上面第三步將目標文件main.o連接成可執行文件時候,仍是用到了動態庫libPrint.dylib了。app

經過靜態連接,咱們知道main.o目標文件裏面,不知道global_var和print兩個符號的地址。而libPrint.dylib裏面有這兩個符號,因此咱們連接時候,用到libPrint.dylib,讓連接器知道這兩符號是來自dylib,只須要給這兩符號作個標記就能夠了,而不是此刻進行綁定和重定位(靜態連接此刻就要綁定和重定位)。獲得的main可執行文件知道這兩個符號是來自dylib,作了標記。等到main被加載時候,再把這兩符號綁定到libPrint.dylib裏,並進行重定位。iphone

如圖,main可執行文件把這兩個符號標記來自libPrint.dylib,可是沒有解析符號的地址。 ide

position-independent code (PIC 地址無關代碼)

產生地址無關代碼緣由

dylib在編譯時候,是不知道本身在進程中的虛擬內存地址的。由於dylib能夠被多個進程共享,好比進程1能夠在空閒地址0x1000-0x2000放共享對象a,可是進程2的0x1000-0x2000已經被主模塊佔用了,只有空閒地址0x3000-0x4000能夠放這個共享對象a。

因此共享對象a裏面有一個函數,在進程1中的虛擬內存地址是0x10f4,在進程2中的虛擬內存地址就成了0x30f4。那是否是機器指令就不能包含絕地地址了(動態庫代碼段全部進程共享;可修改的數據段,每一個進程有一個副本,私有的)。

PIC原理

爲了解決dylib的代碼段能被共享,PIC(地址無關代碼)技術就產生了。PIC原理很簡單,就是把指令中那些須要被修改的部分分離出來,跟數據部分放在一塊兒,這樣指令部分就能夠保持不變,而數據部分是每一個進程都有一個副本。

dylib須要被修改的部分(對地址的引用),按照是否跨模塊分爲兩類,引用方式又能夠分兩類:函數調用和數據訪問。這樣就分紅了4類:

  1. 第一種是模塊內部的函數調用、跳轉等。
  2. 第二種是模塊內部的數據訪問,好比模塊中定義的全局變量、靜態變量。
  3. 第三種是模塊外部的函數調用、跳轉等。(好比動態庫a調用動態庫b中的函數)
  4. 第四種是模塊外部的數據訪問,好比訪問其它模塊中定義的全局變量。

第一種:模塊內部的函數調用、跳轉等。

因爲調用者和被調用者都在同一個模塊裏,它們之間的相對位置不變。因而有了相對尋址,用相對尋址就能夠作到是地址無關代碼。

相對尋址

給出相對於當前地址的偏移地址,二者相加就能夠獲得尋找的地址。

第二種:模塊內部的數據訪問(靜態變量)

上圖中講了arm64裏的bl跳轉指令,比較簡單。爲了講解模塊內部的數據訪,我這裏再講一個比較難的指令adr和adrp指令,也是一個相對尋址指令。講以前,你們想下,爲何要有相對尋址?有兩個緣由:

  1. 咱們上面講到,模塊內部相對位置不變,能夠產生地址無關代碼。
  2. 根源問題是,全部的ARMv7 / ARMv8指令都是4字節長,可是對應地址是4字節/8字節長,一條指令是沒辦法容納下絕對地址的。因此產生了相對地址。

adr 和 adrp

adr指令是能夠尋找+/- 1MB的相對地址;adrp指令能夠尋找+/-4GB的相對地址。

  1. adr指令:

immhi(immediate value high 當即數高位)和immlo(immediate value low當即數低位)一塊兒是21位,1位是符號位(往前/後跳),剩下20位表示1MB(2的10次方=1KB,2的20次方=1MB...)。當即數(offset)+PC(base)=目標地址。

  1. adrp指令:

adrp相似於adr,但它將12個較低位歸零並相對於當前PC頁面偏移。因此咱們能夠尋找+/-4GB的相對地址,代價是adrp指令後面,要跟着add指令去設置較低的12位。

adrp指令將21位當即數左移12位,將其和PC(程序計數器)相加,最後較低12位清零,而後將結果寫入通用寄存器。這容許計算4KB對齊的存儲區域的地址。 結合add指令,設置較低12位,能夠計算或訪問當前PC的±4GB範圍內的任何地址。

模塊內部的數據訪問也是用相對尋址,由於模塊內部數據相對指令的相對位置也是固定的。在arm64中用adrp來相對尋址。

第三種:模塊外部的數據訪問。

模塊外部的數據訪問的目標地址,要等到模塊被裝載時才決定,例如上面的動態連接🌰,main函數(能夠看成是主模塊的一個函數)訪問外部的global_var全局變量。global_var被定義在libPrint.dylib模塊,要等這個模塊被裝載了,而後連接器才決定global_val目標地址。前面提到了PIC基本思想就是把跟地址相關的部分放到數據段裏面。mach-o文件的數據段有一個got section(got:Global Offset Table 全局偏移表),當代碼須要引用該全局變量時,能夠經過got中相對應的項間接引用。

例以下圖,訪問global_var時,地址是0x100008000,說明global_var的真實地址存放在地址0x100008000裏面(注意:global_var真實地址不是0x100008000,而是放在0x100008000裏面。想下C語言中的指針)。連接器在裝載模塊時候會查找每一個外部變量所在的地址,而後填充got中的各個項,確保got裏面(下圖的綠框)存放的地址是正確的。got在數據段,因此它能夠在模塊裝載時被修改,而且每一個進程均可以有獨立的副本,相互不受影響。

got如何作到PIC呢。從模塊內部數據訪問,咱們知道用相對尋址就能夠作到PIC。而got也在模塊內部,咱們指令訪問got,能夠用相對尋址作到PIC。而後取got地址存放的值,就是模塊外部的數據的目標地址。作到了PIC。這種尋址也成爲間接尋址。

第四種:模塊外部的函數調用、跳轉等。

模塊外部的函數調用,跟上面同樣的,也是間接尋址,此時got裏面存放的是模塊外部的函數地址。

經過上面,能夠看到模塊內部的函數訪問和數據訪問,都是用相對尋址作到PIC;模塊外部的函數訪問和數據訪問,都是用間接尋址作到PIC。

延遲綁定

延遲綁定基本思想

延遲綁定基本思想跟iOS的objc_msgSend基本同樣的,都是第一次調用函數時候,去查找函數的地址。而不是程序啓動時候,先把全部地址查找好。

模塊外部的函數和數據訪問,都是經過got來間接尋址的。程序被加載時候,動態連接要進行一次連接工做,好比加載依賴的模塊,修改got裏面的地址(符號查找、地址重定位)等工做,減慢了程序的啓動速度。好比咱們引入了Foundation動態庫,就必定會使用裏面的所有函數嗎?確定不是的,那咱們能夠相似objc_msgSend,等第一次調用時候,再去查找函數的地址。(got在數據段,程序運行期間可修改,因此第一次調用後,把函數的真實地址填入便可。objc_msgSend是第一次調用後,把函數地址放入cache裏,加速查找。)

MachO文件動態連接的具體實現

dysymtab_command

上面已經講了兩個和動態連接有關係的加載命令:dylinker_command(LC_LOAD_DYLINKER)和dylib_command(LC_LOAD_DYLIB)。接下來說下加載命令:dysymtab_command(LC_DYSYMTAB)
//定義在<mach-o/loader.h>中
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 間接符號表裏元素的個數*/
    .... 省略
};	
複製代碼

dysymtab_command可稱爲間接符號表(Indirect Symbol Table),能夠看作指向一個數組,裏面元素是整型數字。例如dysymtab[0]值爲2,意義:間接符號表第0項對應的符號在符號表第2項中(不清楚符號表:見上篇文章靜態連接)。

got和la_symbol_ptr

前面講模塊間的函數調用和數據訪問,都是經過got間接尋址,而後又講到延遲綁定。

具體到macho文件,由於模塊間的數據訪問不多(模塊間還提供不少全局變量給其它模塊用,那耦合度太大了,因此這樣的狀況不多見),因此外部數據地址,都是放到got(也稱Non-Lazy Symbol Pointers)數據段,非惰性的,動態連接階段,就尋找好全部數據符號的地址;而模塊間函數調用就太頻繁了,就用了延遲綁定技術,將外部函數地址都放在la_symbol_ptr(Lasy Symbol Pointers)數據段,惰性的,程序第一次調用到這個函數,才尋址函數地址,而後將地址寫入到這個數據段。下面經過上面的動態連接🌰來分析:

got

上圖,從main函數中,看到訪問的global_var在got數據段。在程序裝載時候,就重定位got裏面的地址,但是重定位global_var時候,至少得知道兩個信息(一、這是什麼符號;二、這個符號來自哪一個模塊),才能找到global_var的地址,修改got。

在上面咱們已經知道了,符號表裏描述了每一個符號的信息(包括外部符號來自哪一個模塊),因此咱們須要知道global_var對應符號表的index。在MachO文件結構分析最後,講了section_64,裏面有一個字段reserved1。

struct section_64 { /* for 64-bit architectures */
	...
	uint32_t	reserved1;	/* reserved (for offset or index) */
    ...
};
複製代碼

在got數據段的section_64裏,這個reserved1表示got裏面的符號在間接符號表(IndirectSymbolTable)的起始index,而後根據間接符號表含義。可獲得

value = IndirectSymbolTable[got.section_64.reserved1];
symbolTable[value] 就是got數據段的第一個符號。
symbolTable[value+1] 就是got數據段的第二個符號。
...依次類推

//從got的section_64能夠找到got數據段裏面元素對應的符號
複製代碼

la_symbol_ptr

模塊間的函數調用,是一個很頻繁的操做。具體到macho的動態連接中,將外部函數地址都放在la_symbol_ptr(Lasy Symbol Pointers)數據段,惰性的,程序第一次調用到這個函數,才尋址函數地址,而後將地址寫入到這個數據段。用上面一樣的方法,在la_symbol_ptr數據段的section_64,先找到reserved1,而後三步找到這個函數符號是什麼,來自哪一個模塊,可是程序加載時候不重定位。下面咱們以上面的動態連接🌰來分析:分析一下第一次調用時候,是如何尋址到函數地址的。

  1. 第一步,在la_symbol_ptr數據段,第一項就是print函數。有沒有發現竟然有print「地址」0x100007fac(got裏都是0,動態連接才重定位,寫入地址)

  1. 第二步,跳到0x100007fac(在stub_helper代碼段)

  1. 第三步,跳到0x100008008,到了got數據段;咱們上面分析時候,都說got裏面存放的都是外部數據符號。可是動態連接時候,會重定位dyld的dyld_stub_binder函數地址,放在這裏。其實dyld_stub_binder是一個尋址外部函數地址的函數,因此必須提早重定位好。那麼第一次調用print函數時候,會調用dyld_stub_binder函數去尋址地址,尋址到了,就把print的地址寫入到第一步的la_symbol_ptr數據段,替換掉0x100007fac,而後調用print函數,後面再次調用print函數時候,就沒有第二三步了,直接調用了print函數。(dyld_stub_binder函數跟objc_msgSend同樣的,也是用匯編寫的)

總結一下MachO文件動態連接的具體實現

有木有發現其實訪問模塊外部(能夠簡單理解主模塊,訪問dylib)的變量和函數,爲了作到PIC,都是把變量和函數的地址放到數據段,由於數據段可修改。只是寫入變量和函數地址的時機不一樣,變量是動態連接時候寫入,主要不多訪問模塊外部變量,對程序啓動速度影響小。而函數是第一次調用時候,纔去尋址,寫入地址。到這裏,應該很好理解fishhook爲啥能夠修改模塊外部的函數/變量的地址,外部函數/變量的地址都放在數據段啊,數據段原本就是能夠修改的。若是咱們不理解動態連接,覺得函數地址在代碼段,那就很難理解fishhook是什麼黑魔法了。下一篇咱們將好好分析一下fishhook。

相關文章
相關標籤/搜索