一个 Vec 的数据到底在内存哪:栈、堆,和它们相向而行的真相

一个 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) :你写的函数本身------maincode_hererecurse 的机器指令。只读,地址最低。
  • 全局/静态区static GLOBAL 这种活得和程序一样久的数据,编译进二进制,紧挨着代码。
  • 堆(heap)Box::newVec 的元素------运行时才申请、大小可变、由你决定生死的数据。
  • 栈(stack)localmarker 这种函数里的局部变量,随调用进出自动生灭,地址最高。
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... 隔了十万八千里,正是这个原因。

至于这套"假地址"是谁、怎么翻译成内存条上真实的位置的------那是 虚拟内存分页 的事,每一个都能单独撑起一篇,这里就先按住不展开。这篇我们只需要拿到一个干净的结论:

一个进程眼里的内存,是分好区的一长条虚拟地址:代码和全局在最低端,堆从那之上向高地址长,栈在最高端向低地址长,中间隔着大片没有映射的空洞。你打印出来的每个地址,都是这条虚拟标尺上的位置,不是内存条上的真实门牌。

下一篇就拆这件最反直觉的事:你打印的地址既然是假的,那它到底怎么变成内存条上的真位置------以及为什么这套"假地址"反而是现代操作系统最重要的发明之一。

相关推荐
程序员黑豆2 小时前
全新系列开启:AI 全栈开发
前端·后端·全栈
自进化Agent智能体2 小时前
Skill Marketplace架构:AI能力的民主化与生态建设
后端
千云2 小时前
ClaudeCode Skill生成教学培训文档,助力新人快速学习项目
人工智能·后端·ai编程
fliter3 小时前
Rust 构建为什么这么慢?从工具链底层到实际优化的完整排查指南
后端
用户9772654613843 小时前
Boto3:Python 开发者操作 AWS 的官方 SDK
后端
程序员cxuan3 小时前
姚顺雨这次访谈,腾讯终于把 AI 下半场讲明白了
人工智能·后端·程序员
神奇小汤圆3 小时前
开源:把自己"博客转推文"蒸馏成一个 Agent Skill
后端
雪隐4 小时前
个人电脑玩AI-02让5060 Ti给你打工——Whisper语音识别篇(下)
人工智能·后端
道友可好4 小时前
Superpowers vs OpenSpec vs Spec Kit:该选哪个?
前端·人工智能·后端