C 程序里的每个变量都存放在某个地方,而存放的位置决定了两件事后无法更改的事情:它能存活多久,以及你能用多少。C 提供了三块存储区域,选错了不是崩溃就是内存泄漏。本页逐一介绍这三块区域,并展示生命周期搞错时出现的经典 bug。
三块区域
high addresses
+---------------------------+
| stack | locals, parameters, return addresses
| grows downward | | freed automatically on return
| v |
+---------------------------+
| (unused gap) |
+---------------------------+
| ^ |
| grows upward | |
| heap | malloc / calloc / realloc blocks
+---------------------------+ freed only by free()
| static / global data | globals and statics, whole run
+---------------------------+
| code (text) | the machine code, read-only
+---------------------------+
low addresses
- 自动存储(栈)存放函数参数和非 static 的局部变量。进入函数时占用一块栈空间,函数返回时释放。大小在编译时就已确定。
- 动态存储(堆)存放
malloc、calloc和realloc分配的所有内存。大小在运行时决定,生命周期只在free时结束。 - 静态存储存放全局变量和所有声明为
static的变量。它在程序整个运行期间都存在,并在main开始前被初始化为零。
图中的地址是常见的排列方式,并不是保证。C 标准规定的是生命周期,而不是内存布局。
自动存储的实际表现
每次调用 demo 都会得到全新的 local 和 table。不需要手动释放,也不可能泄漏,分配只需要一条移动栈指针的指令。这就是为什么普通局部变量应该是你的默认选择:它们是 C 中最快、最安全的存储方式。
问题出在右花括号。一旦执行到那里,这块内存就没了。
悬空指针
下面这个 bug,每个 C 程序员都写过一次:
/* BROKEN: returns the address of memory that no longer exists */
int *make_counter(void) {
int count = 0;
return &count; /* count dies at this brace */
}
int main(void) {
int *p = make_counter();
*p = 5; /* writing into a dead stack frame */
return 0;
}
make_counter 运行期间,&count 是完全有效的地址。函数返回后,那块栈空间会交给接下来被调用的函数,于是 p 指向了别人的局部变量。读取得到垃圾值,写入会破坏它们。GCC 和 Clang 会对这种写法给出警告(-Wreturn-local-addr),所以编译时要打开警告。
同样的 bug 换成数组就会改头换面,而且这时警告往往不会出现:
这个函数的错误版本会在局部变量 char buf[64] 里拼出文本,然后 return buf;,返回的是一个在同一时刻就不复存在的缓冲区的地址。
三种修复方法
1. 由调用者提供缓冲区(如上所示)。不需要分配内存,不存在所有权问题,也是 C 库中最常见的写法。函数接收缓冲区大小,这样就不会写出界。
2. 返回堆内存,并说明由谁释放。
堆内存块按设计就会比函数活得更久,这正是动态内存的意义所在。代价是那条说明所有权的注释,以及调用者的 free。
3. 使用静态存储,前提是可以接受一个共享的缓冲区:
函数内的 static 让变量的作用域保持局部,同时拥有整个程序的生命周期,所以返回它的地址是合法的。代价是它永远只有一份:所有调用者共享它,这让这种写法无法用于多线程代码,即使在单线程代码中,当两个调用者同时持有这个指针时也会出人意料。
大小:栈在哪里用完
栈空间小而且固定。主线程在 Windows 上通常有 1 MB,在 Linux 上有 8 MB,新创建的线程往往少得多。堆的上限是系统的可用内存。
void bad(void) {
int huge[1000000]; /* ~4 MB of stack - likely crashes on entry */
huge[0] = 1;
}
没有任何提示,也没有 NULL 可以检查:程序在函数体第一行执行之前就直接终止了,通常是段错误。堆上的版本则能正常报告失败:
深度递归也会以同样的方式耗尽栈,一次消耗一个栈帧。失控的递归函数是实际中最常见的栈溢出原因。
开销与局部性
栈分配只是对一个寄存器做一次算术运算。堆分配则是一次库函数调用:要查找合适的内存块,可能要加锁,偶尔还要向操作系统申请更多内存。在频繁执行的循环中,这个差别是可以测出来的。
栈上的数据也更紧凑,而且刚被访问过,所以往往就在缓存里。堆内存块则可能分散各处。这两点都不应该单独左右设计,生命周期的正确性永远排在第一位。但在两个都正确的设计之间,用栈的那个通常更快。
观察这几块区域
打印地址能让内存布局变得具体。每次运行的具体值都不同(现代系统会随机化地址),但分组是看得出来的:
全局变量和静态变量挨在一起,堆内存块在别处,局部变量通常离这两者都很远。%p 要转换成 void *,这是该格式说明符的要求。
怎么选择
以下情况用栈:
- 大小在编译时已知,
- 数据只在这个函数及其调用的函数中使用,
- 而且数据很小,几 KB,而不是几 MB。
以下情况用堆:
- 大小取决于输入、文件或计算结果,
- 数据必须比创建它的函数活得更久,
- 或者数据大到可能突破栈的限制。
以下情况用静态存储:
- 整个程序中只应该存在一个实例,
- 并且让所有调用者共享它确实是正确的。
默认选择栈。只有符合上面三个理由之一时才使用堆,用的时候要遵循内存泄漏一页中的所有权规则,确保你为了延长生命周期而申请的内存块最终仍会被释放。
两个互为镜像的 bug
这两个 bug 值得放在一起讲,因为它们是同一个生命周期问题的两种错误答案:
- 悬空指针:内存比指针先死。返回
&local,或者在free之后继续使用指针。程序读写的存储已经属于别的东西了。 - 内存泄漏:指针比内存先死。丢失了指向某个
malloc内存块的最后一个引用。不会立刻出问题,只是进程占用的内存越来越大。
两者都源于数据需要存活的时间和你把它放在的区域不匹配。先确定生命周期,区域自然就定了。
常见问题
C 语言中栈和堆有什么区别?
栈存放局部变量:大小由编译器确定,进入函数时创建,函数返回时销毁,分配几乎没有开销。堆存放 malloc 分配的内存块:大小在运行时由你决定,内存块一直存在,直到你调用 free,而且分配有实际的开销。
C 语言中为什么不能返回局部变量的指针?
因为函数一返回,局部变量的存储就被释放了。指针仍然保存着那个地址,但那块内存已经归下一次函数调用使用:读它得到垃圾值,写它会破坏无关的数据。这就是悬空指针。应该返回 malloc 分配的内存块,或者让调用者提供缓冲区。
C 语言的栈有多大?
主线程通常是 1 到 8 MB,其他线程要小得多。小到把 int big[1000000]; 声明为局部变量,程序通常一进入函数就会崩溃。堆受系统可用内存限制,所以大块数据或大小未知的数据应该放在堆上。
C 语言中什么时候应该用堆而不是栈?
三种情况:大小要到运行时才知道,数据必须比创建它的函数活得更久,或者内存块太大,栈放不下(大致是超过几百 KB 的数据)。其他情况都应该用普通的局部变量:更快,而且不可能泄漏。