標籤:

誰說不能與龍一起跳舞:Clang / LLVM (2)

本章將接著上一篇:誰說不能與龍一起跳舞:Clang / LLVM (1) - 藍色的味道 - 知乎專欄 文章進行編寫,而內容則是上篇文章末尾提到的Driver。下筆(鍵盤)之前,我思考了一下如何書寫本篇Driver,如是詳細還是簡述,是直接上代碼還是寫一篇總攬等。最後我決定先寫一篇概論,並且吹吹牛,然後再在下面的文章指明哪些具體的代碼部分,並且結合代碼來更細緻的闡述一些概念,然後實現我們自己的選項來達到更深的理解Driver,若你有更好的建議歡迎反饋給我。

對於Driver,這是幾乎所有編譯原理教材都會忽略的內容,因為這是一個實際工程問題,而非編譯理論問題。如你編譯器的選項如何設置,應該怎麼設置,你給用戶的是什麼介面,應該怎麼使用這些介面等問題,都是屬於做編譯器產品時應該考慮的問題,而非編譯原理教材本身應該講解的範疇。然而,Driver卻是實際編譯器的一個非常重要的部分,它也是最直接面向程序員使用者、最影響用戶體驗的一部分。如你在開發你的產品時選用的編譯器是GCC,你使用了很多GCC編譯器選項,當你想要移植到其它平台或者支持更多編譯器時,往往不是你的代碼需要做更改,而是你的編譯器選項需要做更改。如你使用了-std=c++11來開啟C++11,然而一些編譯器卻不是叫這個,如在AIX的IBM編譯器,叫做-qlanglvl=extended0x,在Windows下的MSVC也不識別-std=c++11這個選項。那麼,事到如今,各個平台都幾乎有一個事實統治的編譯器,很多時候若你要在這個平台下要與它競爭、與它搶用戶,一個很重要的地方就是保持與它的兼容,其中一大塊就是Driver,保證用戶在不修改編譯選項的時候,也能完成相同的功能,從而順利完成編譯器的遷移。於是乎,無論是IBM的XL編譯器,還是這篇文章談論的Clang,若它們想要在Linux下搶用戶,一個目標就是要保證與GCC的兼容性。而由於IBM編譯器的歷史比較悠久,以前採用的一直是-q風格的選項(如-qlanglvl=對應於-std=),所以IBM甚至提供了一個Driver叫做gxlC來保證你可以使用GCC的選項來調用XL編譯器,當然現在IBM編譯器越做越好,默認編譯器已經高度兼容GCC,在擁抱了Clang以後,我可以很自信的說IBM編譯器在編譯Linux開源軟體時,不需要更改任何編譯選項了,這也是抱了Clang的大腿,而且這個大腿足夠粗,感謝偉大的Clang!咳咳...回歸話題,這裡說到了gxlC來保證可以使用GCC的選項來調用XL編譯器,其實Clang也使用過類似的東西,這就是clang-cl。它可以讓Clang開啟微軟VC++的cl Driver模式,讓用戶可以使用微軟的cl選項來調用Clang編譯器,不過Clang在Windows的支持不是我的關注重點,所以我將更多的談論Clang本身及其默認的Driver上。然而說了這麼多,大家其實也可以發現Driver雖然在編譯原理里提到的不多,但是在實際編譯器開發中卻是非常重要的一個部分。

那麼,接下來我們看看Clang的Driver設計,那就是這一張圖:

這張圖來自於Clang官網,我將詳細的講解這張圖,Clang Driver的實現代碼也完全體現了這張圖的思想。

這張圖的橘色部分除了代表圖片左上角提及的Input / Output,更是代表了Clang Driver裡面的具體數據結構,如你所見的ArgList;綠色部分除了代表 Driver Functions,也代表了Driver的具體走向流程;藍紫色部分則是一些重要的輔助類。那麼,我們先從第一個步驟Parse講起。

  • Parse的作用是選項解析,負責把用戶傳入過來的命令行選項解析成一個一個的參數,放入到Arg實例中。

那麼,我們以一個具體的實例來看看會發生什麼。這裡,我有一個a.c文件,裡面就一個空的main函數。

// a.cnint main(){}n

然後我們很簡單的使用clang a.c -I/My/Hello/World來編譯,但是我們使用-###選項(它需要放在所有選項的前面),它可以列印Parse後的結果,所以命令是clang -### a.c -I/My/Hello/World.

首先,我們不要被這麼多選項嚇住了,我知道在第一次揭開編譯器這個黑盒子的時候都會被不知道的東西嚇住,但是後面見多了,理解了這些是什麼就不用害怕了。對於我們不知道的這些東西,我們暫時就忽略,知道是編譯器自己加上去,為了編譯程序使用的就好。而我們應該注意的是 我畫紅線的兩個地方,這是我們傳過去的東西,即我們要編譯的源文件a.c和選項-I/My/Hello/World. 在第一個紅框內,我們可以發現編譯器把-I/My/Hello/World拆開為了 -I 和 /My/Hello/World. 而這裡的-I 是 Clang支持的選項,在Clang內部使用的是OPT__I,隸屬於Option類。Clang對於選項使用了DSL專門處理,然後存為.td文件,再使用table-gen進行處理,轉為C++語言,隨後與其餘的C++代碼一起編譯。我會在後面的文章進行代碼分析的時候專門講解Option,並且我們會編寫我們的編譯選項,嵌套在Clang中。而/My/Hello/World就是-I所接收的值,這裡我們就可以發現Driver層做了處理,解析了我們傳進去的-I/My/Hello/World. 當然,相比-I/My/Hello/World, Driver對於我們傳進去的文件a.c則做了更多的處理,分別是-x c a.c. 那麼-x這個選項,我相信很多程序員是第一次見,這在Clang中是指定源代碼文件所需要的編譯語言。Clang Driver在識別到a.c為C語言文件後,則為-x選項加上了c這個值,表明在編譯C語言文件。那麼Clang Driver怎麼這麼聰明知道這是C語言文件呢?其實Clang Driver是個「蠢貨」,它就是根據文件後綴名.c來判定是C語言的。所以我們可以用-x c++ a.c來使用C++模式編譯。而若你有觀測過clang 和 clang++編譯的話,你會發現clang++本質就是clang,只是默認為C++編譯,所以所有相關選項都是針對C++。不信且看:

你可以發現它其實就是指向的clang,而非單獨的clang++。那麼我們用clang a.cpp為何不能編譯C++程序呢?如下面的簡單Hello World

// a.cppn#include <iostream>nnint main()n{n std::cout << "Hello World!" << std::endl;n}n

若是clang a.cpp 或者 clang -x c++ a.cpp,你會發現一堆的鏈接錯誤,如:

但是若我們回顧C++程序的編譯流程,我們知道一個C++程序會經歷 編譯 -> 鏈接 這兩個大流程,而這裡鏈接錯誤則代表著我們已經正確的過了編譯期,但是是鏈接期錯誤了。而C和C++在鏈接期一個巨大的差別就是C++鏈接期需要鏈接C++標準庫,所以我們鏈接上C++標準庫即可,如:

然而我們在使用clang++時則不需要,因為在Driver層已經幫你做好了。所以,在C與C++識別上,編譯器不僅會根據傳入的文件類型,也會根據你調用的是clang還是clang++來做處理,是兩方面。有同學可能會說,那麼我每次就自動加上-lc++好了?我會回答這樣不好。很多時候我們在調用C++時包括的可能不僅僅C++標準庫,如我們IBM C++編譯器,我們可能還有ibm c++標準庫等,甚至還有非C++標準庫,如其他的C++輔助庫等,所以何必自尋煩惱呢?C程序使用clang,C++程序使用clang++就好,麻煩事交給編譯器開發人員吧。

而在正式進入第二個流程前,我想再講解一個特別的選項 -cc1. 這個選項非常重要,若不理解也很難理解後面的代碼,因為在driver的main處理時,其本質會走入cc1_main. 那麼cc1是什麼呢?簡單的來說就是有了cc1,就走入到Clang的前端了。在這裡,將會有獨特的選項和行為,如-emit-obj選項,這會告訴Clang進行emit object file。但是,這個選項你在外層是無法使用的,如clang -emit-obj a.c,會說不識別emit-obj選項,因為這是Clang前端的,而不是Clang Driver的,你需要clang -cc1 -emit-obj a.c才可以。我在很久以前回答過一個有關cc1的問題,我以代碼的方式闡述過:Clang裡面真正的前端是什麼? - 編譯器 . 無論如何,即使現在不理解cc1,也需要記住clang -cc1是Clang前端,clang是Clang Driver。

接下來讓我們看Driver的第二個流程:Pipeline.

  • Pipeline的作用則是根據具體的編譯選項,構建不同的Compiler Action。

請大家注意我說的英文,Compiler Action。因為在Clang Driver處理中,是一個又一個Action。如上文所述,一個C++程序編譯會經歷兩個大階段:編譯 -> 鏈接。然而,讓我們把這個過程再次展開,就會有 預編譯 -> 編譯 -> 彙編 -> 鏈接,這是很多編譯器的流程。讓我們再次回到Clang,我們可以利用 -ccc-print-phases 選項列印Pipeline後的結果。

我們這裡可以看到熟悉的過程,如利用preprocessor進行預處理,然後利用compiler進行編譯等,後面的則是它們所產生的結果。如預處理後的值是cpp-output。注意這裡的cpp不是C++,是C語言預處理後的結果。如果是C++的話是C++-cpp-output。這在Clang代碼裡面也容易犯錯,而前者對應.i文件,後者對應.ii文件。在Clang的定義如下:

在Clang的Pipeline過程中,大部分Action都是對應實際發生的行為,如你所見的預編譯,編譯等,但是有兩個特別的Action:InputAction和BindArchAction,前者用於輔助實際發生Action,例如預編譯時你需要輸入文件進來,那麼InputAction就給你。而BindArchAction則是用於InputAction的綁定機器架構,如本例中則用於x86_64. 而這個BindArchAction由於可以把輸入的綁定機器架構,所以它一個常見應用是同時創建一個庫,既支持32位,也支持64位。我們可以做一個簡單的測試

可以發現,我們最後的產生文件是支持32位,也支持64位的。若是創建庫,亦同理。可能會有同學想知道是怎麼做到的,其實很簡單,就是編譯了鏈接了兩次,產生了兩個平台的。然後Mac下用lipo打包到一起了。

  • Bind的作用則在於Tool與Filename的選擇提供

在我們創建一系列Action進行的時候,Bind則是為我們提供具體的Tool來進行這一系列的實際運行,而在實際運行時則是一個一個的子進程。如我們有Assemble Action,那麼誰能做Assemble的工作呢?好,是Assembler。那麼選擇哪個Assembler呢?是自己內嵌的,是GNU的,還是什麼呢?那麼這就是Bind的作用。而負責選擇Tool工作的則是ToolChain,每一個架構、平台、操作系統都有一個ToolChain,而Bind則是與ToolChain打交道,ToolChain負責選擇具體的Tool來完成一系列Action。而Filename是怎麼回事呢?我們每一個Action都可能與另外一個Action打交道,甚至於一個Action是另外一個Action的輸入,那麼這時候作為輸入,是怎麼樣的形式呢?是進程間通信?管道?還是文件等等(記得上文曾說過運行實際的Tool是以子進程的方式,進程與進程間通信的方式在操作系統教材中也早已書寫過)。而若是文件的方式,最後是以怎麼樣的文件名?那麼則就是Filename的作用。

那麼,我們也可以使用-ccc-print-bindings來列印Bind後的結果。

首先,我們可以看到編譯選擇的是clang, 然後鏈接選擇的是darwin::Linker。然後是不是很吃驚彙編過程和彙編器去哪裡了?其實在Mac平台下,Clang使用了內置彙編器,integrated-as。在產生LLVM IR以後,調用了內置彙編器,然後直接生成.o,好處就是減掉了生成彙編文件和調用目標彙編器的開銷。怎麼讓這個彙編出來呢?那就是使用-fno-integrated-as,告訴clang不要使用內置彙編器。

在我們後面代碼分析中,我們也會發現有一個cc1as_main的東西,這跟彙編過程息息相關。

Clang的做法我是非常喜歡的,為每一個平台、操作系統、架構提供抽象的ToolChain。如Clang目前沒有支持AIX,然而在上一次我為Clang實現AIX的支持時,我需要提供AIX整個的工具鏈支持,那麼在這一塊,我只需要創建出來我自己的AIXToolChain,然後繼承於它的抽象基類,實現相關的函數(如assembler, linker等),那麼整個這一套我就做好了,所以Clang的這一設定具有非常好的擴展性。

  • Translate的作用則是處理工具的選項參數翻譯

這一塊兒其實則是把相關的參數對應到各個平台的工具上,這一個與Bind過程結合的很緊密。一個例子就是我們使用-shared選項創建動態庫,我們在Mac上是這樣的:

而在Ubuntu Linux下則是這樣:

而Translate則是負責這樣的轉換工作。

  • Execute的作用:執行整個編譯。

這個過程很直白,沒有什麼多說的,可能會有一些選項進行交互,如-ftime-report.

這篇文章也終於寫完了,寫了很久,也希望你們有所收穫。

推薦閱讀:

為什麼Apple的Clang生成的LLVM IR比開源的Clang生成的IR要讀者友好?
如何評價Clang with Microsoft CodeGen?
windows下如何使用clang來編譯c++14項目?

TAG:Clang | LLVM |