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. 函数内的局部变量也会放到栈中。