week02-C++内存

  1. 1. 引子:打破幻觉
  2. 2. 内存分区
    1. 2.1. 一、标准内存分区总览
    2. 2.2. 二、各区详细说明
      1. 2.2.1. 1. 代码段(.text)
      2. 2.2.2. 2. 常量区(.rodata)
      3. 2.2.3. 3. 全局/静态存储区(.data + .bss)
        1. 2.2.3.1. (1).data 段(已初始化全局/静态变量)
        2. 2.2.3.2. (2).bss 段(未初始化全局/静态变量)
        3. 2.2.3.3. 全局/静态区统一特点
      4. 2.2.4. 4. 栈区(Stack)
      5. 2.2.5. 5. 堆区(Heap)
    3. 2.3. 三、文档重点结论(内存相关)
      1. 2.3.1. 1. 全局对象构造 / 析构时机
      2. 2.3.2. 2. 尽量避免直接使用全局对象
      3. 2.3.3. 3. 字符串优化要点
    4. 2.4. 四、一句话速记总结
  3. 3. 环境准备
  4. 4. 重要的结论
    1. 4.1. 1. 优先使用常量指针,而非数组
    2. 4.2. 2. 全局变量与程序同生共死
    3. 4.3. 3. 全局变量极其危险,尽量少用,如需使用,尽量延迟
      1. 4.3.1. 核心原理
      2. 4.3.2. 1. 编译器生成了两个关键全局变量
      3. 4.3.3. 2. 进入 func 后第一件事:检查标记
      4. 4.3.4. 3. 只有第一次会走到这里:执行构造
      5. 4.3.5. 4. 构造完立刻做两件事
      6. 4.3.6. 5. 第二次调用 func()
      7. 4.3.7. 总结(最关键 3 句话)
      8. 4.3.8. 图表秒懂
      9. 4.3.9. 4. 静态变量
      10. 4.3.10. 5. 栈 & 堆
      11. 4.3.11. 6. 复杂类型的函数参数默认加引用
      12. 4.3.12. 7. 关于初始化
    4. 4.4. 内存管理导致的问题
      1. 4.4.1. 问题分类
      2. 4.4.2. 崩溃捕获
      3. 4.4.3. 调用约定补充

引子:打破幻觉

main函数是程序执行的第一个函数?
任务:用VS,写一个win32控制台程序,定义一个全局变量的类对象,调试程序观察如何运行。

内存分区

分区 描述
stack 栈区(向下增长)↓ 由编译器自动分配释放。存放:局部变量,形参,返回值
heap 堆区(向上增长)↑ 由程序员分配释放内存。调用函数:malloc(),free()
全局(静态)区 未初始化(.bss),已初始化(.data)
.rodata 常量区 字符串”ABCD”等
.text 代码区 存放程序的代码

一、标准内存分区总览

C++ 程序运行时,内存从低到高通常分为 5 个核心区域

  1. 代码段(.text / Code Segment)
  2. 常量区(.rodata / Read-only Data)
  3. 全局/静态存储区(.data + .bss)
  4. 栈区(Stack)
  5. 堆区(Heap / 自由存储区)

二、各区详细说明

1. 代码段(.text)

  • 存放:程序编译后的二进制指令、函数体代码
  • 特点:
    • 只读,防止程序被意外修改
    • 运行期间大小固定
    • 共享,多个进程可同享一份代码

2. 常量区(.rodata)

  • 存放:
    • 字符串字面量("hello"
    • const 修饰的全局常量、全局常量数组
  • 特点:
    • 只读,修改会直接崩溃
    • 编译器可能做常量合并(相同字符串只存一份)
  • 文档对应示例:
    1
    2
    3
    const char* g_cstr = "asdf";
    const char* g_cstr1 = "as""df"; // 与上面可能指向同一块内存
    const int g_arr[6] = {8,2,6,3,5,1};

3. 全局/静态存储区(.data + .bss)

分为两部分:

(1).data 段(已初始化全局/静态变量)

  • 存放:
    • 已初始化的全局变量
    • 已初始化的static 静态变量(全局/局部)
  • 文档对应示例:
    1
    2
    3
    4
    5
    6
    int g_var = 10;                // 全局变量
    static int gs_var = 11; // 全局静态变量

    void func() {
    static int s_val = 20; // 局部静态变量
    }

(2).bss 段(未初始化全局/静态变量)

  • 存放:未初始化的全局/静态变量
  • 特点:程序加载时自动清零

全局/静态区统一特点

  • 生命周期:程序启动创建 → 程序结束销毁
  • 作用域:
    • 全局变量:整个工程
    • static 变量:只在当前文件/函数可见
  • 多个实例共享同一块内存

4. 栈区(Stack)

  • 存放:
    • 函数形参
    • 局部非静态变量
    • 函数调用栈信息(返回地址等)
  • 文档对应示例:
    1
    2
    3
    4
    int test4(int i) {
    int value = i + 10; // value 在栈上
    return value;
    }
  • 特点:
    • 系统自动分配/释放,无需手动管理
    • 空间小、分配快、连续
    • 生命周期:函数进入创建,函数退出销毁
    • 线程私有,不共享

5. 堆区(Heap)

  • 存放:new / malloc 动态申请的内存
  • 特点:
    • 手动申请、手动释放(delete / free
    • 空间大、分配慢、不连续
    • 生命周期由程序员控制
    • 多线程共享,需注意线程安全

三、文档重点结论(内存相关)

1. 全局对象构造 / 析构时机

  • 构造:在 main 函数执行之前 完成(动态初始化)
  • 析构:在 main 函数返回之后 执行
  • 危险点:
    • 全局对象间初始化顺序不确定
    • 容易引发未定义行为、死锁、崩溃

2. 尽量避免直接使用全局对象

推荐方案:局部静态单例(延迟初始化)

1
2
3
4
Test& get_test() {
static Test t; // 第一次调用时才初始化
return t;
}

优点:

  • 避免 main 前的不确定初始化顺序
  • 线程安全(C++11 后)
  • 用到才创建,节约启动时间

3. 字符串优化要点

  • const char* p = "xxx" → 字符串在常量区,只读
  • char arr[] = "xxx" → 字符串在栈/全局区,可修改
  • 高优化下,相同字符串字面量会被合并,地址相同

四、一句话速记总结

  • 代码段:存指令
  • 常量区:存只读常量
  • 全局静态区:存全局、static,程序全程存在
  • :局部变量,自动管理
  • :动态内存,手动管理

环境准备

万能编译器
https://godbolt.org/ (跨平台的实践神器)
编译参数设置
约定一下编译器与编译参数:
Windows端
x86 msvc v19.24
编译参数: /std:c++ 17 /O2 (/Zc:threadSafeInit-:关闭线程安全,默认是安全)
Mac端
x86-64 clang 9.0.1
编译参数: -std=c++ 17 -O2
Linux端
x86-64 gcc 15.2
实践的代码
分段给出,引导学生调试、理解各种变量的内存分布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#include <iostream>
#include <string>
#include <cstdlib>
#include <cmath>

const char* g_cstr = "asdf"; // 常量字符串常量
const char* g_cstr1 = "as""df";

char g_strArray1[] = "hello";
char g_strArray2[] = "hello";

const int g_arr[6] = { // 常量数组
8, 2, 6, 3, 5, 1
};
static int gs_var = 9 + 2; // 全局静态变量
int g_var = 10; // 全局变量

int test1(int i) noexcept
{
static const int arr2[10] = { // 局部静态常量
0, 1, 2, 3, 4, 5, 6, 7, 8, 9
};
return arr2[i];
}

int test2(int i) noexcept
{
return g_arr[i];
}

int test3(int i) noexcept
{
g_var = i * 2;
gs_var = i;
return i;
}

int test4(int i) noexcept
{
int value = i + 10;
return value;
}

struct Test {
Test()
{
srand(100);
a = rand();
}
~Test()
{
}
int a = 1;
int b = 2;
int c = 3;
};

//导致全局变量动态初始化
Test g_test;

//通过单例,延迟静态变量初始化,避免动态初始化
Test& get_test() {
static Test t;
return t;
}

void test5()
{
get_test();
}

int AllocID()
{
static int s_id = rand();
return ++s_id;
}

int main() {
// 输出全局常量
std::cout << "g_cstr: " << g_cstr << std::endl;
std::cout << "g_cstr1: " << g_cstr1 << std::endl;

std::cout << "g_strArray1: " << g_strArray1 << std::endl;
std::cout << "g_strArray2: " << g_strArray2 << std::endl;

std::cout << "g_arr: ";
for (int i = 0; i < 10; i++) {
std::cout << g_arr[i] << " ";
}
std::cout << std::endl;

// 输出全局变量
std::cout << "gs_var: " << gs_var << std::endl;
std::cout << "g_var: " << g_var << std::endl;

// 调用测试函数
std::cout << "test1(5): " << test1(5) << std::endl;
std::cout << "test2(3): " << test2(3) << std::endl;
std::cout << "test3(4): " << test3(4) << std::endl;

// 输出全局结构体
std::cout << "g_test.a: " << g_test.a << std::endl;
std::cout << "g_test.b: " << g_test.b << std::endl;
std::cout << "g_test.c: " << g_test.c << std::endl;

test5();

srand(100);
return AllocID();
}

重要的结论

1. 优先使用常量指针,而非数组

可见不开启/O2优化且main未使用时,字符串指针常量也会直接占用内存,而且相同的常量指针还占两份空间,而字符数组因为main未使用而不会被初始化。

开启/O2优化后,相同的常量指针只占一份空间,main使用字符数组后,在有/O2优化的情况下,相同的字符数组仍然占两份空间。

  • 存储观察:相同字符串常量在Debug模式下可能存储多份,在Release(优化)模式下可能合并
  • 编码建议:优先使用字符指针而非字符数组定义字符串常量,避免定义未使用的常量造成浪费

2. 全局变量与程序同生共死

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>

class Test {
public:
Test()
{
int a = 10; //7
}
~Test()
{
} //11
Test(const Test& ts)
{
this->m_value = ts.m_value;
this->m_str = ts.m_str;
}

private:
int m_value = 1; //19
std::string m_str = "Hello";
};

Test g_test;

void func()
{
std::cout << "func" << std::endl; //27
}

int main()
{
func(); //32
return 0; //33
}

分别在7,11,19,27,32,33行加断点,按F5调试运行,执行顺序为:19,7,32,27,33,11。可见在main函数执行前,会先执行全局类的构造,在main函数结束后又进行析构。

动态初始化机制,先于main函数执行
编译器为每个需要动态初始化的全局对象生成一个小函数,在函数里调用该对象的构造函数;再由 C++ 运行时在启动阶段(在进入 main 之前)依次调用这些函数。
和“内存/生命周期”的对应关系

  • 存储位置:g_test 本身占用的内存(例如在数据段或 BSS 里)在程序加载时就已经存在,但里面的值在动态初始化之前是未定义或零;
  • 何时“真正成为有效对象”:就是在执行完 Test::Test() 的那次 call 之后,即动态初始化完成的时刻;
    销毁时机晚于main函数后
    在 main 返回后,运行时执行已注册的 atexit 回调时,会调用 dynamic atexit destructor for 'g_test',进而调用 ~Test(),完成析构。

核心发现:main函数并非程序执行的第一个入口,全局/静态对象初始化代码更早执行
全局对象:全局/静态对象的构造函数在main函数之前被调用,析构函数在之后调用
潜在风险:全局/静态对象的构造与析构时机不可控,可能拖慢程序启动或导致关闭问题
优化建议:谨慎使用全局/静态对象,避免在构造/析构中进行复杂或耗时操作
延迟初始化:通过函数内静态变量(static)实现按需构造,但析构时机仍不可控
线程安全:函数内静态变量的初始化在默认编译选项下是线程安全的,但需注意编译选项差异

3. 全局变量极其危险,尽量少用,如需使用,尽量延迟

通过单例,延迟全局变量的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>

class Test {
public:
Test()
{
int a = 10; //7
}
~Test()
{
} //11
Test(const Test& ts)
{
this->m_value = ts.m_value;
this->m_str = ts.m_str;
}

private:
int m_value = 1;
std::string m_str = "Hello";
};

//通过单例,延迟静态变量初始化,避免动态初始化
Test& func()
{
static Test ts; //25
return ts;
}

int main()
{
func(); //31
int a = 10; //32
func(); //33
return 0; //34
}

分别在7,11,25,31,32,33,34行加断点,按F5调试运行,执行顺序为:31,25,7,32,33,25,34,11。可见单例延迟了初始化,使程序成功在main开始,而且7行的构造函数初始化动作也只执行了一次。但是这种方式也只能控制什么时候构造,控制不了析构,析构还是固定在 return 0 后调用这个 dynamic atexit destructor for 'ts''()

这段汇编里,C++ 是怎么保证 static Test ts; 只构造一次的!

局部静态变量 = 全局变量 + 一个「是否已初始化」的标记 + 线程安全锁
编译器靠这个标记实现「只构造一次」。


核心原理

1
2
3
4
5
Test& func()
{
static Test ts; // 重点在这里
return ts;
}

编译器会自动生成:

  1. 静态变量本身(全局区,程序一运行就存在)
  2. 一个初始化标记(0 = 未初始化,1 = 已初始化)
  3. 线程安全的初始化逻辑(C++11 后默认)

第一次调用 func() → 标记是 0 → 执行构造 → 把标记改成 1
第二次调用 func() → 标记是 1 → 直接跳过构造,直接返回


1. 编译器生成了两个关键全局变量

1
2
Test `func'::ts DB 01cH DUP (?)     ; 静态对象本体(全局区)
int `func'::$TSS0 DD 01H DUP (?) ; 初始化标记(0=未初始化,1=已初始化)
  • ts:你的静态对象
  • $TSS0是否已经构造过的标记

2. 进入 func 后第一件事:检查标记

1
2
cmp     eax, DWORD PTR __Init_thread_epoch[edx]
jle SHORT $LN2@func

翻译:
如果已经初始化过(标记=1),直接跳走,不执行构造!


3. 只有第一次会走到这里:执行构造

1
2
mov     ecx, OFFSET ts
call Test::Test(void) ; 🔥 只在这里执行一次构造

4. 构造完立刻做两件事

  1. 把标记改成「已初始化」
  2. 注册析构函数(程序结束时自动析构)
    1
    2
    push    offset $TSS0
    call __Init_thread_footer ; 标记变 1
1
2
push    offset destructor
call _atexit ; 程序结束自动析构

5. 第二次调用 func()

直接走到这里:

1
2
$LN2@func:
mov eax, OFFSET ts ; 直接返回对象地址,不构造

总结(最关键 3 句话)

  1. 局部静态变量本质 = 全局变量
    存在全局/静态区,不是栈!

  2. 编译器偷偷加了一个「初始化标记」
    0 = 没构造
    1 = 已构造

  3. 执行流程
    第一次 → 标记0 → 构造 → 标记变1
    第二次 → 标记1 → 直接返回 → 不执行构造


图表秒懂

1
2
3
4
5
第一次调用 func()
[检查标记: 0] → 执行构造 → [标记设为 1] → 返回

第二次调用 func()
[检查标记: 1] → 跳过构造 → 直接返回

4. 静态变量

编译器(MSVC)默认开启 /Zc:threadSafeInit,局部 static 动态初始化时默认是线程安全的。加入 /Zc:threadSafeInit- 编译选项关闭线程安全。没有线程安全的话,就需要自己加锁。
少用静态变量,少用全局变量,他们都非常危险。 危险就指的是你控制不了他们的生命周期。咱们说控制不了这个是我们 没办法直接控制它,没办法说我什么时候用你,什么时候删你, 这我们我们搞不定,我们不能这么去干干预干预这个东西全部是系统来帮我们做的,有时候是帮我们做他他是个坏事情。因为C++,我们说了C++我就是想 操控所有的内存管理。

5. 栈 & 堆

栈空间小,堆空间大,栈自动销毁,堆要手动销毁。
栈 (Stack)
极速分配,函数 退出即销毁

  • 参数传递:按值传递会触发拷贝构造与析构,按引用传递则不会,应根据需求选择

  • 作用域优化:对于大型栈对象,应及时使用{}限定作用域以提前释放资源,避免栈空间浪费
    不加{},在`return 0`才析构。

加{},在 } 处即析构,节省栈空间。

堆 (Heap)
跨越函数生命周期,必须 手动或利用 RAII “接力” 管理。

1
2
3
4
5
6
void test7()
{
Test* pTest = new Test();
pTest->b = 100;
delete pTest; //不写会内存泄漏
}

delete pTest 在 C++ 里等价于 “先析构再释放”,编译器用一条 call 完成这两步,便于统一处理、避免漏调。

  • 基本操作:使用new/delete进行堆内存的申请与释放,需手动管理生命周期
  • 析构调用:即使汇编代码未显式调用,delete操作符最终会触发对象的析构函数
  • 内存泄漏:忘记delete会导致内存泄漏,需遵循“谁申请,谁释放”原则

6. 复杂类型的函数参数默认加引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>

class Test {
public:
Test()
{
}
~Test()
{
}
Test(const Test& ts)
{
this->m_value1 = ts.m_value1;
this->m_value2 = ts.m_value2;
this->m_str = ts.m_str;
}
private:
int m_value1 = 1;
int m_value2 = 2;
std::string m_str = "hello";
};

void test1(Test ts)
{
}

void test2(Test& ts)
{
}

int main()
{
Test temp;
test1(temp);
test2(temp);
return 0;
}

实践思考
删掉 11 行~16 行的拷贝构造,观察函数的调用:编译器会自动生成一个拷贝构造函数?平凡拷贝?
删掉类的 m_str 变量,观察函数的调用
对比 24 和 28 不同函数参数调用的区别

7. 关于初始化

初始化方式:推荐在类定义中直接初始化成员变量,而非在构造函数体内赋值,后者可能产生额外指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

struct TestResult {
std::string errorPinyin;
double correctionTime {};
bool parseSuccess = true;
int correctionSuccessNum {
};
};

int main()
{
TestResult result;
result.correctionTime = 20;
return 0;
}

实践思考
分析代码 5~7 行起到的作用。

内存管理导致的问题

问题分类

Resource leak: 资源泄漏
Out-of-Bounds access: 越界访问
Uninitialized scalar variable: 未初始化标量变量
No virtual destructor: 无虚析构
Use after free: 释放后使用
Double free: 双重释放
Uninitialized pointer read: 未初始化指针读取
String not null terminated: 字符串未以空字符结尾
Reference leak in object: 对象中的引用泄漏
Buffer not null terminated: 缓冲区未以空字符结尾
Deleting void pointer: 删除空指针

崩溃捕获

1
2
SetUnhandledExceptionFilter
LONG WINAPI unhandledException(...)

让自己的程序 “优雅” 崩溃!

调用约定补充

x64 调用约定
在 x64 调用约定中,函数参数优先通过寄存器传递:
第 1 个参数:RCX
第 2 个参数:RDX
第 3 个参数:R8
第 4 个参数:R9
其余参数:按照 从右向左 的顺序压入栈中。

x86 调用约定
在 x86 调用约定主要通过 栈(Stack) 来传递参数
压栈顺序:参数通常按从右向左的顺序压入栈中。
返回值:通常存储在 EAX 寄存器中。

栈帧平衡
__cdecl:由调用者(caller)负责清理栈(手动增加 ESP)
__stdcall:由被调用者(callee)负责清理栈(通过 ret n 指令)