Rust:函数栈帧 & Box智能指针


内存模型

在讲解更加复杂的类型之前,先简单了解一下Rust程序是在怎样的一个内存环境执行的,这将有利于你理解后续的很多概念。

函数栈帧

不知你有没有思考过,你的程序运行在计算的什么位置?你声明的变量被放在了哪里?

程序运行在操作系统上,操作系统会给程序分配内存,允许程序运行时存储一些数据。这样一段内存称为进程地址空间,而进程地址空间有一部分会划分给用户,称为用户空间,另一部分由OS自己保留,称为内核空间

一个程序保存的所有变量,二进制指令,都存在这个用户空间中,它又被分为四个区域进行管理:

  • 代码段:存储可执行代码和只读常量
  • 数据段:存储全局变量和静态数据
  • 堆区:用于动态内存管理,堆区内存往高处增长
  • 栈区:大部分局部变量,栈区内存往低处增长

本博客主要讨论堆区与栈区,其余区域不在讨论范围内。

当你写一个main函数,它作为程序的入口,程序一定会先执行这个函数,那么就要在栈区为这个main函数分配一部分空间。

fn main() {
    let a = 10;
    let b = 20;
}

以上代码声明了ab两个变量,在栈区中,程序行为如下:

在这里插入图片描述

首先程序会在栈区给main预先开辟一段内存。每当用户创建一个变量,就在main函数空间的顶部新增一个空间存放数据。

如图,从左到右分别是代码执行到不同语句,栈区的内存视图。

如果发生了函数的嵌套,此时栈区就会在顶部新开一段函数空间。

fn func() {
    let year = 2025;
    println!("Hello {}!", year);
}

fn main() {
    let a = 10;
    let b = 20;
    func();
    println!("Hello over...");
}

以上代码,在main函数中调用了func函数,输出Hello 2025!,调用结束后再输出Hello over...

在栈区它的内存视图如下:

在这里插入图片描述

起初先给main分配好内存,定义了ab两个变量。随后调用func,于是操作系统又要在栈区给func开一段新的空间,放到栈顶。像这样一个函数在栈区分配到的一块空间,称为一个函数栈帧。每个函数声明的变量会放在自己的栈帧内部,year变量自然也会在func函数的栈帧内部。

func调用完毕,此时操作系统就会把属于func的栈帧回收,包括内部的变量,方便以后给其它函数使用,这样就实现了内存可复用。随后回到了main函数栈帧。

现在考虑一个问题,有没有可能函数内部定义的变量太多,函数的栈帧不够大?比如说func函数要声明yearmonthday三个变量,结果空间不够了,比如:

在这里插入图片描述

这会导致func的函数栈帧扩容吗?OS是如何处理的?

答案是这个情况根本不可能发生!因为所有定义在栈区的变量,要求在编译期可以确定大小的类型

比如说func函数要yearmonthday三个变量:

fn func() {
	let year = 2025; // i32 需要 8byte
	let month = 12;  // i32 需要 8byte
	let day = 25;    // i32 需要 8byte
}

由于三个变量都是i32,编译期就可以确定func至少需要8 + 8 + 8 = 24 byte 的内存,至少也会分配这么多内存给func的栈帧。

在编译期间,Rust的编译器就会计算每个函数所需的空间,后续调用的时候,直接让OS给这个函数分配指定大小的栈帧即可。


动态大小类型

既然我们刚刚提到:编译器会在编译期就帮每个函数算好需要多少内存,所以所有能放在栈区的变量,必须是在编译期能确定大小的。

那么问题来了:有没有一些变量,它的大小在编译期根本没法确定?

最常见的例子就是字符串:

fn main() {
    let s = String::from("world"); // 运行时动态分配,大小不固定
}

String表示的是一个可变、可扩展的字符串,运行中可以通过 push_str 一大段文字,长度完全不确定。 这种类型称为动态大小类型(DST, Dynamically Sized Type)

对于这种大小无法预知的情况,Rust 无法把它直接放到栈帧里。于是就需要用到另一个区域:堆区

堆区可以自由存储任意大小的数据,程序可以自由申请指定大小的空间,并自由向空间内存储任意数据。而且对于一块已经申请过的堆区内存,还可以对它进行扩容的操作,这样就可以把动态大小类型存到堆区里了。

但是我们编写的大部分代码,都在函数里,函数都在栈区执行。如何在栈区访问到堆区的内存?这就需要通过指针。

在这里插入图片描述

如图所示,栈区的变量s它通过一个地址,指向了堆区的字符串"world",这样就可以在栈区访问到堆区的数据了。

变量s本质上不是一个字符串,而是一个栈区的指针,在这个指针内部存储了栈区数据的相关信息,比如说字符串的长度以及字符串所在的地址,刚刚的图片实际上是简化版,实际情况如下:

在这里插入图片描述

在栈区中,s是一个指针,指针内部存储了ptrlen,分别表示堆区数据的地址以及字符串长度。像这样携带有其它相关数据的指针,称为胖指针


智能指针

我们用到的绝大部分动态大小类型,实际上都已经封装好了,实际操作的是指针,间接操作到真正的数据,比如String。那么我们自己要如何实现一个这样的模式?

Rust 提供了一个最基础的工具:智能指针 Box<T>

Box<T> 的作用就是把一段数据放到堆区存储,同时在栈区留下一个指针。通过 Box 我们仍然可以像使用普通变量一样,方便地解引用去访问里面的数据。

此处的<T>表示泛型,你可以把任意类型的数据通过Box放到堆上。

示例:

fn main() {
    let p = Box::new(2025);
}

此时p就是一个智能指针,它通过Box把一个i32的数据放到了堆区,如下图:

在这里插入图片描述

那要如何使用这个p

可以通过*解引用来访问到堆区的数据:

fn main() {
    let mut p = Box::new(2025);
    let val = *p;
    *p = 2026;
}

比如通过val = *p访问堆区的数据,也可以直接*p = 2026修改堆区的数据,前提pmut修饰的。

现有以下代码:

fn func() {
    let year = Box::new(2025);
    println!("Hello {}!", *year);
}

fn main() {
    let a = 10;
    let b = 20;
    func();
}

这个代码在main中调用了func,而funcyear通过Box开在了堆区。

内存分配如下:

在这里插入图片描述

如上图,当进入func后,把2025放在了堆区,等到func调用结束,属于func的栈帧会自动销毁,指针year自然也会跟着被销毁。问题来了,堆区的数据2025谁来回收?

堆区的内存分配,不论是申请还是回收,都是需要用户自己操作的,如果你学过C语言应该深有体会。但是我们并没有回收堆区的数据,等到变量year被销毁,那堆区的2025就永远无法访问了,这块内存后续又用不上,还无法分配给其它变量,这就是典型的内存泄露问题,在C/C++是很常见的错误,后来C++RAII大幅减缓了这个错误的可能性。

实际上,刚刚这个情况不会发生,因为Rust学习了RAII思想,智能指针自带RAII当一个智能指针在栈区的变量被销毁的时候,会自动把它指向的堆区数据一起销毁。可以理解为智能指针把一个堆区数据的生命周期与栈区的变量绑定在了一起,当栈区变量被销毁,堆区数据会随着一起销毁。

这个机制就叫做RAII,在这种机制逻辑在于:

  • 如果堆区的数据还在那么说明一定有栈区的智能指针指向它,那么这个堆区数据就还有被使用的可能
  • 当堆区的数据不被任何智能指针指向,用户就无法通过栈区指针操作到这个数据,那么这个数据就不可能再被使用了,直接回收内存

除了Box还有很多智能指针,智能指针也有很多其它特性,这些会在其他博客深入讲解。


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐