0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

详解C/C++堆栈的工作机制

电子工程师 来源:segmentfault.com 作者:轻松学编程 2022-07-29 09:09 次阅读

1、前言

我们经常会讨论这样的问题:什么时候数据存储在堆栈(Stack)中,什么时候数据存储在堆(Heap)中。我们知道,局部变量是存储在堆栈中的;debug时,查看堆栈可以知道函数的调用顺序;函数调用时传递参数,事实上是把参数压入堆栈,听起来,堆栈象一个大杂烩。那么,堆栈(Stack)到底是如何工作的呢?本文将详解C/C++堆栈的工作机制。阅读时请注意以下几点:

1)本文讨论的编译环境是 Visual C/C++,由于高级语言的堆栈工作机制大致相同,因此对其他编译环境或高级语言如C#也有意义。

2)本文讨论的堆栈,是指程序为每个线程分配的默认堆栈,用以支持程序的运行,而不是指程序员为了实现算法而自己定义的堆栈。

3) 本文讨论的平台为intelx86。

4)本文的主要部分将尽量避免涉及到汇编的知识,在本文最后可选章节,给出前面章节的反编译代码和注释。

5)结构化异常处理也是通过堆栈来实现的(当你使用try…catch语句时,使用的就是c++对windows结构化异常处理的扩展),但是关于结构化异常处理的主题太复杂了,本文将不会涉及到。

2、从一些基本的知识和概念开始

1) 程序的堆栈是由处理器直接支持的。在intel x86的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),如下图所示:

43a96342-0e89-11ed-ba43-dac502259ad0.png

因此,栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。

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完成的。

3、开始讨论堆栈是如何工作的

我们来讨论堆栈的工作机制。堆栈是用来支持函数的调用和执行的,因此,我们下面将通过一组函数调用的例子来讲解,看下面的代码:

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;}

这段代码本身并没有实际的意义,我们只是用它来跟踪堆栈。下面的章节我们来跟踪堆栈的建立,堆栈的使用和堆栈的销毁。

4、堆栈的建立

我们从main函数执行的第一行代码,即int result=foo(3,4); 开始跟踪。这时main以及之前的函数对应的堆栈帧已经存在在堆栈中了,如下图所示:

43b55062-0e89-11ed-ba43-dac502259ad0.png

图1

5、参数入栈

当foo函数被调用,首先,caller(此时caller为main函数)把foo函数的两个参数:a=3,b=4压入堆栈。参数入栈的顺序是由函数的调用约定(Calling Convention)决定的,我们将在后面一个专门的章节来讲解调用约定。一般来说,参数都是从右往左入栈的,因此,b=4先压入堆栈,a=3后压入,如图:

43c1ba1e-0e89-11ed-ba43-dac502259ad0.png

图2

6、返回地址入栈

我们知道,当函数结束时,代码要返回到上一层函数继续执行,那么,函数如何知道该返回到哪个函数的什么位置执行呢?函数被调用时,会自动把下一条指令的地址压入堆栈,函数结束时,从堆栈读取这个地址,就可以跳转到该指令执行了。如果当前"call foo"指令的地址是0x00171482,由于call指令占5个字节,那么下一个指令的地址为0x00171487,0x00171487将被压入堆栈:

43ce1a3e-0e89-11ed-ba43-dac502259ad0.png

图3

7、代码跳转到被调用函数执行

返回地址入栈后,代码跳转到被调用函数foo中执行。到目前为止,堆栈帧的前一部分,是由caller构建的;而在此之后,堆栈帧的其他部分是由callee来构建。

EBP指针入栈

在foo函数中,首先将EBP寄存器的值压入堆栈。因为此时EBP寄存器的值还是用于main函数的,用来访问main函数的参数和局部变量的,因此需要将它暂存在堆栈中,在foo函数退出时恢复。同时,给EBP赋于新值。

1)将EBP压入堆栈

2)把ESP的值赋给EBP

43dfd8be-0e89-11ed-ba43-dac502259ad0.png

图4

这样一来,我们很容易发现当前EBP寄存器指向的堆栈地址就是EBP先前值的地址,你还会发现发现,EBP+4的地址就是函数返回值的地址,EBP+8就是函数的第一个参数的地址(第一个参数地址并不一定是EBP+8,后文中将讲到)。因此,通过EBP很容易查找函数是被谁调用的或者访问函数的参数(或局部变量)。

为局部变量分配地址

接着,foo函数将为局部变量分配地址。程序并不是将局部变量一个个压入堆栈的,而是将ESP减去某个值,直接为所有的局部变量分配空间,比如在foo函数中有ESP=ESP-0x00E4,(根据烛秋兄在其他编译环境上的测试,也可能使用push命令分配地址,本质上并没有差别,特此说明)如图所示:

43f04f46-0e89-11ed-ba43-dac502259ad0.png

图5

奇怪的是,在debug模式下,编译器为局部变量分配的空间远远大于实际所需,而且局部变量之间的地址不是连续的(据我观察,总是间隔8个字节)如下图所示:

43fb60de-0e89-11ed-ba43-dac502259ad0.png

图6

我还不知道编译器为什么这么设计,或许是为了在堆栈中插入调试数据,不过这无碍我们今天的讨论。

通用寄存器入栈

最后,将函数中使用到的通用寄存器入栈,暂存起来,以便函数结束时恢复。在foo函数中用到的通用寄存器是EBX,ESI,EDI,将它们压入堆栈,如图所示:

44123250-0e89-11ed-ba43-dac502259ad0.png

图7

至此,一个完整的堆栈帧建立起来了。

8、堆栈特性分析

上一节中,一个完整的堆栈帧已经建立起来,现在函数可以开始正式执行代码了。本节我们对堆栈的特性进行分析,有助于了解函数与堆栈帧的依赖关系。

1)一个完整的堆栈帧建立起来后,在函数执行的整个生命周期中,它的结构和大小都是保持不变的;不论函数在什么时候被谁调用,它对应的堆栈帧的结构也是一定的。

2)在A函数中调用B函数,对应的,是在A函数对应的堆栈帧“下方”建立B函数的堆栈帧。例如在foo函数中调用foo1函数,foo1函数的堆栈帧将在foo函数的堆栈帧下方建立。如下图所示:

441f53fe-0e89-11ed-ba43-dac502259ad0.png

图8

3)函数用EBP寄存器来访问参数和局部变量。我们知道,参数的地址总是比EBP的值高,而局部变量的地址总是比EBP的值低。而在特定的堆栈帧中,每个参数或局部变量相对于EBP的地址偏移总是固定的。因此函数对参数和局部变量的的访问是通过EBP加上某个偏移量来访问的。比如,在foo函数中,EBP+8为第一个参数的地址,EBP-8为第一个局部变量的地址。

4)如果仔细思考,我们很容易发现EBP寄存器还有一个非常重要的特性,请看下图中:

442c534c-0e89-11ed-ba43-dac502259ad0.png

图9

我们发现,EBP寄存器总是指向先前的EBP,而先前的EBP又指向先前的先前的EBP,这样就在堆栈中形成了一个链表!这个特性有什么用呢,我们知道EBP+4地址存储了函数的返回地址,通过该地址我们可以知道当前函数的上一级函数(通过在符号文件中查找距该函数返回地址最近的函数地址,该函数即当前函数的上一级函数),以此类推,我们就可以知道当前线程整个的函数调用顺序。事实上,调试器正是这么做的,这也就是为什么调试时我们查看函数调用顺序时总是说“查看堆栈”了。

9、返回值是如何传递的

堆栈帧建立起后,函数的代码真正地开始执行,它会操作堆栈中的参数,操作堆栈中的局部变量,甚至在堆(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函数的定义如下并将它的代码做适当的修改:

MyStructfoo(`inta,intb)`{...}

MyStruct定义为:

structMyStruct{intvalue1;__int64 value2;boolvalue3;};

这时,在调用foo函数时参数的入栈过程会有所不同,如下图所示:

4436c5b6-0e89-11ed-ba43-dac502259ad0.png

图10

caller会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer指向caller局部变量区的一块未命名的地址,这块地址将用来存储callee的返回值。函数返回时,callee把返回值拷贝到ReturnValuePointer指向的地址中,然后把ReturnValuePointer的地址赋予EAX寄存器。函数返回后,caller通过EAX寄存器找到ReturnValuePointer,然后通过ReturnValuePointer找到返回值,最后,caller把返回值拷贝到负责接收的局部变量上(如果接收返回值的话)。

你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer是在该堆栈帧中,不也应该被销毁了吗?对的,堆栈帧是被销毁了,但是程序不会自动清理其中的值,因此ReturnValuePointer中的值还是有效的。

10、堆栈帧的销毁

当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反:(堆栈帧的销毁过程就不一一画图说明了)

1)如果有对象存储在堆栈帧中,对象的析构函数会被函数调用。

2)从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。

3)ESP加上某个值,回收局部变量的地址空间(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。

4)从堆栈中弹出先前的EBP寄存器的值,恢复EBP寄存器。

5)从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续执行。

6)ESP加上某个值,回收所有的参数地址。

前面1-5条都是由callee完成的。而第6条,参数地址的回收,是由caller或者callee完成是由函数使用的调用约定(calling convention )来决定的。下面的小节我们就来讲解函数的调用约定。

11、函数的调用约定(calling convention)

函数的调用约定(calling convention)指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数。有2个办法可以指定函数使用的调用约定:

1)在函数定义时加上修饰符来指定,如

void__thiscallmymethod();{...}

2)在VS工程设置中为工程中定义的所有的函数指定默认的调用约定:在工程的主菜单打开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寄存器来传递了。

12、反编译代码的跟踪(不熟悉汇编可跳过)

以下代码为和foo函数对应的堆栈帧建立相关的代码的反编译代码,我将逐行给出注释,可对照前文中对堆栈的描述:

main函数中 int result=foo(3,4); 的反汇编:

008A147E push4//b=4 压入堆栈008A1480 push3//a=3 压入堆栈,到达图2的状态008A1482 callfoo(8A10F5h)//函数返回值入栈,转入foo中执行,到达图3的状态008A1487addesp,8//foo返回,由于采用__cdecl,由Caller清理参数008A148A mov dword ptr [result],eax//返回值保存在EAX中,把EAX赋予result变量

下面是foo函数代码正式执行前和执行后的反汇编代码

008A13F0push ebp //把ebp压入堆栈008A13F1mov ebp,esp //ebp指向先前的ebp,到达图4的状态008A13F3subesp,0E4h //为局部变量分配0E4字节的空间,到达图5的状态008A13F9push ebx //压入EBX008A13FApush esi //压入ESI008A13FBpush edi //压入EDI,到达图7的状态008A13FClea edi,[ebp-0E4h] //以下4行把局部变量区初始化为每个字节都等于cch008A1402mov ecx,39h008A1407mov eax,0CCCCCCCCh008A140Crep stos dword ptr es:[edi]......//省略代码执行N行......008A1436pop edi //恢复EDI008A1437pop esi //恢复ESI008A1438pop ebx //恢复EBX008A1439add esp,0E4h //回收局部变量地址空间008A143Fcmp ebp,esp //以下3行为Runtime Checking,检查ESP和EBP是否一致008A1441call @ILT+330(__RTC_CheckEsp) (8A114Fh)008A1446mov esp,ebp008A1448pop ebp //恢复EBP008A1449ret //弹出函数返回地址,跳转到函数返回地址执行 //(__cdecl调用约定,Callee未清理参数)
审核编辑:汤梓红

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表德赢Vwin官网 网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • 堆栈
    +关注

    关注

    0

    文章

    175

    浏览量

    19653
  • C++
    C++
    +关注

    关注

    21

    文章

    2084

    浏览量

    73242

原文标题:弄懂C/C++堆栈的工作机制很有必要!

文章出处:【微信号:嵌入式情报局,微信公众号:嵌入式情报局】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

    C语言与C++的区别

    在很大程度上, C++C的超集,这意味着一个有效的 C程序也是一个有效的 C++程序。
    发表于09-16 10:20 1026次阅读

    详解C/C++中的getMemory()函数

    如果你将面试一份 C/ C++工作,那么无论是笔试题或者面试题都有极大可能会被问到getMemory()的问题。当然这也是一道比较纠结的题目,本文就对这几道题目来做一个分析对比。
    发表于07-17 17:35 681次阅读

    关于C++中的函数重载机制

    函数重载是 C++的新增 机制,是在同一个作用域中能声明定义多个同名字的函数.(我们知道函数的名字是函数代码块的起始地址,这个首地址能够将函数的控制权转移给这个代码块的区域).在定义多个同名函数的时候
    发表于10-01 17:18

    鸿蒙c++模板开发详解

    鸿蒙 c++模板开发 详解
    发表于09-11 15:28

    C++异常机制探讨

    C++的异常 机制为我们提供了更好的解决方法。异常处理的基本思想是:当出现错误时抛出一个异常,希望它的调用者能捕获并处理这个异常。
    发表于11-23 11:04 3404次阅读
    <b class='flag-5'>C++</b>异常<b class='flag-5'>机制</b>探讨

    C++语言命令详解(第二版)

    德赢Vwin官网 网站提供《 C++语言命令 详解(第二版).txt》资料免费下载
    发表于07-28 13:06 0次下载

    C++C/C++程序设计教程_C/C++概述

    C++基础知识,简要介绍了 C++的一些简单知识,概念,函数
    发表于12-25 10:15 0次下载

    C/C++详解

    众多 C++书籍都忠告我们 C语言宏是万恶之首,但事情总不如我们想象的那么坏,就如同goto一样。宏有一个很大的作用,就是自动为我们产生代码。如果说模板 众多 C++书籍都忠告我们 C语言宏是
    发表于09-21 12:55 1537次阅读

    图文详解C++虚表的剖析

    图文 详解C++虚表的剖析
    的头像 发表于06-29 14:23 2409次阅读
    图文<b class='flag-5'>详解</b>:<b class='flag-5'>C++</b>虚表的剖析

    图文详解C++的输出输入

    图文 详解C++的输出输入
    的头像 发表于06-29 14:53 3277次阅读
    图文<b class='flag-5'>详解</b>:<b class='flag-5'>C++</b>的输出输入

    C++的异常机制底层原理与实际应用详细说明

    我们在对 vector 做 push 操作的时候,或者对某个指针做 new 操作的时候,如果没有做异常处理,一旦系统内存不够用了,程序是会被 terminate 掉的。这就要求我们熟悉 C++异常,保证日常开发中能正确处理它。本文主要介绍 C++异常
    的头像 发表于11-22 11:34 3093次阅读

    C++程序异常处理机制是什么

    那么 C++设计了一套异常处理 机制,一方面能够使得异常处理和正常运行代码进行分离,使得程序更加模块化;另一方面, C++的异常处理可以不需要异常处理在异常发生时的同一个函数,而是可以在更上层合适的位置进行处理。
    的头像 发表于02-21 10:37 732次阅读
    <b class='flag-5'>C++</b>程序异常处理<b class='flag-5'>机制</b>是什么

    浅谈C语言与C++的前世今生

    C++开发人员将有这些问题归咎于 C,而 C开发人员则认为 C++过于疯狂。我觉得站在 C的角度看 C++
    发表于05-26 09:27 333次阅读
    浅谈<b class='flag-5'>C</b>语言与<b class='flag-5'>C++</b>的前世今生

    C++之父新作带你勾勒现代C++地图

    为了帮助大家解决这些痛点问题,让大家领略现代 C++之美,掌握其中的精髓,更好地使用 C++C++之父Bjarne Stroustrup坐不住了,他亲自操刀写就了这本《 C++之旅》!
    的头像 发表于10-30 16:35 654次阅读
    <b class='flag-5'>C++</b>之父新作带你勾勒现代<b class='flag-5'>C++</b>地图

    C++简史:C++是如何开始的

    MISRA C++:2023,MISRA® C++标准的下一个版本,来了!为了帮助您做好准备,我们介绍了 Perforce 首席技术支持工程师 Frank van den Beuken 博士撰写
    的头像 发表于01-11 09:00 383次阅读
    <b class='flag-5'>C++</b>简史:<b class='flag-5'>C++</b>是如何开始的