vatt'ghern jaskier's ballads

WWDC 一張 slide 說 Apple 開始用 Swift 寫核心作業系統的 kernel,於是有人去 grep macOS 27 的 kernelcache——真的撈到了 Swift。撈到的是一個 37 個符號的 Embedded Swift runtime,被靜態鏈進 pthread 這個 kernel extension、開機時載入、反組譯後確認是會動的真貨。然後問題來了:掃遍整個 kernelcache 的 370 個 fileset entry,沒有任何一個元件呼叫它。

核心裡的 Swift——還沒人呼叫,卻已鏈進去

WWDC 上 Devon Maloney 貼出一張 slide,說 Apple 從 27 版本起「starting writing parts of the core operating system kernel in Swift」。對逆向 Apple 系統的人來說,這句話太誘人,不可能放著不查。Josh Maine 在他 6 月 18 日的這篇文章裡寫得很直接——「Naturally I dropped what I was doing and went grepping through the iOS 27 kernelcache.」這就是整篇調查的 hook:一個官方說法觸發了一次硬碰硬的二進位逆向,而逆向得到的結論,比那句 slide 上的話精確得多,也奇怪得多。

奇怪的點在於:Swift 確實在 kernel 裡,但它在那裡什麼都不做。它被鏈進去、開機載進記憶體、佔了符號表的位置,卻沒有任何一段核心程式碼會去呼叫它。原文用一句話把這個狀態釘死——「The runtime is linked, loaded at boot, and idle.」這篇值得拆開來讀的地方,不在「Apple 用 Swift 改寫了 kernel」這個會被誤傳的標題,而在作者怎麼用符號表、反組譯、fileset 交叉引用、DWARF 統計這四種手法,一步步把「鏈進去了」「是真 runtime」「但沒人呼叫」「核心仍是純 C/C++」這四件事各自證實,最後拼出一個「就位待命」的形狀。

要看懂為什麼這個狀態值得寫一整篇,得先分清楚「Swift」在這裡指的是哪一個 Swift。一般 app 跑的是完整 Swift runtime——它假設底下有 libc、有 Foundation、有作業系統的記憶體配置器與執行緒模型可用,runtime 本身相當厚重。kernel 裡不能用這一套:核心程式碼跑在沒有 userland 那層服務的環境,不能依賴一個假設「外面有完整作業系統」的 runtime。撈到的這份是 Embedded Swift,是 Swift 為了能在 kernel、firmware 這種裸環境跑而裁剪出的子集——拿掉了 reflection 與需要動態配置的 metadata,縮到只剩記憶體管理與少數核心原語。換句話說,Apple 放進 kernel 的不是「把 app 那套搬進來」,而是一個專門為這種約束環境設計的精簡 runtime。這個區分很重要:它說明這不是隨手鏈錯了一個 userland library,而是有人特意挑了能在 kernel 裡活下來的那個 Swift 變體,編譯好放進去。

謎題:Swift 在 kernel 裡,卻沒有任何呼叫者

先把謎題的形狀講精確。一個 runtime 出現在 kernelcache,直覺的解讀是「有東西開始用它了」。但 idle 這個詞同時否定了兩種更省事的可能:它不是被誤鏈進來的死碼(死碼不會在開機時被載入、也不會帶完整 runtime entry point),也不是已經有人在用的活躍元件(活躍元件會有 import 它的呼叫者)。它卡在中間——一個完整、就緒、卻零呼叫者的 runtime。要證明這個中間狀態,光看「有 Swift 符號」不夠,得反過來證明「沒有任何外部呼叫者」,而這是一個關於整個 kernelcache 的全稱命題,得掃過所有 fileset entry 才能下。

作者用的就是這個笨辦法:把整個 kernelcache 的每一個 fileset entry 都翻一遍,看有沒有誰引用了那些 swift_* 符號。原文寫得很清楚——「I scanned all 370 macOS kernelcache fileset entries for `swift_*` references anywhere else, i.e. some other kext that links against this runtime.」用的指令是 ipsw macho search mkc -m '^_swift_',在 merged kernelcache 裡找開頭是 _swift_ 的符號引用。結果是:除了 pthread 自己,「not a single other component in the kernelcache imports them」。370 個 entry,0 個外部 importer。下面這個 widget 把這次掃描的形狀畫出來——你可以自己把游標掃過全部 370 個 entry,看 importer 數始終貼在 0。

拖動掃描游標走過全部 370 個 fileset entry · 看 swift_* 外部 importer 數

#185 / 370
kernelcache fileset entry(共 370 個) 匯入 swift_* 的外部元件數 2 1 0 com.apple.kec.pthread 掃描游標 pthread 自己定義 swift_*(非外部呼叫)
x 軸是 kernelcache 裡的 370 個 fileset entry,y 軸是匯入那批 swift_* 符號的外部元件數。掃過整條軸,外部 importer 數恆為 0——只有 pthread 自己(圖中綠點,落在定義端而非呼叫端)出現 swift_* 符號。數值錨點(370 個 entry、0 外部 importer、pthread 為唯一持有者)來自原文;曲線即原文「not a single other component in the kernelcache imports them」的可視化。

把這張圖讀進去,謎題就成形了。如果 importer 那條線在某個 entry 跳起來,故事就會變成「Apple 已經開始在某個 kext 裡用 Swift」;但它從頭到尾貼在零,只有 pthread 這一格在「定義端」亮著綠燈。一個有人定義、沒人呼叫的 runtime——這正是 idle 的精確意思,也是接下來每一步逆向要鞏固的核心觀察。

這裡值得停下來想一下「掃過 370 個 entry 都是零」這個結果,在邏輯上有多強、又有多脆。它強在它是一個全稱命題的直接驗證:不是抽樣幾個 kext 沒看到,而是把整個 kernelcache 的每一格都翻過,所以「沒人呼叫」這句話不是「我還沒找到」,而是「在這份 kernelcache 裡就是沒有」。但它的限制也得誠實標出——這只證明了「這一版 kernelcache 的這 370 個 entry 沒有外部 importer」,不保證 Apple 內部的開發版沒有,也不保證下一個 beta 不會冒出來。原文那句「not a single other component in the kernelcache imports them」是對一個具體 artifact 下的全稱判斷,不是對「Swift 永遠不會被呼叫」的預言。把這兩者分清楚,後面「這是漸進部署的第一步」那個推論才站得住——正因為現在是零、而零是可被將來推翻的,這個 idle 狀態才會是一個有時效、可追蹤的訊號。

第一個假設:那會不會只是個沒實作的 stub?

看到符號表裡有一排 swift_* 名字,第一個該懷疑的是:這些會不會只是占位用的空殼?符號存在不代表函式有內容——linker 有時會留下未實作的 weak symbol,或是 header 帶進來、實際 body 被 strip 掉的殘骸。如果是這樣,那這篇文章就只是「Apple 不小心把幾個 Swift 符號名洩進 kernelcache」,沒什麼好說。要排除這個假設,得反組譯,看符號背後到底有沒有真的指令。

作者挑了 swift_release 來看,因為它是 Swift 記憶體管理最核心的一段:減少物件的 reference count,歸零就釋放。反組譯出來的不是空殼。原文的描述是——它是「a real atomic refcount decrement with a `brk #1` underflow trap and a call to `_swift_embedded_invoke_heap_object_destroy` at zero.」三個細節各自有意義:atomic 的遞減代表它設計成多執行緒安全;brk #1 的 underflow trap 代表它對「refcount 減成負數」這種記憶體錯誤會主動斷掉,是防禦性的真實實作;歸零時呼叫 _swift_embedded_invoke_heap_object_destroy 代表完整的釋放路徑都接上了。一個 stub 不會長這樣。下面這張圖把這段反組譯的三個關鍵動作拆開標註。

_swift_release pthread.kext · Embedded Swift runtime atomic refcount decrement 把物件的 reference count 原子遞減——多執行緒安全的真實實作,不是空殼。 brk #1 underflow trap refcount 若被減成負數就觸發中斷——防禦性設計,是會動的程式碼才需要的保護。 refcount 歸零 → call _swift_embedded_invoke_heap_object_destroy 歸零時走完整的釋放路徑——destroy 鉤子接上,runtime 的生命週期是閉合的。
三個動作全部來自原文對 swift_release 反組譯的描述:原子遞減、brk #1 underflow trap、歸零呼叫 destroy。任何一個 stub 都不會同時具備這三者——這就是「真 runtime,不是占位符」的證據。

所以第一個假設被否掉:這不是 stub。runtime 是活的、會跑的、自洽的。可是這恰恰讓謎題更尖銳——一個被仔細實作、會做原子操作、會處理 underflow、會正確釋放物件的 runtime,竟然沒有任何呼叫者。它不是半成品,是成品;只是還沒插電。要注意的是,反組譯到此為止只回答了「它是不是真的」這一個問題:它能確認這段碼會正確執行,但完全不能告訴你有沒有人會去執行它。「會動」與「有人用」是兩件獨立的事——一個寫得無懈可擊的函式,照樣可以一次都沒被呼叫。這就是為什麼還得換一種完全不同的手法(掃 fileset 找呼叫者)才能把謎題的另一半補上。

四種證據各自回答什麼:一份逆向方法論

到這裡值得停下來看作者整套手法的結構,因為這篇之所以可信,靠的不是單一證據,而是四種彼此獨立的逆向方法,各自回答一個不同的子問題。符號表回答「裡面有什麼」,反組譯回答「它是不是真的」,fileset 交叉引用回答「有沒有人用」,DWARF 統計回答「核心本體變了沒」。四個問題的答案疊起來,才撐得起「鏈進去、是真貨、沒人用、核心沒動」這個完整結論。下面這個 widget 把這四種方法各自的輸入、產出與它釘死的結論攤開——任何一種單獨拿出來都不足以下結論,是它們的交集才精確。

四種逆向方法,各釘一個結論

四種逆向方法,各釘一個結論——交集才是完整論斷 符號表 · 裡面有什麼 pthread.kext 匯出 37 個 swift_* → 對得上 EmbeddedRuntime nm / macho 反組譯 · 是不是真的 swift_release = atomic 遞減 + brk #1 trap + 歸零呼叫 destroy disasm fileset 交叉引用 · 有沒有人用 370 個 entry 全掃過 → 0 個外部 importer ipsw search DWARF 統計 · 核心動了沒 2,106 compile unit:1,855 C11 / 249 C++14 / zero Swift dSYM

點任一方法看它各自回答的子問題(桌面點圖層,手機點卡片)

符號表 · 裡面有什麼

pthread.kext 匯出 37 個 swift_* 符號,含 swift_retain、swift_release、swift_once、swift_dynamicCast 等,與開源 Swift repo 的 EmbeddedRuntime 實作對得上。這一步只證明「存在」,不證明它會動,也不證明有人用。

反組譯 · 是不是真的

swift_release 反組譯後是真的 atomic refcount 遞減、帶 brk #1 underflow trap、歸零時呼叫 _swift_embedded_invoke_heap_object_destroy。這排除了「只是空殼 stub」的可能,但仍不回答有沒有呼叫者。

fileset 交叉引用 · 有沒有人用

用 ipsw macho search 掃過全部 370 個 macOS kernelcache fileset entry 找 swift_* 的外部引用,結果除了 pthread 自己,沒有任何其他元件匯入。這是「idle」的直接證據——一個全稱命題,得掃全部 entry 才下得了。

DWARF 統計 · 核心動了沒

KDK 的 kernel symbol file 有 2,106 個 DWARF compile unit,細分為 1,855 個 DW_LANG_C11、249 個 DW_LANG_C_plus_plus_14、2 個 Mips assembler、zero DW_LANG_Swift。XNU 核心本體(Mach、BSD、IOKit)一行 Swift 都沒有。

這四格的順序本身就是論證的順序。先有「存在」,才需要問「真假」;確認是真的,才值得問「有沒有人用」;確認沒人用,才有意義去問「那核心到底動了沒」。少掉任何一格,結論都會鬆掉——只有符號表會被質疑是 stub,只有反組譯不知道有沒有呼叫者,只有 fileset 掃描不知道核心本體如何。是交集,不是任一單點。

核心本體沒動:2,106 個 compile unit 裡 zero Swift

四種方法裡,DWARF 統計這一步最容易被忽略,卻是反駁「Apple 改寫了 kernel」這個誤讀的關鍵。Swift 出現在 pthread、Libm 這些 kernel extension 裡,跟「核心本體被改寫」是兩件完全不同的事。前者是在 kext 這個外圍層放東西,後者才是動 Mach、BSD、IOKit 這些 XNU 的骨架。作者去翻 KDK(Kernel Development Kit)裡那份帶除錯資訊的 kernel symbol file,數它的 DWARF compile unit——每個 compile unit 帶一個語言標記,加總起來就是整個核心本體的語言組成。

數出來是「2,106 DWARF compile units」,細分為「1,855 `DW_LANG_C11`, 249 `DW_LANG_C_plus_plus_14`, 2 `DW_LANG_Mips_Assembler`, and zero `DW_LANG_Swift`」。zero。核心本體一個 Swift compile unit 都沒有。下面這張圖把這 2,106 個 unit 的語言比例畫成一條長條——Swift 那一段的寬度,是零。

kernel.release dSYM · 2,106 個 DWARF compile unit 的語言組成 C11 · 1,855 C++14 DW_LANG_C11 ......... 1,855 DW_LANG_C_plus_plus_14 .. 249 DW_LANG_Mips_Assembler ... 2 DW_LANG_Swift ........... 0
2,106 個 compile unit,Swift 那一段的寬度是零——圖中只剩一個虛線空框標出它的缺席。數字(2,106 / 1,855 / 249 / 2 / 0)逐一來自原文 DWARF 統計;長條寬度按比例。Mach、BSD、IOKit 都落在這 2,106 個 C/C++ unit 裡。

這裡得把 kext 與核心本體的分界講清楚,否則「Swift 在 kernel 裡」這句話會把兩個層級混為一談。XNU 的核心本體是 Mach(排程、IPC、虛擬記憶體這些最底層的機制)、BSD(檔案系統、網路、系統呼叫介面)與 IOKit(驅動程式框架)三大塊,它們構成作業系統真正不可替換的骨架。pthread、Libm 這些 kernel extension 則是掛在這個骨架外圍的元件——它們跑在 kernel 的位址空間裡沒錯,但它們是「可被替換、可被重編」的零件,動它們跟動 Mach 是兩種完全不同量級的事。Swift 出現在 pthread.kext 裡,是有人在外圍零件上做實驗;只有當 swift compile unit 開始出現在那 2,106 個 unit 裡,才代表骨架本身被碰了——而 DWARF 統計量的就是骨架那一層,答案是一個 unit 都沒動。

這條長條把 idle 的另一半補齊了:不只 pthread 裡的 Swift 沒人呼叫,整個核心骨架根本沒有一行 Swift。前面那句被到處轉述的「Apple 用 Swift 寫 kernel」,到了二進位這一層只剩下一個被精確限定的事實——Swift 進到了 kext 層,核心本體仍是 1,855 個 C11 加 249 個 C++14 的純 C/C++。誤讀與事實之間的距離,恰好是這張圖裡 Swift 那一段為零的寬度。

就位的證據:Libm 重建、六個平台 ID、一個對上的 UUID

如果故事停在「有個 idle runtime」,它只是一個有趣的異常。讓它變成「就位待命」的,是另外三條旁證,每一條都指向「基礎設施正在被有計畫地鋪設」,而不是某個誤鏈的意外。

第一條是 Libm。math library 被以 KernelKit SDK 重新編譯為 Libm_kernelkit,帶「65 symbols of math: `_cbrt`, `__sincos_stret`, `__ceilf16`」這類符號與 float16 intrinsics。為什麼 kernel 突然需要一份 KernelKit 版的數學庫?作者給的是一個帶保留的推測——「presumably so Swift's `Double`/`Float` operations have something to link against.」他用了「presumably」這個字:合理的推測是 Swift 的浮點運算需要 libm 撐著,所以 Apple 先把 libm 鋪進去,但這是推論、不是他能從 binary 直接證實的事。若成立,這跟 Swift runtime 會是同一個動作的兩面:把未來會用到的東西,提前準備好。

第二條是平台 ID。作者去翻 Xcode 27 linker 的 Platform.cpp,列舉出六個全新的 KernelKit 平台常數:macOS-kernelKit(25)、iOS-kernelKit(26)、tvOS-kernelKit(27)、watchOS-kernelKit(28)、visionOS-kernelKit(29)、bridgeOS-kernelKit(30)。六個平台、ID 連號 25 到 30——這不是給某一個產品線臨時加的東西,是給整條 Apple 平台家族都在 linker 表裡預留了 KernelKit 編譯目標。但作者對這份清單帶了個誠實的限定:六個常數雖然都定義好了,真正在出貨二進位裡現身的只有兩個——「25 and 26 are the only ones I've seen in shipping binaries so far.」也就是只有 macOS(25)和 iOS(26)他在 shipping 二進位裡實際看到,其餘四個目前還只是表裡的預留位。

第三條是把 KernelKit 二進位與 kernelcache 接起來的 UUID。光看到 /System/KernelKit/ 底下有個獨立編譯的 pthread,還不能斷定它就是 kernelcache 裡那一個——它們可能是兩份不同的東西。作者用 Mach-O 的 UUID 來確認:那個 pthread 的 UUID 是 F44A1FAB-1F9C-3E38-9C8B-1B238A61939C,同時出現在 /System/KernelKit/.../pthread 與 kernelcache 的 fileset entry com.apple.kec.pthread。UUID 是 Mach-O 的內容指紋,兩邊一致,就證明 KernelKit 那個獨立編譯的 pthread 確實被 prelink 進了 kernelcache。下面這張圖把 pthread.kext 這個 Mach-O 攤開——同一個二進位裡同時住著 thread 系統呼叫這些既有的 C 符號、那 37 個就位待命的 swift_* 符號,以及兩端一致的那個 UUID。

com.apple.kec.pthread Mach-O · pthread.kext · 一個二進位,兩種命運的符號 LC_UUID F44A1FAB-1F9C-3E38-9C8B-1B238A61939C = /System/KernelKit/.../pthread 的 UUID 符號表(節錄) 既有 C 符號 · 有呼叫者、在運作 _pthread_create _pthread_mutex_lock _pthread_cond_wait _pthread_self _pthread_kill ↑ kernel 各處都在呼叫 swift_* 符號(37 個)· 就位待命、零呼叫者 _swift_retain _swift_release _swift_once _swift_dynamicCast _swift_allocEmptyBox … 共 37 個 ↑ 370 個 entry 掃過,0 個呼叫
pthread.kext 同一個 Mach-O 裡的兩欄符號:左欄是既有、在運作的 thread C 符號;右欄是 37 個就位待命、零外部呼叫者的 swift_*(符號名取自原文清單)。最上方的 LC_UUID 與 /System/KernelKit 端一致,證明這個二進位被 prelink 進 kernelcache。UUID、37 這個數字、符號名與「0 個呼叫」都來自原文。

三條旁證疊起來,「意外」這個解釋就站不住了——有人在鋪 SDK、鋪數學庫、鋪平台定義、把成品 prelink 進核心,這是計畫,不是巧合。

結論:Swift 來了,但是以 KernelKit 元件來,不是改寫 Mach

把所有證據收攏,作者讀出的是一個 rollout 順序。他用的措辭帶著明確的 hedge——「That reads to me like the rollout order is: ship the SDK and the runtime first, watch it not break anything for a beta cycle or two, then start landing actual Swift kernel components.」這是一個合理的推測,不是已證實的事實:先把 SDK 與 runtime 上線、讓它在 production 二進位裡跑過一兩個 beta cycle、確認不破壞任何既有路徑,再開始 land 真正會被呼叫的 Swift kernel 元件。idle runtime 不是 bug,是這個漸進部署的第一步——成品先就位,插電留待後續。

這裡要把「二進位證明的」與「作者推論的」這條線劃清楚,因為這篇之所以可信,正在於它沒有把後者偽裝成前者。二進位能直接證明的,是四件事實:runtime 被靜態鏈進 pthread、反組譯確認它會動、370 個 entry 裡沒有外部呼叫者、核心本體 zero Swift。這四件都是可以重跑指令驗證的硬事實。但「為什麼會這樣」——也就是 rollout 順序、「先讓它跑過 beta 確認不出事」這個意圖——是作者從這些事實往回推的動機解釋,原文用「reads to me like」明白地把它標成個人讀法。一個 idle runtime 在邏輯上其實還容得下別的解釋——也許是某個功能被砍了一半。作者的漸進部署說之所以最有力,靠的是那三條旁證:有人同步在鋪 SDK、數學庫、平台 ID,這個「整套配套都在備齊」的模式,才讓「故意先就位、等將來插電」勝過「半途而廢」。但勝過不等於唯一,這層 hedge 必須保留:binary 證明的是現狀,意圖永遠是推論。

對 iOS 還多一層觀察:作者寫「iOS gets the platform plumbing now and the runtime later.」iOS 先拿到平台定義這層水管,runtime 之後才補上——平台家族裡不同產品線的進度本來就不必同步。這也解釋了為什麼一個「沒人呼叫」的 runtime 值得專門寫一篇逆向:它是一個可觀測的訊號。對追蹤 OS 演化的人來說,當哪天 kernelcache 裡開始冒出 import 這些 swift_* 的新 kext,import 那條線從零跳起來,就是 Apple 真的開始把 Swift land 進 kernel 的那一刻——而在那之前,idle runtime 就是它在倒數的證據。

逆向方法論:當官方一句話(「用 Swift 寫 kernel」)與二進位事實對不上時,別停在符號表——符號證明存在,反組譯證明真假,fileset 交叉引用證明有沒有呼叫者,DWARF 統計證明核心本體動了沒;四種方法的交集,才把模糊的標題收斂成一句精確的話——「Whatever Swift is coming, it's coming as KernelKit components, not as a rewrite of Mach.」