1. 一次凌晨的崩溃让我重新翻开了内存分区这张老图
几年前接手过一个图像处理的小模块,逻辑不复杂:一个函数把处理结果写进局部数组,返回这个数组的指针给上层。Debug 版本跑了几十次都没事,Release 版本一开优化,程序在某个随机位置段错误,堆栈信息指向的地方完全不讲道理。那会儿我第一次认真意识到,堆区、栈区、数据段、bss段、代码区这套看着像考试知识点的东西,实际上是每天都在影响程序行为的东西——它决定了变量能活多久、谁有权改它、它躺在可执行文件的哪一部分、以及你越界写一个字节之后会砸到谁。
这篇文章我打算换个角度讲这五个分区:不讲成八股,而是从"一段代码里的每个变量到底住在哪"出发,把每个分区的职责、生命周期、读写权限、验证方法、以及最容易踩的坑一次说透。如果你写过 C++、调过段错误、见过内存泄漏,或者正在准备面试里的"八股"环节,这篇都能用得上;如果你刚入门,那么从内存视角理解指针和引用,会比死记语法快得多。
先把一个容易被忽略的前提说清楚:堆、栈、数据段、bss段、代码区是编译器、链接器和操作系统共同实现的一套内存布局模型,不是 C++ 标准里的术语。C++ 标准从语言层面描述的是"存储期"(storage duration)——自动存储期、静态存储期、动态存储期、线程存储期。五个分区是这套抽象在主流平台(Linux/Windows 的 x86-64、ARM64)上的具体落地方式。为什么这个区分重要?因为当你听到有人说"局部 const 变量在代码区"或者"指针在堆上"时,你需要有能力判断这句话到底错在哪,而不是靠背结论。下面每一节,我都会先用语言层面的规则定性,再落到具体平台的实现细节上。
大体上,一个典型的 Linux 进程地址空间,从高地址往低地址看是这样的:栈区在最上面,往下是共享库和内存映射区,再往下是堆区,堆的下面是 bss 段和数据段,最底下是代码区(含只读数据)。这个顺序不是随便排的,它和段权限、加载效率、以及内存增长方向都有关系,后面会逐个解释。
2. 栈区:自动变量的家,也是崩溃的第一现场
栈区是我在排查问题时最先怀疑的地方,因为绝大多数"莫名其妙"的崩溃都发生在栈上。它的规则简单到可以用一句话概括:函数调用时分配,函数返回时回收,由编译器自动管理,你不需要也不应该插手。但正是这份"自动",让很多边界问题变得隐蔽。
2.1 一个栈帧里究竟压了哪些东西
每次调用函数,运行时会在栈顶开一块区域,叫栈帧(stack frame)。一个 x86-64 的典型栈帧包含这几样:调用者传进来的参数(超出寄存器数量的部分)、函数返回后要跳回去的返回地址、上一帧的帧指针、被调用者需要保存的寄存器、函数内部的局部变量、以及编译器安排的临时对象。局部数组、局部对象本身,就活在这块内存里。
栈在 x86-64 Linux 上是向低地址方向生长的:新栈帧的地址比老栈帧低。所以同一个函数递归调用时,每一层的局部变量地址是递减的。理解这一点,你就能明白为什么"数组越界往后写"和"往前写"破坏的东西不一样——往后写(更高地址)可能碰到的是本帧内其他变量,往前写(更低地址)则可能踩到返回地址,那就是直接跳飞。
分配的速度为什么快?因为编译器在编译期就算好了这个函数需要多少栈空间,函数入口处一条减法指令调整栈指针(sub rsp, N)就完成了"分配",函数出口一条加法(或者用leave)就完成了"释放"。整个过程中没有系统调用、没有锁、没有元数据查找,只有一个寄存器加减。这也是栈分配比堆分配快一两个数量级的根本原因,不是编译器做了什么魔法优化,而是分配这个动作本身就是一条指令。
2.2 栈是有限资源,别拿递归当儿戏
栈区的大小不是无限的。Linux 上主线程的栈大小由ulimit -s决定,多数发行版默认 8MB;用 pthread 创建的子线程,栈大小由pthread_attr_setstacksize指定,Linux 上默认也是 8MB,而 Windows 上默认通常是 1MB。这个数量级意味着:如果你的函数栈帧是 4KB,那么递归深度到 2000 层左右就会耗尽栈空间。
下面这段代码是我用来测栈深度的小工具,很土但很好用:
#include <cstdio> int depth = 0; void recurse() { char buf[4096]; // 强行让每帧占大约 4KB buf[0] = static_cast<char>(depth & 0xFF); // 防止被优化掉 ++depth; if (depth % 100 == 0) { printf("depth = %d\n", depth); fflush(stdout); } recurse(); } int main() { recurse(); return 0; }实测下来,在 8MB 栈限制下这个程序大约在 2000 层出头的时候收到 SIGSEGV。注意,栈溢出导致的崩溃不是标准异常,不会抛出std::bad_alloc,也不会给你的try/catch任何机会,进程直接挂。所以业务代码里凡是递归,要么改成显式栈的迭代,要么在入口处加深度上限判断。
提示:如果确实需要很深的递归,可以调大
ulimit -s,但这只是把问题往后推。更稳的做法是把大数组、大对象从栈上挪走——局部定义一个int buf[1024 * 1024]就是 4MB,一个大数组就能把栈吃掉一半。
2.3 返回局部变量的地址为什么有时居然"正常"
这是新手必踩的坑,也是我开头那个崩溃故事的根源:
#include <cstdio> int* bad() { int local = 42; return &local; // 函数返回后这块栈内存随时会被重用 } int main() { int* p = bad(); printf("%d\n", *p); // 可能是 42,可能是垃圾值,也可能是段错误 return 0; }为什么它有时候真的打印出 42?因为函数返回只是把栈指针往上挪,那块内存的内容并不会被立刻擦掉。只要后面没有别的函数调用把这块区域覆盖掉,你读到的就是"残留"的旧值。这就是它最坑的地方:在 Debug、单元测试、小 demo 里经常"能跑",一旦上线、一旦优化等级上去、一旦有别的函数插进来,立刻崩。这类问题无法靠加打印解决,只能靠代码规范——返回对象的拷贝、用智能指针、或者让调用方传缓冲区进来。
另一个相关的实测现象:开启-O2之后,编译器可能把局部变量优化进寄存器,&local这个地址甚至都不存在了,你会看到更诡异的行为,比如取地址操作强制变量落到栈上,反而让代码"看起来正常"了。所以遇到这类问题,先看优化等级,再看行为,别被表象带节奏。
3. 堆区:手动管理的自由,和它的三笔代价
堆区是五个分区里最"自由"也最容易出事的。它的大小只受进程可用虚拟内存限制,可以在运行期动态伸缩,分配出来的对象生命周期完全由你控制——你不还,它就不走。这份自由换来的是三笔代价:分配慢、容易泄漏、容易碎片化。
3.1 一次 malloc 在底层到底走了哪条路
new是 C++ 的操作符,它先调用operator new拿原始内存,再在这块内存上跑构造函数;而operator new的标准实现,绝大多数情况下就是转手调用malloc。所以理解malloc的行为,基本就理解了堆区的行为。
glibc 的malloc对小块和大块是两套策略。小于 mmap 阈值(默认 128KB,可通过M_MMAP_THRESHOLD调整)的请求,走的是brk系统调用:连续地把堆顶边界往上推,从主堆区里切一块出来。超过阈值的请求,直接走mmap,在映射区单独开一块,free的时候直接还给操作系统。
这块策略差异会带来两个非常实际的影响。第一,小块分配释放后,内存通常留在进程里不还给 OS,只回到分配器的空闲链表上,所以你看到 top/任务管理器里进程的 RSS 只涨不跌,不一定是泄漏,可能只是分配器把内存扣着复用了。第二,大块分配用完就munmap,频繁地申请释放大块内存会导致大量的系统调用和缺页中断,性能很差。我处理过的图像流水线里就踩过这个坑:每帧malloc一张 2MB 的大图,结果 CPU 有很大一块时间花在内核态。
再往细里说,每个分配出来的块前面都有分配器自己维护的头部信息(chunk header),记录块大小、前一个块的信息等,所以malloc(1)实际占用的内存远不止 1 字节。glibc 还有 per-thread 的 tcache 缓存,每个大小档位保留若干个刚释放的块,下次同尺寸申请可以 O(1) 拿到,这是为了减少锁竞争。这些细节你平时不用管,但在排查"为什么空闲内存不还"或者"为什么小对象分配这么快"的时候,它们就是答案。
3.2 泄漏、碎片、悬垂:三种典型故障的现场特征
这三类问题在现象上很像,但成因和排查手段完全不同,我把它们的特征列成表方便对照。
| 故障类型 | 典型现象 | 根因 | 常用定位手段 |
|---|---|---|---|
| 内存泄漏 | 进程 RSS 持续单调上涨,长时间运行后 OOM | new之后某条分支漏了delete,或异常路径提前返回 | AddressSanitizer 的 LeakSanitizer、valgrind massif |
| 外部碎片 | 明明总空闲内存够,却频繁分配失败或变慢 | 大量小块反复分配释放,空闲块被切得太散 | 观察不同尺寸分配耗时、改用内存池 |
| 悬垂指针 | 行为不稳定,有时读到旧值有时崩溃 | free之后指针没置空,还在被使用 | ASan 的 use-after-free 检测、把指针置空 |
| 双重释放 | 崩溃在分配器的元数据里,堆栈看不出业务逻辑 | 同一指针被delete两次,或者多个智能指针管同一块内存 | ASan、代码审查所有权设计 |
悬垂指针最阴的一点在于它常常"能跑":free之后,那块内存并没有被立刻改写,只是回到了空闲链表,如果这段时间没有新的分配来复用,你读到的还是老数据,逻辑看起来完全正确。等到某一天并发上来,内存被复用,数据就变成了别人的内容。我在一个队列实现里见过这种 bug——弹出元素后没有清空指针,析构函数里又用了一次,在单线程测试里稳如老狗,一上多线程就随机出错。
3.3 堆和栈的性能差,到底差在哪
很多人知道"栈比堆快",但说不清差多少、差在哪。我做过一个不算严谨但足够说明问题的测试:在一个循环里,分别做一百万次栈上局部对象构造和一百万次new/delete,栈版本通常在个位数毫秒,堆版本会到几十甚至上百毫秒,差距在一到两个数量级。差异来自三处:分配动作本身(一条指令 vs 一次函数调用加可能存在的锁)、内存局部性(栈上连续分配,缓存友好;堆上块与块之间可能隔着很远)、以及缺页和元数据维护的开销。
这个结论的实践意义不是"别用堆",而是:热路径上的小对象,优先考虑栈上分配、对象池或者连续容器。比如游戏里每帧要处理的粒子,用std::vector一整块连续内存,比每个粒子单独new出来性能好得多,顺带还解决了碎片和泄漏两个问题。反过来,生命周期需要跨越函数边界、大小在运行期才能确定、或者对象很大的时候,堆就是更合理的选择,RAII 和智能指针负责把它的生命周期管住。
4. 数据段与 bss 段:全局变量为什么要分两个地方放
第一次在objdump里看到.data和.bss两个段的时候,我以为是历史遗留的冗余设计,后来才明白这两个段的拆分是一个相当聪明的工程取舍:它们的区别不在运行时,而在可执行文件里。
4.1 .data 与 .bss 的分界线到底是什么
规则非常简单:已经初始化为非零值的全局变量和静态变量,进 .data 段;未初始化、或者初始化为 0 的全局变量和静态变量,进 .bss 段。注意后半句,int g = 0;这种显式写零的,和int g;一样进 .bss。
为什么这么分?关键在于文件体积。.data段的内容需要实实在在存在可执行文件里,因为程序启动时必须把它原样加载到内存。而.bss段的内容全是 0,没必要在磁盘上存一大堆 0——可执行文件里只记录"这一段有多大、从哪开始",程序加载时由加载器负责分配并清零。
我做过一个很直观的对比:定义一个一兆字节的全局数组,分别测试初始化和不初始化两种情况,编译出来的可执行文件大小差了一兆左右。这就是 bss 段存在的全部理由,它让"大块清零的静态缓冲区"几乎不占可执行文件体积。你可以自己验证:
// demo1.cpp int big[262144]; // 1MB,未初始化,进 .bss // demo2.cpp int big[262144] = {1}; // 1MB,已初始化,进 .data分别编译后用ls -l看体积,差距非常明显。这个特性在实际工程里是有用的:一些嵌入式场景会刻意把大缓冲区定义成未初始化的全局变量,就是为了压缩固件体积。
注意:bss 段不占文件体积,但占运行时的物理内存。它只是在加载时被清零,清零本身是通过操作系统的"零页映射"和写时复制机制实现的——你不写它,可能一直在共享同一个物理零页;你一写,才真正分配物理页面。所以"不占空间"这个说法只对磁盘成立。
4.2 用 size 和 objdump 把段大小看到眼里
光靠记忆不如亲手看一遍。写一个包含多种变量的源文件,编译成目标文件,然后用工具把它剖开:
// layout.cpp int g_inited = 123; // .data int g_zero = 0; // .bss int g_uninit; // .bss const int g_const = 77; // 通常进 .rodata static int s_inited = 5; // .data static int s_uninit; // .bss int main() { static int local_static = 9; // .data(作用域受限,但仍属静态存储期) static int local_static_zero; // .bss int stack_var = 1; // 栈 char* p = new char[16]; // 指针在栈,数组在堆 delete[] p; return stack_var; }g++ -c layout.cpp -o layout.o size -A layout.o # 看各段大小 objdump -h layout.o # 看段头部信息 objdump -t layout.o # 看符号落在哪个段size -A的输出里,text、data、bss、rodata各行的大小一目了然。objdump -t更细,每个符号后面会标注它属于哪个段。这个习惯我强烈建议养成:当你怀疑某个变量被意外地放到了什么地方,或者想确认一个const到底进了只读数据还是被优化掉了,这两条命令比翻书快得多。
4.3 静态局部变量和字符串字面量归谁管
这两个是最容易搞混的。静态局部变量(函数里的static变量)虽然写在函数内部、作用域只在函数内,但它的存储期是静态的:程序启动时初始化一次,进程结束才销毁。所以它不可能在栈上,它一定在.data或者.bss里,具体进哪个由是否初始化为非零决定。因为它跨调用存在,所以初始化只发生一次,而且 C++11 之后,局部静态变量的初始化是线程安全的,编译器会插入相应的同步逻辑。
字符串字面量则不一样,它属于只读数据,一般在.rodata段。这里有个常见误解:const char* s = "hello";中,s这个指针本身是局部变量,在栈上;它指向的那六个字节在只读数据区。所以你能改s让它指向别处,但不能改它指向的内容。如果你写char* s = "hello";(不带 const),C++11 起这是被禁止的隐式转换,编译器会警告甚至报错,因为这种写法诱导你去修改一块只读内存,等于埋雷。
这里再补一个关于const全局变量的细节:C++ 里全局const默认具有内部链接(internal linkage),这意味着每个包含它的翻译单元都有一份自己的副本,编译器甚至可以把它当编译期常量直接内联到使用处,这种情况下它根本不占用任何段。只有当它被取地址、或者被extern声明为外部链接时,才会真正在.rodata或者.data里分配存储。这也是为什么"const 变量一定在代码区"这种说法是错的:局部const在栈上,全局const可能被完全优化掉,只有需要落地的常量才进只读数据段。
5. 代码区与只读数据:为什么改一个字面量就段错误
代码区(.text)存放编译出来的机器指令。它的关键属性是只读且可执行,这两点都由操作系统的页表权限位保证。现代操作系统在加载可执行文件时,会给.text所在的页标记为"可执行但不可写",给.rodata标记为"只读且不可执行",这是一种基本的安全设计:让数据区没法被执行,让代码区没法被改写。
5.1 .text 与 .rodata 的权限差异
.text和.rodata经常被放在一起说,但它们权限不同。.text通常同时具备读和执行权限,.rodata只读,不执行。你平时把函数指针和数据指针混用、或者往数据缓冲区里塞机器码再跳过去执行,在现代系统上很可能直接触发保护异常,原因就在这里。
这个设计的实际影响是:任何试图写.text或.rodata的行为都会触发段错误。最经典的复现就是这个:
#include <cstdio> int main() { char* p = (char*)"hello"; // 强制去掉 const,非常危险 p[0] = 'H'; // 段错误(SIGSEGV) printf("%s\n", p); return 0; }在 Linux 上这段代码几乎必然崩,因为在链接之后"hello"被放进了只读段,写操作被 MMU 拦下。但这里有个更麻烦的点:这是未定义行为。在某些平台或者某些编译选项下,字面量可能被放在可写段里,于是程序"成功"地改掉了内容,但你可能同时改掉了所有指向同一个字面量的地方——因为编译器常常会合并相同的字面量。
5.2 字面量地址复用的实测
#include <cstdio> int main() { const char* a = "hello"; const char* b = "hello"; const char* c = "hell"; printf("a=%p b=%p c=%p\n", (void*)a, (void*)b, (void*)c); printf("same: %d\n", a == b); return 0; }在我的环境下,a和b打印出来的地址是相同的,比较结果也是 1。编译器做了常量合并,把同一个字面量只存了一份。这意味着如果你(通过一些不推荐的手段)改动了a指向的内容,b也会跟着变,排查这种问题时你会怀疑人生。所以我给的建议很直接:永远不要试图修改字符串字面量,需要可修改的字符缓冲区就用char buf[] = "hello";,这样数据在栈上,随你改,代价是每次都要拷贝一份。
5.3 段错误现场的定位流程
遇到段错误不要慌,我的固定套路是这样的。首先确认 core dump 有没有开(ulimit -c),打开之后重新跑一次,拿到 core 文件。然后用 gdb 打开程序和 core:
g++ -g -O0 crash.cpp -o crash ulimit -c unlimited ./crash gdb ./crash core进 gdb 之后三条命令定方向:bt看调用栈,定位是哪一行;info registers看rip现在停在哪;info proc mappings看这个地址落在哪个段、权限是什么。如果崩的地址落在只读段,那几乎可以肯定是往字面量或者只读全局变量写数据;如果地址是一串很小的值(比如 0x0 或者 0x10 附近),那多半是解引用了空指针或者被破坏的指针。
还有一种情况值得单独说:栈上数组越界写坏了返回地址。这种崩溃的表现是调用栈完全不可信,bt出来的东西乱七八糟。这时候要往回看一层,检查数组边界和循环条件。开-fstack-protector-strong编译能让编译器在栈帧里插入 canary 值,一旦返回地址附近被改写就会在函数返回时主动终止程序,崩溃点更接近问题现场,对定位极其有帮助。
6. 五区对照与三个实际判断
前面几节拆开讲完之后,我把五个分区放在一张表里对照一下。这张表我建议你按自己的平台实测填一遍,因为不同架构、不同编译器、不同优化等级下,有些细节是会变的。
6.1 一张表理清归属、生命周期与权限
| 分区 | 存放内容 | 生命周期 | 读写权限 | 增长速度 |
|---|---|---|---|---|
| 栈区 | 局部变量、函数参数、返回地址、临时对象 | 函数调用期间 | 读写 | 向低地址增长 |
| 堆区 | new/malloc出来的对象 | 从分配到释放 | 读写 | 向高地址增长 |
数据段.data | 初始化为非零的全局/静态变量 | 整个进程 | 读写 | 编译期确定 |
bss 段.bss | 未初始化或初始化为 0 的全局/静态变量 | 整个进程 | 读写 | 编译期确定 |
代码区.text | 机器指令 | 整个进程 | 只读可执行 | 编译期确定 |
只读数据.rodata | 字符串字面量、常量表 | 整个进程 | 只读 | 编译期确定 |
补充一点:读写权限不是绝对的,取决于平台和编译选项。比如某些嵌入式平台的 RAM 有限,会把常量和代码也放在一起;有的安全加固选项会强制.data也不可执行。所以表的用法是"给出默认预期",不是"背下来当真理"。
6.2 实际调试中怎么快速判断一个变量在哪
我总结了几个快速判断的经验法则,基本可以在不查资料的情况下覆盖八成场景:
看它是不是在函数内部声明、没有static、没有new——那基本就是栈上。看它是不是全局的、或者带static——那就在.data或者.bss。看它是不是通过new、malloc、容器(比如std::vector的元素)来的——那在堆上。看它是不是字符串字面量或者被放进只读常量表的——那在.rodata。
举个容易迷糊的例子:std::vector<int> v(1000);里,v这个对象本身是局部变量,在栈上;它内部维护的那个指针指向的 1000 个int的缓冲区在堆上。所以栈溢出和堆溢出的报错位置是不同的。再比如int* arr = new int[10];,arr这个指针变量在栈上,占 8 字节,它指向的 40 字节在堆上——这是初学者最常搞混的一层。
6.3 三个常见误解的澄清
"指针在堆上。"不对。指针变量本身和其他变量一样,遵循它的声明位置:局部指针在栈上,全局指针在数据段。堆上的是指针指向的对象。这个误解会让人在排查指针被覆盖的问题时找错方向。
"const变量都在代码区。"不对。局部const在栈上;全局const可能进只读数据区,也可能因为常量传播被完全优化掉,根本不存在于任何段里;作为类成员的const则嵌在对象内部——对象在哪它就在哪。
"bss 段的数据不占内存。"不对。准确说法是bss 段不占可执行文件的体积,加载进内存之后它照样占虚拟地址空间,写得多了也一样占物理内存。它省的是磁盘,不是 RAM。
把这三条澄清完,回头再看那些"堆和栈的区别"的面试题,你会发现真正值得说的不是背下来的定义,而是"为什么这么设计"、"分配失败的后果分别是什么"、"哪些行为会触发未定义行为"。
最后说个我自己的习惯:每次写完一段涉及手动内存管理的代码,我都会在脑子里过一遍这些对象各自在哪、谁负责释放、跨不跨线程、有没有可能在异常路径上被漏掉。这几个问题问下来,大部分内存相关的 bug 在编码阶段就能摁住,比事后开 sanitizer 一个一个抓效率高得多。这套分区模型真正有用的地方不在于面试时能背出来,而在于它给了你一个心算工具:看到一行代码,你脑子里能自动浮现出这块内存在哪个段、能活多久、被谁动过。这个东西练出来了,指针和内存对你就不再是玄学了。