指针、函数、引用
函数栈帧的建立和销毁
我们在初学C语言时经常听到这一句:实参的值不随形参的变化而变化。这句话不少人似懂非懂,以为懂了其实根本不知道说的是啥,就像高中学的奇变偶不变符号看象限。下面通过底层原理的理解让你彻底搞懂。首先我们需要了解一下函数栈帧的建立和销毁,以便我们后续的理解。
首先回顾汇编语法:懂得直接跳过。
汇编常见指令及用法(来自《程序是怎样跑起来的》,部分术语有差异)
操作码 | 操作数 | 功能 |
---|---|---|
add | A,B | 把A的值和B的值相加,并把结果存入A |
cmp | A,B | 对A和B的值进行比较,比较结果会自动存入标志寄存器中 |
inc | A | A的值加1 |
ige | 标签名 | 和cmp命令组合使用。跳转到标签行 |
jl | 标签名 | 和cmp命令组合使用。跳转到标签行 |
jle | 标签名 | 和cmp命令组合使用。跳转到标签行 |
jmp | 标签名 | 将控制无条件跳转到指定标签行 |
mov | A,B | 把B的值赋值给A |
pop | A | 从栈中读取出数值并存入A中 |
push | A | 把A的值存入栈中 |
ret | 无 | 将处理返回到调用源 |
xor | A,B | A和B的位进行异或比较,并将结果存入A中 |
x86常用寄存器及其功能(来自《程序是怎样跑起来的》,部分术语有差异)
寄存器名 | 名称 | 主要功能 |
---|---|---|
eax | 累加寄存器 | 运算 |
ebx | 基址寄存器 | 存储内存地址 |
ecx | 计数寄存器 | 计算循环次数 |
edx | 数据计数器 | 存储数据 |
esi | 源基址寄存器 | 存储数据发送源的内存地址 |
edi | 目标基址寄存器 | 存储数据发送目标的内存地址 |
ebp | 扩展基址指针寄存器 | 存储数据存储领域基点的内存地址 |
esp | 扩展栈指针寄存器 | 存储栈中最高位数据的内存地址 |
esp在本篇博客里理解为栈顶指针,ebp在本篇博客里理解为栈底指针
下面我以《Linux C编程一站式学习》 19.1《函数调用》 这一节的代码来讲:
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;
}
对应的汇编代码如下:
$ gcc main.c -g
$ objdump -dS a.out
......
08048394 <bar>:
int bar(int c, int d)
{
8048394: 55 push %ebp
8048395: 89 e5 mov %esp,%ebp
8048397: 83 ec 10 sub $0x10,%esp
int e = c + d;
804839a: 8b 55 0c mov 0xc(%ebp),%edx
804839d: 8b 45 08 mov 0x8(%ebp),%eax
80483a0: 01 d0 add %edx,%eax
80483a2: 89 45 fc mov %eax,-0x4(%ebp)
return e;
80483a5: 8b 45 fc mov -0x4(%ebp),%eax
}
80483a8: c9 leave
80483a9: c3 ret
080483aa <foo>:
int foo(int a, int b)
{
80483aa: 55 push %ebp
80483ab: 89 e5 mov %esp,%ebp
80483ad: 83 ec 08 sub $0x8,%esp
return bar(a, b);
80483b0: 8b 45 0c mov 0xc(%ebp),%eax
80483b3: 89 44 24 04 mov %eax,0x4(%esp)
80483b7: 8b 45 08 mov 0x8(%ebp),%eax
80483ba: 89 04 24 mov %eax,(%esp)
80483bd: e8 d2 ff ff ff call 8048394 <bar>
}
80483c2: c9 leave
80483c3: c3 ret
080483c4 <main>:
int main(void)
{
80483c4: 8d 4c 24 04 lea 0x4(%esp),%ecx
80483c8: 83 e4 f0 and $0xfffffff0,%esp
80483cb: ff 71 fc pushl -0x4(%ecx)
80483ce: 55 push %ebp
80483cf: 89 e5 mov %esp,%ebp
80483d1: 51 push %ecx
80483d2: 83 ec 08 sub $0x8,%esp
foo(2, 3);
80483d5: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp)
80483dc: 00
80483dd: c7 04 24 02 00 00 00 movl $0x2,(%esp)
80483e4: e8 c1 ff ff ff call 80483aa <foo>
return 0;
80483e9: b8 00 00 00 00 mov $0x0,%eax
}
80483ee: 83 c4 08 add $0x8,%esp
80483f1: 59 pop %ecx
80483f2: 5d pop %ebp
80483f3: 8d 61 fc lea -0x4(%ecx),%esp
80483f6: c3 ret
......
1.main函数调用foo 函数
foo(2, 3);
80483d5: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp)
80483dc: 00
80483dd: c7 04 24 02 00 00 00 movl $0x2,(%esp)
80483e4: e8 c1 ff ff ff call 80483aa <foo>
return 0;
80483e9: b8 00 00 00 00 mov $0x0,%eax
这个地方的汇编代码和我之前 的汇编系列有一点语法上的不同,但是原理大致相同,首先看第一句:
movl $0x3,0x4(%esp),这句话 意思是将立即数(旧版立即数前要加$号)16位制 3 送给地址为esp+4所指向的内存位置,类似于 mov [esp+4] , 3 .第二句类似mov [esp] , 2. 注意:C语言是参数是从右向左依次压栈的。
此时的函数栈帧:
之后call 80483aa 是调用foo函数,这个指令有两个作用:
- foo函数调用完之后要返回call的下一条指令继续执行,所以把call的下一条指令的地址0x80483e9压栈,同时把esp的值减4,esp的值现在是0xbf822d18。
- 修改程序计数器eip,跳转到foo函数的开头执行。
2.foo函数(1)
int foo(int a, int b)
{
80483aa: 55 push %ebp
80483ab: 89 e5 mov %esp,%ebp
80483ad: 83 ec 08 sub $0x8,%esp
首先将ebp寄存器的值(80483e9)压栈,同时把esp的值再减4,esp的值现在是0xbf822d14。在每个函数的栈帧中,ebp指向栈底,而esp指向栈顶,在函数执行过程中esp随着压栈和出栈操作随时变化,而ebp是不动的,函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问的.
然后再把esp的值-8.
现.在从ebp到esp就是foo函数的栈帧。如图:
到此我们总结一下:调用函数我们要做的两件事:
(1)将原函数下一条指令的地址压栈
(1)将原函数起始地址压栈(就是将ebp的值压栈)
3.foo函数调用bar函数
return bar(a, b);
80483b0: 8b 45 0c mov 0xc(%ebp),%eax
80483b3: 89 44 24 04 mov %eax,0x4(%esp)
80483b7: 8b 45 08 mov 0x8(%ebp),%eax
80483ba: 89 04 24 mov %eax,(%esp)
80483bd: e8 d2 ff ff ff call 8048394 <bar>
第一句:mov 0xc(%ebp),%eax 将地址为ebp+12的内容送入eax寄存器,第二句将eax寄存器的内容送入地址为esp+4的位置。同理3,4句将ebp+8的内容送入地址为esp的位置。
call 8048394 ,见下:
4.bar函数(1)
int bar(int c, int d)
{
8048394: 55 push %ebp
8048395: 89 e5 mov %esp,%ebp
8048397: 83 ec 10 sub $0x10,%esp
int e = c + d;
804839a: 8b 55 0c mov 0xc(%ebp),%edx
804839d: 8b 45 08 mov 0x8(%ebp),%eax
80483a0: 01 d0 add %edx,%eax
80483a2: 89 45 fc mov %eax,-0x4(%ebp)
参照上图,push %ebp 将ebp的值压栈,mov %esp,%ebp,将esp 的值送给ebp,所以ebp的值是上图的esp的值,也就是bff1c408.
sub $0x10,%esp 将上一个esp的值-16,就是现在的bff1c3f4.然后是将地址为ebp-8的内容和地址为ebp-12 的内容加起来送到地址ebp-4。
4.bar函数(2)
return e;
80483a5: 8b 45 fc mov -0x4(%ebp),%eax
}
80483a8: c9 leave
80483a9: c3 ret
bar函数有一个int型的返回值,这个返回值是通过eax寄存器传递的,所以首先把e的值读
到eax寄存器中。然后执行leave指令,这个指令是函数开头的push %ebp和mov %esp,%ebp的逆
操作:
- 把ebp的值赋给esp,现在esp的值是0xbf822d04。
- 现在esp所指向的栈顶保存着foo函数栈帧的ebp,把这个值恢复给ebp,
- 同时esp增加4,现在esp的值是0xbf822d08。
最后是ret指令,它是call指令的逆操作:
- 现在esp所指向的栈顶保存着返回地址,把这个值恢复给eip,同时esp增加4,现在esp的
值是0xbf822d0c。 - 修改了程序计数器eip,因此跳转到返回地址0x80483c2继续执行。
这样我们就完成了函数栈帧的销毁,这下大家明白为什么要做这两件事了吧:
(1)将原函数下一条指令的地址压栈
(1)将原函数起始地址压栈(就是将ebp的值压栈)
foo函数的返回与之类似:
80483c2: c9 leave
80483c3: c3 ret
Parameter 形参 是函数定义的地方的参数,Argument实参 调用函数时的参数。比如main函数调用foo时在main函数栈帧的就是Argument,在foo函数栈帧里的就是Parameter 。如果被调函数需要使用调用函数传的参,会在被调函数的栈帧里复制一份,就像c,d复制a,b。当被调函数栈帧销毁时,c,d也就不存在了,这样无论foo函数中对c,d怎么变化也不会影响a,b.
“We will generally use parameter for a variable named in the parenthesized list in a function definition, and argument for the value used in a call of the function. The terms formal argument and actual argument are sometimes used for the same distinction.”——《The C Programming Language》Section 1.7 K&R
由于这个例子的函数没有用到返回值,建议大家学完再看看下面的博客;
更多关于函数栈帧的建立与销毁参考
《程序是怎样跑起来的》第10章
https://dins.site/coding-basics-assembly-function-chs/
参考资料:
《彻底搞定c指针》
《程序是怎样跑起来的》第10章
https://courses.cs.washington.edu/courses/cse333/22sp/lectures/03-c-pointers.pdf