简述栈在函数调用栈中的作用

程序的内存布局――函数调用栈的那点事
[注]此文是《程序员的自我修养》的总结,其中掺杂着一些个人的理解,若有不对,欢迎拍砖。
程序的内存布局
现代的应用程序都运行在一个虚拟内存空间里,在32位的里,这个内存空间拥有4GB的寻址能力。现代的应用程序可以直接使用32位的地址进行寻址,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。
在进程的不同地址区间上有着不同的地位,Windows在默认情况下会将高地址的2GB空间分配给内核,而默认将高地址的1GB空间分配给内核,具体的内存布局如下图:
(1)代码区:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域取指并执行。
(2)数据区:用于存储全局变量、常量。
(3)堆区:进程可以在堆区动态地请求一定大小的内存,并在用完之后归还给堆区。动态分配和回收是堆区的特点。
(4)栈区:用于动态地存储函数之间的关系,以保证被调用函数在返回时恢复到母函数中继续执行。
高级语言写出的程序经过编译链接,最终会变成可执行文件。当可执行文件被装载运行后,就成了所谓的进程。
可执行文件代码段中包含的二进制级别的机器代码会被装入内存的代码区(.text);
处理器将到内存的这个区域一条一条地取出指令和操作数,并送入运算逻辑单元进行运算;
如果代码中请求开辟动态内存,则会在内存的堆区分配一块大小合适的区域返回给代码区的代码使用;
当函数调用发生时,函数的调用关系等信息会动态地保存在内存的栈区,以供处理器在执行完被调用函数的代码时,返回母函数。
如果把计算机看成一个有条不紊的工厂,我们可以得到如下类比:
* CPU是干活的工人。
* 数据区、堆区、栈区等则是用来存放原料、半成品、成品等各种东西的场所。
* 存放在代码区的指令则告诉CPU要做什么,怎么做,到哪里去领原材料,用什么工具来做,做完以后把成品放到哪个货仓去。
在经典的操作系统里,栈总是向下增长的。栈顶由esp寄存器定位。压栈操作使栈顶的地址减小,弹出操作使栈顶地址增大。
当函数调用的时候发生了什么?
int main(void)
foo(1,2,3) ;
return 0 ;
当方法main需要调用foo时,它的标准行为:
1、在main方法的调用栈中,将 foo的参数从右向左 依次push到栈中。
2、把main方法当前指令的 下一条指令地址 (即return address)push到栈中。(隐藏在call指令中)
3、使用call指令调用目标函数体foo。
请注意,以上3步都处于main的调用栈,其中ebp保存其栈底,而esp保存其栈顶。
接下来,在foo函数中:
1、push ebp: 将ebp的当前值push到栈中,即保存ebp。
2、mov ebp,esp: 将esp的值赋给ebp,则意味着进入了foo方法的调用栈。
3、[可选]sub esp, XXX: 在栈上分配XXX字节的临时空间。(抬高栈顶)(编译器根据函数中的局部变量的总大小确定临时空间的大小)
4、[可选]push XXX: 保存(push)一些寄存器的值。
【注意:push寄存器的值,这一操作,可以在分配临时空间之前,也可在其之后,《程序员的自我修养》写的是在开辟临时变量之后】
(编译器中保存的有相应的变量名对应的临时空间中的位置)
而在foo方法调用完毕后,便执行前面阶段的逆操作:
1、保存返回值: 通常将函数的返回值保存在寄存器eax中。
2、[可选]恢复(pop)一些寄存器的值。
3、mov esp,ebp: 恢复esp同时回收局部变量空间。(恢复原栈顶)
4、pop ebp: 将栈顶的值赋给ebp,即恢复main调用栈的栈底。(恢复原栈底)
5、ret: 从栈顶获得之前保留的return address,并跳转到此位置继续执行。
main方法先将foo方法所需的参数压入栈中,然后再改变ebp,进入foo方法的调用栈。
因此,如果在foo方法中需要访问那些参数,则需要根据当前ebp中的值,再向高地址偏移后进行访问&&因为高地址才是main方法的调用栈。
也就是说,地址ebp + 8存放了foo方法的第1个参数,地址ebp + 12存放了foo方法的第2个参数,以此类推。那么地址ebp + 4存放了什么呢?它存放的是return address,即foo方法返回后,需要继续执行下去的main方法指令的地址。
若需在函数中保存被调函数保存寄存器(如ESI、EDI),则编译器在保存EBP值时进行保存,或延迟保存直到局部变量空间被分配。在栈帧中并未为被调函数保存寄存器的空间指定标准的存储位置。
【注:几个相关的寄存器(关于详细的介绍,见王爽汇编)】
(1)esp:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
(2)ebp:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。(ebp在当前栈帧内位置固定,故函数中对大部分数据的访问都基于ebp进行)
(3)eip:指令寄存器(extended instruction pointer),其内存放着一个指针,该指针永远指向下一条等待执行的指令地址。 可以说如果控制了EIP寄存器的内容,就控制了进程&&我们让eip指向哪里,CPU就会去执行哪里的指令。eip可被jmp、call和ret等指令隐含地改变(事实上它一直都在改变)(ret指令就是把当前栈顶保存的返回值地址 弹到eip中)
函数栈帧的大小并不固定,一般与其对应函数的局部变量多少有关。函数运行过程中,其栈帧大小也是在不停变化的。
函数的调用方和被调用方对于函数如何调用需要遵守同样的约定,函数才能被正确地调用,这样的约定称为**调用惯例**。
* 函数参数的传递顺序和方式
调用惯例要规定参数压栈的顺序:是从左至右,还是从右至左。有些调用惯例还允许使用寄存器传递参数,以提高性能。
* 栈的维护方式
(谁负责弹出形参?)
在被调函数返回时,需要将被压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方完成,也也可以由被函数完成。
* 名字修饰规则
为了链接的时候对调用惯例进行区分,调用惯例要对函数本身的名字进行修饰,不同的调用惯例有不同的名字修饰策略。
参数压栈方向
下划线+函数名
下划线+函数名@参数字节数
头两个参数放入寄存器,其它从右至左
@函数名字名@参数字节数
是CDeclaration的缩写,表示C语言默认的函数调用方法:所有参数从右到左依次入栈,这些参数由调用者清除,称为手动清栈。被调用函数无需要求调用者传递多少参数,调用者传递过多或者过少的参数,甚至完全不同的参数都不会产生编译阶段的错误。(典型的如printf函数)
是Standard Call的缩写,是C++的标准调用方式:所有参数从右到左依次入栈。这些堆栈中的参数由被调用的函数在返回后清除,使用的指令是 retn X,X表示参数占用的字节数,CPU在ret之后自动弹出X个字节的堆栈空间。称为自动清栈。函数在编译的时候就必须确定参数个数,并且调用者必须严格的控制参数的生成,不能多,不能少,否则返回后会出错。
几乎我们写的每一个WINDOWS API函数都是_stdcall类型的,因为不同的编译器产生栈的方式不尽相同,调用者不一定能正常的完成清除工作。如果使用_stdcall,上面的问题就解决了,函数自己解决清除工作。所以,在跨平台的调用中,我们都使用_stdcall(虽然有时是以WINAPI的样子出现)。
但当我们遇到这样的函数如printf()它的参数是可变的,不定长的,被调用者事先无法知道参数的长度,事后的清除工作也无法正常的进行,因此,这种情况我们只能使用\_cdecl。到这里我们有一个结论,如果你的程序中没有涉及可变参数,最好使用_stdcall关键字。
函数返回值传递
一般情况下,寄存器eax是传递返回值的通道,函数将返回值存储在eax中,返回后函数的调用方再读取eax。
但是eax本身只有4字节,那么大于4字节的返回值是如何传递的呢?
对于返回5~8字节数据的情况,一般采用eax和edx联合返回的方式进行的。其中eax存储返回值的低4字节,edx存储返回值的高4字节。
对于超过8字节的返回类型:
typedef struct big_thing
char buf[128] ;
big_thing return_test();
//---------------------------
int main(void)
big_thing n = return_test() ;
big_thing return_test()
b.buf[0] = 0 ;
分析这段代码:
首先,在主调函数main中,肯定有一个128字节的变量n,在被调函数return_test中,肯定有一个128字节的变量b。
那被调函数如何返回128字节的变量?直接从b拷贝到n么?你这样直接改变主调函数中变量的值,似乎不符合返回值传值的规则。
那么实际上,编译器是怎么设计大尺寸返回值传递的呢?
* main函数在其栈中的局部变量区域中额外开辟一片空间,将其一部分作为传递返回值的临时对象temp。
* 将temp对象的地址作为隐藏参数传递给return_test函数。
* return_test函数将数据拷贝给temp对象,并将temp对象的地址用eax传出。
* return_test返回后,main函数将eax指向的temp对象的内容拷贝给n。
(return_test是没有真正的参数的,只有一个&伪参数&由函数的调用方悄悄传入)
函数返回值的传递:小于8字节的返回值,以**寄存器**为中转。大于8字节的,以主调函数中新开辟的同样大小的中间变量temp为中转。
C语言对于尺寸太大的返回值类型,会使用一个临时的栈上内存区域作为中转,结果返回值对象会被拷贝两次。故不到万不得已,不要轻易返回大尺寸对象。
C++函数的返回值传递
C++处理大返回值略有不同,其可能是像C那样,1次拷贝到栈上的临时对象里,然后把临时对象拷贝到存储返回值的对象里。
但,有些编译器会进行返回值优化RVO(Return Value Optimization),这样,对象拷贝会减少一次,即没有临时对象temp了,直接拷贝到主调函数的相应对象中。
struct cpp_obj
cout&& &ctor\n& ;
cpp_obj(const cpp_obj& c)
cout&& &copy ctor\n& ;
cpp_obj& operator=(const cpp_obj& rhs)
cout&& &operator=\n& ;
~cpp_obj()
cout&& &dtor\n& ;
cpp_obj foo()
cout && &before foo return\n& ;
int main()
n = foo() ;
cout && &before main return\n& ;
return 0 ;
//---------运行结果---------
before foo return
before main return
此例子是在g++下编译运行。此例就没有设置一个临时变量temp,而是直接把被调函数局部变量的值直接拷贝到主调函数中去。
C++对于返回值还有一种更&激进&的优化策略&&NRV(Named Return Value)具名返回值优化
这种优化是甚至连被调函数中的局部变量都不要了!直接在主调函数中操作对象(根据隐藏参数传入的对象的引用)。
关于NRV要注意两点:(自己总结的,若有不对,请拍砖)
1、在被调函数foo中,其局部变量声明处即是调用主调函数main中对象的默认构造函数处。main中的对象定义处,只是开辟一个空间,当时并不调用构造函数。
2、为何在主调函数中 CObj obj = foo() 会触发NRV优化
而分开写: CO obj = foo() ; 没有NRV优化呢?
程序员必须给class X定义拷贝构造函数才能触发NRV优化,不然还是按照最初的较慢的方式执行。(我们的第二种方式没有涉及到拷贝构造函数,故不会触发NRV优化)
但现在的编译器即使去掉类中的拷贝构造函数,也一样会有NRV优化,但必须是向在对象初始化时调用子函数才会有NRV。
(若没有NRV优化,则被调函数中会生成局部对象,但这个局部对象直接拷贝到主函数相应的对象中,也不会像C那样还要生成一个临时变量)
若把上面的例子的调用方式改为: cpp_obj n = foo() ;
则会触发NRV优化,执行结果就是:
//cpp_obj n = foo() ;改为:
//foo实际就被改为:
void foo(cpp_obj& __result)
// 调用__result的默认构造函数
__result.cpp_obj::cpp_obj();
// 处理__result
//---------NRV后的运行结果---------
before foo return
before main return
(一定注意:只有CObj obj = foo();形式的调用才会有NRV优化!)
关于NRV优化详细见《深入理解C++对象模型》
堆是一块巨大的内存空间,常常占据整个虚拟地址空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效。在C语言中我们可以用malloc函数在堆上申请空间。
malloc的实现:
操作系统内核管理着进程的地址空间,它通过的有系统调用,若让malloc调用这个系统调用实现申请内存,可完成这个工作。
但是,这样做性能较差,因为每次进行申请释放空间都需要进行系统调用,系统调用的开销比较大,会进行内核态和用户态的切换。
比较好的做法是程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,管理着堆空间分配的往往是程序的运行库(一般是操作系统提供的共享库)。
malloc实际上就是对这共享库中函数的包装。
&批发-零售&类比:
运行库相当于是向操作系统批发了一块较大的堆空间,然后零售给程序用。运行库在向程序零售空间时,必须管理此空间,不能把一块空间出售两次。
当空间不够用时,运行库再向操作系统批发(调用OS相应的系统调用)。
注意:这个运行库一般也是操作系统或语言提供给我们的,其包含了管理堆空间的算法,其运行在用户态下。
(我们自己也可以实现这个分配算法,但常用的分配算法已经被各种系统、库实现了无数遍,没有必要重复发明轮子)
每个进程在创建时都会有一个默认堆,这个堆在进程启动时创建,并且直到进程结束都一直存在。在Windows中默认堆大小为1MB。
(注意:在Windows中堆不一定是向上增长的)
问:malloc申请的空间是不是连续的?
答:若&空间&指的是虚拟空间的话,那么答案是连续的,即每一次malloc分配后返回的空间都可以看做是一块连续的地址。(进程中可能存在多个堆,但一次能够分配的最大堆空间取决于最大的那个堆)
如果空间值的是物理空间,则不一定连续,因为一块连续的虚拟地址空间有可能是若干个不连续的物理页拼凑成的。
堆空间管理算法
* 1、空闲链表法
把堆中各个空闲块按链表的方式连接起来,当用户请求时遍历链表找到合适的块。
* 2、位图(这个思想好)
将整个堆划分为大量的大小相同的块。当用户请求时分配整数个空间给用户。我们可以用一个整数数组的位来记录分配状况。
(每个块只有头/使用/空闲三种状态,即用两个位就可表示一个块,因此称为位图。头是用来标记定界的作用)
(window.slotbydup=window.slotbydup || []).push({
id: '2467140',
container: s,
size: '1000,90',
display: 'inlay-fix'
(window.slotbydup=window.slotbydup || []).push({
id: '2467141',
container: s,
size: '1000,90',
display: 'inlay-fix'
(window.slotbydup=window.slotbydup || []).push({
id: '2467142',
container: s,
size: '1000,90',
display: 'inlay-fix'
(window.slotbydup=window.slotbydup || []).push({
id: '2467143',
container: s,
size: '1000,90',
display: 'inlay-fix'
(window.slotbydup=window.slotbydup || []).push({
id: '2467148',
container: s,
size: '1000,90',
display: 'inlay-fix'& & & 这是我的第一篇博客,由于公司项目需要,将暂时告别C语言一段时间。所以在此记录一下自己之前学习C语言的一些心得体会,希望可以分享给大家,也可以记录下自己学习过程中遇到的问题以及存在的疑惑(其实就是自己学习过程中不解的地方)。好了,废话不多说,开始微博内容了,O(&_&)O哈哈~
& & & 接下来将通过下面几个问题解析函数调用中对堆栈理解:
函数调用过程中堆栈在内存中存放的结构如何?
汇编语言中call,ret,leave等具体操作时如何?
linux中任务的堆栈,数据存放是如何?
& & & 1. 函数调用过程中堆栈在内存中存放的结构如何?
& & & 计算机,嵌入式设备,智能设备等其实都是有软件和硬件两部分组成,具体实现也许复杂,但整体的结构也就如此。软件运行在硬件上,告诉硬件该干什么。操作系统软件是在启动过程中经过BIOS,bootloarder等(如果有这些过程的话)从磁盘加载到内存中,而自定义软件则是编写存放到磁盘中,只有通过加载才会到内存中运行。
& & & 首先我们来看一下什么是堆、栈还有堆栈,我们经常说堆栈其实它是等同于栈的概念。
& & & 可以通俗意义上这样理解堆,堆是一段非常大的内存空间,供不同的程序员从其中取出一段供自己使用,使用之后要由程序员自己释放,如果不释放的话,这部分存储空间将不能被其他程序使用。堆的存储空间是不连续的,因为会因为不同时间,不同大小的堆空间的申请导致其不连续性。堆的生长是从低地址向高地址增长的。
& & & 对栈的理解是,栈是一段存储空间,供系统或者操作系统使用,对程序员来说一般是不可见的,除非从一开始由程序员自己通过汇编等自己构建栈,栈会由系统管理单元自己申请释放。栈是从高地址向低地址生长的,既栈底在高地址,栈顶低地址。
& & & 其次我们看一下应用程序的加载,应用程序被加载进内存后,由操作系统为其分配堆栈,程序的入口函数会是main函数。不过main函数也不是第一个被调用的函数,我们通过简单的例子讲解。
#include &stdio.h&
#include &string.h&
int function(int arg)
int main(void)
int i = 10;
j = function(i);
printf("%d\n",j);
用gcc -S main.c 生成汇编文件main.s, 其中function的汇编代码如下:
.cfi_startproc
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
%rsp, %rbp
.cfi_def_cfa_register 6
%edi, -4(%rbp)
-4(%rbp), %eax
.cfi_def_cfa 7, 8
.cfi_endproc
看以看到当函数被调用时,首先会把调用函数的栈底压栈到自己函数的栈中(pushq %rbp),然后将原来函数栈顶rsp作为当前函数的栈底(movq %rsp, %rbp)。函数运行完成时,会将压入栈中的rbp重新出栈到rbp中(popq %rbp)。当前function汇编函数没有显示出栈顶的变化(rsp的变化),我们可以通过main函数来看栈顶的变化,汇编代码如下:
.cfi_startproc
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
%rsp, %rbp
.cfi_def_cfa_register 6
$10, -4(%rbp)
-4(%rbp), %eax
%eax, %edi
%eax, -8(%rbp)
-8(%rbp), %eax
%eax, %esi
$.LC0, %edi
.cfi_def_cfa 7, 8
.cfi_endproc
从上面的汇编代码可以看到首先也是压栈和设置新栈底的过程,从此可以看出main函数也是被调用的函数,而不是第一个调用函数。代码中的黄色部分是当前栈顶变化,从使用的subq可以知道,栈顶的地址要小于栈底的地址,所以栈是从高地址向低地址生长。
& & & 接下来可能有点绕,慢慢读,将用语言描述函数调用过程,调用函数会将被调用函数的实参从右往左的顺序压入调用函数的栈中,通过call指令调用被调用函数,首先将return address(也就是call指令的后一条指令的地址)压入调用函数栈中,这时rsp寄存器中存储的地址是存放return address内存地址的下一地址值,这时调用函数的栈结构形成,然后就会进入被调用函数的作用域中。被调用函数首先将调用函数的rbp压入被调用函数栈中(其实这个地址就是rsp寄存器中存储的地址),接下来将会将这个地址作为被调用函数的rbp地址,才会有movq %rsp, %rbp指令设置被调用函数的栈底。如上所描述的构成了函数调用的堆栈结构如下图所示。
此图来自/taek/archive//2338877.html,此图中MOV EBP,ESP与本文的movq指令操作不同。
& & &2. 汇编语言中call,ret,leave等具体操作时如何?
  push:将数据压入栈中,具体操作是rsp先减,然后将数据压入sp所指的内存地址中。rsp寄存器总是指向栈顶,但不是空单元。
  pop:将数据从栈中弹出,然后rsp加操作,确保rsp寄存器指向栈顶,不是空单元。
  call:将下一条指令的地址压入当前调用函数的栈中(将PC指令压入栈中,因为在从内存中取出call指令时,PC指令已经自动增加),然后改变PC指令的为call的function的地址,程序指针跳转到新function。
  ret:当指令指到ret指令行时,说明一个函数已经结束了,这时候rsp已经从被调用函数的栈指到了调用函数构建的返回地址位置。ret是将rsp所指栈顶地址中的内容赋值给PC,接下来将执行call function的下一条指令。
  leave:相当于mov %esp, %ebp, pop ebp。头一条指令其实是把ebp所指的被调用函数的栈底作为新的栈顶,pop指令时相当于把被调用函数的栈底弹出,rsp指向返回地址。
  int:通过其后加中断号,实现软件引发中断,linux操作系统中系统调用多有此实现,其他实时操作系统中在操作系统移植时,会有tick心脏函数也有此实现。
  其他的汇编指令在此就不多讲了,因为汇编指令众多,硬件cpu寄存器也因硬件不同而不同,此节就讲了函数构建进入和离开函数时用到的几个汇编指令,这几条指令和栈变化有关。自己构建汇编函数,或者是在读linux操作系统的系统调用时会对其理解有帮助。硬件寄存器中rsp,和rbp用于指示栈顶和栈底。
& & &&3. linux中任务的堆栈,数据存放是如何?
& & & linux的任务堆栈分为两种:内核态堆栈和用户态堆栈。接下来简单介绍一下这两个堆栈,如果以后有机会将详细介绍这两个堆栈。
1. 内核态堆栈
& & & linux操作系统分为内核态和用户态。用户态代码访问代码和数据收到诸多限制,用户态主要是为程序员编写程序使用,处于用户态的代码不可以随便访问linux内核态的数据,这主要就是设置用户态的权限,安全考虑。但是用户态可以通过系统调用接口,中断,异常等访问指定内核态的内容。内核态主要是用于操作系统内核运行以及管理,可以无限制的访问内存地址和数据,权限比较大。
& & & linux操作系统的进程是动态的,有生命周期,进程的运行和普通的程序运行一样,需要堆栈的帮助,如果在内核存储区域内为其提前分配堆栈的话,既浪费内核内存(任务地址大约3G的空间),也不能灵活的构建任务,所以linux操作系统在创建新的任务时,为其分配了8k的存储区域用于存放进程内核态的堆栈和线程描述符。线程描述符位于分配的存储区域的低地址区域,大小固定,而内核态堆栈则从存储区域的高地址开始向低地址延伸。如果之前版本为内核态堆栈和线程描述符分配4k的存储空间时,则需要为中断和异常分配额外的栈供其使用,防止任务堆栈溢出。
此图出自http://blog.csdn.net/bailyzheng/article/details/,
2. 用户态堆栈
& & & 对于32位的linux操作系统,每个任务都会有4G的寻址空间,其中0-3G为用户寻址空间,3G-4G为内核寻址空间。每个任务的创建都会有0-3G的用户寻址空间,但是3G-4G的内核寻址空间是属于所有任务共享的。这些地址都属于线性地址,需要通过地址映射转换成物理地址。为了实现每个任务在访问0-3G的用户空间时不至于混淆地址,每个任务的内存管理单元都会有一个属于自身的页目录pgd,在任务创建之初会创建新的pgd,任务会通过地址映射为0-3G空间映射物理地址。用户态的堆栈就在这0-3G的用户寻址空间中分配,和之前的main函数以及function函数构建堆栈一样,但是具体映射到哪个物理地址,还需要内存管理单元去做映射操作。总之,linux任务用户态的堆栈和普通应用程序一样,由操作系统分配和释放,对程序员来说不可见,不过因为操作系统的原因,任务用户程序寻址有限制。如果有机会之后介绍一下linux内存管理的个人理解。
Views(...) Comments()这篇文章是摘录自《软件漏洞分析入门》,作者failwest,在此对作者表示感谢和膜拜,\(^o^)/~
根据不同的操作系统,一个进程可能被分配到不同的内存区域去执行。但是不管什么样的操作系统、什么样的计算机架构,进程使用的内存都可以按照功能大致分成以下四个部分:
代码区:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域来取指并执行。
数据区:用于存储全局变量等。
堆区:进程可以在堆区动态的请求一定大小的内存,并在用完之后归还给堆区。动态分配和回收是堆区的特点
栈区:用于动态的存储函数之间的调用关系,以保证被调用函数在返回时恢复到母函数中继续执行
&&&&&注意:这种简单的内存划分方式是为了让您能够更容易地理解程序的运行机制。《深入理解计算机系统》一书中有更详细的关于内存使用的论述,如果您对这部分知识有兴趣,可以参考之&
&&在windows平台下,高级语言写出的程序经过编译链接,最终会变成各位同学最熟悉不过的PE文件。当PE文件被装载运行后,就成了所谓的进程。
&&&&&&&&&&&&&&&&&&&&&&
&&如果把计算机看成一个有条不紊的工厂的话,那么可以简单的看成是这样组织起来的:
CPU是完成工作的工人;
数据区,堆区,栈区等则是用来存放原料,半成品,成品等各种东西的场所;
存在代码区的指令则告诉CPU要做什么,怎么做,到哪里去领原材料,用什么工具来做,做完以后把成品放到哪个货舱去;
值得一提的是,栈除了扮演存放原料,半成品的仓库之外,它还是车间调度主任的办公室。
&&程序中所使用的缓冲区可以是堆区、栈区、甚至存放静态变量的数据区。缓冲区溢出的利用方法和缓冲区到底属于上面哪个内存区域密不可分,本讲座主要介绍在系统栈中发生溢出的情形。堆中的溢出稍微复杂点,我会考虑在中级班中给予介绍
&&以下内容针对正常情况下的大学本科二年级计算机水平或者计算机二级水平的读者,明白栈的飘过即可。
&&从计算机科学的角度来看,栈指的是一种数据结构,是一种先进后出的数据表。栈的最常见操作有两种:压栈(PUSH),弹栈(POP);用于标识栈的属性也有两个:栈顶(TOP),栈底(BASE)
&&可以把栈想象成一摞扑克牌:
&&PUSH:为栈增加一个元素的操作叫做PUSH,相当于给这摞扑克牌的最上面再放上一张;
&&POP:从栈中取出一个元素的操作叫做POP,相当于从这摞扑克牌取出最上面的一张;
&&TOP:标识栈顶位置,并且是动态变化的。每做一次PUSH操作,它都会自增1;相反每做一次POP操作,它会自减1。栈顶元素相当于扑克牌最上面一张,只有这张牌的花色是当前可以看到的。
&&BASE:标识栈底位置,它记录着扑克牌最下面一张的位置。BASE用于防止栈空后继续弹栈,(牌发完时就不能再去揭牌了)。很明显,一般情况下BASE是不会变动的。
&&内存的栈区实际上指的就是系统栈。系统栈由系统自动维护,它用于实现高级语言中函数的调用。对于类似C语言这样的高级语言,系统栈的PUSH,POP等堆栈平衡细节是透明的。一般说来,只有在使用汇编语言开发程序的时候,才需要和它直接打交道。
&&注意:系统栈在其他文献中可能曾被叫做运行栈,调用栈等。如果不加特别说明,我们这里说的栈都是指系统栈这个概念,请您注意与求解“八皇后”问题时在自己在程序中实现的数据结构区分开来。
&&我们下面就来探究一下高级语言中函数的调用和递归等性质是怎样通过系统栈巧妙实现的。请看如下代码:
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_
&&var_main=func_A(4,3);
&&return&var_
&&这段代码经过编译器编译后,各个函数对应的机器指令在代码区中可能是这样分布的:
&&根据操作系统的不同、编译器和编译选项的不同,同一文件不同函数的代码在内存代码区中的分布可能相邻也可能相离甚远;可能先后有序也可能无序;但他们都在同一个PE文件的代码所映射的一个“区”里。这里可以简单的把它们在内存代码区中的分布位置理解成是散乱无关的。
&&当CPU在执行调用func_A函数的时候,会从代码区中main函数对应的机器指令的区域跳转到func_A函数对应的机器指令区域,在那里取指并执行;当func_A函数执行完闭,需要返回的时候,又会跳回到main函数对应的指令区域,紧接着调用func_A后面的指令继续执行main函数的代码。在这个过程中,CPU的取指轨迹如下图所示:
&&那么CPU是怎么知道要去func_A的代码区取指,在执行完func_A后又是怎么知道跳回到main函数(而不是func_B的代码区)的呢?这些跳转地址我们在C语言中并没有直接说明,CPU是从哪里获得这些函数的调用及返回的信息的呢?
&&原来,这些代码区中精确的跳转都是在与系统栈巧妙地配合过程中完成的。当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,并把它压入栈中。这个栈帧中的内存空间被它所属的函数独占,正常情况下是不会和别的函数共享的。当函数返回时,系统栈会弹出该函数所对应的栈帧。
&&如图所示,在函数调用的过程中,伴随的系统栈中的操作如下:
&&在main函数调用func_A的时候,首先在自己的栈帧中压入函数返回地址,然后为func_A创建新栈帧并压入系统栈
&&在func_A调用func_B的时候,同样先在自己的栈帧中压入函数返回地址,然后为func_B创建新栈帧并压入系统栈
&&在func_B返回时,func_B的栈帧被弹出系统栈,func_A栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址重新跳到func_A代码区中执行
&&在func_A返回时,func_A的栈帧被弹出系统栈,main函数栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址跳到main函数代码区中执行
&&注意:在实际运行中,main函数并不是第一个被调用的函数,程序被装入内存前还有一些其他操作,上图只是栈在函数调用过程中所起作用的示意图
&&每一个函数独占自己的栈帧空间。当前正在运行的函数的栈帧总是在栈顶。WIN32系统提供两个特殊的寄存器用于标识位于系统栈栈顶的栈帧:
&&ESP:栈指针寄存器(extended&stack&pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶
&&EBP:基址指针寄存器(extended&base&pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部
&&寄存器对栈帧的标识作用如下图所示:
&&函数栈帧:ESP和EBP之间的内存空间为当前栈帧,EBP标识了当前栈帧的底部,ESP标识了当前栈帧的顶部。
&&在函数栈帧中一般包含以下几类重要信息:
&&局部变量:为函数局部变量开辟内存空间。
&&栈帧状态值:保存前栈帧的顶部和底部(实际上只保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本帧被弹出后,恢复出上一个栈帧。
&&函数返回地址:保存当前函数调用前的“断点”信息,也就是函数调用前的指令位置,以便函数返回时能够恢复到函数被调用前的代码区中继续执行指令。
&&注意:函数栈帧的大小并不固定,一般与其对应函数的局部变量多少有关。在以后几讲的调试实验中您会发现,函数运行过程中,其栈帧大小也是在不停变化的。
&&除了与栈相关的寄存器外,您还需要记住另一个至关重要的寄存器:
&&EIP:指令寄存器(extended&instruction&pointer),&其内存放着一个指针,该指针永远指向下一条待执行的指令地址
&&可以说如果控制了EIP寄存器的内容,就控制了进程——我们让EIP指向哪里,CPU就会去执行哪里的指令。下面的讲座我们就会逐步介绍如何控制EIP,劫持进程的原理及实验。
函数调用约定与相关指令
函数调用约定描述了函数传递参数方式和栈协同工作的技术细节。不同的操作系统、不同的语言、不同的编译器在实现函数调用时的原理虽然基本类同,但具体的调用约定还是有差别的。这包括参数传递方式,参数入栈顺序是从右向左还是从左向右,函数返回时恢复堆栈平衡的操作在子函数中进行还是在母函数中进行。下面列出了几种调用方式之间的差异。
&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&C&&&&&&&&&&SysCall&&StdCall&&BASIC&&FORTRAN&&PASCAL
参数入栈顺序&&&&&&&&&&&&&&&&&&右-&左&&右-&左&&右-&左&&左-&右&&左-&右&&左-&右
恢复栈平衡操作的位置&&母函数&&子函数&&子函数&&子函数&&子函数&&子函数
&&具体的,对于Visual&C++来说可支持以下三种函数调用约定
调用约定的声明&&参数入栈顺序&&恢复栈平衡的位置
__cdecl&&右-&左&&母函数
__fastcall&&右-&左&&子函数
__stdcall&&右-&左&&子函数
&&要明确使用某一种调用约定的话只需要在函数前加上调用约定的声明就行,否则默认情况下VC会使用__stdcall的调用方式。本篇中所讨论的技术,在不加额外说明的情况下,都是指这种默认的__stdcall调用方式。
&&除了上边的参数入栈方向和恢复栈平衡操作位置的不同之外,参数传递有时也会有所不同。例如每一个C++类成员函数都有一个this指针,在windows平台中这个指针一般是用ECX寄存器来传递的,但如果用GCC编译器编译的话,这个指针会做为最后一个参数压入栈中。
&&同一段代码用不同的编译选项、不同的编译器编译链接后,得到的可执行文件会有很多不同。
&&函数调用大致包括以下几个步骤:
&&参数入栈:将参数从右向左依次压入系统栈中
&&返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行
&&代码区跳转:处理器从当前代码区跳转到被调用函数的入口处
&&栈帧调整:具体包括
&&保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP入栈)
&&将当前栈帧切换到新栈帧。(将ESP值装入EBP,更新栈帧底部)
&&给新栈帧分配空间。(把ESP减去所需空间的大小,抬高栈顶)
&&对于__stdcall调用约定,函数调用时用到的指令序列大致如下:
push&参数3&&&&;&假设该函数有3个参数,将从右向左依次入栈
push&参数2&&&&
push&参数1&&&&
call&函数地址&&;&call指令将同时完成两项工作:a)向栈中压入当前指令在内存中的位置,&&&&&&&&&&;&即保存返回地址;b)跳转到所调用函数的入口地址
&&;函数入口处
push&ebp&&&&&&;&保存旧栈帧的底部
mov&ebp,esp&&&&;&设置新栈帧的底部(栈帧切换)
sub&esp,xxx&&&&;&设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间)
上面这段用于函数调用的指令在栈中引起的变化如下图所示:
注意:关于栈帧的划分不同参考书中有不同的约定。有的参考文献中把返回地址和前栈帧EBP值做为一个栈帧的顶部元素,而有的则将其做为栈帧的底部进行划分。在后面的调试中,您会发现OllyDbg在栈区标示出的栈帧是按照前栈帧EBP值进行分界的,也就是说前栈帧EBP值即属于上一个栈帧,也属于下一个栈帧,这样划分栈帧后返回地址就成为了栈帧顶部的数据。我们这里将坚持按照EBP与ESP之间的位置做为一个栈帧的原则进行划分。这样划分出的栈帧如上面最后一幅图所示,栈帧的底部存放着前栈帧EBP,栈帧的顶部存放着返回地址。划分栈帧只是为了更清晰的了解系统栈的运作过程,并不会影响它实际的工作。
&&类似的,函数返回的步骤如下:
&&保存返回值:通常将函数的返回值保存在寄存器EAX中
&&弹出当前栈帧,恢复上一个栈帧:
&&具体包括
&&在堆栈平衡的基础上,给ESP加上栈帧的大小,降低栈顶,回收当前栈帧的空间
&&将当前栈帧底部保存的前栈帧EBP值弹入EBP寄存器,恢复出上一个栈帧
&&将函数返回地址弹给EIP寄存器
&&跳转:按照函数返回地址跳回母函数中继续执行
&&还是以C语言和WIN32平台为例,函数返回时的相关的指令序列如下:&&
add&xxx,&esp&&;降低栈顶,回收当前的栈帧
pop&ebp&&&&;将上一个栈帧底部位置恢复到ebp,
retn&&&&&&;这条指令有两个功能:a)弹出当前栈顶元素,即弹出栈帧中的返回地址。至此&&&&&&&&;栈帧恢复工作完成。b)让处理器跳转到弹出的返回地址,恢复调用前的代码区
&&按照这样的函数调用约定组织起来的系统栈结构如下:
参考知识库
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
访问:2022次
排名:千里之外}

我要回帖

更多关于 函数调用栈分析 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信