Skip to content

内存模型(Memory Model)

理解内存是理解 C++ 程序行为的基础。这篇文档解释数据在内存中如何存储、变量如何分配和释放。


1. 内存是什么

内存是程序运行时存放一切数据的地方。每一个变量、每一个对象、每一个数组,都占据着内存中的某一段空间。

在 C++ 中,内存主要分为三个区域:静态存储区(Static)栈(Stack)堆(Heap)


2. 静态存储区(Static)——编译期确定的内存

2.1 特点

  • 程序启动时分配,程序结束时释放,生命周期贯穿整个运行期
  • 大小在编译期确定,运行时不可变
  • 不占用栈空间,也不占用堆空间,位于独立的数据段

2.2 什么数据放在静态存储区

int g_counter = 0;               // 全局变量——静态存储区
static int s_id = 100;           // 文件作用域的静态变量——静态存储区

void foo()
{
    static int callCount = 0;    // 函数内静态局部变量——静态存储区
    callCount++;                 // 值在多次调用间保持
}

const char* msg = "hello";       // 字符串字面量 "hello" 在静态存储区
                                  // msg 指针本身在栈上

2.3 静态存储区的内存布局

高地址
├───────────────
│  栈(Stack)
│  ├── 局部变量
│  └── 函数调用栈帧
├───────────────
│      ↓
│    (空闲)
│      ↑
├───────────────
│  堆(Heap)
│  ├── new/malloc 分配
│  └── 动态增长
├───────────────
│  静态存储区
│  ├── 全局变量 g_counter
│  ├── 静态变量 s_id
│  ├── 字符串字面量 "hello"
│  └── 静态局部变量 foo::callCount
├───────────────
│  代码段(只读)
│  └── 程序指令
低地址

静态存储区在程序加载时就已经分配好,与栈、堆物理隔离。

2.4 为什么 GSRL 中大量使用静态分配

嵌入式环境(如机器人控制系统)对实时性和内存碎片敏感,静态分配有天然优势:

  • 无运行时开销:编译期已确定布局,无需 new/delete
  • 无内存碎片:不会因反复分配/释放导致堆碎片化
  • 可预测性:内存用量在编译期已知,不会出现运行时内存不足

GSRL 中常见的大量数组、滤波器矩阵,如果能确定最大尺寸,优先用静态分配:

// 静态分配:编译期确定大小,零运行时开销
fp32 s_buffer[256];

// 动态分配:灵活但需管理生命周期
fp32* d_buffer = new fp32[size];
// ...
delete[] d_buffer;

3. 栈(Stack)——自动管理的内存

3.1 特点

  • 由编译器自动管理,函数调用时分配,函数返回时释放
  • 速度快,但空间有限(通常几 MB)
  • 后进先出(LIFO),像一摞盘子——最后放上去的最先被取走

3.2 什么数据放在栈上

void foo()
{
    int a = 42;           // a 在栈上
    float b = 3.14f;      // b 在栈上
    int arr[10];          // 整个数组在栈上
    PIDParam param;       // 结构体在栈上(成员都在栈上)
}  // 函数结束,以上全部自动释放

3.3 栈的内存布局

高地址
├───────────────
│  main 的栈帧
│  ├── int x = 10
│  ├── float y = 3.14
│  └── ...
├───────────────
│  foo 的栈帧      ← 调用 foo() 时压入
│  ├── int a = 42
│  ├── float b = 3.14f
│  └── int arr[10]   (40 字节)
├───────────────      ← foo() 返回后弹出,全部释放
│  main 的栈帧(继续)
│  ...
低地址

每次函数调用都会在栈顶"压入"一个新的栈帧(stack frame),包含该函数的局部变量。函数返回时栈帧被弹出,变量随之消失。


4. 堆(Heap)——手动管理的内存

4.1 特点

  • 由程序员手动申请(new/malloc)和释放(delete/free
  • 空间大(受系统可用内存限制)
  • 分配和释放较慢
  • 不遵循 LIFO 顺序,可以在任何时候分配和释放

4.2 什么数据放在堆上

void foo()
{
    int* p = new int(42);        // p 本身在栈上,但 *p(42)在堆上
    fp32* arr = new fp32[100];   // arr 指针在栈上,100 个 fp32 在堆上

    delete p;       // 手动释放
    delete[] arr;   // 手动释放数组
}

4.3 堆的内存布局

        栈                         堆
    ┌──────────┐            ┌──────────────┐
    │ p (指针) │──────→     │    42        │  ← int,4 字节
    ├──────────┤            ├──────────────┤
    │ arr(指针)│──────→     │ [0][1]...[99]│  ← fp32[100],400 字节
    └──────────┘            └──────────────┘

指针变量本身在栈上,但指针指向的数据在堆上。这是理解 C++ 内存模型的关键。

4.4 忘记释放会怎样——内存泄漏

void leak()
{
    int* p = new int[1000];  // 在堆上分配了 1000 个 int
    // ... 做一些事 ...
    // 忘记 delete[] p —— 函数返回后,指针 p 消失
    // 但堆上的 1000 个 int 仍然占据着内存,且再也无法访问
    // 这就是内存泄漏(memory leak)
}

5. 三种内存区域对比

特性 静态存储区(Static) 栈(Stack) 堆(Heap)
管理方式 编译器自动 编译器自动 程序员手动(或智能指针)
分配速度 编译期完成 极快(移动栈指针) 较慢(需要查找空闲块)
大小限制 编译期固定 小(通常 1~8 MB) 大(受系统内存限制)
生命周期 程序整个运行期 随函数调用/返回 手动控制
碎片问题 可能出现
典型用途 全局变量、静态变量、字符串字面量 局部变量、函数参数 动态大小的数据、大对象

6. 实际例子:一个对象的内存分布

class SimplePID
{
    PIDParam m_param;       // 三个 float,在对象内部
    fp32* m_errorHistory;   // 一个指针(8 字节),在对象内部
    int m_historySize;      // 一个 int(4 字节),在对象内部
};

当你在栈上创建一个 SimplePID

栈
┌──────────────────────┐
│ SimplePID 对象       │
│  ├── m_param         │  ← 内嵌的三个 float,直接存在对象里
│  │   ├── Kp (4字节)  │
│  │   ├── Ki (4字节)  │
│  │   └── Kd (4字节)  │
│  ├── m_errorHistory  │  ← 这是个指针(8字节),存的是地址
│  │       │           │
│  │       ↓           │
│  └── m_historySize   │  ← int,4 字节
└──────────────────────┘
                          ↓
        堆
    ┌──────────────────────┐
    │ fp32[100]            │  ← 构造函数中 new 出来的
    │ [0][0]...[0]         │     400 字节在堆上
    └──────────────────────┘

关键理解: 对象本身可能在栈上,但对象内部通过 new 分配的数据在堆上。m_errorHistory 只是一个"遥控器"(指针),真正的数据在别处。


7. 为什么移动语义需要理解内存

看这个场景:

SimplePID pid1(param);                // pid1 在栈上,其 m_errorHistory 指向堆上数组
SimplePID pid2(std::move(pid1));      // 移动构造

移动构造做的事情:只复制指针,不复制堆上的数据

移动前:                         移动后:
pid1.m_errorHistory → [堆数据]   pid1.m_errorHistory → nullptr
pid2.m_errorHistory → nullptr     pid2.m_errorHistory → [堆数据](同一个!)

如果不理解指针和堆,就无法理解为什么"复制一个指针"就完成了"移动"——实际上只是换了遥控器,电视机没动。


8. 与 GSRL 的关系

在 GSRL 中,堆的使用是受限的(嵌入式环境),但理解三种内存区域的区别仍然重要:

  • 局部变量(如 KalmanFilter 实例)在栈上
  • std::vector 内部的数据在堆上
  • newdelete 在嵌入式环境中需谨慎使用
  • 静态分配(static allocation)在 GSRL 中更常见

理解内存模型,才能在写移动构造函数、析构函数时,清楚地知道自己在操作什么。


9. 与其他语言的对比

场景:创建一个数组,存放 1000 个数,用完销毁。

Python

data = [0] * 1000

# 离开作用域后,垃圾回收器会在某个时刻自动回收内存
data = None  # 或直接离开作用域

Python 的内存由解释器全权管理,开发者无需关注分配与释放。代价是回收时机不可控(由 GC 策略决定),且无法直接感知内存布局。

Java

int[] data = new int[1000];

// 离开作用域后,JVM 的 GC 自动回收
data = null;

与 Python 类似,Java 中所有对象均分配在堆上,由 JVM 垃圾回收器统一管理。代价是 GC 触发时可能引起 Stop-The-World 暂停,在对实时性有严格要求的场景(如机器人控制)中不可接受。

C

int* data = (int*)malloc(1000 * sizeof(int));  // 手动在堆上分配

// ... 使用 data ...

free(data);  // 手动释放,遗漏则内存泄漏

C 没有构造函数/析构函数和智能指针机制,内存的分配与释放完全由开发者显式控制。

C++

// 方案 A:栈上分配,离开作用域自动释放
int data_stack[1000];

// 方案 B:堆上分配 + 手动管理(与 C 一致)
int* data_heap = new int[1000];
delete[] data_heap;

// 方案 C:堆上分配 + 智能指针,自动管理
auto data_smart = std::make_unique<int[]>(1000);
// 离开作用域自动释放

C++ 允许开发者根据场景选择合适的内存管理策略——栈上自动回收、堆上手动控制、或通过智能指针实现自动管理。其核心哲学是"不为不使用的特性付出开销"(zero-overhead principle):需要多大控制权,就承担多大责任。理解内存模型,正是做出正确选择的前提。

作者: Qing | 修改日期: 2026-07-18