第9章 堆与栈:程序内存分区
说实话,我见过不少写了三五年C语言的工程师,一聊到内存分区还是模模糊糊的。他们能写出能跑的代码,但一旦遇到栈溢出、内存泄漏这种问题,就开始抓瞎了。嗯,这章我们就来把这层窗户纸捅破。
9.1 程序的内存分区
一个C程序跑起来之后,它在内存里是怎么布局的?我习惯把这五个区记在脑子里:栈区、堆区、全局区(静态区)、常量区、代码区。你想想看,这五个区各管各的事,谁也不越界。
五个内存分区速览:
| 分区 | 存储内容 | 生命周期 |
|---|---|---|
| 栈区 | 局部变量、函数参数、返回地址 | 函数调用期间 |
| 堆区 | 动态分配的内存(malloc/calloc) | 从分配直到free |
| 全局区 | 全局变量、static变量 | 整个程序运行期 |
| 常量区 | 字符串常量、const常量 | 整个程序运行期 |
| 代码区 | 程序指令(二进制机器码) | 整个程序运行期 |
我在项目中遇到过一个问题:一个嵌入式设备跑着跑着就重启了。查了半天,发现是栈溢出了——一个递归函数没控制好深度,直接把栈给撑爆了。从那以后,我对栈的大小就特别敏感。
9.2 栈与栈帧
栈,说白了就是一块后进先出的内存区域。每次调用一个函数,系统就会在栈上给它分配一块空间,这块空间就叫栈帧。
一个栈帧里装了什么?我列给你看:
- 函数的局部变量
- 函数的参数(如果参数不多,通常放寄存器里,多了才压栈)
- 返回地址(函数执行完后该回到哪里)
- 保存的寄存器值(调用者需要恢复的现场)
为什么会这样设计?因为函数调用天然就是嵌套的——A调B,B调C,C返回后B继续,B返回后A继续。栈这种结构,完美匹配这个场景。
我的一个习惯:在嵌入式开发中,我会在启动文件里把栈大小设成实际需要的1.5倍。别问我为什么,问就是吃过亏。
// 看看栈帧是怎么工作的
void func_b() {
int b_local = 20; // 存在栈帧里
printf("b_local = %d\n", b_local);
}
void func_a() {
int a_local = 10; // 存在栈帧里
func_b(); // 调用func_b,压入新栈帧
// 回到这里时,func_b的栈帧已经弹出
printf("a_local = %d\n", a_local);
}
int main() {
func_a();
return 0;
}
每次调用func_b,系统就在栈顶分配一块新空间。func_b返回后,这块空间自动回收。你不需要手动释放,这就是栈的方便之处。但方便归方便,栈的大小是有限的——在嵌入式系统里,通常只有几KB到几十KB。
我曾经踩过的坑:在一个STM32项目里,我在一个中断服务函数中定义了一个512字节的局部数组。结果中断一触发,栈直接溢出,系统死机。后来我才意识到,中断的栈和主程序的栈是共享的,大数组一定要用静态分配或者堆分配。
9.3 堆内存的生命周期
堆就不一样了。堆是程序员自己管的地盘。你用malloc申请一块内存,用完了用free释放。它的生命周期完全由你控制。
堆内存的生命周期分三个阶段:
- 分配期:调用malloc/calloc/realloc,从堆中划出一块连续内存
- 使用期:这块内存归你所有,可以读写
- 释放期:调用free,把内存还给堆
说白了,堆内存就像你租的房子——你签了合同(malloc),住进去(使用),到期退租(free)。如果你忘了退租,那就是内存泄漏。
#include <stdlib.h>
void heap_demo() {
// 分配期
int *p = (int *)malloc(10 * sizeof(int));
if (p == NULL) {
// 分配失败的处理
return;
}
// 使用期
for (int i = 0; i < 10; i++) {
p[i] = i * 2;
}
// 释放期
free(p);
p = NULL; // 我习惯置空,防止野指针
}
堆和栈的核心区别:
| 对比项 | 栈 | 堆 |
|---|---|---|
| 分配方式 | 自动分配、自动释放 | 手动分配、手动释放 |
| 速度 | 快(一条指令) | 慢(需要查找空闲块) |
| 大小 | 小(KB级别) | 大(MB甚至GB级别) |
| 碎片问题 | 无 | 有(频繁分配释放会产生碎片) |
| 使用场景 | 小数据、确定生命周期 | 大数据、不确定生命周期 |
9.4 全局区与常量区
全局区存的是全局变量和static变量。这些变量在程序启动时就分配好,直到程序结束才释放。你想想看,这意味着什么?意味着所有函数都能访问它们,但也意味着你要小心多线程竞争。
常量区存的是字符串常量和const修饰的全局变量。这块区域通常是只读的。如果你试图修改它,程序会崩溃——嗯,我在刚学C语言时就干过这种事。
#include <stdio.h>
int global_var = 100; // 全局区
static int static_var = 200; // 全局区(但只在本文件可见)
const int const_var = 300; // 常量区
void demo() {
static int local_static = 400; // 全局区(但作用域在函数内)
char *str = "hello"; // "hello"在常量区,str在栈上
// 下面这行会崩溃
// str[0] = 'H'; // 试图修改常量区
}
我的建议:能用static就用static,别动不动就搞全局变量。static至少限制了作用域,出了这个文件就访问不到,能减少不少命名冲突。
9.5 代码区
代码区存的是程序的二进制指令。这块区域通常是只读的,防止程序意外修改自己的指令。在嵌入式系统里,代码区往往放在Flash里,而不是RAM里。
我记得有一次调试一个Bootloader,发现程序跑飞了。后来用调试器一看,是代码区的地址被某个野指针给覆盖了——虽然理论上代码区是只读的,但在某些MCU上,如果你不配置MPU(内存保护单元),还是能写进去的。从那以后,我每次做项目都会检查MPU配置。
9.6 一张图看懂内存分区
下面这张图,是我自己画的内存分区布局。你把它记在脑子里,写代码时心里就有数了。
注意看,栈和堆是相向而长的。栈从高地址往低地址长,堆从低地址往高地址长。如果它们撞上了,那就是栈溢出或者堆溢出——程序就挂了。
9.7 避坑指南
最后,我把自己这些年踩过的坑总结一下:
- 栈上别放大数组:局部数组超过几百字节,就考虑用堆或者static。我曾经在中断里放了个1KB的数组,系统直接死给你看。
- malloc后一定要检查返回值:嵌入式系统里内存紧张,malloc可能失败。不检查就直接用,解引用空指针,程序必崩。
- free后一定要置空:不置空的话,那个指针就成了野指针。万一后面不小心又用了,那就是未定义行为。
- 别返回局部变量的地址:函数返回后,栈帧就释放了。你拿着那个地址去访问,读到的是垃圾数据。
- 常量区的内容别试图修改:字符串常量、const全局变量,都是只读的。想改?程序直接段错误。
我曾经犯过的错:在一个通信协议栈里,我用了一个全局缓冲区来存数据。结果多个任务同时读写,数据全乱了。后来改成每个任务自己malloc一块内存,用完释放,问题才解决。全局区虽方便,但并发访问时要加锁。
好了,这章的内容就到这。内存分区这东西,说白了就是一张地图——你知道每个变量、每块内存该待在哪,写代码时心里就有底了。下一章我们聊聊指针的算术运算,那又是另一个有意思的话题。
公众号:蓝海资料掘金营,微信deep3321