74、【OS】【Nuttx】【启动】深入理解 caller-saved 和 callee-saved(下)
【声明】本博客所有内容均为个人业余时间创作,所述技术案例均来自公开开源项目(如Github,Apache基金会),不涉及任何企业机密或未公开技术,如有侵权请联系删除
背景
接上篇 blog
【OS】【Nuttx】【启动】深入理解 caller-saved 和 callee-saved(上)
分析了易失性寄存器(volatile registers)和非易失性寄存器(non-volatile registers)两个重要概念,现在回过头来再看 caller-saved 和 callee-saved
caller-saved 和 callee-saved
首先看 caller-saved ,调用者保存
caller-saved(调用者保存)
综合之前 blog 分析的,调用者保存的,应该是易失性的寄存器(范围 r0-r3),这些寄存器用于临时数据或参数传递,比如下列流程:
- 函数 A 调用函数 B
- 如果 A 想在调用 B 后还能继续使用某个寄存器的值,它有两种选择,一种选择是将该内容放到非易失性寄存器里(比如 r4-r8,r10-r11),另一种选择是在调用前把该寄存器压栈保存,把数据放到 ram 上
- B 可以修改这些寄存器,而不用负责恢复它们的值
比如下面这段汇编代码演示的
; 函数 A 要调用函数 B 了,想要存下 r0 的值,那就先压到栈上
push {r0} ; 入栈保存
bl function_b ; 调用函数 B
pop {r0} ; 出栈恢复
callee-saved(被调用者保存)
同样按照之前分析的,被调用者保存的,应该是非易失性的寄存器(范围 r4-r8,r10-r11),这些寄存器可以用来长期存储局部变量等,比如下面流程
- 函数 B 被调用时,如果要用到某个非易失性寄存器(比如 r4),那它必须先把该寄存器压栈保存
- 返回函数 A 之前再恢复这个寄存器的值,如下图
- 这样做可以不让调用者(函数 A)担心非易失性寄存器被破坏
比如下面汇编代码演示的
function_b:push {r4} ; 保存 r4... ; 对 r4 做一些事情pop {r4} ; 恢复 r4
AAPCS
这种规则是 arm 官方定义的调用约定,也就是一直提到的 AAPCS(ARM Architecture Procedure Call Standard),这样做有这么些好处:
- 提高效率:caller-saved 寄存器可以让函数调用更轻量,只有真正需要保留时才压栈(callee-saved 寄存器都用满了,然后还不够,还有数据要保存),而 callee-saved 寄存器可以避免频繁压栈(被调用函数要用到的时候才需要压栈),适合长期使用的变量
- 统一接口:所有编译器都遵守同样的规则,不同模块之间可以安全交互
- 便于优化:编译器可以根据这些规则进行寄存器分配、内联优化等
提高效率和便于优化好理解,这里再着重强调下统一接口这个好处
统一接口
这也是 AAPCS 的核心价值之一,只要两个模块(A 和 B)都遵循 AAPCS 调用约定,并且目标平台一致(如都是 Cortex-M4),即使它们是用不同的编译器(比如 gcc、clang、armcc、iar 等)编译的,也可以安全地链接在一起形成一个可执行文件, 这就是调用约定存在的意义,可以看作是编程世界的交通规则,通信协议
想象一下没有 AAPCS 的情况:
- 编译器 1 在编译模块 A 时,把第一个参数放在 r0;
- 编译器 2 在编译模块 B 时,把第一个参数放在栈偏移 +8
- 最后链接成一个可执行文件后,模块 A 和模块 B 之间的函数调用就会出错
所以为了确保不同编译器之间兼容,C 和汇编混合编程可行,动态库,静态库,裸机程序,RTOS 任务之间互操作,就需要有一个统一的标准来定义
- 参数怎么传:通过 r0~r3,再加栈
- 返回值放哪:r0(≤ 4字节时)
- 哪些寄存器必须保存: r4~r11
- 栈增长方向:向下增长
等等,这就是 AAPCS 存在的意义
再举个例子,假设有两个模块 A 和 B,其中
- 模块 A:用 gcc 编写并编译;
- 模块 B:用 iar 编写并编译;
- 两者都针对 Cortex-M4 平台,且都遵守 AAPCS;
那么就可以把它们分别编译成 .o,或者 .so 文件,然后用链接器将它们链接为一个完整的 elf 可执行文件,最后在 stm32f4 上正常运行, 因为它们都遵守相同的调用规则,函数之间可以互相调用,不会出错
调用约定就分析到这,下篇继续