前言我们经常讨论这样的问题:什么时候数据存放在栈(Stack),什么时候数据存放在堆(Heap)。我们知道局部变量是存放在栈中的;调试时,看栈就可以知道函数的调用顺序;在调用函数时传递参数时,实际上是将参数压入栈中。听起来堆栈就像一个大杂烩。那么,堆栈是如何工作的呢?本文将详细讲解C/C++栈的工作机制。阅读时请注意以下几点:1)本文所讨论的编译环境为VisualC/C++。由于高级语言的栈工作机制大致相同,因此对于其他编译环境或C#等高级语言也有一定的意义。2)本文所讨论的栈是指程序默认为每个线程分配的栈,以支持程序的运行,而不是程序员为实现算法而定义的栈。3)本文讨论的平台是intelx86。4)本文主体部分会尽量避开汇编的知识。在文末的选修章节中,给出了前几章的反编译代码和注释。5)结构化异常处理也是通过栈实现的(当你使用try...catch语句时,你使用的是C++对windows结构化异常处理的扩展),但是结构化异常处理这个话题太复杂了。本文将不涉及。6)推荐自己的linuxC/C++交流群:973961276!整理了一些个人认为比较好的学习书籍、视频资料和视频资料,分享到群文件里。需要的朋友可以自行添加哦!~先从一些基础知识和概念说起1)程序的栈是处理器直接支持的。在intelx86系统中,栈在内存中是从高地址向低地址扩展的(这与自定义栈从低地址向高地址扩展不同),如下图所示:因此,栈顶的地址栈的地址是不断递减的,数据越晚入栈,地址越低。2)在32位系统中,栈的每个数据单元的大小为4字节。小于或等于4个字节的数据,如byte、word、doubleword、Boolean,在栈中占用4个字节;大于4字节的数据在栈中占用的空间是4字节的整数倍。3)与栈操作相关的两个寄存器是EBP寄存器和ESP寄存器。在本文中,你只需要将EBP和ESP理解为两个指针即可。ESP寄存器总是指向栈顶。当执行PUSH命令将数据压入堆栈时,ESP减4,然后将数据复制到ESP指向的地址;执行POP命令时,先将ESP指向的数据复制到内存地址/寄存器中,然后将ESP加4。EBP寄存器用于访问堆栈中的数据。它指向栈中间的某个位置(具体位置后面会详细说明)。函数的参数地址高于EBP的值,函数的局部变量地址高于EBP的值。低,所以总是通过在EBP中加上或减去某个偏移地址来访问参数或局部变量。例如,访问一个函数的第一个参数是EBP+8。4)栈中存放的是什么数据?包括:函数参数、函数局部变量、寄存器值(用于恢复寄存器)、函数返回地址和用于结构化异常处理的数据(仅当函数中有try...catch语句时,本文不讨论).这些数据按照一定的顺序组织在一起,我们称之为栈帧(StackFrame)。一个栈帧对应一个函数调用。在函数开始时,对应的栈帧已经完全建立(所有的局部变量在函数帧建立的时候就已经分配了空间,而不是随着函数的执行不断的创建和销毁);当函数退出时,整个函数框架将被销毁。5)在文中,我们将函数的调用者称为调用者(caller),将被调用函数称为被调用者(callee)。之所以引入这个概念,是因为一个函数框架的建立和清理,有些工作由Caller完成,有些工作由Callee完成。让我们谈谈堆栈的工作原理让我们来讨论一下堆栈的工作原理。栈用于支持函数的调用和执行。因此,我们将在下面通过一组函数调用示例进行说明。看下面的代码:intfoo1(`intm,intn)`{intp=m*n;returnp;}intfoo(`inta,intb)`{intc=a+1;intd=b+1;inte=foo1(c,d);returne;}intmain(){intresult=foo(3,4);return0;}这段代码本身没有实际意义,我们只是用而已跟踪堆栈。在后面的章节中我们将追溯栈的建立、栈的使用和栈的销毁。栈的建立我们从main函数执行的第一行代码开始追踪,即intresult=foo(3,4);此时栈中已经存在main和前面函数对应的栈帧,如下图:图1参数入栈foo函数调用时,首先调用者(调用者是此时的main函数)将两个参数:a=3,b=4压入栈中。参数入栈的顺序由函数的调用约定(CallingConvention)决定。我们会在后面专门的章节对调用约定进行讲解。一般来说,参数是从右向左压栈的。所以b=4先入栈,然后a=3入栈,如图:图2返回地址入栈我们知道函数结束时,代码为回到上一个函数继续执行,函数怎么知道要返回到哪个函数,去哪里执行呢?当函数被调用时,它会自动将下一条指令的地址压入堆栈。当函数结束时,从栈中读取这个地址,跳转到指令处执行。如果当前“callfoo”指令的地址是0x00171482,由于call指令占用5个字节,那么下一条指令的地址是0x00171487,0x00171487会被压入栈:图3中的代码跳转到被调用的functiontoexecute返回地址入栈后,代码跳转到调用的函数foo处执行。至此,栈帧的第一部分由调用者构建;之后,堆栈帧的其余部分由被调用者构建。将EBP指针压入堆栈在foo函数中,首先将EBP寄存器的值压入堆栈。因为此时ebp寄存器的值仍然是供main函数使用的,用于访问main函数的参数和局部变量,所以需要暂存栈中,等foo函数退出时恢复.同时,一个新的值被赋给EBP。1)将EBP压栈2)将ESP的值赋给EBP图4这样我们很容易发现当前EBP寄存器指向的栈地址就是EBP之前值的地址,你会还发现EBP+4地址是函数返回值的地址,EBP+8是函数第一个参数的地址(第一个参数的地址不一定是EBP+8,会是后面会提到)。因此,通过EBP很容易找出是谁调用了函数或者访问了函数的参数(或局部变量)。为局部变量分配地址接下来,foo函数将为局部变量分配地址。程序不会将局部变量一个一个压栈,而是将ESP减去一定的值,直接为所有局部变量分配空间。其他编译环境测试也可能使用push命令分配地址,本质上没有区别,特此说明)如图:图5奇怪的是,在debug模式下,编译器为local分配了很多空间variables实际需要,并且局部变量之间的地址不是连续的(据我观察,总是有8个字节的间隔)如下图:图6还是不知道编译器为什么要这样设计way,也许是为了在栈中插入调试数据,但这并不妨碍我们今天的讨论。通用寄存器入栈最后,函数中使用的通用寄存器被压入栈并暂时存储,以便函数结束时可以恢复。foo函数中使用的通用寄存器有EBX、ESI、EDI,将它们压入栈中,如图:图7至此,一个完整的栈帧已经建立。栈特性分析在上一节中,已经建立了一个完整的栈帧,现在函数可以开始正式执行代码了。本节我们分析栈的特性,有助于理解函数和栈帧之间的依赖关系。1)一个完整的栈帧建立后,其结构和大小在函数执行的整个生命周期内保持不变;不管函数什么时候被谁调用,其对应栈帧的结构也是一定的。2)在A函数中调用B函数,相当于在A函数对应的栈帧“下方”创建了B函数的栈帧。比如在foo函数中调用了foo1函数,那么foo1函数的栈帧就会建立在foo函数的栈帧下面。如下图所示:图83)函数使用EBP寄存器访问参数和局部变量。我们知道参数的地址总是高于EBP的值,而局部变量的地址总是低于EBP的值。在一个特定的栈帧中,每个参数或局部变量相对于EBP的地址偏移量总是固定的。因此,函数对参数和局部变量的访问是通过EBP加上一定的偏移量来访问的。比如foo函数中,EBP+8就是第一个参数的地址,EBP-8就是第一个局部变量的地址。4)我们仔细想想,不难发现EBP寄存器还有一个很重要的特点,请看下图:图9我们发现EBP寄存器一直指向前一个EBP,前一个EBP指向之前的前一个EBP,从而在栈中形成一个链表!这个特性有什么用?我们知道EBP+4地址存放的是函数的返回地址。通过这个地址,我们可以知道当前函数的上层函数(通过在符号文件中查找最接近函数返回地址的函数地址,这个函数就是当前函数的上层函数),依此类推,我们就可以知道当前线程的整个函数调用顺序。事实上,调试器正是这样做的,这就是为什么我们在调试时查看函数调用顺序时总是说“查看堆栈”的原因。返回值如何传递栈帧建立后,函数的代码才真正开始执行。它会对栈中的参数进行操作,对栈中的局部变量进行操作,甚至会在堆(Heap)上创建对象,balabala...,最后函数完成了它的工作,有些函数需要将结果返回给它父函数,这是怎么做到的?首先,调用者和被调用者必须在这个问题上有“约定”。由于调用者不知道被调用者内部是如何执行的,所以调用者需要知道从被调用者的函数声明中应该从哪里获取返回值。同样,被调用者也不能只把返回值放在某个寄存器或内存中,就期望调用者正确获取。它应该根据函数声明,按照“约定”将返回值放在正确的“位置”。下面解释一下这个“约定”:1)首先,如果返回值等于4字节,函数会将返回值赋值给EAX寄存器,通过EAX寄存器返回。比如返回值是字节、字、双字、布尔、指针等类型,都是通过EAX寄存器返回的。2)如果返回值等于8字节,函数会将返回值赋值给EAX和EDX寄存器,通过EAX和EDX寄存器返回,EDX存放高4字节,EAX存放低4字节。例如返回值类型为__int64或8字节的结构体是通过EAX和EDX返回的。3)如果返回值为double或float,函数会将返回值赋值给浮点寄存器,通过浮点寄存器返回。4)如果返回值是大于8字节的数据,如何传递返回值?这是一个比较麻烦的问题,我们会详细解释一下:我们修改foo函数的定义如下,适当修改其代码:MyStructfoo(`inta,intb)`{...}MyStruct定义为:structMyStruct{intvalue1;__int64value2;boolvalue3;};此时调用foo函数时,参数的入栈过程会有所不同,如下图所示:图10最左边的参数压入后,调用者会再压入一个指针,姑且称之为ReturnValuePointer,ReturnValuePointer指向调用者局部变量区中一个未命名的地址,这个地址将用来存放被调用者的返回值。当函数返回时,被调用者将返回值复制到ReturnValuePointer指向的地址,然后将ReturnValuePointer的地址赋值给EAX寄存器。函数返回后,调用者通过EAX寄存器找到ReturnValuePointer,再通过ReturnValuePointer找到返回值。最后,调用者将返回值复制到负责接收的局部变量中(如果接收到返回值)。你可能会有这样的疑问,函数返回后,对应的栈帧已经被销毁了,而ReturnValuePointer在栈帧中,不应该被销毁吗?是的,栈帧被销毁了,但是程序不会自动清理里面的值,所以ReturnValuePointer中的值还是有效的。栈帧的销毁当函数将返回值赋值给某些寄存器或将返回值复制到栈的某个地方时,函数开始清理栈帧,准备退出。栈帧被清理的顺序刚好和栈创建的顺序相反:(栈帧的销毁过程就不一一说明了)1)如果有一个对象存储在栈帧,对象的析构函数将被函数调用。2)从栈中弹出前一个通用寄存器的值,恢复通用寄存器。3)ESP增加一定的值,回收局部变量的地址空间(增加的值与创建栈帧时分配给局部变量的地址大小相同)。4)从栈中弹出前一个EBP寄存器的值,恢复EBP寄存器。5)从栈中弹出函数的返回地址,准备跳转到函数的返回地址继续执行。6)给ESP增加一定的值,回收所有参数地址。前1-5项均由被调用者完成。而第6条,参数地址的回收是由调用者或被调用者完成的,这是由函数使用的调用约定(callingconvention)决定的。在下一节中,我们将解释函数的调用约定。函数调用约定(callingconvention)函数调用约定(callingconvention)是指函数的参数在进入函数时入栈的顺序,函数退出时由谁(Caller或Callee)清理堆栈上的参数。有2种方法可以指定函数使用的调用约定:1)在定义函数时添加修饰符指定,如void__thiscallmymethod();{...}2)在VS项目设置中,设置所有项目中定义的函数指定了默认的调用约定:在项目主菜单中打开Project|ProjectProperty|ConfigurationProperties|C/C++|Advanced|CallingConvention,选择调用约定(注意:该方法无效对于类成员函数)。常用的调用约定有以下三种:1)__cdecl。这是VC编译器的默认调用约定。规则是:参数从右向左入栈,调用者在函数退出时清空栈中的参数。这种调用约定的特点是支持可变数量的参数,例如printf方法。由于被调用者不知道调用者压入了多少参数入栈,被调用者没有办法自己清理栈,所以只能在函数退出后,调用者清理栈,因为调用者总是知道有多少它传入的参数。2)__stdcall。所有WindowsAPI都使用__stdcall。规则是:参数从右向左入栈,函数退出时callee清理栈中的参数。__stdcall不支持可变数量的参数,因为参数由被调用者本身清理。3)__thiscall。类成员函数使用的默认调用约定。规则是:参数从右向左入栈,x86架构下通过ECX寄存器传递this指针,函数退出时调用者清空栈中的参数,传递this指针x86架构下通过ECX寄存器。也不支持可变数量的参数。如果类成员函数被显式声明为使用__cdecl或__stdcall,则将使用__cdecl或__stdcall的规则进行入栈和出栈,并将this指针作为函数的第一个参数入栈,取而代之使用ECX寄存器传递。反编译代码跟踪(不熟悉汇编的可以跳过)以下代码是foo函数对应的stackframe相关代码的反编译代码。我会逐行给出注释,可以类比上一篇文章中对栈的描述:intresult=foo(3,4)的mainDisassembly;函数中:008A147Epush4//b=4入栈008A1480push3//a=3入栈,达到图2状态008A1482callfoo(8A10F5h)//返回值function入栈,转移到foo执行,达到图3状态008A1487addesp,8//foo返回,因为使用了__cdecl,参数被Caller清零008A148Amovdwordptr[result],eax//return值存放在EAX中,EAX赋值给result变量。下面是foo函数代码正式执行前后的反汇编代码。图4中的状态008A13F3subesp,0E4h//为局部变量分配0E4字节的空间,达到图5中的状态008A13F9pushebx//按EBX008A13FApushesi//按ESI008A13FBpushedi//按EDI,状态008A13FCleaedi,图7中的[ebp-0E4h]//下面4行初始化局部变量区,使每个字节等于cch008A1402movecx,39h008A1407moveax,0CCCCCCCCh008A140Crepstos]。....//省略代码执行N行...008A1436popedi//恢复EDI008A1437popesi//恢复ESI008A1438popebx//恢复EBX008A1439add????????esp,0E4h//回收局部变量地址空间008A143F?cmp????????ebp,esp//以下3行为RuntimeChecking,检查ESP和EBP是否一致008A1441?call???????@ILT+330(__RTC_CheckEsp)(8A114Fh)008A1446?mov????????esp,ebp008A1448?pop????????ebp//RestoreEBP008A1449ret//Popupthefunctionreturnaddress,jumptothefunctionreturnaddressforexecution//(__cdeclcallingconvention,Calleeuncleanedparameters)refertoDebugTutorialPart2:TheStackIntelAssemblyLanguageProgramming(FourthEdition)Chapter8
