简析C语言中的函数调用栈机制
来源:互联网 发布:sql平均值函数 编辑:程序博客网 时间:2024/06/01 12:54
前言
首先来说一下,为什么我们要了解函数调用栈机制。很多人会说,我从未关注函数调用栈机制,同样可以写出工作的很好的程序,知道这个又有什么 用。但是,对于任何技术而言,我们对其了解的越透彻,才能越好的驾驭他;另一方面,在实际工作中,我们经常会遇到通过故障转储文件(.dump)文件来定 位BUG的问题,如果不对函数调用栈机制有一个清晰的认识,就很难从dump中得到函数参数,返回值的宝贵信息。
本文约定
本文的讨论基于以下假设,做这些假设是为了讨论结果更为确定,避免二义性:
- 讨论的是C语言中的函数调用机制,基本上也适用于C++。
- 调用使用__cdecl约定,也就是由调用者(下文也称caller)而非被调用者(下文也称callee)负责压入与清理参数和返回地址。
- 不考虑FPO等优化技术。
正文
本文的讨论基于以下的示例代码,代码功能很简单,但是足以阐述清楚本文的主题。
C语言示例代码
- int Add(int a, int b);
- int main()
- {
- int a = 12;
- int b = 34;
- int c = Add(a, b);
- return 0;
- }
- int Add(int a, int b)
- {
- int c = a + b;
- return c;
- }
对应的汇编代码
第一步,我们需要得到汇编结果。侯捷老师有句名言,叫做源代码面前没有秘密可言,对于程序逻辑来说的确如此,但是要想了解机器的运作机制,目前看来只有汇 编可以做到,可以说汇编面前计算机脱去了最后一层薄纱。在Visual Studio里面想得到汇编代码在Project→Property→C/C++→OutputFiles里面把 Assembler Output选上就可以了。汇编后的结果如下:
- ; Listing generated by Microsoft (R) Optimizing Compiler Version 15.00.30729.01
- TITLE d:/Projects/ForWinDbg/FunctionCall/FunctionCall.cpp
- .686P
- .XMM
- include listing.inc
- .model flat
- INCLUDELIB MSVCRTD
- INCLUDELIB OLDNAMES
- PUBLIC ?Add@@YAHHH@Z ; Add
- PUBLIC _main
- EXTRN __RTC_CheckEsp:PROC
- EXTRN __RTC_Shutdown:PROC
- EXTRN __RTC_InitBase:PROC
- ; COMDAT rtc$TMZ
- ; File d:/projects/forwindbg/functioncall/functioncall.cpp
- rtc$TMZ SEGMENT
- __RTC_Shutdown.rtc$TMZ DD FLAT:__RTC_Shutdown
- rtc$TMZ ENDS
- ; COMDAT rtc$IMZ
- rtc$IMZ SEGMENT
- __RTC_InitBase.rtc$IMZ DD FLAT:__RTC_InitBase
- ; Function compile flags: /Odtp /RTCsu /ZI
- rtc$IMZ ENDS
- ; COMDAT _main
- _TEXT SEGMENT
- _c$ = -32 ; size = 4
- _b$ = -20 ; size = 4
- _a$ = -8 ; size = 4
- _main PROC ; COMDAT
- ; 4 : {
- push ebp
- mov ebp, esp
- sub esp, 228 ; 000000e4H
- push ebx
- push esi
- push edi
- lea edi, DWORD PTR [ebp-228]
- mov ecx, 57 ; 00000039H
- mov eax, -858993460 ; ccccccccH
- rep stosd
- ; 5 : int a = 12;
- mov DWORD PTR _a$[ebp], 12 ; 0000000cH
- ; 6 : int b = 34;
- mov DWORD PTR _b$[ebp], 34 ; 00000022H
- ; 7 :
- ; 8 : int c = Add(a, b);
- mov eax, DWORD PTR _b$[ebp]
- push eax
- mov ecx, DWORD PTR _a$[ebp]
- push ecx
- call ?Add@@YAHHH@Z ; Add
- add esp, 8
- mov DWORD PTR _c$[ebp], eax
- ; 9 :
- ; 10 : return 0;
- xor eax, eax
- ; 11 : }
- pop edi
- pop esi
- pop ebx
- add esp, 228 ; 000000e4H
- cmp ebp, esp
- call __RTC_CheckEsp
- mov esp, ebp
- pop ebp
- ret 0
- _main ENDP
- ; Function compile flags: /Odtp /RTCsu /ZI
- _TEXT ENDS
- ; COMDAT ?Add@@YAHHH@Z
- _TEXT SEGMENT
- _c$ = -8 ; size = 4
- _a$ = 8 ; size = 4
- _b$ = 12 ; size = 4
- ?Add@@YAHHH@Z PROC ; Add, COMDAT
- ; 14 : {
- push ebp
- mov ebp, esp
- sub esp, 204 ; 000000ccH
- push ebx
- push esi
- push edi
- lea edi, DWORD PTR [ebp-204]
- mov ecx, 51 ; 00000033H
- mov eax, -858993460 ; ccccccccH
- rep stosd
- ; 15 : int c = a + b;
- mov eax, DWORD PTR _a$[ebp]
- add eax, DWORD PTR _b$[ebp]
- mov DWORD PTR _c$[ebp], eax
- ; 16 : return c;
- mov eax, DWORD PTR _c$[ebp]
- ; 17 : }
- pop edi
- pop esi
- pop ebx
- mov esp, ebp
- pop ebp
- ret 0
- ?Add@@YAHHH@Z ENDP ; Add
- _TEXT ENDS
- END
解析
我们要分析的是main函数对Add函数的调用过程。
- 在main函数里面,是这样做的。
- mov eax, DWORD PTR _b$[ebp] ;将参数b的值放入eax寄存器中
- push eax ;将eax寄存器的值压入栈中
- mov ecx, DWORD PTR _a$[ebp] ;将参数a的值放入ecx寄存器中
- push ecx ;将ecx寄存器的值压入栈中
- call ?Add@@YAHHH@Z ;调用Add函数
- add esp, 8 ;清理参数
- mov DWORD PTR _c$[ebp], eax ;将eax的值赋给c变量,即处理返回值
- 而在Add函数里面,是这样做的。
- push ebp ;将ebp寄存器的值压入栈中
- mov ebp, esp ;将esp寄存器的值赋给ebp寄存器
- ;省略部分,函数Add的内部工作,本文不关心
- mov esp, ebp ;将ebp寄存器的值赋给esp寄存器
- pop ebp ;从栈中弹出一个值,并赋给ebp寄存器
- ret 0 ;函数返回
应该说,C的函数调用都是基于以上的框架结构的,无非是可能函数的参数更多一些,类型更复杂一些而已。通过注释,我们已经知道函数调用的逻辑 过程,但是还存在一个严重的问题,为什么这样的调用就可以保证栈能正常恢复?或者说,为什么调用Add以后栈可以恢复的恰到好处?本文试图解答这个问题。
要完成函数调用时栈的扩展和恢复,主要是由esp和ebp这两个寄存器实现的。esp是栈顶寄存器,里面存的是下次push时将会写入的地 址,当然了,由于x86架构下栈由高地址向低地址生长,所以push会导致esp变小,而pop导致esp变大,因而有人也将esp称为栈底寄存器,这个 称呼是无所谓的,也不影响讨论。ebp是基址寄存器,里面存的函数的基地址。应该说esp是很好理解的,但是ebp不然,基地址到底是什么呢?我们可以通 过程序运行时esp和ebp所存储的值来解答这个问题。
做一个注明,以下所称函数运行到某某指令都是指该指令将执行而未执行,和在这行打断点是一个意思。
- 程序运行至mov eax, DWORD PTR _b$[ebp],假设此时esp=0x0012fe78,ebp=0x0012ff68。当然了,这也不是随便假设的,在程序运行中,始终是有esp& lt;=ebp这样的不大于关系存在的,否则只有一种可能,栈已经被破坏了。
- 程序运行至call ?Add@@YAHHH@Z ,由于压入了两个参数,esp的值减8。此时esp=0x0012fe70,ebp=0x0012ff68。
- 程序运行至push ebp,这里已经进入了Add函数,函数的返回地址也被压入栈中,所以esp的值再减4。此时esp=0x0012fe6c,ebp=0x0012ff68。
- 程序运行至mov ebp, esp,由于将ebp压栈,esp的值再减4。此时esp=0x0012fe68,ebp=0x0012ff68。
- 将esp寄存器的值赋给ebp寄存器,此时esp = ebp = 0x0012fe68。此时ebp的值就很奇妙了,ebp+4就是函数的返回地址,ebp+8就是函数从左往右的第一个参数,ebp+12是第二个参数, 依次类推。这次赋值意味着一个重要的时刻,那就是caller和callee职责的交割,这以后callee执行自己的代码,更改esp的值,都是 callee自己的事情,而ebp寄存器已经将这一时刻的栈顶记录了下来。此外,ebp还是caller和callee之间的数据枢纽,callee通过 ebp加上偏移量才能得到参数的值,ebp被称为基地址寄存器的意义也就在于此,以他为基准,区分了caller和callee的数据,也就是参数,返回 地址和callee局部变量。
- 程序运行到mov esp, ebp,由于Add函数中有局部变量,esp减小了一些。此时esp=0x0012fd9c,ebp=0x0012fe68。
- 将ebp寄存器的值赋给esp寄存器。这绝不应该理解为一句简单的赋值,这次赋值意味着Add函数的局部变量都已经被抹去,如果只考虑栈,可以说Add函数对栈的影响已经消除。此时esp=ebp=0x0012fe68,和步骤5时一致,我们应该意识到,栈的恢复开始了。
- 从栈中pop出一个值,并赋给ebp寄存器。由于ebp在步骤4中记录的是运行完push ebp的栈顶,恰好此时弹出的是当时压入的值,当然了,由于pop操作,esp需要加4。此时esp=0x0012fe6c,ebp=0x0012ff68,与步骤3一致。
- 程序运行至ret 0,此时如果观察eip寄存器的值,就会发现和步骤5中的ebp+4的值一样,由于eip寄存器存储的是CPU将要运行的下一条指令,这里也可以看出函数返回地址的含义。
- 函数返回,将函数返回地址弹出栈,esp加4。此时esp=0x0012fe70,ebp=0x0012ff68,与步骤2一致。
- 清理参数,此时程序又回到了main函数中,由于是两个参数,所以esp加8。此时esp=0x0012fe78,ebp=0x0012ff68,与步骤1一致,栈恢复完成。
通过以上运行时的分析,我们可以看到在函数调用过程中栈的扩展与恢复的动态过程,应该说C里面这个函数调用栈机制的设计是颇为精巧的,关键是 esp和ebp这两个寄存器之间的赋值时机,正是caller和callee职责交替的时机,正是这个时机的正确,才能实现正确的恢复。
如果清楚了C中的函数调用栈机制,还是有一些立竿见影的效果,举两个例子:
- 网上盛传的Google面试题,如何通过C/C++编程来判断栈的生长方向。很明显,如果比较函数中两个局部变量的地址,这是很不靠谱,在函 数调用中,由于局部变量导致的esp偏移是一次性完成的,没有什么机制保证先声明或者先使用的局部变量更靠近栈底或者栈顶,不过参数总是比局部变量先压栈 的,拿个参数和局部变量的地址比较一下直接就出结果了。
- 栈的正确恢复极大程度的依赖于压入的ebp的值的正确性,但是ebp和局部变量是挨的很近的,如果编程过程中有意无意的通过局部变量的地址偏 移窜改了ebp,程序的行为将变得非常危险,至于有意与无意的区别无非就在与恶意软件与BUG之分。虽然Visual Studio 2005以后有了一些保护措施,不过我们编程中依然需要对此非常小心。:)
==================================================================
后记:
之所以找了这篇文章,是因为在调试代码的时候,碰到了自以为奇怪的问题,所调试的代码大概如下:
- char *pMsg = NULL;
- //这里给pMsg复制
- pMsg = (char *)malloc(MSG_SIZE_MAX);
- //printf("message address :%p/n", pMsg);
- FillMsg(pMsg);
- //printf("message address :%p/n", pMsg);
- ....
在以上代码中,如果没有printf语句,则在FillMsg函数调用后,pMsg的值被改了,当然,肯定不是在FillMsg函数故意改的。最后发现了原因,那就是,函数调用栈被破坏了,因为在FillMsg函数中定义了一个长度为256的字符数组,在该项目的老版本中,这个长度无论如何都是够了的,但是很不幸,在新版本中由于重构的缘故,这个长度却显得有点勉强了,虽然大多数情况下是够了。
假设调用FillMsg的这个函数的函数名为AddMsg。因为一般来说,往字符数组里拷贝内容的时候,都是从低地址写往高地址的,而函数调用栈的压栈顺序则是从高地址压往低地址的(即栈底在高地址一端,而栈顶在低地址一端),所以,FillMsg函数中定义的字符数组如果写越界了就会破坏AddMsg函数的局部变量了。
有了所找的这篇文章的知识背景后,这个问题就好找多了!
像这种调用栈被踩(即上面说的被破坏)的情况还是比较好定位的,最难的是内存被踩,这个以后再整理一篇文章出来
- 简析C语言中的函数调用栈机制
- C语言函数调用机制
- 简析C中的函数调用堆栈机制
- C语言函数调用的底层机制
- C语言函数调用的底层机制
- C语言函数调用的底层机制
- C语言中的函数调用,栈的使用
- 浅谈C语言中的函数调用方式-----栈帧结构
- C语言调用Python脚本中的函数
- C语言中的函数调用(栈帧)
- C语言中的函数调用(栈帧)
- C语言函数调用栈
- C函数调用机制
- C函数调用机制
- C函数调用机制
- C语言中的函数调用和函数返回值
- C语言函数调用模型[调用过程中在堆栈中的出栈、进栈顺序]
- C语言函数调用
- 分析PMT changed for the ROM:it must be downloaded.升级失败。
- pythontip84
- KMP之个人理解
- JAVA annotation入门
- C4.5
- 简析C语言中的函数调用栈机制
- PPT2007中图片挖空效果的实现
- 第三周:程序阅读(2)
- ThinkPHP的简单CURD操作代码
- CRC算法原理及其Verilog实现
- UvaOJ 10167 Birthday Cake
- Android开发_Intent
- 刷题之道001——查找最小的k个元素
- 工作计划与安排