語涵編譯器面向程序員的介紹——管線,基礎IR,以及默認管線中的各個抽象
1. 簡介
本文從講解設計理念和源代碼的角度介紹語涵編譯器。語涵編譯器的本體是一個 Python 程序(Python >= 3.10)。本文需要讀者:
熟悉計算機編程,用過?Python?編程語言,最好還能了解?C/C++ 編程語言
如果能了解 Python 的類型標注(Type annotation)那就更好了,源代碼里到處都是。。不過沒見過也沒關系。
對視覺小說引擎的功能有所了解
(可選)了解 RenPy 引擎的語法
(可選)對 Clang/LLVM 編譯器架構(gòu)有所了解
閱讀完本文后,讀者應該可以:
能夠獨立閱讀語涵編譯器代碼庫
掌握編寫語涵編譯器插件的能力
源代碼在 https://github.com/PrepPipe/preppipe-python, 以下所有的樣例都以?6c99407512caf6731a570434a2aecadcfd8ac10b 提交(版本號 0.0.1.post119)為基準。下面所有的 Python 腳本的位置在代碼倉庫里都有 src/preppipe/ 前綴(除非特殊說明),比如下面的 irdataop.py 就是在?src/preppipe/irdataop.py, 在 import 時可以寫 "import preppipe.irdataop" 或者其他變體來使用里面定義的內(nèi)容。
這里第 1 節(jié)是對本文的大體介紹,下面的第 2 節(jié)講一些可以幫助理解的背景知識。第 3 節(jié)介紹語涵編譯器的管線,大致介紹語涵編譯器的各個模塊是如何組合在一起的。第 4 節(jié)介紹語涵編譯器的基礎 IR ,這是后面所有數(shù)據(jù)結(jié)構(gòu)的支撐。第 5 節(jié)介紹語涵編譯器的各個數(shù)據(jù) IR 以及它們?nèi)绾问沟谜Z涵編譯器能夠處理視覺小說劇本。
本來想做視頻的,請原諒筆者偷懶。。。文本實在是太長了。。。

2. 背景
2.1 Python 修飾符(Decorator)
Python 的 Decorator (修飾符)是一個方便在函數(shù)、類(class)剛被定義時對其進行處理的語言功能,語涵編譯器所有的“注冊”功能和對部分類的拓展(比如給它們加成員函數(shù))都是通過修飾符實現(xiàn)的。
代碼1:(把類型標注去掉后的)irdataop.py 中的修飾符定義
以?IROperationDataclass /?IROperationDataclassWithValue 為例,這里我們有個函數(shù)叫 _process_class(cls, ...),?這個參數(shù)對被傳入的類?cls 加一些成員(后面會細講)并在完成后返回傳入的cls,我們想要在“要被改造的類”剛剛定義時就調(diào)用這個函數(shù)來修改該類。這時候我們就可以用上面“代碼1”中的例子來定義兩個修飾符,沒有參數(shù)的話用下面的?IROperationDataclass, 有參數(shù)的話用上面的?IROperationDataclassWithValue,前面加 '@' 之后放在類的定義前,比如下面的代碼2和代碼3:
代碼2:renpy/ast.py 中使用不帶參數(shù)的修飾符(IROperationDataclass)的例子
代碼3:vnmodel.py 中使用帶參數(shù)的修飾符(IROperationDataclassWithValue)的例子
有了這些修飾符之后,當 Python?解釋器讀取完所標注的類之后, _process_class() 會立即被調(diào)用。如果像代碼3中那樣有多個修飾符,那么最下面的、最靠近類型定義的先被執(zhí)行,比如這里的 IRObjectJsonTypeName 會先執(zhí)行,IROperationDataclassWithValue 會在之后執(zhí)行。
2.2 Clang/LLVM 編譯器管線
LLVM 是一個開源的編譯器框架,其他人可以使用 LLVM 開發(fā)不同語言的編譯器。Clang 是一個基于 LLVM 的 C/C++ 編譯器。語涵編譯器的管線設計大量參考了?Clang/LLVM 的設計。
在 Clang/LLVM 的管線里,如果要編譯一個 C 程序到 x86-64 (正常人的PC平臺),流水線基本上是這樣的:(我們以做手辦為比方,可能不太恰當,因為筆者沒有做過手辦。。)
Clang 使用C預處理器將預處理器命令處理完畢,生成完整的編譯單元(Compilation unit)的源代碼。編譯單元=C/C++源文件+所有#include 的頭文件。預處理結(jié)束后,編譯單元內(nèi)(1)包含了所有需要編譯器編譯的內(nèi)容(包括頭文件里的),(2)所有不需要編譯器編譯的內(nèi)容都會被去掉(比如用 #if...#endif 包裹的、不啟用的內(nèi)容)。之后所有的編譯都以編譯單元為整體。打個比方的話,這是甲方(程序員)眼中的“手辦該是什么樣子”的需求說明。
Clang 將其解析為 AST(語法樹),這是編譯器視角的“這個編譯單元里有什么”,并且已經(jīng)轉(zhuǎn)換為一個樹狀的結(jié)構(gòu),方便代碼處理。打個比方,這是乙方(編譯器)眼中的手辦什么樣的說明。
Clang 使用代碼生成(CodeGen) 將AST生成為 LLVM IR (Intermediate Representation, 一般簡稱 IR)。這個 IR 是一個抽象(不局限于 x86-64)的程序?qū)崿F(xiàn),打比方就像是一個手辦的3D建模。各種手辦制作方法可以用這個建模來做手辦,但大概率還不能直接用。
LLVM 對 IR 進行分析、優(yōu)化,結(jié)果還是以 LLVM IR 的形式保存。打比方就像是調(diào)整、細化建模。
LLVM 使用 x86-64 后端進行代碼生成,將 LLVM IR 生成為 x86-64 的 MIR (Machine IR,機器相關的 IR)(代碼9),打個比方就像是適用于某個3D打印機的手辦設計文件。
LLVM 繼續(xù)代碼生成,寫出 x86-64 匯編或可執(zhí)行代碼(代碼10)。到這才算把手辦做出來了。
這樣的流水線可以實現(xiàn):
便于拓展,新的功能(主要是編譯器優(yōu)化)可以用這些中間形態(tài)作輸入輸出的接口,與其余的代碼無縫對接。新的后端也可以復用已有的前端和優(yōu)化代碼。打個比方,換個新的手辦制作廠家的話,不用從需求文檔開始重新做,只要重新做建模之后的步驟就行了。
便于調(diào)試,每個階段都可以輸出為文本信息,方便排查問題。打個比方,如果手辦有問題,到成品階段再發(fā)現(xiàn)、改正問題就會消耗不必要的人力物力。這種情況下還可能不知道問題出在哪,從頭開始詳細追蹤的話更加費時費力。如果在手辦制作中的各個階段及時找到問題的話,就可以及時找到問題,減少不必要的麻煩。
附錄1中有一個通過一個 C 程序 Hello World 的樣例來介紹各個階段的樣例,有興趣的讀者可以閱讀。注:該樣例并不需要讀者理解,沒有C背景的讀者完全可以跳過。
2.3 MLIR
MLIR?是將?LLVM?IR?的設計思路拓展、使其能夠(1)服務于編譯器管線中更多階段(比如AST階段),(2)更好地服務于像GPU、專用硬件這類加速器的編譯工作。最主要的是,MLIR將IR的“形”(存儲、表示方法)給固定下來、使其通用,各個“IR”(包括以前不被認為是IR的AST等中間形態(tài))的“神”(即內(nèi)容的含義)還是交給各個階段自行定義。這樣更加方便拓展新功能,不同的編譯器管線階段能夠復用的代碼更多。
語涵編譯器的使用的基礎?IR?是基于?MLIR 的魔改版,下文會詳細介紹魔改后的基礎?IR。

3. 語涵編譯器的管線
3.1 管線總覽
為了能讓讀者了解管線的整體情況,我們從圖形界面的調(diào)用方式開始:

圖1:語涵編譯器啟動器(圖形界面)準備好選項之后的截圖
在輸入?yún)?shù)準備好之后,執(zhí)行區(qū)下方畫紅框是所有要傳給主程序的參數(shù)。這里的 preppipe_cli<版本號>.exe 是用 Pyinstaller 打包后生成的可執(zhí)行文件,用來打包的入口是這個:
代碼4:打包的入口腳本 preppipe_cli.py (在代碼倉庫根目錄下,不屬于 preppipe package)
注:雖然主函數(shù)在 preppipe.pipeline.pipeline_main(),但是這里必須有 import preppipe.pipeline_cmd,如果用 python -m 調(diào)用的話也請使用 python -m preppipe.pipeline_cmd,原因稍后解釋。
這里的主函數(shù)調(diào)用了 _PipelineManager.pipeline_main(),這個函數(shù)基本做了以下的事:
讀取插件(從環(huán)境變量 PREPPIPE_PLUGINS 所指向的目錄里讀取,如果這個環(huán)境變量有的話)。所有的插件都會接在 "preppipe.plugin.*" 模塊路徑下。
創(chuàng)建命令行解析器 (Python 的 argparse.ArgumentParser)
創(chuàng)建一個 Context 對象(4.1里會細講,一個給其余代碼提供支持的類)并對其初始化。
使用命令行把要執(zhí)行的步驟都列出來(即把管線搭建出來)。這一步同時會對命令行輸入做一個基本的檢查。
執(zhí)行管線的各個步驟。
比如圖1樣例中的命令:
執(zhí)行時我們可以看到以下輸出:
這些就是上面第4步的輸出,列舉了搭建出來的管線的細節(jié)。后面的輸出就是第5步(執(zhí)行)的內(nèi)容了。
在講其他內(nèi)容之前,我們還需了解一下語涵編譯器的基礎 IR。語涵編譯器給管線里流動的所有數(shù)據(jù)結(jié)構(gòu)使用統(tǒng)一的表示法(類似JSON 那樣的泛用的數(shù)據(jù)結(jié)構(gòu)),在這種表示法下,數(shù)據(jù)也是像 JSON 那樣有嵌套結(jié)構(gòu),JSON是【對象和數(shù)組】套【對象和數(shù)組】,基礎 IR 使用的表示法是"操作項"(Operation)套"區(qū)"(Region),區(qū)套“塊”(Block),塊套操作項。JSON頂層可以是數(shù)組可以是對象,基礎 IR 的頂層一定是操作項。使用基礎 IR 時,不同的數(shù)據(jù)會創(chuàng)建新的類并繼承操作項(Operation),所以在語涵編譯器管線里的數(shù)據(jù)基本都是 Operation 的子類。當我們描述管線里的數(shù)據(jù)類型時,我們使用最外層(沒嵌套的)Operation 的子類來指代該類型。關于操作項的細節(jié)會在下面的 4.3 節(jié)敘述。(語涵編譯器使用的基礎 IR?是從 MLIR 魔改而來的。)
接下來我們講 TransformBase 類,這是構(gòu)成管線的基礎。3.2 節(jié)大致描述 TransformBase 類和它在管線中的作用,3.3 節(jié)詳細講述代碼。
3.2 TransformBase 類初探
語涵編譯器中所有對數(shù)據(jù)的處理都使用 TransformBase?基類(Base class)的接口接入管線。一個 Transform (轉(zhuǎn)換、變換) 有點像數(shù)學概念上的傳遞函數(shù)(Transfer function),或者像是其他編譯器中的 Pass (不過編譯器中的 Pass 一般只指代輸入輸出都是IR 的步驟)。語涵編譯器使用這個泛化的“轉(zhuǎn)換”概念將輸入、輸出、(狹義的)轉(zhuǎn)換的接口都統(tǒng)一了。比如 3.1 樣例中,第一行那里 ReadOpenDocument (那種格式的名字就叫"OpenDocument") 是 TransformBase 的子類,后面所有管線步驟后面的除了輸入的選項(flag)外,后面的就是?TransformBase 子類的名字。
轉(zhuǎn)換有三類:(我們使用 IR 來指代在管線里流動的數(shù)據(jù))
前端轉(zhuǎn)換(Frontend): 該轉(zhuǎn)換不讀取當前管線里的 IR,執(zhí)行結(jié)束后會生成 IR。上面例子中的步驟1(讀取 odt 文件)就是前端轉(zhuǎn)換。這類轉(zhuǎn)換可以讀取外部文件也可以不讀??;憑空生成 IR 常用于調(diào)試。
中端轉(zhuǎn)換(MiddleEnd):該轉(zhuǎn)換讀取當前管線里的 IR,同時也會生成 IR。上面例子中的步驟2-8 都屬于中端轉(zhuǎn)換。這些可以說是傳統(tǒng)編譯器概念上的 Pass。
后端轉(zhuǎn)換(Backend): 該轉(zhuǎn)換不覆蓋當前管線里的 IR。上面例子中的步驟 9 屬于后端轉(zhuǎn)換。這類轉(zhuǎn)換可以導出文件也可以不導出;不導出的常用于調(diào)試。
我們用下面這張圖為例(一個假想的管線)說明管線支持的轉(zhuǎn)換間的組合方式:

圖2中所有的方框(A-G)都是轉(zhuǎn)換步驟(TransformBase 子類實例),所有的圓形連接(1-5)都是 IR 類型(Operation 子類),所有的文件圖標(沒標號)代表輸入輸出的文件。左邊是紅色的轉(zhuǎn)換和文件是輸入部分,綠色的轉(zhuǎn)換步驟是中端轉(zhuǎn)換,綠色的圓形連接是在管線內(nèi)流動的 IR,右邊藍色的轉(zhuǎn)換步驟是后端轉(zhuǎn)換。該樣例管線是按A-G的順序執(zhí)行的。對照以上圖示:
前端轉(zhuǎn)換(Frontend)不讀取當前管線里的IR,除此之外無關乎執(zhí)行順序,它們可以在任意階段出現(xiàn)。前端轉(zhuǎn)換C在前端轉(zhuǎn)換B之后執(zhí)行,它生成的IR (3號)會在下一個轉(zhuǎn)換開始執(zhí)行前與轉(zhuǎn)換B之后的轉(zhuǎn)換組合在一起。管線要求這里 2號 IR 和 3 號 IR 的類型一致,否則會在構(gòu)建管線時報錯。
后端轉(zhuǎn)換(Backend)不覆蓋當前管線里的IR,除此之外無關乎執(zhí)行順序。這里的后端轉(zhuǎn)換E在中端轉(zhuǎn)換F前執(zhí)行,其結(jié)果不影響后面執(zhí)行的 F 和 G。語涵編譯器里的“生成分析報告”、調(diào)試信息等都是以后端轉(zhuǎn)換 E 這種形式實現(xiàn)的。
除了管線會幫助前端獲取輸入?yún)?shù)、幫助后端獲取輸出參數(shù)之外,管線并不管轉(zhuǎn)換是否會在執(zhí)行過程中讀取或輸出其他文件。轉(zhuǎn)換可以再新加命令行參數(shù)來輔助輸入輸出。圖上的黑色虛線箭頭和文件標志代表這種額外做的輸入輸出。注意:如果要讀取的內(nèi)容是通過輸入的數(shù)據(jù)指定的,請考慮使用 FileAccessAuditor (4.2節(jié))。
3.3 TransformBase 的實現(xiàn)和命令讀取
TransformBase 基類長這樣:
代碼5:TransformBase 基類 (pipeline.py)
install_arguments() 和 handle_arguments() 我們稍后再講。ctx 是管線里使用的 Context 對象的引用,下面的 4.1 節(jié)詳細描述。_inputs 和 _output 是輸入和輸出參數(shù)的地方,一般使用不加前綴下劃線的名稱來使用它們。這里的?run() 成員函數(shù)是一定要子類提供的,轉(zhuǎn)換在執(zhí)行時就調(diào)用這個函數(shù)。最簡單的轉(zhuǎn)換可能只需要提供一個 run():
代碼6:"--vn-entryinference" 調(diào)用的轉(zhuǎn)換的類型定義。(transform/vnmodel/vnentryinference.py)
這段代碼里的 VNEntryInferencePass 就是 TransformBase 的子類,用來將同文件內(nèi)?vn_entry_inference()?提供的功能接入管線。這個函數(shù)輸入、輸出的頂層操作項(Operation)都是 VNModel (對標 LLVM IR 的抽象的演出內(nèi)容, 5.2 節(jié)會細講),所以 self.inputs 里面有個 VNModel,輸出時要在 run() 結(jié)束時返回一個?VNModel。由于該函數(shù)是在輸入上原地進行轉(zhuǎn)換(換句話說就是不新建一個 VNModel),返回時就直接 "return self.inputs[0]"。
定義轉(zhuǎn)換的子類之后需要使用修飾符注冊這個轉(zhuǎn)換。目前一共有三個修飾符,都在 pipeline.py 中定義:
代碼7:用于注冊轉(zhuǎn)換的修飾符 (pipeline.py)
其中 @MiddleEndDecl 用于注冊中端轉(zhuǎn)換,所以代碼6中的中端轉(zhuǎn)換使用這個修飾符進行注冊。三個修飾符都有 flag (命令行上使用的選項), input_decl (輸入應該是什么),output_decl (輸出應該是什么)三個參數(shù),pipeline.py 中使用這些信息來在命令行啟用這些轉(zhuǎn)換。對于中端轉(zhuǎn)換來說,輸入輸出都是 IR 類型,所以 input_decl 和 output_decl 都使用類型對象(比如上面代碼 6 中的VNModel)作為參數(shù)。如果是前端或后端轉(zhuǎn)換,輸入輸出項使用一個 IODecl 的實例進行描述,比如:
代碼8:讀取 ODT 文檔的轉(zhuǎn)換的注冊部分?(frontend/opendocument.py)
代碼8是讀取 odt 文檔的輸入轉(zhuǎn)換的注冊部分。生成的?IR 的頂層是 IMDocumentOp,5.1節(jié)會講。
如果除了輸入、輸出路徑之外,轉(zhuǎn)換有其他參數(shù)需要指定,那么該轉(zhuǎn)換需要使用另一個修飾符 @TransformArgumentGroup 來創(chuàng)建一個命令組(Python 的 argparse._ArgumentGroup)
代碼9:RenPy 最后導出工程目錄的后端轉(zhuǎn)換 (renpy/passes.py)
@TransformArgumentGroup 必須加在前/中/后端聲明的外面(要比它們晚執(zhí)行)。兩個參數(shù),一個是命令組的標題,一個是描述,在這之后就可以通過覆蓋 TransformBase 里的?install_arguments() 和 handle_arguments() (見代碼5)來注冊、讀取額外的命令行參數(shù)。
現(xiàn)在我們可以解釋為什么 pipeline.py 定義了 pipeline_main() 但是 python -m 一定要從 pipeline_cmd.py 啟動了。pipeline.py 定義了這里所有的基礎類型,所以所有要注冊轉(zhuǎn)換(TransformBase)的模塊都會引用(import) pipeline.py, 這也使得 pipeline.py 不能引用這些模塊。我們在執(zhí)行時,一定需要 Python 解釋器讀取過所有這些有轉(zhuǎn)換的模塊,這樣才能執(zhí)行注冊這些轉(zhuǎn)換的代碼,使它們能夠成功執(zhí)行。 pipeline_cmd.py 就是這樣一個 import 了語涵編譯器代碼倉庫里所有帶有轉(zhuǎn)換的模塊的這么個“樞紐”,import 了這個模塊(或者從這個模塊開始執(zhí)行)的話就能保證代碼倉庫里的轉(zhuǎn)換都已經(jīng)注冊。
3.4 一個簡單的插件
閱讀完以上內(nèi)容后,您應該能夠?qū)懸粋€什么都不做的插件了。這里我們給出一個例子:
代碼9:測試用的插件 (test.py,置于一個獨立的目錄中)

圖3顯示了要在啟動器啟用代碼9中的測試插件需要做的設置改動。首先,紅框部分,要把 test.py 放在一個目錄中(該目錄不應該有其他無關的 Python 源代碼,否則都會被作為插件讀取)。其次,在藍框部分,需要把啟用測試轉(zhuǎn)換的命令行"--donothing"加上。這是我們可以看到在執(zhí)行部分,"--donothing" 被加到了 "--vn-longsaysplitting"?之后、"--renpy-codegen" 之前,這里就是這個測試用的轉(zhuǎn)換會被執(zhí)行的地方。點擊執(zhí)行后,可以看到如下輸出:(這是筆者電腦上的結(jié)果)
這樣我們的什么都不做的插件就執(zhí)行好了。當然,什么都不做的插件沒什么用,如果我們想實際上手修改數(shù)據(jù),那么我們需要開始了解語涵編譯器的基礎 IR?(第 4 節(jié)),然后了解基于基礎 IR 的、實際描述數(shù)據(jù)的操作項子類 (第 5 節(jié))

4. 語涵編譯器的基礎 IR
語涵編譯器的基礎 IR 是從 MLIR 的設計魔改而來,它與 Context 對象一起給語涵編譯器里所有跨模塊(跨轉(zhuǎn)換)的數(shù)據(jù)提供了便于調(diào)試的數(shù)據(jù)底座。就像之前描述的那樣,類似 JSON 那種對象和數(shù)組互相嵌套的結(jié)構(gòu),基礎 IR 的大致形態(tài)是“操作項”(Operation)套“區(qū)”(Region),區(qū)套“塊”(Block),塊再套操作項,數(shù)據(jù)將自定義操作項的子類并使用基礎 IR 提供的區(qū)、塊類型。這一節(jié)我們先從 Context 對象開始講起,后面再講基礎 IR 的其他細節(jié)。
除非特殊說明,否則這一節(jié)提到的所有類都在 irbase.py 中定義。
4.1 Context 對象
語涵編譯器中的 Context 類/對象參考了 LLVM 中 LLVMContext 類的設計。語涵編譯器每次運行只應有一個 Context 對象,該對象不應被繼承。Context 對象提供了以下功能:
常量池:所有的常量、字面值(Literal)(包括字符串、文本、圖片、音頻素材等)、類型對象、位置元數(shù)據(jù)等都由 Context 管理并去重(去除重復,Uniquing)。當任意 IR 需要記錄一個圖片、音頻素材時,會在 Context 中保存該素材,并在 IR 內(nèi)容中生成一個對該素材的引用。這樣,后續(xù)步驟中要將素材傳遞給另一個 IR 類型時,只需賦予對該素材的引用,素材一直由 Context 管理,素材本身不需要復制。基本上所有(1)會在不同 IR 子類中傳遞的內(nèi)容,(2)需要去重(或者去重對其有益)的內(nèi)容都在 Context 中。
文件訪問控制。Context 對象會保存一個 FileAccessAuditor 對象,可以通過 Context 對象的 get_file_auditor() 函數(shù)獲得對其的引用。FileAccessAuditor 會在下面的 4.2 節(jié)詳細描述。
語涵編譯器內(nèi)基本所有的類實例創(chuàng)建時都需要傳遞進當前的 Context 對象,基本所有的類實例也都有一個 "context" 屬性(Python 的 @property)來保存對 Context 的引用。
除了像字符串這樣輕量的字面值外,Context 需要保存當前所有的音頻、圖片等素材。Context 對象會在創(chuàng)建時生成一個臨時目錄,用來存放那些沒有外部路徑、內(nèi)嵌在文檔中的圖片等素材。如果有轉(zhuǎn)換生成了新的圖片素材(比如什么濾鏡效果),那么它們也會被保存在這個臨時目錄中。臨時目錄會在 Context 對象生命周期結(jié)束前清除。
4.2 FileAccessAuditor 對象
FileAccessAuditor (util/audit.py) 提供了對讀?。ǔ嗣钚猩辖o出的文件路徑)之外所有文件是否被允許訪問的檢查。讓我們先假想一個場景:路人甲在自己的電腦上搭建了一個基于語涵編譯器的服務,該服務讀取其他人寫的劇本并把生成的內(nèi)容返還給用戶。假設這時有好事者乙想竊取路人甲電腦上的其他文件(比如身份證照片),他可以嘗試把想竊取的文件以素材的方式在惡意編寫的“劇本”中引用。如果語涵編譯器傻乎乎地根據(jù)“劇本”中的路徑把身份證照片給包含到工程目錄中,那么當輸出的工程目錄交給乙的時候,他就成功竊取到了這個圖片。為了避免這種情況,語涵編譯器作如下假設:
命令行上給出的所有路徑都是可信賴的。在例子中的場景里,命令行上的所有參數(shù)(包括這些路徑)都應該是由路人甲寫的服務提供的,所以都被認為是可信的。
劇本中的所有資源引用路徑都被認為是不可信的、需要進行檢查的。在例子中的場景里,這劇本可能是好事者乙寫的,有可能存在竊取其他文件、信息的可能。
如果語涵編譯器接入了插件,語涵編譯器無法對插件中對外部文件的讀寫進行干預,運行語涵編譯器的人(路人甲)需要確保插件不會偷取外部文件。由于語涵編譯器的插件需要通過環(huán)境變量進行設置,而環(huán)境變量完全在路人甲的控制下,所以只要路人甲沒有加入未經(jīng)檢查的第三方插件,就也不會導致上述信息泄露的情況。
傳統(tǒng)的C/C++編譯器只有在使用 #include (包含其他頭文件)的時候可能會有惡意的源代碼使用該功能去嘗試讀取不應該讀取的內(nèi)容。這些編譯器要求所有能被 #include 進的頭文件都在編譯器命令行上寫明,編譯器不會這些目錄之外查找頭文件?;?LLVM 的編譯器如果使用了插件(無法保證是否惡意),也是需要在命令行上給出插件路徑、顯式地啟用這些插件的。語涵編譯器提供了相同的保護,只要運行語涵編譯器的時候不作死,就不會有這種信息泄露的問題。語涵編譯器本體內(nèi)所有的代碼(1)在讀取文件時只從命令行或 FileAccessAuditor 允許的路徑中讀取,(2)寫入文件時只寫到命令行提供的路徑或者寫到臨時目錄里。
FileAccessAuditor 類也只會在 Context 對象創(chuàng)建之初創(chuàng)建一個實例,執(zhí)行語涵編譯器時提供的的搜索路徑就是被保存在這個類中。目前 FileAccessAuditor 的實現(xiàn)區(qū)分以下兩種路徑:
(全局)搜索路徑:當用戶在劇本中提供一個相對路徑(比如一個沒頭沒尾的"語涵正常微笑")時,除了以劇本本身所在目錄為起點外, FileAccessAuditor 會在這些全局搜索路徑中一一嘗試,直到找到合適的文件。
可達路徑:當劇本中代表一個素材的任意路徑(不管是相對路徑還是絕對路徑)解析完成時, FileAccessAuditor 會判斷該路徑是否在任意一個可達的路徑之下,如果沒有一個路徑包含這個新素材所在的目錄,那么 FileAccessAuditor 會拒絕這個讀取嘗試。
不過目前(2023-08-09)在命令行上只有一個 "--searchpath" 參數(shù)(圖形界面里這個叫“素材搜索目錄”),該參數(shù)同時設置全局搜索路徑和可達路徑。目前還沒有情況需要區(qū)分這兩者,以后有需要的話會再改命令行參數(shù)。
4.3 基礎 IR
現(xiàn)在我們開始介紹基礎 IR 的設計,詳細的代碼請參考代碼庫中的 irbase.py ?;A IR 中大量使用了多重繼承,對于只熟悉C++的讀者來說,Python 的類基本上可以理解為只有虛繼承(virtual inheritance),區(qū)別是繼承的所有成員里不能有同名的,同名時只會有一個成員。
由于數(shù)據(jù)結(jié)構(gòu)中常會需要使用自己的類來做數(shù)據(jù)結(jié)構(gòu)(比如鏈表)的結(jié)點,基礎 IR 定義了以下基類用來替代 Python 給的數(shù)據(jù)結(jié)構(gòu):
IList/IListNode. 基礎 IR 中使用了大量的雙向鏈表,鏈表的結(jié)點是基礎 IR 中各種類的對象。為了保持性能,語涵編譯器使用 IListNode 來做這些鏈表結(jié)點的類的基類。(IListNode 中的 “I” 代表 Intrusive, 和 LLVM 里的 simple_ilist 一樣,鏈表的前后指針都是直接在結(jié)點本身存儲的,而不是鏈表使用特別的鏈表結(jié)點類,然后再額外帶個數(shù)據(jù)對象;鏈表的數(shù)據(jù)和其他數(shù)據(jù)在同一個 Python 對象里。)同時, IList 類會作為鏈表本體的類與 IListNode 對接。
NameDict/NameDictNode.基礎 IR 中也包含“可以通過名稱查找到對象”的結(jié)構(gòu),就像是一個 Python 的 dict, 鍵(key)是字符串,值(value)是對象引用。基礎 IR 中使用 NameDict 類來存儲這些信息,所有會在 NameDict 中作為值的對象將繼承自 NameDictNode 。
除了以上提到的區(qū)別外,基礎 IR 中的數(shù)據(jù)結(jié)構(gòu)基本都帶有 parent 引用;我們可以從鏈表結(jié)點(IListNode)找到鏈表對象(IList)再找到包含這個鏈表對象的類(比如操作項什么的)。我們也可以從結(jié)點對數(shù)據(jù)結(jié)構(gòu)進行操作,比如我們可以從 NameDictNode 處把結(jié)點移除,其所在的 NameDict 也會更新。
其次,語涵編譯器內(nèi)不使用 Python 的 float, 只使用 decimal.Decimal 。這樣保證以十進制小數(shù)形式寫在劇本里的值(比如漸變時間 0.3 秒)能夠沒有誤差地寫到輸出中。我們說“浮點數(shù)”時,指代的也是 decimal.Decimal 。
還有,基礎 IR 中區(qū)分“值”(Value)與非值對象,只有值對象才能在基礎 IR 中有持久化的引用(指可以保存在數(shù)據(jù)里),作為其他內(nèi)容的參數(shù)等等。值(Value)在基礎 IR 中是個基類,構(gòu)造時需要提供一個值類型。和 LLVM IR 一樣,值有一個 uselist, 每次 Value 新增加一個引用時會新建一個 Use 對象存在這里,我們可以從這個 uselist 找到某個 Value 的所有引用,并提供 LLVM 的 replace_all_uses_with() (把所有對當前值的引用改成對另一個值的引用)這種功能,方便轉(zhuǎn)換與優(yōu)化?;A IR 定義了各種字面值類型 (Literal) 來存放 Python 自帶數(shù)據(jù)類型的值,比如 IntLiteral 存放 int 值, FloatLiteral 存放 decimal.Decimal 值, StringLiteral 存放 str 值,等等。
介紹了這些類之后,我們可以開始介紹基礎 IR 中的主體:操作項(Operation)。數(shù)據(jù)中新定義的所有類基本都繼承自 Operation 類。一個操作項對象包含:
一個字符串類型的名稱(name)
一個表示輸入位置的元數(shù)據(jù)(location, 一般用于追蹤錯誤位置,也用來提示素材搜索起始路徑)
0-N 個輸入?yún)?shù) (OpOperand 實例),每個輸入?yún)?shù)有一個字符串的名稱,可以有 0-N 個輸入值(輸入值必須是 Value 的子類)
0-N 個輸出結(jié)果 (OpResult 實例)。輸出結(jié)果是 Value 的子類,每個輸出結(jié)果都有一個字符串的名稱。
一個字典 (Python 的 collections.OrderedDict)存放所有的屬性(Attributes)。屬性是一個輕量的、保存信息的方式,鍵是字符串,值可以是整數(shù)、字符串、布爾型(True/False),或是浮點數(shù)。
一個存放所有內(nèi)部的區(qū)(Region)的 NameDict. 所有主要內(nèi)容都在這里。
操作項在構(gòu)造時需要提供名稱和位置。為了保持可拓展,操作項的輸入、結(jié)果都是按名字的,每個輸入都可以有 0-N 個值。不過子類也可以同時繼承自 Operation 和 Value, 這樣這個對象本身也可以作為值引用。允許輸入?yún)?shù)沒有值是為了方便可選參數(shù)的實現(xiàn),允許多個值是為了方便(1)代表不定量的參數(shù)(比如人物上場命令可以同時上多個人),(2)方便代表有格式信息的文本。語涵編譯器使用 StringLiteral 代表一個沒有格式信息的字符串,使用 TextFragmentLiteral 代表一個有格式的文本片段,里面有(1)一個對 StringLiteral 的引用來表示內(nèi)容,(2)一個格式(TextStyleLiteral)表示該片段所有內(nèi)容使用的文字樣式。由于文本有可能是有樣式和無樣式的交錯,要代表“一段文本”需要使用一個列表的 StringLiteral 或者 TextStyleLiteral, 這種情況下他們可以全部塞進一個參數(shù)里。
大多數(shù)比較簡單的操作項都只需在構(gòu)造時創(chuàng)建輸入?yún)?shù),輸入?yún)?shù)就能夠表示其內(nèi)容。比如 RenPy AST 中用來表示一個 play 命令的結(jié)點可以寫為:
代碼10:"play" 命令在 RenPy AST 中的表示?(RenPyPlayNode) (renpy/ast.py)
(上述代碼中 RenPyNode 繼承自 Operation)
為了便于在代碼中創(chuàng)建新的 Operation 子類,irdataop.py 提供了 @IROperationDataclass 和 @IROperationDataclassWithValue 兩個修飾符,前一個用于不繼承自 Value 的 Operation 子類,后一個用于同時繼承兩個類的子類。加了這個修飾符之后,就可以像 @dataclasses.dataclass 那樣列舉成員,初始化時修飾符提供的初始化函數(shù)可以自動按照列舉的順序和名稱創(chuàng)建對應的項。由于 Operation 的初始化過程比較麻煩 (下面的 4.5 節(jié)會細講),我們推薦所有新寫的 Operation 子類使用這兩個修飾符。
對于有較多內(nèi)容的操作項來說(比如代表一整篇輸入文檔的 IMDocumentOp),使用操作項-區(qū)-塊-操作項的嵌套結(jié)構(gòu)是期望的保存數(shù)據(jù)的方式。一個區(qū)(Region)單純只是一個有名字的、保存一串塊(Block)的類,一個塊(Block)則有以下信息:
塊的名稱(字符串)
一個放 Operation 及其子類的 IList.
0-N 個塊參數(shù) (BlockArgument)。注意這里的“參數(shù)”不一定是輸入、輸出值。語涵編譯器內(nèi)絕大部分地方的塊沒有塊參數(shù)(如果您熟悉編譯器 IR 的話,這是因為語涵編譯器內(nèi)的 IR 現(xiàn)在還不是 SSA 形式),塊參數(shù)僅在 VNModel 中使用,后面會講。
舉個例子,剛剛讀取完 odt 文檔所生成的、代表一整篇輸入文檔的 IMDocumentOp, 下面有一個叫"body"的區(qū),這個區(qū)里有一堆塊,每個塊代表一個自然段。每個塊內(nèi)可以有 IMElementOp 代表文本、圖片等內(nèi)容,或者 IMListOp 代表一個列表,等等。
語涵編譯器還提供了以下 Operation 的子類來提供一些常用功能:
MetadataOp: 語涵編譯器一般使用 MetadataOp 的子類(間接繼承 Operation)來保存不影響代碼生成邏輯的元數(shù)據(jù)。語涵編譯器使用 CommentOp 來記錄源劇本中的注釋,使用 ErrorOp 來記錄處理劇本時的問題、錯誤等,這兩個類都是 MetadataOp 的子類?;旧纤械霓D(zhuǎn)換都會將輸入 IR 中的 MetadataOp 原封不動地復制到輸出 IR 。RenPy 輸出中所有 "preppipe_error_sayer_xxx" 的錯誤信息都是由 ErrorOp 轉(zhuǎn)化而來的。
Symbol(與 SymbolTableRegion 組合): 有時需要把 Operation 作為像 dict 中的值,比如一個工程里有多個函數(shù),我們想有個類似字典的接口,通過函數(shù)的名字找到函數(shù)對象,這時候函數(shù)對象就可以繼承自該類。然后使用一個 SymbolTableRegion 來存放這些 Symbol。SymbolTableRegion 是 Region 的一個子類,專門用于這種情況。這種 Region 只會有一個塊,塊內(nèi)存的都是 Symbol ??梢园?SymbolTableRegion 當作一個字典來訪問其中的 Symbol ,當這些 Symbol 更新(比如移除)時, SymbolTableRegion 也會更新自己的數(shù)據(jù)。
以上內(nèi)容大部分都比較抽象、不太直觀,下面我們提供一種能夠直觀檢視基礎 IR 的方法,并講述為什么需要這樣的基礎 IR 。
4.4 基礎 IR 提供的功能:檢查,復制、保存接口
使用同一套基礎 IR 使得語涵編譯器能夠共享以下功能的實現(xiàn):
檢視(打?。褐灰^承了 Operation 類,開發(fā)者就可以使用 Operation.dump() 來將該類里所有的信息顯示(print)出來,也可以使用 Operation.view() 來保存一個 HTML 格式的輸出,程序會把它會保存在一個臨時目錄下并且嘗試打開瀏覽器去讀取它。這兩個功能主要在調(diào)試器中使用,哪里信息不清楚就 dump 哪里。在管線中,也可以使用 "--view" 選項將當前管線里的 IR 的 HTML 表示給存下來。(注:這個功能需要繞過啟動器,直接調(diào)用命令行;一般也僅有開發(fā)者需要這個功能來輔助調(diào)試。)
統(tǒng)一的復制、保存接口。只要繼承了該類,開發(fā)者就可以使用 Operation.clone() 來復制一個實例。雖然現(xiàn)在還沒有做,但是語涵編譯器未來將支持把 IR 保存為 JSON 格式、讀取 JSON 格式的 IR, 到時候所有的子類都可以使用基類的實現(xiàn)。


4.5 IRObject
注:這部分只有新建 Operation 子類(或者其他基礎類型的子類)才需要閱讀,如果只想對已有的 Operation 子類進行操作的話可以跳過該部分。
目前我們可預見的?Operation 的子類和其他基礎類型的子類一共有三種創(chuàng)建方式:
構(gòu)造:第一次用新數(shù)據(jù)創(chuàng)建時是這種方式。
復制:從一個已有的實例復制出一個新的實例時是這種方式。
導入:以后做了 JSON 導入的話應該可以通過這種方式恢復實例。
但是,Python 只有一個 __init__(),并不像 C++ 那樣可以有多個構(gòu)造函數(shù)(constructor)。語涵編譯器的解決辦法就是自己做一個初始化的機制,做在一個基礎類 IRObject 里,包括 Operation 在內(nèi)的基礎類型都繼承自該類。具體細節(jié)如下:
IRObject?定義以下成員函數(shù): (1) construct_init() 用于第一種創(chuàng)建方式,(2) copy_init() 用于第二種,(3) json_import_init() 用于第三種方式。
IRObject?提供一個泛用的 __init__() ,除了 context?參數(shù)外還取一個 init_mode 枚舉值來選擇以上三個成員函數(shù)之一進行調(diào)用。
IRObject 額外定義一個 base_init() 來讓子類執(zhí)行一些“不管什么創(chuàng)建方式都應該執(zhí)行”的代碼,和一個 post_init() 來讓子類在以上“構(gòu)造函數(shù)”執(zhí)行完之后再收尾。
Operation 和其他 IRObject 子類覆蓋這些成員函數(shù)來自定義初始化過程。這些類一定不會覆蓋 __init__()。
對于一般的 Operation 子類來說,需要覆蓋的成員函數(shù)只有?construct_init() 和?post_init(),前一個用來給父類傳遞合適的 construct_init() 的參數(shù),后一個用來給自己添加成員(因為如果是復制或是導入的話,基類只會把基類的成員加到對象上,子類的成員還不會加)。這里我們以 CommentOp 為例:
代碼11: CommentOp 的定義(irbase.py)
基本上所有已有的 Operation 子類都有個叫 create() 的靜態(tài)函數(shù)用于創(chuàng)建實例,他們的內(nèi)容和代碼11中的樣例基本上差不多。

5 語涵編譯器的抽象 IR 和默認執(zhí)行管線
前面的基礎 IR 講完后,從這里我們開始講語涵編譯器具體是如何使用這些功能來進行對視覺小說劇本的處理的。下面的圖6、圖7是目前執(zhí)行管線(使用啟動器時要執(zhí)行的默認的那串轉(zhuǎn)換)和所用的 Operation 子類的大致說明:


后面的內(nèi)容中我們使用以下最小樣例來介紹各個部分:
5.1 IMDocumentOp (InputModel)
為了在未來支持多種輸入文件類型,劇本剛剛讀取時會把讀取的劇本使用一個 IMDocumentOp 來表示,每個 IMDocumentOp 代表一個文件。IMDocumentOp 將輸入文檔內(nèi)用到的格式信息保留、轉(zhuǎn)化,其他沒有用到的信息全部丟棄,這樣可以“抹平”不同輸入文檔格式之間的差異,使用同一套代碼進行后續(xù)處理。
原來編寫時有一個叫 InputModel 的 Operation 子類用作頂層 Operation,現(xiàn)在已經(jīng)棄用、刪除,但是這個 IR 還是被叫做 InputModel。所有涉及的類都在 inputmodel.py 中定義。
上面的最小樣例 "minimal.odt" 在轉(zhuǎn)換為 IMDocumentOp 之后是這樣的:

圖8就是在初次讀取之后的 view() 的結(jié)果。文檔的內(nèi)容存放在 "body" 區(qū)(Region) 下,每個塊(因為沒有名稱所以都是 <anon>, 匿名)代表一個自然段。第一段是第一行的內(nèi)容,第二段是第二行的內(nèi)容。IMElementOp 表示一個正常的文本、圖片等內(nèi)容。如果有列表、特殊塊等內(nèi)容,則在相應的塊內(nèi)有 IMListOp 和 IMSpecialBlockOp 等,這里不再贅述。(我們會在之后整理關于所支持的前端語法的詳細說明。)
在文檔讀取完成之后,下一步是找到文檔中的所有命令,并把它們替換為 GeneralCommandOp,把參數(shù)等內(nèi)容都分開。下面是執(zhí)行這個 "--cmdsyntax" (frontend/commandsyntaxparser.py) 轉(zhuǎn)換后的結(jié)果:

可以看到第二段里的 IMElementOp 被替換為了 GeneralCommandOp, 命令名是“注釋”,有一個按位參數(shù) (positional argument),內(nèi)容是“這確實是最小樣例”。
如果有參數(shù)是調(diào)用表達式的樣子(比如有個參數(shù)是“占位(分辨率=xxx,描述=xxx)”),那么在 "nested_calls" 區(qū)會有一個?GeneralCommandOp 來放這個參數(shù)。如果命令后面接了列表或者特殊塊,他們會被移到 "extended_data" 區(qū)。因為這個例子都沒有,所以這兩個區(qū)都為空。這個命令也沒有關鍵字參數(shù),所以 "keyword_arg" 也為空。
到這之前的所有內(nèi)容都不涉及視覺小說的具體邏輯;它們可以復用于其他用途。接下來的內(nèi)容就是涉及視覺小說、目前仍然在尋求反饋階段的內(nèi)容了。
5.2 VNAST 和?VNModel
注:VNAST 和 VNModel 仍會在語涵編譯器完善過程中迭代,當前內(nèi)容僅供參考。
在之前的轉(zhuǎn)換中,我們只找到了命令的形式、內(nèi)容,但是還沒聯(lián)系到命令的含義。在接下來的 "--vnparse" (frontend/commandsemantics.py, frontend/vnmodel/vnparser.py) 轉(zhuǎn)換中,語涵編譯器會掃過文檔中的所有內(nèi)容,根據(jù)命令內(nèi)容執(zhí)行對應的操作。

這里第一句“語涵:這是最小樣例?!北唤馕鰹橐痪浒l(fā)言(VNASTSayNode),第二段的命令轉(zhuǎn)化為了注釋 (CommentOp)。由于這個樣例沒有定義任何函數(shù)、章節(jié),所以所有內(nèi)容都在一個以文件名"minimal"為名稱的函數(shù)下(VNASTFunction)。這個樣例沒有定義角色、場景等信息,所以有一堆空白的區(qū)。
VNAST (frontend/vnmodel/vnast.py) 是對當前所有劇本的內(nèi)容的“需求理解”,對標 Clang/LLVM 中的 Clang AST。但是這個信息還不能支持后端 (如 RenPy) 的生成。我們需要先創(chuàng)建一個抽象的演出腳本,這就是 VNModel (vnmodel.py)。VNModel 對標 Clang/LLVM 中的 LLVM IR,我們以后會再對 VNModel 的內(nèi)容進行梳理。

5.3 RenPy AST (RenPyModel)
為了方便在導出 RenPy 工程前對其內(nèi)容進行優(yōu)化,語涵編譯器也對常用的 RenPy 命令建模,做出了刪減版的 RenPy AST (renpy/ast.py),其內(nèi)容基本上可以一一對應到 RenPy 工程輸出。
上面的最小樣例的 RenPy AST 是這樣的:

可以看到"script.rpy"(RenPyScriptFileOp) 下有(1) 一個 "define" 語句(RenPyDefineNode) 定義了發(fā)言者“語涵”,(2)一個 label start 跳轉(zhuǎn)到下面的 minimal, (3) label minimal, 里面是實際的內(nèi)容。
總結(jié)
本文大致介紹了語涵編譯器的工作原理,包括內(nèi)部的管線、基礎 IR, 以及在此基礎上的各個子類 IR 和流水線上他們的關系。如果讀者對某些源代碼感興趣,可以從本文包含的文件路徑出發(fā),或者搜索命令行參數(shù)來找到對應的轉(zhuǎn)換注冊(@FrontendDecl 這種)。目前基礎 IR 和管線沒有很強的修改需求,接下來的很長時間內(nèi),我們將改進輸入劇本的語法以及涉及的?VNAST、VNModel,爭取更好的用戶體驗和生成的演出質(zhì)量。
感謝您閱讀到這!筆者肝不動了,結(jié)尾就這樣了。。。

附錄1:Clang/LLVM 管線中的 Hello world 樣例
下面我們以一個?Hello World?程序為基礎,介紹一下管線的各個階段:
注:以下過程僅提供個大致的說明,并不需要讀者理解,沒有C背景的讀者完全可以跳過這里,到下一個部分?。?!
代碼4:Hello world C 代碼 (hello.c)
從此,這個無名的 hello.c 開始了它那鮮為人知的壯闊旅途。
代碼5:(刪減后的)"clang -E hello.c" 輸出結(jié)果
這里預處理器把源代碼里引用的 stdio.h?的內(nèi)容全都包含進來了。其中最重要的就是 printf() 的聲明。
代碼6:(刪減后的)Clang AST,"clang -Xclang -ast-dump -c hello.c" 輸出結(jié)果。
從這開始,這些顯示內(nèi)容都是內(nèi)部數(shù)據(jù)結(jié)構(gòu)的顯示結(jié)果,不需要再進行文本處理。這里的 FunctionDecl 對應函數(shù)(function) int main() 的聲明(Declaration),CompoundStmt? 對應 main() 后面的花括號 {} (也代表函數(shù)本體內(nèi)容),?CompoundStmt?下有一項 CallExpr 表示對 printf() 的調(diào)用,另一項 ReturnStmt 代表返回語句。
代碼7:未優(yōu)化的 LLVM IR ("clang -S -emit-llvm hello.c -o hello.ll" 后 hello.ll 的內(nèi)容節(jié)選)
LLVM IR 代表抽象的程序?qū)崿F(xiàn)。字符串(Hello world)的內(nèi)容被放在只讀的常量區(qū),對其的引用轉(zhuǎn)化為了地址(@.str)。這里 %2 = call ... @printf(...) 是 printf 的調(diào)用,下面的 ret i32 0 是返回語句生成的內(nèi)容。上面的 %1 = ... 和下面的 store 指令沒有用處。
代碼8:優(yōu)化的 LLVM IR?("clang -O1 -S -emit-llvm hello.c -o hello.ll" 后 hello.ll 的內(nèi)容節(jié)選)
可以看到這里 LLVM 把對 printf() 的調(diào)用優(yōu)化為對 puts() 的調(diào)用,上面兩個沒用的指令也被去掉了。到這里,所有的代碼基本都是平臺無關的,除了x86-64外,如果新加了平臺(比如手機用的ARM),也可以復用這些優(yōu)化。
代碼9:x86-64 MIR, "llc -print-after-all hello.ll" 輸出節(jié)選。
從這里開始就是x86-64獨有的部分。MIR階段也有多個步驟,當前步驟下所有的寄存器已經(jīng)確定(比如 $eax 做返回值,調(diào)用 puts() 之前,"hello world"字符串的地址放在 $rdi 里面),指令也已選定。
代碼10:最后的匯編結(jié)果。