长期以来,Rust 在 x86-32 和 x86-64 架构上关于 128 位整数的对齐方式与 C 语言存在不一致。这个问题最近得到了解决,但这个修复带来了一些值得注意的影响。
作为用户,您很可能不需要担心这些更改,除非您正在
- 假设 `i128`/`u128` 的对齐方式,而不是使用 `align_of`
- 忽略 `improper_ctypes*` lint,并在 FFI 中使用这些类型
此外,除了 x86-32 和 x86-64 之外的架构没有任何变化。如果您的代码大量使用 128 位整数,您可能会注意到运行时性能有所提高,但可能会增加内存使用量。
这篇文章记录了问题所在,为了解决问题所做的更改,以及对这些更改的预期。如果您已经熟悉这个问题,只是在寻找兼容性矩阵,请跳转到兼容性部分。
背景
数据类型有两个与它们在内存中的排列方式相关的内在值:大小和对齐方式。类型的大小是它在内存中占用的空间量,而对齐方式指定了它允许放置的地址。
像基本类型这样的简单类型的大小通常是明确的,即它们表示的数据的确切大小,没有填充(未使用的空间)。例如,一个 `i64` 总是具有 64 位或 8 字节的大小。
然而,对齐方式可能有所不同。一个 8 字节的整数 *可以* 存储在任何内存地址(1 字节对齐),但是大多数 64 位计算机如果将其存储在 8 的倍数上(8 字节对齐),将会获得最佳性能。因此,像其他语言一样,Rust 中的基本类型默认具有这种最有效的对齐方式。当创建复合类型时,可以看到这种影响(playground 链接)
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)规定了它们应该是什么。在 System V (Unix & Linux) 的 x86-64 psABI (处理器特定的 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 中报告的问题。
调用约定问题
还有一个问题:当将 128 位整数作为函数参数传递时,LLVM 并非总是做正确的事情。这是 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" /* 2nd slot (rsi): lower half of `a` */
"movq $0x1122334455667788, %rdx \n\t" /* 3rd 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 中得到解决(Clang 18 和 Rust 1.78 在使用捆绑的 LLVM 时)。
但是,`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 的 Rust(从 1.78 版本开始的默认版本)中的 `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 后会更加确定。关于在即将发布的版本中取消 lint,正在进行讨论,但我们希望谨慎,避免为那些使用较旧 LLVM 构建的 Rust 编译器的用户引入静默中断。