要知道,汇编语言是没有直接提供函数调用这个概念的,cpu的机器指令更没有提供函数调用这个概念。但是函数调用的功能对实现复杂逻辑、代码管理又非常重要,如何使用低级、简单的汇编指令,去实现高级语言中抽象、复杂的函数调用的功能,是很有必要的。下面就探索下前辈们是怎么用简单的汇编指令实现函数调用功能的。
先上代码,下面是一段简单的c语言函数调用:
#include<stdio.h>
int func(int i, double j)
{
int k = i + j;
return k;
}
int main(){
int result = func(12, 89.67);
return 0;
}
这段C代码很简单,定义一个函数,参数有俩,最后返回个int类型的返回值。func(int, double)函数里也做了个加法,还创建了个func函数内部的局部变量。我最关心的就是下面5个点:
1. 汇编程序是怎么从main函数切换到func函数的?
2. func函数需要的参数是怎么传递过去的?
3. func函数执行完后,又是怎么返回main函数刚才那句代码的?
4. 我们常说子函数中的局部变量,随着子函数调用结束就被释放了,那么这里的子函数func()是怎么释放它自己的局部变量k的?
5. main函数里的代码是如何拿到func()函数的返回值的?
# 这5个点我经常在各种资料看到解释,但总觉得隔靴搔痒,所以不如直接来看汇编程序是怎么实现这上述5个功能的!
下面是刚才那段C代码编译出来的汇编程序:
.file "1.c"
.text
.globl func
.type func, @function
func:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp # rbp寄存器的内容入栈,并且rsp向前移动一个位置。因为此时rbp的指向的地址,是main函数的栈帧地址,因此在进入func函数后,要先把main函数的栈帧地址rbp先放到栈首(rsp指向的内存),以便回main函数时可以恢复main函数的栈帧地址
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp # 刚才rbp的内容已经被备份到栈首了,且rsp又向前移动了一个位置,下一步需要开辟func函数的栈帧了,因此让rbp寄存器指向新的栈首内存地址
.cfi_def_cfa_register 6
movl %edi, -20(%rbp) # 传第1个int参数(参数12)。(回答了第2个问题)将edi的4字节int数据,放到rbp-20内存处。为何为4字节,movl就是移动4字节指令,且edi也是4字节寄存器
movsd %xmm0, -32(%rbp) # 传第2个double参数(参数89.67)。(回答了第2个问题)将xmm0的8字节数据,指向rbp-32内存处,movsd是移动8字节
cvtsi2sdl -20(%rbp), %xmm0 # 强制类型转换,把内存rbp-20内存处的int数据12,强制转成double,并放到xmm0里
addsd -32(%rbp), %xmm0 # rbp-32处的double数据89.67 + xmm0寄存器里的12.0相加放到xmm0里
cvttsd2sil %xmm0, %eax # 强制类型转换,double转int,并放到eax里
movl %eax, -4(%rbp) # eax结果数据,放到rbp-4内存处
movl -4(%rbp), %eax # rbp-4内存处的结果数据,放到eax里【为何要重复做?因为第1次放eax是计算结果的暂时缓存,第2次放rbp-4是给局部变量k分配4字节内存,第3次是把局部变量k内存里的数据放到eax是为了给main返回func()函数的返回值】
popq %rbp # 出栈。(回答了第4个问题)将刚才备份到内存中的main函数的栈帧数据,取出来放到rbp寄存器里。其实此时就已经释放掉子函数内部的局部变量数据了,因为此时rbp指向的栈帧已经是main函数的栈帧地址了,等于是把func的栈帧给扔了
.cfi_def_cfa 7, 8
ret # 返回到刚才main函数执行call func的下一句那里 (回答了第3个问题)
.cfi_endproc
.LFE0:
.size func, .-func
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp # 栈帧指针rbp指向栈指针rsp所指的内存处
.cfi_def_cfa_register 6
subq $16, %rsp # 栈指针rsp向前移动16个字节,因为栈是从高地址往低地址增长,所以入栈是减16字节
movq .LC0(%rip), %rax # .LC0(%rip)是计算.LC0标号的绝对地址,这句指令是指把.LC0处开始的8个字节数据(89.67),放到寄存器rax里
movq %rax, %xmm0 # rax寄存器数据放到xmm0寄存器里
movl $12, %edi # 立即数12放到edi寄存器里,即func(12, 89.67)
call func # 调用func 函数(回答了第1个问题)
movl %eax, -4(%rbp) # 将子函数func()的返回值(刚才存到eax里了),放到rbp-4的内存处(给result分配了4字节地址)(回答了第5个问题)
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.section .rodata
.align 8
.LC0:
.long 1202590843
.long 1079405281
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
通过上面逐行的注释,将这5个问题详细的回答了,最后总结下这5个问题的答案:
1. 汇编程序是怎么从main函数切换到func函数的?
答: 调用call func指令。
2. func函数需要的参数是怎么传递过去的?
答:第1个int参数放到了edi寄存器里,第2个double参数放到了xmm0寄存器里。
3. func函数执行完后,又是怎么返回main函数刚才那句代码的?
答:执行了 ret 指令返回到call func 指令的下一句。
4. 我们常说子函数中的局部变量,随着子函数调用结束就被释放了,那么这里的子函数func()是怎么释放它自己的局部变量k的?
答:首先在刚进入子函数func()时,先把main函数的栈帧rbp里的数据备份到了内存里,然后在最后执行完子函数func()后,又把这个备份的数据恢复到rbp里,等于是扔掉了func()的栈帧,也就释放了子函数func()的局部变量。
5. main函数里的代码是如何拿到func()函数的返回值的?
答:子函数func()把返回值放到了eax里,返回到main函数后,main的汇编代码去eax里拿出来返回值数据,并同时为result变量分配了4字节数据,然后把eax里的数据放到了result变量的内存里。
好了,高级语言中的函数调用功能,就是这么实现的。
总体来说,就是用call、ret指令实现跳转,然后通过不同的寄存器实现子函数的传参和返回值的返回。
而且,关于子函数的局部变量的是否也很有意思,只不过是把rbp重新指向了main函数的栈帧地址处,间接的把子函数func()的栈帧扔掉了,也就把栈帧中的局部变量释放扔掉了。
从这里也切实体会到,每次进入一个新的子函数,都会记录备份父函数的栈帧rbp寄存器里的数据到内存,然后给新的子函数创建一个栈帧地址并放到rbp寄存器里!简简单单几条指令就实现了复杂的函数调用功能,前辈们真的是有创意!