一个 Vec 的数据到底在内存哪:栈、堆,和它们相向而行的真相
上一篇我们看清了:一个 Vec 是栈上 24 字节的「牌子」,真正的数据在堆上。当时我把「栈」和「堆」当成两个理所当然的词用过去了。但你有没有想过一个更朴素的问题------栈在内存的哪、堆在内存的哪?它俩是挨着的,还是天各一方?谁的地址高?
这篇还是老办法:不背概念,打印几个真实地址,让一个进程自己告诉我们它的内存长什么样。
本文所有地址都是下面这段程序真跑出来的,复制即可复现(rustc main.rs && ./main):
rust
static GLOBAL: i32 = 42; // 全局/静态:编译进二进制
fn code_here() {} // 一段代码:.text
fn recurse(depth: u32) {
let marker = depth; // 每层调用栈帧里的一个局部变量
println!(" 栈 调用第 {depth} 层 @ {:p}", &marker);
if depth < 3 { recurse(depth + 1); }
}
fn main() {
let b1 = Box::new(1u8);
let b2 = Box::new(2u8);
let v = vec![10, 20, 30];
let local = 7;
println!("== 四样东西各落在哪个地址 ==");
println!("代码 code_here @ {:p}", code_here as *const ());
println!("全局 GLOBAL @ {:p}", &GLOBAL);
println!("堆 Box / Vec @ {:p} / {:p}", &*b1, v.as_ptr());
println!("栈 局部变量 @ {:p}", &local);
println!("\n== 堆向上长(后申请的地址更高)==");
println!(" 堆 先 Box @ {:p}", &*b1);
println!(" 堆 后 Box @ {:p}", &*b2);
println!("\n== 栈向下长(越深的调用地址越低)==");
recurse(0);
}
我这台机器(macOS / arm64)跑出来是这样:
ini
== 四样东西各落在哪个地址 ==
代码 code_here @ 0x1044e8e88
全局 GLOBAL @ 0x1045220d0
堆 Box / Vec @ 0x10457dad0 / 0x10457db10
栈 局部变量 @ 0x16b91628c
== 堆向上长(后申请的地址更高)==
堆 先 Box @ 0x10457dad0
堆 后 Box @ 0x10457db00
== 栈向下长(越深的调用地址越低)==
栈 调用第 0 层 @ 0x16b916104
栈 调用第 1 层 @ 0x16b916094
栈 调用第 2 层 @ 0x16b916024
栈 调用第 3 层 @ 0x16b915fb4
老规矩:受地址随机化(ASLR)影响,你跑出来的具体数值 不会和我一样,每次运行也都在变。但关系是恒定的------代码 < 全局 < 堆,永远在远低于栈的那一段;堆往高地址长,栈往低地址长。看关系,不看数字。
一、四样东西,四个区
先把上面第一组地址从小到大排一下,规律一眼就出来:
rust
代码 code_here @ 0x1044e8e88 ┐
全局 GLOBAL @ 0x1045220d0 │ 低地址这一头,挨在一起
堆 Box/Vec @ 0x10457dad0 ┘
............(巨大的空洞)............
栈 局部变量 @ 0x16b91628c 高地址这一头,孤零零在很远的地方
一个正在运行的进程,内存不是一锅粥,是分好区的。你写的每样东西,编译器和运行时都给它安排了固定的去处:
- 代码区(.text) :你写的函数本身------
main、code_here、recurse的机器指令。只读,地址最低。 - 全局/静态区 :
static GLOBAL这种活得和程序一样久的数据,编译进二进制,紧挨着代码。 - 堆(heap) :
Box::new、Vec的元素------运行时才申请、大小可变、由你决定生死的数据。 - 栈(stack) :
local、marker这种函数里的局部变量,随调用进出自动生灭,地址最高。
arduino
一个进程看到的地址空间(低 → 高)
低地址 ┌──────────────────┐ 0x1044e8...
│ 代码 .text │ 你写的函数指令,只读
├──────────────────┤ 0x1045220...
│ 全局 / 静态 │ static,活得和程序一样久
├──────────────────┤ 0x10457d...
│ 堆 heap ↑↑↑ │ Box / Vec 数据,向高地址长
│ │
│ ⋮ │
│ (巨大空洞) │ ← 没有映射的地址,碰一下就段错误
│ ⋮ │
│ │
│ 栈 stack ↓↓↓ │ 局部变量,向低地址长
高地址 └──────────────────┘ 0x16b916...
二、堆向上长,栈向下长
最有意思的是中间那一大段空洞,和两头朝相反方向生长这件事。
先看堆。我连着 Box::new 两次,地址是 ...dad0 → ...db00,后申请的更高。堆就像往一个房间里从门口开始往里摆箱子,新箱子摆在更靠里(更高)的地址。
rust
== 堆向上长 ==
先 Box @ 0x10457dad0 ← 先来,地址低
后 Box @ 0x10457db00 ← 后来,地址高 堆朝高地址生长 ↑
再看栈。recurse 每多递归一层,就多压一个栈帧,里面的 marker 地址是:
== 栈向下长 ==
第 0 层 @ 0x16b916104 ┐
第 1 层 @ 0x16b916094 │ 每深一层,地址往下掉 0x90
第 2 层 @ 0x16b916024 │ 栈朝低地址生长 ↓
第 3 层 @ 0x16b915fb4 ┘
每深一层调用,地址往下掉(这台机器上每层正好 0x90 字节)。函数返回时再一层层弹回去,地址又涨回来。所以栈是从高地址往低地址压的。
把两件事拼起来,就是那张经典的图:堆从下往上长,栈从上往下长,两边朝着中间那片空洞相向而行。
arduino
低 ┌────────────┐
│ 代码/全局 │
├────────────┤
│ 堆 heap │
│ ↑ ↑ ↑ │ 申请越多,往上顶
│ │
│ ......空洞...... │ ← 留给两边生长的余地
│ │
│ ↓ ↓ ↓ │ 调用越深,往下压
│ 栈 stack │
高 └────────────┘
为什么这么设计?因为程序运行时,没人提前知道你会用多少栈、多少堆。让它俩从两端相向生长、共享中间那片巨大的空洞,谁也不挤谁------这是最省心的分配方式。真正撞上了(栈一直压到把堆顶穿),就是你听过的 stack overflow 和 OOM 的物理来源。
三、一个不能不提的诚实
到这里你可能想拿尺子量一量:代码在 0x1044e8...,栈在 0x16b916...,中间这片"空洞"有多大?算一下差不多是 16 GB 还多。
可我这台机器根本没有 16 GB 内存全给这一个小程序。所以这片"空洞"不是真的空着的物理内存 ------它是一段没有映射 的地址,你写下的每个地址(0x16b916... 这种)也不是内存条上的真实位置。它们是操作系统发给这个进程的一套"假地址",进程以为自己独占了一整条笔直的地址空间,从低到高随便用。
这就是为什么上面那几张图我都画成分段的、中间留着大片空白 ,而不是教科书里常见的那种"一根连续标尺、栈堆紧贴着两端对冲"------真实的地址空间是稀疏 的,绝大部分都是没人住的空地。你打印出来的 0x16b916... 和 0x10457d... 隔了十万八千里,正是这个原因。
至于这套"假地址"是谁、怎么翻译成内存条上真实的位置的------那是 虚拟内存 和 分页 的事,每一个都能单独撑起一篇,这里就先按住不展开。这篇我们只需要拿到一个干净的结论:
一个进程眼里的内存,是分好区的一长条虚拟地址:代码和全局在最低端,堆从那之上向高地址长,栈在最高端向低地址长,中间隔着大片没有映射的空洞。你打印出来的每个地址,都是这条虚拟标尺上的位置,不是内存条上的真实门牌。
下一篇就拆这件最反直觉的事:你打印的地址既然是假的,那它到底怎么变成内存条上的真位置------以及为什么这套"假地址"反而是现代操作系统最重要的发明之一。