你有没有遇到过这种问题:一个函数里定义了个局部变量,函数返回后外面还在用这个地址,结果数据时好时坏;或者随手写了一行给字符串赋值,程序直接段错误;再比如一个递归函数跑了几万层,内存突然飙升然后整个进程崩掉。这些现象看起来八竿子打不着,真正把它们串起来的,正是程序运行时的内存分区。搞懂了五大内存分区——栈区、堆区、全局区(静态区)、常量区和代码区,你就掌握了一套从内存角度看程序行为的方法,很多疑难杂症都能一眼定位。这篇文章适合刚开始学 C/C++、正在复习操作系统知识、或者想系统补一下内存基础的同学,我会把每个分区的作用、生命周期、地址规律都讲清楚,最后用 Linux 下的实测命令带你验证一遍。
1. 程序还没运行,内存布局就已经被"刻"在文件里了
很多人一提到内存,脑子里全是物理内存条、地址总线这些东西,但实际上我们平时讨论的内存分区,指的是进程的虚拟地址空间。物理内存怎么分配是内核的事,程序员看到的是一个从 0 开始的、连续的逻辑地址空间。每个进程都以为自己独占整栋楼,实际上楼下是按需派发的。这里的关键是,这个"虚拟地址空间"并不是程序运行的时候才临时划分的,而是从可执行文件诞生那天起,布局就已经基本确定了。
1.1 可执行文件里被打包好的"房间图纸"
我们用 C 语言写一个程序,编译链接后生成的可执行文件,内部不是一锅粥,而是按"节"(section)组织的:代码编译成指令放在 .text,初始化过的全局变量放在 .data,未初始化的变量放在 .bss,只读常量放在 .rodata。链接器把这些节按固定顺序排好,加载器启动进程时,再把这些节映射到虚拟内存对应的段里。你可以把它理解成搬家:可执行文件是打包好的纸箱,纸箱上写着"衣服""书""厨房用品",到了新房子,工人按标记往不同房间搬。内存分区的"房间",早在装箱的时候就被设计好了。这个视角很重要——它不是后端的运行时行为,而是前端的编译链接产物。
正因为可执行文件里已经划分好了这些区域,程序运行时才能快速把对应内容加载到正确的位置。如果所有数据混在一起,加载器就无法给不同数据设置不同的权限,也无法有效组织启动过程。所以"分区"这件事,从编译期就开始了。
1.2 为什么非要拆成这么多区域,一个区不行吗
一个很自然的问题:直接把所有东西堆在一起不行吗?答案是不行。原因是不同的数据有完全不同的特性:代码只需要读,不需要写;全局变量需要在一开始就有确定的值或被清零;局部变量要求函数调用结束立即释放;字符串常量希望只读、还能被多个进程共享。如果全放一起,那整个内存区域就必须可写,代码可以被意外修改,安全隐患极大;也必须可读可执行,数据段被当成指令执行同样是灾难。所以现代系统的权限管理(Read、Write、eXecute)就是依赖分段来实现的。
对照一下你用的操作系统:代码段通常只读可执行,数据段可读可写但不可执行,栈和堆也是可读写不可执行。这样的隔离,不只是"整洁",更是安全性的基础。当一个进程试图往只读页面上写入数据时,CPU 的 MMU 会直接拦下来,触发缺页异常,操作系统再把这个异常转换成 SIGSEGV 发给进程。这也是很多 C/C++ 程序段错误的物理来源。把内存分区理解成"带权限的房间",很多运行时崩溃的原因就一目了然了。
2. 五大分区逐个讲:放什么、活多久、有多大
这一节是文章的核心。我会把五个分区一个个拆开讲,包括存放内容、生命周期、地址方向、大小限制,以及它们为什么这么设计。
2.1 栈区:自动分配、自动回收,随函数调用伸缩
栈区是函数调用最忠实的"账本"。每当一个函数被调用,系统就会在栈上分配一块空间,叫做栈帧(stack frame),用来存放局部变量、函数参数、返回地址、保存的寄存器值等等。函数返回时,这块空间直接作废。所以栈的根本特点就是:自动分配、自动回收,生命周期严格和函数绑定。C 语言里那些"普通局部变量",只要没有 static 修饰,就统统住在栈上。
栈的方向很有意思,在绝大部分常见平台上默认是向下生长的,也就是从高地址往低地址走。很多初学者第一次看到这个结论会愣一下,因为我们看书上的堆栈图,总感觉"堆在上、栈在下",其实它们的生长方向相反:栈向下,堆向上。为什么这么设计?最主流的解释是为了让堆和栈从虚拟地址空间的两端相向生长,中间留一大片空地给两者灵活占用,避免一开始就撞车。
栈的大小不是无限的。Linux 下用ulimit -s查看,默认通常是 8MB 左右。这个大小包括主线程栈和每个线程栈的总限制。所以无限递归最后不是"慢",而是直接栈溢出,操作系统会把进程杀掉,表现就是段错误。别觉得 8MB 很大,一个深度递归每次调用占用几百字节,就足以几万层直接干穿。我曾经排查过一个诡异崩溃,调用栈显示函数只嵌套了不到一百层,但其中有个函数内部定义了一个巨大的局部结构体数组,一次性就把栈余量吃光了。
2.2 堆区:手工管理的"后厨仓库",灵活但要负责
堆区也被叫做动态内存区,malloc、new 出来的对象都住在这里。堆和栈最大的区别是生命周期不受函数调用约束:你在一个函数里 malloc 出来的内存,函数返回后依然存在,必须由你自己管理——要么你在合适的地方 free/delete,要么就泄漏。C 语言中的 free、C++ 中的 delete、Rust 中的所有权释放,本质都是在决定"仓库里这块货什么时候报废"。
堆的分配不像栈那样简单地移动指针,它需要一套分配器来维护空闲块列表、处理碎片、合并相邻块。glibc 下通常是 ptmalloc2,Windows 上的堆管理器又是另一套实现,所以"堆开销比栈大得多"这句话不只是体感,它背后真的有复杂的算法和数据结构的代价。堆的大小限制理论上比栈大得多,但受物理内存、虚拟内存上限和碎片影响,实际并不等于无限。另外,现在的 malloc 实现中,大块内存往往通过 mmap 匿名映射获得,而不是传统上一直往上长的那块堆区。用cat /proc/PID/maps看的时候,你经常能看到一堆[heap]和一堆 mmap 区域,原因就在这里。
2.3 全局区(静态区):贯穿程序一生的"公共办公室"
全局区存放的是全局变量和 static 修饰的变量。它们的特点是:在程序启动时创建,程序结束时销毁,全程都在。生命周期比 main 函数还长,因为早在 main 执行之前,这些变量的空间就已经准备好了。很多语言里的"全局变量初始化顺序"问题,根源也是这个区域在 main 之前就被填充。
这个区内部其实是两半:已经初始化的变量放在 .data 段,未初始化的变量放在 .bss 段。bss 段有个非常讨喜的特性:程序启动时由内核将这些区域清零,所以未初始化的全局变量和静态变量天然是 0。为什么要把"有初始值"和"没有初始值"分开?因为可执行文件要保存在磁盘上,如果一个变量没有初始值,文件里就没必要为它腾出空间记录那一堆 0,只需要记一个"这块区域到时候要多大"。这能显著减小可执行文件体积,尤其是那些声明了但没有立即使用的全局数组。
static 关键字在这里有个很重要的作用:它把变量从"外部可见"变成"当前编译单元内部可见",但存储位置依然是全局区,生命周期依然贯穿全程。所以 static 局部变量和全局变量的差异,不在于生命周期——它们其实一样长,而在于名字的可见范围。很多人以为"static 局部变量比普通局部变量活得更久",这样说其实不够准确,它和全局变量一样长,区别只是名字只能在函数内部访问。
2.4 常量区:只读数据为什么值得单独占一块
常量区(严格说是只读数据段 .rodata)用来放字符串字面量、const 修饰的全局变量、编译期常量等。操作系统会把这块区域映射成只读,你一旦试图写它,硬件 MMU 会直接抛异常,程序收到 SIGSEGV 段错误。
单独分一个只读区的好处有三个:安全、共享、错误及早暴露。先说共享:多个进程运行同一个可执行文件时,常量区这样的只读页面可以被内核映射到多个进程的地址空间里,物理内存只保留一份。再说安全:代码区和常量区都只读,即使攻击者成功改写了栈或堆,也很难直接篡改只读数据制造更多破坏路径。最后,把"不应当修改的东西"放到只读区,等于让 CPU 和操作系统来当你的编译器,任何越界写都会立刻崩溃,而不是悄悄污染数据。
这里要澄清一个经典误区:不是所有 const 都一定在常量区。const 修饰的全局变量通常会被放到 .rodata,但 const 局部变量往往只是编译器层面上的"不容许通过这个名字修改",存储空间大概率还是在栈上,只是编译器不让你正常写入而已。真正决定地址放哪里的,是变量的存储期(storage duration)和链接器布局,不是 const 这个关键词本身。
2.5 代码区:指令住在哪里,为什么它可以共享
代码区(.text)存放编译后的机器指令。正常程序的指令是不需要修改的,所以这块区域被映射成只读可执行,目的是防止程序 bug 或恶意代码在运行期改写指令。在现代 CPU 上,指令缓存对顺序执行的优化也很依赖代码的局部性,所以代码区通常按页对齐、布局紧凑,方便预取和缓存。
函数地址在 C/C++ 里其实就落在代码区。你如果打印一个函数指针的值,会发现它通常处于整个进程地址空间非常低的地址段。这也是区分数据和代码的一个小技巧:打印地址,看它落在低地址还是高地址,大致能猜出它属于哪个区。
下面整理成一张总表,方便对照:
| 分区 | 存放内容 | 生命周期 | 地址方向/大小 | 典型权限 |
|---|---|---|---|---|
| 栈区 | 局部变量、函数参数、返回地址 | 函数调用期间 | 高地址向下生长,Linux 默认约 8MB | 可读写,不可执行 |
| 堆区 | 动态申请的内存 | malloc/free 之间 | 低地址向上生长,受虚拟内存限制 | 可读写,不可执行 |
| 全局区(静态区) | 全局变量、static 变量 | 程序启动到结束 | 低地址段,.data + .bss | 可读写,不可执行 |
| 常量区 | 字符串字面量、const 全局变量 | 程序启动到结束 | 低地址段,.rodata | 只读 |
| 代码区 | 机器指令、函数体 | 程序启动到结束 | 最低地址段,.text | 只读、可执行 |
3. 源码里的一行变量,编译器是怎么"安排房间"的
很多同学背下了五大分区,但一到写代码就不知道某一行变量到底会被放到哪里。这一节我们直接从源码出发,看编译器、链接器是怎么一步步把变量"送"到对应分区的。
3.1 从源代码到 section:谁来决定变量去哪
先看一段典型代码:
#include <stdio.h> #include <stdlib.h> int g_init = 42; // 初始化过的全局变量,去 .data int g_uninit; // 未初始化全局变量,去 .bss static int s_init = 10; // 初始化过的静态变量,也在 .data static int s_uninit; // 未初始化静态变量,在 .bss const int c_init = 100; // 全局 const,通常在 .rodata void func(void) { static int local_static = 7; // 局部 static,仍在 .data printf("%d\n", local_static); } int main(void) { int local = 0; // 栈区 int *p = malloc(sizeof(int)); // 堆区 char *str = "hello"; // 指针在栈上,字符串本体在 .rodata char arr[] = "hello"; // 数组在栈上,拷贝了一份字符串 printf("%p %p %p %p\n", &g_init, &g_uninit, &s_init, &s_uninit); printf("%p %p %p %p\n", &c_init, (void *)func, (void *)p, (void *)str); printf("%p %p\n", (void *)arr, &local); free(p); return 0; }在 Linux 上用 gcc 编译,再用size a.out看节分布,你会发现:g_init、s_init 在 .data,g_uninit、s_uninit 在 .bss,c_init 在 .rodata,func 在 .text,而 local、arr 的地址在运行时落在栈区,p 指向堆区,str 指向 .rodata。同一个源文件里,不同的变量因为这个简单的声明差异,被分到了完全不同的地方。
这里最容易绕晕的是字符串:char *str = "hello"和char arr[] = "hello"看起来都是存了一个字符串,但前者是让指针指向常量区里已有的 "hello",后者是在栈上开辟一个数组,把字符串逐字符拷贝进去。所以修改str[0]会段错误,而修改arr[0]完全合法。就这一行区别,几乎每年都能从线上崩溃日志里看到现场。
3.2 static、const、extern:存储期和可见性的博弈
static 修饰全局变量或函数,限制的是链接可见性,不是存储位置。static 修饰局部变量,改变的是存储期:从自动期变成静态期。这也解释了为什么 static 局部变量即使函数返回,下一次进入时值还在——它的"家"根本不在栈帧里,而在全局区。
const 修饰的是"可写性"。const 全局变量放在 .rodata,const 局部变量多数在栈上。所以 const 不是内存分区的决定因素,存储期才是。如果面试题问"const 变量存在哪",标准答案不是简单三个字"常量区",而应该分情况讨论。这对实际工程的影响是:你在一个大型项目里看到某个全局 const 被修改然后段错误,第一反应不是"const 被谁改了",而是"这个 const 被强制类型转换抛弃了类型修饰,尝试写入 .rodata 失败"。
extern 则只负责声明外部符号,它不改变存储期,也不改变分区。它更像是一张"通行证",告诉编译器:这个变量或函数在别的编译单元里定义,链接时再去找。所以 extern 变量本身的存储位置,取决于它在定义处有没有初始化、有没有 static,规则和前面完全一样。
3.3 链接期与运行期:为什么地址每次运行都可能变化
为什么我们 printf 打印指针时,每次运行地址都可能会变?现代操作系统默认开了 ASLR(地址空间布局随机化),加载器每次启动进程时给栈、堆、共享库一个随机化的基址偏移,但布局的相对顺序不变。这也是为什么初学者做实验时,会发现"第一次打印的地址比第二次高/低一大截",但栈一定在高地址、代码段一定在低地址。
理解这层随机化对调试很有用:它意味着你无法在崩溃日志里直接依赖绝对地址做长期判断,但可以通过相对位置和映射表来定位问题。比如一个空指针解引用和一次 .rodata 写入违例,虽然都会表现为段错误,但崩溃地址落在的区间完全不同。你只要把地址和 /proc/PID/maps 对照一下,问题方向马上就清楚了。
4. 上机实测:把五大分区从进程里"抓"出来
光看理论不过瘾,我建议你亲自在 Linux 上跑一遍下面的命令。整个实验只需要一个 C 编译器和一个终端,几分钟就能把五大分区的位置全部验证出来。
4.1 用 size 和 objdump 看还没有运行的分区分布
先把上面 3.1 的代码保存成 mem_layout.c,然后编译:
gcc -g -o mem_layout mem_layout.c size mem_layout objdump -h mem_layout | grep -E 'text|data|bss|rodata'size 的输出里会有 text、data、bss 三列,分别对应代码区、全局区中已初始化部分、全局区中未初始化部分。objdump 还能看到 .rodata 这个节。关键点是:栈和堆在文件里是看不到的,只有程序运行起来才会出现。这个"文件里有、内存里有;文件里没有、内存里也有"的细节,正好体现了栈和堆的临时性。
如果你的程序用了动态链接,运行期还会加载一堆共享库,那些库自身也有 .text、.data、.rodata。所以一个复杂进程的地址空间里,同一类分区会出现很多份,分布在不同的映射区域里,而不是只有一份。
4.2 运行期打印地址:地址高低是判断分区的最佳线索
再写一个更直白的程序,把所有关键地址打印出来:
#include <stdio.h> #include <stdlib.h> int global_init = 1; int global_uninit; const char *const_str = "constant string"; int main(void) { static int local_static = 2; int stack_var = 3; char stack_arr[16]; int *heap_var = malloc(sizeof(int)); char *lit = "Hello from rodata"; printf("code : %p\n", (void *)main); printf("rodata : %p\n", (const void *)const_str); printf("data : %p\n", (void *)&global_init); printf("bss : %p\n", (void *)&global_uninit); printf("static local : %p\n", (void *)&local_static); printf("stack var : %p\n", (void *)&stack_var); printf("stack arr : %p\n", (void *)stack_arr); printf("heap : %p\n", (void *)heap_var); printf("literal : %p\n", (const void *)lit); free(heap_var); return 0; }编译运行后,地址从低到高大致会呈现这样的顺序:代码区 main、常量区、全局区(.data/.bss)、堆区、栈区。栈区地址最高,堆区在全局区之上的某个位置。你连续打印 stack_var 和 stack_arr,会发现后定义的局部变量地址更小,因为栈是向下生长的。堆则相反,连续 malloc 几次,地址通常递增,因为堆向上生长。
这个实验最好多跑几次。由于 ASLR,每次的绝对地址都不同,但"栈最高、代码段最低、堆在中间偏下"的相对规律不会变。你在面试里讲到"栈向下生长、堆向上生长"的时候,能说出自己验证过,会比单纯背书可信得多。
4.3 打开 /proc/PID/maps,把整个虚拟地址空间摊开看
Linux 下最完整、最权威的答案是查进程的内存映射表:
./a.out & pid=$! cat /proc/$pid/mapsmaps 文件里每一行是一个内存映射区域。你会在底部看到可执行文件名对应的映射:权限r-xp的是代码段,r--p的是只读数据(.rodata 等),rw-p的是数据段和 bss 段。再往高地址走,会看到[heap]和[stack],以及一大堆共享库的映射区域。
这个命令是排查内存类问题的神器。比如程序崩溃了,内核日志里有一个崩溃地址,你想知道它到底落在哪段,拿 maps 对照一下就清楚了。我曾经遇到过一个非常隐蔽的 bug:程序在 free 时崩溃,用 maps 一看,指针地址落在了堆区范围之外,说明它根本不是 malloc 分配出来的,而是被某个越界写把指针内容给覆盖了。这种问题如果不用 maps 定位,单看堆栈信息很容易误判。
5. 分区没学好,这些 Bug 迟早找上你
前四节讲的是"正确布局",这一节讲的是"错误用法"。我在实际工作中见过的内存类故障,绝大多数都能归类到下面四种,每一个都和五大分区直接相关。
5.1 修改字符串字面量:一次段错误帮你记住 .rodata
最常见的新手失控现场:
char *p = "hello"; p[0] = 'H';这在 C 语言里不是语法错误,编译时可能连警告都没有,但运行时会段错误。因为字符串字面量在 .rodata 里,处理器尝试写入只读页面时直接产生保护异常。排查时记住一个口诀:栈和堆是"读写法",常量区是"只读法"。以后看到段错误,第一反应就是看崩溃地址落在哪个映射区域。如果是 .rodata,马上检查有没有人通过非 const 指针去写字符串或全局常量。
我见过最离奇的版本是:一个字符串指针在函数间传了好几层,最后在一个完全不相关的地方被修改,触发崩溃。从崩溃窗口看,修改代码和被修改的字符串隔了十万八千里,但地址一查就知道,它写到了 .rodata,问题就从"很多人传参"收窄成了"有人放弃了 const 限定"。这个排查过程非常有代表性。
5.2 返回局部变量地址:栈帧销毁后的"幽灵数据"
int *bad(void) { int x = 42; return &x; }函数返回后,x 所在的内存并没有真的消失——栈顶只是下移,那块地方仍然存在,但可能立刻被下一次函数调用覆盖。所以 bad() 返回的指针可能有时好用、有时乱码。在 Release 模式下,编译器可能把那块栈空间复用来做完全无关的事,表现成"一会儿对一会儿错"。这就是生命周期没搞清导致的典型悬垂指针问题。
有人会想:既然内存还在,我临时用一下行不行?不行。归还给系统的内存,你再用就是未定义行为,可能这次没事,但下一次函数调用就把它踩了。更麻烦的是,这类 bug 通常在出问题时,崩溃现场离错误源头已经很远,因为栈顶已经不知道被多少函数来回使用过。排查这类问题,最有效的方法是先怀疑所有返回栈地址的接口,再用 AddressSanitizer 编译一遍,立刻就能定位。
5.3 栈溢出与堆越界:两个方向上的"内存越狱"
无限递归会耗尽栈空间,这个不难理解。难的是递归深度不大但每次栈帧很大,比如一个函数里定义了一个 8MB 的局部数组,一个普通调用直接把栈干爆。栈溢出在 Linux 上的常见表现是 Segmentation Fault,而不是精确的 "stack overflow" 字样,调试时容易被当成空指针问题。我的排查习惯是:先看崩溃地址是否接近栈区底部,再看函数调用栈是不是异常深。
堆越界是另一个高发事故。malloc 分配的内存前后通常带有分配器元数据,你的越界写如果刚好越过用户区写进元数据,等到 free 时,分配器校验元数据发现不一致,会报free(): invalid pointer或直接中止。这类问题报错地点和真正写错的地方通常隔了很远,排查要靠 AddressSanitizer 这类工具。编译时加上-fsanitize=address,问题行为会立刻暴露,精确到文件和行号,比在那猜快得多。
5.4 面试题里的"五大分区"到底在考什么
很多笔试题会有一道:写出 C 程序的五大内存分区并说明 stack 和 heap 的区别。如果你只背了"栈存局部变量,堆存 malloc",明显不够。常考点包括:全局变量和 static 变量在哪、未初始化全局变量为什么默认为 0、字符串字面量修改会怎样、函数指针地址在哪、栈为什么向下生长。这些我们前面都已经覆盖到。
面试时如果能把"可执行文件里的节、运行时的段、mmap 映射权限"这条线串起来讲,就已经超过大多数只背答案的候选人了。比如你可以说:未初始化全局变量进 .bss,是因为可执行文件不需要为 0 值占磁盘空间,程序加载时内核直接按页清零,本质上是一种惰性初始化。这个回答能体现你是真的理解,而不是背概念。
最后分享一个我自己的排查习惯。遇到程序崩溃,先用dmesg | tail看内核日志里的崩溃地址,或者用addr2line把地址落到代码行,然后用 /proc/PID/maps 确认这个地址落在哪个分区。如果地址落在 .rodata,先怀疑字符串字面量被写;如果落在栈边界附近,先查递归深度和超大局部数组;如果落在堆的异常位置,大概率是越界写。这个顺序帮我节省过大量调试时间,比你拿着 gdb 一遍遍跑要快得多。内存分区这个知识点,背下来只要十分钟,但真正形成"看到地址就知道问题在哪"的直觉,需要你亲手跑几次实验、踩几次坑。