QEMU.js:現在認真並使用 WASM

曾幾何時,我決定為了好玩 證明過程的可逆性 並學習如何從機器碼產生 JavaScript(更準確地說,Asm.js)。 選擇 QEMU 進行實驗,一段時間後寫了一篇關於 Habr 的文章。 在評論中有人建議我在 WebAssembly 中重新製作該項目,甚至我自己退出了 就快結束了 不知怎的,我不想要這個專案......工作正在進行,但非常緩慢,現在,最近在那篇文章中出現 評論 主題“那麼這一切是如何結束的?” 針對我的詳細回答,我聽到「這聽起來像一篇文章」。 嗯,如果可以的話,會有一篇文章。 也許有人會發現它很有用。 從中,讀者將了解有關 QEMU 程式碼產生後端設計的一些事實,以及如何為 Web 應用程式編寫即時編譯器。

任務

由於我已經學會如何「以某種方式」將 QEMU 移植到 JavaScript,所以這次決定明智地去做,而不是重複舊錯誤。

錯誤一:從點發布分支

我的第一個錯誤是從上游版本 2.4.1 分叉出我的版本。 然後在我看來這是一個好主意:如果點發布存在,那麼它可能比簡單的 2.4 更穩定,分支更是如此 master。 由於我計劃添加大量自己的錯誤,因此我根本不需要其他人的錯誤。 結果大概就是這樣。 但事情是這樣的:QEMU 並沒有停滯不前,在某些時候他們甚至宣布將生成的程式碼優化 10%。「是的,現在我要凍結了,」我想,然後崩潰了。 這裡我們需要說一句題外話:由於QEMU.js的單線程特性,並且原始QEMU並不意味著沒有多線程(即能夠同時操作幾個不相關的程式碼路徑,並且不僅僅是“使用所有核心”)對此至關重要,我必須「將其轉出」線程的主要功能才能從外部呼叫。 這在合併過程中自然產生了一些問題。 然而,事實上,分支機構的一些變化 master我試圖與它合併我的程式碼,也在點發布中(因此在我的分支中)被精心挑選,也可能不會增加便利性。

總的來說,我認為扔掉原型,將其拆解為零件並基於更新鮮的東西從頭開始構建新版本仍然是有意義的 master.

錯誤二:TLP 方法

從本質上講,這不是一個錯誤,一般來說,這只是在完全誤解「去哪裡以及如何移動?」和一般「我們會到達那裡嗎?」的情況下創建專案的一個特徵。 在這些條件下 笨拙的程式設計 是一個合理的選擇,但是,自然地,我不想不必要地重複它。 這次我想明智地做到這一點:原子提交、有意識的程式碼更改(而不是“將隨機字串在一起直到編譯(帶有警告)”,正如Linus Torvalds 曾經對某人所說的那樣,根據維基語錄)等等。

錯誤三:不知道淺灘就下水

我還沒有完全擺脫這個,但現在我決定根本不走阻力最小的路,而是「作為一個成年人」去做,即從頭開始編寫我的 TCG 後端,以免後來不得不說,「是的,這當然是慢慢的,但我無法控制一切——TCI就是這樣寫的……” 此外,這最初似乎是一個顯而易見的解決方案,因為 我產生二進位代碼。 正如他們所說,「根特聚集у,但不是那個」:程式碼當然是二進位的,但控制權不能簡單地轉移給它 - 它必須明確地推入瀏覽器進行編譯,從而產生來自 JS 世界的某個對象,該對象仍然需要被保存在某個地方。 然而,在普通的 RISC 架構上,據我了解,一種典型的情況是需要明確重置指令快取以重新生成程式碼 - 如果這不是我們需要的,那麼無論如何,它已經接近了。 另外,從我上次的嘗試中,我了解到控制似乎沒有轉移到翻譯區塊的中間,因此我們實際上不需要從任何偏移量解釋字節碼,我們可以簡單地從 TB 上的函數生成它。

他們過來踢

儘管我早在 XNUMX 月就開始重寫程式碼,但一種神奇的力量卻在不知不覺中悄悄出現:來自 GitHub 的信件通常作為有關問題和 Pull 請求響應的通知到達,但在這裡, 突然 在線程中提及 Binaryen 作為 qemu 後端 在上下文中,“他做了類似的事情,也許他會說些什麼。” 我們正在討論使用 Emscripten 的相關庫 二進位 建立 WASM JIT。 好吧,我說你那裡有 Apache 2.0 許可證,而 QEMU 整體上是在 GPLv2 下分發的,它們不太相容。 突然發現,執照可以 以某種方式修復它 (我不知道:也許改變它,也許雙重許可,也許其他什麼......)。 這當然讓我很高興,因為那時我已經仔細觀察過 二進位格式 WebAssembly,我有點悲傷和難以理解。 還有一個函式庫可以透過轉換圖吞噬基本區塊,產生字節碼,甚至在必要時在解釋器本身中運行它。

然後還有更多 一封信 在 QEMU 郵件列表上,但這更多的是關於「誰需要它?」的問題。 它是 突然,事實證明這是必要的。 至少,如果它的工作速度或多或少快的話,您可以收集以下使用可能性:

  • 無需任何安裝即可啟動一些教育性內容
  • iOS 上的虛擬化,據傳言,唯一有權動態生成程式碼的應用程式是 JS 引擎(這是真的嗎?)
  • 迷你作業系統示範——單軟碟、內建、各種韌體等...

瀏覽器運行時功能

正如我已經說過的,QEMU 與多線程相關,但瀏覽器沒有它。 嗯,就是不……一開始根本不存在,然後WebWorkers出現了——據我了解,這是基於訊息傳遞的多線程 沒有共享變數。 當然,當基於共享記憶體模型移植現有程式碼時,這會產生嚴重的問題。 隨後迫於輿論壓力,也以名義實施 SharedArrayBuffers。 它被逐漸引入,他們慶祝了它在不同瀏覽器中的推出,然後他們慶祝了新年,然後是 Meltdown...之後他們得出的結論是粗略或粗略的時間測量,但藉助共享內存和線程遞增計數器,都是一樣的 它會非常準確地計算出來。 因此我們禁用了共享記憶體的多執行緒。 似乎他們後來又重新打開了它,但是,正如從第一個實驗中清楚地看到的那樣,沒有它仍然存在,如果是這樣,我們將嘗試在不依賴多線程的情況下做到這一點。

第二個功能是不可能對堆疊進行低階操作:您不能簡單地取得、儲存當前上下文並使用新堆疊切換到新上下文。 呼叫棧由JS虛擬機器管理。 看來,問題是什麼,因為我們仍然決定完全手動管理先前的流程? 事實上,QEMU 中的區塊 I/O 是透過協程實現的,這就是低階堆疊操作派上用場的地方。 幸運的是,Emscipten 已經包含了非同步操作的機制,甚至是兩個: 非同步化 и 解釋器。 第一個方法會導致產生的 JavaScript 程式碼顯著膨脹,因此不再受支援。 第二種是當前的“正確方法”,透過本地解釋器產生字節碼來工作。 當然,它的工作速度很慢,但它不會使程式碼變得臃腫。 確實,對這種機制的協程的支援必須獨立貢獻(已經有為 Asyncify 編寫的協程,並且有一個用於 Emterpreter 的大致相同 API 的實現,您只需要連接它們)。

目前,我還沒有設法將程式碼拆分為在 WASM 中編譯並使用 Emterpreter 解釋的程式碼,因此區塊裝置還無法運作(請參閱下一個系列,正如他們所說...)。 也就是說,最後你應該得到像這樣有趣的分層的東西:

  • 解釋塊 I/O。 那麼,您真的期望模擬 NVMe 具有本機效能嗎? 🙂
  • 靜態編譯的主要 QEMU 程式碼(轉換器、其他類比設備等)
  • 動態編譯訪客程式碼到 WASM

QEMU 源的特點

正如您可能已經猜到的,用於模擬客戶架構的程式碼和用於產生主機機器指令的程式碼在 QEMU 中是分開的。 事實上,這甚至有點棘手:

  • 有訪客架構
  • 加速器,即用於Linux上硬體虛擬化的KVM(用於相互相容的來賓和主機系統),用於在任何地方產生JIT程式碼的TCG。 從 QEMU 2.9 開始,出現了對 Windows 上 HAXM 硬體虛擬化標準的支援(細節)
  • 如果使用 TCG 而不是硬體虛擬化,那麼它為每個主機架構以及通用解釋器提供單獨的程式碼產生支持
  • ....以及圍繞所有這些 - 模擬外圍設備、用戶介面、遷移、記錄重播等。

順便說一句,你知道嗎: QEMU 不僅可以模擬整個計算機,還可以模擬主機核心中單獨使用者進程的處理器,例如,AFL 模糊器將其用於二進位偵測。 也許有人想將 QEMU 的這種操作模式移植到 JS 上? 😉

與大多數歷史悠久的自由軟體一樣,QEMU 是透過呼叫建構的 configure и make。 假設您決定添加一些內容:TCG 後端、線程實作或其他內容。 對於與 Autoconf 進行通訊的前景,不要急於感到高興/害怕(酌情下劃線)——事實上, configure QEMU 顯然是自己寫的,並不是任何東西產生的。

WebAssembly

那麼這個叫做 WebAssembly(又稱 WASM)的東西是什麼呢? 這是 Asm.js 的替代品,不再偽裝成有效的 JavaScript 程式碼。 相反,它是純粹的二進制且經過優化的,甚至簡單地向其中寫入整數也不是很簡單:為了緊湊性,它以以下格式存儲 LEB128.

您可能聽說過 Asm.js 的重新循環演算法 - 這是「高級」流程控制指令(即 if-then-else、循環等)的恢復,JS 引擎是為此設計的,來自低階LLVM IR,更接近處理器執行的機器代碼。 自然,QEMU的中間表示更接近第二種。 看起來這就是字節碼,折磨的結束......然後還有塊,if-then-else 和循環!......

這也是 Binaryen 有用的另一個原因:它自然可以接受與 WASM 中儲存的內容接近的高階區塊。 但它也可以從基本區塊圖和它們之間的轉換產生程式碼。 嗯,我已經說過,它將 WebAssembly 儲存格式隱藏在方便的 C/C++ API 後面。

TCG(微型代碼產生器)

TCG的 原本是 顯然,它無法承受與 GCC 的競爭,但最終它在 QEMU 中作為主機平台的程式碼產生機制找到了自己的位置。 還有一個 TCG 後端會產生一些抽象字節碼,這些字節碼會立即由解釋器執行,但我決定這次避免使用它。 然而,事實上,在 QEMU 中,已經可以透過函數啟用到產生的 TB 的轉換 tcg_qemu_tb_exec,事實證明它對我來說非常有用。

要將新的 TCG 後端新增至 QEMU,您需要建立子目錄 tcg/<имя архитектуры> (在這種情況下, tcg/binaryen),它包含兩個檔案: tcg-target.h и tcg-target.inc.c и 規定 這全都是關於 configure。 您可以將其他文件放在那裡,但是,正如您可以從這兩個文件的名稱中猜測到的那樣,它們都將包含在某處:一個作為常規頭文件(它包含在 tcg/tcg.h,並且該文件已經存在於目錄中的其他文件中 tcg, accel 不僅如此),另一個 - 僅作為程式碼片段 tcg/tcg.c,但它可以存取其靜態函數。

考慮到我會花太多時間詳細研究它的工作原理,我只是從另一個後端實現中複製了這兩個文件的“骨架”,並在許可證標頭中誠實地指出了這一點。

文件 tcg-target.h 主要包含表單中的設定 #define-s:

  • 目標架構上有多少個暫存器以及寬度是多少(我們想要多少就有多少,想要多少就有多少 - 問題更多的是瀏覽器在「完全目標」架構上將產生更有效率的程式碼) .. .)
  • 主機指令的對齊:在x86上,甚至在TCI中,指令根本沒有對齊,但我將在程式碼緩衝區中放入根本不是指令,而是指向Binaryen庫結構的指針,所以我會說:4位元組
  • 後端可以產生哪些可選指令 - 我們包含在 Binaryen 中找到的所有內容,讓加速器將其餘指令分解為更簡單的指令
  • 後端請求的TLB快取的大小大約是多少? 事實上,在 QEMU 中,一切都很嚴肅:儘管有輔助函數執行加載/存儲時考慮到客戶 MMU(如果沒有它,我們現在會在哪裡?),它們以結構的形式保存翻譯緩存,其處理方便直接嵌入到廣播塊中。 問題是,這個結構中的哪個偏移量可以透過小而快速的命令序列最有效地處理?
  • 在這裡,您可以調整一兩個保留暫存器的用途,啟用透過函數呼叫 TB,並可選擇描述一些小的 inline- 功能如 flush_icache_range (但這不是我們的情況)

文件 tcg-target.inc.c當然,通常尺寸要大得多,並且包含幾個強制功能:

  • 初始化,包括限制哪些指令可以對哪些操作數進行操作。 我從另一個後端公然複製
  • 需要一個內部字節碼指令的函數
  • 您也可以將輔助函數放在這裡,也可以使用靜態函數 tcg/tcg.c

對於我自己,我選擇了以下策略:在下一個翻譯區塊的第一個單字中,我寫下了四個指標:一個起始標記(附近的某個值) 0xFFFFFFFF,它確定 TB 的當前狀態)、上下文、生成的模組和用於調試的幻數。 最初,標記被放置在 0xFFFFFFFF - n哪裡 n - 一個小的正數,每次通過解釋器執行都會增加1。當達到 0xFFFFFFFE,編譯發生,模組被保存在函數表中,導入到一個小的「啟動器」中,執行從 tcg_qemu_tb_exec,並且該模組已從 QEMU 記憶體中刪除。

套用經典的話就是「拐杖,這聲音裡交織著多少進步者的心…」。 然而,內存在某個地方洩漏了。 而且,它是由 QEMU 管理的記憶體! 我有一段程式碼,在編寫下一條指令(好吧,即指標)時,刪除了先前連結位於此位置的指令,但這沒有幫助。 實際上,在最簡單的情況下,QEMU 在啟動時分配記憶體並將生成的程式碼寫入其中。 當緩衝區用完時,程式碼將被丟棄,並開始在其位置寫入下一個程式碼。

在研究了程式碼之後,我意識到使用幻數的技巧可以讓我在第一次傳遞時釋放未初始化緩衝區上的錯誤內容,從而避免堆破壞失敗。 但是誰會重寫緩衝區來繞過我的函數呢? 正如Emscripten 開發人員建議的那樣,當我遇到問題時,我將生成的程式碼移植回本機應用程序,並在其上設定Mozilla Record-Replay...總的來說,最終我意識到了一個簡單的事情:對於每個區塊, A struct TranslationBlock 及其描述。 猜猜在哪裡...沒錯,就在緩衝區中的區塊之前。 意識到這一點,我決定不再使用拐杖(至少有些),只是扔掉神奇的數字,並將剩餘的單字轉移到 struct TranslationBlock,建立一個單鍊錶,當翻譯快取重置時可以快速遍歷,並釋放記憶體。

一些拐杖仍然存在:例如,程式碼緩衝區中的標記指標 - 其中一些只是簡單的 BinaryenExpressionRef,即他們查看需要線性放入生成的基本區塊中的表達式,部分是BB之間轉換的條件,部分是去哪裡。 嗯,已經有為Relooper準備好的區塊了,需要根據情況進行連接。 為了區分它們,假設它們都至少對齊四個位元組,因此您可以安全地使用最低有效的兩位作為標籤,只需記住在必要時將其刪除。 順便說一下,QEMU 中已經使用了此類標籤來指示退出 TCG 循環的原因。

使用二進位

WebAssembly 中的模組包含函數,每個函數都包含一個主體,即一個表達式。 表達式是一元和二元運算、由其他表達式列表、控制流等組成的區塊。 正如我已經說過的,這裡的控制流被精確地組織為高階分支、循環、函數呼叫等。 函數的參數不是在堆疊上傳遞的,而是明確傳遞的,就像在 JS 中一樣。 還有全域變量,不過我沒用過,就不告訴大家了。

函數也有局部變量,從零開始編號,類型為:int32 / int64 / float / double。 在這種情況下,前 n 個局部變數是傳遞給函數的參數。 請注意,雖然這裡的所有內容在控制流程方面並不完全是低階的,但整數仍然不帶有「有符號/無符號」屬性:數字的行為方式取決於操作代碼。

一般來說,Binaryen 提供 簡單的C-API:您建立一個模組, 在他那邊 建立表達式 - 一元、二元、其他表達式的區塊、控制流等。 然後建立一個以表達式作為函數體的函數。 如果您像我一樣有一個低階轉換圖,那麼 relooper 元件將為您提供協助。 據我了解,可以在區塊中使用執行流的高級控制,只要不超出區塊的邊界即可 - 也就是說,可以使內部快速路徑/慢速路徑路徑分支在內建TLB快取處理程式碼內部,但不會幹擾“外部”控制流。 當您釋放 relooper 時,它的區塊也會被釋放;當您釋放模組時,指派給它的表達式、函數等會消失 競技場.

但是,如果您想動態解釋程式碼,而不需要不必要地建立和刪除解釋器實例,則將此邏輯放入C++ 檔案中可能是有意義的,並從那裡直接管理庫的整個C++ API,繞過現成的 -做了包裝紙。

所以要產生你需要的程式碼

// настроить глобальные параметры (можно поменять потом)
BinaryenSetAPITracing(0);

BinaryenSetOptimizeLevel(3);
BinaryenSetShrinkLevel(2);

// создать модуль
BinaryenModuleRef MODULE = BinaryenModuleCreate();

// описать типы функций (как создаваемых, так и вызываемых)
helper_type  BinaryenAddFunctionType(MODULE, "helper-func", BinaryenTypeInt32(), int32_helper_args, ARRAY_SIZE(int32_helper_args));
// (int23_helper_args приоб^Wсоздаются отдельно)

// сконструировать супер-мега выражение
// ... ну тут уж вы как-нибудь сами :)

// потом создать функцию
BinaryenAddFunction(MODULE, "tb_fun", tb_func_type, func_locals, FUNC_LOCALS_COUNT, expr);
BinaryenAddFunctionExport(MODULE, "tb_fun", "tb_fun");
...
BinaryenSetMemory(MODULE, (1 << 15) - 1, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
BinaryenAddMemoryImport(MODULE, NULL, "env", "memory", 0);
BinaryenAddTableImport(MODULE, NULL, "env", "tb_funcs");

// запросить валидацию и оптимизацию при желании
assert (BinaryenModuleValidate(MODULE));
BinaryenModuleOptimize(MODULE);

……如果我忘記了什麼,抱歉,這只是代表比例,詳細資訊在文件中。

現在,crack-fex-pex 開始了,如下所示:

static char buf[1 << 20];
BinaryenModuleOptimize(MODULE);
BinaryenSetMemory(MODULE, 0, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
int sz = BinaryenModuleWrite(MODULE, buf, sizeof(buf));
BinaryenModuleDispose(MODULE);
EM_ASM({
  var module = new WebAssembly.Module(new Uint8Array(wasmMemory.buffer, $0, $1));
  var fptr = $2;
  var instance = new WebAssembly.Instance(module, {
      'env': {
          'memory': wasmMemory,
          // ...
      }
  );
  // и вот уже у вас есть instance!
}, buf, sz);

為了以某種方式連接 QEMU 和 JS 的世界,同時快速存取編譯後的函數,創建了一個陣列(用於導入啟動器的函數表),並將生成的函數放置在那裡。 為了快速計算索引,最初使用的是零字翻譯區塊的索引,但後來使用這個公式計算的索引開始簡單地擬合到 struct TranslationBlock.

順便說一句, 演示 (目前許可證不明確) 僅在 Firefox 中工作正常。 Chrome 開發者是 不知為何還沒準備好 事實上,有人想要建立超過一千個 WebAssembly 模組實例,因此他們只需為每個實例分配一千兆位元組的虛擬位址空間...

目前為止就這樣了。 如果有人有興趣的話也許會有另一篇文章。 也就是說,至少還剩下 只是 使塊設備工作。 按照 JS 世界的慣例,非同步編譯 WebAssembly 模組也可能有意義,因為在本機模組準備就緒之前仍然有一個解釋器可以完成所有這些工作。

最後出個謎語: 您已經在 32 位元體系結構上編譯了一個二進位文件,但程式碼透過記憶體操作從 Binaryen 爬升,位於堆疊上的某個位置,或位於 2 位元位址空間上 32 GB 的其他位置。 問題在於,從 Binaryen 的角度來看,這是訪問太大的結果位址。 如何解決這個問題?

以管理員的方式

我最終沒有對此進行測試,但我的第一個想法是“如果我安裝 32 位元 Linux 會怎麼樣?” 那麼位址空間的上部將會被核心佔用。 唯一的問題是佔用多少空間:1 或 2 Gb。

以程式設計師的方式(從業者的選項)

讓我們在地址空間的頂部吹一個泡泡。 我自己也不明白為什麼它會起作用 - 在那裡 已經 必須有一個堆疊。 但“我們是實踐者:一切都對我們有用,但沒人知道為什麼…”

// 2gbubble.c
// Usage: LD_PRELOAD=2gbubble.so <program>

#include <sys/mman.h>
#include <assert.h>

void __attribute__((constructor)) constr(void)
{
  assert(MAP_FAILED != mmap(1u >> 31, (1u >> 31) - (1u >> 20), PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0));
}

……確實它與 Valgrind 不相容,但幸運的是,Valgrind 本身非常有效地將每個人都趕出了那裡:)

也許有人會更好地解釋我的這段程式碼是如何運作的...

來源: www.habr.com

添加評論