内存基础:值放堆上还是放栈上

今日教学目标

  1. 深刻理解栈和栈帧的工作原理,以及函数调用与栈的关系。
  2. 深刻理解堆的工作原理,以及堆内存的分配与释放。
  3. 掌握”什么时候数据放栈上、什么时候放堆上”的判断方法。
  4. 理解手工内存管理、GC、ARC 三种内存管理方式的优劣。
  5. 为后续学习 Rust 所有权系统打下坚实的底层基础。

课前小故事:仓库管理员的烦恼

想象你是一个仓库管理员,仓库有两种存储区域:

货架区(栈):整齐划一的标准格子,存取速度极快——伸手就拿到。但每格大小固定,只能放标准件。更重要的是,货架区按照”后进先出”原则管理——最后放上去的必须最先取走。

露天堆场(堆):空间巨大,想放多大就放多大。但每次存放都要登记(malloc)、调用叉车(系统调用),速度慢。而且,你必须记住每件货物放在哪、什么时候该运走——忘了运走就会越堆越满(内存泄漏),运走后发现还要用就会出事故(use after free)。

Rust 的所有权系统,本质上就是一套极其严格的仓库管理制度——它让你不用雇清洁工(GC),也不用自己天天盯着(手工管理),就能安全高效地管理堆场上的每一件货物。


RustRover 操作指引

创建第 3 天项目

  1. File → New Project,项目名 day03_memory,模板选 Binary
  2. 点击 Create

调试查看内存

RustRover 可以在调试时查看变量的内存布局:

  1. 在代码行号左侧点击设置断点
  2. Shift+F9 调试运行
  3. 在底部 Debug 窗口中,展开变量查看其值和地址
  4. 右键变量选择 View Memory(如可用)可以查看原始内存

常用快捷键

快捷键 作用
Shift+F9 调试运行
F8 单步执行(不进入函数)
F7 单步执行(进入函数)
Alt+F8 调试时求值表达式
Ctrl+F8 切换断点

语法讲解

一、内存是什么?

我们的程序无时无刻不在跟内存打交道。看下面这行简单的 Rust 代码:

1
let s = "hello world".to_string();

这一行代码,实际上跟只读数据段(RODATA)、堆、栈分别有深度交互:

  1. “hello world” 作为字符串常量,在编译时被存入可执行文件的 .RODATA 段,程序加载时获得一个固定的内存地址。
  2. 执行 .to_string(),在堆上分配一块新内存,把 “hello world” 逐个字节拷贝过去。
  3. 把堆上的数据赋值给 ss 作为栈上的变量,需要知道堆上内存的地址、字符串的当前长度和总容量。

最终,为了表述这个字符串,我们使用了三个 word

  • 第一个 word:指向堆数据的指针
  • 第二个 word:字符串的当前长度(11)
  • 第三个 word:这片内存的总容量(11)

在 64 位系统下,三个 word 是 24 个字节。这就是一个典型的”栈上存胖指针、堆上存实际数据”的例子。


二、栈(Stack)

栈是程序运行的基础

每当一个函数被调用时,一块连续的内存就会在栈顶被分配出来,这块内存被称为帧(frame)

栈是自顶向下增长的。一个程序的调用栈最底部,除去入口帧,就是 main() 函数对应的帧。随着 main() 一层层调用,栈会一层层扩展;调用结束,栈又会一层层回溯,把内存释放回去。

💡 初学者提示:可以把栈想象成一摞盘子——每调用一个函数就放一个新盘子(栈帧)在上面,函数返回就拿走这个盘子。堆则像一个仓库——东西放在哪里需要记下地址(指针)。

栈帧的内容

一个新的帧会分配足够的空间存储:

  • 寄存器的上下文:函数里使用到的通用寄存器会在栈上保存一个副本。函数调用结束后,通过副本恢复寄存器上下文
  • 局部变量:函数所需要使用到的局部变量都会在帧分配时被预留出来

编译期确定大小

编译器需要明确每个局部变量的大小,以便预留空间。这是关键:

在编译时,一切无法确定大小或者大小可以改变的数据,都无法安全地放在栈上,最好放在堆上。

比如一个函数参数是字符串:

1
2
3
4
5
fn say_name(name: String) {}

// 调用
say_name("Lindsey".to_string()); // 7 字节
say_name("Rosie".to_string()); // 5 字节

字符串的数据结构在编译时大小不确定,运行时执行到具体代码才知道大小。所以无法把字符串本身放在栈上,只能先放在堆上,然后在栈上分配对应的指针引用堆上的内存。

栈上分配的高效性

栈上的内存分配非常高效:

  • 分配:只需要改动栈指针(stack pointer),就可以预留相应的空间
  • 释放:把栈指针改动回来,预留的空间就被释放

预留和释放只是动动寄存器,不涉及额外计算、不涉及系统调用,效率极高。

栈溢出(Stack Overflow)

虽然栈高效,但调用栈大小有限。过大的栈内存分配或递归函数没有妥善终止,都会导致栈溢出——程序被系统终止,产生崩溃。

⚠️ 避坑指南

  • 避免在栈上分配大数组(如 [u8; 1_000_000]
  • 递归函数必须有终止条件
  • 如果递归深度不确定,考虑改用循环

三、堆(Heap)

何时需要堆

栈的局限显而易见:当我们需要动态大小的内存时,只能使用堆。比如可变长度的数组、列表、哈希表、字典,它们都分配在堆上。

1
2
3
4
5
6
fn main() {
// Vec 是动态数组,分配在堆上
let mut arr = Vec::new();
arr.push(1);
arr.push(2);
}

上面这个列表实际预留的大小是 4(大于实际长度 2),因为堆上内存分配会使用 malloc() 函数,其内部会请求操作系统的系统调用。系统调用的代价是昂贵的,所以我们要避免频繁 malloc。

动态生命周期

除了动态大小,动态生命周期的内存也需要分配到堆上

栈上内存的生命周期不受开发者控制,局限在当前调用栈。函数调用结束,帧被回收,相关变量也被回收。

而堆上分配的每一块内存需要显式释放,这使得堆上内存有更加灵活的生命周期,可以在不同的调用栈之间共享数据

堆的三大问题

堆内存的灵活性也给内存管理带来很多挑战:

1. 内存泄漏(Memory Leak)
如果手工管理堆内存,分配后忘记释放,就会造成内存泄漏。程序运行越久越吃内存,最终被操作系统终止。

2. 堆越界(Heap Out of Bounds)
如果堆上内存被多个线程引用,一个线程在遍历列表,另一个线程释放了其中某项,就可能访问野指针。堆越界是第一大内存安全问题

3. Use After Free
如果堆上内存被释放,但栈上指向它的指针没有被清空,就可能发生使用已释放内存。这是第二大内存安全问题


四、三种内存管理方式对比

为了解决堆内存管理问题,不同的语言采用了不同的策略:

手工管理(C/C++)

开发者自己调用 malloc/free。效率最高,但极易出错——忘了 free 就泄漏,free 了又用就 use after free。

追踪式垃圾回收 GC(Java/Go/Python)

通过定期标记(mark)找出不再被引用的对象,然后清理(sweep)掉。

  • 优点:分配和释放无需额外操作,吞吐量大
  • 缺点:释放时机不确定,STW(Stop The World)导致延迟不确定。不适于嵌入式或实时系统
  • 体验差异:Android 偶尔卡顿而 iOS 丝滑,很大程度是 GC vs ARC 的差异

自动引用计数 ARC(Swift/ObjC)

在编译时为每个函数插入 retain/release 语句,维护引用计数。计数为零时释放对象。

  • 优点:延迟确定,没有 STW
  • 缺点:额外代码开销,循环引用需要特殊处理

💡 关键理解:Rust 走了第四条路——通过所有权系统和生命周期,在编译期保证内存安全,无需 GC 也无需 ARC 的运行时开销。这让内存安全和高性能二者兼得。


五、一句话总结堆与栈

特性
大小 编译期确定 运行时动态
生命周期 当前调用栈作用域 从分配到释放
速度 极快(移动指针) 较慢(系统调用)
管理 自动(函数返回时释放) 手动/GC/ARC/所有权
安全性 安全 容易出问题
适用场景 固定大小的局部变量 动态大小、动态生命周期

栈上存放的数据是静态的——固定大小,固定生命周期。堆上存放的数据是动态的——不固定大小,不固定生命周期。


常见错误 / 避坑指南

1. 误以为基本类型一定在栈上

很多人有个模糊的印象:”基本类型在栈上,对象在堆上”或”少量数据在栈上,大量数据在堆上”。这些说法虽然对,但没有抓到实质。

真正的判断标准:编译时能否确定大小。如果大小固定且不可变,放栈上;如果大小动态可变,放堆上。

2. 忽视栈溢出风险

1
2
3
4
5
6
7
8
9
10
11
// 危险:在栈上分配 1MB
fn bad() {
let huge = [0u8; 1_048_576]; // 可能栈溢出!
println!("{}", huge[0]);
}

// 正确:使用 Vec 放在堆上
fn good() {
let huge = vec![0u8; 1_048_576]; // 堆上分配,安全
println!("{}", huge[0]);
}

3. 不理解递归为何导致栈溢出

每次递归调用都会创建新的栈帧。如果递归没有终止条件,栈会不断扩展直到溢出。

4. 混淆 GC 和 ARC 的性能

GC 的分配/释放吞吐量其实比 ARC 高,但因为偶尔的高延迟 STW,被感知的性能比较差。所以会给人”GC 不如 ARC”的错觉。做后端服务时,GC 的 STW 也会影响 p99 延迟。

💡 类比理解

  • GC 就像定期派清洁工来打扫整个仓库——打扫时仓库必须暂停营业(STW),但平时放东西很快
  • ARC 就像每件货物自带计数器——有人拿走就 +1,放回就 -1,归零就运走。不用暂停营业,但每次存取都要登记

🔒 安全视角延伸

栈溢出攻击(Stack Smashing)与 Rust 的防御

💡 概念区分:正文中讲的”栈溢出”是指栈空间耗尽导致的崩溃(无意行为);本节讲的是”栈溢出攻击”(Stack Smashing)——攻击者故意向栈上的缓冲区写入超长数据,覆盖返回地址,劫持程序控制流(恶意行为)。两者名称相似但本质不同。

上一天我们了解了缓冲区溢出的基本概念。今天我们深入栈内存,看看**栈溢出攻击(Stack Smashing)**的具体原理,以及 Rust 如何从源头阻止这类攻击。

栈溢出攻击的完整链条

当一个函数被调用时,系统会在栈上分配一个栈帧(Stack Frame),其中包含局部变量、函数参数,以及最关键的——返回地址(CPU 执行完函数后跳回的位置)。攻击者的目标是覆盖这个返回地址,让 CPU 跳转到 shellcode(一段精心构造的机器码)。

C 的危险函数 vs Rust 的安全输入

C 语言中导致栈溢出的经典函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// vulnerable.c — 演示栈溢出漏洞
#include <stdio.h>

void read_name() {
char buf[16];
// gets() 从标准输入读取,不检查长度!
// 攻击者只需输入超过 16 字节即可覆盖返回地址
gets(buf); // 💀 已被 C11 标准废弃
printf("Hello, %s!\n", buf);
}

// scanf 同样危险
void read_with_scanf() {
char buf[16];
// %s 不限制输入长度,同样可溢出
scanf("%s", buf); // 💀 等价于 gets 的危险
printf("Hello, %s!\n", buf);
}

Rust 的标准输入读取方式则完全不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// safe_input.rs — Rust 安全读取用户输入
use std::io;

fn read_name() {
// 创建一个可变的空 String,动态增长,分配在堆上
let mut name = String::new();

// read_line 会读取一行输入到 name 中
// 它会根据输入长度自动扩展 String 的容量
// 不会发生栈溢出!因为数据在堆上,且有边界保护
io::stdin()
.read_line(&mut name)
.expect("读取输入失败");

// 去掉末尾换行符
let name = name.trim_end();

// 安全打印
println!("Hello, {}!", name);
}

fn main() {
read_name(); // 无论输入多长,都不会溢出
}

Rust 之所以安全,关键在于:String 的数据存储在上,会根据输入动态扩展。栈上只保存胖指针(指针 + 长度 + 容量),大小固定。写入时如果超出容量,会触发堆上的重新分配,而不是覆盖相邻栈内存。

Stack Canary:传统防御机制 vs Rust 的前置防御

主流操作系统和编译器引入了多种栈溢出防御机制,其中最经典的是 Stack Canary(栈金丝雀值)

Stack Canary 的名字来源于煤矿工人携带金丝雀下井的传统——金丝雀对有害气体更敏感,如果它倒下了,矿工就知道有危险。同理,编译器在栈帧中插入一个随机值(canary),函数返回前检查这个值是否被修改——如果被修改,说明发生了溢出,程序立即终止。

但 Stack Canary 是一种事后检测机制——它发现溢出已经发生后才中止程序。Rust 的边界检查则是事前预防机制:

防御机制 工作时机 保护效果
Stack Canary 函数返回时检测 检测到溢出则终止程序,但溢出已发生
Rust 边界检查 写入内存时检查 在越界写入发生前就 panic,阻止溢出

Rust 在写入时就阻止越界,比 canary 在返回时检测更前一步。

合理的大栈需求:stacker crate

有些场景确实需要较大的栈空间(如深度递归的解析器)。Rust 提供了 stacker crate 来优雅处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在 Cargo.toml 中添加: stacker = "1"

fn deep_recurse(n: u32) {
if n == 0 {
return;
}

// 当当前线程栈空间不足 32KB 时
// stacker 会自动切换到一个新栈上继续执行
// 避免栈溢出崩溃
stacker::maybe_grow(32 * 1024, 1024 * 1024, || {
deep_recurse(n - 1);
});
}

fn main() {
deep_recurse(100_000); // 深度递归也不会溢出!
}

stacker 不是绕过安全检查,而是合理地扩展栈空间——在保持内存安全的前提下处理大栈需求。

📖 延伸阅读OWASP Stack Buffer Overflow — 了解更多关于栈溢出攻击变体和防御策略。


课后实验作业

作业 1:观察 String 的内存布局

  • 创建一个 String 变量,用 std::mem::size_of_val 查看其大小
  • 创建一个 &str 变量,对比大小
  • 思考:为什么 String 的大小是 24 字节(64位系统)?

作业 2:栈 vs 堆

  • 创建一个固定大小数组 [i32; 1000] 和一个 Vec<i32>(也放 1000 个元素)
  • 分别写出函数接收它们,思考哪些数据在栈上、哪些在堆上

作业 3:思考题

  1. 如果有一个数据结构需要在多个线程中访问,可以把它放在栈上吗?为什么?
  2. 可以使用指针引用栈上的某个变量吗?如果可以,在什么情况下可以这么做?

作业 4(大实验种子):理解 Agent 消息的内存布局

  • pi_agent 中定义 Message { role: String, content: String }
  • 思考:rolecontent 各自哪些部分在栈上、哪些在堆上
  • 打印 std::mem::size_of::<Message>() 的结果

参考答案

作业 1 答案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// day03_memory/src/main.rs

fn main() {
// String 的内部结构:指针 + 长度 + 容量 = 3 个 word
let s = String::from("hello");
println!("String 大小: {} 字节", std::mem::size_of_val(&s));
// 64位系统输出 24 字节(3 * 8)

// &str 的内部结构:指针 + 长度 = 2 个 word(胖指针)
let s2: &str = "hello";
println!("&str 大小: {} 字节", std::mem::size_of_val(&s2));
// 64位系统输出 16 字节(2 * 8)

// 解释:
// String: ptr(8) + len(8) + capacity(8) = 24 字节
// &str: ptr(8) + len(8) = 16 字节(没有 capacity,因为不可变)

// i32 在栈上,大小固定
let n = 42i32;
println!("i32 大小: {} 字节", std::mem::size_of_val(&n));
// 输出 4 字节
}

作业 2 答案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// day03_memory/src/main.rs

// 接收固定大小数组——数组本身在栈上
fn process_array(arr: [i32; 1000]) {
println!("数组第一个元素: {}", arr[0]);
println!("数组大小: {} 字节", std::mem::size_of_val(&arr));
// 4000 字节全在栈上
}

// 接收 Vec——Vec 的元数据在栈上,数据在堆上
// (惯用写法是接收 &[i32] 切片而非 &Vec<i32>,更通用;这里为演示 Vec 结构而保留)
fn process_vec(v: &[i32]) {
println!("Vec 第一个元素: {}", v[0]);
println!("Vec 数据大小: {} 字节", v.len() * 4);
// 实际数据 4000 字节在堆上
}

fn main() {
// 固定大小数组:1000 个 i32 全部在栈上
let arr = [0i32; 1000];
process_array(arr);

println!("---");

// Vec:元数据在栈上,1000 个 i32 在堆上
let v = vec![0i32; 1000];
process_vec(&v);
}

作业 3 答案

1. 多线程访问的数据能放栈上吗?

不能。栈上内存的生命周期局限在当前调用栈内,函数返回后帧被回收。多线程访问需要数据在不同的调用栈之间共享,必须放在堆上。

2. 可以用指针引用栈上的变量吗?

可以,但必须保证在该指针使用期间,被引用的栈帧没有被回收。也就是说,引用不能比被引用的变量活得更久。这恰恰是 Rust 生命周期系统要保证的——编译器会检查引用不会超出被引用者的生命周期。

作业 4 答案(大实验种子)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// pi_agent/src/main.rs

// 定义消息结构体
struct Message {
role: String, // String: 栈上 24 字节(指针+len+cap), 堆上存实际字符
content: String, // 同上
}

fn main() {
println!("Pi Agent initialized.");

// Message 结构体在栈上的大小 = 两个 String 的大小
println!("Message 大小: {} 字节", std::mem::size_of::<Message>());
// 64位系统输出 48 字节(2 * 24)

// 内存布局分析:
// Message 在栈上:role 的 (ptr, len, cap) + content 的 (ptr, len, cap) = 48 字节
// role 的实际字符在堆上
// content 的实际字符在堆上

let msg = Message {
role: String::from("user"),
content: String::from("什么是 Rust?"),
};

println!("role 长度: {}", msg.role.len());
println!("content 长度: {}", msg.content.len());
}

今日小结

  • 一行简单的 let s = "hello".to_string() 涉及 RODATA 段、堆、栈三个区域。
  • 栈是程序运行的基础,每个函数调用产生一个帧;栈分配极快但大小有限。
  • 编译时无法确定大小或大小可变的数据,必须放堆上——这是判断堆栈的根本标准。
  • 堆提供动态大小和动态生命周期,但带来内存泄漏、堆越界、use-after-free 三大安全隐患。
  • 手工管理、GC、ARC 各有优劣;Rust 通过所有权系统走出第四条路——编译期保证内存安全,零运行时开销。
  • 栈上存放的数据是静态的:固定大小、固定生命周期。堆上存放的数据是动态的:不固定大小、不固定生命周期。
  • 这些底层知识是理解 Rust 所有权和生命周期的基础,明天我们继续学习变量、数据类型等具体语法。