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_castchar(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 持续单调上涨长时间运行后 OOMnew之后某条分支漏了delete或异常路径提前返回AddressSanitizer 的 LeakSanitizer、valgrind massif外部碎片明明总空闲内存够却频繁分配失败或变慢大量小块反复分配释放空闲块被切得太散观察不同尺寸分配耗时、改用内存池悬垂指针行为不稳定有时读到旧值有时崩溃free之后指针没置空还在被使用ASan 的 use-after-free 检测、把指针置空双重释放崩溃在分配器的元数据里堆栈看不出业务逻辑同一指针被delete两次或者多个智能指针管同一块内存ASan、代码审查所有权设计悬垂指针最阴的一点在于它常常能跑free之后那块内存并没有被立刻改写只是回到了空闲链表如果这段时间没有新的分配来复用你读到的还是老数据逻辑看起来完全正确。等到某一天并发上来内存被复用数据就变成了别人的内容。我在一个队列实现里见过这种 bug——弹出元素后没有清空指针析构函数里又用了一次在单线程测试里稳如老狗一上多线程就随机出错。3.3 堆和栈的性能差到底差在哪很多人知道栈比堆快但说不清差多少、差在哪。我做过一个不算严谨但足够说明问题的测试在一个循环里分别做一百万次栈上局部对象构造和一百万次newdelete栈版本通常在个位数毫秒堆版本会到几十甚至上百毫秒差距在一到两个数量级。差异来自三处分配动作本身一条指令 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里具体进哪个由是否初始化为非零决定。因为它跨调用存在所以初始化只发生一次而且 C11 之后局部静态变量的初始化是线程安全的编译器会插入相应的同步逻辑。字符串字面量则不一样它属于只读数据一般在.rodata段。这里有个常见误解const char* s hello;中s这个指针本身是局部变量在栈上它指向的那六个字节在只读数据区。所以你能改s让它指向别处但不能改它指向的内容。如果你写char* s hello;不带 constC11 起这是被禁止的隐式转换编译器会警告甚至报错因为这种写法诱导你去修改一块只读内存等于埋雷。这里再补一个关于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 打开程序和 coreg -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::vectorint v(1000);里v这个对象本身是局部变量在栈上它内部维护的那个指针指向的 1000 个int的缓冲区在堆上。所以栈溢出和堆溢出的报错位置是不同的。再比如int* arr new int[10];arr这个指针变量在栈上占 8 字节它指向的 40 字节在堆上——这是初学者最常搞混的一层。6.3 三个常见误解的澄清指针在堆上。不对。指针变量本身和其他变量一样遵循它的声明位置局部指针在栈上全局指针在数据段。堆上的是指针指向的对象。这个误解会让人在排查指针被覆盖的问题时找错方向。const变量都在代码区。不对。局部const在栈上全局const可能进只读数据区也可能因为常量传播被完全优化掉根本不存在于任何段里作为类成员的const则嵌在对象内部——对象在哪它就在哪。bss 段的数据不占内存。不对。准确说法是bss 段不占可执行文件的体积加载进内存之后它照样占虚拟地址空间写得多了也一样占物理内存。它省的是磁盘不是 RAM。把这三条澄清完回头再看那些堆和栈的区别的面试题你会发现真正值得说的不是背下来的定义而是为什么这么设计、分配失败的后果分别是什么、哪些行为会触发未定义行为。最后说个我自己的习惯每次写完一段涉及手动内存管理的代码我都会在脑子里过一遍这些对象各自在哪、谁负责释放、跨不跨线程、有没有可能在异常路径上被漏掉。这几个问题问下来大部分内存相关的 bug 在编码阶段就能摁住比事后开 sanitizer 一个一个抓效率高得多。这套分区模型真正有用的地方不在于面试时能背出来而在于它给了你一个心算工具看到一行代码你脑子里能自动浮现出这块内存在哪个段、能活多久、被谁动过。这个东西练出来了指针和内存对你就不再是玄学了。