RTOS任务栈大小估算

栈里有什么

函数调用链 + 局部变量 + 任务切换现场

对ARM架构而言,每一级函数调用,都要保存R4-R11 + LR 共9个寄存器 36字节。

局部变量的空间取决于代码。

任务现场的保存固定需要64字节,也就是保存完整的16个寄存器,这个是不会随着调用深度变化的。

为什么是R4-R11

ARM的寄存器分工是这样的:

寄存器别名用途谁负责保存
R0-R3-参数传递、返回值调用者(Caller)
R4-R11-通用寄存器被调用者(Callee)
R12IP临时寄存器调用者
R13SP栈指针-
R14LR返回地址被调用者
R15PC程序计数器-

R0-R3用于传参和返回值,调用后值会变,所以调用者自己负责保存还想用的值。

R4-R11调用者会假设"调用完这些值不变",所以被调用函数如果要改,必须要先保存、用完恢复。这里自然是会保存到栈中。 LR保存的是返回地址,如果函数里还要调用其他函数,会导致LR被覆盖,所以也需要保存到栈里。

为什么是64字节的现场

ARM有16个可见寄存器,占 16×4=64字节。 因为任务需要做到对切换无感知,所以需要保存全部的状态。也就是说,任务切换需要是透明的,一切都要恢复到原样,包括上面提到的应该由调用者负责的寄存器。

Cortex-M的优化

实际上64字节是简化说法,比如Cortex-M有硬件自动保存机制: 每当异常、中断触发的时候,硬件会自动将R0, R1, R2, R3, R12, LR, PC, xPSR这8个寄存器自动压栈,软件只需要保存R4-R11。虽然整体还是占用64字节,但是软件不需要负责其中的32字节的保存。 需要注意的是,这不意味着我们就不用预留空间了,还是要预留64字节的空间,只是硬件会自动保存到栈上的硬件存储区而已。

举个例子:

1
2
3
4
5
void A(void) {
    int x = 100;  // 假设编译器把 x 放在 R4
    B();          // 调用 B
    // 这里还要用 x,所以 R4 必须保持 100
}

如果 B() 也用了 R4,B() 必须先保存 R4 的旧值,返回前恢复。 这就是为什么 R4-R11 和 LR 要保存——调用约定保证了"调用前后这些寄存器值不变"。

估算方法

找最深的调用链、找最大的局部变量,然后相加,就能得到一个粗略的估算结果。 工程上还需要进行运行测量,用真实数据跑够所有的分支,借助 uxTaskGetStackHighWaterMark() 获取任务执行以来的栈剩余空间最小值来测量。还需要配合栈溢出钩子函数做记录。

常见的工作流程是这样的:

  1. 初期:估算一个值,乘 1.5-2 倍作为初始栈大小
  2. 开发期:关键任务周期性打印 uxTaskGetStackHighWaterMark() 结果
  3. 测试期:跑所有功能路径(最深调用链、最大局部变量场景)
  4. 发布前:根据测量结果,保留 20-30% 余量