Linux下32位环境的用户空间内存分布情况

Image

栈(stack)

核心总结:

栈 = CPU + 编译器 + 操作系统协同实现的 “自动管理,连续内存,超高性能”的临时数据区

栈的本质

CPU 视角

CPU 眼里没有“栈区”这个概念,只有

  • SP(Stack Pointer)寄存器

  • 一段连续内存

栈 = SP 指向的一段连续内存+一套约定的使用规则

栈的核心机制

栈的本质就是:

通过 SP(栈指针)做加减法来分配/释放内存

例如:

void func() {
    int a;
    int b;
}

编译后本质类似:

sub rsp, 8   // 分配空间(压栈)
...
add rsp, 8   // 释放空间(出栈)

没有 malloc/free 没有链表查找,纯指针移动

为什么栈访问的时间复杂度是O(1)

栈是靠地址偏移量来寻址

例如:

a → SP + 0
b → SP + 4
c → SP + 8

不需要查找 → 直接算地址 → O(1)

栈中存什么

局部变量

int a;

函数参数

func(int x, int y);

返回地址(非常关键)

CALL func → 把返回地址压栈
RET → 从栈中取出返回地址

寄存器现场(上下文)

  • 保存调用前的状态

  • 用于函数返回恢复

栈的核心特性

连续内存(非常重要),栈是一整块连续空间

对比:

区域 是否连续
栈 连续
堆 分散

后进先出(LIFO)

A 调 B
B 调 C

执行顺序:
C → B → A

自动管理(无需程序员干预)

  • 编译器负责生成栈操作代码

  • CPU负责执行

生命周期 = 作用域

{
    int a;  // 进入作用域创建
}           // 离开作用域销毁

空间小(高风险)

系统 默认栈大小
Linux ~8MB
Windows ~1MB

栈为什么这么快

连续内存(最关键)

CPU 缓存机制

CPU读取方式:访问一个变量 → 附近变量一起进入缓存

一次读取 64 字节(cache line)

无查找开销

栈:SP + offset → O(1)
堆:链表/树查找 → O(n)/log(n)

线程与栈

每个线程都有自己的栈,互不共享

线程A → 栈A
线程B → 栈B

线程切换时,SP也跟着“切换”

线程切换时:

保存 SP_A
加载 SP_B

栈和堆会不会相撞?会

有操作系统(Linux/Windows)

  • 有 Guard Page(保护页)

  • 撞上 -> 直接崩溃:Segmentation Fault

无MMU(嵌入式)

栈覆盖堆 / 堆覆盖栈
→ 数据错乱(最难调试)

栈的核心风险

栈溢出(Stack Overflow)

超出栈空间 → 程序崩溃

void func() {
    char buf[10MB];  // ❌
}

返回局部变量地址(致命)

a 已销毁 → 野指针

int* func() {
    int a = 10;
    return &a;  // ❌
}

深度递归

每次调用都会压栈 → 爆栈

func() {
    func();  // ❌
}

大结构体传参(性能坑)

整体拷贝 → 栈压力大

void func(struct BigData data); // ❌

// 正确方式:传指针
void func(struct BigData* data);

大数据在栈上的后果

中等大(几十KB~几百KB)

不会崩溃,但:

  • Cache 被冲爆

  • 性能暴跌(缓冲抖动)

非常大(MB级)

直接:

Stack Overflow

工程最佳实践

栈只放“小数据”

int / 指针 / 小结构体

大数据必须放堆

malloc()

避免深递归

控制线程数量

每个线程 ≈ 1MB~8MB

嵌入式要特别小心:堆栈互相覆盖

堆(heap)

堆的本质

一句话定义

堆 = 一段由运行时库(glibc)管理的虚拟内存区域 + 一套动态分配算法

拆开理解:

  • 内存来源 -> 操作系统(虚拟内存)

  • 管理者 -> glibc(malloc实现)

  • 核心能力 -> 动态分配 / 释放 / 复用

堆 vs 栈

维度 栈 堆
管理者 CPU + 编译器 + 内核 glibc(用户态)
分配方式 SP 寄存器移动 查找 + 算法
生命周期 自动 手动
结构 线性 非线性
性能 极快 相对慢

本质总结

栈 = 硬件级优化(寄存器驱动)

堆 = 软件级调度(算法驱动)

堆从哪里来(系统层)

程序运行时的真实过程

很多人误解:

❌ “程序一启动,堆就分好了”

真实情况是:

两层结构

  1. 虚拟地址空间(提前规划)
代码段 | 数据段 | 堆 | 栈
  1. 物理内存(按需分配)
malloc 才触发分配

malloc 调用链

你的代码
   ↓
malloc()
   ↓
glibc(ptmalloc)
   ↓
系统调用(brk / mmap)
   ↓
内核
   ↓
物理内存

malloc 从哪里拿内存

  1. brk / sbrk(传统堆)
heap_end → 向上增长

特点:

  • 连续区域

  • 适合小块分配

  1. mmap
独立内存映射

特点:

  • 大块分配

  • 不依赖堆区

核心结论

操作系统:负责“给大块”

malloc:负责“切小块”

malloc 到底干了什么(核心)

关键问题

malloc 怎么知道哪里有空间

答案:

它维护了一套数据结构(内存管理系统)

最基础实现:空闲链表

内存真实结构

[已用][空闲][已用][空闲][空闲]

glibc内部维护

free_list:
  → block1 → block2 → block3

malloc 分配流程

1. 查找空闲块(遍历/分箱)
2. 找到合适大小
3. 分割(如果太大)
4. 返回指针

free 释放流程

1. 标记为空闲
2. 插入空闲链表
3. 尝试合并相邻块

两个核心机制

分割(Splitting)

64B → 分16B → 剩48B

合并(Coalescing)

[free][free] → 合并 → 大块

堆的结构本质(非常关键)

内存块结构(Chunk)

[ header | user data ]

header里包含:

  • size

  • 是否使用

  • 指针(链表)

为什么必须有 header?

因为:

堆是“无序空间”,必须靠元数据管理

为什么堆是“分散的”

示例

p1 = malloc(100);
p2 = malloc(200);
free(p1);
p3 = malloc(50);

可能变成:

[p3][剩余][p2]

本质原因

分配是按需求动态进行

核心结论

堆 = 非连续 + 不规则 + 碎片化

堆为什么慢

查找成本

遍历链表 / bins

元数据开销

[header][data]

cache 不友好(关键)

栈:

连续内存 -> cache 命中率高

堆:

node = node->next;

→ 每次跳地址 → cache miss

本质总结

堆慢不是 malloc 慢,而是访问模式差

CPU 视角理解

栈访问

SP + offset

一条指令

堆访问

指针 → 地址 → 数据

多次跳转

类比

数据结构 性能
数组(栈) 快
链表(堆) 慢

堆会出现的核心问题

内粗泄露

malloc → 没有 free

本质:

指针丢失 = 无法回收

野指针

free(p);
*p = 10; // ❌

double free

free(p);
free(p); // ❌

会破坏堆结构(非常严重)

内存碎片(重点)

外部碎片

空闲但不连续 → 分配失败

内部碎片

分配大于需求 → 浪费

什么时候必须用堆

大数据

malloc(10MB);

生命周期不确定

  • 用户输入

  • 网络数据

跨函数

动态结构

  • 链表

  • 数

  • 图

工程最佳实践

基本原则

malloc ↔ free 必须配对

释放后置NULL

free(p);
p = NULL;

减少 malloc 次数

高频调用 = 性能杀手

使用内存池(关键)

适合:

  • 小对象

  • 高频分配

批量分配

减少碎片 + 提升性能

认知闭环

堆的本质

堆不是一块简单内存,它是一个“用户态内存管理系统”

三层结构

OS(给内存)
glibc(管理内存)
程序(使用内存)

三个核心机制

  • 分块(chunk)

  • 组织(链表 / bins)

  • 调度(分配算法)

全局数据区(.data/.bss)

这个区域主要用来存放 **全局变量 **和 **静态变量 **。它们的特点是生命周期贯穿整个程序运行期,不会因为函数调用结束而消失。

在底层实现上,这个区域通常会被细分为两个部分:

  1. 已初始化数据区(.data段)
  • 存放内容:这里存放的是已经被显示初始化且不为 0 的全局变量和静态变量。

  • 例子: int g_num = 100; 或者 static int s_count = 5;

  • 特点:程序启动时,系统会把初始值从可执行文件中拷贝到内存里。

  1. 未初始化数据区(.bss段)
  • 存放内容:这里存放的是未初始化或者被初始化为 0 的全局变量和静态变量。

  • 例子:int g_val; 或者 static int s_zero = 0; 。

  • 冷知识:为什么要把初始化为0的单独放?为了省空间。在硬盘的可执行文件中,.bss 段不占实际空间(因为全是0,没必要存),只有当程序加载到内存运行时,操作系统才会分配空间并清零。

  • 未初始化或者被初始化为 0 的全局变量和静态变量 默认值都为0,在函数内部定义的普通局部变量(非 static)不一定为0 。

  1. 全局数据区为什么要分全局变量 和 静态变量?(可见性)

好处:

  • 避免命名冲突

  • 实现模块的“私有成员”

一句话总结: 只要你定义了一个变量,希望它在整个程序里都能“记住”状态,或者跨函数共享,它大概率就住在这里。

常量区(.rodata)

这个区域通常被称为 .rodata (Read-Only Data),顾名思义,它是只读的。

  • 存放内容:

    • 字符串常量: 比如代码里写的 "Hello World"。

    • const 修饰的全局常量: 比如 const int MAX_SIZE = 100;(注意:如果是局部的 const 变量,可能会在栈上,但全局的 const 一定在这里)。

  • 关键特性:

    • 只读保护: 如果你试图修改这里的数据(例如 char *p = "abc"; p = 'x';),程序会直接崩溃(段错误 Segmentation Fault)。这是操作系统层面的保护机制。

    • 共享性: 如果你在代码里多次使用了相同的字符串常量(比如用了 10 次 "Error"),编译器为了省内存,通常会在常量区只存一份,让所有引用都指向同一个地址。

一句话总结: 这里是“只读档案室”,存放那些一旦设定就不允许修改的固定数据,保证了数据的安全性和内存的节省。

代码区(.text)

这个区域也被称为 .text 段,它是程序的“大脑”和“骨架”。

  • 存放内容:

    • 机器指令: 也就是你写的 if、for、函数调用等逻辑编译后的二进制代码。

    • 函数体: 所有函数的代码逻辑都存放在这里。

  • 关键特性:

    • 只读且可执行: 这里的内存属性通常是“可读、可执行,但不可写”。这是为了防止程序因为 Bug 或病毒攻击而意外修改了自己的指令(比如覆盖了函数的代码)。

    • 共享: 如果有多个进程运行同一个程序(比如打开了 5 个 Chrome 浏览器),操作系统通常只需要在物理内存中加载一份代码区,所有进程共享这一份代码,极大地节省了内存。

一句话总结: 这里是程序的“剧本”,CPU 从这里读取指令并执行,普通变量永远不会存放在这里。

栈 VS 堆

特性 栈 堆
分配方式 自动 手动
内存结构 连续 分散
速度 极快 较慢
生命周期 作用域 手动
大小 小 大
典型问题 栈溢出 内存泄露