汇编之路-复习栈操作
不得不承认上次关于栈桢和栈操作写得有些笼统,这里做一次“补充”,美名其曰:“复习”。
下面的这个例子几乎就能覆盖所有的栈操作相关的内容了。
void dummy()
{
int i = 12;
int j = 13;
char c = 'a';
}
int main()
{
dummy();
return 0;
}
下面是利用MDB(注[1])反汇编的代码:
> main::dis
main: pushl %ebp
main+1: movl %esp,%ebp
main+3: subl $8,%esp
main+6: andl $0xf0,%esp
main+9: movl $0,%eax
main+0xe: subl %eax,%esp
main+0×10: call -0x2a
main+0×15: movl $0,%eax
main+0x1a: leave
main+0x1b: ret
> dummy::dis
dummy: pushl %ebp
dummy+1: movl %esp,%ebp
dummy+3: subl $0xc,%esp
dummy+6: movl $0xc,-4(%ebp)
dummy+0xd: movl $0xd,-8(%ebp)
dummy+0×14: movb $0×61,-9(%ebp)
dummy+0×18: leave
dummy+0×19: ret
分析上面的汇编代码我们要解决如下几个方面问题:
1、过程调用的标准模式
我们知道发生过程调用的指令是call,那么call做了些什么呢?上面每个过程的最后都有leave指令,它又作了什么呢?我们不妨来跟踪一个栈帧的形成过程,分析后自然会有答案。
(1) 我们从main + 0×10处开始,这里是一个call指令,此时的活动栈帧为main的栈帧,dummy栈帧尚未形成:
+ + 0xffffffff
| |
+———-+
| | main的返回地址,属于main的调用者栈帧范畴
+———-+ —————————
| A | main栈帧栈底 <– %ebp
+———-+
| B |
+———-+
| C | main栈帧栈顶 <– %esp
+———-+
| |
+ + 0×00000000
(2) 调用call指令后,未执行dummy前,此时main的栈帧已经结束,%eip中存放dummy起始指令地址准备执行。
+ + 0xffffffff
| |
+———-+
| | main的返回地址,属于main的调用者栈帧范畴
+———-+ —————————
| A | main栈帧栈底 <— %ebp
+———-+
| B |
+———-+
| C |
+———-+
| | dummy的返回地址, main栈帧栈顶 <– %esp
+———-+ —————————
| |
+ + 0×00000000
可见call首先将main调用的函数(这里是dummy)的返回地址pushl到栈中,形成main栈帧的最后一个部分,然后跳到dummy的起始处。所以call等价于下面两条指令:
pushl %eip //将下一条指令地址压入栈中
jmp dummy
(3) 形成dummy栈帧
dummy首先将main的栈底保存起来,然后创建自己的栈底。
+ + 0xffffffff
| |
+———-+
| | dummy的返回地址,属于main的栈帧范畴
+———-+ —————————
| D | dummy栈帧栈底 <– %ebp,存储着main栈帧栈底
+———-+
| E |
+———-+
| F | dummy栈帧栈顶 <– %esp
+———-+ —————————
| |
+ + 0×00000000
(4) dummy返回
dummy返回时调用的第一条指令leave,该指令相当于如下两条指令:
指令1: movl %ebp %esp // 将%esp置到dummy栈桢首部
该指令执行后状态如下:
+ + 0xffffffff
| |
+———-+
| | dummy的返回地址,属于main的栈帧范畴
+———-+ —————————
| D | dummy栈帧栈底 <– %esp <– %ebp
+———-+
| E |
+———-+
| F | dummy栈帧栈顶
+———-+ —————————
| |
+ + 0×00000000
指令2:popl %ebp
该指令执行后状态如下:
+ + 0xffffffff
| |
+———-+
| | main的返回地址,属于main的调用者栈帧范畴
+———-+ —————————-
| A | main栈帧栈底 <— %ebp
+———-+
| B |
+———-+
| C |
+———-+
| | dummy的返回地址,main栈帧栈顶 <– %esp
+———-+ —————————
| D | dummy栈帧栈底
+———-+
| E |
+———-+
| F | dummy栈帧栈顶
+———-+ —————————
| |
+ + 0×00000000
dummy返回时调用的第二条指令ret,该指令相当于popl %eip,执行完内存栈的情况如下:
+ + 0xffffffff
| |
+———-+
| | main的返回地址,属于main的调用者栈帧范畴
+———-+ —————————-
| A | main栈帧栈底 <— %ebp
+———-+
| B |
+———-+
| C | <– %esp main栈帧栈顶
+———-+
| | dummy的返回地址
+———-+ —————————
| D | dummy栈帧栈底
+———-+
| E |
+———-+
| F | dummy栈帧栈顶
+———-+ —————————
| |
+ + 0×00000000
至此,main的栈桢又再次被恢复了。
经过上面分析,得出过程调用标准模式如下:
pushl %ebp
movl %esp %ebp
…
//过程体
…
leave
ret
其中ret和call对应,而leave则和最开始的那两句对应。
2、访问局部变量
在dummy的汇编码中我们可以清晰的看到对三个局部变量i,j,c的赋值语句:
movl $0xc,-4(%ebp)
movl $0xd,-8(%ebp)
movb $0×61,-9(%ebp)
其三者有一个共同点就是“都是通过对%ebp的偏移来访问局部变量的”。
3、局部变量的分配
两个以上的局部变量的栈上分配涉及到栈内存的对齐问题,dummy的代码足以说明问题。我们在dummy的栈桢中分配了两个整型和一个char型变量,实际需要9个字节。那我们来看看汇编是否给我们只分配了9个字节呢?
movl %esp,%ebp
subl $0xc,%esp
movl $0xc,-4(%ebp)
…
可以看出subl $0xc,%esp一句在内存栈上为我们留出12个字节的空间,在char c的后面又多分了3个字节,以保证对后面的变量的地址访问是对齐的。
4、对异构类型变量的分配和访问
举例如下:
struct test_t {
int i;
int j;
int a[3];
};
void dummy()
{
struct test_t t;
t.i = 11;
t.j = 12;
t.a[0] = 'a';
t.a[1] = 'b';
t.a[2] = 'c';
}
int main()
{
dummy();
return 0;
}
> dummy::dis
dummy: pushl %ebp
dummy+1: movl %esp,%ebp
dummy+3: subl $0×28,%esp
dummy+6: movl $0xb,-0×28(%ebp)
dummy+0xd: movl $0xc,-0×24(%ebp)
dummy+0×14: movl $0×61,-0×20(%ebp)
dummy+0x1b: movl $0×62,-0x1c(%ebp)
dummy+0×22: movl $0×63,-0×18(%ebp)
dummy+0×29: leave
dummy+0x2a: ret
与上面的例子不同的是这次为了存储一个test_t类型结构,栈居然留出了0×28(40d)大小的空间,在t.a[2]与%ebp之间留了0×14(20)个字节空闲。这里的原因不得而知。如果是为了对齐,那么这个代价着实不小。
[注1]
在X86平台的Solaris9上,GDB反汇编使用的语法与我们的稍有差异,而使用Solaris自带的MDB(The Modular Debugger)则和我们的汇编语法保持一致。顺便说一句MDB是一个强大的调试工具,在Sun公司的网站上有其详细的使用说明。
评论