wasm32-unknown-unknown 的 C ABI 变更

2025 年 4 月 4 日 · Alex Crichton

wasm32-unknown-unknown 目标的 extern "C" ABI 自该目标诞生以来就一直使用非标准定义,因为它没有实现 WebAssembly 官方 C ABI,并且它还 泄露了 Rust 编译器和 LLVM 的内部编译器实现细节。这将在未来的 Rust 编译器版本中改变,届时将改为使用 官方 C ABI

本文详细介绍了此次变更的一些历史背景以及在此宣布的原因,但您也可以直接跳至 “我受影响吗?” 部分。

wasm32-unknown-unknown 的 C ABI 历史

wasm32-unknown-unknown 目标于 2017 年首次添加时,当时并未过多关注 extern "C" ABI 的具体定义。2018 年,添加了专用于 wasm 的 ABI 定义,并且该目标至今仍在使用此定义。随着时间的推移,这个定义变得越来越有问题,尽管一些问题已得到修复,但根本原因依然存在。

值得注意的是,这个 ABI 定义与 tool-conventions 中定义的 C API 不符,该文档是当前 WebAssembly 工具链之间应如何交互的标准。最初,除了 Emscripten 之外,所有基于 WebAssembly 的目标都使用了这个非标准定义,但这在 2021 年发生了变化,Rust 的 WASI 目标开始使用修正后的 ABI 定义。然而,wasm32-unknown-unknown 仍然沿用了非标准定义。

现在是时候纠正这个历史遗留问题了,Rust 编译器很快将对 wasm32-unknown-unknown 目标使用正确的 ABI 定义。然而,这意味着生成的 WebAssembly 二进制文件将与之前不同。

什么是 WebAssembly C ABI?

ABI 的定义回答了以下方面的问题:

  • 参数在哪些寄存器中传递?
  • 结果在哪些寄存器中传递?
  • 128 位整数如何作为参数传递?
  • union 如何作为返回值传递?
  • 参数何时通过内存而不是寄存器传递?
  • 类型在内存中的大小和对齐方式是什么?

对于 WebAssembly,这些问题的答案与原生平台略有不同。例如,WebAssembly 没有物理寄存器,并且所有函数都必须用类型进行标注。WebAssembly 确实拥有诸如 i32i64f32f64 等类型。这意味着对于 WebAssembly,ABI 需要定义如何用这些类型来表示值。

这就是 tool-conventions 文档的作用所在。该文档定义了如何在 WebAssembly 格式中表示 C 语言的基本类型,以及 C 语言的函数签名如何映射到 WebAssembly 的函数签名。例如,Rust 的 u32 由 WebAssembly 的 i32 表示,并直接作为函数参数传递。如果 Rust 结构 #[repr(C)] struct Pair(f32, f64) 从函数返回,那么将使用一个返回指针,该指针必须具有 8 字节对齐和 16 字节大小。

本质上,WebAssembly C ABI 充当了 C 类型系统和 WebAssembly 类型系统之间的桥梁。这包括内存布局以及 C 函数签名到 WebAssembly 函数签名的转换等细节。

wasm32-unknown-unknown 为何非标准?

尽管当前的 ABI 定义是非标准的,但其许多方面仍与 tool-conventions 指定的相同。例如,类型的大小/对齐方式与 C 中相同。主要区别在于函数签名的计算方式。一个例子(您可以在 godbolt 上跟随查看)是:

#[repr(C)]
pub struct Pair {
    x: u32,
    y: u32,
}

#[unsafe(no_mangle)]
pub extern "C" fn pair_add(pair: Pair) -> u32 {
    pair.x + pair.y
}

这将生成以下 WebAssembly 函数:

(func $pair_add (param i32 i32) (result i32)
  local.get 1
  local.get 0
  i32.add
)

值得注意的是,您可以在这里看到结构体 Pair 被“展开”(splatted)成了它的两个组成部分,因此实际的 $pair_add 函数接受两个参数,即 xy 字段。然而,tool-conventions 特别指出,“其他结构体(struct)或联合体(union)”是间接传递的,特别是通过内存。我们可以通过编译以下 C 代码来验证这一点:

struct Pair {
    unsigned x;
    unsigned y;
};

unsigned pair_add(struct Pair pair) {
    return pair.x + pair.y;
}

这将生成以下函数:

(func (param i32) (result i32)
  local.get 0
  i32.load offset=4
  local.get 0
  i32.load
  i32.add
)

在这里,我们可以确信地看到,pair 是在线性内存中传递的,并且这个函数只有一个参数,而不是两个。这个参数是一个指向存储 xy 字段的线性内存的指针。

Diplomat 项目整理了一份比这详细得多的概述,如果您想深入了解,建议查阅。

为何这个问题没有早点修复?

对于 wasm32-unknown-unknown,在 2021 年 WASI 的 ABI 更新时,大家已经很清楚其 ABI 是非标准的。那么,为何不像 WASI 那样修复这个 ABI 呢?最初的主要原因在于 wasm-bindgen 项目

wasm-bindgen 中,目标是让 Rust 轻松集成到使用 WebAssembly 的网页浏览器中。JavaScript 用于与宿主 API 和 Rust 模块本身进行交互。自然地,这种通信涉及到许多 ABI 细节!问题在于 wasm-bindgen 依赖于上面的例子,特别是 Pair 被“展开”(splatted)到参数中,而不是间接传递。如果参数在内存中传递,生成的 JS 将无法正常工作。

当时发现这个问题时,修复 wasm-bindgen 以使其不再依赖这种展开行为被认为相当困难。当时也认为这不是一个普遍问题,而且编译器维护一个非标准 ABI 的成本也不高。然而,多年来压力越来越大。Rust 编译器为了绕过 wasm32-unknown-unknown 上的非标准 C ABI,不得不背负着不断增长的各种“补丁”(hacks)。此外,越来越多的项目开始依赖这种“展开”行为,依赖非标准行为的未知项目数量可能更多,风险也随之增大。

2023 年底,wasm-bindgen 项目修复了绑定生成,使其不再受 `extern "C"` 切换到标准定义的影响。在接下来的几个月里,rustc 添加了一个 future-incompat lint,专门用于引导使用旧版本 wasm-bindgen 的用户迁移到“已修复”的版本。这是为了在经过足够时间后,能够改变 wasm32-unknown-unknown 的 ABI。自 2025 年初以来,使用旧版本 wasm-bindgen 的用户现在将收到一个硬性错误,要求他们进行升级。

然而,尽管贡献者们付出了所有这些巨大的努力,现在发现除了 wasm-bindgen 之外,还有更多项目依赖于这个非标准 ABI 定义。因此,这篇博文旨在向 wasm32-unknown-unknown 的其他用户发出通知,告知 ABI 即将发生破坏性变更,并且项目可能需要进行修改。

我受影响吗?

如果您不使用 wasm32-unknown-unknown 目标,则不受此变更影响。如果您在 wasm32-unknown-unknown 目标上不使用 extern "C",则也不受影响。然而,如果您符合上述情况(使用 wasm32-unknown-unknown 且使用 extern "C"),则可能受影响!

要确定您的项目受到的影响,您可以使用以下几种工具:

  • Rust 编译器中添加了一个新的 future-incompat 警告,如果检测到在 ABI 变更时会发生改变的签名,就会发出警告。
  • 2023 年,Rust 编译器中添加了 -Zwasm-c-abi=(legacy|spec) 标志。该标志默认为 -Zwasm-c-abi=legacy,即非标准定义。代码可以使用 -Zwasm-c-abi=spec 来为 crate 使用 C ABI 的标准定义,以测试变更是否可行。

测试您的 crate 的最佳方法是使用 nightly-2025-03-27 或更高版本进行编译,确保没有警告,然后测试您的项目在使用 -Zwasm-c-abi=spec 时仍能正常工作。如果所有这些都通过,那么您就没问题了,即将到来的 C ABI 变更不会影响您的项目。

我受影响了,怎么办?

那么,您正在使用 wasm32-unknown-unknown,使用 extern "C",并且 Nightly 编译器发出了警告。此外,当使用 -Zwasm-c-abi=spec 编译时,您的项目无法正常工作。现在怎么办?

目前,对您来说不幸的是,这将是一个有些艰难的过渡期。您有几种选择,但它们都有各自的缺点:

  1. 将您的 Rust 编译器版本固定到当前的稳定版本,直到 ABI 变更后再更新。这意味着您不会收到任何编译器警告(因为旧编译器不会警告),并且在 ABI 变更时也不会受到影响(因为您没有切换编译器)。最终,当您更新到默认使用 -Zwasm-c-abi=spec 的稳定编译器时,您将不得不迁移您的 JS 或绑定代码以适应新的 ABI。

  2. 更新到 Rust nightly 作为您的编译器并传入 -Zwasm-c-abi=spec。这是将 (1) 中所需的工作提前到您的目标上进行。您现在就可以让您的项目兼容 -Zwasm-c-abi=spec。这种方法的缺点是您的项目将只能在 nightly 编译器和 -Zwasm-c-abi=spec 的组合下工作,并且在默认设置切换之前您将无法使用稳定版本。

  3. 更新您的项目,使其不再依赖 -Zwasm-c-abi=legacy 的非标准行为。例如,这包括不在参数中按值传递结构体(structs-by-value)。例如,您可以传递上面的 &Pair,而不是 Pair。这与上面的 (2) 类似,立即进行更新项目的工作,但好处是可以继续在稳定版 Rust 上工作。然而,这种方法的缺点是,在某些情况下,您可能无法轻易修改或更新您的 C ABI。

  4. 更新到 Rust nightly 作为您的编译器并传入 -Zwasm-c-abi=legacy。这暂时会消除编译器警告,但请注意,ABI 未来仍会改变,并且 -Zwasm-c-abi=legacy 选项将完全被移除。当 -Zwasm-c-abi=legacy 选项被移除后,唯一的选项将是标准 C ABI,也就是当前 -Zwasm-c-abi=spec 所启用的。

如果您有任何不确定、疑问或困难,请随时通过future-incompat 警告的跟踪 issue 或在 Zulip 上联系。

ABI 变更时间线

目前,还没有关于默认 ABI 如何变更的精确时间表。然而,预计变更将花费 3-6 个月左右的时间,大致会是这样的:

  • 2025 年 3 月:(即将到来)- 编译器中将添加一个 future-incompat 警告,以警告项目是否受此 ABI 变更的影响。
  • 2025 年 5 月 15 日:此 future-incompat 警告将随 1.87.0 版本到达稳定版 Rust 通道。
  • 2025 年夏季:(左右)- -Zwasm-c-abi 标志将从编译器中移除,legacy 选项也将完全移除。

-Zwasm-c-abi 具体何时移除将取决于社区的反馈以及 future-incompat 警告触发的频率。然而,希望在 Rust 1.87.0 稳定发布后不久,旧的 legacy ABI 行为就可以被移除。