内存布局:int x = 42 里的 42,到底存在哪
上一章咱们在比错误处理的三种写法,那些失败都还够得着"处理"二字:除数为零、文件打不开,返回值和异常总能接住一个。但有一种失败连这些机制都来不及上场——segmentation fault 一出,进程直接就没了,没有什么返回值可检查。
要弄懂这类问题出在哪,得先回答一个从头悬到现在的问题:当咱们写下 int x = 42; 的时候,这个 42 到底存在哪里?它在内存中的什么位置?什么时候被创建,什么时候被销毁?
如果我们不理解这些问题,咱们迟早要吃一顿大的,出了问题就只能瞎猜:崩溃的地址明明指向栈,您却读不出这层信息。
TL; DR的说:理解内存布局,本质上就是搞清楚两件事:数据存在哪里,以及它活多久。
程序的四大内存区域
一个 C++ 程序运行时,操作系统会为它分配一块虚拟地址空间。这块空间并不是一整块同质的区域,而是被划分成了若干个段(segment),每个段有各自的用途和管理方式。对咱们来说,最核心的是以下四个区域:
咱们先看四个区域各自放什么。代码段(Text segment)存放编译后的机器指令和一些只读数据(比如字符串字面量 "hello"),这个区域通常是只读的,尝试修改会直接触发段错误。数据段(Data segment)存放已初始化的全局变量和 static 变量,它们的值在程序启动时就已经确定。BSS 段是数据段的一部分,专门放未初始化的全局和 static 变量——这些变量会被自动初始化为零,所以可执行文件里不需要存储它们的初始值,只记录大小就够了。堆和栈则是运行时动态使用的区域,前者由程序员手动管理,后者由编译器自动管理。
这个布局模型有一个很关键的观察:栈在高地址向低地址增长,堆在低地址向高地址增长,两者相向而行。这意味着它们的地址范围不会重叠(除非某一方耗尽了可用空间),而且咱们同时打印一个栈变量的地址和一个堆变量的地址,栈变量通常会有一个明显更大的地址值。
栈内存——编译器管进管出
栈是 C++ 程序中最常用的内存区域。咱们在函数里声明的局部变量、函数参数、返回地址,统统在栈上。栈的管理方式极为简单粗暴:一个指针(栈指针)指向当前栈顶,分配内存就是把指针往低地址挪,释放内存就是把指针往高地址挪回来。这种"指针移动"式的分配不需要任何搜索或合并操作,所以栈分配的速度快到几乎为零开销。
每次函数调用时,编译器会在栈上为这个函数创建一个"栈帧"(stack frame),里面包含该函数的所有局部变量、参数和返回地址。函数返回时,整个栈帧被弹掉,所有局部变量瞬间销毁。这种机制叫做自动存储期(automatic storage duration):变量的生命周期完全由作用域决定,进入作用域就创建,离开作用域就销毁,不需要咱们操半点心。
#include <iostream>
void foo()
{
int a = 1; // 栈上分配
double b = 2.0; // 紧随 a 之后
std::cout << "a 的地址: " << &a << "\n";
std::cout << "b 的地址: " << &b << "\n";
// 函数返回,a 和 b 的空间自动回收
}
int main()
{
foo();
// 这里 a 和 b 已经不存在了
return 0;
}一些朋友可能看到栈居然如此高效,就直接起飞了,啥啥都是栈栈栈。我说别犯教条主义,教条主义害死人啊。笔者先讲自己熟悉的Linux:。Linux 上默认栈大小通常是 8 MB(可以用 ulimit -s 查看),Windows 上通常是 1 MB。这个限制对于正常的函数调用来说绰绰有余,但是吧,两个场景可以轻轻松松毫无压力的击穿我们的栈的空间。
第一个,是被骗去“C++就是刷题”的选手爱犯的错误。在栈上直接写下int arr[10000000]; 的玩意看着没啥,但是这个看起来人畜无害的声明实际上需要约 40 MB 的栈空间,远超默认限制,程序启动时直接 segmentation fault,连个错误信息都来不及输出。咱们要是需要大块内存,请用 std::vector 或在堆上分配。
之前笔者为一个朋友解答问题的时候差点没绷住,他在
main()里,直接放置了一个非常非常巨大的int arr[10000000];,然后跑过来问:“新人,这个代码怎么炸了?”我热心的去看的时候没绷住,告诉他您是否知道Windows上的栈只有1MB而您直接用了40MB的时候。他反应过来他放错位置了(虽然后来告诉他也被这样干,很低级的写法,麻烦用vector来,内存开销还优雅不是?)。OJ比赛固然说犯不着搞工程规范这样的东西(实际上,完全死守工程规范不过是另外的教条主义),但是放在一般的程序设计中,这显然是不合适,甚至是错误的!
另一种,请各位拉高警惕,因为大家入门的时候,都写过递归。递归的确是个好东西,大问题拆成小问题,小问题拆成没问题。很符合我们处理工程问题的直觉,B! U! T! 只是符合直觉,因为很多场景下,我们面对的数据集是未知的,我们编写的递归代码,很容易接受到让递归链极端庞大的输入,他们很容易击穿一个栈。
比如说,计算阶乘时递归到 n = 100000,每一层函数调用,都要疯狂的消耗栈帧空间。这样的操作,很快就会把栈吃光。
栈溢出在调试器里通常表现为一个异常低的栈指针地址:在 Linux 上咱们会看到寄存器
rsp的值已经远低于正常的栈区域范围,这就是栈指针一路向下冲过了安全边界。
其实有第三关的。。。有嵌入式的朋友嘛!看这里!对于嵌入式,栈空间往往更小(有些 RTOS 任务栈只有几 KB),在这个时候,您甚至不太敢放入即便是普通的局部数组(比如 char buf[512];)。他们都可能成为炸弹,在您喜欢或者是不喜欢的位置上直接起爆。所以咱们写代码的时候要养成一个习惯:对于超过几百字节的数据结构,优先考虑堆分配或静态分配,不要默认往栈上扔。
想异常处理?没门~。栈溢出(stack overflow)不像堆的
new失败那样会抛出异常或返回nullptr,操作系统检测到栈溢出时直接发送 SIGSEGV 信号,程序立刻终止。没有任何优雅处理的机会,咱们调试时只能靠事后分析 core dump 或者在递归入口加计数器防护。
堆内存——非常的自由,但是是事故高发地
堆是程序中最大的可用内存区域——理论上它可以扩展到操作系统允许的最大值(在 64 位系统上是 TB 级别)。咱们用 new 或 malloc 申请内存时,分配器在堆上找一块合适大小的空间返回,这块空间会一直存在,直到显式释放它(delete 或 free)。这种由程序员手动控制生命周期的存储方式叫做动态存储期(dynamic storage duration)。
咱们看堆的灵活性是有代价的。分配器需要维护空闲链表或伙伴系统等数据结构来追踪哪些区域已被占用、哪些是空闲的,每次 new 都要执行搜索算法找到合适大小的块,每次 delete 都要执行合并操作防止内存碎片化。这些管理开销使得堆分配比栈分配慢几个数量级。而且咱们频繁地分配和释放不同大小的块,还会导致内存碎片(memory fragmentation):虽然总空闲空间足够,但因为这些空闲块被切割成了大量不连续的小碎片,无法满足较大的分配请求。这也是为什么高性能系统会使用自定义分配器或内存池来绕过默认的堆分配机制。
#include <iostream>
int main()
{
// 堆分配
int* p1 = new int(42);
int* p2 = new int[1000]; // 数组也在堆上
std::cout << "p1 指向的地址: " << p1 << "\n";
std::cout << "p2 指向的地址: " << p2 << "\n";
// 必须手动释放
delete p1;
delete[] p2;
return 0;
}忘记 delete 导致内存泄漏(memory leak)是 C++ 最臭名昭著的问题之一(一想到笔者能在自己公司的投诉反馈平台看到专业的:“你们C++写的程序不?内存泄漏咋这么严重?水平在哪里”的时候,忍不住轻哼啊。)。
问题的严重之处在于:泄漏的内存在程序结束前永远不会被回收。对于短命的控制台程序这通常不是大问题(进程退出时操作系统会回收所有资源),但对于长时间运行的服务器程序或嵌入式系统,内存泄漏会一点一点把可用内存吃干,最终导致系统崩溃。这也是为什么咱们在前面章节反复强调 RAII:用智能指针(std::unique_ptr、std::shared_ptr)和容器(std::vector、std::string)来管理动态内存,让析构函数自动释放资源,从根本上消灭手动 delete 的需求。
静态和全局内存——从程序启动到终结
咱们看全局变量、名字空间作用域的变量、用 static 声明的变量,以及类的 static 成员变量,都属于静态存储期(static storage duration)。它们的生命周期贯穿整个程序运行过程:在 main() 开始执行之前就已经被创建和初始化,在 main() 返回之后才被销毁。
咱们看静态存储区的变量有两种初始化方式。编译期能确定值的常量(比如 const int kMaxSize = 100;),编译器直接把初始值写进可执行文件的数据段;初始值需要运行时计算的(比如 static int counter = compute_init_value();),则会在程序启动时、main() 执行之前完成初始化。
展开代码收起代码共 25 行
#include <iostream>
int global_var = 10; // 数据段:已初始化全局变量
int global_uninit; // BSS 段:未初始化全局变量(自动为 0)
const char* kMessage = "hello"; // 数据段:指针本身在数据段
// "hello" 字面量在代码段(只读)
void demo()
{
static int call_count = 0; // 数据段:首次调用时初始化
++call_count;
std::cout << "第 " << call_count << " 次调用\n";
}
int main()
{
std::cout << "global_var = " << global_var << "\n";
std::cout << "global_uninit = " << global_uninit << "\n";
demo(); // 第 1 次
demo(); // 第 2 次
demo(); // 第 3 次
return 0;
}咱们看 static 局部变量有一个很实用的特性:延迟初始化。它只在程序执行到该声明语句时才进行初始化,而不是在程序启动时。从 C++11 开始,这种初始化还是线程安全的——如果多个线程同时首次进入包含 static 局部变量的函数,编译器会保证只有一个线程执行初始化,其他线程阻塞等待。这个特性使得 static 局部变量成为实现线程安全单例模式的最佳方案(Meyers' Singleton)。
全局变量的构造和析构顺序在跨翻译单元(即不同 .cpp 文件)之间是未定义的。a.cpp 里要是有个全局对象依赖 b.cpp 里另一个全局对象的初始化结果,程序可能会在启动阶段就出现未定义行为——因为标准不保证哪个先初始化。这就是臭名昭著的"静态初始化顺序陷阱"(Static Initialization Order Fiasco)。解决方案是使用函数内的 static 局部变量来包装(Construct On First Use Idiom),利用咱们刚才说的延迟初始化特性来确保正确的初始化顺序。
动手验证——打印各区域的地址
说了这么多理论,咱们写一个程序来实际验证一下。在下面这段代码中,咱们在每个区域各放一个变量,然后打印它们的地址。通过观察地址的数值大小和相对位置,就能直观地验证内存布局模型。
展开代码收起代码共 44 行
// layout.cpp
// 编译: g++ -std=c++17 -O0 layout.cpp -o layout
// 注意: 使用 -O0 关闭优化,防止编译器对变量做激进优化
#include <cstdint>
#include <iostream>
// 全局变量 —— 数据段(已初始化)
int g_initialized = 42;
// 全局变量 —— BSS 段(未初始化,自动为 0)
int g_uninitialized;
// const 全局 —— 通常在只读段或被编译器内联
constexpr int kGlobalConst = 100;
int main()
{
// 栈变量
int stack_var = 1;
// 堆变量
int* heap_var = new int(2);
// static 局部变量 —— 数据段
static int s_static_local = 3;
std::cout << "=== 各区域变量地址 ===\n";
std::cout << "代码段 (函数地址): main() @ " << reinterpret_cast<void*>(main) << "\n";
std::cout << "数据段 (已初始化): g_initialized @ " << &g_initialized << "\n";
std::cout << "BSS段 (未初始化): g_uninitialized @ " << &g_uninitialized << "\n";
std::cout << "数据段 (static局部): s_static_local @ " << &s_static_local << "\n";
std::cout << "栈: stack_var @ " << &stack_var << "\n";
std::cout << "堆: heap_var @ " << heap_var << "\n";
std::cout << "\n=== 地址大小关系 ===\n";
std::cout << "栈地址 > 堆地址? " << (&stack_var > heap_var ? "是" : "否") << "\n";
std::cout << "栈地址 > 数据段地址? " << (&stack_var > &g_initialized ? "是" : "否") << "\n";
std::cout << "数据段地址 > 代码段地址? "
<< (&g_initialized > reinterpret_cast<int*>(main) ? "是" : "否") << "\n";
delete heap_var;
return 0;
}咱们把完整代码放在下面,点"动手试一试"直接跑(编译条件已设为 -O0,正文说过原因):
Compiler Explorer
动手验证:layout.cpp
在线打印代码段、数据段、BSS、栈、堆上变量的地址。您多跑几次:栈和堆地址每次都变(ASLR),但三条大小关系不变。
这些地址完美地验证了咱们的布局模型:代码段在最低地址,数据段和 BSS 段紧随其上,堆在中间偏低的区域向上增长,栈在接近最高地址的位置向下增长。main 函数的地址远小于其他所有变量,它确实在代码段里。g_initialized 和 s_static_local 的地址非常接近,它们都在数据段。g_uninitialized 的地址比已初始化变量略大一些,BSS 段在数据段之后。而栈变量和堆变量之间的巨大地址差值,就是两者之间那片未使用的空间。
您在自己的机器上运行这个程序,具体的地址数值肯定不同(特别是栈地址,每次运行都会变化——这叫 ASLR,地址空间布局随机化,是操作系统的安全机制),但相对大小关系应该是一致的。哪天您要是看到栈地址反而比堆地址小,大概率是编译器做了什么特殊的内存布局优化,或者您的平台使用了非传统的内存模型,这种情况在桌面和服务器环境中极为罕见。
练习
练习 1:判断存储区域
请您判断以下每个变量分别存储在哪个内存区域(栈、堆、数据段、BSS 段、代码段):
const char* msg = "error"; // msg 和 "error" 各在哪里?
static int count; // ?
int* p = new int[10]; // p 和 p 指向的数组各在哪里?
void func() {
int local = 0; // ?
static int visits = 0; // ?
}练习 2:找出栈溢出隐患
以下代码有什么问题?请您思考应该如何修复?
void process_image()
{
// 图像缓冲区:1920 x 1080 x 4 (RGBA) = 约 8 MB
unsigned char buffer[1920 * 1080 * 4];
// ... 处理图像 ...
}
int fibonacci(int n)
{
return fibonacci(n - 1) + fibonacci(n - 2); // 缺少终止条件
}练习 3:验证布局模型
请您编写一个程序,在同一个函数中声明一个局部变量、分配一个堆变量、定义一个 static 局部变量,并打印一个全局变量的地址。观察它们的地址分布是否符合咱们描述的布局模型。然后在函数中再调用一个子函数,在子函数中打印一个局部变量的地址,验证子函数的栈变量地址是否比父函数更小(栈向低地址增长)。