3、栈内存管理:栈帧结构、局部变量生命周期、栈溢出检测与预防

栈这个东西,说白了就是程序运行时的「临时工作台」。每次你调用一个函数,系统就会在栈上给你划一块区域,用来放局部变量、函数参数、返回地址这些。函数一返回,这块区域就自动回收了。听起来挺省心,对吧?但我在嵌入式项目里见过太多次,就是这块「省心」的区域,把整个系统搞崩了。

3.1 栈帧结构:函数调用时发生了什么?

每次调用函数,CPU 都会在栈上「压入」一个栈帧(Stack Frame)。这个栈帧里装了什么?我画个简化的结构给你看:

高地址
+-----------------+
|   函数参数       |  (如果有的话)
+-----------------+
|   返回地址       |  (调用者下一条指令的地址)
+-----------------+
|   保存的寄存器   |  (比如 R4-R11,ARM 下)
+-----------------+
|   局部变量       |  (你的 int a, char buf[64] 都在这里)
+-----------------+
|   临时空间       |  (编译器优化用的)
+-----------------+
低地址  ← 栈指针(SP)

嗯,这里要注意:栈是向下生长的。也就是说,每次 push 数据,栈指针(SP)的地址值会减小。我刚开始学的时候总搞反,后来在调试器里看内存地址才彻底明白。

每个函数都有自己的栈帧。函数嵌套调用时,栈帧就像叠盘子一样,一层层往上叠。返回的时候,再一层层弹出来。这就是所谓的「后进先出」(LIFO)。

3.2 局部变量的生命周期:别拿地址往外传

局部变量的生命周期,严格绑定在它所在的函数执行期间。函数一返回,局部变量占用的栈空间就被释放了。听起来很简单,但坑就在这里。

我曾经在项目里见过这样的代码:

int* get_value(void) {
    int local = 42;
    return &local;  // 危险!返回了局部变量的地址
}

void caller(void) {
    int* p = get_value();
    // p 指向的地址已经无效了!
    // 后续使用 *p 是未定义行为
}

这个 local 变量,在 get_value 返回后,它所在的栈空间就被回收了。但 p 还拿着那个地址。下次再调用别的函数,那个地址很可能被覆盖。你读到的值,可能是垃圾数据,也可能是别的函数的局部变量。这种 bug 非常难查,因为它不是每次都必现的。

警告:永远不要返回局部变量的地址或引用。如果你需要返回一个指针,要么用静态变量,要么用堆内存(malloc),要么让调用者传入缓冲区。

局部变量的生命周期还有另一个特点:每次进入函数,局部变量都会被重新「创建」。注意,这个创建不一定是初始化。比如:

void example(void) {
    int counter;  // 未初始化!
    counter++;
    printf("%d\n", counter);
}

每次调用这个函数,counter 的值都是栈上残留的随机值。你想想看,第一次调用可能是 0,第二次可能是 0xDEADBEEF。这种问题在嵌入式里特别隐蔽,因为栈上的初始值取决于之前的函数调用历史。

个人建议:所有局部变量在声明时立即初始化。哪怕初始化为 0,也比随机值安全得多。我习惯写成 int counter = 0;,多敲几个字符,少熬几个通宵。

3.3 栈溢出:嵌入式系统的头号杀手

栈溢出,就是函数调用层数太多,或者局部变量太大,导致栈空间不够用了。栈溢出的后果是什么?轻则数据被覆盖,程序行为异常;重则直接跑飞,看门狗复位。

我在一个物联网项目里遇到过:产品在实验室跑得好好的,一部署到现场就随机死机。查了三天,最后发现是某个中断服务函数里定义了一个 2KB 的局部数组。而那个平台的总栈空间才 4KB。中断一嵌套,栈直接爆了。

3.3.1 栈溢出的常见原因

  • 递归调用过深:嵌入式里我基本不用递归。每次递归都要压栈,深度一上去,栈就扛不住了。
  • 局部变量太大:比如在函数里定义 char buf[4096],而栈总共才 8KB。一个函数吃掉一半,其他函数怎么办?
  • 中断嵌套:主程序用了一部分栈,中断来了又用一部分。如果中断里再开中断,嵌套个三四层,栈很容易爆。
  • 函数调用链过长:A 调 B,B 调 C,C 调 D……每个函数都消耗一些栈,链子一长,总和就大了。

3.3.2 栈溢出检测方法

检测栈溢出,我常用的方法有几种:

方法一:栈填充(Stack Canary / 栈哨兵)

在系统初始化时,把整个栈空间填充成一个已知模式,比如 0xAA。然后定期检查栈的「水位线」,看看哪些区域被覆盖了。如果发现填充模式被破坏,说明栈已经用到了那个位置。

// 栈填充示例(伪代码)
#define STACK_SIZE  4096
#define STACK_PATTERN 0xAA

uint8_t stack[STACK_SIZE];

void stack_init(void) {
    memset(stack, STACK_PATTERN, STACK_SIZE);
}

uint32_t stack_used(void) {
    uint32_t count = 0;
    for (uint32_t i = 0; i < STACK_SIZE; i++) {
        if (stack[i] != STACK_PATTERN) {
            count++;
        }
    }
    return count;
}

void stack_check(void) {
    uint32_t used = stack_used();
    if (used > STACK_SIZE * 0.8) {
        // 栈使用超过 80%,报警!
        system_alert("Stack usage high!");
    }
}

这个方法我一般在调试阶段用。发布版本可以去掉,或者只保留一个粗略的检查。

方法二:硬件栈溢出检测

有些 MCU 有 MPU(内存保护单元)或者硬件栈指针监控功能。你可以设置一个栈底保护区,一旦 SP 指针进入保护区,就触发异常。比如在 Cortex-M 系列上,可以设置 MPU 把栈底附近的区域标记为不可访问。

// 伪代码:利用 MPU 检测栈溢出
void mpu_stack_guard_config(void) {
    // 假设栈底在 0x20001000,栈大小 4KB
    // 在栈底下方设置一个 32 字节的保护区
    MPU->RNR  = 0;                    // 区域编号
    MPU->RBAR = 0x20000FE0;           // 保护区基地址
    MPU->RASR = (0x05 << 1) |         // 大小:32 字节
                (0x00 << 24) |        // 禁止访问
                MPU_RASR_ENABLE;
}

一旦程序试图访问这个保护区(比如栈溢出写到了那里),CPU 会立刻触发 MemManage Fault。嗯,这比软件检查可靠多了。

方法三:编译器内置检查

GCC 有 -fstack-protector-fstack-protector-strong 选项。开启后,编译器会在栈帧里插入一个 canary 值(随机数),函数返回前检查这个值是否被修改。如果变了,说明栈被破坏了。

// 编译时加上 -fstack-protector-strong
// 编译器会自动生成类似这样的代码:
void vulnerable_func(void) {
    char buf[64];
    // 编译器在栈上放了一个 canary
    // 函数返回前检查 canary 是否完好
    gets(buf);  // 如果这里溢出,canary 会被覆盖
    // 返回时触发 __stack_chk_fail()
}
核心要点:栈溢出检测不是可选项,而是嵌入式系统的必需品。我个人的习惯是:调试阶段用栈填充法 + MPU 硬件保护,发布版本至少保留 MPU 保护或编译器 canary 检查。

3.4 栈溢出预防:从设计上杜绝问题

检测是事后补救,预防才是根本。我总结了几个实战经验:

3.4.1 合理规划栈大小

不要拍脑袋定栈大小。我建议用以下方法估算:

  1. 列出所有可能同时存在的函数调用路径(包括中断嵌套)。
  2. 对每个函数,估算它的栈帧大小(局部变量 + 保存的寄存器 + 参数)。
  3. 找出最深的调用路径,把所有栈帧大小加起来。
  4. 加上中断嵌套的额外开销(通常每个中断预留 200-500 字节)。
  5. 最后乘以 1.5 的安全系数。

比如:

调用路径 函数 栈帧大小(字节)
主循环 main() 128
→ 任务处理 task_handler() 64
→ 协议解析 protocol_parse() 256
→ 数据打包 pack_data() 512
中断 UART_IRQHandler() 200
中断嵌套 TIM_IRQHandler() 200
总计(主路径 + 中断嵌套) 128+64+256+512+200+200 = 1360
建议栈大小(×1.5) 2040 ≈ 2KB

3.4.2 避免大局部变量

超过几百字节的局部变量,我建议改用静态分配或者堆分配。比如:

// 不推荐:大局部变量
void process_data(void) {
    uint8_t buffer[2048];  // 2KB 在栈上,太危险了
    // ...
}

// 推荐:静态分配
static uint8_t buffer[2048];
void process_data(void) {
    // 使用静态 buffer
}

// 或者:动态分配
void process_data(void) {
    uint8_t* buffer = (uint8_t*)malloc(2048);
    if (buffer != NULL) {
        // 使用 buffer
        free(buffer);
    }
}

3.4.3 控制递归深度

嵌入式里我基本不用递归。如果非要用,必须加深度限制:

#define MAX_RECURSION_DEPTH 10

static int recursion_depth = 0;

void safe_recursive_func(void) {
    if (recursion_depth >= MAX_RECURSION_DEPTH) {
        // 达到最大深度,报错返回
        error_handler("Recursion depth exceeded");
        return;
    }
    recursion_depth++;
    // 递归逻辑
    safe_recursive_func();
    recursion_depth--;
}

3.4.4 中断栈与主栈分离

在支持双栈模式的 MCU 上(比如 Cortex-M3/4),我强烈建议把中断栈和主任务栈分开。这样中断不会吃掉主任务的栈空间,大大降低溢出风险。

// 在启动文件中配置
// 主栈大小:4KB
// 中断栈大小:1KB
__attribute__((section(".stack")))
uint8_t main_stack[4096];

__attribute__((section(".stack_irq")))
uint8_t irq_stack[1024];
避坑指南:我曾经在一个项目中,把中断栈和主栈混在一起用。结果主程序栈快满了,一个中断进来,直接踩到了别的全局变量上。那个 bug 查了我整整一周。从那以后,我所有新项目都强制分离中断栈。

3.5 总结

栈内存管理,说白了就是三件事:

  • 理解栈帧结构——知道你的局部变量放在哪,怎么放。
  • 管好局部变量生命周期——别拿地址往外传,记得初始化。
  • 防住栈溢出——检测手段要有,预防措施更要到位。

嗯,栈这东西,平时看不见摸不着,一出问题就是大问题。我见过太多项目,功能逻辑写得漂漂亮亮,最后栽在栈溢出上。希望今天讲的这些,能帮你少踩几个坑。

下一章,我们会聊堆内存管理。那又是另一片「血雨腥风」的战场了。