为了进一步了解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
只截取我们关心的部分:
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 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的作用:
在刚进入函数时,rbp指向当前函数的栈底,可以使用间接寻址 访问上一个栈帧的栈底地址 。
rsp指向当前函数的栈顶,在函数中偏移频繁。
函数的参数和局部变量都是通过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的逆操作,等价于:
回到bar函数,这里leaveq是push %rbp和mov %rsp, %rbp的逆操作,等价于:
最终结果:
总结 这篇文章是一气呵成的,写到后面已经感觉不太行了,知识储备不足,导致写得一点也不像实验 (感觉也出不了什么结果,都是在网上搜集的资料)。并且,事实上,真正的实验流程远没有这么顺利,更像是一个螺旋上升 的过程,快到最后还有一点下降。。。写成这样也只是为了复习时能理清思路。首次尝试了画图 以帮助理解,不过看上去效果不太行。汇编真是复杂 。
后期参考文章
Assembly x86 - “leave” Instruction
%eax vs (%eax)
how does push and pop work in assembly
X86-64寄存器和栈帧
endbr32
同类文章 程序的内存布局——函数调用栈的那点事
后记 暑假开始读CSAPP,对x86-64有了更多的了解。这里记录一下与这篇文章相关的、让我印象深刻的部分:
指令的更多类型与用途
MOV类、lea的两种用法、跳转指令、条件码
C语言条件分支、循环的不同实现
函数调用的细节
编译器强大的优化能力
感谢浏览!