喜欢的话别忘了点赞、收藏加关注哦(加关注即可阅读全文),对接下来的教程有兴趣的可以关注专栏。谢谢喵!(=・ω・=)
1.13.1. 类型的基本职责
每个Rust值都有类型,而类型的职责在于告诉你如何解释内存中的比特位(bits)。
例如:0b10111101这串比特(bits)本身并没有意义,但是:
- 用
u8
类型来解释就会得到数字189 - 用
i8
类型来解释就会得到数字-67
当自定义类型时:编译器决定该类型的各部分在内存表示中的位置
1.13.2. 对齐(Alignment)
对齐(Alignment)决定了类型的字节可以被存储在哪里。
而一旦类型的表示被确定之后,你可能想在内存上随便找个地方存进去就行,这在理论上是可行的。但实际上计算机硬件对给定的类型可以存放的位置是有约束的。
最典型的一个例子是指针,它指向的是字节(bytes),而不是位(bits),1个字节等于8比特。换言之,它并不指向具体的比特。所以如果将某类型的值放在计算机内存中索引为4的位(bits)上,那就无法引用它的地址,因为指针指向的是字节,而不是具体的比特,所以就必须对齐字节,也就是对齐到8比特。
出于这个原因,所有的值(无论什么类型),都必须开始于字节的边界。所有类型必须至少是字节对齐的(byte-aligned)。换言之,存放的地址必须是8bits的整数倍。
1.13.3. 更严格的对齐规则
有一些类型的对齐规则比字节的对齐规则还要严格:在CPU和内存系统里,内存经常按大于单个byte的块进行访问。
例如:在64位的CPU上,大部分的值是按照8bytes的块进行访问的,每个操作都开始于“8bytes对齐”的地址上。(这也叫做CPU的字长,英文是word size)
CPU当然也有办法处理更小值的读写,以及跨越块边界的值。但是我们作为开发者应该尽可能地保证硬件可以操作于它的原生(native)对齐。
举个例子:如果想读取的i64
值它开始于8bytes块的中间,那这个时候要读取它就至少需要两次读取。因为i64
是8字节,而它开始于两个8字节块中间说明它一定横跨了这两个块。所以在读取时引进就得从这两个块读取数据,第一个块读取i64
的前面部分,第二个块读取i64
的后面部分,然后再把它们合并到一起。
这种操作是非常低效的,会拖累程序执行的速度,所以我们应该尽可能保证硬件可以操作于它的原生对齐。
1.13.4. 没对齐的操作
CPU访问内存时,数据的地址没有按照架构要求的对齐方式进行访问叫做"misaligned access"。这会导致性能低下和并发问题。
很多CPU操作多要求/强烈建议它们的参数是自然对齐的(naturally aligned)。自然对齐值的对齐是匹配他们值的大小的。
例如我想加载8字节,那么提供的地址就需要8字节对齐。
1.13.5. 编译器会尽可能利用对齐
基于类型包含的内容,编译器通过计算为类型分配一个对齐(或者叫给它分配一个对齐方案):
对于内值的值,通常对齐到它们的大小。比如说
u8
按1字节对齐,u16
按2字节对齐,u32
按4字节对齐,u64
按8字节对齐。而复杂类型(包含其它类型的类型),通常被赋予所含类型的最大对齐。例如某类型含有
u8
、u16
和u32
这三个类型的字段,那么类型就应该是4字节对齐(u32
是最大对齐,为4字节)
1.13.6. 布局(Layout)
类型的布局(Layout)指的是编译器决定这个类型在内存上如何表示。
Rust编译器对于类型如何布局,并没有给出多少保证。
Rust提供了repr
属性(attribute):它可以添加到你类型的定义上,来请求特定的类型表示。
1.13.7. repr(C)
repr
属性(attribute)最常见的一个是repr(C)
。名字里带个C说明跟C语言有关系。
repr(C)
布局方式与C/C++编译器对同类型的布局兼容。这对于使用FFI(外部函数接口,英文是Foreign Function Interface)与其它语言交互的Rust代码很有用。
使用FFI与其它语言交互的时候,Rust会生成一个匹配其它语言编译器期望的布局。因为C语言的布局是可预测且不易改变的,所以repr(C)
在unsafe
(unsafe Rust详见【Rust自学】19.2.6. 使用newtype模式在外部类型上实现外部trait)的上下文是非常有用的。
比如说你使用指向该类型的原始指针时,或者在两个具有相同字段的类型间进行转换时,都可以使用到repr(C)
。
1.13.8. repr(transparent)
repr(transparent)
中的transparent是透明的意思,它仅能用于只含单个字段的类型,它保证了外层类型的布局与内层类型一样。
这与newtype模式(详见【Rust自学】19.5. 高级类型)结合起来很好用。
我们在这里回顾一下newtype模式:利用元组结构体来构建一个新的类型放在本地,相当于是薄封装。
举个例子:你想操作struct A
和struct NewA(A)
的内存表示,使用了repr(transparent)
之后两者的内存表示就应该是一样的。不使用的话Rust编译器就没发保证了。
1.13.9. 使用repr
属性的例子
我们来看一个例子:
代码 | 字段类型的大小 | 默认内存表示 | 填充 | 最终对齐 |
---|---|---|---|---|
#[repr(C)] |
||||
struct Foo { |
||||
tiny: bool, |
1 bit | 1 byte 对齐 | 3 bytes | |
normal: u32, |
4 bytes | 4 bytes 对齐 | (tiny + normal )8 bytes |
|
small: u8, |
1 byte | 1 byte 对齐 | 7 bytes | 8 bytes |
long: u64, |
8 bytes | 8 bytes 对齐 | 8 bytes | |
short: u16, |
2 bytes | 2 bytes 对齐 | 6 bytes | 8 bytes |
} |
||||
共 32 bytes |
这个表展现了 Rust 结构体在#[repr(C)]
下的内存对齐和填充:
代码是最左边的这列,使用了
repr(C)
注解。结构体里面有好几个字段Rust编译器首先看
tiny
字段是bool
类型的,占1bit内存,就会对齐到1字节编译器接着看
normal
字段是u32
类型的,占4字节,所以对齐到4字节即可。这时候Rust发现tiny
字段对齐到的是1字节,所以编译器就会填充3字节让tiny
字段占4字节由于这个字段刚好占了8字节,是4字节的整数倍,所以已经对齐了
small
字段是u8
类型,占1字节,对齐到1字节。由于上面的两个字段已经对齐了,所以Rust编译器会根据下文的字节来判断给它填充多少字节。判断到这里Rust编译器还得观望一下。long
是u64
类型,占8字节,自然就是8字节对齐。它的字段是8字节及以上。此时我们看tiny
和normal
组成了8字节对齐,long
也是8字节对齐,Rust明白了现在的情况是应该按8字节对齐。那就只能给small
字段填充7个字节补成一个8字节对齐了。short
是u16
类型,占2字节,由于现在的情况是应该按8字节对齐,所以编译器会给它补6字节合成8字节对齐。
其过程用表格表述就是:
字段 | 类型大小 | 需要的对齐 | 填充情况 | 备注 |
---|---|---|---|---|
tiny: bool |
1 bit | 1 byte | 3 bytes 填充 | 为了对齐下一个 u32 |
normal: u32 |
4 bytes | 4 bytes | 无填充 | 按 u32 对齐 |
small: u8 |
1 byte | 1 byte | 7 bytes 填充 | 为了对齐下一个 u64 |
long: u64 |
8 bytes | 8 bytes | 无填充 | 8 字节对齐 |
short: u16 |
2 bytes | 2 bytes | 6 bytes 填充 | 以 8 字节对齐结构体 |