將 Rust 編譯 WebAssembly 指南

本文譯自:https://surma.dev/things/rust-to-webassembly/

下面是我所知道的關於將 Rust 編譯爲 WebAssembly 的所有知識。

前一段時間,我寫了一篇如何在沒有 Emscripten 的情況下將 C 編譯爲 WebAssembly[1] 的博客文章,即使用非默認工具來簡化這個過程。在 Rust 中,使 WebAssembly 變得簡單的工具稱爲 wasm-bindgen[2],我們正在放棄它!同時,Rust 有點不同,因爲 WebAssembly 長期以來一直是一流的目標,並且開箱即用地提供了標準庫佈局。

Rust 編譯 WebAssembly 入門

讓我們看看如何讓 Rust 以儘可能少的偏離標準 Rust 工作流程的方式編譯成 WebAssembly。如果你瀏覽互聯網,許多文章和指南都會告訴你使用 cargo init --lib 創建一個 Rust 庫項目,然後將 crate-type = ["cdylib"] 添加到你的 cargo.toml,如下所示:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]
   
[dependencies]

如果你不將 crate 類型設置爲 cdylib,Rust 編譯器將生成一個 .rlib 文件,這是 Rust 自己的庫格式。雖然 cdylib 這個名字暗示了一個與 C 兼容的動態庫,但我懷疑它真的只是代表 “使用可互操作的格式” 或類似的東西。

現在,我們將使用 Cargo 在創建新庫時生成的默認 / 示例函數:

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

一切就緒後,我們現在可以將這個庫編譯爲 WebAssembly:

cargo build --target=wasm32-unknown-unknown --release

你會在 target/wasm32-unknown-unknown/release/my_project.wasm 找到它。在整篇文章中,我將繼續使用 --release 進行構建,因爲它使 WebAssembly 模塊在我們反彙編時更具可讀性。

可執行文件與庫

你可以創建一個 Rust 可執行文件(通過 cargo init --bin),而不是創建一個庫。但是請注意,你要麼必須讓 main() 函數具有完善的簽名,要麼使用 #![no_main] 關閉編譯器以讓它知道缺少 main() 是故意的。

那個更好嗎?這對我來說似乎是一個品味問題,因爲這兩種方法在功能上似乎是等同的並且生成相同的 WebAssembly 代碼。大多數時候,WebAssembly 模塊似乎扮演了一個庫的角色,而不是一個可執行文件(除了在 WASI[3] 的上下文中,稍後會詳細介紹!),所以在我看來,庫方法在語義上似乎更可取。除非另有說明,否則我將在本文的其餘部分使用庫設置。

導出

繼續庫樣式的設置,讓我們看看編譯器生成的 WebAssembly 代碼。爲此,我推薦 WebAssembly Binary Toolkit[4](簡稱 “wabt”),它提供了有用的工具,如 wasm2wat。另外,請確保安裝了 Binarygen[5],因爲本文後面我們將需要 wasm-opt。Binaryen 還提供了 wasm-dis,其工作方式與 wasm2wat 類似,但不產生 WebAssembly 文本格式 (WAT)。它生成標準化程度較低的 WebAssembly S-Expression 文本格式 (WAST)。最後,ByteCodeAlliance 的 wasm-tools[6] 提供了 wasm-tools print

wasm2wat ./target/wasm32-unknown-unknown/release/my_project.wasm

此命令會將 WebAssembly 二進制文件轉換爲 WAT:

(module
  (table (;0;) 1 1 funcref)
  (memory (;0;) 16)
  (global $__stack_pointer (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048576))
  (global (;2;) i32 (i32.const 1048576))
  (export "memory" (memory 0))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2)))

令人髮指的是,我們發現我們的 add 函數已從二進制文件中完全刪除。我們只剩下一個堆棧指針和兩個全局變量,它們指定數據部分的結束位置和堆的開始位置。事實證明,將函數聲明爲 pub 不足以讓它出現在我們最終的 WebAssembly 模塊中。我其實希望這就足夠了,但我懷疑 Rust 模塊可見性是唯一的,而不是鏈接器級別的符號可見性。

確保編譯器不會刪除我們關心的函數的最快方法是添加屬性 #[no_mangle],儘管我不喜歡這個命名。

#[no_mangle]
pub fn add(left: usize, right: usize) -> usize {
    left + right
}

很少需要,但是你可以通過使用 #[export_name = "..."] 導出一個名稱與其 Rust 內部名稱不同的函數。

將我們的 add 函數標記爲導出後,我們可以再次編譯項目並檢查生成的 WebAssembly 文件:

(module
  (type (;0;) (func (param i32 i32) (result i32)))
  (func $add (type 0) (param i32 i32) (result i32)
    local.get 1
    local.get 0
    i32.add)
  (table (;0;) 1 1 funcref)
  (memory (;0;) 16)
  (global $__stack_pointer (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048576))
  (global (;2;) i32 (i32.const 1048576))
  (export "memory" (memory 0))
  (export "add" (func $add))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2)))

這個模塊可以用普通的 WebAssembly API 實例化:

const importObj = {};

// Node
const data = require("fs").readFileSync("./my_project.wasm");
const {instance} = await WebAssembly.instantiate(data, importObj);

// Deno
const data = await Deno.readFile("./my_project.wasm");
const {instance} = await WebAssembly.instantiate(data, importObj);

// For Web, it’s advisable to use `instantiateStreaming` whenever possible:
const response = await fetch("./my_project.wasm");
const {instance} = 
  await WebAssembly.instantiateStreaming(response, importObj);

instance.exports.add(40, 2) // returns 42

突然之間,我們幾乎可以使用 Rust 的所有功能來編寫 WebAssembly。

需要特別注意模塊邊界處的函數(即你從 JavaScript 調用的函數)。至少就目前而言,最好堅持使用能夠清晰映射到 WebAssembly 的類型 [7](如 i32 或 f64)。如果你使用更高級別的類型,如數組、切片,甚至 String,該函數最終可能會使用比它們在 Rust 中更多的參數,並且通常需要對內存佈局和類似原則有更深入的瞭解。

ABI

請注意:是的,我們正在成功地將 Rust 編譯爲 WebAssembly。然而,在 Rust 版本中,可能會生成一個具有完全不同函數簽名的 WebAssembly 模塊。函數參數從調用者傳遞到被調用者的方式(例如作爲指向內存的指針或作爲立即值)是應用程序二進制接口定義或簡稱 “ABI” 的一部分。rustc 默認使用 Rust 的 ABI,它不穩定,主要考慮 Rust 內部。

rustc 爲了穩定這種情況,我們可以顯式定義要爲函數使用哪個 ABI 。這是通過使用 extern[8] 關鍵字來完成的。跨語言函數調用的一個長期選擇是 C ABI[9],我們將在此處使用它。C ABI 不會改變,所以我們可以確定我們的 WebAssembly 模塊接口也不會改變。

#[no_mangle]
pub fn add(left: usize, right: usize) -> usize {
pub extern "C" fn add(left: usize, right: usize) -> usize {
    left + right
}

我們甚至可以省略 "C" 而只使用 extern,因爲 C ABI 是默認的替代 ABI。

導入

WebAssembly 的一個重要部分是它的沙箱。它確保在 WebAssembly VM 中運行的代碼無法訪問主機環境中的任何內容,除了通過 imports 對象顯式傳遞到沙箱中的函數。

假設我們想在我們的 Rust 代碼中生成隨機數。我們可以引入 rand Rust 沙箱,但如果主機環境中已經有東西,爲什麼還要發佈代碼。作爲第一步,我們需要聲明我們的 WebAssembly 模塊需要導入:

#[link(wasm_import_module = "Math")]
extern "C" {
    fn random() -> f64;
}

#[export_name = "add"]
pub fn add(left: f64, right: f64) -> f64 {
    left + right  
    left + right + unsafe { random() }
}

extern "C" 塊(不要與上面的 extern "C" 函數混淆)聲明編譯器希望在鏈接時由 “其他人” 提供的函數。這通常是你在 Rust 中鏈接 C 庫的方式,但該機制也適用於 WebAssembly。但是,外部函數總是隱式不安全的,因爲編譯器無法爲非 Rust 函數提供任何安全保證。因此,除非我們將調用包裝在 unsafe { ... } 塊中,否則我們無法調用它們。

上面的代碼可以編譯,但不會運行。我們的 JavaScript 代碼拋出錯誤,需要更新以滿足我們指定的導入。導入對象是導入模塊的字典,每個模塊都是導入項的字典。在我們的 Rust 代碼中,我們聲明瞭一個導入模塊 "Math",並期望一個被調用的函數 "random" 出現在該模塊中。這些值當然是經過仔細選擇的,這樣我們就可以傳入整個 Math 對象。

  const importObj = {
    Math: {
      random: () => Math.random(),
    }
  };

  // or
  
  const importObj = { Math };

爲了避免到處注入 unsafe { ... },通常需要編寫包裝函數來恢復 Rust 的安全不變量。這是 Rust 內聯模塊的一個很好的用例:

mod math {
    mod math_js {
        #[link(wasm_import_module = "Math")]
        extern "C" {
            pub fn random() -> f64;
        }
    }

    pub fn random() -> f64 {
        unsafe { math_js::random() }
    }
}

#[export_name = "add"]
pub extern "C" fn add(left: f64, right: f64) -> f64 {
    left + right + math::random()
}

順便說一句,如果我們沒有指定 #[link(wasm_import_module = ...)] 屬性,則函數將在默認 env 模塊上運行。此外,就像你可以使用 #[export_name = "..."] 更改導出的函數的名稱一樣,你可以使用 #[link_name = "..."] 更改導入的函數的名稱。

高級類型

我之前說過,在模塊邊界處理函數的最有效方法是使用透明映射到 WebAssembly 支持的數據類型的值類型。當然,編譯器允許你使用更復雜的類型作爲函數的參數和值。在這些情況下,編譯器生成 C ABI[10] 中指定的代碼(除了 rustc 目前不完全符合 C ABI 的 不足 [11])。

無需贅述,類型大小(例如,struct、enum 等)就變成了一個簡單的指針。數組和元組是有大小的類型,如果它們使用少於 32 位,它們將被轉換爲立即值。更復雜的情況是函數返回大於 32 位的數組類型的值:如果是這種情況,函數將不會收到返回值,而是會收到一個附加類型的參數 i32,該函數將利用指向此參數的指針來存儲結果。如果一個函數返回一個元組,無論元組的大小如何,它總是被認爲是函數的參數。

(?Sized) 具有未指定類型的函數參數,例如 str[u8] 或 dyn MyTrait,由兩部分組成:第一部分是指向數據的指針,第二部分是指向元數據的指針。如果是 str 的一個或一部分,則元數據是數據的長度。在特徵對象的實例中,它是一個虛擬表(或 vtable),它是指向各個特徵函數實現的函數指針列表。如果你想了解更多有關 Rust 中的 VTable 的信息,我可以推薦 Thomas Bächler 的這篇文章 [12]。

我在這裏省略了重要的細節,因爲建議你不要編寫下一個 wasm-bindgen,除非你非要這樣做。我建議依靠現有工具而不是創建新工具。

模塊大小

當 WebAssembly 部署在 web 上時,它的二進制文件的大小非常重要。每一點都必須通過網絡傳輸並通過瀏覽器的 WebAssembly 編譯器,因此,較小的二進制大小意味着在 WebAssembly 開始運行之前用戶等待的時間更少。如果我們將默認項目構造爲發佈版本,我們將生成 1.7MB 的 WebAssembly。這對於兩個數字相加的功能似乎太大了。

數據部分:WebAssembly 模塊的大部分由數據組成。即數據在特定點保存在內存中,然後複製到線性內存。這些部分的編譯成本很低,因爲編譯器會跳過它們,在分析和減少模塊的啓動時間時請記住這一點。

檢查 WebAssembly 模塊內部結構的一種簡單方法是 llvm-objdump,這應該可以在你的系統上訪問。或者,你可以使用 wasm-objdump,它是 wabt 的一部分,通常提供相同的接口。

$ llvm-objdump -h target/wasm32-unknown-unknown/release/my_project.wasm

target/wasm32-unknown-unknown/release/my_project.wasm: file format wasm

Sections:
Idx Name            Size     VMA      Type
  0 TYPE            00000007 00000000
  1 FUNCTION        00000002 00000000
  2 TABLE           00000005 00000000
  3 MEMORY          00000003 00000000
  4 GLOBAL          00000019 00000000
  5 EXPORT          0000002b 00000000
  6 CODE            00000009 00000000 TEXT
  7 .debug_info     00062c72 00000000
  8 .debug_pubtypes 00000144 00000000
  9 .debug_ranges   0002af80 00000000
 10 .debug_abbrev   00001055 00000000
 11 .debug_line     00045d24 00000000
 12 .debug_str      0009f40c 00000000
 13 .debug_pubnames 0003e3f2 00000000
 14 name            0000001c 00000000
 15 producers       00000043 00000000

llvm-objdump 過於籠統,爲那些有使用其他語言彙編經驗的人提供熟悉的命令行。然而,專門用於調試二進制字符串的大小,它缺少簡單的工具,如按大小排序部分或按功能分解部分。幸運的是,有專門爲此設計的 WebAssembly 專用工具 Twiggy[13]:

$ twiggy top target/wasm32-unknown-unknown/release/my_project.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼─────────────────────────────────────────
        652300 ┊    36.67% ┊ custom section '.debug_str'
        404594 ┊    22.75% ┊ custom section '.debug_info'
        285988 ┊    16.08% ┊ custom section '.debug_line'
        254962 ┊    14.33% ┊ custom section '.debug_pubnames'
        176000 ┊     9.89% ┊ custom section '.debug_ranges'
          4181 ┊     0.24% ┊ custom section '.debug_abbrev'
           324 ┊     0.02% ┊ custom section '.debug_pubtypes'
            67 ┊     0.00% ┊ custom section 'producers'
            25 ┊     0.00% ┊ custom section 'name' headers
            20 ┊     0.00% ┊ custom section '.debug_pubnames' headers
            19 ┊     0.00% ┊ custom section '.debug_pubtypes' headers
            18 ┊     0.00% ┊ custom section '.debug_ranges' headers
            17 ┊     0.00% ┊ custom section '.debug_abbrev' headers
            16 ┊     0.00% ┊ custom section '.debug_info' headers
            16 ┊     0.00% ┊ custom section '.debug_line' headers
            15 ┊     0.00% ┊ custom section '.debug_str' headers
            14 ┊     0.00% ┊ export "__heap_base"
            13 ┊     0.00% ┊ export "__data_end"
            12 ┊     0.00% ┊ custom section 'producers' headers
             9 ┊     0.00% ┊ export "memory"
             9 ┊     0.00% ┊ add
...

現在很明顯,模塊大小的所有主要貢獻者都是與模塊用途無關的自定義組件。它們的標題暗示它們包含用於故障排除的信息,因此這些部分是爲構建和發佈而發出的這一事實有些不合常規。這似乎與我們代碼的一個長期存在的問題有關,該問題導致它在編譯時沒有調試符號,但在我們的機器上預編譯的標準庫仍然有調試符號。

爲了解決這個問題,我們在 Cargo.toml 中添加了:

[profile.release]
strip = true

這將導致 rustc 刪除所有自定義部分,包括爲函數分配名稱的部分。這可能不是我們想要的,因爲 twiggy 的輸出將只包含 saycode[0] 或類似的函數。如果你想維護函數名稱,我們可以使用特定的模式來刪除信息:

[profile.release]
strip = true
strip = "debuginfo"

如果你想完全細粒度控制,你可以恢復並完全禁用 rustc 的 strip 方法,而是使用 llvm-strip 或 wasm-strip。這使你能夠決定應保留哪些自定義部件。

llvm-strip --keep-section=name target/wasm32-unknown-unknown/release/my_project.wasm

移除外層後,我們剩下一個與 116B 一樣大或大於 116B 的塊。拆解它會發現該模塊的唯一目的是調用 add 並執行 (f64.add (local.get 0) (local.get 1)),這意味着 Rust 編譯器能夠生成最佳代碼。當然,代碼庫的大小增加了,這使得掌握二進制大小變得更加困難。

自定義部分

有趣的事實:我們可以使用 Rust 將我們的自定義部分添加到 WebAssembly 模塊中。如果我們聲明一個字節數組(不是切片!),我們可以添加一個 #[link_section=...] 屬性來將這些字節打包到它自己的部分中。

const _: () = {
    #[link_section = "surmsection"]
    static SECTION_CONTENT: [u8; 11] = *b"hello world";
};

我們可以使用 WebAssembly.Module.customSection() AP[14]I 或使用 llvm-objdump 提取這些數據:

$ llvm-objdump -s -j surmsection target/wasm32-unknown-unknown/release/my_project.wasm

target/wasm32-unknown-unknown/release/my_project.wasm: file format wasm
Contents of section surmsection:
 0000 68656c6c 6f20776f 726c64             hello world

偷偷摸摸的膨脹

我在網上看到一些關於 Rust 爲看似很小的工作創建 WebAssembly 模塊的抱怨。根據我的經驗,Rust 創建的 WebAssembly 二進制文件可能很大的原因有以下三個:

我們已經看到了前兩個。讓我們仔細看看最後一個。這個無害的程序編譯成 18KB 的 WebAssembly:

static PRIMES: &[i32] = &[2, 3, 5, 7, 11, 13, 17, 19, 23];

#[no_mangle]
extern "C" fn nth_prime(n: usize) -> i32 {
    PRIMES[n]
}

好吧,也許它畢竟不是那麼無害。你可能已經知道我要幹嘛了。

恐慌

快速瀏覽一下 twiggy 就會發現,影響 Wasm 模塊大小的主要因素是與字符串格式化、恐慌和內存分配相關的函數。這說得通!參數 n 未清理並用於索引數組。Rust 別無選擇,只能注入邊界檢查。如果邊界檢查失敗,Rust 會崩潰,這是創建格式正確的錯誤消息和堆棧跟蹤所必需的。

解決這個問題的一種方法是自己進行邊界檢查。Rust 的編譯器非常擅長僅在需要時注入檢查。

fn nth_prime(n: usize) -> i32 {
    if n < 0 || n >= PRIMES.len() { return -1; }
    PRIMES[n]
}

可以說更慣用的方法是依靠 Option<T>API 來控制錯誤情況的處理方式:

fn nth_prime(n: usize) -> i32 {
    PRIMES[n]
    PRIMES.get(n).copied().unwrap_or(-1)
}

第三種方法是使用 unchecked Rust 明確提供的一些方法。這些爲未定義的行爲打開了大門,因此是 unsafe,但如果你能夠承擔起安全的重擔,性能(或文件大小)的提高將是顯着的!

fn nth_prime(n: usize) -> i32 {
    PRIMES[n]
    unsafe { *PRIMES.get_unchecked(n) }
}

我們可以嘗試處理恐慌可能發生的位置,並嘗試手動處理這些路徑。然而,一旦我們開始依賴第三方 crate,成功的機會就會減少,因爲我們無法輕易改變庫內部處理錯誤的方式。

LTO

我們可能不得不接受這樣一個事實,即我們無法避免代碼庫中出現 panic 的代碼路徑。雖然我們可以嘗試減輕恐慌的影響(我們會的!),但有一個相當強大的優化通常可以節省一些重要的代碼。這個優化過程由 LLVM 提供,稱爲 LTO(Link Time Optimization,鏈接時優化)[15]。 rustc 在將所有內容鏈接到最終二進制文件之前編譯和優化每個 crate。然而,一些優化只有在鏈接後纔會變得明顯。例如,許多函數根據輸入有不同的分支。在編譯期間,你只會看到來自同一個 crate 的函數調用。在鏈接時,你知道對任何給定函數的所有可能調用,這意味着現在可以消除其中一些代碼分支。

LTO 默認處於關閉狀態,因爲它是一項代價高昂的優化,會顯着減慢編譯時間,尤其是在較大的 crate 中。你可以通過在 Cargo.toml 中配置 rustc 的許多代碼生成選項啓用。具體來說,我們需要將這一行添加到我們的 Cargo.toml 中以在發佈版本中啓用 LTO:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[profile.release]
lto = true

啓用 LTO 後,剝離的二進制文件減少到 2.3K,這令人印象深刻。LTO 的唯一成本是更長的鏈接時間,但如果二進制大小是一個問題,LTO 將成爲一項利器,因爲它 “僅” 花費構建時間並且不需要更改代碼。

wasm-opt

另一個幾乎應該成爲構建管道一部分的工具是來自 binaryen[16] 的 wasm-opt。它是另一個優化過程的集合,完全在 WebAssembly VM 指令上工作,獨立於生成它們的源語言。像 Rust 這樣的高級語言有更多的信息可以用來應用更復雜的優化,所以 wasm-opt 不能替代你的語言編譯器的優化。但是,它通常設法將模塊大小減少幾個額外的字節。

wasm-opt -O3 -o output.wasm target/wasm32-unknown-unknown/my_project.wasm

在我們的例子中,wasm-opt 進一步縮小了 Rust 的 2.3K WebAssembly 二進制文件,最後是 2.0K。好的!但別擔心,我不會就此打住。這對於數組中的查找來說仍然太大了。

非標準

Rust 有一個標準庫 [17],其中包含你每天進行系統編程時所需的許多抽象和實用程序:訪問文件、獲取當前時間或打開網絡套接字。一切都在那裏供你使用,無需去 crates.io[18] 或類似網站上搜索。然而,許多數據結構和函數對它們的使用環境做出了假設:它們假設硬件的細節被抽象成一個統一的 API,並且它們假設它們可以以某種方式分配(和釋放)任意大小的內存塊。通常,這兩項工作都是由操作系統完成的,我們大多數人每天都在操作系統上工作。

但是,當你通過原始 API 實例化 WebAssembly 模塊時,情況就不同了:沙箱(WebAssembly 的定義安全功能之一)將 WebAssembly 代碼與主機隔離開來,從而與操作系統隔離開來。你的代碼只能訪問一大塊線性內存,它甚至無法弄清楚哪些部分正在使用,哪些部分可以使用。

WASI:這不是本文的一部分,但就像 WebAssembly 是對運行代碼的處理器的抽象一樣,WASI[19](WebAssembly 系統接口)旨在成爲對運行代碼的操作系統的抽象,併爲你提供可以使用單一、統一的 API。Rust 支持 WASI,儘管 WASI 本身仍在發展中。

這意味着 Rust 給了我們一種虛假的安全感!它爲我們提供了一個沒有操作系統支持的完整標準庫。事實上,許多 stdlib 模塊只是別名或者失敗了。也就是說,它們在沒有操作系統支持的情況下不能正常工作。在沒有操作系統支持的情況下,許多返回 Result <T> 類型的函數可能會因爲無法正常工作而始終返回 Err,這意味着無法得到正確的操作結果。同樣,其他一些函數可能會因爲無法正常工作而導致程序崩潰。

向無操作系統設備學習

只是一個線性內存塊。沒有管理內存或外圍設備的中央實體。只是算術。如果你曾經使用過嵌入式系統,這聽起來可能很熟悉。雖然現代嵌入式系統運行 Linux,但較小的微處理器沒有資源來這樣做。 Rust 還針對那些超受限環境 [20],Embedded Rust Book[21] 和 Embedomicon[22] 解釋瞭如何爲這些環境正確編寫 Rust。

要進入裸機世界🤘,我們必須在代碼中添加一行:#![no_std]。這個 crate 宏告訴 Rust 不要鏈接到標準庫。相反,它只鏈接到 core[23]。Embedonomicon 非常簡潔地 解釋 [24] 了這意味着什麼:

core crate 是 std crate 的子集,它對程序將在其上運行的系統做出零假設。因此,它爲語言原語(如浮點數、字符串和切片)提供 API,以及公開處理器功能(如原子操作和 SIMD 指令)的 API。但是,它缺少任何處理堆內存分配和 I/O 的 API。

對於應用程序,std 不僅僅是提供一種訪問操作系統抽象的方法。std 還負責設置堆棧溢出保護、處理命令行參數以及在調用程序的主函數之前生成主線程。 #![no_std] 應用程序缺少所有標準運行時,因此如果需要它必須初始化自己的運行時。

這聽起來有點可怕,但讓我們一步一步來。我們首先將上面的 panic-y 素數程序聲明爲 no_std

#![no_std]
static PRIMES: &[i32] = &[2, 3, 5, 7, 11, 13, 17, 19, 23];

#[no_mangle]
extern "C" fn nth_prime(n: usize) -> i32 {
    PRIMES[n]
}

很遺憾,Embedonomicon 段落預示了這一點。因爲我們沒有提供核心依賴項的一些基礎知識。在列表的最頂部,我們需要定義在這種環境中發生恐慌時應該發生什麼。這是由恰當命名的恐慌處理程序完成的,Embedonomicon 給出了一個例子:

#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
    loop {}
}

這對於嵌入式系統來說是非常典型的,有效地阻止了處理器在崩潰發生後進行任何進一步的處理。然而,這在 web 上不是好的行爲,所以對於 WebAssembly,我通常選擇手動發出無法訪問的指令來阻止任何 Wasm VM 運行:

#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
    loop {}
    core::arch::wasm32::unreachable()
}

有了這個,我們的程序再次編譯。剝離和 wasm-opt 後,二進制文件大小爲 168B。極簡主義再次獲勝!

內存管理

當然,我們因非標準而放棄了很多。沒有堆分配,就沒有 Box,沒有 Vec,沒有 String 和許多其他有用的東西。幸運的是,我們可以在不放棄整個操作系統的情況下取回這些東西。

std 提供的很多東西實際上只是來自 core 的另一個稱爲 alloc 的東西。 alloc 包含有關內存分配和依賴於它的數據結構的所有內容。通過導入它,我們可以重新獲得我們信任的 Vec

#![no_std]
// One of the few occastions where we have to use `extern crate`
// even in Rust Edition 2021.
extern crate alloc;
use alloc::vec::Vec;

#[no_mangle]
extern "C" fn nth_prime(n: usize) -> usize {
    // Please enjoy this horrible implementation of
    // The Sieve of Eratosthenes.
    let mut primes: Vec<usize> = Vec::new();
    let mut current = 2;
    while primes.len() < n {
        if !primes.iter().any(|prime| current % prime == 0) {
            primes.push(current);
        }
        current += 1;
    }
    primes.into_iter().last().unwrap_or(0)
}

#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
    core::arch::wasm32::unreachable()
}

當然,嘗試編譯它會失敗 —— 我們實際上並沒有告訴 Rust 我們的內存管理是什麼樣的,Vec 需要知道它才能運行。

$ cargo build --target=wasm32-unknown-unknown --release
error: no global memory allocator found but one is required; 
  link to std or add `#[global_allocator]` to a static item that implements 
  the GlobalAlloc trait

error: `#[alloc_error_handler]` function required, but not found

note: use `#![feature(default_alloc_error_handler)]` for a default error handler

在撰寫本文時,在 Rust 1.67 中,你需要提供一個在分配失敗時調用的錯誤處理程序。在下一個版本中,Rust 1.68 default_alloc_error_handler 已經穩定下來,這意味着每個非標準的 Rust 程序都將帶有這個錯誤處理程序的默認實現。如果你仍想提供自己的錯誤處理程序,你可以:

#[alloc_error_handler]
fn alloc_error(_: core::alloc::Layout) -> ! {
    core::arch::wasm32::unreachable()
}

有了這個複雜的錯誤處理程序,我們最終應該提供一種方法來進行實際的內存分配。就像我在 C 到 WebAssembly[25] 的文章中一樣,我的自定義分配器將是一個最小的 bump 分配器,它往往又快又小,但不會釋放內存。我們靜態分配一個 arena 作爲我們的堆,並跟蹤 “空閒區域” 的開始位置。由於我們不使用 Wasm 線程,因此我也會忽略線程安全。

use core::cell::UnsafeCell;

const ARENA_SIZE: usize = 128 * 1024;
#[repr(C, align(32))]
struct SimpleAllocator {
    arena: UnsafeCell<[u8; ARENA_SIZE]>,
    head: UnsafeCell<usize>,
}

impl SimpleAllocator {
    const fn new() -> Self {
        SimpleAllocator {
            arena: UnsafeCell::new([0; ARENA_SIZE]),
            head: UnsafeCell::new(0),
        }
    }
}

unsafe impl Sync for SimpleAllocator {}

#[global_allocator]
static ALLOCATOR: SimpleAllocator = SimpleAllocator::new();

將 #[global_allocator] 全局變量標記爲管理堆的實體。此變量的類型必須實現 GlobalAlloc 特性。特性上的 GlobalAlloc 方法都使用 &self,所以如果你想修改數據類型中的任何值,你必須使用內部可變性。我這裏選擇了 UnsafeCell。使用 UnsafeCell 使我們的結構隱式!Sync,Rust 不允許全局靜態變量。這就是爲什麼我們還必須手動實現 Synctrait 來告訴 Rust 我們知道我們有責任使這種數據類型成爲線程安全的(而我們完全忽略了這一點)。

該結構被標記爲 #[repr(C)] 的原因很簡單,以便我們可以手動指定對齊方式。這樣我們就可以確保即使是 arena 中的第一個字節(以及我們返回的第一個指針的擴展)也具有 32 位對齊,這應該可以滿足大多數數據結構。

現在爲特徵的 GlobalAlloc 的實際實現:

unsafe impl GlobalAlloc for SimpleAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let size = layout.size();
        let align = layout.align();

        // Find the next address that has the right alignment.
        let idx = (*self.head.get()).next_multiple_of(align);
        // Bump the head to the next free byte
        *self.head.get() = idx + size;
        let arena: &mut [u8; ARENA_SIZE] = &mut (*self.arena.get());
        // If we ran out of arena space, we return a null pointer, which
        // signals a failed allocation.
        match arena.get_mut(idx) {
            Some(item) => item as *mut u8,
            _ => core::ptr::null_mut(),
        }
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        /* lol */
    }
}

#[global_allocator] 不僅僅是 #[no_std]!你還可以使用它來覆蓋 Rust 的默認分配器並將其替換爲你自己的分配器,因爲 Rust 的默認分配器消耗大約 10K Wasm 空間。

wee_alloc

當然,你不必自己實現分配器。事實上,依靠經過良好測試的實施可能是明智的。處理分配器中的錯誤和微妙的內存損壞並不好玩。

許多指南推薦 wee_alloc,這是一個非常小的 (<1KB) 分配器,由 Rust WebAssembly 團隊編寫,也可以釋放內存。可悲的是,它似乎沒有得到維護,並且有一個關於內存損壞和內存泄漏的未解決問題 [26]。

在任何相當複雜的 WebAssembly 模塊中,Rust 的默認分配器消耗的 10KB 只是整個模塊大小的一小部分,所以我建議堅持使用它並知道分配器經過良好測試和性能。

wasm-bindgen

現在我們已經完成了幾乎所有困難的事情,我們已經看到了使用 wasm-bindgen[27] 爲 WebAssembly 編寫 Rust 的便捷方法。

wasm-bindgen 的關鍵特性是 #[wasm_bindgen] 宏,我們可以將它放在我們想要導出的每個函數上。這個宏添加了我們在本文前面手動添加的相同編譯器指令,但它還做了一些更有用的事情。

例如,如果我們將上面的宏添加到我們的 add 函數中,它會發出另一個以數字格式 [28] 返回我們的函數 __wbindgen_describe_add 的描述。具體來說,我們函數的描述符如下所示:

Function(
    Function {
        arguments: [
            U32,
            U32,
        ],
        shim_idx: 0,
        ret: U32,
        inner_ret: Some(
            U32,
        ),
    },
)

這是一個非常簡單的函數,但是 wasm-bindgen 中的描述符能夠表示非常複雜的函數簽名。

展開: 如果你想查看宏發出的代碼 #[wasm_bindgen],請使用 rust-analyzer 的 “遞歸擴展宏” 功能。你可以通過命令面板在 VS Code 運行它。

這些描述符有什麼用?wasm-bindgen 不僅提供了一個宏,它還附帶了一個 CLI,我們可以使用它來對我們的 Wasm 二進制文件進行後處理。CLI 提取這些描述符並使用此信息生成自定義 JavaScript 綁定(然後刪除所有不再需要的描述符函數)。生成的 JavaScript 具有處理更高級別類型的所有例程,允許你無縫傳遞類型,例如字符串、ArrayBuffer 甚至閉包。

如果你想爲 WebAssembly 編寫 Rust,我推薦 wasm-bindgen。wasm-bindgen 不適用於 #![no_std],但實際上這很少成爲問題。

wasm-pack

我還想提一下 wasm-pack[29],這是另一個用於 WebAssembly 的 Rust 工具。我們使用全套工具來編譯和處理我們的 WebAssembly 以優化最終結果。wasm-pack 是一種對大多數這些過程進行編碼的工具。它可以使用針對 WebAssembly 優化的所有設置引導一個新的 Rust 項目。它構建項目並使用所有正確的標誌調用 cargo,然後它調用 wasm-bindgen CLI 來生成綁定,最後它運行 wasm-opt 以確保我們不會留下任何性能問題。wasm-pack 還能夠準備你的 WebAssembly 模塊以發佈到 npm,但我個人從未使用過該功能。

總結

Rust 是一種用於 WebAssembly 的優秀語言。啓用 LTO 後,你將獲得非常小的模塊。Rust 的 WebAssembly 工具非常出色,自從我第一次在 Squoosh[30] 中使用它以來,它變得更好了。發出的膠水代碼 wasm-bindgen 既現代又 tree-shaken。看到它在幕後是如何工作的,我從中獲得了很多樂趣,它幫助我理解和欣賞所有工具爲我所做的事情。我希望你也有同感。非常感謝 Ingrid[31]、Ingvar[32] 和 Saul[33] 審閱這篇文章。

引用鏈接

[1] 如何在沒有 Emscripten 的情況下將 C 編譯爲 WebAssembly: https://surma.dev/things/c-to-webassembly
[2] wasm-bindgen: https://rustwasm.github.io/wasm-bindgen/
[3] WASI: https://wasi.dev/
[4] WebAssembly Binary Toolkit: https://github.com/WebAssembly/wabt
[5] Binarygen: https://github.com/WebAssembly/binaryen
[6] wasm-tools: https://github.com/bytecodealliance/wasm-tools
[7] 能夠清晰映射到 WebAssembly 的類型: https://webassembly.github.io/spec/core/syntax/types.html#number-types
[8] externhttps://doc.rust-lang.org/reference/items/functions.html#extern-function-qualifier
[9] C ABI: https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md
[10] C ABI: https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md
[11] 不足: https://github.com/rustwasm/team/issues/291
[12] 這篇文章: https://articles.bchlr.de/traits-dynamic-dispatch-upcasting
[13] Twiggy: https://rustwasm.github.io/twiggy/
[14] WebAssembly.Module.customSection() AP: https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Module/customSections
[15] LTO(Link Time Optimization,鏈接時優化): https://llvm.org/docs/LinkTimeOptimization.html
[16] binaryen: https://github.com/WebAssembly/binaryen
[17] 標準庫: https://docs.rs/std
[18] crates.io: https://crates.io/
[19] WASI: https://wasi.dev/
[20] Rust 還針對那些超受限環境: https://www.rust-lang.org/what/embedded
[21] Embedded Rust Book: https://docs.rust-embedded.org/book/
[22] Embedomicon: https://docs.rust-embedded.org/embedonomicon/
[23] core: https://docs.rs/core
[24] 解釋: https://docs.rust-embedded.org/embedonomicon/smallest-no-std.html#what-does-no_std-mean
[25] C 到 WebAssembly: https://surma.dev/things/c-to-webassembly
[26] 關於內存損壞和內存泄漏的未解決問題: https://github.com/rustwasm/wee_alloc/issues/105
[27] wasm-bindgen: https://rustwasm.github.io/wasm-bindgen/
[28] 數字格式: https://github.com/rustwasm/wasm-bindgen/blob/main/crates/cli-support/src/descriptor.rs
[29] wasm-pack: https://rustwasm.github.io/wasm-pack/
[30] Squoosh: https://squoosh.app/
[31] Ingrid: https://twitter.com/opinionatedpie
[32] Ingvar: https://twitter.com/rreverser
[33] Saul: https://twitter.com/saulecabrera/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WdINwAqaok0OSutOBUptLg