Linux 下函数栈帧分析

1、关于栈

对于程序,编译器会对其分配一段内存,在逻辑上可以分为代码段,数据段,堆,栈

  • 代码段:保存程序文本,指令指针EIP就是指向代码段,可读可执行不可写
  • 数据段:保存初始化的全局变量和静态变量,可读可写不可执行
  • BSS:未初始化的全局变量和静态变量
  • 堆(Heap):动态分配内存,向地址增大的方向增长,可读可写可执行
  • 栈(Stack):存放局部变量,函数参数,当前状态,函数调用信息等,向地址减小的方向增长,非常非常重要,可读可写可执行。如下图所示:

这里写图片描述

首先必须明确一点也是非常重要的一点,栈是向下生长的,所谓向下生长是指从内存高地址->低地址的路径延伸,那么就很明显了,栈有栈底和栈顶,那么栈顶的地址要比栈底低。对x86体系的CPU而言,其中
—> 寄存器ebp(base pointer )可称为“帧指针”或“基址指针”,其实语意是相同的。
—> 寄存器esp(stack pointer)可称为“ 栈指针”。
要知道的是:
—> ebp 在未受改变之前始终指向栈帧的开始,也就是栈底,所以ebp的用途是在堆栈中寻址用的。
—> esp是会随着数据的入栈和出栈移动的,也就是说,esp始终指向栈顶。
见下图,假设函数A调用函数B,我们称A函数为"调用者",B函数为“被调用者”则函数调用过程可以这么描述:
(1)先将调用者(A)的堆栈的基址(ebp)入栈,以保存之前任务的信息。
(2)然后将调用者(A)的栈顶指针(esp)的值赋给ebp,作为新的基址(即被调用者B的栈底)。
(3)然后在这个基址(被调用者B的栈底)上开辟(一般用sub指令)相应的空间用作被调用者B的栈空间。
(4)函数B返回后,从当前栈帧的ebp即恢复为调用者A的栈顶(esp),使栈顶恢复函数B被调用前的位置;然后调用者A再从恢复后的栈顶可弹出之前的ebp值(可以这么做是因为这个值在函数调用前一步被压入堆栈)。这样,ebp和esp就都恢复了调用函数B前的位置,也就是栈恢复函数B调用前的状态。
这个过程在AT&T汇编中通过两条指令完成,即:
leave
ret
这两条指令更直白点就相当于:
mov %ebp , %esp
pop %ebp
这里写图片描述

2、简单实例

开发测试环境:
Linux ubuntu 3.11.0-12-generic
gcc版本:gcc version 4.8.1 (Ubuntu/Linaro 4.8.1-10ubuntu8)
下面我们用一段代码说明上述过程:

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 -g Code.c ,加上-g,那么用objdump -S a.out 反汇编时可以把C代码和汇编代码穿插起来显示,这样C代码和汇编代码的对应关系看得更清楚。反汇编的结果很长,以下只列出我们关心的部分。

080483ed <bar>:
int bar(int c, int d)
{
 80483ed:	55                   	push   %ebp
 80483ee:	89 e5                	mov    %esp,%ebp
 80483f0:	83 ec 10             	sub    $0x10,%esp
	int e = c + d;
 80483f3:	8b 45 0c             	mov    0xc(%ebp),%eax
 80483f6:	8b 55 08             	mov    0x8(%ebp),%edx
 80483f9:	01 d0                	add    %edx,%eax
 80483fb:	89 45 fc             	mov    %eax,-0x4(%ebp)
	return e;
 80483fe:	8b 45 fc             	mov    -0x4(%ebp),%eax
}
 8048401:	c9                   	leave  
 8048402:	c3                   	ret    

08048403 <foo>:

int foo(int a, int b)
{
 8048403:	55                   	push   %ebp
 8048404:	89 e5                	mov    %esp,%ebp
 8048406:	83 ec 08             	sub    $0x8,%esp
	return bar(a, b);
 8048409:	8b 45 0c             	mov    0xc(%ebp),%eax
 804840c:	89 44 24 04          	mov    %eax,0x4(%esp)
 8048410:	8b 45 08             	mov    0x8(%ebp),%eax
 8048413:	89 04 24             	mov    %eax,(%esp)
 8048416:	e8 d2 ff ff ff       	call   80483ed <bar>
}
 804841b:	c9                   	leave  
 804841c:	c3                   	ret    

0804841d <main>:

int main(void)
{
 804841d:	55                   	push   %ebp
 804841e:	89 e5                	mov    %esp,%ebp
 8048420:	83 ec 08             	sub    $0x8,%esp
	foo(2, 3);
 8048423:	c7 44 24 04 03 00 00 	movl   $0x3,0x4(%esp)
 804842a:	00 
 804842b:	c7 04 24 02 00 00 00 	movl   $0x2,(%esp)
 8048432:	e8 cc ff ff ff       	call   8048403 <foo>
	return 0;
 8048437:	b8 00 00 00 00       	mov    $0x0,%eax
}
 804843c:	c9                   	leave  
 804843d:	c3                   	ret    

整个程序的执行过程是main调用foo,foo调用bar,我们用gdb跟踪程序的执行,直到bar函数中的int e = c + d;语句执行完毕准备返回时,这时在gdb中打印函数栈帧,因为此时栈已经生长到最大。

ZP1015@ubuntu:~/Desktop/c/Machine_Code$ gdb a.out 
GNU gdb (GDB) 7.6.1-ubuntu
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i686-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/ZP1015/Desktop/c/Machine_Code/a.out...done.
(gdb) start
Temporary breakpoint 1 at 0x8048423: file Code.c, line 14.
Starting program: /home/ZP1015/Desktop/c/Machine_Code/a.out 

Temporary breakpoint 1, main () at Code.c:14
14		foo(2, 3);
(gdb) s
foo (a=2, b=3) at Code.c:9
9		return bar(a, b);
(gdb) s
bar (c=2, d=3) at Code.c:3
3		int e = c + d;
(gdb) disas
Dump of assembler code for function bar:
   0x080483ed <+0>:	push   %ebp
   0x080483ee <+1>:	mov    %esp,%ebp
   0x080483f0 <+3>:	sub    $0x10,%esp
=> 0x080483f3 <+6>:	mov    0xc(%ebp),%eax
   0x080483f6 <+9>:	mov    0x8(%ebp),%edx
   0x080483f9 <+12>:	add    %edx,%eax
   0x080483fb <+14>:	mov    %eax,-0x4(%ebp)
   0x080483fe <+17>:	mov    -0x4(%ebp),%eax
   0x08048401 <+20>:	leave  
   0x08048402 <+21>:	ret    
End of assembler dump.
(gdb) si
0x080483f6	3		int e = c + d;
(gdb) 
0x080483f9	3		int e = c + d;
(gdb) 
0x080483fb	3		int e = c + d;
(gdb) 
4		return e;
(gdb) 
5	}
(gdb) bt
#0  bar (c=2, d=3) at Code.c:5
#1  0x0804841b in foo (a=2, b=3) at Code.c:9
#2  0x08048437 in main () at Code.c:14
(gdb) info registers
eax            0x5	5
ecx            0xbffff724	-1073744092
edx            0x2	2
ebx            0xb7fc4000	-1208205312
esp            0xbffff658	0xbffff658
ebp            0xbffff668	0xbffff668
esi            0x0	0
edi            0x0	0
eip            0x8048401	0x8048401 <bar+20>
eflags         0x206	[ PF IF ]
cs             0x73	115
ss             0x7b	123
ds             0x7b	123
es             0x7b	123
fs             0x0	0
gs             0x33	51
(gdb) x/20x $esp
0xbffff658:	0x0804a000	0x08048492	0x00000001	0x00000005
0xbffff668:	0xbffff678	0x0804841b	0x00000002	0x00000003
0xbffff678:	0xbffff688	0x08048437	0x00000002	0x00000003
0xbffff688:	0x00000000	0xb7e2d905	0x00000001	0xbffff724
0xbffff698:	0xbffff72c	0xb7fff000	0x0000002a	0x00000000
(gdb) 

下面从主函数开始,一步一步分析函数调用过程:

0804841d <main>:

int main(void)
{
 804841d:	55                   	push   %ebp
 804841e:	89 e5                	mov    %esp,%ebp
 8048420:	83 ec 08             	sub    $0x8,%esp
	foo(2, 3);
 8048423:	c7 44 24 04 03 00 00 	movl   $0x3,0x4(%esp)
 804842a:	00 
 804842b:	c7 04 24 02 00 00 00 	movl   $0x2,(%esp)
 8048432:	e8 cc ff ff ff       	call   8048403 <foo>

要调用函数foo先要把参数准备好,第二个参数保存在esp+4指向的内存位置,第一个参数保存在esp指向的内存位置,可见参数是从右向左依次压栈的。然后执行call指令,这个指令有两个作用:

  1. foo函数调用完之后要返回到call的下一条指令继续执行,所以把call的下一条指令的地址0x8048437压栈,同时把esp的值减4
  2. 修改程序计数器eip,跳转到foo函数的开头执行。
int foo(int a, int b)
{
 8048403:	55                   	push   %ebp
 8048404:	89 e5                	mov    %esp,%ebp
 8048406:	83 ec 08             	sub    $0x8,%esp
	return bar(a, b);
 8048409:	8b 45 0c             	mov    0xc(%ebp),%eax
 804840c:	89 44 24 04          	mov    %eax,0x4(%esp)
 8048410:	8b 45 08             	mov    0x8(%ebp),%eax
 8048413:	89 04 24             	mov    %eax,(%esp)
 8048416:	e8 d2 ff ff ff       	call   80483ed <bar>

push %ebp指令把ebp寄存器的值压栈,同时把esp的值减4。这两条指令合起来是把原来ebp的值保存在栈上,然后又给ebp赋了新值。在每个函数的栈帧中,ebp指向栈底,而esp指向栈顶,在函数执行过程中esp随着压栈和出栈操作随时变化,而ebp是不动的,函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问,例如foo函数的参数a和b分别通过ebp+8和ebp+12来访问。所以下面的指令把参数a和b再次压栈,为调用bar函数做准备,然后把返回地址压栈,调用bar函数:

080483ed <bar>:
int bar(int c, int d)
{
 80483ed:	55                   	push   %ebp  
 80483ee:	89 e5                	mov    %esp,%ebp
 80483f0:	83 ec 10             	sub    $0x10,%esp
	int e = c + d;
 80483f3:	8b 45 0c             	mov    0xc(%ebp),%eax
 80483f6:	8b 55 08             	mov    0x8(%ebp),%edx
 80483f9:	01 d0                	add    %edx,%eax
 80483fb:	89 45 fc             	mov    %eax,-0x4(%ebp)
	return e;
 80483fe:	8b 45 fc             	mov    -0x4(%ebp),%eax
}
 8048401:	c9                   	leave  
 8048402:	c3                   	ret    

08048403 <foo>:

这次又把foo函数的ebp压栈保存,然后给ebp赋了新值,指向bar函数栈帧的栈底,通过ebp+8和ebp+12分别可以访问参数c和d。bar函数还有一个局部变量e,可以通过ebp-4来访问。所以后面几条指令的意思是把参数c和d取出来存在寄存器中做加法,计算结果保存在eax寄存器中,再把eax寄存器存回局部变量e的内存单元。bar函数有一个int型的返回值,这个返回值是通过eax寄存器传递的,所以首先把e的值读到eax寄存器中,然后执行leave指令,最后是ret指令。

在gdb中可以用bt命令和frame命令查看每层栈帧上的参数和局部变量,现在可以解释它的工作原理了:如果我当前在bar函数中,我可以通过ebp找到bar函数的参数和局部变量,也可以找到foo函数的ebp保存在栈上的值,有了foo函数的ebp,又可以找到它的参数和局部变量,也可以找到main函数的ebp保存在栈上的值,因此各层函数栈帧通过保存在栈上的ebp的值串起来了。

地址0x804841b处是foo函数的返回指令:

}
 804841b:	c9                   	leave  
 804841c:	c3                   	ret    

重复同样的过程,又返回到了main函数。

	return 0;
 8048437:	b8 00 00 00 00       	mov    $0x0,%eax
}
 804843c:	c9                   	leave  
 804843d:	c3                   	ret    

整个函数执行完毕。

函数调用和返回过程中的需要注意这些规则:

  1. 参数压栈传递,并且是从右向左依次压栈。
  2. ebp总是指向当前栈帧的栈底。
  3. 返回值通过eax寄存器传递。

局部变量申请栈空间时的入栈顺序
在没有溢出保护机制下的编译时,我们可以发现,所有的局部变量入栈的顺序(准确来说是系统为局部变量申请内存中栈空间的顺序)是正向的,即哪个变量先申明哪个变量就先得到空间,
也就是说,编译器给变量空间的申请是直接按照变量申请顺序执行的。
在有溢出保护机制下的编译时,情况有了顺序上的变化,对于每一种类型的变量来说,栈空间申请的顺序都与源代码中相反,即哪个变量在源代码中先出现则后申请空间;而对不同的变量来说,申请的顺序也不同,有例子可以看出,int型总是在char的buf型之后申请,不管源代码中的顺序如何(这应该来源于编译器在进行溢出保护时设下的规定)。

推荐博文:
c函数调用过程原理及函数栈帧分析

  • 4
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论
Linux 下,程序崩溃后可以通过调用信息来帮助我们定位问题。我们可以使用一些工具和技术来获取程序崩溃时的调用信息。 1. 使用 GDB 调试器 GDB 是一个功能强大的调试器,在程序崩溃时可以用它来获取调用信息。我们可以在终端中使用以下命令来使用 GDB 调试器: ``` gdb <程序名> ``` 进入 GDB 调试器后,可以使用 `bt` 命令来打印当前的调用信息。 2. 使用 Core Dump 文件 Core Dump 文件是程序崩溃时产生的一种文件,它可以包含程序崩溃时的状态信息,包括调用信息。我们可以使用一些工具来分析 Core Dump 文件,比如 GDB 或者 Valgrind 工具。 在 Linux 中,可以使用以下命令来开启 Core Dump 文件的生成: ``` ulimit -c unlimited ``` 这个命令会将 Core Dump 文件的大小设置为无限制,这样当程序崩溃时就会生成 Core Dump 文件。 3. 使用 backtrace() 函数 在程序崩溃时,我们可以使用 backtrace() 函数来获取当前的调用信息。这个函数定义在 execinfo.h 头文件中,使用时需要链接 libexecinfo 库。 backtrace() 函数的原型如下: ```c int backtrace(void **buffer, int size); ``` 第一个参数是一个指向 void* 类型的数组,用来存储调用信息;第二个参数是数组的大小,用来控制 backtrace() 函数最多返回多少个调用帧的信息。 在程序崩溃时,我们可以在程序的信号处理函数中调用 backtrace() 函数来获取当前的调用信息,然后输出到日志文件中。 4. 使用 libunwind 库 libunwind 是一个开源的 C/C++ 库,用于获取当前线程的调用信息。它可以在程序崩溃时获取调用信息,也可以在程序运行时获取调用信息。 使用 libunwind 库时,我们需要在程序中链接 libunwind 库,并且包含 libunwind.h 头文件。然后,我们可以使用 libunwind 库中的函数来获取当前线程的调用信息,例如: ```c #include <libunwind.h> void print_backtrace() { unw_cursor_t cursor; unw_context_t context; // 获取当前线程的上下文信息 unw_getcontext(&context); // 初始化 cursor unw_init_local(&cursor, &context); // 遍历调用帧,输出每个帧的信息 while (unw_step(&cursor) > 0) { unw_word_t ip; unw_get_reg(&cursor, UNW_REG_IP, &ip); printf("%lx\n", (long)ip); } } ``` 上面的代码中,我们定义了一个函数 `print_backtrace()`,它可以输出当前线程的调用信息。使用时,我们只需要在程序崩溃时调用这个函数即可。
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

狂奔的乌龟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值