Rust底层揭秘:函数调用、栈帧与虚表的内存博弈
核心结论摘要:
- 虚表(Vtable)是编译器产物:它要求接口签名(ABI)必须在编译时确定。
- 函数调用基于栈帧:调用者(Caller)负责在自己的栈帧中预留返回值的空间。
- 根本矛盾:为了生成通用的栈分配指令,编译器必须知道返回值的确切内存大小。这就是
async fn(动态大小)无法直接用于dyn Trait(动态分发)的物理原因。
一、函数调用的物理图景
要理解 Rust 的类型系统,首先要理解计算机是如何执行函数调用的。
1.1 栈与栈帧(Stack vs Stack Frame)
- 栈:操作系统分配给线程的一大块连续内存。可以把它想象成一本空白笔记本。
- 栈帧:笔记本上的一页内容
- 压栈(Call):CPU 的栈指针(SP)移动,划出一块新区域供新函数使用。
- 弹栈(Return):SP 指针回退,逻辑上释放这块区域。
- 关键点:函数调用不是开辟新栈,而是在同一个栈上不断叠加栈帧。
1.2 大对象的传递机制(Caller Reservation)
当函数返回值很小(如u64,Box指针时),通过 CPU 寄存器(如RAX)传递。当函数返回值很大(如结构体、Future 状态机)时,寄存器装不下,采用"调用者预留 + 隐式指针"机制。
内存路线图:
|-------------------|
| Caller 栈帧 | <--- 老板 (调用者)
| |
| [局部变量...] |
| |
| [预留返回值区] | <--- 关键:老板必须提前画好框,大小必须确定!
|___________________|
|
| 传递地址 (隐式参数)
v
____________________
| Callee 栈帧 | <--- 打工仔 (被调用者)
| |
| [参数...] |
| [局部变量...] |
|___________________|
- 预留:Caller 在自己的栈上腾出空间(比如 100 字节)
- 写入:Callee 计算结果,跨越栈帧 直接写到 Caller 预留的那个地址里。
- 返回: Callee 销毁自己,数据已经留在 Caller 手里了。
二、虚表的编译期本质
2.1 虚表不是运行时生成的
- 误区:以为动态分发意味着 vtable 是运行时平凑的。
- 真相:对于每一个具体类型(如
DbUser),编译器在编译阶段就生成了对应的静态 vtable,存放在只读数据段(.rodata)中。运行时只是查表。
2.2 编译器生成的汇编指令
当编译器处理 service.get() 这样的动态调用时,它只能生成一套通用的汇编代码:
; 这段代码必须适用于所有实现类 (DbUser, FileUser, etc.)
; 1. 预留栈空间
SUB SP, <SIZE> ; <--- 致命问题:<SIZE> 填多少?
; 2. 传递地址
MOV RDI, SP
; 3. 查表跳转
CALL [VTABLE_PTR + OFFSET]
三、终极矛盾 – 内存大小的确定性
3.1 为什么 “知道类型” =“知道大小”
在底层视角,类型(Type)只是给程序员看的逻辑约束;对于机器码生成器来说,类型唯一的意义就是 Size & Alignment(大小与对齐)。
3.2 async fn vs Box<dyn Future>
为什么 async fn不能进 vtable,而Box可以?
场景 A:直接返回 async fn (impl Future)
- Impl A (
DbUser): 返回的状态机结构体大小为 1024 字节。
- Impl B (
MemUser): 返回的状态机结构体大小为 64 字节。 - 编译器的死结:
- 在生成
SUB SP, <SIZE>时,<SIZE>必须是一个固定的立即数。 - 填 1024?对 B 来说极其浪费。
- 填 64?对 A 来说栈溢出(Stack Overflow)。
- 结论:无法生成统一的调用代码,编译失败。
- 在生成
场景 B:返回 Box
- Impl A: 在堆上分配 1024 字节,返回一个 8 字节 的指针。
- Impl B: 在堆上分配 64 字节,返回一个 8 字节 的指针。
- 编译器的快乐:
- 所有实现类返回的都是 8 字节。
- 汇编生成:
SUB SP, 8。 - 结论:vtable 规范统一,编译成功。
四、总结
通过这次深度的讨论,我们从应用层一直穿透到了汇编层,逻辑链条如下:
- 物理限制:函数调用基于栈帧,大对象返回需要 Caller 预留栈空间。
- 指令限制:预留栈空间的指令(
SUB SP, X)要求X必须是编译期确定的常数。 - vtable 限制:为了生成通用的动态分发代码,所有实现方法的返回值大小必须一致。
- Async 特性:
async fn生成的状态机大小各异,打破了上述限制。 - 解决方案:使用
Box(指针) 将不同大小的数据“归一化”为固定大小的指针(8 字节),从而满足 vtable 的物理要求。
这就是 Rust 乃至 C++ 等系统语言中,“类型”、“大小”与“多态” 之间永恒的三角关系。