一般来讲,应用程序使用的内存空间里有如下的默认区域:
1 栈:用于维护函数调用的上下文。栈通常在用户空间的最高地址出分配,通常有数兆字节的大小
2 堆:堆是用来容纳应用程序动态分配的内存区域。比如使用malloc和new分配内存就从堆里分配。
3 可执行文件镜像:这里存储着可执行文件在内存里的映射
首先来介绍栈:
在操作系统中,栈总是向下增长的,栈顶由称为esp的寄存器进行定位,压栈的操作使栈顶的地址减小,弹出的操作使栈顶的地址增大。栈保存了一个函数调用所需要维护的信息,这通常称为堆栈帧或活动记录。堆栈帧包括如下几个方面的内容:
1 函数返回地址和参数
2 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
3 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
在i386中,一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又称为帧指针。常见的活动记录如下图所示:
在ebp之前首先是这个函数的返回地址,它的地址是ebp-4, 再往前是压入栈中的参数,它们的地址分别是ebp-8,ebp-12等等。ebp所直接指向的的数据是调用该函数前ebp的值,这样函数在返回的时候,ebp可以读取这个值恢复到调用前的值。所以一个i386的程序调用顺序如下:
1 把所有或者一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递
2 把当前指令的下一条指令的地址压入栈中
3 跳转到函数体执行
其中2,3由执行call一起执行。跳转到函数体之后就开始执行函数。I386函数体的标准开头过程如下:
1 push ebp: 把ebp压入栈中,也就是old ebp
2 move ebp,esp: ebp=esp(ebp指向栈顶,此时栈顶就是old ebp)
3 sub esp,xxx 在栈上分配xxx字节的临时空间
4 push xxx 保存名为xxx的寄存器
把ebp压入栈中,是为了在函数返回的时候便于恢复以前的ebp值,函数返回的时候过程正好相反。
1 pop xxx
2 mov esp,ebp 恢复esp同时收回局部变量空间
3 pop ebp:从栈中恢复保存的ebp的值
4 ret: 从栈中取得返回地址,并跳转到该位置。
我们用一个简单的函数调用然后查看汇编代码来看下这个过程
#include <stdio.h>
int foo()
{
return 123;
}
int main()
{
int ret;
ret=foo();
return 1;
}
objdump –d stack_test.o 可以看到如下结果
00000000000005fa :
5fa: 55 push %ebp
5fb: 48 89 e5 mov %esp,%ebp
5fe: b8 7b 00 00 00 mov $0x7b,%eax
603: 5d pop %ebp
604: c3 retq
在main中首先是将ebp进栈,然后是将esp赋值为ebp。同时将esp减去0x10,也就是开辟了0x10的栈空间。同样的过程在foo对应的汇编也可以看到。mov $0x7b,%eax 这条指令是将返回值123赋值给eax,同时将ebp值出栈
0000000000000605 :
605: 55 push %ebp
606: 48 89 e5 mov %esp,%ebp
609: 48 83 ec 10 sub $0x10,%esp
60d: b8 00 00 00 00 mov $0x0,%eax
612: e8 e3 ff ff ff callq 5fa
617: 89 45 fc mov %eax,-0x4(%ebp)
61a: b8 01 00 00 00 mov $0x1,%eax
61f: c9 leaveq
620: c3 retq
我们在把函数变更下使得foo函数带参数
#include <stdio.h>
int foo(int i, int j)
{
return 123;
}
int main()
{
int ret;
ret=foo(1,2);
return 1;
}
再看下汇编代码:可以看到参数i和j的入栈过程,首先在main中将参数值分别赋给esi和edi寄存器。然后在foo中分别将edi和esi的值存入到ebp+0x04和ebp-0x08的地址中。
00000000000005fa :
5fa: 55 push %ebp
5fb: 48 89 e5 mov %esp,%ebp
5fe: 89 7d fc mov %edi,-0x4(%ebp)
601: 89 75 f8 mov %esi,-0x8(%ebp)
604: b8 7b 00 00 00 mov $0x7b,%eax
609: 5d pop %ebp
60a: c3 retq
000000000000060b :
60b: 55 push %ebp
60c: 48 89 e5 mov %esp,%ebp
60f: 48 83 ec 10 sub $0x10,%esp
613: be 02 00 00 00 mov $0x2,%esi
618: bf 01 00 00 00 mov $0x1,%edi
61d: e8 d8 ff ff ff callq 5fa
622: 89 45 fc mov %eax,-0x4(%ebp)
625: b8 01 00 00 00 mov $0x1,%eax
62a: c9 leaveq
62b: c3 retq
62c: 0f 1f 40 00 nopl 0x0(%rax)
以一个框图来表示调用关系
函数返回值传递
除了参数的传递外,函数与调用方的另一个交互就是返回值。前面可以看到eax寄存器是传递返回值的通道。但是eax本身只有4个字节,那么大于4字节的返回值是如何传递的呢。对于返回5-8字节的情况,需要联合eax和edx联合返回的方式进行。eax存储低4字节,edx存储高4字节。但是对于超过8个字节的情况,就比较复杂了。以下面的例子为例:
#include <stdio.h>
typedef struct big_thing
{
char buf[128];
}big_thing;
big_thing return_test()
{
big_thing b;
b.buf[0]=0;
return b;
}
int main()
{
big_thing n=return_test();
}
对应的汇编代码: 将栈上的一个地址ebp-1D0h存储在eax中,然后将eax入栈,再调用return_test
lea eax,[ebp-1D0h]
push eax
call _return_test
这就相当于将eax的值作为了return_test的参数。但是实际上return_test是没有参数的,因此这个也被称为隐含参数。
下面这4行是一个整体,rep movs是一个复合指令,意思是重复movs指令知道ecx寄存器为0,于是rep movs a,b的意思就是将b指向位置的若干个双字节拷贝到a指向的位置上。相当于memcpy(ebp-88h,eax,0x20*4) ebp-88h就是n的地址
mov ecx ,20h
mov esi,eax
lea edi,[ebp-88h]
rep movs dword ptr es:[edi],dword ptr [esi]
再来看下return_test的实现。
lea esi, [ebp-88h]
mov edi,dword ptr [ebp+8]
rep movs dword ptr es:[edi], dword ptr [esi]
ebp+8指向函数的参数,ebp-88h指向的是变量b的位置,因此rep movs dword ptr es:[edi], dword ptr [esi]相当于memcpy([ebp+8],&b,128)。也就是将变量b地址的内容拷贝到传入的参数也就是ebp-1D0h这个地址去。
那么整个流程如下:
1 main在栈中开辟一段空间,并将这块空间的一部分作为传递返回值的临时对象,称为temp
2 将temp对象的地址作为隐藏参数传递给return_test参数
3 return_test将数据拷贝到temp对象
4 return_test返回之后,main函数将eax指向的temp对象拷贝给n