Rust的内存模型
TL;DR
最近在尝试rCore这个内核实验
这本教程旨在一步一步展示如何 从零开始 用 Rust 语言写一个基于 RISC-V 架构的 类 Unix 内核 。
所以只是理解Rust的各种语言特性就不够了,需要学习Rust的内存模型,理解它是如何在内存空间中组织各个实例
基于C++的堆栈管理
Rust对于数据存放在栈还是堆上有着清晰的界定
栈只存放编译器确定的数据
堆存放大小动态变化或需要在作用域之外继续生存的数据
基础类型:
bool,i32,i64等,存放在栈上结构体:默认存放在栈上(Rust中结构体会经历
重排优化)&str:本质是一个指针和长度信息的集合
Box,String: 在堆上分配
RAII and Drop
RAII
栈上的生命周期和C/C++中是一样的,在所在栈帧结束后,统一将栈上的实例pop出来
堆上的内存由栈上的引用/指针管理,在栈数据弹出之前,会自动调用该变量的Drop函数,释放内存
这就是基本的RAII思想
堆栈之间的转换与移动
当把一个包含堆数据的变量赋值给另一个变量时,Rust 会执行浅拷贝,仅复制栈上的指针和元数据
这避免了额外的内存拷贝和双重释放(Double Free)
更深入一些
让我们来看真实的反汇编代码
为了方便我们看汇编,所以需要在.cargo/config.toml(没有则创建)中声明不优化代码
这样编译器生成的汇编就会在栈/堆上显式分配内存
[profile.dev]
opt-level = 1
找到fn main
在Rust中,使用gdb直接b main找到的确实是main函数,但并不是业务逻辑的main函数,而是Rust初始化运行时环境的代码
如果使用objdump或gdb中直接反汇编main函数,只会看到
0000000000009680 <main>:
9680: 50 push rax
9681: 48 89 f1 mov rcx, rsi
9684: 48 63 d7 movsxd rdx, edi
9687: 48 8d 05 d2 ff ff ff lea rax, [rip - 0x2e] # 0x9660 <_RNvCseoItouaaNcv_8rust_lab4main>
968e: 48 89 04 24 mov qword ptr [rsp], rax
9692: 48 8d 35 e7 04 06 00 lea rsi, [rip + 0x604e7] # 0x69b80 <stderr+0x8>
9699: 48 89 e7 mov rdi, rsp
969c: 45 31 c0 xor r8d, r8d
969f: ff 15 33 28 06 00 call qword ptr [rip + 0x62833] # 0x6bed8 <_GLOBAL_OFFSET_TABLE_+0x6a8>
96a5: 59 pop rcx
96a6: c3 ret
96a7: 66 0f 1f 84 00 00 00 00 00 nop word ptr [rax + rax]
代码中的fn main() {}则是在_RNvCseoItouaaNcv_8rust_lab4main这个标签中
具体的标准在Rust RFC Book
简单来讲,这个是基于C++ g++ v3的风格进行的符号修饰
Instance
use std::hint::black_box;
#[inline(never)]
fn process_buffer() {
let mut data = vec![0u8; 1024];
data[0] = 42;
black_box(&data); // 阻断优化
}
fn main() {
process_buffer();
}
下面是对应的process_buffer()的反汇编
00000000000136f0 <_RNvCs31H03Ek5cJ6_8rust_lab14process_buffer>:
136f0: 48 83 ec 28 sub rsp, 0x28
136f4: ff 15 c6 f4 03 00 call qword ptr [rip + 0x3f4c6] # 0x52bc0 <writev+0x52bc0>
136fa: bf 00 04 00 00 mov edi, 0x400
136ff: be 01 00 00 00 mov esi, 0x1
// 调用内存分配器分配堆内存
13704: ff 15 be f4 03 00 call qword ptr [rip + 0x3f4be] # 0x52bc8 <writev+0x52bc8>
// 检查返回是否为空指针(OOM处理)
1370a: 48 85 c0 test rax, rax
1370d: 74 48 je 0x13757 <_RNvCs31H03Ek5cJ6_8rust_lab14process_buffer+0x67>
// 构建Vec
1370f: 48 c7 44 24 08 00 04 00 00 mov qword ptr [rsp + 0x8], 0x400
13718: 48 89 44 24 10 mov qword ptr [rsp + 0x10], rax
1371d: 48 c7 44 24 18 00 04 00 00 mov qword ptr [rsp + 0x18], 0x400
13726: c6 00 2a mov byte ptr [rax], 0x2a
13729: 48 8d 44 24 08 lea rax, [rsp + 0x8]
1372e: 48 89 44 24 20 mov qword ptr [rsp + 0x20], rax
13733: 48 8d 44 24 20 lea rax, [rsp + 0x20]
// 离开作用域,自动Drop
13738: 48 8b 74 24 08 mov rsi, qword ptr [rsp + 0x8]
1373d: 48 85 f6 test rsi, rsi
13740: 74 10 je 0x13752 <_RNvCs31H03Ek5cJ6_8rust_lab14process_buffer+0x62>
13742: 48 8b 7c 24 10 mov rdi, qword ptr [rsp + 0x10]
13747: ba 01 00 00 00 mov edx, 0x1
// 调用dealloc函数
1374c: ff 15 7e f4 03 00 call qword ptr [rip + 0x3f47e] # 0x52bd0 <writev+0x52bd0>
13752: 48 83 c4 28 add rsp, 0x28
13756: c3 ret
可以观察到
- Vec这样到指针在底层上,存储了
三元组(Capacity, Pointer, Length),实际上就是俗称的胖指针概念
这里推荐一个blog
- 在
Drop的时候,先取出了胖指针中的capacity,检测是否为空,再进行释放
End
写这篇东西的初衷是 rCore。做内核实验的时候,光是背下所有权和借用的规则是不够的,还得知道这些规则在内存里是怎么落实的:哪些数据在栈上,哪些在堆上,作用域结束时Drop又是怎么被触发的。
回头看一下,收获最大的反而是最后那段反汇编。与其靠文档脑补Box、String这类类型的行为,不如直接看生成的指令。看到Vec在底层就是一个(Capacity, Pointer, Length)三元组,看到Drop先取容量判断非空再调用dealloc,内存模型就不再是纸面上的概念了。
如果后面还有时间,想沿着这条思路继续往下挖。比如所有权转移在汇编层面到底发生了什么,或者进入no_std之后全局分配器是怎么接进内核的。那才是 rCore 真正要碰的东西。
