C语言函数调用的底层实现

最近在阅读大名鼎鼎的《深入理解计算机系统》,读到第三章,介绍了函数的底层实现。对底层的实现有了一些了解。

为了理解,我就用书上的例子,如果在中途有出现的术语,我会就近解释。

1. 背景

全文将会围绕下面两个函数来介绍所有的实现机制,这两个函数是:

第一个,主调用函数(它去调用另外一个函数 proc)

long call_proc()
{
        long  x1 = 1; int  x2 = 2;
        short x3 = 3; char x4 = 4;
        proc(x1, &x1, x2, &x2, x3, &x3, x4, &x4);
        return (x1+x2)*(x3-x4);
}

第二个,被调用函数 (它接受其他函数的调用)

void proc(long  a1, long  *a1p,
          int   a2, int   *a2p,
          short a3, short *a3p,
          char  a4, char  *a4p)
{
        *a1p += a1;
        *a2p += a2;
        *a3p += a3;
        *a4p += a4;
}

将这个函数分别 设置保存为两个文件,例如以函数名为文件名,proc.c 和 call_proc.c。 用gcc自带的汇编功能将两个文件汇编成汇编文件。例如:

gcc -Og -S call_proc.c

对这两个C文件汇编,将得到两个以 .s 为后缀的汇编文件。如,call_proc.s 和 proc.s 。

2. 汇编文件介绍

下面,将通过介绍上面两个汇编文件来了解底层对函数调用的支持。

按照顺序,先来看看主调用函数 call_proc.s 的内容

	.file	"p171_call_proc.c"
	.text
	.globl	call_proc
	.type	call_proc, @function
call_proc:
.LFB0:
	.cfi_startproc
	subq	$24, %rsp
	.cfi_def_cfa_offset 32
	movq	$1, 8(%rsp)
	movl	$2, 4(%rsp)
	movw	$3, 2(%rsp)
	movb	$4, 1(%rsp)
	leaq	1(%rsp), %rax
	pushq	%rax
	.cfi_def_cfa_offset 40
	pushq	$4
	.cfi_def_cfa_offset 48
	leaq	18(%rsp), %r9
	movl	$3, %r8d
	leaq	20(%rsp), %rcx
	movl	$2, %edx
	leaq	24(%rsp), %rsi
	movl	$1, %edi
	movl	$0, %eax
	call	proc
	movslq	20(%rsp), %rax
	addq	24(%rsp), %rax
	movq	%rax, %rcx
	movswl	18(%rsp), %edx
	movsbl	17(%rsp), %eax
	subl	%eax, %edx
	movslq	%edx, %rax
	imulq	%rcx, %rax
	addq	$40, %rsp
	.cfi_def_cfa_offset 8
	ret
	.cfi_endproc
.LFE0:
	.size	call_proc, .-call_proc
	.ident	"GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
	.section	.note.GNU-stack,"",@progbits

从大方向来看,

第一部分:第6行到第13行,准备局部变量,x1,x2,x3,x4

第二部分:第14行到第25行,准备形参

第三部分:第27行到第34行,计算返回值

下面具体说每一行代码。

第一部分 局部变量

从第8行开始:

subq    $24, %rsp

解释:rsp 寄存器就是堆栈寄存器,存放栈顶指针。“根据惯例,我们的栈是倒过来画的,因而栈“顶”在底部。x86-64 中,栈向低地址方向增长,所以压栈是减小栈指针(寄存器%rsp)的值,并将数据存放到内存中,而出栈是从内存中读数据,并增加栈指针的值”,如下图 1所示。subq 是减少的意思,也就是将rsp寄存器的内容减少24,也即是增加24个字节的栈大小。如下图 2所示。

                              图 1

                                                  图  2 

第10行:

movq    $1, 8(%rsp)

解释: 将立即数1 移动到rsp+8指针指向的栈位置,movq中的 q 表示操作数占8个字节。对应于C程序call_proc中的 long  x1 = 1 语句;执行完此命令,栈变成:

                                                               图 3

第11行:

movl    $2, 4(%rsp)

解释: 将立即数2 移动到rsp+4指针指向的栈位置,movl中的l表示操作数占4个字节。对应于C程序call_proc中的 int  x2 = 2 语句;执行完此命令,栈变成:

                                                                  图  4

第12行:

movw    $3, 2(%rsp)

解释: 将立即数3 移动到rsp+2指针指向的栈位置,movw中的w表示操作数占2个字节。对应于C程序call_proc中的 short x3 = 3 语句;执行完此命令,栈变成:

                                                                                   图   5

第13行:

movb    $4, 1(%rsp)

解释: 将立即数4 移动到rsp+1指针指向的栈位置,movb中的b表示操作数占2个字节。对应于C程序call_proc中的 char x4 = 4语句;执行完此命令,栈变成:

                                                                                          图  6

第二部分 函数形参

在 x86_64 中,如果参数超过6个,就需要栈来传递剩下的参数,当然,第1到 6个参数使用哪个寄存器也是固定的,具体见表1。例如,如果一个函数有10个参数,那么主调用函数需要将第1到第6个参数依次按顺序放到图7的寄存器中,而剩下的4个参数就需要放到栈中,而被调用函数就需要到栈来取这些参数。

                                                                               表 1

继续我们的汇编代码:

第14,15行:

leaq    1(%rsp), %rax
pushq   %rax

解释:“加载有效地址(load effective address)指令leaq 实际上是movq i指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第-一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读人数据,而是将有效地址写入到目的操作数。在图3-10中我们用C语言的地址操作符&S 说明这种计算。这条指令可以为后面的内存引用产生指针。”一句话,leaq的这条指令的意思就是取栈顶指针+1 的地址,复制给rax寄存器,然后pushq就是将刚刚的地址再压入栈中。此时,栈的大小会增加8个字节,也就是地址减少8(为什么减少,是因为栈从大地址向小地址扩大的,为什么是8个字节,是因为x86_64中,指针都是8个字节大小)。根据第一部分的栈的图示,这两条指令是将 第八个参数 &x4 压入栈中,见图 7,也就是准备proc(x1, &x1, x2, &x2, x3, &x3, x4, &x4) 中的第8个,也即最后一个参数。 

                                                                                    图  7

第17行:

pushq   $4

 解释:将立即数4压入栈中,也就是准备第7个参数,x4,此时栈会扩大8个字节,因为pushq 是按8个字节进行操作的。执行完这条指令后,栈的情况如下,图8所示。                                  

                                                                                              图 8

这时候,还剩下6个参数,这6个参数将不再通过栈来传递,而是通过更加快速的寄存器来传递,哪个位置的参数要用到哪个具体的寄存器,我们需要对照表1中的规则。

下面来具体看看,首先是第六个参数,也就是 &x3 ,根据上图9中所示,&x3的值是 %rsp + 18,需要放到8字节大小(上面已经提到,指针大小都是8字节)的寄存器中,查找表1,应该是 %r9,因此,对应于的是第19行指令:

leaq    18(%rsp), %r9

后面的几个命令依次类推,不再赘述。

 

这时候,主调用函数所有的情况都已准备好,我们暂停分析第三部分的汇编代码,看看被调用函数 proc的情况。

注意,当进入到被调用函数之前,主调用函数会将自己的下一条执行指令的地址写到栈中,这是为了方便被调用函数在执行完自己后,能够根据栈中的返回地址,顺利地返回到主调用函数。

因此,在此例中,当进入到被调用函数时候,被调用函数拿到栈如下图9所示:

                                                                            图  9

下面继续分析被调用函数proc的汇编代码:

	.file	"p169_proc.c"
	.text
	.globl	proc
	.type	proc, @function
proc:
.LFB0:
	.cfi_startproc
	movq	16(%rsp), %rax
	addq	%rdi, (%rsi)
	addl	%edx, (%rcx)
	addw	%r8w, (%r9)
	movl	8(%rsp), %edx
	addb	%dl, (%rax)
	ret
	.cfi_endproc
.LFE0:
	.size	proc, .-proc
	.ident	"GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
	.section	.note.GNU-stack,"",@progbits

有了上面分析的基础,我们直接在一起分析。

我们直接看第8行到第13行。

第9,10,11行的很相似,我们在一块分析。我们对照来看一下表1。例如第9行

addq    %rdi, (%rsi)

%rdi 是存放第一个参数的,%rsi 是存放第二个参数的,这个我们在上面讲过,主调用函数call_proc已经在调用前都设置好了,只等被调用函数proc来取了。这个时候也就是被调用函数来取的时候啦。

那么这句话的意思是,取rsi寄存器存放指针所指向的值(*a1p)与rdi寄存器的值(a1)相加,结果放到 rsi 指向的单元中。也就是

*a1p += a1 这句话的翻译了。第10,11行类似,就不再赘述了。

再来看第8行,第12行,13行。

movq    16(%rsp), %rax

movl    8(%rsp), %edx
addb    %dl, (%rax)

根据图9, 16(%rsp)取的是rsp+16处的值,也就是 &x4,而 8(%rsp)的值是4,因此,addb的效果是将edx寄存器的最低8位和 rax 指向的值 x4(* &x4)相加。对应于C语句 *a4p += a4。

第14行,ret,返回指令。

现在被调用函数proc已经执行完了,程序将会回到 call_proc 继续执行。而返回的依据就是图9 栈中的返回地址。将这个地址复制到 程序计数器PC中,就可以返回原来的函数了。

主调用函数的后面,也就是第三部分的汇编代码与本主题没有太大联系,就不再叨扰。

总结

从底层的汇编代码我们总结到以下三点:

1. 主调用函数的前6个参数会放到规定的6个寄存器中,被调用函数将从这6个寄存器拿数据。超过6个的部分将会放到栈中,被调用函数将从栈中取剩下的参数数据。

2. C语言的参数是从右往左准备的,也就是从右往左执行。

3. 函数内的局部变量也会放到栈中。

  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值