2.1.1内存的不同用途
根据不同的操作系统,一个进程可能被分配到不同的内存区域去执行。不管什么样的操作系统、什么样的计算机架构,进程使用的内存都可以按照功能大致分成以下四个部分:
(1)代码区:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域取指并执行;
(2)数据区:用于存储全局变量等;
(3)堆区:进程可以在堆区动态地请求一定大小的内存,并在用完后归还给堆区。动态分配和回收是堆区地特点;
(4)栈区:用于动态地存储函数之间的调用关系,以保证被调用函数在返回时恢复到母函数中继续执行。
在windows平台下,高级语言写出的程序经过编译链接,就变成了PE文件,PE文件被装载运行后,就成了所谓的进程。
PE文件代码段中包含的二进制级别地机器代码会被装入内存的代码区(.text),处理器将到内存的这个区域一条一条地取出指令和操作数,并送入算数逻辑单元进行运算;如果代码中请求开辟动态内存,则会在内存的堆区分配一块大小合适的区域返回给代码区地代码使用;当函数调用发生时,函数的调用关系等信息会动态地保存在内存的栈区,以供处理器在执行完被调用函数的代码时,返回母函数。这个协作过程如图2.1.1所示。
图2.1.1
内存的栈区实际上指的是系统栈。系统栈由系统自动维护,它用于实现高级语言中函数的调用。
2.1.2 函数调用时发生了什么
示例代码:
int func_B(int arg_B1,int arg_B2)
{
int var_B1,var_B2;
var_B1=arg_B1+arg_B2;
var_B2=arg_B1-arg_B2;
return var_B1*var_B2;
}
int func_A(int arg_A1,int arg_A2)
{
int var_A;
var_A=func_B(arg_A1,arg_A2)+arg_A1;
return var_A;
}
int main(int argc,char *argv,char *envp)
{
int var_main;
var_main=func_A(4,3);
return var_main;
}
这段代码经过编译器编译,各个函数对应的机器代码在代码区中可能是这样分布的,如图2.1.2。
根据操作系统的不同、编译器和编译选项的不同,同一文件不同函数的代码在内存代码区的分布可能相邻,也可能相离甚远,可能先后有序,也有可能无序;但它们都在同一个PE文件的代码所映射的一个“节”里。我们可以简单的把它们在内存代码区的分布位置理解成是散乱无序的。
在CPU在执行调用func_A函数的时候,会从代码区main函数对应的机器指令的区域跳转到func_A函数对应的机器指令区域,在那里取指并执行;当func_A函数执行完毕,需要返回的时候,又会跳到main函数对应得指令区域,紧接着调用func_A后面的指令继续执行main函数的代码。在这个过程中,CPU的取值轨迹如图2.1.3所示。
图2.1.2 图2.1.3
在调用过程中,CPU如何获取函数调用和返回的信息呢?
这些代码区中精确的跳转都是在与系统栈配合下完成的。当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,并把它压入系统栈。这个栈帧中的内存空间被它所属的函数独占,正常情况下不会与别的函数共享。当函数返回时,系统栈会弹出该函数所对应的栈帧。在函数调用过程中,伴随系统栈中的操作如图2.1.4所示:
图2.1.4
2.1.3 寄存器与函数栈帧
每个函数独占自己的栈帧空间。当前运行的函数的栈帧总是在栈顶。Win32系统提供了两个特殊的寄存器用于标识系统栈顶端的栈帧:
(1)ESP:栈指针寄存器,该指针永远指向系统栈最上面一个栈帧的栈顶;
(2)EBP:基址指针寄存器,该指针永远指向系统栈最上面一个栈帧的底部。
函数栈帧:ESP和EBP之间的内存空间为当前栈帧,EBP标识了当前栈帧的底部,ESP标识了当前栈帧的顶部。
在函数栈帧中,一般包含以下几类重要的信息:
(1)局部变量:为函数局部变量开辟的内存空间;
(2)栈帧状态值:保存前栈帧的顶部和底部(实际只保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本栈帧被弹出后恢复上一个栈帧;
(3)函数返回地址:保存当前函数调用前的“断点”信息,也就是函数调用前的指令位置,以便在函数返回时能够恢复到函数被调用前的代码区中继续执行指令。
除了与栈相关的寄存器外,还有一个重要的寄存器:
EIP:指令寄存器,该指针永远指向下一条等待执行的指令地址。
2.1.4 函数调用约定与相关指令
函数调用约定描述了函数传递参数方式和栈协同工作的技术细节。主要讨论参数入栈的顺序是从左到右还是从右到左,函数返回时恢复堆栈平衡的操作在子函数中进行还是在母函数中进行。不同语言的调用方式之间的差异见表2-1-1.
表2-1-1
C | SysCall | StdCall | BASIC | FORTRAN | PASCAL | |
参数入栈顺序 | 右→左 | 右→左 | 右→左 | 左→右 | 左→右 | 左→右 |
恢复栈平衡操作的位置 | 母函数 | 子函数 | 子函数 | 子函数 | 子函数 | 子函数 |
函数调用大致包括以下几个步骤:
(1)参数入栈:将参数从右到左依次压入系统栈中;
(2)返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行;
(3)代码区跳转:处理器从当前代码区跳转到被调用函数的入口处;
(4)栈帧调整:具体包括:
1)保存当前栈帧状态值,以备后面恢复本栈帧时使用(EBP入栈);
2)将当前栈帧切换到新的栈帧(将ESP装入EBP,更新栈帧底部);
3)给新栈帧分配空间(把ESP减去所需空间的大小,抬高栈顶)。
对于——stdcall调用约定,函数调用时用到的指令序列大致如下:
push 参数3; //假设该函数有3个参数,将从右往左依次入栈
push 参数2;
push 参数1;
call 函数地址;//call指令将完成两项工作:向栈中压入当前指令的下一条指令的在内存中的地址;跳转到所调 ; //用函数的入口地址
push ebp; //保存旧栈帧的底部
mov ebp,esp; //设置新栈帧的底部
sub esp,xxx; //设置新栈帧的顶部
上面这段指令在栈中引起的变化如图2.1.5
图2.1.5
类似地,函数返回的步骤:
(1)保存返回值:通常将函数的保存值保存在寄存器EAX中;
(2)弹出当前栈帧,恢复上一栈帧。具体包括:
- 在堆栈平衡的基础上,给ESP加上栈帧的大小,降低栈帧,回收当前栈帧的空间;
- 将当前栈帧底部保存的前栈帧EBP值弹入EBP寄存器,恢复上一栈帧;
- 将函数返回地址弹给EIP寄存器。
(3)跳转:按照函数返回地址跳回到母函数中继续执行。
以C语言和Win32平台为例,函数返回时的相关的指令序列如下:
add esp,xxx; //降低栈帧,回收当前的栈帧
pop ebp; //将上一个栈帧底部位置恢复到ebp
ret n; //这条指令有两个功能:弹出当前栈顶的元素,即弹出栈帧中的返回地址;让处理器跳转到弹出的 ; //返回地址处,恢复调用前的代码区。
按照这样的函数调用约定组织起来的系统栈结构如图2.1.6:
图2.1.6