Translators Everywhere All at Once
這篇算是名詞整理。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。其他語言各用到哪幾個:
| 語言 | preprocessor | compiler | assembler | linker | 執行時 |
|---|---|---|---|---|---|
| C、C++ | 有 | gcc、clang、MSVC | 有,gcc 交給 as,clang 用 LLVM 內建的 | 有 | 直接跑 machine code |
| Rust | -,macro 展開在 rustc 內 | rustc,走 LLVM | LLVM 內建 | 有 | 直接跑 |
| 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 倍。
有些語言主流實作只有一種,有些兩種都有:
| 語言 | 為什麼 | |
|---|---|---|
| 只有 AOT | C、C++、Rust、Go、Swift | 型別在編譯時全部確定,沒有執行期才知道的資訊給 JIT 用 |
| 只有 JIT 或直譯 | 瀏覽器裡的 JavaScript | 程式碼是執行時才從網路下載的,沒有「執行前」可以編譯 |
| 兩種都有 | Java、C#、Python、Dart | HotSpot 是 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 .js | source code | 人 | 看下面 | 看下面 |
| .s .asm | assembly | gcc -S,或人手寫 | 不執行,要先組譯 | |
| .o .obj | object code | assembler | 不執行,要先 link | unmanaged |
| .so .dylib、C++ 的 .dll | machine code,動態連結 | linker | 執行時載入,AOT | unmanaged |
| ELF、Mach-O、C 的 .exe | machine code | linker | 直接跑,AOT | unmanaged |
| .pyc | Python bytecode | CPython 自動 | CPython 直譯,開了 JIT 才有 JIT | 有 GC,沒人叫它 managed |
| .class .jar | Java bytecode | javac,jar 是 zip 起來 | JVM 直譯加 JIT | managed |
| .dex | Android 的 bytecode | d8 | ART 直譯加 JIT | managed |
| .NET 的 .dll .exe | CIL 加 metadata | Roslyn | CLR JIT;Native AOT 出來的 .exe 是純 machine code | managed |
| .wasm | WebAssembly 的 bytecode | emcc、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