Menu

C 语言栈和堆的区别:生命周期、悬空指针与怎么选

数据到底存放在哪里:栈上的自动存储、堆上的动态存储,以及第三块区域静态存储。附带返回局部变量地址导致的经典悬空指针 bug,和一条选择规则。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

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 的数据)。其他情况都应该用普通的局部变量:更快,而且不可能泄漏。

Coddy 编程语言插图

和 Coddy 一起学编程

开始