Linux下32位环境的用户空间内存分布情况
栈(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 寄存器移动 | 查找 + 算法 |
| 生命周期 | 自动 | 手动 |
| 结构 | 线性 | 非线性 |
| 性能 | 极快 | 相对慢 |
本质总结
栈 = 硬件级优化(寄存器驱动)
堆 = 软件级调度(算法驱动)
堆从哪里来(系统层)
程序运行时的真实过程
很多人误解:
❌ “程序一启动,堆就分好了”
真实情况是:
两层结构
- 虚拟地址空间(提前规划)
代码段 | 数据段 | 堆 | 栈
- 物理内存(按需分配)
malloc 才触发分配
malloc 调用链
你的代码
↓
malloc()
↓
glibc(ptmalloc)
↓
系统调用(brk / mmap)
↓
内核
↓
物理内存
malloc 从哪里拿内存
- brk / sbrk(传统堆)
heap_end → 向上增长
特点:
-
连续区域
-
适合小块分配
- 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)
这个区域主要用来存放 **全局变量 **和 **静态变量 **。它们的特点是生命周期贯穿整个程序运行期,不会因为函数调用结束而消失。
在底层实现上,这个区域通常会被细分为两个部分:
- 已初始化数据区(.data段)
-
存放内容:这里存放的是已经被显示初始化且不为 0 的全局变量和静态变量。
-
例子:
int g_num = 100;或者static int s_count = 5; -
特点:程序启动时,系统会把初始值从可执行文件中拷贝到内存里。
- 未初始化数据区(.bss段)
-
存放内容:这里存放的是未初始化或者被初始化为 0 的全局变量和静态变量。
-
例子:
int g_val;或者static int s_zero = 0;。 -
冷知识:为什么要把初始化为0的单独放?为了省空间。在硬盘的可执行文件中,.bss 段不占实际空间(因为全是0,没必要存),只有当程序加载到内存运行时,操作系统才会分配空间并清零。
-
未初始化或者被初始化为 0 的全局变量和静态变量 默认值都为0,在函数内部定义的普通局部变量(非
static)不一定为0 。
- 全局数据区为什么要分全局变量 和 静态变量?(可见性)
好处:
-
避免命名冲突
-
实现模块的“私有成员”
一句话总结: 只要你定义了一个变量,希望它在整个程序里都能“记住”状态,或者跨函数共享,它大概率就住在这里。
常量区(.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 堆
| 特性 | 栈 | 堆 |
|---|---|---|
| 分配方式 | 自动 | 手动 |
| 内存结构 | 连续 | 分散 |
| 速度 | 极快 | 较慢 |
| 生命周期 | 作用域 | 手动 |
| 大小 | 小 | 大 |
| 典型问题 | 栈溢出 | 内存泄露 |