引子:打破幻觉
main函数是程序执行的第一个函数?
任务:用VS,写一个win32控制台程序,定义一个全局变量的类对象,调试程序观察如何运行。
内存分区
| 分区 | 描述 |
|---|---|
| stack 栈区(向下增长)↓ | 由编译器自动分配释放。存放:局部变量,形参,返回值 |
| heap 堆区(向上增长)↑ | 由程序员分配释放内存。调用函数:malloc(),free() |
| 全局(静态)区 | 未初始化(.bss),已初始化(.data) |
| .rodata 常量区 | 字符串”ABCD”等 |
| .text 代码区 | 存放程序的代码 |
一、标准内存分区总览
C++ 程序运行时,内存通常分为 5 个核心区域:
- 代码段(.text / Code Segment)
- 常量区(.rodata / Read-only Data)
- 全局/静态存储区(.data + .bss)
- 栈区(Stack)
- 堆区(Heap / 自由存储区)
二、各区详细说明
1. 代码段(.text)
- 存放:程序编译后的二进制指令、函数体代码
- 特点:
- 只读,防止程序被意外修改
- 运行期间大小固定
- 共享,多个进程可同享一份代码
2. 常量区(.rodata)
- 存放:
- 字符串字面量(
"hello") const修饰的全局常量、全局常量数组
- 字符串字面量(
- 特点:
- 只读,修改会直接崩溃
- 编译器可能做常量合并(相同字符串只存一份)
- 文档对应示例:
1
2
3const 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
6int g_var = 10; // 全局变量
static int gs_var = 11; // 全局静态变量
void func() {
static int s_val = 20; // 局部静态变量
}
(2).bss 段(未初始化全局/静态变量)
- 存放:未初始化的全局/静态变量
- 特点:程序加载时自动清零
全局/静态区统一特点
- 生命周期:程序启动创建 → 程序结束销毁
- 作用域:
- 全局变量:整个工程
- static 变量:只在当前文件/函数可见
- 多个实例共享同一块内存
4. 栈区(Stack)
- 存放:
- 函数形参
- 局部非静态变量
- 函数调用栈信息(返回地址等)
- 文档对应示例:
1
2
3
4int test4(int i) {
int value = i + 10; // value 在栈上
return value;
} - 特点:
- 由系统自动分配/释放,无需手动管理
- 空间小、分配快、连续
- 生命周期:函数进入创建,函数退出销毁
- 线程私有,不共享
5. 堆区(Heap)
- 存放:
new / malloc动态申请的内存 - 特点:
- 手动申请、手动释放(
delete / free) - 空间大、分配慢、不连续
- 生命周期由程序员控制
- 多线程共享,需注意线程安全
- 手动申请、手动释放(
三、文档重点结论(内存相关)
1. 全局对象构造 / 析构时机
- 构造:在 main 函数执行之前 完成(动态初始化)
- 析构:在 main 函数返回之后 执行
- 危险点:
- 全局对象间初始化顺序不确定
- 容易引发未定义行为、死锁、崩溃
2. 尽量避免直接使用全局对象
推荐方案:局部静态单例(延迟初始化)
1 | Test& get_test() { |
优点:
- 避免 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 |
|
重要的结论
1. 优先使用常量指针,而非数组


- 存储观察:相同字符串常量在Debug模式下可能存储多份,在Release(优化)模式下可能合并
- 编码建议:优先使用字符指针而非字符数组定义字符串常量,避免定义未使用的常量造成浪费
2. 全局变量与程序同生共死
1 |
|
分别在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 |
|
分别在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 | Test& func() |
编译器会自动生成:
- 静态变量本身(全局区,程序一运行就存在)
- 一个初始化标记(0 = 未初始化,1 = 已初始化)
- 线程安全的初始化逻辑(C++11 后默认)
第一次调用 func() → 标记是 0 → 执行构造 → 把标记改成 1
第二次调用 func() → 标记是 1 → 直接跳过构造,直接返回
1. 编译器生成了两个关键全局变量
1 | Test `func'::ts DB 01cH DUP (?) ; 静态对象本体(全局区) |
ts:你的静态对象$TSS0:是否已经构造过的标记
2. 进入 func 后第一件事:检查标记
1 | cmp eax, DWORD PTR __Init_thread_epoch[edx] |
翻译:
如果已经初始化过(标记=1),直接跳走,不执行构造!
3. 只有第一次会走到这里:执行构造
1 | mov ecx, OFFSET ts |
4. 构造完立刻做两件事
- 把标记改成「已初始化」
- 注册析构函数(程序结束时自动析构)
1
2push offset $TSS0
call __Init_thread_footer ; 标记变 1
1 | push offset destructor |
5. 第二次调用 func()
直接走到这里:
1 | $LN2@func: |
总结(最关键 3 句话)
局部静态变量本质 = 全局变量
存在全局/静态区,不是栈!编译器偷偷加了一个「初始化标记」
0 = 没构造
1 = 已构造执行流程
第一次 → 标记0 → 构造 → 标记变1
第二次 → 标记1 → 直接返回 → 不执行构造
图表秒懂
1 | 第一次调用 func() |
4. 静态变量
编译器(MSVC)默认开启 /Zc:threadSafeInit,局部 static 动态初始化时默认是线程安全的。加入 /Zc:threadSafeInit- 编译选项关闭线程安全。没有线程安全的话,就需要自己加锁。
少用静态变量,少用全局变量,他们都非常危险。 危险就指的是你控制不了他们的生命周期。咱们说控制不了这个是我们 没办法直接控制它,没办法说我什么时候用你,什么时候删你, 这我们我们搞不定,我们不能这么去干干预干预这个东西全部是系统来帮我们做的,有时候是帮我们做他他是个坏事情。因为C++,我们说了C++我就是想 操控所有的内存管理。
5. 栈 & 堆
栈空间小,堆空间大,栈自动销毁,堆要手动销毁。
栈 (Stack)
极速分配,函数 退出即销毁。

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


作用域优化:对于大型栈对象,应及时使用{}限定作用域以提前释放资源,避免栈空间浪费


堆 (Heap)
跨越函数生命周期,必须 手动或利用 RAII “接力” 管理。
1 | void test7() |
delete pTest 在 C++ 里等价于 “先析构再释放”,编译器用一条 call 完成这两步,便于统一处理、避免漏调。

- 基本操作:使用new/delete进行堆内存的申请与释放,需手动管理生命周期
- 析构调用:即使汇编代码未显式调用,delete操作符最终会触发对象的析构函数
- 内存泄漏:忘记delete会导致内存泄漏,需遵循“谁申请,谁释放”原则
6. 复杂类型的函数参数默认加引用
1 |
|
实践思考
删掉 11 行~16 行的拷贝构造,观察函数的调用:编译器会自动生成一个拷贝构造函数?平凡拷贝?
删掉类的 m_str 变量,观察函数的调用
对比 24 和 28 不同函数参数调用的区别
7. 关于初始化
初始化方式:推荐在类定义中直接初始化成员变量,而非在构造函数体内赋值,后者可能产生额外指令
1 |
|
实践思考
分析代码 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 | SetUnhandledExceptionFilter |
让自己的程序 “优雅” 崩溃!
调用约定补充
x64 调用约定
在 x64 调用约定中,函数参数优先通过寄存器传递:
第 1 个参数:RCX
第 2 个参数:RDX
第 3 个参数:R8
第 4 个参数:R9
其余参数:按照 从右向左 的顺序压入栈中。
x86 调用约定
在 x86 调用约定主要通过 栈(Stack) 来传递参数
压栈顺序:参数通常按从右向左的顺序压入栈中。
返回值:通常存储在 EAX 寄存器中。
栈帧平衡__cdecl:由调用者(caller)负责清理栈(手动增加 ESP)__stdcall:由被调用者(callee)负责清理栈(通过 ret n 指令)