把C语言编译成汇编是什么样子:3、如何用低级的汇编程序实现高级语言中【函数调用】功能?

199 阅读6分钟

要知道,汇编语言是没有直接提供函数调用这个概念的,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) # 传第1int参数(参数12)。(回答了第2个问题)将edi的4字节int数据,放到rbp-20内存处。为何为4字节,movl就是移动4字节指令,且edi也是4字节寄存器
   movsd	%xmm0, -32(%rbp) # 传第2double参数(参数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 # 强制类型转换,doubleint,并放到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函数需要的参数是怎么传递过去的?
    答:第1int参数放到了edi寄存器里,第2double参数放到了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寄存器里!简简单单几条指令就实现了复杂的函数调用功能,前辈们真的是有创意!