【翻译】Rust笔记:Type Layouts和ABIs
本文阐述了Rust中类型在内存中的布局及ABI相关的知识,对于学习rust ffi很有帮助。
名词解释
英文 | 中文 |
---|---|
Platform | 平台 |
Two’s complement | 补码 |
Alignment | 字节对齐 |
padding | 填充 |
layout | (类型在内存里的)布局 |
SIMD | single instruction multiple data,单指令多数据 |
Endianness/byte-order | 端序、字节序 |
big-endian/little-endian | 大端序、小端序 |
calling convention | 调用约定 |
vtable | 虚函数表 |
stack frame | 栈帧 |
解剖平台
市面上有很多独树一帜的平台,C语言就像被绑架了一样要支持所有的这些平台。有些平台的变种非常的令人讨厌但是在技术上又是合理的,因为这些都是当时平台间确实是存在的差异所造成的。比如说不用补码来表示整数,又比如说不用8位来表示一个字节(char)。其他的则是C语言中人为的错误,例如:整型大小的模糊定义以及类型提升规则。
曾经C语言想要解决的很多问题,很大程度上已经不存在或者被放到更底层的平台了。因此,Rust在兼具C的跨平台兼容性的情况下,得以定义它所支持的平台的更多特性。
像Rust一样完全支持一个平台,标准C语言必须满足以下约束:
具备 8位不对齐的字节(字符)
布尔值为1个字节,其中
true=1
,false=0
整数用补码表示
如果二进制浮点数存在的话(我们可以禁用),需要支持IEEE 754(-2008?)定义的二进制浮点数
至少需要16位(就指针大小而言?)
支持将NULL转为0
(在运行标准库的时候还会有其他约束,如支持原子操作)
对现代的程序员来说,这些都是非常合理的约束。实际上,如果上述约束中的任意一条不成立都会让大部分的程序员感到惊讶!据我所知,只有一些DSP(数字信号处理器)是不遵守上述约束的最后堡垒,因为它们真的不喜欢8位的字节。为了让99.9999%的Rust用户拥有更干净的语言,Rust不支持这些DSP。
Rust 明确支持下列平台特性,尽管它们马上就要消逝了:
16位指针(由社区志愿者维护,最低支持MSP430微处理器)
Rust可能会支持下列特性,但是还没有真正考虑过,可能我会做出了一个会让事情变得更糟糕的决定:
ptrdiffi_t = intptr_t = ssize_t
不成立的平台
剖析类型
类型定义了几个用于操作和读取类型值的属性。如果仅仅知道这些属性中的某一些,那么只有执行某些特定的操作才是安全的。
如果对某个类型一无所知,那么你唯一能做的就是以类型安全的方式向它传递指针。例如,你使用的库定义了一个类型,它需要你保留某些指向它的指针,但是有不希望你通过这些指针访问数据。这种情况下就可以通过向回调函数状态。Rust中把这种类型叫做外部类型。
截止撰写本文时,外部类型还是实验性的。struct MyType { }
语法也可以实现类似的目的,但是如果对这个类型存取值,编译器不会报错,而是默默地忽略这些操作。
大小(size)
类型最基本的属性是大小:表示在内存中占用多少字节。在仅知道一个类型的大小的情况下,可以操作元素为该类型的数组的指针偏移量,也可以在该类型的指针之间复制值。数组中各元素的地址之间的步长总是等于元素的大小。尽管寄存器不是Rust中的语义模型,但是类型的值也可以通过寄存器来存取。
与C/C++不同的是,Rust中,类型的大小可以是0(zero-sized type, ZST,零大小类型)。通常这种类型仅用来表示它在内存中不存在,因此对它进行读/写实际上不会做任何事情。
类型的大小也可能根据它的值来确定,例如[t]
和Trait
类型。这种类型没有实现本应该被实现的Sizedtrait。一些通用的函数如果也要适用于这种类型,则必须传入<T: ?Sized>
。
字节对齐
类型第二个最基本的属性是字节对齐:它在内存中的存储位置必须是多少字节的倍数(当存储在内存中时)。例如,一个按4字节对齐的类型只可以被存储在0、4、8等位置。有了大小和字节对齐,就可以分配内存来存储类型的值。
字节对齐的值最小为1,且必须等于2的n次幂。大小则是字节对齐的倍数。类型的字段通常按其最大对齐字节来对齐。为了满足字节对齐的要求,类型中需要引入填充字节,而填充字节在逻辑上是不会被初始化的,这是因为类型的大小或相对位置需要取整才能满足字节对齐的要求。不管对填充部分读还是写,都不能保证得到期望的结果。
字节对齐对硬件来说更是必不可少,因为硬件对操作有一定的一致性要求。在很多情况下,非对齐访问“只会”导致严重的性能下降,但是某些情况下,硬件会因为不对齐而抛出异常。在某种意义上,硬件怎么运行其实并不是很重要,因为编译器会假设指针是对齐的,如果不对齐会导致编译错误!
零大小类型的字节对齐可能会大于1(如,[u32; 0
]的字节对齐是u32
,也就是4)。尽管ZST并不在内存中,该类型的字段和指针仍然需要对齐,因此ZST类型也会影响包含该类型的组合类型的布局、大小和字节对齐。
值得一提的是,像i386系统 V ABI(x86 linux C ABI)等较老的ABI对齐的方式稍微有点奇怪。当一个double
类型放在结构体中的时候,它按4字节对齐,但是在堆上则永远按8字节对齐。Rust通过永远按4字节对齐来兼容这种情况,而不像C一样不能区别一个double
类型的指针是结构体的一部分还是指针自身的一部分。
偏移量
类型的偏移量指的是它每个字段的相对位置。偏移量在Rust中存在以下三种可能性:
偏移量是不确定的
偏移量的排列顺序是确定的,但是偏移量的精确值不确定
偏移量的精确值是确定的
这里对确定性的定义比较微妙。这里的确定性指的是:根据结构体的定义及其目标平台就能确定结构体内字段的偏移量。默认情况下,用户自定义的Rust类型的偏移量是不确定的,这是因为不同版本的编译器会选择不同的偏移量,或者后续的构建会导致产生不同的偏移量(尽管如此,我们永远不会将两个具有不同类型偏移量的rust代码链接在一起)。
看两个典型的例子:
// 虽然这两个结构体相同,但是并没有要求Rust编译器对它们的字段应用相同的偏移量
struct A(u32, u64);
struct B(u32, u64);
// 下面的结构体并不要求Rust编译器按照定义的顺序来构造字段。
// 例如:在内存中,y可能被放在x前面
struct Rect {
x: f32,
y: f32,
w: f32,
h: f32,
}
这么设计是出于两个目的:优化和模糊。
在优化方面,通常不依赖精确的结构体布局,因此上述的偏移设计十分有利于优化。尤其是对泛型结构,可能不存在一个适用于所有类型替换的最佳布局。例如,下面的结构体的所有字段就不能有一个单一的最佳顺序:
struct Impossible<T, U, V> {
t: T,
u: U,
v: V,
}
例如,用u16
,u16
和u32
分别替换T,U和V。只要u32
不是第二个元素,这个结构体就会紧密地排列。然而,不论我们选择怎样的顺序,都必须要在中间位置放一个元素,而且我们可能将中间元素的类型改为u32
,这就会使得这个顺序不是最佳的。因此,泛型结构中的字段没有一个单一的最佳排列顺序。
模糊(目前为止还没有被用到)的作用是允许字段的排列顺序随机化,以便更容易暴露隐藏的bug。
后面的章节中将会讨论到,某些注释会使人误以为字段是按顺序排列的。但是如果一个类型的字段没有确定的排列顺序,那么它的大小也可能是不确定的,这也会导致外层的类型也没有确定的偏移量。
例如,下面的结构的字段具有确定的排列顺序,但是各字段的偏移量是不确定的:
#[repr(C)]
struct MyStruct {
x: u32,
y: Vec<u8>,
z: u32,
}
Vec
没有确定的排序,因此尽管我们明确知道x
和y
存储位置的偏移值,我们也不能确定z
的偏移值或者MyStruct
的大小,因为它们依赖于y的大小,而它是不能确切知道的。因此,这个类型不适用于C的FFI。
实际上,也有可能是默认情况下对齐也是不确定的导致的?这样的话,y的偏移量也就未知了。这一点有待确认,Rust 开发者正在积极讨论中。
布局
类型的布局是指它的大小、字节对齐、偏移量在内存里的分布,并递归它的字段的布局。
如果知道一个类型的完整布局,就可以访问这个类型的所有字段。这也使得在具有兼容布局的类型间相互转换成为可能。我实在想不到一个严格的定义来描述兼容布局。通常来说,如果某块内存在两种类型中的位置相同,那么就可以把这两种类型中的一个类型看成是另一个,并知道这块内存反生了什么。这在Rust中是完全合法的,因为Rust没有基于类型的别名分析(TBAA,也叫做”严格别名”)。
例如,可以这样来实现继承:
#[repr(C)]
struct Base {
x: u32,
y: u64,
is_derived: bool,
}
#[repr(C)]
struct Derived {
base: Base,
z: f32,
}
fn process<'a>(data: &'a Base) {
print!("x: {}, y: {}", base.x, base.y);
if data.is_derived {
// upcast from Base to Derived
let derived = unsafe { mem::transmute::<&'a Base, &'a Derived>(data) };
print!(", z: {}", derived.z);
}
println!("");
}
如果能在C/C++中用兼容布局实现一个类型声明,就可以用引用的方式将这个类型的值通过FFI传递,在FFI的两边都可以读/写所有的字段。
ABI
如果只是在Rust中,知道一个类型的布局就足以做任何事情了,但是不足以支持与C语言无障碍通信。实际上,仅知道类型布局不足以支持按值传递的方式将数据传给C语言函数。只是因为在类型的ABI(Application Binary Interface)有其他的属性。一个类型的ABI决定了它的值是如何传递给C语言函数的。
据我所知,ABI唯一特有的属性是type-kind。虽然#[repr(C)] struct MyType(u32)
,u32
和f32
在给定的目标平台上的布局是兼容的,但是它们的ABI仍然不兼容,因为它们具有不同的type-kind。
截止本文,Rust需要考虑以下4种type-kind:
整数 integer(指针被当作整数,但是以后可能会有变动)
浮点数 float
集合 aggregate
Vector
注意:type-kind不是官方的概念,用在这里只是便于描述ABI。所有的规范性性文件中都找不到这个概念,但是它与sysv x64 ABI 3.2.3节中提到的类型的“class”概念类似。
整数和浮点type-kind代表了一个基本类型可能具有的两种类别。如果两个类型具有相同的大小、字节对齐以及基本type-kind,那么它们的ABI就完全兼容(例如,x64 linux平台上的u64和usize具有一致的ABI)。
结构体、枚举和组合类型默认具有集合type-kind。但是集合type-kind可以在一定的条件和注解下转换为另外三种type-kind。详见后文。
所有C的结构体和组合类型都具有集合type-kind,C的SIMD类型具有Vector type-kind,而C的枚举类型具有整数type-kind。
集合和vector type-kind的准确ABI取决于它们各字段值的准确ABI是什么。例如,下面两个类型在x64 linux平台上具有不同的ABI,虽然它们有相同的大小、字节对齐和type-kind:
#[repr(C)]
struct Homo(u64, u64);
#[repr(C)]
struct Hetero(u64, f64);
Rust内置类型的布局/ABI
下表列出了Rust中内置的核心基本类型的ABI、与他们ABI兼容的C/C++类型,以及给这些基本类型的取值范围(对一个类型存储其他值会被当作undefined):
大小 | 字节对齐 | type-kind | C/C++类型 | 取值范围 | |
---|---|---|---|---|---|
u8 | 1 | 1 | integer | uint8_t | all |
u16 | 2 | ≤2 | integer | uint16_t | all |
u32 | 4 | ≤4 | integer | uint32_t | all |
u64 | 8 | ≤8 | integer | uint64_t | all |
u128 | 16 | ≤16 | N/A | N/A | all |
usize | ptr | ptr | integer | uintptr_t | all |
i8 | 1 | 1 | integer | int8_t | all |
i16 | 2 | ≤2 | integer | int16_t | all |
i32 | 4 | ≤4 | integer | int32_t | all |
i64 | 8 | ≤8 | integer | int64_t | all |
i128 | 16 | ≤16 | N/A | N/A | all |
isize | ptr | ptr | integer | intptr_t | all |
*const T | ptr | ptr | integer | T* | all |
*mut T | ptr | ptr | integer | T* | all |
&T | ptr | ptr | integer | T* | not null |
&mut T | ptr | ptr | integer | T* | not null |
Option<&T> | ptr | ptr | integer | T* | all |
Option<&mut T> | ptr | ptr | integer | T* | all |
bool | 1 | 1 | integer | bool(_Bool) | 0=false, 1=true |
char | 4 | ≤4 | N/A | N/A | 0x0-0xD7FF, 0xE000-0x10FFFF |
f32 | 4 | ≤4 | float | float | all |
f64 | 8 | ≤8 | float | double | all |
理论上,u128
和i128
应该会兼容__int128
的ABI,但是由于llvm中的一个bug,目前还不兼容。同样的,我们可能也可以定义Rust的char
类型来兼容C++的char32_t
,但是目前为止还没有人太关心并尝试解决这个问题。
需要注意的是,实际上基本类型通常会对齐它们的大小。字节对齐比大小更小往往意味着这个类型是在当前平台通过软件仿真出来的(如,u64
在x86 linux平台的对齐值为4)。当然,类型的大小和字节对齐是编译目标的标准C实现所要关心的,这里我们主要关心的是兼容性。
Rust的数组([T; n]
)与C数组具有相同的布局:与T
对齐,大小为n * size_of::<T>()
,元素i
的字节偏移是i * size_of::<T>()
。但是数据目前没有特定的type-kind,因此数组不能在C中不能按值传递(void func(int x[5]
的语法与void func(int* x)
是相等的)。
除了()
外,元组没有特定的布局。()
的大小是0,对齐值是1。
特殊的布局和ABI
下面这些特殊的注释会影响布局和ABI:
#[repr(c)]
:作用于结构体时,会根据C的规则,强制将结构体的各字段按照声明的顺序排列,填充采用贪婪的方式。如果所有的字段都具有完整定义的布局,则这个类型具有完整定义的布局。注意,这个注释有纯rust的应用,如类型双关或者继承,而且#[repr(c)
实际上不保证被作用的类型是FFI安全的。#[repr(simd)]
:作用于结构体时,与#[repr(c)]
作用一样,只不过这个注释会给类型添加vctor type-kind。这个特性目前还不稳定,稳定之路也不可欺。从长远来看,稳定的vector type-kind会在标准库中提供使用。短期内,可以通过simd
crate来使用它的不稳定版本。#[repr(transparent)]
:作用于只有一个字段的结构体时,使得这个结构体具有它的字段的ABI。所以,如果结构体包含一个i32
类型的字段,那么它就有了i32
的ABI。通常情况下,只有需要获得匹配的type-kind时才会这么做,因为大小和字节对齐会自动匹配字段类型。这个特性在构造FFI安全的新整数类型时尤其有用。#[repr(packed(N))]
:作用于结构体,移除所有的尾部填充并设置类型的字节对齐为N,使得它与C中包装好的结构体兼容。注意,这会使得结构体中的字段布局时产生错位。直接访问这些字段会生成代码去管理这种不对齐,但是通过指针访问却是危险的,因为编译器会“忘记”这些字段是不对齐的,从而按对齐的方式访问,以致发生 undefined行为。#[repr(align=x)]
:作用于结构体,强制结构体至少向x
对齐。这个特性可能会影响大小。#[repr(c)]
:作用于没有字段的枚举,使得该枚举变量的ABI与声明相同的C枚举的ABI相同。所以,该枚举会具有目标C枚举的任意整数类型(通常是int
?)的整数type-kind,大小和对齐。注意,不像C一样,Rust中不能不赋值。#[repr(int)]
(其中int
是任意的基础类型,如u8
):作用于没有字段的枚举,使得该枚举具有与给定整数类型相同的ABI。这一点对于匹配C++中的enum MyEnum: some-int-type
非常有用。注意,不像C一样,Rust中不能不赋值。#[repr(int)]
或者#[repr(C)]
:作用于有字段的枚举,使得该枚举有兼容C的标记化组合表示特性
上述是我知道的Rust中所有用来定义内存布局和ABI的内容!
扩展阅读
C语言整数层级结构
C语言需要解决两个问题:不同平台的字节(可寻址内存的最小单位)大小是不同的;不同的平台具有不同的“原生”(效率最高/最重要)整数大小。
C语言从两个方面来解决这个问题:给目标平台的内存单位定义一个类型(char
),然后定义一套整数的层级结构,不同层级间具有不同的大小约束。理论上,这样C代码就是可移植的,可以在10位、16位、32位等所有的平台运行良好。
对主要的整数类型的约束如下:
char
至少有8位,所有其他类型整数必须是这个大小(CHAR_BIT
)的整数倍short
至少有16位,同时至少要有一个char
的位数int
至少有short
的位数(目的是要作为“原生”整数大小)long
至少有32位,同时至少要有一个int
的位数long long
至少有64位,同时至少有一个long
的位数
表面上看,这套整数层级结构十分合理:如果想存一个16位的值,那就用short
类型;如果想存一个32位的值,就用long
类型。存多大的位数都可以,但是这可能吗?
答案是否,因为事实证明了解类型准确的大小是很重要的!如果想要从某些二进制格式中精确地读/写32位,应该怎么做呢?如果用long
,可能会访问到64位!另外,哪种类型适合用于存储指针呢?(intptr_t
只在C99中加入了)
这不仅仅是理论上的顾虑。在32位机时代,把int
准确地设定为32位是约定俗成的,以至于64位硬件刚开始出现的时候,编译器开发者被迫将int
类型仍定义成32位,因为将int
定义为其它位数会使得大部分软件彻底无法运行。
当然,关于int
的点都是基于它是“原生”的整数大小的假设,这反过来让编译器开发者相信,在位数真的有影响的情况下,如果允许int
隐式提升到64位,会导致未定义的有符号整数溢出。
曾经又一封很好的编译器开发(gcc?)邮件讲述了这段历史,但是我找不到了。所以暂且用这篇文章的讨论来解释这个问题。
端序
对于整数和浮点数,端序(也叫 字节序)指定了一个值的各个字节的排列方式。在大端序编码系统中,
字节的排列就跟我们在纸上写数字一样:最有标志性的的字节在前面。在小端序系统中则是最不具意义的字节在前面。在我来看,这个问题可以说上是牛津逗号的系统编程版本,该用哪种字节序并不重要,因人而异,所以这两种端序都很常见。
现在,小端序逐渐赢得了这场争夺,因为在新硬件平台上(如所有的x64芯片和大部分ARM芯片)普遍用小端序作为它们的原生格式,而大端序则被降级到仅用作为各种随机格式的存储/线路编码。
这么说来,在不知道一个平台的原生端序的情况下写程序也是很简单的事情,所以让Rust去支持其他的大端序平台也不是什么大问题。
分段架构
这里的分段架构指的是具有相同运行时表示的指针实际上指向不同的内存区域,因为它们被关联到了不同的片段。
John Regehr提供的一个例子是ATmega128,它是一个有4个片段:SRAM, EEPROM, ROM和I/O的8位微处理器。
我认为分段会给编程模型增添复杂度,原因有三:
不同片段的指针可能会有不同的属性/要求;
不同片段的指针之间如何进行比较尚不明确;
分段需要将指针大小和指针偏移大小去耦合,但是Rust目前把它们同等看待(
usize
)
不幸的是,到这里我没有头绪了,对这些问题我只了解了皮毛。所以,我暂时不在这里讨论了,让其他人来解决吧!
调用约定
不同的人对ABI有不同的理解。到目前为止,可以说它是“为了让至少两个东西能够良好工作所需要满足的条件的实现细节”的通用术语。本文中ABI的概念涵盖类型的内存布局以及不同类型/值在C函数之间是如何传递的两方面,因为它们是Rust ABI所保证的且确实有用。
Rust的ABI目前还有一些没有规范化或不稳定的细节,如trait对象的虚函数表的排列,以及链接器/调试符号如何被拆分。如果你不知道我在说什么,也没关系,因为你现在不必关心这些内容!(但是仍有人不断尝试了解…)
无论如何,这里我想重点讲调用约定**,它与ABI的参数/返回值有关。
为了简单起见,我只关注C语言中调用约定相关的内容,因为C语言广泛使用在流行的现代硬件和操作系统(如:x86, x64, AArch64; Mac, Windows, Linux, Android, iOS)上。这里我不会展开讨论一些基础知识,读者可能会感兴趣:
调用约定的问题和作用
首先说问题:通常CPU调用一个函数也会有native概念,但是它往往比编程语言的函数调用简单得多。最简单的形式, 一个调用指令仅仅告诉CPU跳转到一个新的指令集并执行这些指令。但是我们了解的大部分编程语言中的函数都有参数,因此我们需要定义某种方式让函数调用者设置状态,以便被调用的函数能找到这些参数。函数返回值也类似,要求被调用的函数设置状态,以便函数调用者能够它中止的地方继续执行,并取得返回值。
在函数调用的调用者与被调用者之间传递状态的方法主要有两种:存在寄存器中,存在栈中。关于这两种方法孰优孰劣有大量的讨论,我没有完全理解它们,但是这里我会尝试给出一些粗略观点。
寄存器是CPU最基本的可观察全局(线程局部)状态。它们的访问速度极快,但是通常也很小。通常CPU要完成任何事情都需要使用寄存器。CPU指令可以理解为具有自己的特殊ABI的微小内置函数,且这些ABI通常在寄存器之间传递参数/返回值。一个优秀且稳定的现代化CPU会提供大约32个64位的通用寄存器。小于1KB的工作空间!SIMD寄存器可能会增加到几KB,但是它们也还是不方便使用。更多关于寄存器工作空间大小设置的有趣细节请参考寄存器重命名!
值被传递给寄存器,是真的将它们放在寄存器中!如果一个参数应该通过寄存器1被传递, 调用函数在执行调用前要保证这个参数值在寄存器1中,当被调用函数运行时就知道寄存器1保存了这个参数值。类似的,如果返回了某个值在寄存器1中,在被调用函数将控制权返回给调用函数之前,被调用函数只需确保这个值在寄存器1中。
栈是用RAM为线程扩展工作空间的简单抽象。栈按照比寄存器大得多的固定最大值(如今通常大约为8M)连续分配。最简单的形式,当一个函数被调用时,会先要求栈“push”足够的空间来保存所有可能需要的状态,当函数返回值,同样大小的空间会被“pop”出来。每个函数所请求的这块空间被称为栈帧。更多有趣的细节,请参考alloca和the red zone)。
栈帧的入栈和出栈的具体细节是另一个与调用约定相关的问题,但这里我们不展开讨论。我们只需要知道栈大小是可预测的,通过将值放在调用函数栈帧末尾或放在被调用函数栈帧的起始处,值就可以在栈中在函数之间被传递。无论那种方式,不负责保存值的函数都会假设对方的栈帧中有足够的空间保存参数或返回值,从而根据需要随意读写这块内存。
栈的工作空间大,所带来的主要问题是使用它会比寄存器更慢。即便使用现代化硬件,由于缓存和推测执行等技术的存在,这是一个复杂的问题。无论如何,我们假设将内容保存在栈和寄存器中都是理想的,继续我们的讨论。
另请注意,为了避免将很大的值拷贝到寄存器或栈中,可能会传递一个指向这个值(无论是在栈还是在寄存器中)的指针,尽管函数声明可能要求按值传递。这个策略在一个很大的值被多个函数调用使用的情况下尤其有效。
理解调用约定我们需要记住的最后一件事情是我们的约束。我们需要ABI在完全静态或动态的上下文中工作。也就是说,调用函数和被调用函数唯一都知道的内容只有被调用函数的函数签名。为了在调用函数之间共享被调用函数的实现(如虚函数表或动态链接),任何其他函数也应该可以调用这个被调用函数。
在我们开始讨论传参问题之前,现在我们已知的内容会导致一个冲突:两个函数都想尽可能的使用寄存器以更快地执行,两个函数都是用相同的寄存器,且它们都不知道对方正在用哪一个寄存器!
有一个很简单(糟糕)的解决方案:在被调用函数的开始处将所有寄存器状态保存到栈,然后当被调用函数准备返回时,从栈中恢复所有的寄存器状态。我们把被调用函数保存寄存器的方案叫做被调用者保存或不可变寄存器。这个方案非常糟糕,因为寄存器非常大!将所有数据拷贝到栈或从栈中取出需要花费很多时间。我们有更好的方案。(总的来说:这正是上下文切换的工作原理,尽管操作系统采取了一些trick来避免总是要保存/恢复所有的寄存器。)
一个稍微好的方案是:在即将要执行调用之前,调用将它真正关心的寄存器保存到栈,然后被调用函数就可以随意使用寄存器了,而调用函数会认为被调用函数使用过所有的寄存器,并在合适的时候重新初始化这些寄存器。这个方案叫做调用者保存或可变寄存器。默认情况下,这个方案更好,因为调用者通常不会使用太多寄存器(特别是大部分寄存器大小是比较难用的SIMD)。这个方案的另一个好处是它释放了所有的寄存器,它们都可以用来传参/返回值!
现代的调用约定通常采用一种基于传统方案的混合方案。某些寄存器被标记为被调用这保存,而另外一些被标记为调用者保存。这让调用者和被调用者具有协同避免寄存器保存的灵活性。
例如,如果调用者将它所有的工作集都保存在不可变寄存器中而被调用者将它的所有工作集保存在可变寄存器中,那么根本就不需要保存寄存器。这就给保留几个被调用者保存寄存器一个合理的理由。类似的,被调用者保存也可以用于需要传递给许多函数“上下文”指针的代码(如:大部分语言中的this/self
,以及几个该死的C框架)。
几个关于调用约定的例子
这里不在讨论调用约定的完整细节了,我将主要关注前文中述及的不同点是如何影响不同约定的表现行为的。看几个System V ABI给x86(“cdecl”)和x64传值的例子(尽管x86有点杂乱,这些都是标准的Linux/BSD/MacOS调用约定,所以这里我们假定是Linux上的GCC,希望它具有共通性)。
关于符号的注解:stack -x
表示这个值在被调用函数的栈帧之前保存了x
字节(因为System V ABIs将栈参数保存在调用者中)。
有如下声明:
struct Meter {int32_t len; }
struct Point { int32_t x; int32_t y; };
int32_t process(void* a, float b, struct Meter c, struct Point d);
可以得到下列的底层描述:
process (x86 System V):
a void*: stack -4
b float: stack -8
c {int32}: stack -12
d {int32, int32}: stack -20
----------------------------------
return int32: register eax
process (x64 System V):
a void*: register rdi
b float: register xmm0
c {int32}: register rsi
d {int32, int32}: register rdx
----------------------------------
return int32: register rax
可以看到老ABI大部分时候只是在栈上传递值,而新ABI则更多地在寄存器中传递值。注意float b
参数被传递到了xmm
寄存器,而不是通用寄存器r
,这是因为浮点数和整数被区别对待(鼓励将两者区分对待)。
x86 ABI处理返回值的方式鼓励区分组合类型和基本类型。如果改变process
返回Meter
,会得到如下的底层描述:
process (x86 System V):
return {int32}: stack -4
a void*: stack -8
b float: stack -12
c {int32}: stack -16
d {int32, int32}: stack -24
----------------------------------
return {int32}*: register eax (pointer to stack -4)
process (x64 System V)
a void*: register rdi
b float: register xmm0
c {int32}: register rsi
d {int32, int32}: register rdx
----------------------------------
return {int32}: register rax
虽然类型的布局是完全相同的,但是x86 ABI总是隐式地将结构体和组合类型作为第一个参数传递到栈上。x64 ABI“修复”了这个问题,将这两者视为等同。
然而x64 ABI处理复合类型的按值传递非常复杂。考虑如下两个声明:
struct Ints { int32_t a; int32_t b; int32_t c; int32_t d; };
struct IntAndFloats { int32_t a; float b; float c; float d; };
void process1(struct Ints vals);
void process2(struct IntAndFloats vals);
process1 (x64 System V)
(vals.a, vals.b): register rdi
(vals.c, vals.d): register rsi
process2 (x64 System V)
(vals.a, vals.b): register rdi
(vals.c, vals.d): register xmm0
x64 ABI将结构体拆分成8字节的块并对字段做递归分类。在这种情况下,可以看到IntAndFloats
的前半部分中整数a
比浮点数b
的“比重更大”,所以这个块被传递到了通用寄存器。而第二个块完全由浮点数组成,因此被传递到了xmm0
。这表明,如果想正确地将一个组合类型传递给x64 ABI,我们需要知道该类型所有字段的精确ABI。