
一艘船,一厘,一艘扁舟,一英寸的丝绸和一英寸的钩子.
一首歌唱了一瓶酒,一个人抓到一条河,一条河落下.
-jiang江孤钓图片
在C / C ++程序中,函数调用是非常常见的操作. 那么,此操作的基本原理是什么?编译器为我们做了什么?在函数调用中如何使用CPU中的寄存器和存储器堆栈?堆栈框架的创建和恢复如何完成?针对上述问题,本文进行了探索和研究.
在调用函数时,我们通常需要在硬件级别上注意CPU的通用寄存器. 在所有CPU架构中,每个寄存器通常都有推荐的使用方法,并且编译器通常根据CPU架构的建议使用这些寄存器,因此我们可以认为这些建议是强制性的.
对于x86-64体系结构,总共有16个64位通用寄存器. 寄存器和用途如下图所示:

从上图可以得出以下结论:
在这里,我们需要区分“ Caller Save”和“ Callee Save”寄存器,即寄存器的值是由“ caller”还是“ callee”保存的. 进行函数调用时,通常在子函数中使用通用寄存器,并且先前保存在这些寄存器中的调用者(父函数)的值将被覆盖. 为了避免数据重写并从子功能返回时导致寄存器中的数据无法恢复,CPU体系结构规定了如何保存通用寄存器.
如果一个寄存器被标记为“ Caller Save”,则在调用该子功能之前,调用者需要预先保存这些寄存器的值. 保存方法通常是将寄存器的值压入堆栈,调用者保存完成后,这些寄存器的值可以在被调用者中被覆盖(子功能). 如果一个寄存器被标记为“ Callee Save”,则调用者无需保存这些寄存器的值,并在调用函数时直接调用该子函数. 进入子功能后,子功能需要先保存这些内容,然后再覆盖这些寄存器. 这些寄存器的值,即这些寄存器的值由被调用者保存和恢复.

调用子功能时,调用者和被调用者的堆栈框架结构如下图所示:

当调用子函数时,执行的操作是: 父函数将调用参数从后往前推->将返回地址推入堆栈并保存->跳转到子函数的起始地址以执行->子功能将调用父功能堆栈帧起始地址(%rpb)推动堆栈->将%rbp的值设置为%rsp的当前值,即,将%rbp指向子功能的起始地址堆栈框架.
在上述过程中,通过调用指令执行保存返回地址并跳转到子功能的操作. 调用指令完成后,它已进入子例程,因此最后一个堆栈帧%rbp被压入堆栈. ,需要由子例程完成. 调用该函数时在汇编级别的指令顺序如下:
... # 参数压栈
call FUNC # 将返回地址压栈,并跳转到子函数 FUNC 处执行
... # 函数调用的返回位置
FUNC: # 子函数入口
pushq %rbp # 保存旧的帧指针,相当于创建新的栈帧
movq %rsp, %rbp # 让 %rbp 指向新栈帧的起始位置
subq $N, %rsp # 在新栈帧中预留一些空位,供子程序使用,用 (%rsp+K) 或 (%rbp-K) 的形式引用空位
保存返回地址并保存前一个堆栈帧的%rbp是为了在函数返回时恢复父函数的堆栈帧结构. 当使用高级语言进行函数调用时,编译器会自动完成上述整个过程. 编译器还会自动完成“调用者保存”和“调用者保存”寄存器的保存和恢复.
应注意,当在父函数中将参数压入堆栈时,顺序是从后到前. 但是,此行为不是固定的,并且取决于编译器的特定实现. 在gcc中,使用从后到前的堆叠方法. 此方法很容易支持类似于printf(“%d,%d``,i,j)这样的函数,这些函数使用可变长度参数进行调用.
当函数返回时,我们只需要获取函数的返回值(存储在%rax中),那么我们需要将堆栈结构恢复到函数调用之间的差异状态,并跳转到返回值父函数的地址继续执行. 由于调用该函数时已保存了父函数堆栈框架的返回地址和起始地址,因此要在调用子函数之前恢复父堆栈框架,我们只需执行以下两条指令即可:
movq %rbp, %rsp # 使 %rsp 和 %rbp 指向同一位置,即子栈帧的起始处
popq %rbp # 将栈中保存的父栈帧的 %rbp 的值赋值给 %rbp,并且 %rsp 上移一个位置指向父栈帧的结尾处
为了促进堆栈帧恢复,x86-64体系结构提供了Leave指令以实现上述两个命令的功能. 执行离开后,上图中函数调用的堆栈框架结构如下:


可以看出,在调用Leave之后,%rsp指向返回地址,并且x86-64提供的ret指令具有从当前%rsp所指向的位置弹出数据的功能(即栈顶)并跳转到此数据表示的地址,执行离开后,%rsp指向返回地址,因此ret的作用是将%rsp向上移动一个位置并跳转到返回地址以执行. 可以看出,leave指令用于恢复父函数的堆栈帧,ret用于跳转到返回地址栈帧,leave和ret配合完成子函数的返回. 完成ret的执行后,%rsp指向父堆栈帧末尾,并且编译器会自动释放存储在父堆栈帧末尾的调用参数.
为了更好地理解函数调用原理,我们可以使用一个程序示例来观察函数调用和返回. 步骤如下:
int add(int a, int b, int c, int d, int e, int f, int g, int h) { // 8 个参数相加
int sum = a + b + c + d + e + f + g + h;
return sum;
}
int main(void) {
int i = 10;
int j = 20;
int k = i + j;
int sum = add(11, 22,33, 44, 55, 66, 77, 88);
int m = k; // 为了观察 %rax Caller Save 寄存器的恢复
return 0;
}
在主功能中,首先执行k = i + j加法,以观察Caller Save的效果. 因为加法将使用%rax,下面的add函数的返回值也将使用%rax. 由于%rax是调用者保存寄存器,因此程序应在调用add子功能之前保存%rax的值.
add函数使用8个参数. 这是在功能参数超过6个时观察程序的行为. 前6个参数保存到寄存器,多于6个参数保存到堆栈. 但是,由于该参数的地址可能在子例程中,并且存储在寄存器中的前6个参数没有内存地址,因此我们可以猜测存储在寄存器中的前6个参数也在子例程中. 堆栈,以获取这6个参数的内存地址. 由上述程序生成并与子功能调用有关的汇编器如下:
add:
.LFB2:
pushq %rbp
.LCFI0:
movq %rsp, %rbp
.LCFI1:
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
movl %edx, -28(%rbp)
movl %ecx, -32(%rbp)
movl %r8d, -36(%rbp)
movl %r9d, -40(%rbp)
movl -24(%rbp), %eax
addl -20(%rbp), %eax
addl -28(%rbp), %eax
addl -32(%rbp), %eax
addl -36(%rbp), %eax
addl -40(%rbp), %eax
addl 16(%rbp), %eax
addl 24(%rbp), %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
leave
ret
main:
.LFB3:
pushq %rbp
.LCFI2:
movq %rsp, %rbp
.LCFI3:
subq $48, %rsp
.LCFI4:
movl $10, -20(%rbp)
movl $20, -16(%rbp)
movl -16(%rbp), %eax
addl -20(%rbp), %eax
movl %eax, -12(%rbp)
movl $88, 8(%rsp)
movl $77, (%rsp)
movl $66, %r9d
movl $55, %r8d
movl $44, %ecx
movl $33, %edx
movl $22, %esi
movl $11, %edi
call add
movl %eax, -8(%rbp)
movl -12(%rbp), %eax
movl %eax, -4(%rbp)
movl $0, %eax
leave
ret
在汇编器中,如果使用64位通用寄存器的低32位,则该寄存器以“ e”开头,例如%eax,%ebx等. 对于%r8-%r15,低32位位在64位注册后,添加“ d”以表示例如%r8d,%r15d. 如果操作数是32位,则指令以“ l”结尾,例如movl $ 11,%esi. 指令和寄存器均为32位格式. 如果操作数是64位,则指令以q结尾,例如“ movq%rsp,%rbp”. 因为示例程序中的操作数都在32位表示范围内,所以上述加法和移动指令都是32位指令和操作数. 创建堆栈帧和操作数时,只有64位指令用于地址对齐.
首先查看主要功能的前三个汇编语句:
.LFB3:
pushq %rbp
.LCFI2:
movq %rsp, %rbp
.LCFI3:
subq $48, %rsp

这三个语句保存父函数的堆栈框架栈帧,然后创建主函数的堆栈框架,并在堆栈框架中分配48个字节的空间. 完成这三个语句的执行后,主函数的堆栈框架如下所示:

然后,在主函数中执行k = i + j的加法和加法参数的处理:
movl $10, -20(%rbp)
movl $20, -16(%rbp)
movl -16(%rbp), %eax
addl -20(%rbp), %eax
movl %eax, -12(%rbp) # 调用子函数前保存 %eax 的值到栈中,caller save
movl $88, 8(%rsp)
movl $77, (%rsp)
movl $66, %r9d
movl $55, %r8d
movl $44, %ecx
movl $33, %edx
movl $22, %esi
movl $11, %edi
call add
当添加k = i + j时,将以特殊方式使用主堆栈空间. 不按照我们通常认为的那样,每次使用堆栈空间时都会执行一次推入操作,但是会使用先前分配的48个空位,并使用-N(%rbp)从%指向的位置开始倒数rbp间隔的使用与每个推操作基本相同. 最后,由i + j计算的结果k以%eax保存. 然后,您需要准备调用add函数.
我们知道add函数的返回值将存储在%eax中,也就是说,%eax必须由子函数add覆盖,现在k的值将保存在%eax中. 如您在C程序中所看到的,调用add之后,我们再次使用了k的值,因此我们需要在覆盖add的调用中覆盖%eax之前保存%eax的值,并且添加后需要恢复%eax %eax. 该值(即k的值),因为%eax是“调用者保存”,所以父函数main应该保存%eax的值,因此在语句中有一个句子“ movl%eax,-12(%rbp)”. 上面的程序集是调用add函数来保存%eax的值.
本文来自电脑杂谈,转载请注明本文网址:
http://www.pc-fly.com/a/jisuanjixue/article-164560-1.html
撞也撞不疼人家