dyld背後的故事&原始碼分析

小可長江發表於2019-02-25

什麼是dyld?

 dyld(the dynamic link editor)是蘋果的動態連結器,是蘋果作業系統的一個重要組成部分,當系統核心做好啟動程式的準備工作之後,餘下的工作會交給dyld來負責處理。那它存在的意義是什麼?它又具體都負責做些什麼呢?這一篇我們一起來一探究竟。前方長篇預警~


dyld存在的意義

 存在即合理,但我們要弄清楚其合理性所在。先從可執行檔案是如何由原始碼生成的說起。

1.可執行檔案的生成--靜態連結。

先看下面這段程式碼:

#include<stdio.h>

int main()
{
	printf("Hello World\n");
	return 0;
}
複製程式碼

 假設這段程式碼原始檔為hello.c,我們輸入最簡單的命令:$gcc hello.c $./a.out,那麼終端會輸出:Hello World,在這個過程中,事實上經過了四個步驟:預處理、編譯、彙編和連結。我們來具體看每一步都做了些什麼。

預編譯的主要處理規則如下:

  1. 刪除所有#define,並將所有巨集定義展開
  2. 將被包含的檔案插入到預編譯指令(#include)所在位置(這個過程是遞迴的)
  3. 刪除所有註釋:// 、/* */等
  4. 新增行號和檔名標識,以便於編譯時編譯器產生除錯用的行號資訊及編譯時能夠顯示警告和錯誤的所在行號
  5. 保留所有的#pragma編譯器指令,因為編譯器須要使用它們

結合上述規則,當我們無法判斷巨集定義是否正確或者標頭檔案是否包含時可以檢視預編譯後的檔案來確定問題,預編譯的過程相當於如下命令:
$gcc -E hello.c -o hello.i
$cpp hello.c > hello.i

編譯的過程就是把預處理完的檔案進行一些列詞法分析、語法分析、語義分析及優化後生產相應的彙編程式碼檔案,這個過程往往是我們整個程式構建的核心部分,也是最複雜的部分之一,編譯的具體步驟涉及到編譯原理等內容,這裡就不展開了。我們使用命令:
$gcc -S hello.c -o hello.s
可以得到彙編輸出檔案hello.s。

 對於 C 語言的程式碼來說,這個預編譯和編譯的程式是 ccl,但是對於 C++ 來說,對應的程式是 ccplus;Objective-C 的是 ccobjc;Java 是 jcl。所以實際上 gcc 這個命令只是這些後臺程式的包裝,它會根據不同的引數要求去呼叫預編譯編譯程式 ccl、彙編器 as、連結器 ld。

彙編器是將彙編程式碼轉變成機器可以執行的指令,每一個彙編語句幾乎都對應一條機器指令。所以彙編器的彙編過程相對於編譯器來講比較簡單,它沒有複雜的語法,也沒有語義,也不需要做指令優化,只是根據彙編指令和機器指令的對照表一一翻譯就可以了,我們使用命令:
$as hello.s -o hello.o
$gcc -c hello.s -o hello.o
來完成彙編,輸出目標檔案(Object File):hello.o。

連結是讓很多人費解的一個過程,為什麼彙編器不直接輸出可執行檔案而是一個目標檔案呢?連結過程到底包含了什麼內容?為什麼要連結?

 這就要扯一下計算機程式開發的歷史了,最早的時候程式設計師是在紙帶上用機器語言通過打孔來實現程式的,連組合語言都沒有,每當程式修改的時候,修改的指令後面的位置要相應的發生移動,程式設計師要人工計算每個子程式或跳轉的目標地址,這個過程叫重定位。很顯然這樣修改程式的代價隨著程式的增大會變得高不可攀,並且很容易出錯,於是有先驅發明了組合語言,組合語言使用接近人類的各種符號和標記來幫助記憶,更重要的是,這種符號使得人們從具體的指令地址中逐步解放出來,當人們使用這種符號命名子程式或者跳轉目標以後,不管目標指令之前修改了多少指令導致目標指令的地址發生了變化,彙編器在每次彙編程式的時候都會重新計算目標指令的地址,然後把所有引用到該指令的指令修正到正確的地址,這個過程不需要人工參與。

 有了組合語言,生產力極大地提高了,隨之而來的是軟體的規模與日俱增,程式碼量快速膨脹,導致人們開始考慮將不同功能的程式碼以一定的方式組織起來,使得更加容易閱讀和理解,更便於日後修改和複用。自然而然的,我們開始習慣用若干個變數和函式組成一個模組(比如類),然後用目錄結構來組織這些原始碼檔案,在一個程式被多個模組分割以後,這些模組最終如何組合成一個單一的程式是須要解決的問題。這個問題歸根結底是模組之間如何通訊的問題,也就是訪問函式需要知道函式的地址,訪問變數需要知道變數的地址,這兩個問題都是通過模組間符號的引用的方式來解決。這個模組間符號引用拼接的過程就是連結

連結的主要內容就是把各個模組之間相互引用的部分處理好,使得各個模組之間能夠正確地銜接。本質上跟前面描述的“程式設計師人工調整地址”沒什麼區別,只不過現代的高階語言的諸多特性和功能,使得編譯器、連結器更為複雜,功能更強大。連結過程包括了地址和空間分配符號決議(也叫“符號/地址繫結”,“決議”更傾向於靜態連結,而“繫結”更傾向於動態連結,即適用範圍的區別)和重定位,連結器將經過彙編器編譯成的所有目標檔案和進行連結形成最終的可執行檔案,而最常見的庫就是執行時庫(RunTime Library),它是支援程式執行的基本函式的集合。其實就是一組最常用的程式碼編譯成目標檔案後的打包存放。

 知道了可執行檔案是如何生成的,我們再來看看它又是如何被裝載進系統中執行起來的。

2.可執行檔案的裝載與動態連結。

裝載

 裝載與動態連結其實內容特別多,很多細節需要對計算機底層有非常紮實的理解,鑑於目前我的能力尚淺,這裡只做粗略的介紹,推薦有興趣的同學購買《程式設計師的自我修養--連結、裝載與庫》這本書瞭解更多細節。

 可執行檔案(程式)是一個靜態的概念,在執行之前它只是硬碟上的一個檔案;而程式是一個動態的概念,它是程式執行時的一個過程,我們知道每個程式被執行起來後,它會擁有自己獨立的虛擬地址空間,這個地址空間大小的上限是由計算機的硬體(CPU的位數)決定的,比如32位的處理器理論最大虛擬空間地址為0~2^32-1。即0x00000000~0xFFFFFFFF,當然,我們的程式執行在系統上時是不可能任意使用全部的虛擬空間的,作業系統為了達到監控程式執行等一系列目的,程式的虛擬空間都在作業系統的掌握之中,且在作業系統中會同時執行著多個程式,它們彼此之間的虛擬地址空間是隔離的,如果程式訪問了作業系統分配給該程式以外的地址空間,會被系統當做非法操作而強制結束程式。

 將硬碟上的可執行檔案對映到虛擬記憶體中的過程就是裝載,但記憶體是昂貴且稀有的,所以將程式執行時所需的指令和資料全部裝載到記憶體中顯然是行不通的,於是人們研究發現了程式執行時是有區域性性原理的,可以只將最常用的部分駐留在記憶體中,而不太常用的資料存放在磁碟裡,這也是動態裝載的基本原理。覆蓋裝入頁對映就是利用了區域性性原理的兩種經典動態裝載方法,前者在發明虛擬記憶體之前使用比較廣泛 ,現在基本已經淘汰,主要使用頁對映。裝載的過程也可以理解為程式建立的過程,作業系統只需要做以下三件事情:

  1. 建立一個獨立的虛擬地址空間
  2. 讀取可執行檔案頭,並且建立虛擬空間與可執行檔案的對映關係
  3. 將CPU的指令暫存器設定成可執行檔案的入口地址,啟動執行

動態連結

 前面我們在生成可執行檔案時說的連結是靜態連結。最後一步是將經過彙編後的所有目標檔案與庫進行連結形成可執行檔案,這裡的提到的庫,包括了很多執行時庫。執行時庫通常是支援程式執行的基本函式的集合,也就意味著每個程式都會用到它,如果每一個可執行檔案都將其打包進自己的可執行檔案,都用靜態連結的方式,雖然原理上更容易理解,但是這種方式對計算機的記憶體和磁碟的空間浪費非常嚴重!在現在的Linux系統中,一個普通的程式會使用到的C語言靜態庫至少在1M以上,如果系統中有2000個這樣的程式在執行,就要浪費將近2G的空間。為了解決這個問題,把執行時庫的連結過程推遲到了執行時在進行,這就是動態鏈接(Dynamic Linking)的基本思想。動態連結的好處有以下幾點:

  1. 解決了共享的目標檔案存在多個副本浪費磁碟和記憶體空間的問題
  2. 減少物理頁面的換入換出,還增加了CPU的快取命中率,因為不同程式間的資料和指令訪問都集中在了同一個共享模組上
  3. 系統升級只需要替換掉對應的共享模組,當程式下次啟動時新版本的共享模組會被自動裝載並連結起來,程式就無感的對接到了新版本。
  4. 更方便程式外掛(Plug-in)的製作,為程式帶來更好的可擴充套件性和相容性。

 至此,終於說回了我們今天的主角:dyld,現在我們們知道了它存在的意義——動態載入的支援。


動態連結的步驟

 現在,我們理解了為什麼需要動態連結,dyld作為蘋果的動態連結器,但本質上dyld也是一個共享物件:

dyld背後的故事&原始碼分析
上圖是dyld在系統中的路徑,在iPhone中只有獲取root許可權(也就是越獄)的使用者才能訪問,後面在逆向實戰中會給大家演示。
 既然dyld也是一個共享物件,而普通共享物件的重定位工作又是由dyld來完成的,雖然也可以依賴於其他共享物件,但被依賴的共享物件還是要由dyld來負責連結和裝載。那麼dyld的重定向由誰來完成呢?dyld是否可以依賴其他的共享物件呢?這是一個“雞生蛋,蛋生雞”的問題,為了解決這個問題,動態連結器需要有些特殊性:

  • 動態連結器本身不依賴其他任何共享物件
  • 動態連結器本身所需要的全域性和靜態變數的重定位工作由它本身完成

上述第一個條件在編寫動態連結器時可以人為的控制,第二個條件要求動態連結器在啟動時必須有一段程式碼可以在獲得自身的重定位表和符號表的同時又不能用到全域性和靜態變數,甚至不能呼叫函式,這樣的啟動程式碼被稱為自舉Bootstrap)。當作業系統將程式控制權交給動態連結器時,自舉程式碼開始執行,它會找到動態連結器本身的重定位入口(具體過程和原理暫未深究),進而完成其自身的重定位,在此之後動態連結器中的程式碼才可以開始使用自己的全域性、靜態變數和各種函式了。

 完成基本的自舉以後,動態連結器將可執行檔案和連結器本身的符號表合併為一個,稱為全域性符號表。然後連結器開始尋找可執行檔案所依賴的共享物件,如果我們把依賴關係看作一個圖的話,那麼這個裝載過程就是一個圖的便利過程,連結器可能會使用深度優先或者廣度優先也可能其他的演算法來遍歷整個圖,比較常見的演算法都是廣度優先的。

 每當一個新的共享物件被裝載進來,它的符號表會被合併到全域性符號表中,裝載完畢後,連結器開始重新遍歷可執行檔案和共享物件的重定位表,將每個需要重新定位的位置進行修正,這個過程與靜態連結的重定位原理基本相同。重定位完成之後,動態連結器會開始共享物件的初始化過程,但不會開始可執行檔案的初始化工作,這將由程式初始化部分的程式碼負責執行。當完成了重定位和初始化之後,所有的準備工作就宣告完成了,這時動態連結器就如釋重負,將程式的控制權交給程式的入口並且開始執行。


dyld原始碼分析

 我們來通過分析dyld的原始碼驗證上述過程:

dyld背後的故事&原始碼分析
新建一個Objective-C的iOS專案作為示例,在任意參與編譯的類中重寫 +load 方法並新增斷點,執行起來進入斷點即可看到上圖所示的dyld呼叫堆疊資訊。

 從圖中frame9的彙編資訊中,你一定發現了在dyld的入口函式__dyld_start裡出現了dyldbootstrap::start(macho_header const*, int, char const**, long, macho_header const*, unsigned long*)的函式呼叫,那這段程式碼是幹嘛的呢?上原始碼:

dyld背後的故事&原始碼分析
這個函式做了這麼幾件事:dyld的自舉(slideOfMainExecutable、rebaseDyld 完成自身的重定位)、開放函式使用:mach_init、設定堆疊保護:__guard_setup、開始裝載共享物件:dyld::_main。

在dyld::_main中主要做了以下幾件事

  1. 配置環境:
    dyld背後的故事&原始碼分析
  2. 載入動態庫(共享快取):
    dyld背後的故事&原始碼分析
  3. 例項化主程式:
    dyld背後的故事&原始碼分析
  4. 插入動態庫:(越獄中編寫外掛就是修改這個配置讓自己寫的庫被載入,這個配置也只有root使用者才有許可權修改,本來是蘋果給自己預留插入動態庫用的)
    dyld背後的故事&原始碼分析
  5. 重定位完所有需要重定位的庫,然後初始化主程式:
    dyld背後的故事&原始碼分析
    1. 經過一系列初始化函式的呼叫,到notifySingle函式
      • 通過斷點除錯發現此函式的回撥是load_images這個函式
      • load_images裡執行call_load_methods函式
        • 迴圈呼叫各個類的 load 方法
    2. 然後呼叫了 doModInitFunctions 函式
      • 內部會呼叫全域性C++物件的建構函式(帶__attribute__((constructor))的c函式)
    3. 返回主程式的入口函式,進入主程式的main函式:
      dyld背後的故事&原始碼分析
      歷經千辛萬苦,我們抵達了最熟悉的main函式:
      dyld背後的故事&原始碼分析

總結

 這一篇我們從dyld出發,將程式從編譯到裝載的整個過程串了一遍,並結合分析了dyld的原始碼,這些資源都是開源的,有興趣一定要自己去自己啃一下,通過看蘋果對資料結構的使用和設計,還是有很多啟發的。在後續的逆向學習中,這一篇的研究或許能讓我不僅知其然,而且知其所以然。路過的大神還望多多指教~

下篇速遞:fishhook的實現原理淺析

相關文章