Teyru

Teyru 編譯器架構

從來源檔到原生執行檔的完整流程:詞法、語法、語意分析、C 程式碼產生與執行期。

流程

來源檔 (UTF-8)
   │  internal/source      — 檔案、行號對應、診斷容器

Token 串
   │  internal/lexer       — 關鍵字、運算子、字面值、文字區塊
   │                        換行不是 token,只在 token 上標記 NL 旗標

AST
   │  internal/parser      — 遞迴下降;游標 + 前瞻 + 選擇點回退

已檢查的程式模型
   │  internal/sema        — 符號表、型別、泛型抹除、多載、佈局

C 原始碼
   │  internal/codegen    — 類別→struct、vtable/itable、GC 根資訊

原生執行檔
      clang/LLVM 或 gcc + internal/runtime/src(GC、字串、陣列、例外)

換行當敘述終止符

Teyru 沒有分號。詞法分析器不產生 NEWLINE token,而是在每個 token 上記錄 「前面是否有換行」。剖析器用兩件事判斷敘述是否結束:

  1. 目前剖析位置的換行是否顯著nl 堆疊;括號內、引數列表內不顯著)。
  2. 前綴是否已完整。例如 return 後面直接換行就是無值 return, 但運算子、逗號、.::-> 之後的換行不終止敘述, 行首是 .:: 時也視為延續。

internal/parser/parser.gocontinues() 是唯一的判斷點。

型別與符號

  • ast.Type 有七種:原生、類別(含型別引數)、陣列、型別變數、萬用字元、null、error。
  • 泛型在 sema.erasure 抹除;執行期只知道類別,不知道型別引數。
  • 多載解析(pickOverload)走 JLS 的三個階段(嚴格、允許 boxing、可變參數), 第一個找得到適用候選的階段就決定,只有同一階段內才比轉換成本(完全相同 0、 拓寬/上轉 1、boxing 2、unboxing 3;boxing 之後還要上轉時是 3)。
  • 方法的 vtable 槽位在 layout() 決定:由父類複製,覆寫者沿用同一槽位; 介面方法另有全域唯一的 selector(Selector),供 itable 使用;每個類別的介面表是 稀疏的,只放它自己實作得出來的 selector,依 selector 排序,ty_itab 掃過這幾個 項目再往父類別找。(密集表一格一個指標、格數等於整個程式的 selector 總數,一個類別 不論實作幾個都要付這筆 .data,hello world 就因此背了 792 KB。)

產生 C 的關鍵對應

TeyruC
類別 Foostruct C_Foo { tyobj obj; ... }(欄位依 InstFields 平鋪,含繼承)
實例方法M_<class>_<name>_<idx>(C_Foo* this, ...)
虛擬呼叫this->obj.cls->vtable[slot](...)
介面呼叫ty_itab(obj, selector)(...)
new Foo(...)GNU 敘述運算式:配置 → 設 cls → 呼叫建構子
陣列tyarr { tyobj; len; data; esize; refs; elemcls },元素內嵌
字串常數靜態 tystr(不經 GC)
try/catchtycatch + setjmp/longjmp
property 讀寫降階成 getter/setter 呼叫(sema.Props 記錄)
for (a : b : c)C 的 whilea 先跑一次,每圈重測 bc 放在圈尾,continue 跳到圈尾的標籤
記錄 Point(int x,int y)struct + 建構子 + x()/y() + toString/hashCode/equals
enum 常數靜態欄位,於 <clinit> 建立並填入 ordinal/name

效能設計

產生的 C 由 clang/LLVM 以 -O2 加 LTO 編譯(--no-lto 可關閉;不支援 LTO 的 工具鏈會自動退回),跨函式 inline、常數傳播與迴圈向量化都由 LLVM 負責。在此之上, 編譯器與執行期刻意讓熱路徑保持單一指令層級:

機制位置說明
行內配置tyrt.hstatic inline ty_alloc指標碰撞(bump pointer)路徑完全內聯,只有區塊用盡或超過 GC 門檻才呼叫 ty_alloc_slow
行內邊界檢查codegen.boundCheck檢查以敘述運算式內聯在取用點,每個索引都產生一次比較(常數索引也一樣,sema 不先摺疊,化簡留給 LLVM)
常數折疊codegen.foldBinaryident字面值運算與字串相加在編譯期算完;static final 常數是把值替換進去,外層的算式留給 LLVM
死 chunk 回收tyrt.c 的 sweep一個 chunk 內若沒有任何存活物件就整塊 free 還給系統,之後的回收不再走它;仍在使用的 chunk 則每個區塊都要走過(計數存活一次、標記或釋放一次),所以單次回收的成本與保留的記憶體量成正比,而不是與存活量成正比
字串常數codegen.strLit字串字面值是靜態 tystr,不經配置、不進 GC
類別初始化codegen.clinitStmt惰性初始化,但旗標由產生的程式碼自己測;繼承鏈上沒有靜態初始化區塊的類別不會有 <clinit> 函式(slot 是 NULL),main 也不會點名它——點名等於在 main 裡取它的位址,而一個位址就足以讓連結期最佳化把整個類別連同它的 vtable、介面表與所有方法保留在執行檔裡
逃逸分析codegen.escape.go不離開所在方法的物件放在 C 堆疊上,LLVM 得以提升欄位並刪除物件
原生互通codegen.native.gonative 方法的 C 符號與宣告由編譯器產生(--native-header

逃逸分析(escape.go)只提升同時滿足兩個條件的區域物件:宣告型別與 new 的類別 完全相同(sameCreatedClass),且該類別有編譯器配置好的 struct 與可直接呼叫的 建構子(promotable)。判定方式是走訪方法內的每一處使用(escWalk):把物件當接收者 讀原生欄位、讀「不可能裝得下這個物件」的參考欄位、寫入物件自己的欄位(含 c.next = c)、instanceof、以及呼叫「接收者不會外流」的方法算安全。只要參考本身 被當成值用掉(當引數、==!=、轉型、賦值給別的變數)、被存進別的物件或陣列、 被回傳/拋出/yield、被 lambda 或方法參考或匿名類別捕獲,或走到分析未涵蓋的節點, 就留在堆積。方法是否會讓接收者外流是對呼叫圖做的傳遞分析(leaksThis):native 方法假設不會,沒有主體的方法假設會,分析中遇到環路也視為會。被提升的物件本身不在 chunk 裡(valid_obj 會拒絕它的位址),但它的參考欄位就在 C 堆疊上,保守的原生堆疊 掃描因此仍看得到它指向的堆積物件。

已知的效能邊界:逃逸分析只涵蓋留在方法內的物件;真正上堆積的物件仍走保守式 標記清除(無分代假設),在「物件長期存活、反覆回收」的負載上 HotSpot 仍可能 勝出。目前的五項 benchmark 都快於 JVM;重現方式見 sh scripts/bench.sh

垃圾回收

  • 保守式標記清除。物件不搬移,所以 C 端的暫存指標永遠有效。
  • 根:shadow stack(ty_rootsty_sp)、以 ty_gc_register_static 註冊的靜態欄位 位址表、以及原生堆疊的保守掃描(起點為當前堆疊指標,終點為執行緒堆疊頂端, 由 pthread_getattr_np 取得)。堆疊上的字不保證是物件,所以每個候選位址都要通過 valid_obj:必須 16 位元組對齊、必須是某個 chunk 裡某個區塊的開頭(starts 位元圖每次回收前重建,落在區塊內部的位址一律不算),而且不能是已釋放的區塊。
  • 標記:tyclass.refoffs 列出每個類別需要追蹤的參考欄位位移;陣列用 refs 旗標。
  • 清除:未標記的區塊進入大小分級的 free list(TY_NCLASS 級,過大的走 bigfree), 下一次配置優先重用;區塊大小字(header 第一個字)的最高位 TY_FREE_BIT 表示已釋放, free list 的鏈結就放在第二個字。完全空掉的 chunk 直接 free 還給系統,所以長時間 執行的程式不會一直佔住尖峰記憶體,但頭 chunk 例外:它承載 bump 指標,永遠保留。
  • 觸發:配置量超過 ty_gc_threshold 這一個條件。門檻初始 4 MB,每次回收後設為存活量 的兩倍,最低不低於 4 MB。chunk 用完不是回收的理由——free list 沒有可用的區塊時 就直接長一個新 chunk,因為門檻還沒到就回收只是白白重掃一次活著的物件。
  • 已知代價:每次回收都要掃描整個使用中的堆疊,且沒有分代假設;清除階段還要走過每個 保留 chunk 的每個區塊。

例外

ty_cur_catch 是一條 handler 鏈。throw 呼叫 ty_throw,後者 longjmp 到最近的 handler;沒有 handler 時印出訊息並以狀態 1 結束。

finally 有兩條路徑,缺一不可:

  1. 例外路徑try 外層包一個 handler,setjmp 回來後先跑 finally, 再把例外重拋。catch 區塊內再拋出時也走同一條路。
  2. 正常離開路徑returnbreakcontinue 不會經過 longjmp, 所以程式碼產生器維護一個 finally 堆疊(Emitter.finallys),在每個 跳躍敘述前先跑完被離開的 finally(由內而外),最後才跳。 try-with-resources 的 close() 是同一個機制的隱含 finally, 因此資源在 return 與例外兩條路徑上都會關閉。

區域與匿名類別捕獲的區域變數會變成合成類別的欄位(Class.CapFields), 由建構子或 closure 建立運算式填入;這讓「方法參考的接收者」也只在建立時求值一次。

本頁內容