第4篇:戏说程序栈-栈帧
本篇详细讲解有关IA32约定中的程序栈帧,我栈顶到栈底的方向逐一回顾一下。
sss5.png
当前栈帧从栈顶到栈底如下构成
- 创建的参数表为要调用的函数建立的参数
- 局部变量:即在函数内部声明的变量(如果有)
- 保存的寄存器的上下文(如果有),当被调用函数返回之前,为调用者函数恢复原来的寄存器中的数据,如果当前架构有足够的空闲物理寄存器可供使用,一些寄存器信息可能不需要压入栈。
- 旧的帧指针,即前一栈帧的ebp指针*,它指向前一帧的栈底。
调用者函数的栈帧
- 参数列表
- 本地变量(如果调用者函数内部有声明局部变量)
- 返回地址:我们知道调用者函数内部的执行到call指令时会将call指令所在行的下一条指令的内存地址压入栈,当被调用者函数内部执行到ret指令后,从栈中弹出返回地址,以便调用者函数回到该地址对应的指令继续执行余下的指令。
具体例子
int add(int x,int y){
return x+y;
}
int main(void){
int s=11;
int b=23;
b=b+s;
return 0;
}
上面的汇编代码你会发现有很多以.cfi_为前缀的指令,这些称为cfi指令集,主要用于C/C++调试器执行并获取程序栈帧的状态信息,跟生成的汇编代码没任何关系,编译后的可执行程序也不会执行它们,所以不要理会它们。如果你非得转牛角尖(浪费时间),可以参考如下链接。如果你想生成干净的汇编代码的话,可以在编译的时候,加上 -fno-asynchronous-unwind-tables这个选项,在这个示例中
gcc -S hello.c -o hello.s -fno-asynchronous-unwind-tables
干净的汇编代码
我们看到上图,下买你我们会逐步用图例来讲解。
- “设定代码”就是每个函数的汇编版本初始化栈帧中通用初始化的操作,下面会用详细的图例进行讲解
- “主体代码”是业务代码对应的汇编版本,即一系列局部变量初始化和call指令调用其他函数等。
- 标号3的位置是“完成代码”就是被调用函数返回值的处理和清理当前函数栈帧占用的内存。
反编译示例
其实我们可以进一步都我们的代码进一步反编译可以更加彻底了解内部的操作,通过如下指令
gcc -m32 -g hello.s -o hello.out;
objdump -d ./hello.out > hello.dmp;
我们得到很多信息,首先我们搜索关键字main函数,我们会发现main函数的内存地址是0x80483ea,如图所示。
再进一步,通过80483ea这个关键字,我们进一步搜索,发现几个关键的信息:
- main函数对应的内存地址落在<_start>这个代码段,而<_start>这个标签是整个汇编程序的全局入口,这类似于C程序的main函数。
- 我们还发现在<_start>内部main的函数地址0x80483ea也被压入栈。如下图所示。
-
<_start>标签的内存地址是0x80482e0,这个地址可以作为我们进一步顺藤摸瓜的的条件。
2019-10-16 13-03-03屏幕截图.png
再次,当你尝试通过_start的地址去查找,基本上找不出什么东西了。但有一样东西可以肯定的是_start调用了__blibc_start_main等相关底层库函数用于支持我们main函数的执行。因为我们这里讨论的是跟用户定义的函数相关的话题,C底层的函数不是我们考虑的问题。
main函数的栈帧
- push %esp指令是用于前面一帧的ebp指针地址压入栈,与此同时%esp会从原来前一帧的位置向低地址下移始终指向最新的栈顶(暗含的指令subl 1,%esp)
- push %esp实际上就是当前栈帧对前一帧状态的一个备份操作。以便能够在当前栈还原前一帧的ebp指针。
-
mov %esp,%ebp将%esp的值覆盖%ebp的值,即当前esp指针指向的位置就是当面main函数的栈底。
-
sub $0x18,%esp,对esp指针中的地址值作减法操作esp下移动24个字节,这实质上已经改变esp指针中的地址值,这样做的目的是为我们接下来main内部的局部变量和要调用的被调用函数“add”需要用到的参数列表分配所需的栈空间。
-
movl $0xb,-4(%ebp) 和movl $0x17,-4(%ebp)分别将变量值11和23写入main栈内已分配的空间,这两条指令通过对ebp相对寻址的方式找到局部变量的栈内存位置,相对寻址并没有改变%ebp中的地址值。
分配局部变量 -
movl -4(%ebp),%eax指令就是将第二个局部变量缓存到eax寄存器中。
创建参数列表 -
movl %eax,0x4(%esp)指令将刚才eax缓存的变量值分配到栈的参数域。
-
接着下面的两条指令跟上面的两条都是做相同的事情,寄存器只是一个变量值的一个中转站。
- 到这里我们在main栈帧中参数区的参数表和局部变量中的顺序是相反的。
- 之前main栈帧在设置代码的阶段在分配了24个字节的空间,但实际上目前我们用到只有了16个字节,这种情况是非常常见的。但许多的文章根本就没向读取明确说明这一点。另外一种比较复杂的情况,如果入栈的是一个用户自定义的数据类型的struct,可能会和其他基本数据类似进行内存对齐,出去空的字节块的情况是非常频繁的。
-
call 80483dd就是执行add函数的地址,该地址位于80483dd的位置。如果你有看前文,call指令等价如下几个以下几条指令操作
- call指令的下一条指令的地址会作为返回地址入栈(即:push 8048410)。
- esp指针指向栈顶(即subl $1,%esp)。
- 最后隐式执行jmp 80483dd这条指令 ,此时将从main函数的上下文跳转到被调用函数add的上下文。
add函数的栈帧
当main执行call指令后,跳转到add函数的上下文,如下图
add函数的上下文
- 这里关于add函数的设定代码部分,同理跟之前main函数的设定代码部分的分析没多大出入。
-
mov 0xc(%ebp),%eax 和mov 0x8(%ebp),%eax分别从上一帧main的栈帧获取参数23和参数11,分别加载到寄存器eax和寄存器edx,以便下一步计算使用,如下图所示。
-
add %edx,%eax指令和刚才寄存器edx和寄存器eax的参数值副本执行加法运算,计算后的结果将保存到eax寄存器。
-
pop %ebp指令其实弹出上一栈main栈帧的ebp地址告知CPU让寄存器ebp将(注意我用的字眼,表示还没有执行完成的状态)重新指向main栈帧的栈底(即:图中我假设的地址0xff71b),实质上pop等价如下几条指令的操作。
-
mov -(%esp),%esp :esp将指向0xff700即上一帧main的返回地址所在帧的内存地址。
mov -(%esp),%esp
-
mov -(%esp),%esp :esp将指向0xff700即上一帧main的返回地址所在帧的内存地址。
-
mov $0xff71b,%ebp :被弹出的0xff71b的地址值会覆盖ebp目前的内容,此时ebp会指向如图中的0xff71b
mov $0xff71b,%ebp执行时的状态
-
ret指令主要隐式地执行两条指令
- pop 0x804841从栈中弹出这个返回地址
-
mov 0x804841,%eip这条指令会将返回地址覆盖eip寄存器中的值,在没有语句之时,但尚未转移到返回地址的瞬间状态如下图。
ret指令
按照惯例,被调用者函数的返回值会放在eax寄存器中,eax的选择是相当随意的,可能是%ecx或%edx等,具体根据不同的C/C++编译器的实现而定。
被调用者函数在执行ret指令时,会将(计算过)的适合4个字节的任意类型的返回值保存到(通常是%eax)寄存器,也可能是其他寄存器, x86环境中的eax寄存器只有4个字节。如果要返回大于4个字节的数据类型,最好的方式是返回一个自定义类型的对象的指针,而不是对象本身。
-
返回时,调用者函数在%eax寄存器(也可能是其他寄存器)中找到返回值。如下图,我们的main函数栈帧,会将eax寄存器的返回值保存到便来那个域当中。
- 当然,main函数也会存在返回值的情况,下面同样的情况,main函数在执行ret指令之前也将返回值0,写入的寄存器eax。
main的返回值也被加载到eax寄存器,等待返回给更上一帧的C/C++库函数 - 最后要说的指令就是leave指令,它隐式等价执行如下语句,他们用于清理main栈帧中的所有局部变量和参数等占用的栈空间。
- mov %ebp,%esp
-
pop %ebp
leave指令的执行
后记
本篇的解决了前一篇提出的许多问题点,并以详实的例子覆盖了栈的大部分话题。目前没有具体提及到寄存器保存约定的细节,因为用到例子的计算就两个操作数字的加法运算本来就不需要栈将寄存器的状态数据进行入栈操作。而实际上,在高层语言设计复杂的算法的时候当转译成汇编的代码不外乎乘除加减,位运算等一系列基础的运算操作以及大量的加载和存储的指令集(move),那么必然涉及到被调用函数在call 被调用函数之前,需要寄存器作为操作数参与一系列的运算和存储计算的中间结果,但CPU中的物理寄存器数量是有限的资源,并且同一个寄存器也可能被其他线程中的函数向其写入数据,那么之前计算的计算结果必然遭到破坏。因此就有了栈寄存器状态执行入栈保存其数据状态,等到需要的时候,再从栈内弹出以供调用函数使用,那么这个话题后面有空会慢慢补上。