内存布局与模型
C++ 程序的内存管理建立在计算机的存储模型(Memory Model)之上。理解程序在内存中的布局,是掌握变量生命周期、作用域、动态分配与释放机制的基础。 一个典型的 C++ 程序在运行时会被划分为多个区域,每个区域负责存储不同类型的数据,并具有各自的生命周期与访问特性。
一、程序的五个内存区域
一个进程的地址空间大致可以分成五块:
高地址
┌──────────────────┐
│ 栈 Stack │ ← 向下增长
├──────────────────┤
│ ↓ │
│ │
│ ↑ │
├──────────────────┤
│ 堆 Heap │ ← 向上增长
├──────────────────┤
│ BSS 段(未初始化) │
├──────────────────┤
│ Data 段(已初始化)│
├──────────────────┤
│ 常量区 .rodata │
├──────────────────┤
│ 代码段 .text │
└──────────────────┘
低地址
| 区域 | 存放内容 | 生命周期 | 大小 |
|---|---|---|---|
| 栈 | 局部变量、函数参数、返回地址 | 随作用域结束 | 编译期确定,通常 1~8 MB |
| 堆 | new / malloc 分配的对象 | 手动控制 | 受限于可用内存 |
| BSS 段 | 未初始化的全局/静态变量 | 整个程序运行期 | 编译期确定 |
| Data 段 | 已初始化的全局/静态变量 | 整个程序运行期 | 编译期确定 |
| 常量区 | 字符串字面量、const 全局量 | 整个程序运行期 | 编译期确定 |
| 代码段 | 机器指令 | 整个程序运行期 | 编译期确定 |
看一段代码,把变量对号入座:
int global_init = 42; // Data 段
int global_uninit; // BSS 段
const char* msg = "hello"; // 指针在 Data 段,"hello" 在常量区
void func()
{
int local = 1; // 栈
static int static_local = 2; // Data 段(只初始化一次)
const char* p = "world"; // 指针在栈,"world" 在常量区
int* heap = new int(3); // 指针在栈,对象在堆
delete heap;
}
常量区的字符串是只读的。
"hello"[0] = 'H'是未定义行为,在多数平台上会直接段错误。
二、栈
栈由编译器自动管理,分配和释放只是移动栈指针,因此极快——通常就是一条指令。
栈帧
每次函数调用都会在栈上压入一个栈帧(stack frame),包含:
高地址
┌──────────────────┐
│ 调用者的局部变量 │
├──────────────────┤
│ 函数参数 │
├──────────────────┤
│ 返回地址 │
├──────────────────┤
│ 保存的寄存器 │
├──────────────────┤
│ 被调用者的局部变量 │
└──────────────────┘
低地址
函数返回时整个栈帧被弹出,其中的局部对象按逆序析构。
栈溢出
栈空间有限(Linux 默认 8 MB),以下情况会导致栈溢出:
void recurse(int n)
{
int buffer[10000]; // 每次调用占用 40 KB 栈空间
recurse(n + 1); // 无限递归,很快耗尽栈
}
int main()
{
int huge[10'000'000]; // 约 40 MB,直接溢出
}
大数组应该放到堆上,或者用 std::vector:
std::vector<int> huge(10'000'000); // 数据在堆上,安全
栈上的对象天然异常安全
栈对象的析构由编译器插入,无论函数是正常返回还是异常退出,析构都会执行。这是 RAII 能成立的前提。
三、堆
堆是程序运行时可以自由申请的内存区域。它的大小只受限于系统可用内存,代价是:
- 分配慢:需要在空闲链表中找合适的块,可能触发系统调用
- 需要手动管理:必须显式释放,否则泄漏
- 容易碎片化:反复分配释放不同大小的块,会留下难以利用的空洞
int* p = new int(42); // 分配
// ... 使用
delete p; // 释放,忘记这步就泄漏了
堆的分配器实现相当复杂(glibc 的 malloc 有几千行),但基本思路是维护若干空闲链表,按大小分类管理。
四、对象的内存布局
1. 成员排布与填充
编译器会按成员声明顺序排布,但为了对齐,可能插入填充字节:
struct A
{
char c; // 1 字节
// 3 字节填充
int i; // 4 字节
char d; // 1 字节
// 3 字节填充(尾部对齐到 4 的倍数)
};
// sizeof(A) == 12,而不是 6
调整成员顺序可以减小体积:
struct B
{
int i; // 4
char c; // 1
char d; // 1
// 2 字节填充
};
// sizeof(B) == 8
2. 对齐规则
每个类型都有一个对齐要求(alignof(T)),通常是其大小的约数。规则是:
- 成员的偏移量必须是其对齐值的整数倍
- 结构体的总大小必须是最大成员对齐值的整数倍
std::cout << alignof(int) << '\n'; // 4
std::cout << alignof(double) << '\n'; // 8
std::cout << alignof(char) << '\n'; // 1
C++11 起可以用 alignas 强制指定对齐:
struct alignas(16) Vec4
{
float x, y, z, w; // 16 字节,正好对齐到 SIMD 寄存器宽度
};
3. 空类与继承
空类的大小是 1 而不是 0——因为每个对象必须有唯一地址:
struct Empty {};
std::cout << sizeof(Empty); // 1
但空基类优化(EBO)可以让空基类不占空间:
struct Empty {};
struct WithMember : Empty
{
int x;
};
std::cout << sizeof(WithMember); // 4,Empty 被优化掉了
4. 虚函数与虚表指针
含有虚函数的类,对象里会多一个虚表指针(vptr),指向该类的虚函数表:
struct Base
{
virtual void f();
int x;
};
std::cout << sizeof(Base); // 16(64 位系统:8 字节 vptr + 4 字节 int + 4 填充)
多个虚函数不会增加对象大小——它们共享同一张虚表。
5. 查看实际布局
offsetof 可以查看成员偏移:
#include <cstddef>
struct A { char c; int i; };
std::cout << offsetof(A, c) << '\n'; // 0
std::cout << offsetof(A, i) << '\n'; // 4
五、字节序
多字节整数在内存中的排列顺序有两种:
| 类型 | 低字节位置 | 典型平台 |
|---|---|---|
| 小端(little-endian) | 低地址 | x86、ARM(默认) |
| 大端(big-endian) | 高地址 | 部分嵌入式、网络字节序 |
int x = 0x12345678;
// 小端内存布局(从低地址到高地址):
// 78 56 34 12
C++20 提供了 std::endian 检测:
#include <bit>
if constexpr (std::endian::native == std::endian::little)
std::cout << "小端\n";
字节序只在跨平台传输数据时才需要关心。同一平台内的内存操作不受影响。
六、常见误区
「栈比堆快,所以尽量用栈」 —— 分配本身确实是栈快,但如果对象很大,栈的拷贝成本可能更高。而且栈空间有限,几十 MB 的数组只能放堆上。
「sizeof 就是成员大小之和」 —— 忽略了填充。结构体大小取决于成员顺序和对齐要求。
「指针大小等于所指向对象的大小」 —— 指针大小固定(64 位系统上是 8 字节),与指向什么无关。
「new 一定返回连续内存」 —— 单次 new T[n] 是连续的,但多次 new 之间没有任何位置保证。
「局部变量的地址可以返回」 —— 函数返回后栈帧被回收,返回局部变量的指针或引用是悬垂引用。
七、相关章节
- 动态内存分配:
new/delete的细节 - RAII 与资源管理:利用栈对象的析构自动释放资源
- 内存调试与诊断:越界、泄漏等问题的排查
- C++ 内存模型(并发):多线程下的内存可见性