深入剖析 iOS 編譯 Clang / LLVM

發表於2017-01-08

前言

iOS 開發中 Objective-C 和 Swift 都用的是 Clang / LLVM 來編譯的。LLVM是一個模組化和可重用的編譯器和工具鏈技術的集合,Clang 是 LLVM 的子專案,是 C,C++ 和 Objective-C 編譯器,目的是提供驚人的快速編譯,比 GCC 快3倍,其中的 clang static analyzer 主要是進行語法分析,語義分析和生成中間程式碼,當然這個過程會對程式碼進行檢查,出錯的和需要警告的會標註出來。LLVM 核心庫提供一個優化器,對流行的 CPU 做程式碼生成支援。lld 是 Clang / LLVM 的內建連結器,clang 必須呼叫連結器來產生可執行檔案。

LLVM 比較有特色的一點是它能提供一種程式碼編寫良好的中間表示 IR,這意味著它可以作為多種語言的後端,這樣就能夠提供語言無關的優化同時還能夠方便的針對多種 CPU 的程式碼生成。

編譯流程

在列出完整步驟之前可以先看個簡單例子。看看是如何完成一次編譯的。

在命令列編譯

在手機上就能夠直接執行main了。這樣還沒發看清clang的全部過程,可以通過-E檢視clang在預處理處理這步做了什麼。

執行完後可以看到檔案

這個過程的處理包括巨集的替換,標頭檔案的匯入,以及類似#if的處理。預處理完成後就會進行詞法分析,這裡會把程式碼切成一個個 Token,比如大小括號,等於號還有字串等。

然後是語法分析,驗證語法是否正確,然後將所有節點組成抽象語法樹 AST 。

完成這些步驟後就可以開始IR中間程式碼的生成了,CodeGen 會負責將語法樹自頂向下遍歷逐步翻譯成 LLVM IR,IR 是編譯過程的前端的輸出後端的輸入。

這裡 LLVM 會去做些優化工作,在 Xcode 的編譯設定裡也可以設定優化級別-01,-03,-0s,還可以寫些自己的 Pass。

Pass 是 LLVM 優化工作的一個節點,一個節點做些事,一起加起來就構成了 LLVM 完整的優化和轉化。

如果開啟了 bitcode 蘋果會做進一步的優化,有新的後端架構還是可以用這份優化過的 bitcode 去生成。

生成彙編

生成目標檔案

生成可執行檔案,這樣就能夠執行看到輸出結果

下面是完整步驟:

  • 編譯資訊寫入輔助檔案,建立檔案架構 .app 檔案
  • 處理檔案打包資訊
  • 執行 CocoaPod 編譯前指令碼,checkPods Manifest.lock
  • 編譯.m檔案,使用 CompileC 和 clang 命令
  • 連結需要的 Framework
  • 編譯 xib
  • 拷貝 xib ,資原始檔
  • 編譯 ImageAssets
  • 處理 info.plist
  • 執行 CocoaPod 指令碼
  • 拷貝標準庫
  • 建立 .app 檔案和簽名

Clang 編譯 .m 檔案

在 Xcode 編譯過後,可以通過 Show the report navigator 裡對應 target 的 build 中檢視每個 .m 檔案的 clang 編譯資訊。

具體拿編譯 AFSecurityPolicy.m 的資訊來看看。首先對任務進行描述。

接下來對會更新工作路徑,同時設定 PATH

接下來就是實際的編譯命令

clang 命令引數

構建 Target

編譯工程中的第三方依賴庫後會構建我們程式的 target,會按順序輸出如下的資訊:

從這些資訊可以看出在這些步驟中會分別呼叫不同的命令列工具來執行。

Target 在 Build 過程的控制

在 Xcode 的 Project editor 中的 Build Setting,Build Phases 和 Build Rules 能夠控制編譯的過程。

Build Phases

構建可執行檔案的規則。指定 target 的依賴專案,在 target build 之前需要先 build 的依賴。在 Compile Source 中指定所有必須編譯的檔案,這些檔案會根據 Build Setting 和 Build Rules 裡的設定來處理。

在 Link Binary With Libraries 裡會列出所有的靜態庫和動態庫,它們會和編譯生成的目標檔案進行連結。

build phase 還會把靜態資源拷貝到 bundle 裡。

可以通過在 build phases 裡新增自定義指令碼來做些事情,比如像 CocoaPods 所做的那樣。

Build Rules

指定不同檔案型別如何編譯。每條 build rule 指定了該型別如何處理以及輸出在哪。可以增加一條新規則對特定檔案型別新增處理方法。

Build Settings

在 build 的過程中各個階段的選項的設定。

pbxproj工程檔案

build 過程控制的這些設定都會被儲存在工程檔案 .pbxproj 裡。在這個檔案中可以找 rootObject 的 ID 值

然後根據這個 ID 找到 main 工程的定義。

在 targets 裡會指向各個 taget 的定義

順著這些 ID 就能夠找到更詳細的定義地方。比如我們通過 GCDFetchFeed 這個 target 的 ID 找到定義如下:

這個裡面又有更多的 ID 可以得到更多的定義,其中 buildConfigurationList 指向了可用的配置項,包含 Debug 和 Release。可以看到還有 buildPhases,buildRules 和 dependencies 都能夠通過這裡索引找到更詳細的定義。

接下來看看 Clang 所做的事情。

Clang Static Analyzer靜態程式碼分析

可以在 llvm/clang/ Source Tree – Woboq Code Browser 上檢視 Clang 的程式碼。

clang 靜態分析是通過建立分析引擎和 checkers 所組成的架構,這部分功能可以通過 clang —analyze 命令方式呼叫。clang static analyzer 分為 analyzer core 分析引擎和 checkers 兩部分,所有 checker 都是基於底層分析引擎之上,通過分析引擎提供的功能能夠編寫新的 checker。

可以通過 clang –analyze -Xclang -analyzer-checker-help 來列出當前 clang 版本下所有 checker。如果想編寫自己的 checker,可以在 clang 專案的 StaticAnalyzer/Checkers 目錄下找到例項參考。這種方式能夠方便使用者擴充套件對程式碼檢查規則或者對 bug 型別進行擴充套件,但是這種架構也有不足,每執行完一條語句後,分析引擎會遍歷所有 checker 中的回撥函式,所以 checker 越多,速度越慢。通過 clang -cc1 -analyzer-checker-help 可以列出能呼叫的 checker,下面是常用 checker

這些 checker 裡最常用的是 DumpCFG,DumpCallGraph,DumpLiveVars 和 DumpViewExplodedGraph。

clang static analyzer 引擎大致分為 CFG,MemRegion,SValBuilder,ConstraintManager 和 ExplodedGraph 幾個模組。clang static analyzer 本質上就是 path-sensitive analysis,要很好的理解 clang static analyzer 引擎就需要對 Data Flow Analysis 有所瞭解,包括迭代資料流分析,path-sensitive,path-insensitive ,flow-sensitive等。

編譯的概念(詞法->語法->語義->IR->優化->CodeGen)在 clang static analyzer 裡到處可見,例如 Relaxed Live Variables Analysis 可以減少分析中的記憶體消耗,使用 mark-sweep 實現 Dead Symbols 的刪除。

clang static analyzer 提供了很多輔助方法,比如 SVal.dump(),MemRegion.getString 以及 Stmt 和 Dcel 提供的 dump 方法。Clang 抽象語法樹 Clang AST 常見的 API 有 Stmt,Decl,Expr 和 QualType。在編寫 checker 時會遇到 AST 的層級檢查,這時有個很好的介面 StmtVisitor,這個介面類似 RecursiveASTVisitor。

整個 clang static analyzer 的入口是 AnalysisConsumer,接著會調 HandleTranslationUnit() 方法進行 AST 層級進行分析或者進行 path-sensitive 分析。預設會按照 inline 的 path-sensitive 分析,構建 CallGraph,從頂層 caller 按照呼叫的關係來分析,具體是使用的 WorkList 演算法,從 EntryBlock 開始一步步的模擬,這個過程叫做 intra-procedural analysis(IPA)。這個模擬過程還需要對記憶體進行模擬,clang static analyzer 的記憶體模型是基於《A Memory Model for Static Analysis of C Programs》這篇論文而來,pdf地址:http://lcs.ios.ac.cn/~xuzb/canalyze/memmodel.pdf 在clang裡的具體實現程式碼可以檢視這兩個檔案 MemRegion.hRegionStore.cpp

下面舉個簡單例子看看 clang static analyzer 是如何對原始碼進行模擬的。

對應的 AST 以及 CFG

CFG 將程式拆得更細,能夠將執行的過程表現的更直觀些,為了避免路徑爆炸,函式 inline 的條件會設定的比較嚴格,函式 CFG 塊多時不會進行 inline 分析,模擬棧深度超過一定值不會進行 inline 分析,這個預設是5。

Clang Attributes

attribute(xx) 的語法格式出現,是 Clang 提供的一些能夠讓開發者在編譯過程中參與一些原始碼控制的方法。下面列一些會用到的用法:

attribute((format(NSString, F, A))) 格式化字串

可以檢視 NSLog 的用法

attribute((deprecated(s))) 版本棄用提示

在編譯過程中能夠提示開發者該方法或者屬性已經被棄用

attribute((availability(os,introduced=m,deprecated=n, obsoleted=o,message=”” VA_ARGS))) 指明使用版本範圍

os 指系統的版本,m 指明引入的版本,n 指明過時的版本,o 指完全不用的版本,message 可以寫入些描述資訊。

attribute((unavailable(…))) 方法不可用提示

這個會在編譯過程中告知方法不可用,如果使用了還會讓編譯失敗。

attribute((unused))

沒有被使用也不報警告

attribute((warn_unused_result))

不使用方法的返回值就會警告,目前 swift3 已經支援該特性了。oc中也可以通過定義這個attribute來支援。

attribute((availability(swift, unavailable, message=_msg)))

OC 的方法不能在 Swift 中使用。

attribute((cleanup(…))) 作用域結束時自動執行一個指定方法

作用域結束包括大括號結束,return,goto,break,exception 等情況。這個動作是先於這個物件的 dealloc 呼叫的。

Reactive Cocoa 中有個比較好的使用範例,@onExit 這個巨集,定義如下:

這樣可以在就可以很方便的把需要成對出現的程式碼寫在一起了。同樣可以在 Reactive Cocoa 看到其使用

可以看出 attributes 的設定和釋放都在一起使得程式碼的可讀性得到了提高。

attribute((overloadable)) 方法過載

能夠在 c 的函式上實現方法過載。即同樣的函式名函式能夠對不同引數在編譯時能夠自動根據引數來選擇定義的函式

attribute((objc_designated_initializer)) 指定內部實現的初始化方法

  • 如果是 objc_designated_initializer 初始化的方法必須呼叫覆蓋實現 super 的 objc_designated_initializer 方法。
  • 如果不是 objc_designated_initializer 的初始化方法,但是該類有 objc_designated_initializer 的初始化方法,那麼必須呼叫該類的 objc_designated_initializer 方法或者非 objc_designated_initializer 方法,而不能夠呼叫 super 的任何初始化方法。

attribute((objc_subclassing_restricted)) 指定不能有子類

相當於 Java 裡的 final 關鍵字,如果有子類繼承就會出錯。

attribute((objc_requires_super)) 子類繼承必須呼叫 super

宣告後子類在繼承這個方法時必須要呼叫 super,否則會出現編譯警告,這個可以定義一些必要執行的方法在 super 裡提醒使用者這個方法的內容時必要的。

attribute((const)) 重複呼叫相同數值引數優化返回

用於數值型別引數的函式,多次呼叫相同的數值型引數,返回是相同的,只在第一次是需要進行運算,後面只返回第一次的結果,這時編譯器的一種優化處理方式。

attribute((constructor(PRIORITY))) 和 attribute((destructor(PRIORITY)))

PRIORITY 是指執行的優先順序,main 函式執行之前會執行 constructor,main 函式執行後會執行 destructor,+load 會比 constructor 執行的更早點,因為動態連結器載入 Mach-O 檔案時會先載入每個類,需要 +load 呼叫,然後才會呼叫所有的 constructor 方法。

通過這個特性,可以做些比較好玩的事情,比如說類已經 load 完了,是不是可以在 constructor 中對想替換的類進行替換,而不用加在特定類的 +load 方法裡。

Clang 警告處理

先看看這個

如果沒有#pragma clang 這些定義,會報出 sizeWithFont 的方法會被廢棄的警告,這個加上這個方法當然是為了相容老系統,加上 ignored “-Wdeprecated-declarations” 的作用是忽略這個警告。通過 clang diagnostic push/pop 可以靈活的控制程式碼塊的編譯選項。

使用 libclang 來進行語法分析

使用 libclang 裡面提供的方法對原始檔進行語法分析,分析語法樹,遍歷語法數上每個節點。寫個 python 指令碼來呼叫 clang

基於語法樹的分析還可以針對字串做加密。

LibTooling 對語法樹完全的控制

因為 LibTooling 能夠完全控制語法樹,那麼可以做的事情就非常多了,比如檢查命名是否規範,還能夠進行語言的轉換,比如把 OC 語言轉成JS或者 Swift 。可以用這個 tools 自己寫個工具去遍歷。

ClangPlugin

通過自己寫個外掛,可以將這個外掛新增到編譯的流程中,對編譯進行控制,可以在 LLVM 的這個目錄下檢視一些範例 llvm/tools/clang/tools

孫源主導的動態化方案 DynamicCocoa 中就是使用了一個將 OC 原始碼轉 JS 的外掛來進行程式碼的轉換。

滴滴的王康在做瘦身時也實現了一個自定義的 clang 外掛,具體自定義外掛的實現可以檢視他的這文章 《基於clang外掛的一種iOS包大小瘦身方案》

編譯後生成的二進位制內容 Link Map File

在 Build Settings 裡設定 Write Link Map File 為 Yes 後每次編譯都會在指定目錄生成這樣一個檔案。檔案內容包含 Object files,Sections,Symbols。下面分別說說這些內容

Object files

這個部分的內容都是 .m 檔案編譯後的 .o 和需要 link 的 .a 檔案。前面是檔案編號,後面是檔案路徑。

Sections

這裡描述的是每個 Section 在可執行檔案中的位置和大小。每個 Section 的 Segment 的型別分為 TEXT 程式碼段和 DATA 資料段兩種。

Symbols

Symbols 是對 Sections 進行了再劃分。這裡會描述所有的 methods,ivar 和字串,及它們對應的地址,大小,檔案編號資訊。

每次編譯後生成的 dSYM 檔案

在每次編譯後都會生成一個 dSYM 檔案,程式在執行中通過地址來呼叫方法函式,而 dSYM 檔案裡儲存了函式地址對映,這樣呼叫棧裡的地址可以通過 dSYM 這個對映表能夠獲得具體函式的位置。一般都會用來處理 crash 時獲取到的呼叫棧 .crash 檔案將其符號化。

可以通過 Xcode 進行符號化,將 .crash 檔案,.dSYM 和 .app 檔案放到同一個目錄下,開啟 Xcode 的 Window 選單下的 organizer,再點選 Device tab,最後選中左邊的 Device Logs。選擇 import 將 .crash 檔案匯入就可以看到 crash 的詳細 log 了。

還可以通過命令列工具 symbolicatecrash 來手動符號化 crash log。同樣先將 .crash 檔案,.dSYM 和 .app 檔案放到同一個目錄下,然後輸入下面的命令

Mach-O 檔案

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

Mach-O 檔案包含三個區域

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

Mach-O 檔案的解析

解析前先看看可以描述該檔案的結構體

根據這個結構體,需要先取出 magic,然後根據偏移量取出其它的資訊。遍歷 ncmds 能夠獲得所有的 segment。cputype 包含了 CPU_TYPE_I386,CPU_TYPE_X86_64,CPU_TYPE_ARM,CPU_TYPE_ARM64 等多種 CPU 的型別。

dyld動態連結

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

附:安裝編譯 LLVM

多種獲取方式

  • 官網:http://releases.llvm.org/download.html
  • svn
  • git

安裝

 

相關文章