一顆 64-bit 的 Windows-shaped 核心,編成 WebAssembly,在瀏覽器分頁裡開機到命令提示字元。它之所以能成,是因為那台 128 MiB 的機器抵達提示字元時,32,768 個 page 裡只有 1,052 個被寫過——整份快照 gzip 之後只剩 901 KB。
瀏覽器分頁裡開機的 Windows 核心:nanokrnl 與 67 KB 的 nanox
Matt Suiche 把一顆叫 nanokrnl 的核心,連同一個叫 nanox 的 x86-64 模擬器,一起塞進瀏覽器分頁。核心編成 WebAssembly,模擬器也編成 WebAssembly,兩者湊在一起,一台虛擬的 64-bit 機器就在分頁裡跑到命令提示字元。這顆核心的來歷不太尋常——作者形容它是「an NT-shaped kernel in Rust that Fable 5 took from an empty directory to a booting system」,一顆 Rust 寫的、NT 形狀的核心,由 Fable 5 從一個空目錄一路帶到能開機。這是一個系列的第一篇,作者說整篇談的是那個 artifact 本身:「nanokrnl, the kernel, and nanox, the emulator we wrote to run it in a browser」。底下拆的是這台機器怎麼組起來——為什麼現成的瀏覽器模擬器都不能用、nanox 砍掉了什麼、兩條開機路各自做了什麼、那份 901 KB 快照的格式,以及怎麼確定這顆手寫的 CPU 是對的。
為什麼現成的瀏覽器模擬器都不能用
nanokrnl 是一顆貨真價實的 64-bit kernel。作者把規格列得很清楚,它跑在 x86-64 long mode 底下:「4-level paging, syscall/sysret, swapgs, a local APIC, interrupts delivered through a real IDT」——4 級分頁、syscall/sysret、swapgs、一個 local APIC,中斷走真正的 IDT。這一串聽起來像在描述一台真的 x86-64 機器,問題正好出在這裡:要在瀏覽器裡跑它,你得先有一台能跑 64-bit long mode 的機器。
現成的答案是 v86,一個 pure-JS 的 x86 模擬器,很多「在瀏覽器裡跑 Linux」的 demo 都靠它。作者試過,然後放棄,理由只有一句話但份量夠重:「v86 is a 32-bit-era emulator. Its long-mode support is incomplete to the point that it cannot deliver a general-protection fault through the IDT, and a 64-bit kernel dies on its first fault, before it can even print that it is alive.」v86 是 32-bit 時代的產物,它的 long-mode 支援殘缺到一個地步——連一個 general-protection fault 都沒辦法透過 IDT 遞送給核心。而一顆 64-bit 核心開機的頭幾步幾乎一定會踩到 fault:設定分頁、探測硬體、跑 self-test,任何一步出錯都要靠 fault 機制接手。fault 一旦無法遞送,核心在它還來不及印出第一行字之前就已經死了。
值得把「dies on its first fault」講得更具體一點。一個 general-protection fault 是處理器在偵測到違規操作時丟出的例外,處理器會查 IDT,跳到核心註冊好的處理常式,讓核心決定怎麼辦。這是保護機制的骨幹,也是核心跟硬體之間最基本的一條回饋線。如果模擬器沒辦法把這個例外照規矩遞送出去,那條回饋線就斷了:核心以為自己下了一道指令、其實什麼都沒發生,或者處理器該攔的沒攔。對一顆需要在開機早期反覆設定與探測的核心來說,這種斷線不是偶爾出點小錯,而是整個控制流從一開始就對不上。所以問題的層級不在效能,而在正確性——模擬器連「把錯誤如實報回來」都做不到。
作者把這件事收斂成一句設計判斷:「nanokrnl is a 64-bit kernel. That single fact eliminated the obvious browser options.」64-bit 這個單一事實,砍掉了所有現成選項。剩下的路只有一條——自己寫一個模擬器。這不是「現成方案略慢、我們優化一下」,而是「現成方案在功能上根本到不了第一個 fault」,兩者差別是能不能開機,不是快不快。從 32-bit 到 64-bit 的落差在這裡不是漸進的,一個只支援到 protected mode 的模擬器,缺的不是幾個暫存器,而是 long mode 整套語意,包含 fault 遞送這種開機必經的路徑。
nanox:砍到只剩能跑這顆核心
於是有了 nanox。作者的描述是「a bespoke x86-64 emulator in Rust that compiles to a single WebAssembly module」——用 Rust 寫、編成單一 WebAssembly module 的專用 x86-64 模擬器。整個 module 是 67,074 bytes,作者說大約六十五 KB。這個尺寸值得停一下:一個能跑 64-bit long mode、能把 fault 經 IDT 遞送、能撐起一顆 Windows-shaped 核心的 CPU 模擬器,編出來比很多前端頁面隨手放的一張圖還小。
小的原因是 nanox 只做必要的事,它不假裝自己是一台完整的 PC。作者寫得很直接:「nanox does not emulate a PC from the reset vector. There is no BIOS, no real-mode bring-up, no A20 gate.」真的機器開機要從 reset vector 開始,經過 BIOS、real-mode 起手、A20 gate 那一整套 1980 年代留下來的儀式,才慢慢爬進 protected mode、再進 long mode。nanox 把這一整段全部跳過——核心 image 直接載進模擬 RAM,機器一開始就已經在 long mode。它模擬的是「跑這顆核心會用到的那部分 x86-64」,不是「一台 IBM PC 相容機的全部歷史」。省下來的不只是程式碼行數,更是那些真實硬體才有、但這顆核心根本不會碰的邊角狀態。
「只模擬會用到的」這個決定,同時解掉了兩個問題。第一個是尺寸:把 real-mode、BIOS 呼叫、legacy 硬體那些路徑全砍掉,剩下的核心指令集與 long mode 語意,剛好塞得進一個六十五 KB 的 WebAssembly module。第二個是正確性:模擬面越小,要驗證正確的表面積就越小。一個號稱模擬整台 PC 的東西,得對一大堆幾乎沒人再用的角落負責;而 nanox 只要保證「這顆核心真的會執行到的那些指令與狀態轉移」是對的。編成單一 WebAssembly module 還有個現實好處——它就是一個可以被瀏覽器直接載入、直接跑的東西,不需要外掛、不需要伺服器端配合,整個模擬器連同它要跑的核心,都在分頁那一個沙盒裡。
MMU 是這種「只做必要」的一個具體例子。nanox 的 MMU 跟核心的 page-table walker 都認得大頁:「Both nanox's MMU and the kernel's page-table walker are large-page aware.」一次位址翻譯本來要一層一層走下去,作者說走法會短路:「The walk short-circuits at a 1 GiB leaf in the PDPT or a 2 MiB leaf in the PD」。如果某段位址用 1 GiB 大頁映射,翻譯走到 PDPT 那層就結束,不必再往下;用 2 MiB 大頁,就在 PD 那層收尾。少走一兩層,對開機期間那種大量而規律的位址翻譯來說,省下的是實打實的指令數——而指令數,稍後會看到,正是冷開機的成本單位。
| 層級 | 一般走法的下一步 | 能否短路成葉(大頁) |
|---|---|---|
| PML4 | 往下走到 PDPT | 否 |
| PDPT | 往下走到 PD | 可——1 GiB 大頁在此收尾 |
| PD | 往下走到 PT | 可——2 MiB 大頁在此收尾 |
| PT | 取得 4 KiB 葉頁 | 本身就是葉 |
兩條到命令提示字元的路
機器有了,怎麼到命令提示字元?nanox 給了兩條路。
第一條是冷開機。作者的描述很機械:「nanox loads the kernel image into emulated RAM, enters long mode, and starts interpreting.」載入核心 image、進 long mode、開始直譯指令,接著就是一長串 self-test 捲過螢幕。到達提示字元的代價是「about one hundred million interpreted instructions」——大約一億條被直譯的指令。這是每一台真的機器開機時都在做的事,只是在這裡,每一條指令都由 nanox 一條一條用軟體解釋,所以你會實際感覺到它在跑,那一億條不是抽象的數字,而是螢幕上真的要捲完的過程。
第二條是快開機,它繞過那一億條指令。它不重跑開機,而是還原一份預先拍好的快照。瀏覽器抓一個 901 KB 的檔案,用瀏覽器內建的 DecompressionStream API 就地解壓——作者的用詞是「gunzips it in-place with the DecompressionStream API」——把 bytes 交給 nanox,呼叫 restore。暫存器回填、非零的 page 放回原位,機器直接出現在「開機已經跑完」的那個狀態,命令提示字元立刻就在那裡。一億條指令對上一次 restore,這就是 nanokrnl.ai 上那兩顆按鈕背後的差別。
這兩條路不是紙上談兵。作者說打開 nanokrnl.ai,那台機器就會走到命令提示字元,頁面上兩顆按鈕各對應一條路,原始碼也放在 GitHub 上。也就是說,冷開機那一億條指令、快開機那次 restore,都是你可以在自己瀏覽器裡按下去、當場看它跑的東西,而不是一段只能讀的描述。
兩條路到達的是同一個終點狀態,差別只在「怎麼到」。冷開機把整個過程重演一遍,好處是它不依賴任何預先準備的東西,壞處是那一億條指令的成本每次都要付一遍。快開機把那次演算的結果冷凍起來,之後每次開機都直接解凍——代價是你得先有一份拍好的快照,而快照只對「拍它的那一刻的那個狀態」有效。這其實是很多系統都會做的取捨:把一段昂貴而確定的計算,換成一份可以重複載入的狀態。放在瀏覽器分頁這個場景裡,它的意義特別直白:使用者不會想每開一次頁面就等一億條指令捲完,而 901 KB 的下載與一次 restore,快到可以當成「頁面載入」的一部分。
drag the divider to wipe between cold boot and fast boot · same axis to the prompt
同一條「power on → 命令提示字元」的軸,兩種走法
冷開機要一條一條直譯大約一億條指令才走到命令提示字元;快開機直接還原 901 KB 快照裡的 1,052 頁,整段開機過程被跳過。
快照:只寫下非零的那 1,052 頁
快照能小到 901 KB,靠的是一個關於記憶體的觀察。作者寫:「the machine has 128 MiB of RAM, which is 32,768 pages of 4 KiB. But most of those pages were never touched.」128 MiB 的 RAM 切成 4 KiB 一頁,總共 32,768 頁;但大多數頁從來沒被碰過。到達提示字元的那一刻,真正被寫過的頁少得驚人:「exactly 1,052 are nonzero at the prompt. Everything else, 96.8 percent of the address space, was never written.」剛好 1,052 頁是非零的,其餘 96.8% 的位址空間,從頭到尾沒被寫過一個 byte。
快照格式順著這個觀察來設計,它不 dump 整個 128 MiB,只寫非零的頁:「The snapshot walks memory a page at a time and writes out only the pages that contain a nonzero byte, each prefixed with its page index.」逐頁掃過記憶體,只寫出含有非零 byte 的頁,每一頁前面掛上它的 page index。零頁直接省略——「Zero pages are simply omitted, and on restore the RAM is zeroed first and then the saved pages are dropped back into place.」還原時先把整片 RAM 清零,再把存下來的頁按 index 放回原位。這個設計的漂亮之處在於:省略零頁是免費的,因為還原端的預設就是零,你不需要記錄「這裡是空的」,只要不記錄它就好。
於是尺寸這樣算下來:1,052 頁乘以 4 KiB,是 4,308,992 bytes 的實際資料;加上每頁的 page index、再加上 624 bytes 的 CPU 與 device 狀態,整份 raw 快照是 4,313,828 bytes,大約 4.11 MiB。gzip 之後剩 922,305 bytes,大約 901 KB,壓縮比約 4.7x。作者特別點出這個 4.7 倍的來源:「because even the touched pages are sparse」——連被寫過的頁裡面也大半是空的。稀疏在兩個層次上同時成立:整個位址空間稀疏,只有 1,052 頁非零;而單頁內部也稀疏,所以 gzip 在只剩非零頁的資料上,還能再擠掉 4.7 倍。下面這張圖用兩種看法呈現同一份快照——按頁數看,它幾乎是空的;按位元組看,它可以被壓得很扁。兩種看法指向同一件事。
換個角度想,這個數字其實在講一件關於「一台剛開好機的機器到底在記憶體裡放了什麼」的事。名目上它有 128 MiB 位址空間,但一顆核心走到命令提示字元、還沒開始做任何實際工作時,真正被填進東西的地方少得可憐——只有那 1,052 頁。剩下的位址空間是被保留、被規劃、但還沒被寫進任何內容的區域。它們在位址表上存在,在物理意義上卻是一片零。快照設計等於承認了這個事實,然後只為那片「真的有東西」的部分付儲存成本。這也是為什麼同樣一台機器,你若用「配置了多少位址空間」去估快照大小,會得到一個離譜地偏大的數字;只有用「實際寫過幾頁」去估,才會落在對的量級上。整份格式的巧思,說到底就是拒絕為那片零付一分錢。
這裡有個容易被忽略、但正是整份設計省下最多空間的關鍵:省略零頁之所以幾乎不花代價,是因為「還原時 RAM 先清零」這個前提。換句話說,零頁不是被壓縮掉的,而是根本沒被記錄——快照只需要說「哪些頁有內容、內容是什麼」,「哪些頁是空的」這件事由還原端的預設值自己補上。這跟一般想像的「壓縮」不同:壓縮還是要處理每一個 byte,只是把它變短;而稀疏序列化是連碰都不碰那 96.8% 的位址空間。真正被 gzip 處理的,只有那 4.11 MiB 的非零資料;4.7 倍是在這個已經瘦過一輪的基礎上再擠出來的。兩種節省疊在一起,才有從 128 MiB 名目位址空間到 901 KB 下載的落差。
switch view · 2 lenses on one snapshot · page census / bytes
拿真的 oracle 逐指令對照
一顆手寫的 x86-64 模擬器,最怕的不是跑不動,而是跑得動但算錯。某條指令的 flag 設錯、某個進位漏掉,核心不會當場崩,而是走到很後面才以一種難以回溯的方式壞掉——你看到的是一億條指令之後的一個奇怪畫面,而不是出錯的那一條。nanox 對付這件事的方法是 differential testing,拿真的 oracle 逐一對照。
作者用了兩個 oracle。一個是 iced-x86,當「decode-length oracle」——它是成熟的 x86 解碼器,用來確認 nanox 把每一條指令的長度切對了;x86 指令長度不定,只要切錯一個 byte,後面整串就全錯位。另一個是 Unicorn,也就是 QEMU 的 CPU core,當「a semantics oracle over random states」——餵進大量隨機的 CPU 狀態,比對 nanox 跟 Unicorn 執行之後的結果一不一樣。一個管「指令的邊界對不對」,一個管「指令做的事對不對」,兩條軸分開驗。
除了隨機狀態,還有逐指令重放:「a lockstep harness that replays the genuine execution of a real program instruction by instruction and diffs the post-state」。一個 lockstep harness 把一個真實程式的執行過程,一條指令一條指令地重放,每跑完一條就 diff 一次執行後的狀態。哪一條指令讓 nanox 跟 oracle 分岔,當場就抓得到,而不是等核心在一百萬條指令之後莫名其妙地死掉。這是把「模擬器對不對」這個模糊的大問題,拆成「這一條指令對不對」這種可以當場判定的小問題。
這三道對照分工很清楚,可以對著它們各自防守的失效模式來看。隨機狀態測試的價值在於覆蓋那些人不會特地想到的組合——各種 flag、邊界值、少見的定址方式湊在一起,靠亂數去踩;它抓的是「某個指令在某種罕見輸入下行為不對」。lockstep 重放剛好相反,它跟的是一段真實程式真的走過的路徑,抓的是「在真實使用中會被觸發、但隨機測試未必生得出來」的序列相依錯誤。而 iced-x86 那道解碼長度的對照更前面:如果連指令從哪裡到哪裡都切錯,後面談語意就沒意義了。一顆要拿去跑作業系統核心的模擬器,靠的不是「我覺得寫對了」,而是這種可以重複、可以自動化、出錯能定位到單一指令的驗證。
| 對照對象 | 角色 | 抓什麼錯 |
|---|---|---|
| iced-x86 | decode-length oracle | 指令長度切錯——x86 指令長度不定,錯一個 byte 後面全錯位 |
| Unicorn | semantics oracle(QEMU 的 CPU core) | 在隨機 CPU 狀態下,執行後結果與 nanox 不一致 |
| lockstep harness | 逐指令重放真實程式 | 哪一條指令讓 post-state 分岔,當場 diff 出來 |
把上面這些湊起來——一個跳過 BIOS、認得大頁的 67,074-byte 模擬器,一份只存 1,052 頁的 901 KB 快照,一套逐指令對照過的 CPU——就得到一台能在瀏覽器分頁裡開機的虛擬機。這幾塊拼圖各自並不神秘,稀疏快照是老技巧,differential testing 也是驗 CPU 的常規做法,專用模擬器更是為了跑特定 workload 才寫的;真正少見的是把它們兜在一起、對準「在一個瀏覽器分頁裡開一顆 64-bit Windows 核心」這個目標。而它整個跑起來有多大,作者給了一個會讓人愣一下的對照——不是拿現代標準比,而是拿三十年前的 Windows 95 比。
What this enables:整台機器「fits in about 4 megabytes, which is roughly the 1995 minimum for Windows 95」——一台 1995 年最低配 Windows 95 的機器,如今裝進一個瀏覽器分頁、一個 901 KB 的下載;其中約 1.60 MiB 是核心本身,剩下的兩 megabytes 半是「the page tables, the kernel's pool and stacks, and the live user-mode processes」。作者說這只是系列第一篇,後面幾篇會深入這篇只是「gestured at」的部分,屆時再看這台四 megabytes 的機器還能長出什麼。