objc系列譯文(6.2):編譯器

發表於2014-04-24

編譯器做些什麼?

本文主要探討一下編譯器主要做些什麼,以及如何有效的利用編譯器。

簡單的說,編譯器有兩個職責:把 Objective-C 程式碼轉化成低階程式碼,以及對程式碼做分析,確保程式碼中沒有任何明顯的錯誤。

現在,Xcode 的預設編譯器是 clang。本文中我們提到的編譯器都表示 clang。clang 的功能是首先對 Objective-C 程式碼做分析檢查,然後將其轉換為低階的類彙編程式碼:LLVM Intermediate Representation(LLVM 中間表達碼)。接著 LLVM 會執行相關指令將 LLVM IR 編譯成目標平臺上的本地位元組碼,這個過程的完成方式可以是即時編譯 (Just-in-time),或在編譯的時候完成。

LLVM 指令的一個好處就是可以在支援 LLVM 的任意平臺上生成和執行 LLVM 指令。例如,你寫的一個 iOS app, 它可以自動的執行在兩個完全不同的架構(Inter 和 ARM)上,LLVM 會根據不同的平臺將 IR 碼轉換為對應的本地位元組碼。

LLVM 的優點主要得益於它的三層式架構 — 第一層支援多種語言作為輸入(例如 C, ObjectiveC, C++ 和 Haskell),第二層是一個共享式的優化器(對 LLVM IR 做優化處理),第三層是許多不同的目標平臺(例如 Intel, ARM 和 PowerPC)。在這三層式的架構中,如果你想要新增一門語言到 LLVM 中,那麼可以把重要精力集中到第一層上,如果想要增加另外一個目標平臺,那麼你沒必要過多的考慮輸入語言。在書 The Architecture of Open Source Applications 中 LLVM 的建立者 (Chris Lattner) 寫了一章很棒的內容:關於LLVM 架構

在編譯一個原始檔時,編譯器的處理過程分為幾個階段。要想檢視編譯 hello.m 原始檔需要幾個不同的階段,我們可以讓通過 clang 命令觀察:

本文我們將重點關注第一階段和第二階段。在文章 Mach-O Executables 中,Daniel 會對第三階段和第四階段進行闡述。

預處理

每當編源譯檔案的時候,編譯器首先做的是一些預處理工作。比如前處理器會處理原始檔中的巨集定義,將程式碼中的巨集用其對應定義的具體內容進行替換。

例如,如果在原始檔中出現下述程式碼:

前處理器對這行程式碼的處理是用 Foundation.h 檔案中的內容去替換這行程式碼,如果 Foundation.h 中也使用了類似的巨集引入,則會按照同樣的處理方式用各個巨集對應的真正程式碼進行逐級替代。

這也就是為什麼人們主張標頭檔案最好儘量少的去引入其他的類或庫,因為引入的東西越多,編譯器需要做的處理就越多。例如,在標頭檔案中用:

代替:

這麼寫是告訴編譯器 MyClass 是一個類,並且在 .m 實現檔案中可以通過 import MyClass.h 的方式來使用它。

假設我們寫了一個簡單的 C 程式 hello.c:

然後給上面的程式碼執行以下預處理命令,看看是什麼效果:

接下來看看處理後的程式碼,一共是 401 行。如果將如下一行程式碼新增到上面程式碼的頂部::

再執行一下上面的預處理命令,處理後的檔案程式碼行數暴增至 89,839 行。這個數字比某些作業系統的總程式碼行數還要多。

幸好,目前的情況已經改善許多了:引入了模組 – modules功能,這使預處理變得更加的高階。

自定義巨集

我們來看看另外一種情形定義或者使用自定義巨集,比如定義瞭如下巨集:

那麼,凡是在此行巨集定義作用域內,輸入了 MY_CONSTANT,在預處理過程中 MY_CONSTANT 都會被替換成 4。我們定義的巨集也是可以攜帶引數的, 比如:

鑑於本文的內容所限,就不對強大的預處理做更多、更全面的展開討論了。但是還是要強調一點,建議大家不要在需要預處理的程式碼中加入內聯程式碼邏輯。

例如,下面這段程式碼,這樣用沒什麼問題:

但是如果換成這麼寫:

用clang的max.c編譯一下,結果是:

用 clang -E max.c 進行巨集展開的預處理結果是如下所示:

本例是典型的巨集使用不當,而且通常這類問題非常隱蔽且難以 debug 。針對本例這類情況,最好使用 static inline:

這樣改過之後,就可以輸出正常的結果 (i:201)。因為這裡定義的程式碼是內聯的 (inlined),所以它的效率和巨集變數差不多,但是可靠性比巨集定義要好許多。再者,還可以設定斷點、型別檢查以及避免異常行為。

基本上,巨集的最佳使用場景是日誌輸出,可以使用 __FILE__ 和 __LINE__ 和 assert 巨集。

詞法解析標記

預處理完成以後,每一個 .m 原始檔裡都有一堆的宣告和定義。這些程式碼文字都會從 string 轉化成特殊的標記流。

例如,下面是一段簡單的 Objective-C hello word 程式:

利用 clang 命令 clang -Xclang -dump-tokens hello.m 來將上面程式碼的標記流匯出:

仔細觀察可以發現,每一個標記都包含了對應的原始碼內容和其在原始碼中的位置。注意這裡的位置是巨集展開之前的位置,這樣一來,如果編譯過程中遇到什麼問題,clang 能夠在原始碼中指出出錯的具體位置。

解析

接下來要說的東西比較有意思:之前生成的標記流將會被解析成一棵抽象語法樹 (abstract syntax tree — AST)。由於 Objective-C 是一門複雜的語言,因此解析的過程不簡單。解析過後,源程式變成了一棵抽象語法樹:一棵代表源程式的樹。假設我們有一個程式hello.m

當我們執行 clang 命令 clang -Xclang -ast-dump -fsyntax-only hello.m 之後,命令列中輸出的結果如下所示:

在抽象語法樹中的每個節點都標註了其對應原始碼中的位置,同樣的,如果產生了什麼問題,clang 可以定位到問題所在處的原始碼位置。

延伸閱讀

靜態分析

一旦編譯器把原始碼生成了抽象語法樹,編譯器可以對這棵樹做分析處理,以找出程式碼中的錯誤,比如型別檢查:即檢查程式中是否有型別錯誤。例如:如果程式碼中給某個物件傳送了一個訊息,編譯器會檢查這個物件是否實現了這個訊息(函式、方法)。此外,clang 對整個程式還做了其它更高階的一些分析,以確保程式沒有錯誤。

型別檢查

每當開發人員編寫程式碼的時候,clang 都會幫忙檢查錯誤。其中最常見的就是檢查程式是否傳送正確的訊息給正確的物件,是否在正確的值上呼叫了正確的函式。如果你給一個單純的 NSObject* 物件傳送了一個 hello 訊息,那麼 clang 就會報錯。同樣,如果你建立了 NSObject 的一個子類 Test, 如下所示:

然後試圖給這個子類中某個屬性設定一個與其自身型別不相符的物件,編譯器會給出一個可能使用不正確的警告。

一般會把型別分為兩類:動態的和靜態的。動態的在執行時做檢查,靜態的在編譯時做檢查。以往,編寫程式碼時可以向任意物件傳送任何訊息,在執行時,才會檢查物件是否能夠響應這些訊息。由於只是在執行時做此類檢查,所以叫做動態型別。

至於靜態型別,是在編譯時做檢查。當在程式碼中使用 ARC 時,編譯器在編譯期間,會做許多的型別檢查:因為編譯器需要知道哪個物件該如何使用。例如,如果 myObject 沒有 hello 方法,那麼就不能寫如下這行程式碼了:

其他分析

clang 在靜態分析階段,除了型別檢查外,還會做許多其它一些分析。如果你把 clang 的程式碼倉庫 clone 到本地,然後進入目錄lib/StaticAnalyzer/Checkers,你會看到所有靜態檢查內容。比如 ObjCUnusedIVarsChecker.cpp 是用來檢查是否有定義了,但是從未使用過的變數。而 ObjCSelfInitChecker.cpp 則是檢查在 你的初始化方法中中呼叫 self 之前,是否已經呼叫 [self initWith...] 或 [super init] 了。編譯器還進行了一些其它的檢查,例如在 lib/Sema/SemaExprObjC.cpp 的 2,534 行,有這樣一句:

這個會生成嚴重錯誤的警告 “performSelector may cause a leak because its selector is unknown” 。

程式碼生成

clang 完成程式碼的標記,解析和分析後,接著就會生成 LLVM 程式碼。下面繼續看看hello.c

要把這段程式碼編譯成 LLVM 位元組碼(絕大多數情況下是二進位制碼格式),我們可以執行下面的命令:

接著用另一個命令來檢視剛剛生成的二進位制檔案:

輸出如下:

在上面的程式碼中,可以看到 main 函式只有兩行程式碼:一行輸出string,另一行返回 0

再換一個程式,拿 five.m 為例,對其做相同的編譯,然後執行 LLVM-dis < five.bc | less:

拋開其他的不說,單看 main 函式:

上面程式碼中最重要的是第 4 行,它建立了一個 NSNumber 物件。第 7 行,給這個 number 物件傳送了一個 description 訊息。第 8 行,將 description 訊息返回的內容列印出來。

優化

要想了解 LLVM 的優化內容,以及 clang 能做哪些優化,我們先看一個略微複雜的 C 程式:這個函式主要是遞迴計算 階乘

先看看不做優化的編譯情況,執行下面命令:

重點看一下針對 階乘 部分生成的程式碼:

看一下 %9 標註的那一行,這行程式碼正是遞迴呼叫階乘函式本身,實際上這樣呼叫是非常低效的,因為每次遞迴呼叫都要重新壓棧。接下來可以看一下優化後的效果,可以通過這樣的方式開啟優化 — 將 -03 標誌傳給 clang:

現在 階乘 計算相關程式碼編譯後生成的程式碼如下:

即便我們的函式並沒有按照尾遞迴的方式編寫,clang 仍然能對其做優化處理,讓該函式編譯的結果中只包含一個迴圈。當然 clang 能對程式碼進行的優化還有很多方面。可以看以下這個比較不錯的 gcc 的優化例子ridiculousfish.com

延伸閱讀

如何在實際中應用這些特性

剛剛我們探討了編譯的全過程,從標記到解析,從抽象語法樹到分析檢查,再到彙編。讀者不禁要問,為什麼要關注這些?

使用 libclan g或 clang 外掛

之所以 clang 很酷:是因為它是一個開源的專案、並且它是一個非常好的工程:幾乎可以說全身是寶。使用者可以建立自己的 clang 版本,針對自己的需求對其進行改造。比如說,可以改變 clang 生成程式碼的方式,增加更強的型別檢查,或者按照自己的定義進行程式碼的檢查分析等等。要想達成以上的目標,有很多種方法,其中最簡單的就是使用一個名為 libclang 的C類庫。libclang 提供的 API 非常簡單,可以對 C 和 clang 做橋接,並可以用它對所有的原始碼做分析處理。不過,根據我的經驗,如果使用者的需求更高,那麼 libclang 就不怎麼行了。針對這種情況,推薦使用 Clangkit,它是基於 clang 提供的功能,用 Objective-C 進行封裝的一個庫。

最後,clang 還提供了一個直接使用 LibTooling 的 C++ 類庫。這裡要做的事兒比較多,而且涉及到 C++,但是它能夠發揮 clang 的強大功能。用它你可以對原始碼做任意型別的分析,甚至重寫程式。如果你想要給 clang 新增一些自定義的分析、建立自己的重構器 (refactorer)、或者需要基於現有程式碼做出大量修改,甚至想要基於工程生成相關圖形或者文件,那麼 LibTooling 是很好的選擇。

自定義分析器

開發者可以按照 Tutorial for building tools using LibTooling 中的說明去構造 LLVM ,clang 以及 clan g的附加工具。需要注意的是,編譯程式碼是需要花費一些時間的,即時機器已經很快了,但是在編譯期間,我還是可以吃頓飯的。

接下來,進入到 LLVM 目錄,然後執行命令cd ~/llvm/tools/clang/tools/。在這個目錄中,可以建立自己獨立的 clang 工具。例如,我們建立一個小工具,用來檢查某個庫是否正確使用。首先將 樣例工程 克隆到本地,然後輸入 make。這樣就會生成一個名為example 的二進位制檔案。

我們的使用場景是:假如有一個 Observer 類, 程式碼如下所示:

接下來,我們想要檢查一下每當這個類被呼叫的時候,在 target 物件中是否都有對應的 action 方法存在。可以寫個 C++ 函式來做這件事(注意,這是我第一次寫 C++ 程式,可能不那麼嚴謹):

上面的這個方法首先查詢訊息表示式, 以 Observer 作為接收者, observerWithTarget:action: 作為 selector,然後檢查 target 中是否存在相應的方法。雖然這個例子有點兒刻意,但如果你想要利用 AST 對自己的程式碼庫做某些檢查,按照上面的例子來就可以了。

clang的其他特性

clang還有許多其他的用途。比如,可以寫編譯器外掛(例如,類似上面的檢查器例子)並且動態的載入到編譯器中。雖然我沒有親自實驗過,但是我覺得在 Xcode 中應該是可行的。再比如,也可以通過編寫 clang 外掛來自定義程式碼樣式(具體可以參見 編譯過程)。

另外,如果想對現有的程式碼做大規模的重構, 而 Xcode 或 AppCode 本身整合的重構工具無法達你的要求,你完全可以用 clang 自己寫個重構工具。聽起來有點兒可怕,讀讀下面的文件和教程,你會發現其實沒那麼難。

最後,如果是真的有這種需求,你完全可以引導 Xcdoe 使用你自己編譯的 clang 。再一次,如果你去嘗試,其實這些事兒真的沒想象中那麼複雜,反而會發現許多箇中樂趣。

相關文章