汇编分析函数调用

通过对汇编的分析可以了解程序的执行本质,对分析问题有很大的帮助,尤其在无法阅读源码的情况下更加有用。最近我刚阅读了《深入理解计算机系统(CSAPP)》的第三章,这一章主要讲解汇编的知识。为了对自己的学习有一个总结,并且给对汇编不甚了解的同学一点启发,所以有了这篇关于汇编的文章。

通过分析函数调用过程可以了解整个汇编的全貌,并且对实际的编程工作也有最大的作用。所以,让我们从一个简单的递归调用函数进入汇编的世界吧。

到公众号【iOS开发栈】学习更多SwiftUI、iOS开发相关内容。

C语言表示

用C语言计算0到20的整数之和:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int add(int start, int max) {
if (start == max) {
return max;
}
return start + add(start + 1, max);
}

int main(int argc, char const *argv[])
{
int result = add(0, 20);
printf("result = %d\n", result);
return 0;
}

add是一个递归函数,用来计算startmax的整数之和。main函数中调用add函数计算0到20的和,并输出到控制台。

把上面的代码保存到文件recursion.c文件。

生成汇编

通过gcc生成汇编:

1
gcc -S recursion.c

执行上面命令,获取到的汇编如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
_add:                                   ## @add
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl %edi, -8(%rbp)
movl %esi, -12(%rbp)
movl -8(%rbp), %eax
cmpl -12(%rbp), %eax
jne LBB0_2
movl -12(%rbp), %eax
movl %eax, -4(%rbp)
jmp LBB0_3
LBB0_2:
movl -8(%rbp), %eax
movl %eax, -16(%rbp) ## 4-byte Spill
movl -8(%rbp), %edi
addl $1, %edi
movl -12(%rbp), %esi
callq _add
movl %eax, %ecx
movl -16(%rbp), %eax ## 4-byte Reload
addl %ecx, %eax
movl %eax, -4(%rbp)
LBB0_3:
movl -4(%rbp), %eax
addq $16, %rsp
popq %rbp
retq
_main: ## @main
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movl %edi, %eax
xorl %edi, %edi
movl $0, -4(%rbp)
movl %eax, -8(%rbp)
movq %rsi, -16(%rbp)
movl $20, %esi
callq _add
movl %eax, -20(%rbp)
movl -20(%rbp), %esi
leaq L_.str(%rip), %rdi
movb $0, %al
callq _printf
xorl %eax, %eax
addq $32, %rsp
popq %rbp
retq

上面的内容是精简过的,去掉了一些说明性的内容,对实际的效果没有任何影响。

_add_main标签表示函数段开始,LBB0_2LBB0_3是汇编语言生成的标签,用来进行跳转;剩下的都是汇编指令。

main函数

main函数我们只看和add函数有关的部分:

1
2
3
4
5
6
7
_main:
...
xorl %edi, %edi
movl $0, -4(%rbp)
movl $20, %esi
callq _add
...

xorl %edi, %edi用来把edi寄存器的值设为0,其中xor表示异或操作,而两个相同的数异或的值等于0,而xorl中的l表示整数。

movl $0, -4(%rbp)——rbp-4表示一个内存地址,movl表示把整数0移动到内存rbp-4。

movl $20, %esi——把20放到esi寄存器中。

call表示函数调用,_add是C语言中add函数的汇编标签。

add函数

1
2
3
movl	%edi, -8(%rbp)
movl %esi, -12(%rbp)
movl -8(%rbp), %eax

通过上面main函数的分析得知edi寄存器中保存的整数0,esi寄存器中放的是整数20。而通过1、3行movl操作后,把0放到了eax寄存器中;第2行中把20放到了内存地址rbp-12。

1
2
cmpl	-12(%rbp), %eax
jne LBB0_2

cmp是英文单词compare的缩写,也就是比较的意思。因此第1行是比较内存地址rbp-12和寄存器eax中的值。jjump的缩写,nenot equal的缩写,jne表示如果不想等就跳转,而跳转的目的是LBB0_2标签。

而在整个add函数的递归过程中,之前几次都是不想等的,那我们继续看不相等的时候跳转到LBB0_2段。

1
2
3
4
5
6
movl	-8(%rbp), %eax
movl %eax, -16(%rbp) ## 4-byte Spill
movl -8(%rbp), %edi
addl $1, %edi
movl -12(%rbp), %esi
callq _add

第6行的又调用了add函数,而第4行、第5行是准备函数参数。

addl $1, %edi——edi寄存器中的值加1后放回edi寄存器。

从上面的分析可以看出edi和esi两个寄存器用做参数传递。

整个递归过程是通过使用jne命令比较两个整数的大小,如果不想等就跳转到LBB0_2标签,而在这个标签中又重新回到了add函数最开始的地方。直到edi和esi中保存的两个整数相等为止。

1
2
3
movl	-12(%rbp), %eax
movl %eax, -4(%rbp)
jmp LBB0_3

esi和edi的值相等时,取出rbp-12内存地址中的值放入eax寄存器,再把eax寄存器的值放入rbp-4内存中。之后跳转到LBB0_3标签处。

1
2
3
4
movl	-4(%rbp), %eax
addq $16, %rsp
popq %rbp
retq

第2行和第3行是还原函数调用栈——为了控制文章篇幅,省略这个知识点,有兴趣的话可以留言,以后再讲。

第1行是把rbp-4内存中保存的内容放入eax,也就是把函数的返回值放入eax。——eax寄存器通常用来保存函数返回值。

上面提到,edi寄存器存放start变量值,esi存放max变量值。这样就很容易可以得到,内存地址ebp-8和ebp-16中存放的是start变量,其中ebp-16只用来保存最新的start值;ebp-12存放的是max变量。

这里有一个地方需要注意:ebp中存放的内存地址在整个递归过程中是不断变化的。递归过程实际是在不断的进行函数调用,每次进行函数调用都会产生新的调用栈,那就会更新ebp中存放的地址值。随着ebp中存放的地址值改变,每一个新的start值都被放到了不同的内存地址中。

当结束条件(start == max)得到满足,递归结束。此时start的值等于max,调用跳转到LBB0_3中遇到了ret命令,当前的调用栈被返回,也就是继续执行callq _add下面的汇编代码。

1
2
3
4
movl	%eax, %ecx
movl -16(%rbp), %eax ## 4-byte Reload
addl %ecx, %eax
movl %eax, -4(%rbp)

这里进行一些内容的交换之外,主要把最新的start值加上之前得到的start+max的和。而在这里由于没有跳转命令,所以会继续执行LBB0_3中的内容,进而不断的ret出栈。

最终,把所有递归调用栈都ret完以后,最后一次的LBB0_3中的ret会返回到main函数中对add函数的调用处。

整个递归调用完成。

到公众号【iOS开发栈】学习更多SwiftUI、iOS开发相关内容。

总结

这篇文章通过对C语言中一个简单的递归调用过程的汇编代码进行分析,了解到递归调用的本质是jmp跳转命令和函数调用栈的结合使用。由于篇幅有限对函数栈、内存以及寄存器进行过多的分析,有兴趣的小伙伴可以直接去看《深入理解计算机系统》第三章,如果有兴趣可以留言,以后继续分析这方面的内容。