dyld背後的故事&源碼分析

什麼是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,在這個過程當中,事實上通過了四個步驟:預處理、編譯、彙編和連接。咱們來具體看每一步都作了些什麼。bootstrap

預編譯的主要處理規則以下:數組

  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。bash

 對於 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。ide

連接是讓不少人費解的一個過程,爲何彙編器不直接輸出可執行文件而是一個目標文件呢?連接過程到底包含了什麼內容?爲何要連接?函數

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

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

連接的主要內容就是把各個模塊之間相互引用的部分處理好,使得各個模塊之間可以正確地銜接。本質上跟前面描述的「程序員人工調整地址」沒什麼區別,只不過現代的高級語言的諸多特性和功能,使得編譯器、連接器更爲複雜,功能更強大。連接過程包括了地址和空間分配符號決議(也叫「符號/地址綁定」,「決議」更傾向於靜態連接,而「綁定」更傾向於動態連接,即適用範圍的區別)和重定位,連接器將通過彙編器編譯成的全部目標文件和進行連接造成最終的可執行文件,而最多見的庫就是運行時庫(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在系統中的路徑,在iPhone中只有獲取root權限(也就是越獄)的用戶才能訪問,後面在逆向實戰中會給你們演示。
 既然dyld也是一個共享對象,而普通共享對象的重定位工做又是由dyld來完成的,雖然也能夠依賴於其餘共享對象,但被依賴的共享對象仍是要由dyld來負責連接和裝載。那麼dyld的重定向由誰來完成呢?dyld是否能夠依賴其餘的共享對象呢?這是一個「雞生蛋,蛋生雞」的問題,爲了解決這個問題, 動態連接器須要有些特殊性:

  • 動態連接器自己不依賴其餘任何共享對象
  • 動態連接器自己所須要的全局和靜態變量的重定位工做由它自己完成

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

 完成基本的自舉之後,動態連接器將可執行文件和連接器自己的符號表合併爲一個,稱爲全局符號表。而後連接器開始尋找可執行文件所依賴的共享對象,若是咱們把依賴關係看做一個圖的話,那麼這個裝載過程就是一個圖的便利過程,連接器可能會使用深度優先或者廣度優先也可能其餘的算法來遍歷整個圖,比較常見的算法都是廣度優先的。

 每當一個新的共享對象被裝載進來,它的符號表會被合併到全局符號表中,裝載完畢後,連接器開始從新遍歷可執行文件和共享對象的重定位表,將每一個須要從新定位的位置進行修正,這個過程與靜態連接的重定位原理基本相同。重定位完成以後,動態連接器會開始共享對象的初始化過程,但不會開始可執行文件的初始化工做,這將由程序初始化部分的代碼負責執行。當完成了重定位和初始化以後,全部的準備工做就宣告完成了,這時動態連接器就如釋重負,將進程的控制權交給程序的入口而且開始執行。


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的 自舉(slideOfMainExecutable、rebaseDyld 完成自身的 重定位)、開放函數使用:mach_init、設置堆棧保護:__guard_setup、開始裝載共享對象:dyld::_main。

在dyld::_main中主要作了如下幾件事

  1. 配置環境:
  2. 加載動態庫(共享緩存):
  3. 實例化主程序:
  4. 插入動態庫:(越獄中編寫插件就是修改這個配置讓本身寫的庫被加載,這個配置也只有root用戶纔有權限修改,原本是蘋果給本身預留插入動態庫用的)
  5. 重定位完全部須要重定位的庫,而後初始化主程序:
    1. 通過一系列初始化函數的調用,到notifySingle函數
      • 經過斷點調試發現此函數的回調是load_images這個函數
      • load_images裏執行call_load_methods函數
        • 循環調用各個類的 load 方法
    2. 而後調用了 doModInitFunctions 函數
      • 內部會調用全局C++對象的構造函數(帶__attribute__((constructor))的c函數)
    3. 返回主程序的入口函數,進入主程序的main函數:
      歷經千辛萬苦,咱們抵達了最熟悉的main函數:

總結

 這一篇咱們從dyld出發,將程序從編譯到裝載的整個過程串了一遍,並結合分析了dyld的源碼,這些資源都是開源的,有興趣必定要本身去本身啃一下,經過看蘋果對數據結構的使用和設計,仍是有不少啓發的。在後續的逆向學習中,這一篇的研究或許能讓我不只知其然,並且知其因此然。路過的大神還望多多指教~

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

相關文章
相關標籤/搜索