C/C++函数调用时的堆栈变化

本文探讨了函数调用过程中堆栈的变化及管理方法,分析了一段代码如何通过操纵堆栈指针使指针失效,同时介绍了不同函数调用约定下的参数传递与堆栈清理机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

深入学习的引起:csdn论坛某诡异问题
转抄如下:

  1. class Test
  2. {
  3. public :
  4. int i;
  5. int j;
  6. };
  7. int crashme(Test*t)
  8. {
  9. //??在这里干点什么能让下面的代码崩溃
  10. }
  11. int main( int argc, char **argv)
  12. {
  13. Test*t=new Test;
  14. crashme(t);
  15. t->i=4;//需要在此处崩溃
  16. return 0;
  17. }

class Test { public: int i; int j; }; int crashme(Test* t) { // ?? 在这里干点什么能让下面的代码崩溃 } int main(int argc, char** argv) { Test *t = new Test; crashme(t); t->i=4;//需要在此处崩溃 return 0; }

某高手的回复:

  1. int crashme(Test*t)
  2. {
  3. /*我的注:破坏了t所指向的内容,让传进来的实参指向0*/
  4. *(int *)(*(( int *)&t-2)-4)=0;
  5. }

int crashme(Test* t) { /*我的注:破坏了t所指向的内容,让传进来的实参指向0*/ *(int*)( *( (int*)&t - 2 ) - 4 ) = 0; }


实验下来确实可行!
可以看出这样的答复需要提出者对函数调用过程中堆栈变化很好的领悟和丰富的经验,钦佩之余也希望乘此机会深入的了解一下在函数调用过程中堆栈是如何变化的。
相对深入地了解了一下,知识记录如下,对于上诉问题最后再给以分析:

%%%%%%%%%%%%%%%%%%%%%%分界线%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

基础知识(此处内容很大部分转自http://www.fmddlmyy.cn/text12.html ,感谢):
1.什么是堆栈
编译器一般使用堆栈实现函数调用。堆栈是存储器的一个区域,嵌入式环境有时需要程序员自己定义一个数组作为堆栈。Windows为每个线程自动维护一个堆栈,堆栈的大小可以设置。编译器使用堆栈来堆放每个函数的参数、局部变量等信息。
函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息,每个函数占用一个连续的区域。一个函数占用的区域被称作帧(frame)。
编译器是从高地址开始使用堆栈。
在多线程(任务)环境,CPU的堆栈指针指向的存储器区域就是当前使用的堆栈。切换线程的一个重要工作,就是将堆栈指针设为当前线程的堆栈栈顶地址。
不同CPU,不同编译器的堆栈布局、函数调用方法都可能不同,但堆栈的基本概念是一样的。

1.1堆栈相关寄存器:


esp:堆栈指针(stack pointer),指向系统栈最上面一个栈帧的栈顶
ebp: 基址指针(base pointer),指向系统栈最上面一个栈帧的底部
cs:eip:指令寄存器(extended instruction pointer),指向下一条等待执行的指令地址
注:ebp在C语言中用作记录当前函数调用基址。

1.2堆栈操作


push: 以字节为单位将数据(对于32位系统可以是4个字节)压入栈,从高到低按字节依次将数据存入ESP-1、ESP-2、ESP-3、ESP-4的地址单元。
pop: 过程与PUSH相反。
call: 用来调用一个函数或过程,此时,下一条指令地址会被压入堆栈,以备返回时能恢复执行下条指令。
ret: 从一个函数或过程返回,之前call保存的下条指令地址会从栈内弹出到EIP寄存器中,程序转到CALL之前下条指令处执行 。
注:
call指令的两个作用
①将下一条指令的地址A保存在栈顶
②设置eip指向被调用程序代码开始处

1.3函数堆栈框架的形成(C语言中)


①执行call XXX之前
cs : eip原来的值指向call下一条指令,该值被保存到栈顶
然后cs : eip的值指向xxx的入口地址
②进入 XXX
第一条指令: pushl %ebp //意为保存调用者的栈帧地址
第二条指令: movl %esp, %ebp //初始化XXX的栈帧地址
然后函数体中的常规操作,可能会压栈、出栈
③退出XXX
movl %ebp,%esp
popl %ebp
ret

1.4堆栈其他作用(之后会有所描述)
①参数的传递

函数调用时,调用者依次把参数压栈,然后调用函数,函数被调用以后,在堆栈中取得数据,并进行计算。函数计算结束以后,或者调用者、或者函数本身修改堆栈,使堆栈恢复原装。

在参数传递中,有两个很重要的问题必须得到明确说明:

当参数个数多于一个时,按照什么顺序把参数压入堆栈
函数调用后,由谁来把堆栈恢复原装
这两个问题,在高级语言中,通过函数调用约定来解决,详细参看本文下文“2.函数调用约定”。


②局部变量的使用

2.函数调用约定
函数调用约定包括传递参数的顺序,谁负责清理参数占用的堆栈等,如下面这个主要的函数约定表显示的 :

函数调用约定 参数传递顺序 谁负责清理参数占用的堆栈
__pascal 从左到右 调用者
__stdcall 从右到左 被调函数
__cdecl 从右到左 调用者

调用函数的代码和被调函数必须采用相同的函数的调用约定,程序才能正常运行。在Windows上,__cdecl是C/C++程序的缺省函数调用约定。
在有的cpu上,编译器会用寄存器传递参数,函数使用的堆栈由被调函数分配和释放。这种调用约定在行为上和__cdecl有一个共同点:实参和形参数目不符不会导致堆栈错误。
不过,即使用寄存器传递参数,编译器在进入函数时,还是会将寄存器里的参数存入堆栈指定位置。参数和局部变量一样应该在堆栈中有一席之地。参数可以被理解为由调用函数指定初值的局部变量。

3 例子:__cdecl和__stdcall
不同的CPU,不同的编译器,堆栈的布局可能是不同的。本文以x86,VC++的编译器为例。
VC++编译器的已经不再支持__pascal, __fortran, __syscall等函数调用约定。目前只支持__cdecl和__stdcall。
采用__cdecl或__stdcall调用方式的程序,在刚进入子函数时,堆栈内容是一样的。esp指向的栈顶是返回地址。这是被call指令压入堆栈的。下面是参数,左边参数在上,右边参数在下(指高地址,先入栈)。
如前表所示,__cdecl和__stdcall的区别是:__cdecl是调用者清理参数占用的堆栈,__stdcall是被调函数清理参数占用的堆栈。
由于__stdcall的被调函数在编译时就必须知道传入参数的准确数目(被调函数要清理堆栈),所以不能支持变参数函数,例如printf。而且如果调用者使用了不正确的参数数目,会导致堆栈错误。


通过查看汇编代码,__cdecl函数调用在call语句后会有一个堆栈调整语句,例如:


a = 0x1234;
b = 0x5678;
c = add(a, b);


对应x86汇编:


mov dword ptr [ebp-4],1234h
mov dword ptr [ebp-8],5678h
mov eax,dword ptr [ebp-8]
push eax
mov ecx,dword ptr [ebp-4]
push ecx
call 0040100a
add esp,8
mov dword ptr [ebp-0Ch],eax

__stdcall的函数调用则不需要调整堆栈:


call 00401005
mov dword ptr [ebp-0Ch],eax

函数


int __cdecl add(int a, int b)
{
return a+b;
}


产生以下汇编代码(Debug版本):


push ebp
mov ebp,esp
sub esp,40h
push ebx
push esi
push edi
lea edi,[ebp-40h]
mov ecx,10h
mov eax,0CCCCCCCCh
rep stos dword ptr [edi]
mov eax,dword ptr [ebp+8]
add eax,dword ptr [ebp+0Ch]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret // 跳转到esp所指地址,并将esp+4,使esp指向进入函数时的第一个参数


再查看__stdcall函数的实现,会发现与__cdecl函数只有最后一行不同:


ret 8 // 执行ret并清理参数占用的堆栈


对于调试版本,VC++编译器在“直接调用地址”时会增加检查esp的代码,例如:


ta = (TAdd)add; // TAdd定义:typedef int (__cdecl *TAdd)(int a, int b);
c = ta(a, b);


产生以下汇编代码:


mov [ebp-10h],0040100a
mov esi,esp
mov ecx,dword ptr [ebp-8]
push ecx
mov edx,dword ptr [ebp-4]
push edx
call dword ptr [ebp-10h]
add esp,8
cmp esi,esp
call __chkesp (004011e0)
mov dword ptr [ebp-0Ch],eax


__chkesp 代码如下。如果esp不等于函数调用前保存的值,就会转到错误处理代码。


004011E0 jne __chkesp+3 (004011e3)
004011E2 ret
004011E3 ;错误处理代码


__chkesp的错误处理会弹出对话框,报告函数调用造成esp值不正确。 Release版本的汇编代码要简洁得多。也不会增加 __chkesp。如果发生esp错误,程序会继续运行,直到“遇到问题需要关闭”。


4 补充说明

函数调用约定只是“调用函数的代码”和被调用函数之间的关系。
假设函数A是__stdcall,函数B调用函数A。你必须通过函数声明告诉编译器,函数A是__stdcall。编译器自然会产生正确的调用代码。
如果函数A是__stdcall。但在引用函数A的地方,你却告诉编译器,函数A是__cdecl方式,编译器产生__cdecl方式的代码,与函数A的调用约定不一致,就会发生错误。
以delphi调用VC函数为例,delphi的函数缺省采用__pascal约定,VC的函数缺省采用__cdecl约定。我们一般将VC的函数设为__stdcall,例如:


int __stdcall add(int a, int b);


在delphi中将这个函数也声明为__stdcall,就可以调用了:


function add(a: Integer; b: Integer): Integer;
stdcall; external 'a.dll';


因为考虑到可能被其它语言的程序调用,不少API采用__stdcall的调用约定。

%%%%%%%%%%%%%%%%%%%%%%分界线结束%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

再回到最初的问题,我们可以有一个解题思路,那就是:

1.由于本题中情况的简单性,我们可以直接形参推算到实参的位置:

引用原解答如下:

int crashme(Test* t)
{
//*****
*(int*)( *( (int*)&t - 2 ) - 4 ) = 0;
}

有已有的知识我们可以知道运行到“//*****”时栈中的情况如下:

低地址 ……

主调函数的ebp值

返回的eip

形参t(指针,实参t的值拷贝,同样指向记录对象地址)

主调函数中实参t(指针,记录对象地址)

(主调函数ebp记录的地址就在此处)

高地址 ……

推算过程如下:

&t为形参参数地址,此地址正是处在栈中
(int*)&t - 1为返回的eip的地址
(int*)&t - 2为存储主调函数ebp的地址
(*((int*)&t - 2)为主调函数ebp的值
(*((int*)&t - 2) - 4)为主调函数中t的地址
*(int*) (*((int*)&t - 2) - 4) = 0;让主调函数中的t为0 ,即指针t设为NULL。

在这里会发现高手有一个问题没有考虑,那就是,new Test这个在堆中申请的空间没有释放无法再访问到,造成了内存泄露。当然这个不是这个问题的关键所在^~^。

有一个疑问依然存在的是:

在crashme中只写下delete t为什么依然能够在函数调用完毕后访问到t所指向的对象?值得进一步去了解一下。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值