0%

C语言函数调用与x86汇编

为了进一步了解C语言,最近又读起了《Linux C编程一站式学习》。书的第二章的标题为C语言本质,涉及到了一些计算机底层的知识,激起了我探索的热情。最近两天在看计算机体系结构基础、x86汇编有关的部分,一个研究C语言函数调用的例子让我印象深刻。这篇文章算是一次实验的记录。

在学习这一节之前,我对C中函数调用的理解是这样的:调用函数时,根据函数声明,为该函数创建一个栈空间。传入的值被赋给一些该空间内的一些局域变量;退出函数时,返回值被赋给一个局域变量,栈空间释放。

下面来看例子:function.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int bar(int c, int d)
{
int e = c + d;
return e;
}

int foo(int a, int b)
{
return bar(a, b);
}

int main(void)
{
foo(2, 3);
return 0;
}

为了在汇编层面理解函数调用的过程,我们编译时加入调试信息,并查看反汇编代码:

1
2
$ gcc -g function.c
$ objdump -dS a.out # -d意为disassemble,-S使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
49
50
51
52
53
54
55
56
57
0000000000001129 <bar>:
int bar(int c, int d)
{
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: 89 7d ec mov %edi,-0x14(%rbp)
1134: 89 75 e8 mov %esi,-0x18(%rbp)
int e = c + d;
1137: 8b 55 ec mov -0x14(%rbp),%edx
113a: 8b 45 e8 mov -0x18(%rbp),%eax
113d: 01 d0 add %edx,%eax
113f: 89 45 fc mov %eax,-0x4(%rbp)
return e;
1142: 8b 45 fc mov -0x4(%rbp),%eax
}
1145: 5d pop %rbp
1146: c3 retq

0000000000001147 <foo>:

int foo(int a, int b)
{
1147: f3 0f 1e fa endbr64
114b: 55 push %rbp
114c: 48 89 e5 mov %rsp,%rbp
114f: 48 83 ec 08 sub $0x8,%rsp
1153: 89 7d fc mov %edi,-0x4(%rbp)
1156: 89 75 f8 mov %esi,-0x8(%rbp)
return bar(a, b);
1159: 8b 55 f8 mov -0x8(%rbp),%edx
115c: 8b 45 fc mov -0x4(%rbp),%eax
115f: 89 d6 mov %edx,%esi
1161: 89 c7 mov %eax,%edi
1163: e8 c1 ff ff ff callq 1129 <bar>
}
1168: c9 leaveq
1169: c3 retq

000000000000116a <main>:

int main(void)
{
116a: f3 0f 1e fa endbr64
116e: 55 push %rbp
116f: 48 89 e5 mov %rsp,%rbp
foo(2, 3);
1172: be 03 00 00 00 mov $0x3,%esi
1177: bf 02 00 00 00 mov $0x2,%edi
117c: e8 c6 ff ff ff callq 1147 <foo>
return 0;
1181: b8 00 00 00 00 mov $0x0,%eax
}
1186: 5d pop %rbp
1187: c3 retq
1188: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
118f: 00

由于我的与书中的运行环境不同(64位、32位操作系统),结果有较大差异,下面是a.out文件的ELF Header:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1040
Start of program headers: 64 (bytes into file)
Start of section headers: 15528 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 34
Section header string table index: 33

进入函数

我初次接触汇编,指令也只认得movl,看不出什么名堂。我们可以gdb调试,并使程序停在main函数入口处:

1
2
$ gdb a.out
(gdb) start

查看当前函数的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
(gdb) disassemble
Dump of assembler code for function main:
=> 0x000055555555516a <+0>: endbr64
0x000055555555516e <+4>: push %rbp
0x000055555555516f <+5>: mov %rsp,%rbp
0x0000555555555172 <+8>: mov $0x3,%esi
0x0000555555555177 <+13>: mov $0x2,%edi
0x000055555555517c <+18>: callq 0x555555555147 <foo>
0x0000555555555181 <+23>: mov $0x0,%eax
0x0000555555555186 <+28>: pop %rbp
0x0000555555555187 <+29>: retq

我目前知道,rsp寄存器总是指向栈顶,利用rsp与rbp两个寄存器,就可以完成函数的调用和退出
首先查看两个寄存器的值,以及从rsp中储存地址开始的4个4字节的数:

1
2
3
4
(gdb) p {$rsp, $rbp}
$1 = {0x7fffffffe3c8, 0x0}
(gdb) x /4x $rsp
0x7fffffffe3c8: 0xf7df70b3 0x00007fff 0xf7ffc620 0x00007fff

搞不懂为什么会是这两个值。由于在x86平台中,栈都向低地址(向下增长),可以先画一个示意图:

为了观察入栈、出栈的情况,监视rsp、rbp寄存器的值:

1
2
(gdb) watch {$rsp, $rbp}
Watchpoint 2: {$rsp, $rbp}

开始单步调试:

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) si
0x000055555555516e 13 {

(gdb)
Watchpoint 2: {$rsp, $rbp}
Old value = {0x7fffffffe3c8, 0x0}
New value = {0x7fffffffe3c0, 0x0}
0x000055555555516f in main () at function.c:13
13 {

(gdb) x /4x $rsp
0x7fffffffe3c0: 0x00000000 0x00000000 0xf7df70b3 0x00007fff

嗯,看起来push指令使rsp的值自减0x08,再将rbp的值赋给了rsp的地址储存的值。按照网上一篇文章的说法,这里的push等价于

1
2
sub $0x08, %rsp
mov %rbp, (%rsp)

为什么是0x08?x86_64架构的寄存器都是64位,为了储存一个64位的地址,我们需要”挪出“64位的空间,也就是8个字节。(也许这也可以说明x86的指针所占空间为4字节,x86_64为8字节)

接下来两个参数2、3以调用时从右到左的顺序分别存入esi、edi寄存器。这里与书中x86平台出现了偏差,x86平台中两个参数以地址偏移的方式存入了esp对应地址的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
(gdb)
0x0000555555555177 14 foo(2, 3);
(gdb)
0x000055555555517c 14 foo(2, 3);
(gdb)
Watchpoint 2: {$rsp, $rbp}
Old value = {0x7fffffffe3c0, 0x7fffffffe3c0}
New value = {0x7fffffffe3b8, 0x7fffffffe3c0}
foo (a=21845, b=1431654464) at function.c:8
8 {

(gdb) x /4x $rsp
0x7fffffffe3b8: 0x55555181 0x00005555 0x00000000 0x00000000

执行callq指令时,rsp的值减了0x08。观察rsp中储存地址开始4个字节的值,我发现存入了一个相对栈空间而言较小的地址,推测可能是函数返回后跳转的指令地址。回看之前的指令,发现:

1
2
0x000055555555517c <+18>:    callq  0x555555555147 <foo>
0x0000555555555181 <+23>: mov $0x0,%eax

果然是foo函数返回后下一条指令的地址。还注意到一点:储存的指令地址低位在内存的低地址空间,高位在内存的高地址空间,读起来不符合人类的习惯,证明是小端模式

现在进入了foo函数,先看一看foo函数的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(gdb) disassemble
Dump of assembler code for function foo:
=> 0x0000555555555147 <+0>: endbr64
0x000055555555514b <+4>: push %rbp
0x000055555555514c <+5>: mov %rsp,%rbp
0x000055555555514f <+8>: sub $0x8,%rsp
0x0000555555555153 <+12>: mov %edi,-0x4(%rbp)
0x0000555555555156 <+15>: mov %esi,-0x8(%rbp)
0x0000555555555159 <+18>: mov -0x8(%rbp),%edx
0x000055555555515c <+21>: mov -0x4(%rbp),%eax
0x000055555555515f <+24>: mov %edx,%esi
0x0000555555555161 <+26>: mov %eax,%edi
0x0000555555555163 <+28>: callq 0x555555555129 <bar>
0x0000555555555168 <+33>: leaveq
0x0000555555555169 <+34>: retq
End of assembler dump.

sub $0x8,%rsp指令将八个字节的空间腾出来,两个mov指令储存两个为int型、占4字节的值2和3。看上去可以用push代替。
有趣的是,从<+12>到<+26>,看起来做了一些无用功。第一个问题:为什么要把edi与esi中的值存入栈中?可能是为了debug时能看到局部变量吧。第二个问题:为什么要用edx与eda寄存器,为什么最后又要把edx、eda中的值分别赋给esi、edi?我不知道。。。

接着逐指令执行,每次push,我都去查看了对应地址开始的4个字节的值。

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) si
0x000055555555514b 8 {
(gdb)

Watchpoint 2: {$rsp, $rbp}

Old value = {0x7fffffffe3b8, 0x7fffffffe3c0}
New value = {0x7fffffffe3b0, 0x7fffffffe3c0}
0x000055555555514c in foo (a=21845, b=1431654464) at function.c:8
8 {
(gdb) x /4x $rsp
0x7fffffffe3b0: 0xffffe3c0 0x00007fff 0x55555181 0x00005555

结合之前的信息,我画出了下图:

之后省略了一些步骤,直接进入bar函数,看一看汇编代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) disassemble
Dump of assembler code for function bar:
=> 0x0000555555555129 <+0>: endbr64
0x000055555555512d <+4>: push %rbp
0x000055555555512e <+5>: mov %rsp,%rbp
0x0000555555555131 <+8>: mov %edi,-0x14(%rbp)
0x0000555555555134 <+11>: mov %esi,-0x18(%rbp)
0x0000555555555137 <+14>: mov -0x14(%rbp),%edx
0x000055555555513a <+17>: mov -0x18(%rbp),%eax
0x000055555555513d <+20>: add %edx,%eax
0x000055555555513f <+22>: mov %eax,-0x4(%rbp)
0x0000555555555142 <+25>: mov -0x4(%rbp),%eax
0x0000555555555145 <+28>: pop %rbp
0x0000555555555146 <+29>: retq
End of assembler dump.

嗯,大概还是熟悉的套路,但是。。。居然访问了foo函数的栈空间,又储存了一次3、2(如果传入bar参数的是a+1、b+2之类的会不同吗?)。与想象大相径庭,与书中x86的行为也不同,我无法解释。
书中说eax寄存器用来储存返回值,看起来是的。

最后得出:

现在可以总结一下进入函数后rsp与rbp的作用:

  1. 在刚进入函数时,rbp指向当前函数的栈底,可以使用间接寻址访问上一个栈帧的栈底地址
  2. rsp指向当前函数的栈顶,在函数中偏移频繁。
  3. 函数的参数和局部变量都是通过rbp的值加上一个偏移量来访问。

退出函数

退出函数的过程可以说是进入函数的逆过程。之后的调试过程省略,但要理解真正发生了什么是有一定难度的(实际上,我已经被绕昏了,不知道在哪应该用哪个)。记住:%eax refers to the register %eax, (%eax) refers to the value in the register %eax

pop是push的逆操作,等价于:

1
2
mov (%rsp), %rbp
add $0x80, %rsp

retq是callq的逆操作,等价于:

1
2
pop %rip
jmp %rip

回到bar函数,这里leaveq是push %rbp和mov %rsp, %rbp的逆操作,等价于:

1
2
mov %rbp, %rsp
pop %rbp

最终结果:

总结

这篇文章是一气呵成的,写到后面已经感觉不太行了,知识储备不足,导致写得一点也不像实验(感觉也出不了什么结果,都是在网上搜集的资料)。并且,事实上,真正的实验流程远没有这么顺利,更像是一个螺旋上升的过程,快到最后还有一点下降。。。写成这样也只是为了复习时能理清思路。首次尝试了画图以帮助理解,不过看上去效果不太行。汇编真是复杂

后期参考文章

  1. Assembly x86 - “leave” Instruction
  2. %eax vs (%eax)
  3. how does push and pop work in assembly
  4. X86-64寄存器和栈帧
  5. endbr32

同类文章

程序的内存布局——函数调用栈的那点事

后记

暑假开始读CSAPP,对x86-64有了更多的了解。这里记录一下与这篇文章相关的、让我印象深刻的部分:

  1. 指令的更多类型与用途

    MOV类、lea的两种用法、跳转指令、条件码

  2. C语言条件分支、循环的不同实现

  3. 函数调用的细节

    • 当函数栈帧长度确定时(比如不使用VLA、alloca函数),只需要用rsp寄存器就能完成函数的进入和退出。(加入-g选项时仍使用rbp寄存器,便于调试)

    • 被调用者保存寄存器(Callee-saved register)、调用者保存寄存器(Called-saved register)

  4. 编译器强大的优化能力

感谢浏览!