内存模型
# 第 11 章 C++ 内存模型
本章定位:承上启下——前面学的"指针 / 引用 / 局部变量"为什么能那样工作?后面要学的"类与对象 / 动态内存 / RAII"建立在什么之上?答案是:进程的内存布局 与 变量的存储期 + 作用域 + 链接性。
# 目录介绍
- 01.学习目标
- 02.内存五大区域
- 03.五区在汇编层的验证
- 04.变量的三大属性:作用域 / 存储期 / 链接性
- 05.栈帧机制:函数调用如何利用栈
- 06.堆分配:new/malloc 究竟在做什么
- 07.对象的生命周期窗口
- 08.六个经典内存错误
- 09.与卷三 / 卷四的衔接
- 10.本章新手陷阱 Top 5
- 11.思考题
- 12.推荐阅读
# 01.学习目标
- [ ] 能画出一个 C++ 进程运行时的内存图(5 区)
- [ ] 区分四种存储期:自动 / 静态 / 线程 / 动态
- [ ] 区分内部链接、外部链接、无链接
- [ ] 解释"返回局部变量引用"为什么是未定义行为
- [ ] 知道一次
new与一次栈对象创建在性能上为什么差几个数量级
# 02.内存五大区域
# 2.1 五区模型总览
当 C++ 程序被加载到内存运行时,操作系统给进程的"地址空间"长这样(64 位 Linux):
高地址 ┌────────────────────────┐
│ 内核空间(不可访问) │
├────────────────────────┤ ← 0x7fff...(用户态上限)
│ 栈区 (Stack) │ ↓ 向下生长
│ · 局部变量 / 参数 / 返回地址│
├────────────────────────┤
│ ↕ 中间是大量未映射区域 │
├────────────────────────┤
│ 堆区 (Heap) │ ↑ 向上生长
│ · new / malloc 分配 │
├────────────────────────┤
│ 全局/静态区 (BSS + Data) │
│ · 全局变量、static 变量 │
├────────────────────────┤
│ 常量区 (rodata) │
│ · 字符串字面量、const │
├────────────────────────┤
│ 代码区 (Text) │
│ · 编译后的机器指令 │
低地址 └────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 2.2 代码区(Text)
- 存什么:编译后的机器指令(函数体)
- 特点:只读、可共享(同一程序多进程共享)
- 写它会怎样:段错误(Segmentation Fault)
# 2.3 全局/静态区(Data + BSS)
- 存什么:全局变量、
static变量 - 细分:
.data:已初始化的(如int g = 42;).bss:未初始化的(如int g;,编译器零初始化)
- 生命周期:从程序启动到程序结束
int g_initialized = 42; // .data
int g_uninitialized; // .bss(自动 0)
static int g_static = 100; // .data,文件内可见
1
2
3
2
3
# 2.4 堆区(Heap)
- 存什么:
new/malloc分配的对象 - 特点:手动管理、空间大、慢(要找空闲块、对齐、更新元数据)
- 释放方式:必须
delete/free,或交给智能指针 / 容器
# 2.5 栈区(Stack)
- 存什么:局部变量、函数参数、返回地址
- 特点:自动管理、极快(移动栈指针即可)、容量小(默认 8 MB)
# 2.6 常量区(rodata)
const char* s1 = "hello"; // "hello" 在 .rodata
char s2[] = "hello"; // 拷贝到栈
s1[0] = 'H'; // ❌ 段错误(修改 rodata)
s2[0] = 'H'; // ✅ 修改栈拷贝,OK
1
2
3
4
2
3
4
# 03.五区在汇编层的验证
# 3.1 用 nm / objdump 查看变量所在段
// memmap.cpp
int g_init = 42; // .data
int g_bss; // .bss
const char* g_ro = "hi"; // 字符串在 .rodata
static int g_static = 1; // .data
int main() {
int local = 5;
static int s_local = 7;
int* heap = new int(9);
delete heap;
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
g++ -g -c memmap.cpp -o memmap.o
nm memmap.o | sort
1
2
2
输出(节选):
0000000000000000 D g_init # D = .data
0000000000000004 d g_static # d = .data, 内部链接
0000000000000000 B g_bss # B = .bss
0000000000000000 R g_ro # R = .rodata
0000000000000000 T main # T = .text
1
2
3
4
5
2
3
4
5
# 3.2 用一段代码打印各类变量地址
#include <iostream>
int g_init = 42;
int g_bss;
const char* g_str = "hello";
static int g_static = 1;
void f() {
int local = 5;
static int s_local = 7;
int* heap = new int(9);
std::cout << "栈 local : " << &local << "\n";
std::cout << "全局 s_local : " << &s_local << "\n";
std::cout << "堆 *heap : " << heap << "\n";
delete heap;
}
int main() {
std::cout << "代码 &main : " << reinterpret_cast<void*>(&main) << "\n";
std::cout << "全局 g_init : " << &g_init << "\n";
std::cout << "全局 g_bss : " << &g_bss << "\n";
std::cout << "常量 g_str → \"\" : " << static_cast<const void*>(g_str) << "\n";
f();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
典型输出(地址因运行环境而异):
代码 &main : 0x000055c4...3160 ← 低(.text)
常量 g_str → "" : 0x000055c4...3008 ← 略高(.rodata)
全局 g_init : 0x000055c4...4010 ← 更高(.data)
全局 g_bss : 0x000055c4...4020 ← .bss
堆 *heap : 0x000055c4...82a0 ← 中等(heap)
栈 local : 0x00007ffd...e0fc ← 高得多(stack)
1
2
3
4
5
6
2
3
4
5
6
# 04.变量的三大属性:作用域 / 存储期 / 链接性
理解了这三个,看任何 C++ 代码都不会迷失。
# 4.1 作用域(Scope)
变量名在源代码哪段范围内可见。
| 作用域 | 何时具有 |
|---|---|
| 块作用域(local) | {} 局部变量、函数参数 |
| 函数作用域 | 仅 goto 标签 |
| 文件作用域(global) | 所有函数外声明 |
| 类作用域 | 类成员(. / -> / :: 访问) |
| 命名空间作用域 | namespace { ... } 内 |
# 4.2 存储期(Storage Duration)
变量对应内存活多久——C++ 中最重要的属性。
| 存储期 | 关键字 | 在哪里 | 何时分配 / 释放 |
|---|---|---|---|
| 自动(automatic) | 默认 | 栈 | 进入作用域分配,离开作用域释放 |
| 静态(static) | static / 全局 | 全局区 | 程序启动初始化,程序结束释放 |
| 线程(thread) | thread_local(C++11) | TLS | 线程开始分配,线程结束释放 |
| 动态(dynamic) | new / malloc | 堆 | 显式分配,显式释放 |
# 4.3 链接性(Linkage)
同一名字在不同源文件之间是否指向同一实体。
| 链接性 | 含义 | 示例 |
|---|---|---|
| 无链接 | 仅当前块作用域可见 | 函数内局部变量 |
| 内部链接 | 仅当前翻译单元可见 | static int x;(全局)/ 匿名命名空间 |
| 外部链接 | 整个程序可见 | 普通全局变量 / 函数 |
// a.cpp
int g_x = 10; // 外部链接
static int g_y = 20; // 内部链接:仅 a.cpp 可见
namespace {
int g_z = 30; // 内部链接(C++11 起匿名命名空间是新的"static")
}
// b.cpp
extern int g_x; // ✅ OK
extern int g_y; // ❌ 链接错误:g_y 是内部链接
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
# 4.4 三者的组合关系矩阵
// 1. 块作用域 + 自动存储期 + 无链接(最常见)
void f() { int x = 1; }
// 2. 块作用域 + 静态存储期 + 无链接
void g() { static int counter = 0; ++counter; }
// 3. 文件作用域 + 静态存储期 + 内部链接
static int g_count = 0;
// 4. 文件作用域 + 静态存储期 + 外部链接
int g_total = 0;
// 5. 块作用域 + 线程存储期 + 无链接
void h() { thread_local int tls = 0; }
// 6. 块作用域 + 动态存储期 + 无链接
void i() { int* p = new int(42); delete p; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 05.栈帧机制:函数调用如何利用栈
int add(int a, int b) {
int sum = a + b;
return sum;
}
int main() { int x = add(3, 4); }
1
2
3
4
5
2
3
4
5
调用 add(3, 4) 时:
- 传参:把
3/4放入寄存器或压栈 - 保存返回地址:
main下一条指令的地址压栈 - 跳转:CPU 指令指针指向
add入口 - 建立栈帧:保存旧 RBP,把 RBP 设为当前 RSP
- 分配局部变量:RSP 下移给
sum留空间 - 执行函数体
- 返回:把返回值放寄存器,恢复 RBP,跳回返回地址
栈帧布局:
高地址 ← main 的栈帧
┌──────────────────────────┐
│ main 的局部变量 x │
├──────────────────────────┤
│ main 调用 add 的返回地址 │ ← call 指令压入
├──────────────────────────┤
│ 保存的旧 RBP │ ← add 函数序言压入
├──────────────────────────┤ ← add 的栈帧底(新 RBP)
│ add 的参数 a, b │
├──────────────────────────┤
│ add 的局部变量 sum │
低地址 └──────────────────────────┘ ← 当前 RSP
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
栈溢出——栈空间通常 8 MB(Linux 默认;ulimit -s 查看):
void rec() { rec(); } // 无限递归 → 栈溢出
void f() { int huge[10'000'000]; } // 40 MB > 8 MB → 栈溢出
1
2
2
正确做法:超大数据放堆(new / std::vector)。
# 06.堆分配:new/malloc 究竟在做什么
堆不是连续切——由运行时分配器(ptmalloc / tcmalloc / jemalloc)维护空闲块链表:
[ used 16B | free 32B | used 64B | free 128B | used 32B | ...]
1
一次 new Foo(1,2) 实际执行:
- 调用
operator new(sizeof(Foo)),遍历自由列表找够大的 - 分裂:返回所需大小,把剩余加回自由列表
- 把返回地址作为
this,调用Foo::Foo(1,2)构造函数 - 把构造好的指针赋给
p
delete p:
- 调用
Foo::~Foo()析构 - 调用
operator delete(p),把内存归还自由列表(可能与相邻空闲块合并)
关键差距:
| 操作 | 平均耗时(量级) |
|---|---|
| 创建栈对象(局部变量) | ~ 1 ns(仅移动 RSP) |
一次 new | ~ 50-200 ns(找块 + 锁 + 构造) |
一次系统调用(brk / mmap) | ~ 1-10 µs |
这就是为什么"性能敏感的循环里绝不要 new"。
# 07.对象的生命周期窗口
# 7.1 局部对象:随作用域结束而析构
void f() {
std::string s = "hello"; // 构造在栈
} // ← 离开作用域 → s.~string() 自动调
1
2
3
2
3
# 7.2 静态对象:贯穿程序生命
struct Logger {
Logger() { /*...*/ }
~Logger() { /*...*/ } // 程序退出时调
};
Logger& logger() {
static Logger inst; // 首次进入函数时构造(C++11 起线程安全)
return inst;
}
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
# 7.3 堆对象:必须手动释放(或交给智能指针)
// ❌ 老 C++ 风格:容易漏 delete
Foo* p = new Foo();
// ... 中间 throw 异常或 return 都会泄漏
delete p;
// ✅ 现代 C++:智能指针自动释放
auto p = std::make_unique<Foo>();
// 不论怎么走(return / throw),离开作用域 unique_ptr 析构自动 delete
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
这就是 RAII(Resource Acquisition Is Initialization)的核心思想——把"资源生命周期"绑定到"栈对象的生命周期"。第 13 章会展开。
# 08.六个经典内存错误
# 8.1 返回局部变量的指针/引用
int& bad() {
int x = 42;
return x; // ❌ 函数返回后 x 已析构,引用悬空
}
int main() {
int& r = bad();
std::cout << r; // ❌ 未定义行为
}
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
# 8.2 use-after-free(释放后使用)
int* p = new int(1);
delete p;
*p = 2; // ❌ p 已悬空
1
2
3
2
3
# 8.3 double-free(重复释放)
int* p = new int(1);
delete p;
delete p; // ❌ 段错误或堆破坏
1
2
3
2
3
# 8.4 内存泄漏(Memory Leak)
void f() {
int* p = new int(1);
if (some_error) return; // ❌ 漏 delete,每次调用泄漏 4 字节
delete p;
}
1
2
3
4
5
2
3
4
5
# 8.5 数组越界(Buffer Overflow)
int arr[5] = {1,2,3,4,5};
arr[5] = 10; // ❌ 越界一格
arr[100] = 99; // ❌ 越界很多 → 可能写坏栈帧 / 返回地址
1
2
3
2
3
# 8.6 栈溢出(Stack Overflow)
void f() { int huge[10'000'000]; }
1
卷四第 5、6 章会教你用 AddressSanitizer / Valgrind 自动检出上面六类错误中的绝大多数。
# 09.与卷三 / 卷四的衔接
| 本章涉及 | 卷三深挖 | 卷四实战 |
|---|---|---|
| 五区内存模型 | 第 1 章 C++ 内存模型与对象内存布局 | — |
| 栈帧 | 第 4 章 构造析构与对象生命周期 | 第 1 章 BusError 排查 |
| 堆分配器 | 第 1 章(cache line / NUMA) | 第 11 章 缓存友好编程 |
| RAII | 第 10 章 智能指针与资源管理 RAII | 第 5 章 内存泄漏排查 |
| use-after-free | — | 第 8 章 安全漏洞与防御 |
# 10.本章新手陷阱 Top 5
# 陷阱 1:以为 static 局部变量在"栈"上
void f() {
static int x = 0; // ❌ 不在栈,在 .data 全局区
++x;
}
1
2
3
4
2
3
4
# 陷阱 2:返回局部变量的指针
const char* msg() {
char buf[32];
sprintf(buf, "hi");
return buf; // ❌ buf 出函数即销毁
}
1
2
3
4
5
2
3
4
5
# 陷阱 3:以为字符串字面量可改
char* s = "hello"; // C++11 后甚至编译警告
s[0] = 'H'; // ❌ 段错误(写 .rodata)
1
2
2
# 陷阱 4:在栈上放超大数组
void f() {
int img[1024 * 1024 * 4]; // 16 MB → 栈溢出
}
1
2
3
2
3
正解:std::vector<int> img(1024 * 1024 * 4);(vector 内部用堆)。
# 陷阱 5:忘记每个 new[] 配 delete[]
int* arr = new int[100];
delete arr; // ❌ 必须 delete[],否则只析构第一个 + 错误的释放
delete[] arr; // ✅
1
2
3
2
3
# 11.思考题
- 写一段代码,分别打印一个全局变量、一个静态局部变量、一个堆变量、一个栈变量的地址,画出它们在地址空间中的相对位置。
static关键字在 C 和 C++ 中分别有几种含义?- 说出 5 种"无链接"的变量场景。
- 解释为什么"递归过深"会导致 SIGSEGV 而不是程序"慢死"。
int* p = new int[5];后,调用delete p;而不是delete[] p;会发生什么?- 一个 64 位 Linux 进程理论上能用多少地址空间?实际能
new多少? - 写一段代码,故意制造内存泄漏,然后用
valgrind --tool=memcheck ./a.out验证。 - 为什么栈分配比堆快?给出三条理由。
thread_local变量与static变量在内存中的区别是什么?- 如果你要实现一个 1ms 内必须返回的实时函数,你会怎样避免里面任何
new/malloc?
上次更新: 2026/06/10, 11:13:41