字型檔裡那段 bytecode 不是資料,是程式——它會驅動一台直譯器,跑在 CoreText 的字型算繪路徑上,輸入來自 PDF 與網頁裡誰都能塞進來的字型。Apple 把這台直譯器從 C 整段改寫成 memory-safe 的 Swift,還讓它平均比原本的 C 版本快 13%。
把 TrueType hinting 直譯器從 C 搬進 Swift——熱路徑上的記憶體安全賭注
這是一段「把熱路徑上的不安全 C 換成安全語言」的遷移記錄——主角是 TrueType 的 hinting 直譯器,作者是 Apple Security Team 的 Scott Perry,文章發於 2026 年 6 月 12 日。值得記下的不是「Swift 又贏了 C」這種口號,而是三件具體的事:為什麼這段 C 值得換、團隊用什麼定義「換對了」、以及在一個會被每秒呼叫無數次的迴圈裡,Swift 預設的記憶體安全開銷是怎麼被一條一條磨掉的。
TrueType 的 hinting 是字型作者寫的一小段程式,告訴算繪器在低解析度下該怎麼微調每個輪廓控制點,讓字在小尺寸下依然清晰。按 Apple 的說法,這段 bytecode「processes data from untrusted sources, making the TrueType hinting interpreter a security-critical attack surface」——字型嵌在 PDF、嵌在網頁,輸入來自全世界。先把這台直譯器在整條算繪管線裡的位置擺清楚。
那台 C 直譯器為什麼是非換不可的攻擊面
把一段 C 換掉的決定通常很難——它能跑、它很快、它穩定了很多年。Apple 給的理由不是「C 不夠現代」,而是這段 C 的形狀。原文的描述是:「This interpreter involves input-driven control flow, complex data structures, and careful memory management—exactly the kind of code that's hard to make perfect and where memory errors are easier to exploit.」三個性質疊在一起,每一個單獨都棘手,合起來就是記憶體安全漏洞最肥沃的土壤。
input-driven 是關鍵。一般程式的控制流由你自己的邏輯決定,攻擊者頂多餵壞資料;hinting 直譯器的控制流本身就是輸入——bytecode 說跳就跳、說呼叫就呼叫、說讀某個堆疊位置就讀。攻擊者不是在「填表格」,而是在「寫程式」,而這支程式跑在你解析任何一份含字型的 PDF、載入任何一個用了 web font 的網頁時。攻擊面不是某個 RPC endpoint,是「打開一份文件」這個再日常不過的動作。
再把這個攻擊面的兩個性質分開看,會更清楚它為什麼難。一個是資料結構複雜——hinting 直譯器內部有自己的堆疊、指令表、狀態暫存,bytecode 在這些結構上跳來跳去,任何一處邊界沒守住,外部輸入就能讀寫到不該碰的記憶體。另一個是手工記憶體管理——在 C 裡,誰配置、誰釋放、什麼時候釋放,全靠人盯著,一個 use-after-free 或 off-by-one 在這種 input-driven 的環境裡不是會不會被觸發的問題,是攻擊者主動構造輸入去觸發的問題。Apple 把這三件事——input-driven 控制流、複雜資料結構、手工記憶體管理——並列稱為「exactly the kind of code that's hard to make perfect」,正是因為它們各自的難處會互相放大,不是相加而是相乘。
這條路徑同時是延遲敏感的,這點決定了後面所有工程取捨。它跑在 CoreText 的字型算繪路徑上,使用者每打開一份含字型的 PDF、每載入一個用 web font 的網頁,這台直譯器就被叫起來,對畫面上每個 glyph、每個控制點反覆運算。它不是後台批次任務,是擋在「文字什麼時候出現在螢幕上」前面的一段同步程式——慢一點,使用者就看得到。也因此「換語言但不能變慢」不是錦上添花的要求,而是這段程式存在的前提:一個讓字遲遲畫不出來的安全版本,在這條路徑上是不能上線的。
把延遲敏感這件事再講透一點,才知道後面的工程為什麼那麼難。一般談記憶體安全,會先接受「安全要付一點效能代價」這個前提,再去論證那點代價值得。但在這條路徑上,這個前提本身就不成立——它擋在文字出現於螢幕之前,是同步的、會被反覆呼叫的一段程式,多出來的每一點延遲都會直接累積在使用者每天的操作體感裡。換句話說,這次重寫的限制不是「安全與效能之間取捨」,而是「安全與效能都不能輸」。一個慢的安全版本,在這條路徑上等於沒做;一個快但不安全的版本,正是他們要離開的起點。兩端都被釘死,可走的空間就只剩中間那條窄縫。
所以這個專案的目標被定得很窄也很硬。Apple 說:「To make the format more resilient on Apple platforms, we rewrote its hinting interpreter from C to memory-safe Swift.」不是重新設計字型格式,不是換一套 hinting 演算法,是把同一台直譯器原地換成記憶體安全的語言。難處從來不在於「能不能用 Swift 寫出來」,而在於下面兩個約束:它不能變慢,也不能變得跟原本不一樣。先看時間軸怎麼鋪開。
拖曳把手沿時間軸前後看每個階段的狀態 · 五個節點
把「正確」定義成逐位元相同,再用四千份語料對拍
重寫一個直譯器最危險的地方不是寫錯,是寫得「差不多對」。字型 hinting 的輸出是亞像素級的控制點座標,一個 rounding 差異就會讓某些字在某些尺寸下糊掉,而這種差異不會 crash、不會報錯,只會在某位使用者的某個視窗裡安靜地壞掉。所以團隊把「正確」的標準訂死:「For this project, we defined correctness to mean exact compatibility with the C implementation's outputs.」不是「視覺上看不出差別」,是逐位元相同。
這個標準背後是一條更硬的約束:「Binary compatibility was crucial for this project to succeed: existing programs had to continue to function the same as they did before.」直譯器不是孤立的函式,是無數既有程式透過 CoreText 間接依賴的東西。任何「我覺得這樣更好」的改動——更圓潤的 rounding、更聰明的邊界處理——只要讓輸出偏離 C 版本,對某個下游就是 regression。重寫的自由度被壓到幾乎為零:你只能換語言,不能換行為。
驗證這件事的力氣,遠超過重寫本身。團隊建了兩套測試,其中一套 unit test「can target both implementations, providing exhaustive (99.7%) code coverage」——同一份測試同時打 C 與 Swift 兩個實作,直接比對輸出。光是覆蓋率還不夠保證面對真實字型不出錯,於是再上 fuzzer:「We used a fuzzer to minimize a corpus of 10 million PDF files down to 4,200」,把一千萬份 PDF 壓成 4,200 份能涵蓋直譯器各種路徑的代表語料,再拿這批語料跨兩個實作比對「27 million glyphs」的輸出。
這兩套測試各自回答不同的問題。一套是能同時打兩個實作的 unit test,達 99.7% 程式碼覆蓋——它保證的是 Swift 直譯器的每一條程式路徑都被走過、跟 C 比對過,幾乎沒有從沒被測到就上線的角落。但覆蓋率高不等於面對真實世界的字型不會出錯:unit test 餵的是人寫得出來的輸入,真實 PDF 裡的字型 bytecode 千奇百怪,會走到的組合遠超人能手寫的數量。所以另一套靠 fuzzer 補上:先讓 fuzzer 把一千萬份 PDF 的語料壓成 4,200 份——這 4,200 份不是隨機抽樣,而是經過 minimize 後仍能涵蓋直譯器各種執行路徑的代表集——再拿這批語料跨兩個實作比對 2,700 萬個 glyph 的輸出,任何一個 glyph 對不上就是 bug。
為什麼要走到 2,700 萬個 glyph 這種規模,回到 exact compatibility 這個定義就懂了。如果「正確」只是「視覺上看不出差別」,抽幾百個字檢查就夠了;但定義是逐位元相同,意味著任何一個 glyph 在任何一份字型、任何一個尺寸下都不能差一個 bit,而能證明這件事的唯一辦法就是大量對拍——把舊版當作 oracle,新版逐一比對到你願意相信兩者等價為止。這也是為什麼驗證的工程量會壓過重寫本身。
這個 fuzzer 的角色值得單獨說。它不是上線後守在前面擋惡意輸入的那種 fuzzer,而是被拿來做語料壓縮——把一千萬份 PDF 縮到 4,200 份。會用到一千萬這種量級,本身就說明了真實世界字型 bytecode 的分佈有多散:直譯器內部那些 input-driven 的分支與資料結構組合,靠人手寫測試永遠補不齊,只能用海量真實檔案去打、再讓 fuzzer 留下那批仍能涵蓋各條路徑的代表檔。壓到 4,200 份之後,這批語料就成了一個可以反覆跑、跑得完的對拍基準——一千萬份每次全跑不現實,4,200 份卻能把 2,700 萬個 glyph 的逐位元比對變成可以日常執行的事。語料壓縮在這裡不是省時間的小技巧,而是把「面對真實字型是否等價」這個問題,從不可驗證壓成可驗證的關鍵一步。
這裡有個容易被略過、但其實是整個專案立足點的數字:「We wrote nearly four times as many lines of test code as we wrote for the Swift interpreter itself.」測試碼的行數接近直譯器本身的四倍。對一個「輸出必須跟舊版逐位元相同、又跑在安全敏感路徑上」的重寫來說,這個比例不是過度謹慎,是把「我們真的等價嗎」這個問題用工程量回答掉——沒有這層對拍,「memory-safe」只是換了個會被別種方式咬人的實作。合理的推測是,正因為重寫的自由度被 binary compatibility 壓到只能換語言、不能換行為,這四倍的測試碼才成了把「等價」這件事釘死的代價——把不確定性從「跑起來看看」搬到了「先證明再上線」。
熱迴圈裡的安全稅,是怎麼一條一條被磨掉的
Swift 的記憶體安全不是免費的。預設的 reference counting(ARC)會在物件的生命週期邊界插入 retain/release;bounds checking 會在每次陣列存取前驗證索引;跨越 C 互操邊界時,資料常常要從 C 的 struct 複製進 Swift 的型別。這些在一般程式裡是可以忽略的常數開銷,但 hinting 直譯器是個會對每個 glyph、每個控制點反覆執行的熱迴圈——常數開銷乘上迭代次數,就成了會輸給 C 的那 ⅹ%。下面這個模擬把這件事攤開:同一段 glyph 工作量,三種實作各自累積的相對成本。
縱軸是相對累積成本(以 C 的單位成本為基準),橫軸是處理過的 glyph 數
安全稅消掉後 Swift 反超 C,平均快 13%。
第一條稅是 ARC。原文把這條 runtime overhead 的對策講得很具體——團隊採用了「~Copyable value types」與「Span」來消掉 automatic reference counting 的成本。`~Copyable`(noncopyable)的值型別讓編譯器知道這個值不會被共享、不需要 retain/release 簿記;`Span` 提供對連續記憶體的安全存取,不必把資料包進會觸發 ARC 的 reference 型別。換句話說,安全是靠型別系統在編譯期保證的,而不是靠執行期一次次數 reference count——稅在編譯時就被免掉了。
為什麼 ARC 在這裡特別痛,得看它在熱迴圈裡的放大效應。retain/release 單看一次只是一對原子操作,代價很小;但它掛在物件生命週期的邊界上,一個值在迴圈裡被傳遞、被存進結構、被取出來用,每一次共享都可能觸發一次計數。直譯器對每個 glyph、每個控制點反覆執行,這些原子操作的次數跟著迭代次數線性放大——單次便宜的東西乘上夠大的次數,就成了拉開與 C 差距的那一截。`~Copyable` 的關鍵在於它從型別層面斷掉「這個值會被共享」的可能:編譯器一旦知道值不可複製、不會有第二個持有者,retain/release 的簿記就整個沒有存在的理由,不是被優化掉,是從一開始就不需要。`Span` 則讓直譯器能安全地走訪一段連續記憶體——這正是邊界檢查該守住的地方——而不必把那段記憶體升級成一個會被 ARC 追蹤的物件。
把 ARC 這條稅再放大一點看,會發現它跟邊界檢查其實是同一類問題的兩面:都是 Swift 為了在執行期維持某種保證而插進來的工作。ARC 維持的是「物件還有人持有嗎」這個生命週期不變式,邊界檢查維持的是「這次存取沒越界」這個安全不變式。在一般程式裡,這兩種執行期工作分攤到整支程式上幾乎察覺不到;但在一個對每個 glyph、每個控制點都要走一遍的迴圈裡,它們被迭代次數放大成可量測的差距。~Copyable 與 Span 之所以是對的工具,是因為它們處理問題的方式不是把這些檢查做得更快,而是讓編譯器在編譯期就確定這些不變式成立、於是執行期那份簿記與驗證從一開始就不必存在——值既然不可複製,就不會有第二個持有者要追蹤;走訪連續記憶體既然透過 Span,邊界資訊編譯器就握在手上。安全沒有被放棄,只是被搬到了不收稅的那一側。
第二條稅是跨 C 邊界的資料複製。原文記了一個有具體數字的轉折:一開始的做法是「copying the glyph's data from its C struct into Swift」,這帶來大約 20% 的 overhead;後來換成「projection types that provide safe access to the underlying C structure」。projection 的意思是不搬資料——Swift 型別直接「投影」到底層 C struct 的記憶體上,提供安全的存取介面,但不複製。對一個每 glyph 都要碰 C struct 的迴圈,省下這趟複製就是省下那 20%。
拖曳中線比較「複製進 Swift」與「projection 投影」兩種跨邊界存取
互動圖表
改用 projection 投影到底層記憶體、不複製,省掉每 glyph 約 20% 的複製。
第三條稅是短命的堆積配置。直譯器的內部運算常需要一小塊暫時的工作空間——若每次都在 heap 上配一塊再釋放,又是一筆會在熱迴圈裡放大的成本。原文的對策是改成 continuation-passing:「a continuation-passing approach where the caller passes a block that can operate on a slice of stack elements.」呼叫者傳進一個 block,這個 block 在一段位於 stack 上的元素切片上運算——工作空間活在 stack 上、生命週期就是那個 block 的執行期間,不碰 heap、不需要釋放。
第四條稅是泛型帶來的 dynamic dispatch。為了讓程式碼通用,很容易把東西寫成泛型,但泛型若沒被特化,每次呼叫都要走一層間接的 dynamic dispatch。團隊的處理是讓最佳化器「specialize all of our generic contexts」——把所有泛型情境都特化成具體型別的版本,間接呼叫被攤平成直接呼叫。值得留意的是這裡的措辭是「所有」泛型情境:只要有一處沒被特化,那層間接呼叫就會留在熱迴圈裡持續收稅,所以這不是「優化了一部分」,而是把整條路徑上的 dynamic dispatch 清乾淨——而 specialization 攤平成直接呼叫之後,inline 與其他常規最佳化才有施展空間,省下的不只是那一跳間接。
第三條跟第四條稅其實指向同一個道理:在熱迴圈裡,便宜的東西也會變貴。短命的 heap 配置,單看一次 malloc/free 不算什麼,但直譯器內部的暫時工作空間若每次運算都配一塊、用完再還,這對配置與釋放就跟著每個 glyph 重複,累積起來就是一筆固定稅。continuation-passing 的巧妙在於它換掉的不是配置的速度,而是配置這件事本身——工作空間改放在 stack 上,生命週期就是傳進來那個 block 的執行期間,block 一返回空間自然消失,沒有 heap 配置、也沒有對應的釋放與其潛在的生命週期錯誤。dynamic dispatch 同理:一次間接呼叫的成本很低,但它擋在每個 glyph 的路徑上,而且間接呼叫會擋住 inline——編譯器看不穿那一跳,就沒辦法把被呼叫的程式攤平進來做進一步最佳化。特化把間接變直接,省下的因此不只是那一跳,而是它後面被連帶解鎖的一整串常規最佳化。
把四條稅排在一起看,會浮現一個共同的形狀。ARC、跨邊界複製、heap 配置、dynamic dispatch,每一條單看都只是一筆小成本,但它們都掛在「每個 glyph 都會走一遍」的熱迴圈上,被迭代次數一致地放大;而消除它們的手法也都同一個調性——不是把演算法改聰明,而是換上一個能讓編譯器在編譯期就確定「這裡不需要那筆執行期成本」的 Swift 低階建構(noncopyable、projection、stack slice、specialization)。安全的代價之所以付得起,是因為型別系統把它從執行期挪到了編譯期。下面這張表把四條稅與對策並排。
點欄位標題排序 · 4 列 × 3 欄
| 熱迴圈裡的稅 | Swift 的對策 | 為什麼省得掉 |
|---|---|---|
| ARC retain/release | ~Copyable value types + Span | 值不共享、無需 reference count 簿記;安全在編譯期由型別系統保證 |
| 跨 C 邊界複製資料 | projection types | 初版整份複製 C struct 帶來約 20% overhead;改成投影到底層記憶體、不複製 |
| 短命的 heap 配置 | continuation-passing + stack slice | 呼叫者傳 block 在 stack 元素切片上運算,工作空間不碰 heap |
| 泛型的 dynamic dispatch | specialize all generic contexts | 最佳化器把泛型情境特化成具體版本,間接呼叫攤平成直接呼叫 |
13% 更快、零回報 bug,還開源成參考實作
把四條稅磨掉之後,結果不是「跟 C 打平」,是反超。原文的數字是:「On average, the Swift interpreter runs 13% faster than the C interpreter it replaced.」一個重寫成記憶體安全語言的版本,平均比它取代的 C 還快 13%——這跟「安全一定要付效能代價」的直覺相反。原因在於 Swift 的型別系統讓編譯器掌握了更多關於值的生命週期與別名(aliasing)的資訊,這些資訊在 C 裡是隱性的、編譯器只能保守處理;資訊更多,最佳化空間就更大。
這個「反而更快」值得多想一層,因為它把一個常見的直覺整個翻過來。C 之所以被當成效能的天花板,是因為它貼著機器、幾乎不替你做任何事;但「不替你做事」的另一面是「不替你記錄任何事」——在 C 裡,一個指標會不會被別名、一個值的生命週期到哪結束,編譯器多半看不出來,只能保守地假設最壞情況,於是錯失很多本來可以做的最佳化。Swift 的型別系統反過來把這些資訊變成顯性的:~Copyable 宣告了值不會被共享,projection 宣告了存取的是哪塊底層記憶體,特化後的泛型把型別釘成具體的。這些原本要付執行期成本去保證的安全性質,在編譯期就成了編譯器手上的已知條件——它知道得越多,能放心做的最佳化就越多。13% 這個數字不是「安全剛好沒拖慢」,而是「把安全資訊餵給編譯器」這件事本身產生的收益。
安全的部分也不是「完全沒有 unsafe」。原文誠實地寫:「The Swift interpreter includes a small number of thoroughly verified unsafe statements at the language interop boundary; there have been no bugs reported against it since it was enabled.」unsafe 沒有被消滅,而是被收斂到語言互操邊界那一小撮、經過徹底審查的敘述裡——這才是 memory-safe rewrite 務實的樣子:不是宣稱零 unsafe,而是把 unsafe 從散佈全身收成一個可以盯著看的小邊界。它在 2025 年秋季於 Apple 平台啟用,到文章發表為止沒有對它回報過任何 bug。
「收斂到小邊界」這個動作的價值,要對照前面那台 C 直譯器才看得清。在 C 版本裡,記憶體安全是整段程式都要靠人盯著的性質——任何一處邊界沒守好都可能被 untrusted 輸入利用,攻擊面散佈在每一行。改寫之後,編譯期保證的記憶體安全把絕大多數程式碼從這份盯防清單裡拿掉,剩下真正需要 unsafe 的只有語言互操邊界那一小撮,而且是經過徹底審查的那種。審查的對象從「整台直譯器」縮小到「一小撮敘述」,能投入的審查強度自然能集中。啟用至今對它零回報 bug,這個結果不該被讀成「Swift 不會有 bug」,而是「需要靠人保證安全的程式面積,被縮到了一個盯得過來的規模」——這正是 memory-safe rewrite 真正交付的東西。
最後一步是把成果公開。Apple 把這份 Swift hinting 直譯器的原始碼放上 GitHub,定位是「production code, intended as a reference implementation」——不是教學玩具,是正在跑的產品碼,同時當作別人要做類似遷移時的參考。對一個長期被視為「只能用 C/C++ 寫」的領域——字型、編解碼、解析器這類直接吃 untrusted bytes 的熱路徑——這份程式碼是一個可以逐行讀的存在證明:安全語言不只能進這個領域,還能在效能上贏。
對正在維護某段「會吃 untrusted 輸入、跑在熱路徑、用 C 寫了很多年」的程式的人,這個案子給的不是「快去用 Swift」,而是一套可複製的方法:先把 correctness 定義成與舊版逐位元相同、用對拍語料把等價性壓到可驗證;再把 Swift 預設的安全稅逐條定位(ARC、跨邊界複製、heap 配置、dynamic dispatch),用 noncopyable、projection、stack slice、specialization 一條條消掉;把 unsafe 收斂到一個審查得起的小邊界。
The lesson:當「正確」被定義成與舊版逐位元相同、安全稅被逐條定位消除、unsafe 被收進可審查的小邊界時,把熱路徑上的 C 換成安全語言不必是效能的退讓——這次甚至快了 13%。