第2章:堆栈工作原理

堆栈这东西,说白了就是一块内存区域,专门用来临时存放数据。我在做RTOS内核开发的头几年,对堆栈的理解其实挺肤浅的——不就是压栈出栈嘛,有什么好研究的?直到有一次,一个任务莫名其妙地跑飞了,我花了整整三天才定位到问题,原来是堆栈溢出把返回地址给覆盖了。从那以后,我对堆栈的敬畏之心就上来了。

2.1 堆栈的数据结构

堆栈在内存中是怎么组织的?其实就是一个连续的内存块,加上一个指针。这个指针就是堆栈指针(SP),它指向栈顶。

我个人习惯把堆栈想象成一个子弹夹。你想想看,子弹一颗颗压进去,最后一颗压进去的子弹最先被射出去。堆栈的操作方式完全一样——数据压进去,最后压进去的数据最先被取出来。

堆栈的物理结构:

  • 栈底(Bottom):堆栈的起始地址,固定不变
  • 栈顶(Top):当前可操作的位置,由SP指向
  • 栈空间:栈底到栈顶之间的内存区域

这里有个关键点:堆栈的地址增长方向。不同的处理器架构,方向可能不同。ARM Cortex-M系列是向下生长的,也就是压栈时SP递减。x86架构也是向下生长。但有些处理器是向上生长的。嗯,这个细节很容易被忽略,我在移植RTOS时就踩过这个坑。

2.2 LIFO原则

LIFO,全称是Last In First Out,后进先出。这是堆栈最核心的原则。

为什么会这样?因为堆栈的设计初衷就是支持函数调用和中断处理。你想想看,函数A调用函数B,B调用C。C返回时,必须回到B;B返回时,必须回到A。这不就是典型的后进先出吗?

我曾经在给一个学生讲课时,他问我:「能不能用FIFO(先进先出)来实现函数调用?」我当时就笑了,说:「你试试看,A调用B,B还没返回呢,C又来了,结果C返回时跳到了A...那程序不乱套才怪。」

LIFO的典型应用场景:

  • 函数调用时的返回地址保存
  • 中断服务程序中的现场保护
  • RTOS中任务切换时的上下文保存
  • 表达式求值中的操作数暂存

2.3 堆栈指针(SP)的作用

堆栈指针,就是指向栈顶的寄存器。它的作用就一个:告诉我们当前栈顶在哪里。

在ARM Cortex-M中,有两个堆栈指针:MSP(主堆栈指针)和PSP(进程堆栈指针)。MSP用于中断和异常处理,PSP用于任务代码。RTOS内核通常会让任务使用PSP,内核自身使用MSP。这样即使任务栈溢出了,也不会立刻影响到内核的稳定性。

我记得有一次调试一个死机问题,发现SP的值变成了0x20000000——这明显不对,因为栈空间根本不在那个地址。后来查出来是某个驱动函数里,局部变量定义了一个超大数组,直接把栈给撑爆了。从那以后,我写代码时对局部变量的大小就格外敏感。

SP操作注意事项:

  • SP必须始终指向有效的内存地址
  • SP的值必须是字对齐的(4字节对齐)
  • 不要手动修改SP,除非你清楚自己在做什么
  • RTOS中切换任务时,SP的保存和恢复必须原子操作

2.4 压栈与出栈操作

压栈(Push)和出栈(Pop),是堆栈最基本的两个操作。

压栈操作:

; ARM Cortex-M 汇编示例
PUSH {R0}      ; 将R0压入堆栈
PUSH {R4-R7}   ; 将R4到R7连续压栈
PUSH {LR}      ; 保存返回地址

出栈操作:

; ARM Cortex-M 汇编示例
POP {R0}       ; 从堆栈弹出到R0
POP {R4-R7}    ; 连续弹出到R4-R7
POP {PC}       ; 恢复返回地址(直接跳转)

压栈时,SP先减4,然后把数据写入SP指向的地址。出栈时,先从SP指向的地址读取数据,然后SP加4。就这么简单。

但简单归简单,坑可不少。我曾经在项目中遇到过一个问题:一个中断服务程序里,我手动压栈了一些寄存器,但出栈时顺序搞反了。结果中断返回后,程序直接跑飞。排查了半天才发现,R0和R1的值被交换了。

压栈出栈的黄金法则:

  • 压栈和出栈必须成对出现
  • 出栈顺序必须与压栈顺序相反
  • 压栈多少个字节,出栈就必须弹出多少个字节
  • 中断服务程序中,必须保证栈平衡

2.5 堆栈的典型应用:函数调用

函数调用时,堆栈是怎么工作的?我画个简单的流程给你看:

步骤 操作 SP变化
1 调用函数A SP不变
2 A压入返回地址 SP -= 4
3 A分配局部变量 SP -= N
4 A调用函数B SP不变
5 B压入返回地址 SP -= 4
6 B分配局部变量 SP -= M
7 B返回 SP += M + 4
8 A返回 SP += N + 4

你看,每次函数调用,堆栈就像搭积木一样,一层层往上堆。返回时再一层层拆掉。这就是堆栈的「栈帧」概念。

在RTOS中,每个任务都有自己的堆栈空间。任务切换时,内核会把当前任务的寄存器全部压入它的堆栈,然后从下一个任务的堆栈中弹出寄存器。这个过程,就是上下文切换的核心。

关于堆栈大小的建议:

  • 每个任务的堆栈大小,至少是最大调用深度的2倍
  • 考虑中断嵌套的情况,中断栈要单独预留空间
  • 局部变量不要太大,超过256字节的建议用malloc
  • 递归函数慎用,每递归一次就消耗一个栈帧

嗯,堆栈的工作原理,说白了就是这些。但真正用好它,需要在实际项目中不断积累经验。下一章,我会详细讲讲堆栈溢出检测的各种方法——这可是RTOS开发中的必修课。