← 返回 Siami 首頁

"編譯器太聰明的悲劇":微軟 x86 模擬器團隊看見 256KB 程式碼只為清空 64KB 記憶體

▲ 482 💬 159
"編譯器太聰明的悲劇":微軟 x86 模擬器團隊看見 256KB 程式碼只為清空 64KB 記憶體

編按:本文綜合整理自 Raymond Chen 微軟 DevBlogs 原文Hacker News 討論串,並加入 Siami 編輯部觀點與分析。

256KB 程式碼只為清空 64KB 記憶體

微軟 DevBlogs 的 Raymond Chen(也就是The Old New Thing那個經典部落格的作者)這次講的是一段他同事口述、年代久遠的「x86 模擬器戰爭故事」。

故事的時空背景是 Windows 還在跑非 x86 原生處理器的年代——Raymond 沒有明說是哪顆 CPU,但微軟當年的硬體名單大家都猜得到:Alpha、MIPS、PowerPC、Itanium 等等,那是一段「Windows NT 跑在各種 RISC 架構上」的歷史。

那個年代 Windows 內建一個 x86-32 的處理器模擬器,讓原生跑別顆 CPU 的機器也能跑 x86 軟體。這個模擬器不是用直譯器(interpreter)逐行翻譯,而是用二進位轉譯(binary translation)——直接把 x86 指令即時編譯(JIT)成原生指令。


然後團隊撞見一段非常生氣的程式碼

某支程式需要在 stack 上配置 64KB 的緩衝區並做初始化。正常編譯器會這樣做:

  • 先做 stack probe 確認 64KB 空間存在
  • 把 stack pointer 減 65536
  • 用一個緊湊的迴圈逐 byte 初始化

但寫這段程式的那個編譯器(Raymond 沒明說是哪一家)覺得「用迴圈太遜了」——它決定把迴圈整個展開(loop unrolling),生成 65,536 條獨立的「寫一個 byte 到記憶體」指令,每條指令 4 bytes。

結果:256KB 的程式碼,只為了初始化 64KB 的資料。

這在當年是很真實的痛點:loop unrolling 是 90 年代編譯器優化教科書的標準技巧,但把 buffer-fill 迴圈展開成 65,536 個 MOV 指令,是教科書不會教、編譯器作者喝了幾杯才寫得出的奇葩產物。


模擬器團隊的反擊:把「爛程式碼」即時改掉

Raymond 的同事當然不是坐視不管。模擬器團隊被這段 256KB 的「展開迴圈」激怒到一個臨界點——他們決定在 binary translator 裡面加特殊規則

「如果偵測到這段展開的爛迴圈,就直接把它換成等價的緊湊迴圈。」

也就是說,模擬器不再只是「忠實執行 x86」,而是變成「x86 編譯器的糾錯器」——它會在 runtime 把上游編譯器的離譜決定逆轉成有效率的版本。

這不是個案。Raymond 提到「This has happened many times」,意味著那支模擬器團隊長期在做**「接手爛程式碼、把它變快」**的服務。

為什麼這件事重要

這則故事表面上是懷舊趣談,實際上折射出三個至今仍然真實的系統軟體難題:

  1. 「優化」是雙面刃:loop unrolling、inlining、template metaprogramming 這些編譯器優化技巧,在某些 input pattern 下會變成「程式碼膨脹」甚至「效能倒退」。現代 LLVM 跟 GCC 都有開關可以關掉 unrolling(-fno-unroll-loops),就是為了這類情境。
  2. 平台團隊被迫變成「產品 QA」:當微軟的模擬器團隊要開始「修」上游編譯器的 bug,這代表商業平台公司(不是 compiler 團隊)正在承擔 compiler 生態系的後果。這跟 Apple Silicon 早期 Rosetta 2 主動幫 Intel binary「修補」、Chromebook ARC++ 主動優化 Android app 的邏輯完全相同。
  3. 「年代相容性」是看不見的成本:Raymond 自己也說「This has happened many times」。模擬器團隊沒有刪掉那些「壞特例」就直接出貨,意思是今天我們在 Apple Silicon 上跑 Intel binary、或是 Windows 11 跑 1995 年的 DOS 程式,背後都有一堆「當年不得不做的髮夾彎」在撐。

數據解讀

指標數字對比
原始程式碼大小256 KB為了清空 64 KB
展開後指令數65,536 條每條 4 bytes
等效迴圈大小約 16 bytesxor + rep stosb
程式碼膨脹比16,000 倍教科書級的「優化失敗」案例

質疑:Raymond 自己也說「don’t know which processor this particular story applied to」——這個故事可能經過多年的口耳相傳,數字略有誇大或失真。但**核心現象(編譯器把迴圈展開成災難)**在現代編譯器生態裡仍然存在,2024 年 LLVM mailing list 還有針對「過度 unrolling 導致 i-cache miss」的討論。這則故事作為「系統思維」的教材價值,遠高於它作為單一歷史事件的可考據性。

延伸案例:HN 社群補完的「同類災難」

這則故事在 HN 拿下 482 分、159 個留言,留言區串起了一整個世代工程師的共同創傷

  • 90 年代 Mac 顯示卡架構師:PageMaker 主動 invalidate 整個視窗卻不通知 redraw,迫使硬體團隊登門「拜訪」軟體團隊請求改寫。
  • 68K Mac emulator 開發者:模擬器最難的不是讓舊程式跑起來,是讓它正確地慢下來——年代久遠的 polling loop 跟 CPU-burning 迴圈才是真正的鬼故事。
  • Excel 內部:為了下相容 1990 年代的怪招,寧可保留最糟的「特例」也不動——這是現代 Office 肥大、難以重寫的根本原因之一。
  • GTA Online:十幾分鐘的開機讀取,官方擺爛多年,最後是熱心玩家逆向工程出社群版修補工具。

這四個案例橫跨 1990 到 2020 年代,核心問題都一樣:商業軟體長期演進後累積的「技術債」沒有清理路徑,使用者、開發者、甚至平台團隊都被迫接手。Raymond 講的是編譯器產出垃圾碼,HN 留言區講的是整個產業的「編譯器產出垃圾碼」心態。

還沒解決的問題

這個故事沒有真的「被解決」——它只是被轉移

  • 編譯器升級 ≠ 程式碼重生:即使現代 LLVM 不會再展開 65,536 條 MOV 指令,那些 1990 年代編譯出來的 binary 仍然躺在使用者的硬碟裡,被模擬器一個一個救回來。
  • 平台團隊的「隱形工時」:Raymond 沒有明說,但這暗示微軟模擬器團隊有一套完整的「客戶端程式碼品質資料庫」,專門記錄哪些常見軟體會觸發哪些特殊路徑。這種隱性成本很難量化,但它是商業平台公司的護城河之一。
  • AI 程式碼生成會讓這更嚴重:當 LLM 開始大量生成程式碼,模擬器團隊要處理的「奇葩輸出」只會越來越多。Raymond 沒明說的故事裡,犯人好歹是「編譯器」——LLM 連編譯器都沒有,直接吐出「能跑就好」的字串。

延伸閱讀:Raymond Chen《The Old New Thing》部落格 累積了 20 年的 Windows 工程文化側寫,是少數能讓你看到「商業系統軟體決策背後真實原因」的一手來源。HN 討論串 裡的留言則補完了整個 90 到 20 年代的同類案例。

網友熱門留言 (5)

#1 Hacker News 用戶 ▲ 287
這故事讓我回想起十五年前我在開發一套『隨選下載遊戲』技術的往事。某款遊戲在掛上我們的 OS hook 之後速度奇慢無比,追了三週才發現是它內部有個『把所有遊戲資源一次塞進 2GB 連續記憶體』的動作。寫那程式的人大概不知道分頁是怎麼運作的。
#2 Hacker News 用戶 ▲ 412
我九零年代在 Mac 做顯示卡架構師。我們家的晶片明明是市面上最快的,但有些程式硬是跑不快——原因是 PageMaker 會主動 invalidate 整個視窗,但從不主動通知 redraw。逼得我們的工程師要登門拜訪『拜託你們別這樣寫』。
#3 Hacker News 用戶 ▲ 356
這是一個恐怖但又完全合理的 Excel 內部運作洞察。Microsoft Office 為了向下相容那些 1990 年代的怪招,往往寧可保留最糟的『特例』也不動它。
#4 Hacker News 用戶 ▲ 198
我寫 68K Mac emulator 跑在現代硬體上時,效能瓶頸反而不是『速度』本身——是那些會把 CPU 燒到 100% 的空轉迴圈,跟年代久遠、依賴硬體時序的 polling loop。模擬器最難的不是讓舊程式跑起來,是讓它『正確地慢』。
#5 Hacker News 用戶 ▲ 234
讓我想到 GTA Online 幾年前那個『社群 patch』的故事。十幾分鐘的開機讀取,官方擺爛了好幾年,最後是熱心玩家逆向工程出一個社群版修補工具。這種『使用者替官方 debug』的文化在大型軟體裡其實屢見不鮮。