LLVM是什么?
LLVM 是編譯器工具鏈技術的一個集合。而其中的 lld 項目,就是內置鏈接器。
編譯器會對每個文件進行編譯,生成 Mach-O(可執行文件);鏈接器會將項目中的多個Mach-O 文件合并成一個。
Xcode運行的過程就是執行一些命令腳本,下面的截圖是Xcode編譯main.m的腳本,在bin目錄下找到clang命令 在后面加一些參數 比如什么語言 編譯到哪些架構上,追加在Xcode設置的配置的參數,最后輸出成.o文件。
LLVM 編譯器架構
編譯器分為三部分,編譯器前端、通用優化器、編譯器后端,中間的優化器是不會變的
增加一種語言只需要處理好編譯器前端就行了
增加一種架構,只需要添加一種編譯器后端的架構處理就可以了
clang在編譯器架構中表示 C、C++、Objective-C的前端,在命令行中也作為一個“黑盒”的Driver,封裝了編譯管線、前端命令、LLVM命令、Toolchain命令等。
LLVM會執行上述的整個編譯流程,大體流程如下:
- 你寫好代碼后,LLVM會預處理你的代碼,比如把宏嵌入到對應的位置。
- 預處理完后,LLVM 會對代碼進行詞法分析和語法分析,生成 AST 。AST 是抽象語法樹,結構上比代碼更精簡,遍歷起來更快,所以使用 AST 能夠更快速地進行靜態檢查,同時還能更快地生成 IR(中間表示)
- 最后 AST 會生成 IR,IR 是一種更接近機器碼的語言,區別在于和平臺有關,通過 IR 可以生成多份適合不同平臺的機器碼。對于 iOS 系統,IR 生成的可執行文件就是 Mach-O。
OC源文件的編譯過程
使用以下命令,查看OC源文件的編譯過程
clang -ccc-print-phases main.m
0:先找到main.m文件
1:預處理器,就是把include、import、宏定義給替換掉
2:編譯成IR中間代碼
3:把中間代碼給后端,生成匯編代碼
4:匯編生成目標代碼
5:鏈接靜態庫、動態庫
6:適合某個架構的代碼
預處理
使用以下命令,可以查看預處理階段所做的工作
clang -E main.m
預處理主要做了以下幾件事情:
1、刪除所有的#define,代碼中使用宏定義的地方會進行替換
2、將#include包含的文件插入到文件的位置,這個插入的過程是遞歸的
3、刪除掉注釋符號及注釋
4、添加行號和文件標識,便于調試
編譯
編譯的過程就是把預處理后的文件進行 詞法分析、語法分析、語義分析及優化后產生相應的匯編代碼
1、詞法分析
這一步把源文件中的代碼轉化為特殊的標記流,源碼被分割成一個一個的字符和單詞,在行尾Loc中都標記出了源碼所在的對應源文件和具體行數,方便在報錯時定位問題。
使用以下命令來進行詞法分析
clang -Xclang -dump-tokens main.m
以下面這段代碼為例:
第11行的這段源碼
int main(int argc, char * argv[]) {
通過詞法分析,會轉化為以下的特殊標記
int 'int' [StartOfLine] Loc=<main.m:11:1>
identifier 'main' [LeadingSpace] Loc=<main.m:11:5>
l_paren '(' Loc=<main.m:11:9>
int 'int' Loc=<main.m:11:10>
identifier 'argc' [LeadingSpace] Loc=<main.m:11:14>
comma ',' Loc=<main.m:11:18>
char 'char' [LeadingSpace] Loc=<main.m:11:20>
star '*' [LeadingSpace] Loc=<main.m:11:25>
identifier 'argv' [LeadingSpace] Loc=<main.m:11:27>
l_square '[' Loc=<main.m:11:31>
r_square ']' Loc=<main.m:11:32>
r_paren ')' Loc=<main.m:11:33>
l_brace '{' [LeadingSpace] Loc=<main.m:11:35>
2、語法分析
這一步就是根據詞法分析的標記流,解析成一個語法樹,在Clang中由Parser和Sema兩個模塊配合完成
在這里面每一個節點也都標記了自己在源碼中的位置
驗證語法是否正確,比如少一個;報一個錯誤提示
根據當前語言的語法,生成語義節點,并將所有的節點組合成抽象語法樹
使用以下命令來進行語法分析
clang -Xclang -ast-dump -fsyntax-only main.m
會解析成以下的語法樹
-FunctionDecl 0x7ffe251a8ce0 <main.m:11:1, line:20:1> line:11:5 main 'int (int, char **)'
|-ParmVarDecl 0x7ffe251a8b00 <col:10, col:14> col:14 argc 'int'
|-ParmVarDecl 0x7ffe251a8bc0 <col:20, col:32> col:27 argv 'char **':'char **'
`-CompoundStmt 0x7ffe251a9200 <col:35, line:20:1>
|-ObjCAutoreleasePoolStmt 0x7ffe251a91b8 <line:13:5, line:18:5>
| `-CompoundStmt 0x7ffe251a9188 <line:13:22, line:18:5>
| |-DeclStmt 0x7ffe251a8e30 <line:14:9, col:32>
| | `-VarDecl 0x7ffe251a8da8 <col:9, line:9:21> line:14:13 used eight 'int' cinit
| | `-IntegerLiteral 0x7ffe251a8e10 <line:9:21> 'int' 8
| |-DeclStmt 0x7ffe251a8ee8 <line:15:9, col:20>
| | `-VarDecl 0x7ffe251a8e60 <col:9, col:19> col:13 used six 'int' cinit
| | `-IntegerLiteral 0x7ffe251a8ec8 <col:19> 'int' 6
| |-DeclStmt 0x7ffe251a9010 <line:16:9, col:31>
| | `-VarDecl 0x7ffe251a8f18 <col:9, col:28> col:13 used rank 'int' cinit
| | `-BinaryOperator 0x7ffe251a8ff0 <col:20, col:28> 'int' '+'
| | |-ImplicitCastExpr 0x7ffe251a8fc0 <col:20> 'int' <LValueToRValue>
| | | `-DeclRefExpr 0x7ffe251a8f80 <col:20> 'int' lvalue Var 0x7ffe251a8da8 'eight' 'int'
| | `-ImplicitCastExpr 0x7ffe251a8fd8 <col:28> 'int' <LValueToRValue>
| | `-DeclRefExpr 0x7ffe251a8fa0 <col:28> 'int' lvalue Var 0x7ffe251a8e60 'six' 'int'
| `-CallExpr 0x7ffe251a9128 <line:17:9, col:30> 'void'
| |-ImplicitCastExpr 0x7ffe251a9110 <col:9> 'void (*)(id, ...)' <FunctionToPointerDecay>
| | `-DeclRefExpr 0x7ffe251a9028 <col:9> 'void (id, ...)' Function 0x7ffe20b20e88 'NSLog' 'void (id, ...)'
| |-ImplicitCastExpr 0x7ffe251a9158 <col:15, col:16> 'id':'id' <BitCast>
| | `-ObjCStringLiteral 0x7ffe251a9068 <col:15, col:16> 'NSString *'
| | `-StringLiteral 0x7ffe251a9048 <col:16> 'char [8]' lvalue "rank-%d"
| `-ImplicitCastExpr 0x7ffe251a9170 <col:26> 'int' <LValueToRValue>
| `-DeclRefExpr 0x7ffe251a9088 <col:26> 'int' lvalue Var 0x7ffe251a8f18 'rank' 'int'
`-ReturnStmt 0x7ffe251a91f0 <line:19:5, col:12>
`-IntegerLiteral 0x7ffe251a91d0 <col:12> 'int' 0
3、靜態分析(通過語法樹進行代碼靜態分析,找出非語法性錯誤)
1、錯誤檢查
如出現方法被調用但是未定義、定義但是未使用的變量
2、類型檢查
一般會把類型分為兩類:動態的和靜態的。動態的在運行時做檢查,靜態的在編譯時做檢查。編寫代碼時可以向任意對象發送任何消息,在運行時,才會檢查對象是否能夠響應這些消息。
4、CodeGen - IR代碼生成
1、CodeGen 負責將語法樹從頂至下遍歷,翻譯成 LLVM IR
2、LLVM IR是Frontend的輸出,也是LLVM Backend的輸入,前后端的橋接語言
3、與Objective-C Runtime 橋接
與Objective-C Runtime 橋接的應用
1、在Objective-C中的 Class / Meta Class / Protocol /Category 這些結構體的內存結構就是在這一步生成的,并放在了Mach-O指定的Section中(如 Class: _DATA, _objc _classrefs),這個 DATA段也會存放一些static變量
2、objct對象發送一個消息最終會編譯成什么樣子啊,會編譯成objc_msgSend調用就發生在這一步,將語法樹中的ObjCMessageExpr翻譯成相應版本的objc_msgSend,對super關鍵字的調用翻譯成objc_msgSendSuper
3、根據修飾符strong / weak /copy /atomic 合成@property自動實現的getter / setter、處理@synthesize也是這一步做的
4、生成block_layout的數據結構、變量的capture(
__block
/ 和__weak
),生成_block_invoke
函數都發生在這一步5、之前總說ARC是編譯器幫我們插入一些內存管理的代碼,具體也是在這一步完成的
ARC: 分析對象的引用關系,將objc_StoreStrong / Objc_StoreWeak等ARC代碼的插入
將ObjCAutotreleasePoolStmt轉譯成objc_autoreleasePoolPush/Pop
實現自動調用[super dealloc]
為每個擁有ivar的Class 合成.cxx_destructor 方法來自動釋放類的成員變量,代替MRC時代的 “self.xxx = nil”
LLVM的中間產物及優化
使用以下命令,生成LLVM中間產物IR(Intermediate Representation),把這個過程打印出來
clang -O3 -S -emit-llvm main.m -o main.ll
使用以下命令,會使用LLVM對代碼進行優化。
//針對全局變量優化、循環優化、尾遞歸優化等。
//在 Xcode 的編譯設置里也可以設置優化級別-01,-03,-0s,還可以寫些自己的 Pass。
clang -emit-llvm -c main.m -o main.bc
生成匯編代碼
使用以下命令,生成相對應的匯編代碼。
clang -S -fobjc-arc main.m -o main.s
至此,編譯階段完成,將書寫代碼轉換成了機器可以識別的匯編代碼,匯編器是將匯編代碼轉變成機器可以執行的指令,每一個匯編語句幾乎都對應一條機器指令。根據匯編指令和機器指令的對照表一一翻譯就可以了。
使用以下命令,生成對應的目標文件。
clang -fmodules -c main.m -o main.o
后來的Xcode新建的工程里并沒有pch文件,為什么呢?
pch文件就是把UIKit、Foundation這些庫用pch文件import一下,這樣就不用在每個源文件中去解析這么多東西了,現在iOS這邊亂搞把一些全局的變量,自己模塊的一些東西都放在里面。
Xcode里面出了一個modules的概念,各個setting里面也是打開的,默認把庫打成一個modules的形式,尤其是UIKit、Foundation這些庫全部都是modules,好處就是我加這個參數(fmodules)以后它就會自動把#import變成@import,現在的編譯就會比最早的那種連pch都沒有的快很多,因為它的出現pch就不會默認出現了
$clang -E -fmodules main.m //加入fmodules參數生成可執行文件
鏈接
這一階段是將上個階段生成的目標文件和引用的靜態庫鏈接起來,最終生成可執行文件,鏈接器解決了目標文件和庫之間的鏈接。
編譯時鏈接器做了什么?
1、Mach-O里面主要是代碼和數據,代碼是函數的定義,數據是全局變量的定義,不管是代碼還是數據都是通過符號關聯起來的。
2、Mach-O里面的代碼,要操作的變量和函數要綁定到各自的地址上,鏈接器的作用就是完成變量和函數的符號和其地址的綁定。
為什么要做符號綁定?
1、如果地址和符號不做綁定的話,要讓機器知道你在操作什么地址,就需要寫代碼的時候設置好內存地址。
2、可讀性差,修改代碼后要重新對地址進行維護
3、需要針對不同平臺寫多份代碼,相當于直接寫匯編
為什么還要把項目中的多個Mach-O合并成一個?
1、多個文件之間的變量和接口是相互依賴的,就需要鏈接器把項目中多個Mach-O文件符號和地址綁定起來。
2、不綁定的話單個文件生成的Mach-O就是無法運行的,運行時遇到調用其他文件的函數實現時,就會找不到函數地址。
3、鏈接多個目標文件就會創建一個符號表,記錄所有已定義和未定義的符號,如果出現相同符號的情況,就會出現“ld: dumplicate symbols”的錯誤信息,如果在目標文件中沒有找到符號,就會提示“Undefined symbols”的錯誤信息。
鏈接器對代碼主要做了哪幾件事?
1、去代碼文件中查找沒有定義的變量
2、將所有符號定義和引用地址收集起來,并放到全局符號表中
3、計算合并后的長度及位置,生成同類型的段進行合并,建立綁定
4、對項目中不同文件里的變量進行地址重定位
鏈接器如何去除無用的函數,保證Mach-O的大小?
鏈接器在整理函數的調用關系時,會以main函數為源頭跟隨每個引用并將其標記為live,跟隨完成后那些未被標記為live的就是無用函數。
總結:一個源文件的編譯過程
代碼實踐
#import <Foundation/Foundation.h>
int main() {
NSLog(@"hello world!");
return 0;
}
1、生成Mach-O可執行文件
clang -fmodules main.m -o main
2、生成抽象語法樹
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
3、生成匯編代碼
clang -S main.m -o main.s
裝載與鏈接
一個App從可執行文件到真正啟動運行代碼,基本需要經過裝載和動態庫鏈接兩個步驟。
程序運行起來會擁有獨立的虛擬地址空間,在操作系統上會同時運行多個進程,彼此之間的虛擬地址空間是隔離的。
裝載就是把可執行文件映射到虛擬內存中的過程,由于內存資源稀缺,只將程序最常用的部分駐留在內存里,不太常用的數據放在磁盤里,這也是動態裝載的過程。
裝載的過程就是進程建立的過程,操作系統主要做了3件事:
1、創建一個獨立的虛擬地址
2、讀取可執行文件頭,并且建立虛擬空間與可執行文件的映射關系
3、將CPU的寄存區設置成可執行文件的入口地址,啟動運行
靜態庫
靜態庫是編譯時鏈接的庫,需要鏈接進你的Mach-O文件里,如果需要更新就重新編譯一次,無法動態的加載和更新。
動態庫
動態庫是運行時鏈接的庫,使用dyld就可以實現動態加載,iOS中的系統庫都是動態鏈接的。
共享緩存
Mach-O是編譯后的產物,而動態庫在運行時才會被鏈接,所有Mach-O中并沒有動態庫的符號定義。
Mach-O中動態庫中的符號是未定義的,但他們的名字和對應的庫的路徑會被記錄下來。
運行時dlopen 和 dlsym 導入動態庫時,先根據記錄的庫路徑找到對應的庫,再通過記錄的名字符號找到綁定的地址。
優點:
代碼共用、易于維護、減少可執行文件的體積