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

内存基础:值放堆上还是放栈上
XIU今日教学目标
- 深刻理解栈和栈帧的工作原理,以及函数调用与栈的关系。
- 深刻理解堆的工作原理,以及堆内存的分配与释放。
- 掌握”什么时候数据放栈上、什么时候放堆上”的判断方法。
- 理解手工内存管理、GC、ARC 三种内存管理方式的优劣。
- 为后续学习 Rust 所有权系统打下坚实的底层基础。
课前小故事:仓库管理员的烦恼
想象你是一个仓库管理员,仓库有两种存储区域:
货架区(栈):整齐划一的标准格子,存取速度极快——伸手就拿到。但每格大小固定,只能放标准件。更重要的是,货架区按照”后进先出”原则管理——最后放上去的必须最先取走。
露天堆场(堆):空间巨大,想放多大就放多大。但每次存放都要登记(malloc)、调用叉车(系统调用),速度慢。而且,你必须记住每件货物放在哪、什么时候该运走——忘了运走就会越堆越满(内存泄漏),运走后发现还要用就会出事故(use after free)。
Rust 的所有权系统,本质上就是一套极其严格的仓库管理制度——它让你不用雇清洁工(GC),也不用自己天天盯着(手工管理),就能安全高效地管理堆场上的每一件货物。
RustRover 操作指引
创建第 3 天项目
File → New Project,项目名day03_memory,模板选Binary- 点击
Create
调试查看内存
RustRover 可以在调试时查看变量的内存布局:
- 在代码行号左侧点击设置断点
Shift+F9调试运行- 在底部 Debug 窗口中,展开变量查看其值和地址
- 右键变量选择
View Memory(如可用)可以查看原始内存
常用快捷键
| 快捷键 | 作用 |
|---|---|
Shift+F9 |
调试运行 |
F8 |
单步执行(不进入函数) |
F7 |
单步执行(进入函数) |
Alt+F8 |
调试时求值表达式 |
Ctrl+F8 |
切换断点 |
语法讲解
一、内存是什么?
我们的程序无时无刻不在跟内存打交道。看下面这行简单的 Rust 代码:
1 | let s = "hello world".to_string(); |
这一行代码,实际上跟只读数据段(RODATA)、堆、栈分别有深度交互:
- “hello world” 作为字符串常量,在编译时被存入可执行文件的
.RODATA段,程序加载时获得一个固定的内存地址。 - 执行
.to_string()时,在堆上分配一块新内存,把 “hello world” 逐个字节拷贝过去。 - 把堆上的数据赋值给
s时,s作为栈上的变量,需要知道堆上内存的地址、字符串的当前长度和总容量。
最终,为了表述这个字符串,我们使用了三个 word:
- 第一个 word:指向堆数据的指针
- 第二个 word:字符串的当前长度(11)
- 第三个 word:这片内存的总容量(11)
在 64 位系统下,三个 word 是 24 个字节。这就是一个典型的”栈上存胖指针、堆上存实际数据”的例子。
二、栈(Stack)
栈是程序运行的基础
每当一个函数被调用时,一块连续的内存就会在栈顶被分配出来,这块内存被称为帧(frame)。
栈是自顶向下增长的。一个程序的调用栈最底部,除去入口帧,就是 main() 函数对应的帧。随着 main() 一层层调用,栈会一层层扩展;调用结束,栈又会一层层回溯,把内存释放回去。
💡 初学者提示:可以把栈想象成一摞盘子——每调用一个函数就放一个新盘子(栈帧)在上面,函数返回就拿走这个盘子。堆则像一个仓库——东西放在哪里需要记下地址(指针)。
栈帧的内容
一个新的帧会分配足够的空间存储:
- 寄存器的上下文:函数里使用到的通用寄存器会在栈上保存一个副本。函数调用结束后,通过副本恢复寄存器上下文
- 局部变量:函数所需要使用到的局部变量都会在帧分配时被预留出来
编译期确定大小
编译器需要明确每个局部变量的大小,以便预留空间。这是关键:
在编译时,一切无法确定大小或者大小可以改变的数据,都无法安全地放在栈上,最好放在堆上。
比如一个函数参数是字符串:
1 | fn say_name(name: String) {} |
字符串的数据结构在编译时大小不确定,运行时执行到具体代码才知道大小。所以无法把字符串本身放在栈上,只能先放在堆上,然后在栈上分配对应的指针引用堆上的内存。
栈上分配的高效性
栈上的内存分配非常高效:
- 分配:只需要改动栈指针(stack pointer),就可以预留相应的空间
- 释放:把栈指针改动回来,预留的空间就被释放
预留和释放只是动动寄存器,不涉及额外计算、不涉及系统调用,效率极高。
栈溢出(Stack Overflow)
虽然栈高效,但调用栈大小有限。过大的栈内存分配或递归函数没有妥善终止,都会导致栈溢出——程序被系统终止,产生崩溃。
⚠️ 避坑指南:
- 避免在栈上分配大数组(如
[u8; 1_000_000])- 递归函数必须有终止条件
- 如果递归深度不确定,考虑改用循环
三、堆(Heap)
何时需要堆
栈的局限显而易见:当我们需要动态大小的内存时,只能使用堆。比如可变长度的数组、列表、哈希表、字典,它们都分配在堆上。
1 | fn main() { |
上面这个列表实际预留的大小是 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 | // 危险:在栈上分配 1MB |
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 | // vulnerable.c — 演示栈溢出漏洞 |
Rust 的标准输入读取方式则完全不同:
1 | // safe_input.rs — Rust 安全读取用户输入 |
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 | // 在 Cargo.toml 中添加: stacker = "1" |
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:思考题
- 如果有一个数据结构需要在多个线程中访问,可以把它放在栈上吗?为什么?
- 可以使用指针引用栈上的某个变量吗?如果可以,在什么情况下可以这么做?
作业 4(大实验种子):理解 Agent 消息的内存布局
- 在
pi_agent中定义Message { role: String, content: String } - 思考:
role和content各自哪些部分在栈上、哪些在堆上 - 打印
std::mem::size_of::<Message>()的结果
参考答案
作业 1 答案
1 | // day03_memory/src/main.rs |
作业 2 答案
1 | // day03_memory/src/main.rs |
作业 3 答案
1. 多线程访问的数据能放栈上吗?
不能。栈上内存的生命周期局限在当前调用栈内,函数返回后帧被回收。多线程访问需要数据在不同的调用栈之间共享,必须放在堆上。
2. 可以用指针引用栈上的变量吗?
可以,但必须保证在该指针使用期间,被引用的栈帧没有被回收。也就是说,引用不能比被引用的变量活得更久。这恰恰是 Rust 生命周期系统要保证的——编译器会检查引用不会超出被引用者的生命周期。
作业 4 答案(大实验种子)
1 | // pi_agent/src/main.rs |
今日小结
- 一行简单的
let s = "hello".to_string()涉及 RODATA 段、堆、栈三个区域。 - 栈是程序运行的基础,每个函数调用产生一个帧;栈分配极快但大小有限。
- 编译时无法确定大小或大小可变的数据,必须放堆上——这是判断堆栈的根本标准。
- 堆提供动态大小和动态生命周期,但带来内存泄漏、堆越界、use-after-free 三大安全隐患。
- 手工管理、GC、ARC 各有优劣;Rust 通过所有权系统走出第四条路——编译期保证内存安全,零运行时开销。
- 栈上存放的数据是静态的:固定大小、固定生命周期。堆上存放的数据是动态的:不固定大小、不固定生命周期。
- 这些底层知识是理解 Rust 所有权和生命周期的基础,明天我们继续学习变量、数据类型等具体语法。




