指南:從架構(gòu)拆解到源碼構(gòu)建與二次開發(fā))
1. llvm-project到底是什么——先解決這玩意值不值得搞的問題接觸編譯器的人早晚都會撞上llvm-project這個倉庫。我第一次真正去翻它的源碼不是出于興趣是被一個項目逼的要在自研芯片上跑一套定制的編譯工具鏈還得把上游的優(yōu)化能力吃下來。那時候打開github一看好家伙這個倉庫大到讓人頭皮發(fā)麻幾十個子項目堆在一起光clone就得等半天。先給沒接觸過的朋友說清楚。llvm-project是LLVM編譯器基礎(chǔ)設施的官方主倉庫GitHub上那個star數(shù)夸張的倉庫就是它。它不是某個具體的編譯器而是整套編譯相關(guān)工具鏈的集合核心構(gòu)件包括LLVM提供中端優(yōu)化和后端代碼生成、ClangC/C/Objective-C前端、LLD鏈接器、libcC標準庫實現(xiàn)、compiler-rt運行時庫、MLIR多層級中間表示框架、OpenMP運行時庫等等??梢哉f現(xiàn)代編譯領(lǐng)域的絕大多數(shù)技術(shù)突破要么直接在這個倉庫里落地要么就是從這里派生出去的。那這個項目能做什么最直接的答案是三件事第一你可以基于它構(gòu)建出一套完整的C/C工具鏈替代或者定制GCC的工作第二你可以在它的中端和后端之上做二次開發(fā)實現(xiàn)自己的優(yōu)化pass或者支持新的處理器架構(gòu)第三你可以用它的庫來構(gòu)建靜態(tài)分析工具、代碼格式化工具、調(diào)試器前端甚至是一門新語言的編譯器。適合誰看想深入理解現(xiàn)代編譯器工作原理的學生工作中要做工具鏈定制或交叉編譯的工程師以及對編程語言實現(xiàn)感興趣但不想從零寫代碼生成器的開發(fā)者這篇文章都能給你一個可以照著做的路徑。我準備從項目本身的構(gòu)成講起再拆核心架構(gòu)然后給你完整的源碼構(gòu)建流程最后把我在實際使用中踩過的坑和排查思路全部整理出來。這篇文章不是教你怎么裝一個Clang然后console.log一下hello world而是帶著你理解這個龐大項目內(nèi)部是怎么組織、怎么構(gòu)建、怎么為二次開發(fā)鋪路的。2. 核心架構(gòu)拆解三段式設計與Clang的定位2.1 LLVM的三段式架構(gòu)救了多少編譯器要理解llvm-project為什么能統(tǒng)治編譯器基礎(chǔ)設施這個領(lǐng)域必須先看它的三段式架構(gòu)設計。傳統(tǒng)編譯器如早期的GCC走的是直接把前端語法樹翻譯成后端指令的路線每支持一門新語言幾乎都要從頭寫一套包含優(yōu)化和代碼生成的完整編譯器。而LLVM把編譯過程拆成三個階段前端負責把源代碼翻譯成中間表示IR中端基于IR做平臺無關(guān)的優(yōu)化后端把優(yōu)化后的IR變成目標機器的匯編或機器碼。這個拆分的好處是邏輯解耦非常徹底。前端開發(fā)者只需要把語言翻譯成合格的LLVM IR立刻就能享受中端所有的優(yōu)化能力和后端所有支持平臺——x86、ARM、RISC-V、GPU等。后端開發(fā)者不需要關(guān)心語言層面的事情專注把IR映射到目標指令集。這帶來的生態(tài)效應是巨大的今天大模型領(lǐng)域的Triton、機器學習領(lǐng)域的TVM核心思路都受了這三段式的啟發(fā)所以搞懂LLVM的架構(gòu)對理解很多現(xiàn)代編譯類框架都有幫助。Clang就是LLVM的前端實現(xiàn)它的定位是把C/C/Objective-C代碼解析、語義分析后生成AST抽象語法樹再降級成LLVM IR。很多人一開始會把Clang和LLVM混為一談實際在llvm-project倉庫里Clang是頂層的一個子目錄項目和LLVM核心是平級的關(guān)系。Clang相比GCC有個很突出的優(yōu)點就是模塊化程度高、代碼清晰另一個更貼近用戶的特點是錯誤提示非常友好能直接指出出錯的具體位置和可能的修復建議這也是很多開發(fā)者一用上Clang就回不去的原因。2.2 中端優(yōu)化與LLVM IR的作用LLVM IR是整個基礎(chǔ)設施的靈魂它是一種靜態(tài)單賦值SSA形式的中間表示也就是說每個變量只被賦值一次。這聽起來有點繞但理解之后你就知道它為什么能支撐那么強大的優(yōu)化能力。SSA形式讓數(shù)據(jù)流分析變得非常直觀比方說一個變量的定義和使用關(guān)系在SSA下就是一目了然的死代碼消除、常量傳播這類優(yōu)化做起來效率極高。IR有三種存儲形態(tài)內(nèi)存中的表示、可讀的文本形式.ll文件、二進制位碼形式.bc文件。這三者可以互相轉(zhuǎn)換而且轉(zhuǎn)換是無損的。這對調(diào)試和測試來說實在太重要了你可以把你的源碼先編譯成.ll文本文件打開一眼看到中間表示長什么樣找到它是否符合預期也可以寫一個優(yōu)化pass然后跑在文本IR上驗證效果全程不用碰機器碼。中端的優(yōu)化Pipeline里跑的是數(shù)百個Pass像EarlyCSE、InlineFunction、GVN、LoopUnroll、SLP向量化等這些Pass從IR入手做各種等價變換目標是讓程序跑得更快、體積更小、功耗更低。llvm-project里大量代碼其實都集中在這層如果你想搞編譯器優(yōu)化方向的研究中端就是你最常待的地方。2.3 后端的指令選擇與寄存器分配IR經(jīng)過中端優(yōu)化后送到后端。這里要經(jīng)歷的環(huán)節(jié)包括指令選擇Instruction Selection、指令調(diào)度Scheduling、寄存器分配Register Allocation、指令布局Layout、編碼Emit等。傳統(tǒng)后端使用SelectionDAG來做指令選擇把IR轉(zhuǎn)成一個依賴圖再通過模式匹配來映射目標指令。近些年LLVM發(fā)展了GlobalISel這個新的指令選擇框架在處理復雜指令和異構(gòu)后端的時候更靈活尤其是對GPU和DSP這類非規(guī)則架構(gòu)GlobalISel在逐步取代老方案。寄存器分配是后端最經(jīng)典的難題之一。寄存器數(shù)量有限程序里的值卻很多LLVM默認使用貪心寄存器分配器Greedy Register Allocator它會為每個虛擬寄存器嘗試找到一個物理寄存器在寄存器不夠的時候觸發(fā)溢寫spill到內(nèi)存。這里面的啟發(fā)式算法直接決定了生成代碼的質(zhì)量差之毫厘就可能導致性能掉幾個百分點。如果你將來要做一個新的CPU后端通常的做法就是在llvm-project中新建一個后端目錄實現(xiàn)TargetMachine、TargetLowering、指令選擇、寄存器信息等接口。這個路徑已經(jīng)被無數(shù)項目驗證過了RISC-V后端就是社區(qū)靠著這套機制在幾年內(nèi)快速成熟的。llvm-project的后端框架之所以值錢在于它提供了一個高度可擴展的模版你不需要從零開始造輪子只需要填好目標平臺相關(guān)的關(guān)鍵信息。3. 實操從源碼構(gòu)建llvm-project的正確姿勢3.1 構(gòu)建前的準備磁盤、內(nèi)存、依賴我在第一次全量構(gòu)建llvm-project時就犯過一個很基本的錯誤直接clone了master的分支然后cmake默認配置開始構(gòu)建結(jié)果編到一半磁盤滿了。llvm-project的歷史版本加上構(gòu)建產(chǎn)物非常吃磁盤空間這個事必須提前重視。首先倉庫本體大小就很可觀git clone的默認淺克隆深度是1這樣能省不少時間。但如果你要做開發(fā)、要切分支、要看歷史提交還是建議完整克隆。我通常的做法是先淺克隆一份需要歷史的時候再按需拉取。這不算偷懶而是節(jié)省時間的實用策略。其次依賴方面最基礎(chǔ)的有CMake建議3.20以上版本、Ninja一個比make更快更方便的構(gòu)建系統(tǒng)、C編譯器GCC、Clang均可要求支持C17標準。另外還需要zlib、libxml2等一些庫因為llvm-project里有些工具會依賴它們。Python也是必需的多個腳本和組件會在構(gòu)建時調(diào)用。在我用的Ubuntu環(huán)境上這一條命令就能裝完大部分依賴sudo apt update sudo apt install -y build-essential cmake ninja-build python3 python3-pip git zlib1g-dev libxml2-dev磁盤空間這一塊完整構(gòu)建所有子項目并且開啟調(diào)試信息幾百GB都可能打不住但如果你只構(gòu)建LLVM和Clang并且關(guān)閉調(diào)試符號50GB以內(nèi)基本夠用。我建議磁盤預留至少80GB避免構(gòu)建到一半才發(fā)現(xiàn)空間不夠的尷尬。內(nèi)存方面全量構(gòu)建LLVM和Clang是比較吃內(nèi)存的尤其是鏈接階段的幾個大的可執(zhí)行文件比如clang和lld鏈接時可能吃掉幾個GB的內(nèi)存。如果機器內(nèi)存只有8GB建議限制并行度不然容易OOM具體后面會說。3.2 CMake配置詳解關(guān)鍵參數(shù)llvm-project的構(gòu)建在大部分場景下走的是CMake這一個入口。在源碼根目錄下建立一個build目錄然后在這個目錄里執(zhí)行cmake命令來生成構(gòu)建文件這是標準的做法防止構(gòu)建產(chǎn)物污染源碼樹。我把非常常用的一套配置給你這幾乎是我每次給自有項目做定制工具鏈時的起始模板cmake -G Ninja ../llvm-project/llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;libcxx;libcxxabi \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_CCACHE_BUILDON逐個解釋這些參數(shù)的意義-G Ninja指定使用Ninja生成構(gòu)建文件。Ninja并發(fā)效率比make高得多尤其在大型C項目上這是一個不用猶豫的選擇。-DCMAKE_BUILD_TYPERelease編譯類型設為Release會啟用-O2優(yōu)化Debug模式生成的編譯器適合斷點調(diào)試但運行速度慢得多且產(chǎn)物體積大幾倍。如果只是日常使用Release就夠了。-DLLVM_ENABLE_PROJECTS這個參數(shù)決定要構(gòu)建哪些LLVM外圍項目。Clang肯定要的lld鏈接器速度飛快值得擁有l(wèi)ibcxx和libcxxabi是C標準庫的實現(xiàn)想測試新標準特性的時候很有用。需要說明的是這里的項目名用分號分隔。-DLLVM_TARGETS_TO_BUILD控制編譯器支持哪些目標架構(gòu)。默認會構(gòu)建所有平臺的后端費時費力實際上大多數(shù)人用不到那些嵌入式或小眾平臺。我平時只保留X86、AArch64和RISCV夠用且省時間。-DLLVM_ENABLE_ASSERTIONSON開啟斷言。對普通使用者這個可以關(guān)掉因為Release下開啟斷言不會提升編譯產(chǎn)物質(zhì)量但如果你在做編譯器開發(fā)強烈建議打開很多內(nèi)部不變量靠斷言來兜底。-DLLVM_CCACHE_BUILDON開啟ccache緩存。增量開發(fā)時有了ccache可以省掉大量重復編譯時間尤其是當你切換分支或改了一點頭文件導致大范圍重編的時候ccache的效果立竿見影。還有一個常見參數(shù)是-DLLVM_PARALLEL_LINK_JOBS用它來控制鏈接任務的并行數(shù)。在內(nèi)存有限的情況下把它設成1或2可以避免多個大鏈接任務同時搶內(nèi)存。我有一次在16GB內(nèi)存的機器上構(gòu)建同時跑了8個鏈接作業(yè)直接物理內(nèi)存爆掉機器卡死。3.3 構(gòu)建與測試驗證配置完成后執(zhí)行構(gòu)建ninja -j8數(shù)字8是并行度具體的值參考CPU核心數(shù)但不建議超過內(nèi)存能承受的上限。構(gòu)建過程會輸出大量編譯日志初次構(gòu)建需要等待較長時間在常見配置下全量構(gòu)建LLVM和Clang在8核機器上大概需要20到30分鐘都是在可接受范圍內(nèi)的。構(gòu)建完成后二進制在build/bin目錄下可以驗證一下./build/bin/clang --version如果想跑一遍LLVM的測試套件用以下命令ninja check-llvm check-clang這個會執(zhí)行大規(guī)模的單元測試和回歸測試。我第一次跑的時候發(fā)現(xiàn)有幾個測試失敗排查后確認是環(huán)境問題導致編譯器斷言被觸發(fā)后來調(diào)整了配置就正常了。測試套件對編譯器開發(fā)者來說是一道防線提交代碼之前至少要把相關(guān)的check跑綠不然很容易把上游已有的功能改壞。構(gòu)建一個小的測試程序驗證生成的編譯器可以正常使用cat hello.c EOF #include stdio.h int main() { printf(hello llvm\n); return 0; } EOF ./build/bin/clang hello.c -o hello ./hello這里有個細節(jié)需要注意新建的Clang默認可能找不到系統(tǒng)頭文件尤其在某些精簡的Linux發(fā)行版上。一個快速的解決方法是安裝build-essential這類包含完整頭文件和庫的開發(fā)包或者讓Clang自動探測GCC的安裝路徑來借用它的頭文件和庫配置。4. 構(gòu)建過程常見問題與排查實錄4.1 內(nèi)存不足與OOM問題這是llvm-project新手最容易碰到的坑。構(gòu)建過程中報c: internal compiler error: Killed (program cc1plus)很多時候就是內(nèi)存不足系統(tǒng)把編譯進程給殺了。說到底是因為編譯大型C文件很吃內(nèi)存多個編譯任務并行時內(nèi)存使用成倍增長。我給的排查思路是先確認當前系統(tǒng)的實際內(nèi)存和swap大小可以用free -h查看。如果內(nèi)存緊張把并行度降下來比如從-j8降到-j4甚至-j2。另外建議把-DLLVM_PARALLEL_LINK_JOBS1顯式加上。還有一個容易被忽略的技巧是使用-DCMAKE_BUILD_TYPERelease而不是DebugRelease下的優(yōu)化雖然耗編譯時間但運行期的內(nèi)存占用反而小Debug模式下的代碼優(yōu)化較少編譯時內(nèi)存壓力更大在某些弱雞機器上確實會明顯吃力。如果實在內(nèi)存太小另一個思路是只構(gòu)建LLVM核心不加Clang。CLang的編譯目標比較大單獨構(gòu)建LLVM庫和工具鏈的話資源開銷會低很多。4.2 頭文件與Python依賴導致失敗構(gòu)建或者運行時偶爾會遇到找不到頭文件的報錯比如fatal error: stdio.h file not found。這種情況通常說明系統(tǒng)的頭文件路徑?jīng)]有被Clang正確識別。在Linux上最省事的方法是安裝GCC的開發(fā)包Clang會檢測到GCC的安裝位置然后借用它的libstdc頭文件和系統(tǒng)C庫頭文件。有一個萬能驗證命令echo | ./build/bin/clang -E -v -x c -它會輸出Clang實際搜索的所有頭文件路徑排查起來非常直觀。如果發(fā)現(xiàn)路徑里缺少了關(guān)鍵的系統(tǒng)頭文件目錄可以通過-DCMAKE_C_COMPILER和-DCMAKE_CXX_COMPILER指定系統(tǒng)已有的編譯器來重新配置或者在編譯時手動加-isystem參數(shù)指向正確的頭文件目錄。Python依賴引發(fā)的構(gòu)建失敗同樣常見比如找不到Python3Development相關(guān)組件或者某個腳本執(zhí)行時報模塊缺失。解決方法是確保系統(tǒng)安裝了python3-dev包并且CMake能夠找到它。如果你用conda環(huán)境有可能CMake優(yōu)先找到了conda里的Python但那個環(huán)境的庫不完整這時可以顯式指定-DPython3_EXECUTABLE$(which python3)。4.3 鏈接階段卡住或失敗鏈接失敗是另一個高頻問題。報錯類型多種多樣比如/usr/bin/ld: cannot find -lz說明缺少zlib庫undefined reference to xxx說明相關(guān)的庫沒有鏈接進來或者某些功能被裁剪掉了。解決這類問題的思路分幾步。第一步確認系統(tǒng)庫已經(jīng)安裝sudo apt install zlib1g-dev libxml2-dev這類命令按需補齊。第二步檢查CMake配置里是否有裁剪選項比如你只開啟了Clang但構(gòu)建的某個工具依賴libxml2那么缺失庫就會暴露出來。第三步看具體是哪個鏈接命令失敗把CMake生成的build.ninja或build/CMakeCache.txt里相關(guān)變量翻出來比如CMAKE_EXE_LINKER_FLAGS手動補充庫路徑或庫名。鏈接卡住看起來沒報錯但一直不動通常是內(nèi)存不夠或者IO瓶頸。我遇到過一次是在機械硬盤上鏈接一個巨大的可執(zhí)行文件等了很多分鐘都沒完換到SSD之后速度快了好幾倍這不是配置問題而是存儲設備的差距。4.4 新舊Pass管理器帶來的API差異這個問題主要影響做二次開發(fā)的人。LLVM從某個版本開始默認使用New Pass Manager新Pass管理器但如果你的項目還基于舊的Legacy Pass Manager的API來寫Pass編譯時可能報一堆兼容性錯誤。這個無論是在構(gòu)建llvm-project本體還是在你自己的分析工具里都容易遇到。最簡單的排查辦法是看LLVM版本然后查對應版本的官方遷移文檔。寫新代碼建議直接基于New PM接口因為上游的優(yōu)化Pipeline已經(jīng)全面轉(zhuǎn)向新框架老接口未來盡可能縮減。還要注意一點llvm-project的版本迭代很快main分支和release分支的API差異很大做開發(fā)一定要鎖定一個版本分支不要讓項目追蹤main分支否則今天能編過下周可能就編不過了。我習慣把項目依賴的LLVM版本用git submodule固定下來這樣團隊協(xié)作時大家構(gòu)建的環(huán)境一致排查問題也不會因為版本漂移而混亂。5. llvm-project能拿來做什么應用場景與二次開發(fā)方向5.1 自制語言與編譯器的構(gòu)建塊如果你對編程語言實現(xiàn)感興趣但又不想從零寫整個編譯器生態(tài)llvm-project這一套可以讓你繞開大量重復造輪子的工作。新語言的前端可以生成LLVM IR之后就全部交給既有優(yōu)化和后端。我之前看過一個朋友做的小型靜態(tài)類型語言前端解析和類型檢查花了幾個月后端幾乎沒碰——直接生成IR、調(diào)用LLVM的JIT引擎就拿到了解釋執(zhí)行和即時編譯能力。LLVM庫提供了一套完整的JIT API比如ORC JIT可以實現(xiàn)運行時把IR編譯成機器碼這對動態(tài)語言、腳本引擎、甚至SQL執(zhí)行引擎的加速都有很大價值。5.2 靜態(tài)分析與代碼質(zhì)量工具llvm-project擁有豐富的靜態(tài)分析基礎(chǔ)設施。Clang Static Analyzer做的是路徑敏感分析能發(fā)現(xiàn)空指針解引用、資源泄漏這類深層次問題Clang-Tidy則是一堆基于語法樹的快速檢查規(guī)則適合做風格和局部正確性的檢查。這些工具的底層都借助了LibTooling和Clang AST Matcher這些庫。如果你要給自己的項目定制一套代碼規(guī)范檢查規(guī)則可以基于Clang-Tidy框架寫一個自定義 checker不需要維護一個完整的編譯器就能做解析和模式匹配。我在實際工作中用到的一個場景是給一個大型C代碼庫批量查找某種不安全的API使用模式。用AST Matcher寫了一個幾十行的工具幾秒鐘掃完整個倉庫效率遠高于人工code review。靜態(tài)分析這個方向llvm-project給的起點已經(jīng)很高關(guān)鍵是你愿不愿意學它的接口。5.3 MLIR與編譯器基礎(chǔ)設施未來MLIRMulti-Level Intermediate Representation是llvm-project里近幾年發(fā)展最猛烈的項目之一。早期它主要服務于機器學習編譯器的多層抽象需求后來大家發(fā)現(xiàn)這個框架對任何需要多層IR變換的領(lǐng)域都很有價值比如HPC、FPGA綜合、領(lǐng)域特定語言DSL等。MLIR解決的是「一層IR不夠用」的問題既有接近源碼的高層抽象方便做領(lǐng)域相關(guān)的優(yōu)化又有接近硬件的低層抽象方便做指令映射和調(diào)度。它可以讓你定義自己的Dialect方言再寫Dialect之間的轉(zhuǎn)換Pass。今天很多火爆的AI編譯框架底層都有MLIR的影子。如果你要吃編譯器這碗飯llvm-project中的MLIR子項目絕對值得投入時間。它不是另一個跟LLVM孤立的技術(shù)而是整個基礎(chǔ)設施向更廣泛場景演進的體現(xiàn)。5.4 工具鏈集成與環(huán)境落地許多企業(yè)級項目并不需要從源碼構(gòu)建LLVM而是直接把llvm-project的發(fā)布版本集成進自己的工具鏈里。比如在CI流水線里使用Clang-Tidy做代碼檢查、用Clang-Format統(tǒng)一代碼風格、用LLD加速鏈接過程。這里用到的產(chǎn)品和llvm-project同源只是使用方式不同。如果你需要定制比如要加一個自定義的優(yōu)化Pass、給某款特殊芯片生成專屬指令序列就沒有辦法回避源碼構(gòu)建和二次開發(fā)。最典型的路徑是拉取llvm-project源碼在llvm/lib/Transforms下新建一個Pass目錄注冊進Pass Pipeline編譯后用opt工具加載并運行。這套流程雖然初看門檻不低但把基礎(chǔ)打通之后后續(xù)基本上都是復現(xiàn)和迭代的問題。我個人在實際操作中的體會是llvm-project最大的學習價值不是某一條命令或者某一個工具而是它展示了「編譯器」這種極度復雜的系統(tǒng)如何通過合理的模塊劃分、嚴格的接口約定、可持續(xù)的測試體系變成一個成千上萬人協(xié)作卻依然保持高效迭代的項目。最后再分享一個小技巧開工之前先看llvm-project的官方文檔和郵件列表存檔很多你準備花一周摸索的問題其實社區(qū)早就討論過并給出了結(jié)論。還有為你的開發(fā)環(huán)境安裝一套版本管理工具比如ccache再加distcc大型編譯任務能舒服不少。