浅谈C/C++堆栈指引——C/C++堆栈
C/C++堆栈指引
Binhua Liu
前言
我们经常会讨论这种问题:什么时候数据存储在飞鸽传书堆栈(Stack)中。什么时候数据存储在堆(Heap)中。我们知道。局部变量是存储在堆栈中的。debug时。查看堆栈能够知道函数的调用顺序。函数调用时传递參数,其实是把參数压入堆栈,听起来。堆栈象一个大杂烩。
那么。堆栈(Stack)究竟是怎样工作的呢? 本文将具体解释C/C++堆栈的工作机制。阅读时请注意以下几点:
1)本文讨论的语言是 Visual C/C++。因为高级语言的堆栈工作机制大致相同,因此对其它高级语言如C#也有意义。
2)本文讨论的堆栈,是指程序为每一个线程分配的默认堆栈,用以支持程序的运行,而不是指程序猿为了实现算法而自定义的堆栈。
3) 本文讨论的平台为intel x86。
4)本文的主要部分将尽量避免涉及到汇编的知识。在本文最后可选章节,给出前面章节的反编译代码和凝视。
5)结构化异常处理也是通过堆栈来实现的(当你使用try…catch语句时,使用的就是c++对windows结构化异常处理的扩展),可是关于结构化异常处理的主题太复杂了,本文将不会涉及到。
从一些主要的知识和概念開始
1) 程序的堆栈是由处理器直接支持的。
在intel x86的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),例如以下图所看到的:
因此。栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。
2) 在32位系统中,堆栈每一个数据单元的大小为4字节。
小于等于4字节的数据。比方字节、字、双字和布尔型。在堆栈中都是占4个字节的;大于4字节的数据在堆栈中占4字节整数倍的空间。
3) 和堆栈的操作相关的两个寄存器是EBP寄存器和ESP寄存器的,本文中。你仅仅须要把EBP和ESP理解成2个指针就能够了。ESP寄存器总是指向堆栈的栈顶。运行PUSH命令向堆栈压入数据时,ESP减4。然后把数据复制到ESP指向的地址;运行POP命令时。首先把ESP指向的数据复制到内存地址/寄存器中,然后ESP加4。EBP寄存器是用于訪问堆栈中的数据的。它指向堆栈中间的某个位置(具体位置后文会具体解说),函数的參数地址比EBP的值高。而函数的局部变量地址比EBP的值低,因此參数或局部变量总是通过EBP加减一定的偏移地址来訪问的,比方,要訪问函数的第一个參数为EBP+8。
4) 堆栈中究竟存储了什么数据? 包含了:函数的參数,函数的局部变量,寄存器的值(用以恢复寄存器)。函数的返回地址以及用于结构化异常处理的数据(当函数中有try…catch语句时才有,本文不讨论)。这些数据是依照一定的顺序组织在一起的,我们称之为一个堆栈帧(Stack Frame)。
一个堆栈帧相应一次函数的调用。
在函数開始时,相应的堆栈帧已经完整地建立了(全部的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的运行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。
5) 在文中,我们把函数的调用者称为Caller(调用者)。被调用的函数称为Callee(被调用者)。之所以引入这个概念。是因为一个函数帧的建立和清理,有些工作是由Caller完毕的,有些则是由Callee完毕的。
開始讨论堆栈是怎样工作的
我们来讨论堆栈的工作机制。堆栈是用来支持函数的调用和运行的。因此,我们以下将通过一组函数调用的样例来解说,看以下的代码:
01 |
int foo1( int m, int n) |
02 |
{ |
03 |
int p=m*n; |
04 |
return p; |
05 |
} |
06 |
int foo( int a, int b) |
07 |
{ |
08 |
int c=a+1; |
09 |
int d=b+1; |
10 |
int e=foo1(c,d); |
11 |
return e; |
12 |
} |
13 |
|
14 |
int main() |
15 |
{ |
16 |
int result=foo(3,4); |
17 |
return 0; |
18 |
} |
这段代码本身并没有实际的意义。我们仅仅是用它来跟踪堆栈。
以下的章节我们来跟踪堆栈的建立。堆栈的使用和堆栈的销毁。
堆栈的建立
我们从main函数运行的第一行代码,即int result=foo(3,4); 開始跟踪。这时main以及之前的函数相应的堆栈帧已经存在在堆栈中了,例如以下图所看到的:
图1
參数入栈
当foo函数被调用。首先。caller(此时caller为main函数)把foo函数的两个參数:a=3,b=4压入堆栈。參数入栈的顺序是由函数的调用约定(Calling Convention)决定的。我们将在后面一个专门的章节来解说调用约定。
一般来说,參数都是从左往右入栈的,因此,b=4先压入堆栈,a=3后压入。如图:
图2
返回地址入栈
我们知道。当函数结束时。代码要返回到上一层函数继续运行。那么,函数怎样知道该返回到哪个函数的什么位置运行呢?函数被调用时,会自己主动把下一条指令的地址压入堆栈,函数结束时,从堆栈读取这个地址,就能够跳转到该指令运行了。
假设当前"call foo"指令的地址是0x00171482,因为call指令占5个字节,那么下一个指令的地址为0x00171487,0x00171487将被压入堆栈:
图3
代码跳转到被调用函数运行
返回地址入栈后。代码跳转到被调用函数foo中运行。
到眼下为止,堆栈帧的前一部分,是由caller构建的;而在此之后。堆栈帧的其它部分是由callee来构建。
EBP指针入栈
在foo函数中。首先将EBP寄存器的值压入堆栈。
因为此时EBP寄存器的值还是用于main函数的,用来訪问main函数的參数和局部变量的。因此须要将它暂存在堆栈中,在foo函数退出时恢复。同一时候,给EBP赋于新值。
1)将EBP压入堆栈
2)把ESP的值赋给EBP
图4
这样一来,我们非常easy发现当前EBP寄存器指向的堆栈地址就是EBP先前值的地址。你还会发现发现。EBP+4的地址就是函数返回值的地址。EBP+8就是函数的第一个參数的地址(第一个參数地址并不一定是EBP+8,后文中将讲到)。
因此,通过EBP非常easy查找函数是被谁调用的或者訪问函数的參数(或局部变量)。
为局部变量分配地址
接着,foo函数将为局部变量分配地址。
程序并非将局部变量一个个压入堆栈的,而是将ESP减去某个值。直接为全部的局部变量分配空间,比方在foo函数中有ESP=ESP-0x00E4。如图所看到的:
图5
奇怪的是,在debug模式下,编译器为局部变量分配的空间远远大于实际所需,并且局部变量之间的地址不是连续的(据我观察。总是间隔8个字节)例如以下图所看到的:
图6
我还不知道编译器为什么这么设计。也许是为了在堆栈中插入调试数据,只是这无碍我们今天的讨论。
通用寄存器入栈
最后。将函数中使用到的通用寄存器入栈。暂存起来。以便函数结束时恢复。在foo函数中用到的通用寄存器是EBX,ESI,EDI,将它们压入堆栈,如图所看到的:
图7
至此,一个完整的堆栈帧建立起来了。
堆栈特性分析
上一节中,一个完整的堆栈帧已经建立起来。如今函数能够開始正式运行代码了。本节我们对堆栈的特性进行分析,有助于了解函数与堆栈帧的依赖关系。
1)一个完整的堆栈帧建立起来后,在函数运行的整个生命周期中,它的结构和大小都是保持不变的。不论函数在什么时候被谁调用。它相应的堆栈帧的结构也是一定的。
2)在A函数中调用B函数,相应的,是在A函数相应的堆栈帧“下方”建立B函数的堆栈帧。比如在foo函数中调用foo1函数,foo1函数的堆栈帧将在foo函数的堆栈帧下方建立。例如以下图所看到的:
图8
3)函数用EBP寄存器来訪问參数和局部变量。我们知道,參数的地址总是比EBP的值高,而局部变量的地址总是比EBP的值低。
而在特定的堆栈帧中,每一个參数或局部变量相对于EBP的地址偏移总是固定的。因此函数对參数和局部变量的的訪问是通过EBP加上某个偏移量来訪问的。比方,在foo函数中,EBP+8为第一个參数的地址,EBP-8为第一个局部变量的地址。
4)假设细致思考,我们非常easy发现EBP寄存器另一个非常重要的特性,请看下图中:
图9
我们发现。EBP寄存器总是指向先前的EBP,而先前的EBP又指向先前的先前的EBP。这样就在堆栈中形成了一个链表!这个特性有什么用呢。我们知道EBP+4地址存储了函数的返回地址,通过该地址我们能够知道当前函数的上一级函数(通过在符号文件里查找距该函数返回地址近期的函数地址,该函数即当前函数的上一级函数),以此类推,我们就能够知道当前线程整个的函数调用顺序。
其实,调试器正是这么做的,这也就是为什么调试时我们查看函数调用顺序时总是说“查看堆栈”了。
返回值是怎样传递的
堆栈帧建立起后。函数的代码真正地開始运行,它会操作堆栈中的參数,操作堆栈中的局部变量,甚至在堆(Heap)上创建对象。balabala….,最终函数完毕了它的工作。有些函数须要将结果返回给它的上一层函数,这是怎么做的呢?
首先,caller和callee在这个问题上要有一个“约定”,因为caller是不知道callee内部是怎样运行的,因此caller须要从callee的函数声明就能够知道应该从什么地方取得返回值。相同的,callee不能随便把返回值放在某个寄存器或者内存中而指望Caller能够正确地获得的,它应该依据函数的声明,依照“约定”把返回值放在正确的”地方“。以下我们来解说这个“约定”:
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函数的定义例如以下并将它的代码做适当的改动:
1 |
MyStruct foo( int a, int b) |
2 |
{ |
3 |
... |
4 |
} |
MyStruct定义为:
1 |
struct MyStruct |
2 |
{ |
3 |
int value1; |
4 |
__int64 value2; |
5 |
bool value3; |
6 |
}; |
这时,在调用foo函数时參数的入栈过程会有所不同。例如以下图所看到的:
图10
caller会在压入最左边的參数后,再压入一个指针。我们姑且叫它ReturnValuePointer,ReturnValuePointer指向当前ESP值下方非常远的一个地址,这个地址将用来存储函数的返回值。
函数返回时,把返回值复制到ReturnValuePointer指向的地址中。然后把ReturnValuePointer的地址赋予EAX寄存器。
函数返回后。caller通过EAX寄存器找到ReturnValuePointer。然后通过ReturnValuePointer找到返回值。
你也许会有这种疑问,函数返回后。相应的堆栈帧已经被销毁,而ReturnValuePointer是在该堆栈帧中。不也应该被销毁了吗?对的,堆栈帧是被销毁了。可是程序不会自己主动清理当中的值,因此ReturnValuePointer中的值还是有效的。
可是,这里另一个问题我没有答案。ReturnValuePointer指向的地址是由caller决定的。而才caller并不知道callee相应的堆栈帧会有多大。假设callee相应的堆栈帧非常大那么就可能会和返回值的地址重合。我还不知道VS编译器通过什么策略来避免这个问题。
堆栈帧的销毁
当函数将返回值赋予某些寄存器或者复制到堆栈的某个地方后,函数開始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反:(堆栈帧的销毁过程就不一一绘图说明了)
1)假设有对象存储在堆栈帧中,对象的析构函数会被函数调用。
2)从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。
3)ESP加上某个值,回收局部变量的地址空间(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。
4)从堆栈中弹出先前的EBP寄存器的值,恢复EBP寄存器。
5)从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续运行。
6)ESP加上某个值,回收全部的參数地址。
前面1-5条都是由callee完毕的。
而第6条。參数地址的回收,是由caller或者callee完毕是由函数使用的调用约定(calling convention )来决定的。以下的小节我们就来解说函数的调用约定。
函数的调用约定(calling convention)
函数的调用约定(calling convention)指的是进入函数时,函数的參数是以什么顺序压入堆栈的,函数退出时。又是由谁(Caller还是Callee)来清理堆栈中的參数。有2个办法能够指定函数使用的调用约定:
1)在函数定义时加上修饰符来指定,如
1 |
void __thiscall mymethod(); |
2 |
{ |
3 |
... |
4 |
} |
2)在VSproject设置中为project中定义的全部的函数指定默认的调用约定:在project的主菜单打开Project|Project Property|Configuration Properties|C/C++|Advanced|Calling Convention。选择调用约定(注意:这种做法对类成员函数无效)。
经常使用的调用约定有以下3种:
1)__cdecl。这是VC编译器默认的调用约定。其规则是:參数从右向左压入堆栈,函数退出时由caller清理堆栈中的參数。这种调用约定的特点是支持可变数量的參数,比方printf方法。因为callee不知道caller究竟将多少參数压入堆栈。因此callee就没有办法自己清理堆栈,所以仅仅有函数退出之后,由caller清理堆栈,因为caller总是知道自己传入了多少參数。
2)__stdcall。
全部的Windows API都使用__stdcall。其规则是:參数从右向左压入堆栈,函数退出时由callee自己清理堆栈中的參数。因为參数是由callee自己清理的,所以__stdcall不支持可变数量的參数。
3) __thiscall。类成员函数默认使用的调用约定。
其规则是:參数从右向左压入堆栈。x86构架下this指针通过ECX寄存器传递,函数退出时由callee清理堆栈中的參数,x86构架下this指针通过ECX寄存器传递。相同不支持可变数量的參数。
假设显式地把类成员函数声明为使用__cdecl或者__stdcall,那么,将採用__cdecl或者__stdcall的规则来压栈和出栈,而this指针将作为函数的第一个參数最后压入堆栈。而不是使用ECX寄存器来传递了。
反编译代码的跟踪(不熟悉汇编可跳过)
以下代码为和foo函数相应的堆栈帧建立相关的代码的反编译代码,我将逐行给出凝视。可对比前文中对堆栈的描写叙述:
main函数中 int result=foo(3,4); 的反汇编:
1 |
008A147E push 4 //b=4 压入堆栈 |
2 |
008A1480 push 3 //a=3 压入堆栈,到达图2的状态 |
3 |
008A1482 call foo (8A10F5h) //函数返回值入栈,转入foo中运行,到达图3的状态 |
4 |
008A1487 add esp,8 //foo返回,因为採用__cdecl,由Caller清理參数 |
5 |
008A148A mov dword ptr [result],eax //返回值保存在EAX中,把EAX赋予result变量 |
以下是foo函数代码正式运行前和运行后的反汇编代码
print?
01 |
008A13F0 push ebp //把ebp压入堆栈 |
02 |
008A13F1 mov ebp,esp //ebp指向先前的ebp,到达图4的状态 |
03 |
008A13F3 sub esp,0E4h //为局部变量分配0E4字节的空间,到达图5的状态 |
04 |
008A13F9 push ebx //压入EBX |
05 |
008A13FA push esi //压入ESI |
06 |
008A13FB push edi //压入EDI,到达图7的状态 |
07 |
008A13FC lea edi,[ebp-0E4h] //以下4行把局部变量区初始化为每一个字节都等于cch |
08 |
008A1402 mov ecx,39h |
09 |
008A1407 mov eax,0CCCCCCCCh |
10 |
008A140C rep stos dword ptr es:[edi] |
11 |
...... //省略代码运行N行 |
12 |
...... |
13 |
008A1436 pop edi //恢复EDI |
14 |
008A1437 pop esi //恢复ESI |
15 |
008A1438 pop ebx //恢复EBX |
16 |
008A1439 add esp,0E4h //回收局部变量地址空间 |
17 |
008A143F cmp ebp,esp //以下3行为Runtime Checking,检查ESP和EBP是否一致 |
18 |
008A1441 call @ILT+330(__RTC_CheckEsp) (8A114Fh) |
19 |
008A1446 mov esp,ebp |
20 |
008A1448 pop ebp //恢复EBP |
21 |
008A1449 ret //弹出函数返回地址。跳转到函数返回地址运行 //(__cdecl调用约定,Callee未清理參数) |
參考
Debug Tutorial Part 2: The Stack
Intel汇编语言程序设计(第四版) 第8章
http://msdn.microsoft.com/zh-cn/library/46t77ak2(VS.80).aspx
浅谈C/C++堆栈指引——C/C++堆栈的更多相关文章
- 浅谈C/C++堆栈指引——C/C++堆栈很强大(绝美)
C/C++堆栈指引 Binhua Liu 前言 我们经常会讨论这样的问题:什么时候数据存储在飞鸽传书堆栈(Stack)中,什么时候数据存储在堆(Heap)中.我们知道,局部变量是存储在堆栈中的:deb ...
- 浅谈Windows API编程
WinSDK是编程中的传统难点,个人写的WinAPI程序也不少了,其实之所以难就难在每个调用的API都包含着Windows这个操作系统的潜规则或者是windows内部的运行机制…… WinSDK是编程 ...
- 浅谈angular2+ionic2
浅谈angular2+ionic2 前言: 不要用angular的语法去写angular2,有人说二者就像Java和JavaScript的区别. 1. 项目所用:angular2+ionic2 ...
- 浅谈struts2之chain
转自:http://blog.csdn.net/randomnet/article/details/8656759 前一段时间,有关chain的机制着实困绕了许久.尽管网上有许多关于chain的解说, ...
- 浅谈JAVA集合框架
浅谈JAVA集合框架 Java提供了数种持有对象的方式,包括语言内置的Array,还有就是utilities中提供的容器类(container classes),又称群集类(collection cl ...
- 浅谈开源项目Android-Universal-Image-Loader(Part 3.1)
本文转载于:http://www.cnblogs.com/osmondy/p/3266023.html 浅谈开源项目Android-Universal-Image-Loader(Part 3.1) 最 ...
- [C#]6.0新特性浅谈
原文:[C#]6.0新特性浅谈 C#6.0出来也有很长一段时间了,虽然新的特性和语法趋于稳定,但是对于大多数程序猿来说,想在工作中用上C#6.0估计还得等上不短的一段时间.所以现在再来聊一聊新版本带来 ...
- 浅谈java类集框架和数据结构(2)
继续上一篇浅谈java类集框架和数据结构(1)的内容 上一篇博文简介了java类集框架几大常见集合框架,这一篇博文主要分析一些接口特性以及性能优化. 一:List接口 List是最常见的数据结构了,主 ...
- 谁还没遇上过NoClassDefFoundError咋地——浅谈字节码生成与热部署
谁还没遇上过NoClassDefFoundError咋地--浅谈字节码生成与热部署 前言 在Java程序员的世界里,NoClassDefFoundError是一类相当令人厌恶的错误,因为这类错误通常非 ...
随机推荐
- JDK7集合框架源码阅读(一) ArrayList
基于版本jdk1.7.0_80 java.util.ArrayList 代码如下 /* * Copyright (c) 1997, 2013, Oracle and/or its affiliates ...
- (6)C#项目结构
一.项目下Properites文件夹 Properties文件夹 定义你程序集的属性 项目属性文件夹 一般只有一个 AssemblyInfo.cs 类文件,用于保存程序集的信息,如名称,版本等,这些信 ...
- 陕西师范大学第七届程序设计竞赛网络同步赛 J 黑猫的小老弟【数论/法拉数列/欧拉函数】
链接:https://www.nowcoder.com/acm/contest/121/J来源:牛客网 题目描述 大家知道,黑猫有很多的迷弟迷妹,当然也有相亲相爱的基友,这其中就有一些二五仔是黑猫的小 ...
- POJ 1833 排列【STL/next_permutation】
题目描述: 大家知道,给出正整数n,则1到n这n个数可以构成n!种排列,把这些排列按照从小到大的顺序(字典顺序)列出,如n=3时,列出1 2 3,1 3 2,2 1 3,2 3 1,3 1 2,3 2 ...
- UVA 11990 ”Dynamic“ Inversion(线段树+树状数组)
[题目链接] UVA11990 [题目大意] 给出一个数列,每次删去一个数,求一个数删去之前整个数列的逆序对数. [题解] 一开始可以用树状数组统计出现的逆序对数量 对于每个删去的数,我们可以用线段树 ...
- iOS duplicate symbol for architecture arm64 解决办法
导致这个问题的原因有多种: 1.重复定义了const常量. 2.多个第三方库同时用到了某个函数库. 暂时列举这几种,以后遇到了其他原因再加.
- adb devices 找不到设备怎么办 --- 2
问题现象:在电脑上安装好手机驱动后,手机进入设置---->应用程序---->开发----->勾选USB调试后连接电脑,,在CMD命令中输入adb devices发现没有设备. 解决方 ...
- po_文件格式[转]
原文: http://cpp.ezbty.org/content/science_doc/po_%E6%96%87%E4%BB%B6%E6%A0%BC%E5%BC%8F 摘要:PO 是一种 GNU 定 ...
- Oracle、SQLServer、ArcSDE怎么查看版本、补丁
http://blog.csdn.net/linghe301/article/details/6712544
- MongoDB 聚合Group(一)
原文:http://blog.csdn.net/congcong68/article/details/45012717 一.简介 db.collection.group()使用JavaScript,它 ...