函数调用时程序内存地址空间里栈的变化
前言
C\C++代码在编译链接后生成机器码文件。我们打开此机器码文件(即打开应用程序)后,系统自动为这个程序分配一个2^32(操作系统位数)大小的虚拟内存地址空间。这个地址空间会被系统安排成几个分区,比如用户模式分区、内核模式分区等等[1]。
其中,用户模式分区又被分为常量区、静态数据区、堆区、栈区和代码区(而机器码内容就被分配到用户模式分区下,这些机器码指令随后会按照顺序被送往CPU里运行)。今天我说的内容就涉及栈区和代码区两个部分。
环境
1.OS - windows
2. IDE - CodeBlocks 16.01
3. 编译器 - mingw32-g++.exe
代码
1 int Add(int x,int &y)
2 {
3 int z = 0;
4 z = x + y;
5 return z;
6 }
7
8 int main(int argc, char *argv[])
9 {
10 int attest = 10;
11 int bttest = 20;
12 int cttest = Add(attest,bttest);
13 printf("Programme End!\n");
14
15 return 0;
16 }
esp和ebp
CPU里有两类寄存器,通用寄存器和专用寄存器。它们就属于专用寄存器。
在一个函数栈里,esp里始终保存着当前栈的栈顶地址;而ebp始终保存着当前栈的栈底地址。比如根据图三中的esp和ebp的值,就可以用下图来表示当前函数栈的位置情况:
图一
函数栈栈底一般在高地址处,而栈顶在低地址处。在栈底和栈顶之间,会存放函数体里定义的一些变量值。
当我们在函数体里定义一个变量,编译器会以一个固定值(esp或ebp里的值)加偏移量的形式来表示一个变量的地址,比如图二中的0x1c(%esp)。[2]
调试
图二
如上图所示:右半部分是指令的汇编代码,左半部分是指令的机器码所在的虚拟内存地址。
我们首先来看看0x40136d处这条汇编指令——把位于常量区的'10'放入esp寄存器所指内存地址处,再往高地址方向偏移0x1C个字节的地方,放入值的长度为32位(因为'movl'指令)。
图三
由上图可知,现在esp里的值是0x28ff00,而我们要查看attest和bttest变量在内存地址中的值是否改变了?&attest的值是(0x28ff00 + 0x1c);&bttest的值是(0x28ff00 + 0x14)。
图四
由上图可知,相应地址里的值确实被修改了。现在0x1c(%esp)的内容是变量attest的值——10,0x14(%esp)的内容是变量bttest的值——20。
下面我们进入正题:
图五
由上图,即将进入Add()函数。在执行0x40138c的调用函数指令前,系统先把两个参数入栈,C\C++语言中函数参数入栈顺序从右往左。
因为形参y是引用类型,所以0x40137d指令是把变量bbtest所在内存地址直接拷贝到通用寄存器eax里;0x401381指令是把eax的内容拷贝到栈顶指针所指内存地址往右0x4个字节的地址处,拷贝值长度为32位(因为eax寄存器的大小是32位)。下图为执行完这两条指令后,0x4(%esp)里的值:
图六
由上图,0x4(%esp)里的内容就是0x14(%esp)的地址。此时%esp + 0x4 = 0x28ff04。①
而形参x是非引用类型,后面两条指令则是把变量attest的值拷贝到栈顶指针所指内存地址处。下图为执行完这两条指令后,(%esp)里的值:
图七
由上图,(%esp)里的内容就是把0x1c(%esp)的内容拷贝了一份过来。此时%esp = 0x28ff00。②这就是C\C++里函数传参时所说的临时变量,以及'值传递'造成的拷贝。而'引用传递'则不会产生临时变量和拷贝,因为它直接把变量的地址传到栈里。
图八
下面我们在进入函数内部前,需要把调用函数下一条指令地址入栈,接着把调用函数的栈底指针入栈,即图五里的0x401391这个地址入栈。然后我们就可以把调用函数的栈顶指针值赋值为当前栈的栈底指针,另外给当前栈的栈顶指针重新附一个值(就像0x401341和0x401343两条指令一样)。进入函数内部后就走到了0x401346这条指令——给z赋值。重点在于后面几条指令,我们先看一下当前ebp的值为:
它正是通过图三里的esp里保存的栈顶指针值0x28ff00、0x28ff00 - 4 - 4后得到的值,第一个4是放入栈里的调用函数下一条指令地址的的大小(因为32位编译器里的指针大小为4字节),后一个4是ebp里存放的调用函数栈底指针的大小。减法运算是因为栈的生长方式是由高地址向低地址生长。这样就可以得到当前函数栈的栈底指针。现在ebp里保存的栈底指针值为0x28fef8。
了解这些后,我们计算0x40134d指令中的0xc(%ebp)值,为0x28ff04,即上述①所述地址,里面存放着实参bttest的地址;0x401352指令中的0x8(%ebp)值,为0x28ff00,即上述②所述地址,里面存放这临时变量的值。这样就获取到实参的内容了。
我们看看目前函数栈的情况:
- 0x28fef4,存放局部变量z的值,即执行过了图八里的0x401346指令;
- 0x28fef8,存放调用函数的栈底指针地址,即图三里的ebp里的内容;
- 0x28fefc,存放调用函数下一条指令的内存地址,即图四里的0x401391这个地址;
- 0x28ff00,存放参数x的临时变量值;
- 0x28ff04,存放参数y的地址。
当图八所示的函数执行完毕后,需要把ebp的内容恢复成调用函数的栈底指针,所以应该有'pop epb'这句汇编。然后需要告诉CPU从哪一条指令继续执行,所以还应该把函数地址pop到相应寄存器里。但是这款编译器没有给出相应pop指令,我们必须知道其实是有pop这个步骤的。
标签
[1]. 详见<Windows核心编程>--(美)Jeffrey Richter,其中的内存结构章节。
[2]. 有关汇编语言中操作数的意义请戳这里:http://blog.chinaunix.net/uid-28458801-id-3558498.html