Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

内存布局与模型

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 之间没有任何位置保证。

「局部变量的地址可以返回」 —— 函数返回后栈帧被回收,返回局部变量的指针或引用是悬垂引用。

七、相关章节