Translators Everywhere All at Once

20 hours ago
compilerjitpythondotnet

這篇算是名詞整理。translator、compiler、interpreter、assembler、transpiler 都有翻譯的意思,那麼在靜態、動態語言之前,JIT、AOT、managed、unmanaged 等等分別是什麼關係,一個一個拆解。

從 source code 到 machine code,中間有哪些形式

high-level language、assembly language、machine language 三層大家都知道,可是中間還有幾種形式,不同地方的叫法也不完全一樣。先拿 x = a + b 一路往下走:

  • source code,也叫 high-level code、原始碼:人寫的文字,x = a + b
  • compiler IR,LLVM IR、rustc 的 MIR、GCC 的 GIMPLE 都是:編譯器自己用的中間形式,%x = add nsw i32 %a, %b,優化在這一層做
  • assembly,也叫 asm、組合語言,副檔名 .s:一行一條 CPU 指令,add eax, ebx,只對一種 CPU 家族有效
  • object code,副檔名 .o 或 .obj:assembler 的產出,已經是 machine code,但外部符號的位址還是佔位符
  • machine code,也叫 native code:linker 填好位址的 bytes,01 d8,CPU 直接跑

bytecode 不在上面那排。source 編譯成 bytecode 之後就交給 VM,不會經過 assembly、object code。別稱有 IL、CIL、MSIL,存成 .pyc、.class、.dll。CPython 的 BINARY_OP +、JVM 的 iadd、CIL 的 add,都是給 VM 讀的,不對任何一種 CPU。直譯器一條一條執行它,JIT 把跑最多次的那幾段編譯成 machine code,這時候才回到上面那排的最後一種形式。

有 linker 這一步的工具鏈才有 object code。C、C++、Rust、Go 都是每個檔案或 package 先產一個 .o,再交給 linker。Java 的 .class、C# 的 .dll 是 bytecode,沒有 linker,符號在 class loader 或 CLR 載入時才解;Python、JavaScript 沒有這一層;JIT 產出的 machine code 直接放記憶體,不存成 .o。

IR 底下有哪些

翻譯到一半停下來的形式都叫 IR(intermediate representation,中間表示):

IR(類別)
├─ LLVM IR        clang、rustc 的中間產物。有型別,不綁 CPU,通常不存檔
├─ MIR            rustc 自己更前面一層,borrow checker 在這裡跑
├─ GIMPLE         gcc 的中間產物
├─ Java bytecode  javac 產的 .class,給 JVM 讀
├─ CIL            Roslyn 產的,放在 .dll,給 CLR 讀。也叫 IL、MSIL
└─ assembly       給 CPU 的文字,assembler 的輸入。也在中間,很少人叫它 IR

這幾種差在三個地方:

  • 只有 assembly 綁定 CPU,x86 的 assembly 到 ARM 上沒用,其他幾種哪顆 CPU 都能用
  • 讀的對象不同。assembly 是給人跟 assembler 讀的,bytecode 跟 CIL 給 VM,LLVM IR 給編譯器自己的優化 pass
  • 高階還是低階,看它還保留多少 source 的資訊。LLVM IR 保留的最多,有型別、有 nsw 這種語意;bytecode 跟 CIL 有型別,但一個指令一個動作;assembly 沒有型別,只剩暫存器跟指令,最低階

「CIL 算不算 assembly」的答案在前兩點。CIL 不綁 CPU、給 VM 讀,它跟 Java bytecode 是同一類,不是 assembly。

translator 概括

source code 變成 assembly,assembly 變成 machine code,做這種轉換的工具統稱 translator。

  • preprocessor:文字換文字,C 的 #include、#define
  • compiler:轉換成另一種語言,通常是高階轉低階
    • AOT compiler:gcc、rustc,執行之前編譯完
    • JIT compiler:V8 的 TurboFan、HotSpot 的 C2、CLR 的 RyuJIT,執行時才編譯,輸入是 bytecode,不是 source
    • transpiler:Babel、tsc,高階換另一種高階,輸出還是給人看的文字
  • assembler:assembly 換 machine code
  • interpreter:不產出新形式,直接執行。早期的 BASIC 跟現在的 bash 讀 source 文字,一行做一行;CPython、JVM 讀的是先編譯好的 bytecode
  • linker:不轉換形式,只把多個 .o 接起來。所以四種 translator 裡沒有它,但工具鏈裡有

C 四個都用到,preprocessor → compiler → assembler → linker。其他語言各用到哪幾個:

語言preprocessorcompilerassemblerlinker執行時
C、C++有gcc、clang、MSVC有,gcc 交給 as,clang 用 LLVM 內建的有直接跑 machine code
Rust-,macro 展開在 rustc 內rustc,走 LLVMLLVM 內建有直接跑
assembly--有有直接跑
Java-javac → .class--HotSpot,直譯加 JIT
C#-Roslyn → IL--CLR 的 JIT,或 Native AOT
Python-內建,source → .pyc--CPython 直譯,3.13 起可開 JIT
JavaScript-引擎內建,source → bytecode--V8 直譯加 JIT

直譯、JIT、AOT 差在什麼時候編譯

AOT 是 ahead of time,執行前就把 machine code 產好,C、Rust、Go 都是。JIT 是 just in time,執行中才產 machine code。foo.py 長這樣,一個加法函式被呼叫一萬次:

def add(a, b):
    return a + b
 
total = 0
for i in range(10_000):
    total = add(total, i)
print(total)

python foo.py 這一次執行的時間軸,直譯跟 JIT 分別是哪一步:

啟動
 → compiler 把整個 foo.py 編成 bytecode(一次做完,存 .pyc)
 → 直譯器開始一條一條讀 bytecode 執行         ← 「直譯」指這裡
 → (有開 JIT 的話)跑了一陣子,發現 add 被呼叫了幾千次
 → 把 add 那段 bytecode 編成 machine code      ← 「JIT」指這裡
 → 之後再呼叫 add,直接執行 machine code,其他段照舊直譯
結束
  • compile(source 到 bytecode):翻譯,產物是 bytecode,執行前做,整個檔案一次編譯完
  • 直譯:不翻譯成別的形式,而是讀一條就做一條,所以產物不是檔案,是動作。直譯器本身是一個已經編譯好的 C 大迴圈,讀到 BINARY_OP + 就跳去執行「兩個 Python 物件相加」那段 C,再讀下一條
  • JIT:翻譯,產物是 machine code,執行中做,只翻跑很多次的。發現 a + b 跑了幾千次而且 a、b 都是 int,就替這段生一小塊 machine code,裡面是一條 CPU 的 add

第一步照字面也是「執行時才編譯」,但它產出的 bytecode CPU 不能跑,跟 javac 把 .java 編成 .class 是同一種動作,沒人說 javac 是 JIT。業界說 JIT,指的是編成 machine code 那個。

JIT 是 runtime 的功能,跟語言無關。V8、HotSpot 預設開,CPython 3.13 起可以開,到 3.15 還不是預設;iOS 不准執行時產生可執行的碼,所以 .NET for iOS 只能 AOT。JIT 也要跑夠多次才有差。同一支 Node 程式開 JIT 跟 --jitless 各量一次,只跑一次兩邊一樣快,跑 300 次 JIT 快 1.23 倍。

有些語言主流實作只有一種,有些兩種都有:

語言為什麼
只有 AOTC、C++、Rust、Go、Swift型別在編譯時全部確定,沒有執行期才知道的資訊給 JIT 用
只有 JIT 或直譯瀏覽器裡的 JavaScript程式碼是執行時才從網路下載的,沒有「執行前」可以編譯
兩種都有Java、C#、Python、DartHotSpot 是 JIT、GraalVM Native Image 是 AOT;CLR 是 JIT、Native AOT 是 AOT;CPython 直譯、Nuitka 是 AOT;Dart 開發時 JIT、發布時 AOT

誰管記憶體?managed 對 unmanaged

跟上面的形式無關,是另一條光譜,差別在記憶體由誰回收:

  • runtime 回收:有 GC(garbage collector),程式只管 new,不用管 free。Java、C#、Go、Python 都是
  • 人自己回收:C 的 malloc 跟 free。忘了 free 是 leak,free 兩次或太早是 crash
  • 編譯器算好什麼時候回收:Rust 的 ownership,變數離開 scope 編譯器就插一句釋放,沒有 GC 也不用自己寫 free

managed code 是 .NET 跟 Java 的用語,指跑在 VM 上、GC 管記憶體、執行期會檢查 null 跟陣列越界。C、C++、Rust 是 unmanaged。Python 嚴格說也符合 managed 的定義,只是沒人這樣叫。

.NET 跟 Java 的 managed code 剛好同時有 GC、跑在 VM 上、執行時 JIT,所以學的人很容易把三個東西想成同一個東西,看到 AOT 編譯成 machine code 就以為它變 unmanaged。三件事各自獨立:

  • managed 只看記憶體是由誰管的
  • AOT 或 JIT 看的是 machine code 什麼時候產出
  • VM 有沒有是第三件

Go 有 GC、沒 VM、AOT 編譯。Native AOT 的 C# 也是有 GC、沒 VM、AOT 編譯,編譯出來是 machine code,也還是 managed。

副檔名對照

常見的副檔名排在一起:

副檔名內容誰產出執行時managed
.c .cc .rs .go .java .cs .py .jssource code人看下面看下面
.s .asmassemblygcc -S,或人手寫不執行,要先組譯
.o .objobject codeassembler不執行,要先 linkunmanaged
.so .dylib、C++ 的 .dllmachine code,動態連結linker執行時載入,AOTunmanaged
ELF、Mach-O、C 的 .exemachine codelinker直接跑,AOTunmanaged
.pycPython bytecodeCPython 自動CPython 直譯,開了 JIT 才有 JIT有 GC,沒人叫它 managed
.class .jarJava bytecodejavac,jar 是 zip 起來JVM 直譯加 JITmanaged
.dexAndroid 的 bytecoded8ART 直譯加 JITmanaged
.NET 的 .dll .exeCIL 加 metadataRoslynCLR JIT;Native AOT 出來的 .exe 是純 machine codemanaged
.wasmWebAssembly 的 bytecodeemcc、rustc、Go瀏覽器或 wasmtime 的 JIT看語言,Rust 編譯的沒 GC

同一個副檔名兩種內容的是 .dll 跟 .exe,C++ 編譯出來的是 machine code,Roslyn 產的是 CIL。要分辨用 file 指令,它不看副檔名,讀檔案開頭幾個 bytes 去對格式表:

$ file translators-lite.md
translators-lite.md: Unicode text, UTF-8 text
$ file /bin/ls
/bin/ls: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64e:Mach-O 64-bit executable arm64e]
$ file UnityPlayer.dll
UnityPlayer.dll: PE32+ executable (DLL) (console) x86-64, for MS Windows

兩種 .dll 都是 PE 格式,開頭都是 MZ 兩個 bytes。.NET 的那種多一個 CLR header,file 會在後面多標一段 Mono/.Net assembly;C++ 的沒有這個 header,就像上面 UnityPlayer.dll 那行,只印到 x86-64。

撞名的 term

  • assembly:組合語言,或者 .NET 的 .dll、.exe 那個檔案。後者內容是 CIL 加 metadata,跟組合語言無關,純粹撞名
  • binary:這篇只指不是純粹文字檔的檔案。machine code 是 binary,.pyc、.class 也是 binary,但不是 machine code。Java 規格裡 .class 就叫那支程式的 binary
  • IL、CIL、MSIL:三個名字同一個東西,.NET 的 bytecode。IR 是類別,LLVM IR、bytecode、CIL 都是成員
  • 前端框架的 AOT、JIT:Vue、Angular 講的是模板什麼時候編成 render function,build 時叫 AOT,瀏覽器裡叫 JIT。兩種輸出都還是 JavaScript,之後都交給 V8,V8 自己那層直譯加 JIT 跟框架選哪一種無關。npm run build 做的是 transpile、bundle、minify,也不是 AOT
  • native code:有時候是 machine code 的同義詞,有時候指 unmanaged code。要區分 managed 跟 unmanaged 的時候,unmanaged 那邊才會被叫成 native

SO 那題「native code、machine code、assembly code 差在哪」最高票答案的畫面。C# 的 hello world 在 Visual Studio 開 Disassembly 視窗,每一行三欄:

00000003  E8 30 BE 03 6F     call  6F03BE38          ; Console.Out getter
0000000a  8B 15 88 20 BD 02  mov   edx, [02BD2088h]

第一欄是位址。第二欄的 hex 是 machine code,CPU 直接吃的 bytes,8B 就是 MOV。第三欄是 assembly,同一段指令的人讀版本,一對一。這段是 CLR 的 JIT 從 CIL 編譯出來的,所以它是 machine code,符合 native 的第一個意思;GC 還在管它,所以是 managed,不符合第二個。

誰等於誰

  • IL = CIL = MSIL,.NET 的中間形式
  • LLVM IR、bytecode、assembly 都是 IR 的一種,但 bytecode ≠ assembly,差在綁不綁 CPU、給誰讀
  • CIL 跟 Java bytecode 是同一類,各自的 VM 是 CLR 跟 JVM
  • .NET 的 assembly ≠ assembly language
  • assembly ≠ assembler 的產物,assembly 給 assembler 之後變出 object code
  • machine code 是 binary 的一種,bytecode 檔也是。所以看到「編成 binary」不能推它是 machine code,Java 規格就叫 .class 是 binary,那是 bytecode
  • machine code = native code 的第一個意思,native 的第二個意思是 unmanaged
  • managed ≠ JIT。Go、Native AOT 的 C# 有 GC 沒 JIT
  • JVM 跟 CLR 的角色一樣,都是 runtime,工作是把 bytecode 檔載進來、一條一條直譯、跑很多次的 method 交給 JIT 編譯成 machine code、記憶體交給 GC。差別只在 JVM 讀的是 .class 裡的 Java bytecode,CLR 讀的是 .dll 裡的 CIL

優文

一點都不深入的了解 Compiler、Interpreter 和 VM