第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释放。它的生命周期完全由你控制。

堆内存的生命周期分三个阶段:

  1. 分配期:调用malloc/calloc/realloc,从堆中划出一块连续内存
  2. 使用期:这块内存归你所有,可以读写
  3. 释放期:调用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 一张图看懂内存分区

下面这张图,是我自己画的内存分区布局。你把它记在脑子里,写代码时心里就有数了。

程序内存分区布局 栈区(Stack) 局部变量、函数参数、返回地址 | 向下增长 堆区(Heap) 动态分配内存 | 向上增长 全局区(静态区) 全局变量、static变量 常量区 代码区(Text) 程序指令(二进制机器码) 高地址 低地址

注意看,栈和堆是相向而长的。栈从高地址往低地址长,堆从低地址往高地址长。如果它们撞上了,那就是栈溢出或者堆溢出——程序就挂了。

9.7 避坑指南

最后,我把自己这些年踩过的坑总结一下:

  • 栈上别放大数组:局部数组超过几百字节,就考虑用堆或者static。我曾经在中断里放了个1KB的数组,系统直接死给你看。
  • malloc后一定要检查返回值:嵌入式系统里内存紧张,malloc可能失败。不检查就直接用,解引用空指针,程序必崩。
  • free后一定要置空:不置空的话,那个指针就成了野指针。万一后面不小心又用了,那就是未定义行为。
  • 别返回局部变量的地址:函数返回后,栈帧就释放了。你拿着那个地址去访问,读到的是垃圾数据。
  • 常量区的内容别试图修改:字符串常量、const全局变量,都是只读的。想改?程序直接段错误。

我曾经犯过的错:在一个通信协议栈里,我用了一个全局缓冲区来存数据。结果多个任务同时读写,数据全乱了。后来改成每个任务自己malloc一块内存,用完释放,问题才解决。全局区虽方便,但并发访问时要加锁。

好了,这章的内容就到这。内存分区这东西,说白了就是一张地图——你知道每个变量、每块内存该待在哪,写代码时心里就有底了。下一章我们聊聊指针的算术运算,那又是另一个有意思的话题。


公众号:蓝海资料掘金营,微信deep3321