1. 程式人生 > Android開發 >重拾iOS-編譯原理

重拾iOS-編譯原理

關鍵詞:LLVM,Clang,Swiftc,IR,preprocessor,Mach-O,dyld

編譯器

把一種程式語言(原始語言)轉換為另一種程式語言(目標語言)的程式叫做編譯器.

大多數編譯器由兩部分組成: 前端和後端.

前端負責詞法分析,語法分析,生成中間程式碼;
後端以中間程式碼作為輸入,進行行架構無關的程式碼優化,接著針對不同架構生成不同的機器碼。

前後端依賴統一格式的中間程式碼(IR),使得前後端可以獨立的變化. 新增一門語言只需要修改前端,而新增一個CPU架構只需要修改後端即可. Objective C/C/C++使用的編譯器前端是clang,swift是swift,後端都是LLVM.

一、LLVM

LLVM的核心庫提供了現代化的source-target-independent優化器和支援諸多流行CPU架構的程式碼生成器. Clang 和 LLDB都是基於LLVM衍生的子專案.

二、Clang

Clang是C語言家族的編譯器前端,誕生之初是為了替代GCC,提供更快的編譯速度。一張圖瞭解clang編譯的大致流程:

大致看來,Clang可以分為一下幾個步驟:

預處理 -> 詞法分析 -> 語法分析 -> 靜態分析 -> 生成中間程式碼和優化 -> 彙編 -> 連結

1、預處理(preprocessor)

預處理會進行如下操作:
1)標頭檔案引入,遞迴將標頭檔案引用替換為標頭檔案中的實際內容,所以儘量減少標頭檔案中的#import,使用@class替代,把#import放到.m檔案中.
2)巨集替換,在原始碼中使用的巨集定義會被替換為對應#define的內容,不要在需要預處理的程式碼中加入太多的內聯程式碼邏輯.
3)註釋處理,在預處理的時候,註釋被刪除
4)條件編譯,(#if,#else,#endif)

2、詞法分析(lexical anaysis)

這一步把原始檔中的程式碼轉化為特殊的標記流. 詞法分析器讀入原始檔的字元流,將他們組織稱有意義的詞素(lexeme)序列,對於每個詞素,此法分析器產生**詞法單元(token)**作為輸出.
原始碼被分割成一個一個的字元和單詞,在行尾Loc中都標記出了原始碼所在的對應原始檔和具體行數,方便在報錯時定位問題. 類似於下面:

int 'int'     [StartOfLine]    Loc=<main.m:14:1>
identifier 'main'     [LeadingSpace]    Loc=<main.m:14:5>
l_paren '('
Loc=<main.m:14:9> int 'int' Loc=<main.m:14:10> identifier 'argc' [LeadingSpace] Loc=<main.m:14:14> comma ',' Loc=<main.m:14:18> char 'char' [LeadingSpace] Loc=<main.m:14:20> star '*' [LeadingSpace] Loc=<main.m:14:25> 複製程式碼
3、語法分析(semantic analysis)

詞法分析的Token流會被解析成一顆抽象語法樹(abstract syntax tree - AST). 在這裡面每一節點也都標記了其在原始碼中的位置.
有了抽象語法樹,clang就可以對這個樹進行分析,找出程式碼中的錯誤。比如型別不匹配,亦或Objective C中向target傳送了一個未實現的訊息.
AST是開發者編寫clang外掛主要互動的資料結構,clang也提供很多API去讀取AST.

4、靜態分析(CodeGen)

把原始碼轉化為抽象語法樹之後,編譯器就可以對這個樹進行分析處理。靜態分析會對程式碼進行錯誤檢查,如出現方法被呼叫但是未定義、定義但是未使用的變數等,以此提高程式碼質量. 也可以使用 Xcode 自帶的靜態分析工具(Product -> Analyze).
常見的操作有:
1)當在程式碼中使用 ARC 時,編譯器在編譯期間,會做許多的型別檢查. 最常見的是檢查程式是否傳送正確的訊息給正確的物件,是否在正確的值上呼叫了正常函式。如果你給一個單純的 NSObject* 物件傳送了一個 hello 訊息,那麼 clang 就會報錯,同樣,給屬性設定一個與其自身型別不相符的物件,編譯器會給出一個可能使用不正確的警告.

一般會把型別分為兩類:動態的和靜態的。動態的在執行時做檢查,靜態的在編譯時做檢查。以往,編寫程式碼時可以向任意物件傳送任何訊息,在執行時,才會檢查物件是否能夠響應這些訊息。由於只是在執行時做此類檢查,所以叫做動態型別。
至於靜態型別,是在編譯時做檢查。當在程式碼中使用 ARC 時,編譯器在編譯期間,會做許多的型別檢查:因為編譯器需要知道哪個物件該如何使用。

2)檢查是否有定義了,但是從未使用過的變數.
3)檢查在 你的初始化方法中中呼叫 self 之前,是否已經呼叫 [self initWith…] 或 [super init] 了.

此處遍歷語法樹,最終生成LLVM IR程式碼。LLVM IR是前端的輸出,後端的輸入. Objective C程式碼在這一步會進行runtime的橋接:property合成,ARC處理等

  • LLVM 會去做些優化工作,在 Xcode 的編譯設定裡也可以設定優化級別-01,-03,-0s,還可以寫些自己的 Pass.
  • 如果開啟了 Bitcode 蘋果會做進一步的優化. 雖然Bitcode僅僅只是一箇中間碼不能在任何平臺上執行,但是它可以轉化為任何被支援的CPU架構,包括現在還沒被髮明的CPU架構. iOS Apps中Enable Bitcode 為可選項,WatchOS和tvOS,Bitcode必須開啟. 如果你的App支援Bitcode,App Bundle(專案中所有的target)中的所有的 Apps 和 frameworks 都需要支援Bitcode.
5、生成彙編指令

LLVM對IR進行優化後,會對程式碼進行編譯優化例如針對全域性變數優化、迴圈優化、尾遞迴優化等,然後會針對不同架構生成不同的目的碼,最後以彙編程式碼的格式輸出.

6、彙編

在這一階段,彙編器將上一步生成的可讀的彙編程式碼轉化為機器程式碼。最終產物就是 以 .o 結尾的目標檔案。使用Xcode構建的程式會在DerivedData目錄中找到這個檔案.

Tips:什麼是符號(Symbols)? 符號就是指向一段程式碼或者資料的名稱。還有一種叫做WeakSymols,也就是並不一定會存在的符號,需要在執行時決定。比如iOS 12特有的API,在iOS11上就沒有.

7、連結

目標檔案(.o)和引用的庫(dylib,a,tbd)連結起來,最終生成可執行檔案(mach-o),連結器解決了目標檔案和庫之間的連結.
這時可執行檔案的符號表資訊已經有了,會在執行時動態繫結.

8、Mach-O檔案

Mach-O是OS X中二進位制檔案的原生可執行格式,是傳送程式碼的首選格式。可執行格式決定了二進位制檔案中的程式碼和資料讀入記憶體的順序。程式碼和資料的順序會影響記憶體使用和分頁活動,從而直接影響程式的效能.
Mach-O是記錄編譯後的可執行檔案,物件程式碼,共享庫,動態載入程式碼和記憶體轉儲的檔案格式。不同於 xml 這樣的檔案,它只是二進位制位元組流,裡面有不同的包含元資訊的資料塊,比如位元組順序,cpu 型別,塊大小等。檔案內容是不可以修改的,因為在 .app 目錄中有個 _CodeSignature 的目錄,裡麵包含了程式程式碼的簽名,這個簽名的作用就是保證簽名後 .app 裡的檔案,包括資原始檔,Mach-O 檔案都不能夠更改.

Mach-O結構

Mach-O 檔案包含三個區域:
Mach-O Header: 包含位元組順序,magic,cpu 型別,載入指令的數量等.
Load Commands: 包含很多內容的表,包括區域的位置,符號表,動態符號表等。每個載入指令包含一個元資訊,比如指令型別,名稱,在二進位制中的位置等.
Data: 最大的部分,包含了程式碼,資料,比如符號表,動態符號表等.

Mach-O檔案的結構如下:

Header
儲存了Mach-O的一些基本資訊,包括了平臺、檔案型別、LoadCommands的個數等等.
使用otool -v -h a.out檢視其內容:

Load commands
這一段緊跟Header,載入Mach-O檔案時會使用這裡的資料來確定記憶體的分佈

Data
包含 Load commands 中需要的各個 segment,每個 segment 中又包含多個 section。當執行一個可執行檔案時,虛擬記憶體 (virtual memory) 系統將 segment 對映到程式的地址空間上.
使用xcrun size -x -l -m a.out檢視segment中的內容:

  • Segment __PAGEZERO。 大小為 4GB,規定程式地址空間的前 4GB 被對映為不可讀不可寫不可執行。

  • Segment __TEXT。 包含可執行的程式碼,以只讀和可執行方式對映。

  • Segment __DATA。 包含了將會被更改的資料,以可讀寫和不可執行方式對映。

  • Segment __LINKEDIT。 包含了方法和變數的元資料,程式碼簽名等資訊。

9、dyld動態連結

生成可執行檔案後就是在啟動時進行動態連結了,進行符號和地址的繫結. 首先會載入所依賴的 dylibs,修正地址偏移,因為 iOS 會用 ASLR 來做地址偏移避免攻擊,確定 Non-Lazy Pointer 地址進行符號地址繫結,載入所有類,最後執行 load 方法和 clang attribute 的 constructor 修飾函式.

10、dSYM

在每次編譯後都會生成一個 dSYM 檔案,程式在執行中通過地址來呼叫方法函式,而 dSYM 檔案裡儲存了函式地址對映,這樣呼叫棧裡的地址可以通過 dSYM 這個對映表能夠獲得具體函式的位置。一般都會用來處理 crash 時獲取到的呼叫棧 .crash 檔案將其符號化.
當release的版本 crash的時候,會有一個日誌檔案,包含出錯的記憶體地址,使用symbolicatecrash工具能夠把日誌和dSYM檔案轉換成可以閱讀的log資訊,也就是將記憶體地址,轉換成程式裡的函式或變數和所屬於的 檔名.

相關參考

  1. iOS編譯原理