【Rust中级教程】1.13. 内存中的类型 Pt.1:对齐(Alignment)、布局(Layout)、`repr`属性

发布于:2025-02-19 ⋅ 阅读:(27) ⋅ 点赞:(0)

喜欢的话别忘了点赞、收藏加关注哦(加关注即可阅读全文),对接下来的教程有兴趣的可以关注专栏。谢谢喵!(=・ω・=)
请添加图片描述

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字节对齐。

  • 而复杂类型(包含其它类型的类型),通常被赋予所含类型的最大对齐。例如某类型含有u8u16u32这三个类型的字段,那么类型就应该是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 Astruct 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编译器还得观望一下。

  • longu64类型,占8字节,自然就是8字节对齐。它的字段是8字节及以上。此时我们看tinynormal组成了8字节对齐,long也是8字节对齐,Rust明白了现在的情况是应该按8字节对齐。那就只能给small字段填充7个字节补成一个8字节对齐了。

  • shortu16类型,占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 字节对齐结构体

网站公告

今日签到

点亮在社区的每一天
去签到