引用的内存结构 Memory layout

Rust 中的引用, 跟 C++ 中的引用一样, 在底层都是指针.

先看一段代码 C++ 代码片段:

#include <cassert>

int addr1() {
  int x = 42;
  int& x_ptr = x;
  x_ptr += 1;
  return x;
}

int main(void) {
  assert(addr1() == 43);
  return 0;
}

这段 C++ 代码对应的汇编代码如下:

	.file	"reference-address.cpp"
	.text
	.globl	_Z5addr1v
	.type	_Z5addr1v, @function
_Z5addr1v:
.LFB3:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$42, -12(%rbp)
	leaq	-12(%rbp), %rax
	movq	%rax, -8(%rbp)
	movq	-8(%rbp), %rax
	movl	(%rax), %eax
	leal	1(%rax), %edx
	movq	-8(%rbp), %rax
	movl	%edx, (%rax)
	movl	-12(%rbp), %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.

然后我们用 C 语言重新写一遍相同的功能, 如下:

#include <stdlib.h>
#include <assert.h>

int addr1() {
  int x = 42;
  int* x_ptr = &x;
  *x_ptr += 1;
  return x;
}

int main(void) {
  assert(addr1() == 43);
  return 0;
}

这段 C 代码对应的汇编代码如下:

	.file	"reference-address.c"
	.text
	.globl	addr1
	.type	addr1, @function
addr1:
.LFB6:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$42, -12(%rbp)
	leaq	-12(%rbp), %rax
	movq	%rax, -8(%rbp)
	movq	-8(%rbp), %rax
	movl	(%rax), %eax
	leal	1(%rax), %edx
	movq	-8(%rbp), %rax
	movl	%edx, (%rax)
	movl	-12(%rbp), %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

可以发现, 两段汇编代码是完全一样的. 简单地说, C++中的引用, 在底层就是通过指针实现的.

最后, 我们看一下对应的 Rust 代码如何写:

fn add1() -> i32 {
    let mut x: i32 = 42;
    let x_ref: &mut i32 = &mut x;
    *x_ref += 1;
    x
}

fn main() {
    assert_eq!(add1(), 43);
}

当然, 也要把它生成汇编代码:

	.section	.text._ZN17reference_address4add117h385aa07c715333f2E,"ax",@progbits
	.p2align	4, 0x90
	.type	_ZN17reference_address4add117h385aa07c715333f2E,@function
_ZN17reference_address4add117h385aa07c715333f2E:
	.cfi_startproc
	pushq	%rax
	.cfi_def_cfa_offset 16
	movl	$42, 4(%rsp)
	movl	4(%rsp), %eax
	incl	%eax
	movl	%eax, (%rsp)
	seto	%al
	jo	.LBB12_2
	movl	(%rsp), %eax
	movl	%eax, 4(%rsp)
	movl	4(%rsp), %eax
	popq	%rcx
	.cfi_def_cfa_offset 8
	retq
.LBB12_2:
	.cfi_def_cfa_offset 16
	leaq	.L__unnamed_3(%rip), %rdi
	movq	_ZN4core9panicking11panic_const24panic_const_add_overflow17h343c6c3f46bad3f5E@GOTPCREL(%rip), %rax
	callq	*%rax
.Lfunc_end12:
	.size	_ZN17reference_address4add117h385aa07c715333f2E, .Lfunc_end12-_ZN17reference_address4add117h385aa07c715333f2E
	.cfi_endproc

可以看到, 生成的汇编代码里压根就没有用到 x_ref 这个引用. 这是因为表达式太简单, 被优化掉了, 我们使用 rustc --emit asm -g reference-address.rs 重新生成汇编, 得到以下内容:

#![allow(unused)]

fn main() {
	.section	.text._ZN17reference_address4add117h385aa07c715333f2E,"ax",@progbits
	.p2align	4, 0x90
	.type	_ZN17reference_address4add117h385aa07c715333f2E,@function
_ZN17reference_address4add117h385aa07c715333f2E:
.Lfunc_begin12:
	.file	11 "/home/shaohua/dev/rust/intro-to-rust/src/references/assets" "reference-address.rs"
	.loc	11 5 0
	.cfi_startproc
	subq	$24, %rsp
	.cfi_def_cfa_offset 32
; let x = 42;
	movl	$42, 12(%rsp)
; let x_ref = &mut x;
	leaq	12(%rsp), %rax
	movq	%rax, 16(%rsp)
	movl	12(%rsp), %eax
	incl	%eax
	movl	%eax, 8(%rsp)
	seto	%al
	jo	.LBB12_2
	movl	8(%rsp), %eax
	movl	%eax, 12(%rsp)
	movl	12(%rsp), %eax
	addq	$24, %rsp
	.cfi_def_cfa_offset 8
	retq
.LBB12_2:
	.cfi_def_cfa_offset 32
.Ltmp40:
	.loc	11 8 5
	leaq	.L__unnamed_3(%rip), %rdi
	movq	_ZN4core9panicking11panic_const24panic_const_add_overflow17h343c6c3f46bad3f5E@GOTPCREL(%rip), %rax
	callq	*%rax
.Ltmp41:
.Lfunc_end12:
	.size	_ZN17reference_address4add117h385aa07c715333f2E, .Lfunc_end12-_ZN17reference_address4add117h385aa07c715333f2E
	.cfi_endproc
}

上面几行重要的汇编代码都加了注释, 可以看到:

  • x_ref 存储的也是 x 的内存地址

这个跟上面 C/C++ 代码里的行为是一致的.

简单地说, Rust 中的引用, 在底层也是通过指针实现的.

但是, 除了我们用过的这些引用类型之外, Rust 还有一些更复杂的引用类型, 它们的内存结构也更复杂.

切片 slice 的引用

上段介绍过了引用本身占用的内存大小只是一个指针大小, 即 usize. 这个类似于 C/C++ 中的 指针.

但切片以及 trait object 的引用, 这两类都是胖指针(fat pointer), 即除了指向内存地址之外, 还有别的属性.

指向 slice 的指针包含两个成员, 第一个是指向 slice 某个元素的内存地址; 第二个 是定义了该引用可访问的元素个数.

指向 trait object 的引用包含了两个 field, 第一个是指向该值的内存地址, 第二个指向 该值对该 trait 的实现的地址, 以方便调用该 trait 定义了的方法.

但还有两个特殊类型的引用, 它们都占两个指针大小, 即 usize * 2:

切片 slice 的引用是一个胖指针, 该指针包含两个字段:

  • slice 起始地址
  • slice 长度

先看一段代码示例:

use std::mem::size_of_val;

fn main() {
    // 整数数组
    let numbers: [i32; 5] = [1, 2, 3, 4, 5];
    assert_eq!(size_of_val(&numbers), 20);

    // 数组的引用
    let numbers_ref: &[i32; 5] = &numbers;
    assert_eq!(size_of_val(numbers_ref), 20);

    // 切片引用, 该切片指向数组中的第 3 个元素, 切片长度为 3
    let slice_ref: &[i32] = &numbers[2..];
    assert_eq!(size_of_val(slice_ref), 12);
    assert_eq!(slice_ref.len(), 3);
}

这里面创建了两个引用:

  • numbers_ref 只是一般的引用, 它指向数组 numbers 第 1 个元素的地址
  • slice_ref 是一个切片引用, 它里面的指针指向 numbers 的第 3 个元素的地址; 而且切片的长度为 3

其内存结构如下图如示:

slice reference

如果还不相信的话, 我们可以用调试器直接查看它们的内存, 首先是 numbers 数组, 注意它的第 3 个元素的内存地址:

numbers array

可以看到, 这个整数数组的内存都是连续存放的, 每个元素占用 4 个字节.

然后是 slice_ref 的内存结构:

slice ref

可以看到, slice_ref 确实包含两个成员:

  • 指针, 指向数组 numbers 的第 3 个元素
  • 切片长度, 是 3

trait object 的引用

trait object 的引用也是一个胖指针, 包含两部分:

  • object 数据地址
  • object virtual table 地址, 即该值对该 trait 的具体实现