跳转至

内存模型

C++ 程序运行时的内存(虚拟地址空间)通常分为以下几个区域,从 低地址 到 高地址 大致排列如下:

高地址
┌──────────────────┐
│  栈(Stack)      │ ← 向下增长:局部变量、函数调用栈帧
├──────────────────┤
│        ↓         │
│    (空闲区)     │
│        ↑         │
├──────────────────┤
│  堆(Heap)       │ ← 向上增长:new / malloc 动态分配
├──────────────────┤
│  BSS 段          │   未初始化 / 零初始化的全局变量、静态变量
├──────────────────┤
│  数据段(.data)  │   已初始化的全局变量、静态变量
├──────────────────┤
│  只读段(.rodata)│   字符串字面量、const 常量
├──────────────────┤
│  代码段(.text)  │   机器指令(只读)
└──────────────────┘
低地址
分区 存放内容 特点
代码段 .text 编译后的机器指令 只读、可共享
只读段 .rodata 字符串字面量、const 全局变量 只读,修改会崩溃
数据段 .data 已初始化 的全局变量、static 变量 可读写,程序结束时释放
BSS 段 .bss 未初始化 的全局变量、static 变量 自动清零,不占可执行文件空间
堆 Heap new / malloc 动态分配的内存 向上增长,需手动释放
栈 Stack 局部变量、函数参数、返回地址 向下增长,自动管理
#include <iostream>

int global_init = 10;       // → 数据段(已初始化)
int global_uninit;          // → BSS 段(未初始化,自动为 0)
static int s_var = 5;       // → 数据段
const int c_global = 3;     // → 只读段(const 常量)

int main() {
    int local = 1;                 // → 栈
    static int s_local = 2;        // → 数据段(静态局部变量)

    int* p = new int(100);         // p 在栈,*p(100)在堆

    const char* str = "hello";     // "hello" 在只读段,str 指针在栈
    char arr[] = "hello";          // arr 整个在栈(可修改)

    delete p;                      // 释放堆内存
    return 0;
}

要点:

1
2
3
4
5
6
7
// "hello" 是字符串字面量,存在只读段,不可修改
const char* s = "hello";
// s[0] = 'H';   // ✗ 未定义行为!试图修改只读内存,通常崩溃

// 数组则拷贝到栈上,可以修改
char arr[] = "hello";
arr[0] = 'H';     // ✓ 合法

1 栈

栈用于管理 函数调用,每个函数调用会创建一个 栈帧(Stack Frame):

1
2
3
4
5
6
7
8
9
int add(int a, int b) {   // 进入函数:压栈帧
    int sum = a + b;      // sum 存在当前栈帧
    return sum;           // 返回后:弹栈帧,sum 自动销毁
}

int main() {
    int x = 1, y = 2;     // x、y 在 main 的栈帧
    int r = add(x, y);    // 参数 a、b 和局部变量 sum 在 add 的栈帧
}

栈帧内容:

┌────────────────┐
│ 返回地址        │
├────────────────┤
│ 函数参数        │
├────────────────┤
│ 局部变量        │
├────────────────┤
│ 保存的寄存器    │
└────────────────┘

特点:

  1. 自动管理:进入函数自动分配,返回自动释放,无需手动干预
  2. 速度快:分配/释放只是移动栈指针(一条指令)
  3. 空间有限:通常几 MB(如 Linux 默认 8MB),递归过深会 栈溢出(Stack Overflow)
  4. 向下增长:地址从高到低

栈溢出的原因

  1. 递归过深
  2. 局部变量过大
  3. 调用链过长
  4. 递归深度 × 单层栈帧大:是 1 和 2 的组合,每层递归的局部变量都很大,很快爆栈

2 堆

堆用于 运行期动态分配 的内存:

1
2
3
4
5
6
7
8
int* p = new int;        // 在堆上分配
int* arr = new int[100]; // 在堆上分配数组
delete p;                // 手动释放
delete[] arr;

// malloc / free(C 风格)
int* q = (int*)malloc(sizeof(int) * 10);
free(q);

特点:

  1. 手动管理:new/delete 配对,忘记 delete 会 内存泄漏
  2. 空间大:受限于系统可用内存(远大于栈)
  3. 速度慢:分配需要查找空闲块、系统调用等
  4. 向上增长:地址从低到高
  5. 生命周期灵活:不随函数返回销毁,跨函数/跨线程存活

堆和栈的区别

  1. 管理方式:栈自动管理;堆手动管理
  2. 空间大小:栈很小(MB 级别);堆很大(GB 级别,受限于虚拟内存)
  3. 生长方向:栈向低地址扩展(向下生长);堆向高地址扩展(向上生长)
  4. 分配效率:栈在底层直接移动寄存器指针,速度极快;堆需要调用系统 API(如 brk 或 mmap)并搜索空闲内存块,速度慢
  5. 连续性与碎片:栈空间是完全连续的;堆频繁 new/delete 会导致大量的内存碎片,降低内存利用率

new/delete 与 malloc/free 的区别

  1. 属性不同:new/delete 是 C++ 的关键字(操作符),受编译器支持且可以被重载;malloc/free 是 C 语言的库函数,需要引入头文件 <cstdlib>
  2. 构造与析构:new 在分配内存后会自动调用该类的构造函数,delete 会先调用对象的析构函数再释放内存。而 malloc/free 只是冷冰冰地划出一块原始内存,完全不管对象的初始化和销毁
  3. 返回类型安全:new 操作成功后返回的是对象类型的指针,类型安全;malloc 成功后返回 void*,必须由程序员显式强转为对应类型的指针
  4. 失败时的处理:内存不足时,new 默认抛出 std::bad_alloc 异常;malloc 会返回 NULL 空指针

3 全局/静态变量的分区细节

变量类型 分区 初始化时机
int g = 10; 数据段 程序启动时,值写入
int g;(未初始化) BSS 段 程序启动时 自动清零
static int s = 5; 数据段 首次执行到之前已初始化
const int c = 3; 只读段 程序启动时,不可修改
#include <iostream>

int a;            // BSS 段,自动初始化为 0
int b = 100;      // 数据段

int main() {
    static int c;      // BSS 段(静态局部变量也是 0)
    std::cout << a;    // 0(BSS 段保证清零)
    std::cout << c;    // 0
}

BSS 段为什么不占用可执行文件空间

.data 段要存每个变量的 实际初值,所以占用文件空间;而 .bss 段的变量全是 0,只需记录"这块区域有多大",加载时由操作系统统一清零即可,不占用可执行文件体积。这就是 int a[1000000]; 全局数组(未初始化)不会让 exe 变大 4MB 的原因

常见问题

问题 原因
栈溢出 递归过深、局部大数组过大
内存泄漏 new 后忘 delete,堆越用越多
悬垂指针 指向已释放内存的指针再次使用
段错误(Segfault) 修改只读段(如字符串字面量)、访问越界地址
堆损坏 重复 delete、越界写堆内存

内存对齐(Memory Alignment)

既然有了基本数据类型,为什么结构体的大小往往比内部元素大小的总和要大?

物理硬件的限制。CPU 访问内存时并不是按字节逐个读取的,而是按字(Word,比如 32 位机器一次读 4 字节,64 位一次读 8 字节)成块读取的。如果一个 int 放到了不对齐的地址里,CPU 可能需要读两次内存然后再进行高低位拼凑,极大地损耗性能。甚至某些硬件平台不支持非对齐访问,会直接抛出硬件异常

对齐规则:

  1. 结构体的第一个成员放在偏移量为 0 的位置
  2. 之后每个成员放在其自身类型大小的整数倍的偏移地址处(如果有内部子结构体,要看子结构体里最大成员的大小)
  3. 整个结构体的总大小必须是其最大基本数据类型成员大小的整数倍,不足的在末尾填充字节(Padding)

可以通过添加 #pragma pack(n) 宏来强制改变编译器的对齐字节数,或者在 C++11 后使用 alignas(n)

评论区

欢迎在评论区指出文档错误,为文档提供宝贵意见,或写下你的疑问