内存模型(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内部的数据在堆上new和delete在嵌入式环境中需谨慎使用- 静态分配(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