栈里有什么
函数调用链 + 局部变量 + 任务切换现场
对ARM架构而言,每一级函数调用,都要保存R4-R11 + LR 共9个寄存器 36字节。
局部变量的空间取决于代码。
任务现场的保存固定需要64字节,也就是保存完整的16个寄存器,这个是不会随着调用深度变化的。
为什么是R4-R11
ARM的寄存器分工是这样的:
| 寄存器 | 别名 | 用途 | 谁负责保存 |
|---|---|---|---|
| R0-R3 | - | 参数传递、返回值 | 调用者(Caller) |
| R4-R11 | - | 通用寄存器 | 被调用者(Callee) |
| R12 | IP | 临时寄存器 | 调用者 |
| R13 | SP | 栈指针 | - |
| R14 | LR | 返回地址 | 被调用者 |
| R15 | PC | 程序计数器 | - |
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字节的空间,只是硬件会自动保存到栈上的硬件存储区而已。
举个例子:
| |
如果 B() 也用了 R4,B() 必须先保存 R4 的旧值,返回前恢复。
这就是为什么 R4-R11 和 LR 要保存——调用约定保证了"调用前后这些寄存器值不变"。
估算方法
找最深的调用链、找最大的局部变量,然后相加,就能得到一个粗略的估算结果。
工程上还需要进行运行测量,用真实数据跑够所有的分支,借助 uxTaskGetStackHighWaterMark() 获取任务执行以来的栈剩余空间最小值来测量。还需要配合栈溢出钩子函数做记录。
常见的工作流程是这样的:
- 初期:估算一个值,乘 1.5-2 倍作为初始栈大小
- 开发期:关键任务周期性打印
uxTaskGetStackHighWaterMark()结果 - 测试期:跑所有功能路径(最深调用链、最大局部变量场景)
- 发布前:根据测量结果,保留 20-30% 余量