长期以来,Rust 在 x86-32 和 x86-64 架构上与 C 在 128 位整数的对齐方面存在不一致。这个问题最近已得到解决,但修复带来了值得注意的一些影响。
作为用户,您很可能不需要担心这些更改,除非您正在
- 假设 `i128`/`u128` 的对齐方式,而不是使用 `align_of`
- 忽略 `improper_ctypes*` 警告并在 FFI 中使用这些类型
此外,除了 x86-32 和 x86-64 之外的其他架构没有任何更改。如果您的代码大量使用 128 位整数,您可能会注意到运行时性能提高,但可能会以增加内存使用为代价。
这篇文章记录了问题是什么,为了解决它做了哪些更改,以及这些更改带来的预期结果。如果您已经熟悉这个问题,并且只想知道兼容性矩阵,请跳到 兼容性 部分。
背景
数据类型有两个与它们如何在内存中排列相关的内在值:大小和对齐方式。类型的 size 是它在内存中占用的空间量,而它的 alignment 指定了它被允许放置的地址。
像基本类型这样的简单类型的 size 通常是明确的,它们代表的数据的精确大小,没有填充(未使用的空间)。例如,一个 `i64` 的 size 始终为 64 位或 8 字节。
然而,alignment 可能会变化。一个 8 字节的整数 *可以* 存储在任何内存地址(1 字节对齐),但大多数 64 位计算机如果它存储在 8 的倍数(8 字节对齐)处,将获得最佳性能。因此,与其他语言一样,Rust 中的基本类型默认具有这种最有效的对齐方式。当创建复合类型时,可以观察到这种影响 (游乐场链接)
use core::mem::{align_of, offset_of};
#[repr(C)]
struct Foo {
a: u8, // 1-byte aligned
b: u16, // 2-byte aligned
}
#[repr(C)]
struct Bar {
a: u8, // 1-byte aligned
b: u64, // 8-byte aligned
}
println!("Offset of b (u16) in Foo: {}", offset_of!(Foo, b));
println!("Alignment of Foo: {}", align_of::<Foo>());
println!("Offset of b (u64) in Bar: {}", offset_of!(Bar, b));
println!("Alignment of Bar: {}", align_of::<Bar>());
输出
Offset of b (u16) in Foo: 2
Alignment of Foo: 2
Offset of b (u64) in Bar: 8
Alignment of Bar: 8
我们看到,在一个结构体中,一个类型将始终被放置在这样的位置,即它的偏移量是其对齐方式的倍数 - 即使这意味着未使用的空间(当不使用 `repr(C)` 时,Rust 默认情况下会将此空间最小化)。
这些数字不是任意的;应用程序二进制接口 (ABI) 规定了它们应该是什么。在 x86-64 psABI(针对 System V(Unix & Linux)的处理器特定 ABI)中,*图 3.1:标量类型* 告诉我们基本类型应该如何表示
C 类型 | Rust 等价物 | sizeof |
对齐方式(字节) |
---|---|---|---|
char |
i8 |
1 | 1 |
unsigned char |
u8 |
1 | 1 |
short |
i16 |
2 | 2 |
unsigned short |
u16 |
2 | 2 |
long |
i64 |
8 | 8 |
unsigned long |
u64 |
8 | 8 |
ABI 只指定 C 类型,但 Rust 为了兼容性和性能优势遵循相同的定义。
不正确的对齐问题
如果两个实现对数据类型的对齐方式有分歧,它们就不能可靠地共享包含该类型的数据。Rust 对 128 位类型存在不一致的对齐方式
println!("alignment of i128: {}", align_of::<i128>());
// rustc 1.76.0
alignment of i128: 8
printf("alignment of __int128: %zu\n", _Alignof(__int128));
// gcc 13.2
alignment of __int128: 16
// clang 17.0.1
alignment of __int128: 16
(Godbolt 链接) 回顾 psABI,我们可以看到 Rust 在这里对齐方式错误
C 类型 | Rust 等价物 | sizeof |
对齐方式(字节) |
---|---|---|---|
__int128 |
i128 |
16 | 16 |
unsigned __int128 |
u128 |
16 | 16 |
事实证明,这不是因为 Rust 正在积极地做错什么:基本类型的布局来自 LLVM 代码生成后端,Rust 和 Clang 等其他语言都使用它,并且它将 `i128` 的对齐方式硬编码为 8 字节。
Clang 仅因为一个解决方法才使用正确的对齐方式,其中对齐方式在将类型传递给 LLVM 之前被手动设置为 16 字节。这修复了布局问题,但一直是其他一些小问题的根源。12 Rust 没有进行这种手动调整,因此在 https://github.com/rust-lang/rust/issues/54341 中报告了这个问题。
调用约定问题
还有一个问题:LLVM 在将 128 位整数作为函数参数传递时并不总是做正确的事情。这是一个 LLVM 中已知的问题,在 发现它与 Rust 的相关性之前。
在调用函数时,参数被传递到寄存器(CPU 中的特殊存储位置),直到没有更多插槽,然后它们被“溢出”到堆栈(程序的内存)。ABI 告诉我们在这里也要做什么,在 *3.2.3 参数传递* 部分
类型为 `__int128` 的参数提供与 INTEGER 相同的操作,但它们不适合一个通用寄存器,而是需要两个寄存器。为了分类目的,`__int128` 被视为如果它被实现为
typedef struct { long low, high; } __int128;
除了类型为 `__int128` 的参数存储在内存中必须对齐到 16 字节边界。
我们可以通过手动实现调用约定来尝试一下。在下面的 C 示例中,内联汇编用于调用 `foo(0xaf, val, val, val)`,其中 `val` 为 `0x11223344556677889900aabbccddeeff`。
x86-64 使用寄存器 `rdi`、`rsi`、`rdx`、`rcx`、`r8` 和 `r9` 按此顺序传递函数参数(你猜对了,这也包含在 ABI 中)。每个寄存器都适合一个字(64 位),任何不适合的都会被 `push` 到堆栈。
/* full example at <https://godbolt.org/z/5c8cb5cxs> */
/* to see the issue, we need a padding value to "mess up" argument alignment */
void foo(char pad, __int128 a, __int128 b, __int128 c) {
printf("%#x\n", pad & 0xff);
print_i128(a);
print_i128(b);
print_i128(c);
}
int main() {
asm(
/* load arguments that fit in registers */
"movl $0xaf, %edi \n\t" /* 1st slot (edi): padding char (`edi` is the
* same as `rdi`, just a smaller access size) */
"movq $0x9900aabbccddeeff, %rsi \n\t" /* 2rd slot (rsi): lower half of `a` */
"movq $0x1122334455667788, %rdx \n\t" /* 3nd slot (rdx): upper half of `a` */
"movq $0x9900aabbccddeeff, %rcx \n\t" /* 4th slot (rcx): lower half of `b` */
"movq $0x1122334455667788, %r8 \n\t" /* 5th slot (r8): upper half of `b` */
"movq $0xdeadbeef4c0ffee0, %r9 \n\t" /* 6th slot (r9): should be unused, but
* let's trick clang! */
/* reuse our stored registers to load the stack */
"pushq %rdx \n\t" /* upper half of `c` gets passed on the stack */
"pushq %rsi \n\t" /* lower half of `c` gets passed on the stack */
"call foo \n\t" /* call the function */
"addq $16, %rsp \n\t" /* reset the stack */
);
}
使用 GCC 运行上述代码会打印以下预期输出
0xaf
0x11223344556677889900aabbccddeeff
0x11223344556677889900aabbccddeeff
0x11223344556677889900aabbccddeeff
但使用 Clang 17 运行会打印
0xaf
0x11223344556677889900aabbccddeeff
0x11223344556677889900aabbccddeeff
0x9900aabbccddeeffdeadbeef4c0ffee0
//^^^^^^^^^^^^^^^^ this should be the lower half
// ^^^^^^^^^^^^^^^^ look familiar?
意外!
这说明了第二个问题:LLVM 期望 `i128` 在可能的情况下被部分传递到寄存器,部分传递到堆栈,但这不被 ABI 允许。
由于这种行为来自 LLVM,并且没有合理的解决方法,因此这在 Clang 和 Rust 中都是一个问题。
解决方案
解决这些问题是许多人长期努力的结果,始于编译器团队成员 Simonas Kazlauskas 在 2017 年的一个补丁:D28990。不幸的是,它最终被回滚了。后来 LLVM 贡献者 Harald van Dijk 在 D86310 中再次尝试,这是最终在 2023 年 10 月发布的版本。
大约在同一时间,Nikita Popov 使用 D158169 修复了调用约定问题。这两个更改都已包含在 LLVM 18 中,这意味着所有相关的 ABI 问题都将在使用此版本的 Clang 和 Rust 中得到解决(使用捆绑的 LLVM 的 Clang 18 和 Rust 1.78)。
但是,`rustc` 也可以使用系统上安装的 LLVM 版本,而不是捆绑版本,该版本可能更旧。为了减轻使用相同 `rustc` 版本时对齐方式不同的问题,一个提案被提出,以手动修正对齐方式,就像 Clang 一直在做的那样。Matthew Maurer 在 #116672 中实现了这一点。
自从这些更改之后,Rust 现在产生了正确的对齐方式
println!("alignment of i128: {}", align_of::<i128>());
// rustc 1.77.0
alignment of i128: 16
如上所述,ABI 指定数据类型对齐方式的部分原因是它在该架构上更高效。我们实际上亲眼目睹了这一点:最初的性能运行,其中包含手动对齐方式更改,显示出编译器性能的非凡改进(它严重依赖 128 位整数来处理整数文字)。增加对齐方式的缺点是,复合类型并不总是能很好地放在一起,从而导致使用量增加。不幸的是,这意味着需要牺牲一些性能提升,以避免增加内存占用。
兼容性
最重要的是,这些修复如何改变了兼容性。简而言之,使用 LLVM 18(从 1.78 开始的默认版本)的 Rust 中的 `i128` 和 `u128` 将与任何版本的 GCC 完全兼容,以及 Clang 18 及更高版本(2024 年 3 月发布)。所有其他组合都有一些不兼容的情况,在下表中总结
编译器 1 | 编译器 2 | 状态 |
---|---|---|
Rust ≥ 1.78,捆绑 LLVM(18) | GCC(任何版本) | 完全兼容 |
Rust ≥ 1.78,捆绑 LLVM(18) | Clang ≥ 18 | 完全兼容 |
Rust ≥ 1.77,LLVM ≥ 18 | GCC(任何版本) | 完全兼容 |
Rust ≥ 1.77,LLVM ≥ 18 | Clang ≥ 18 | 完全兼容 |
Rust ≥ 1.77,LLVM ≥ 18 | Clang < 18 | 存储兼容,存在调用错误 |
Rust ≥ 1.77,LLVM < 18 | GCC(任何版本) | 存储兼容,存在调用错误 |
Rust ≥ 1.77,LLVM < 18 | Clang(任何版本) | 存储兼容,存在调用错误 |
Rust < 1.773 | GCC(任何版本) | 不兼容 |
Rust < 1.773 | Clang(任何版本) | 不兼容 |
GCC(任何版本) | Clang ≥ 18 | 完全兼容 |
GCC(任何版本) | Clang < 18 | 存储兼容,存在调用错误 |
影响和未来步骤
如引言中所述,除非您已经在这些类型上做了一些有问题的操作,否则大多数用户不会注意到此更改的影响。
从 Rust 1.77 开始,您可以开始在 FFI 中尝试使用 128 位整数,在 1.78 中更新 LLVM 后,您可以更加确定。关于在即将发布的版本中解除警告的 正在进行的讨论,但我们希望谨慎行事,避免为那些 Rust 编译器可能使用旧版 LLVM 构建的用户引入静默错误。