(整理:网站建设与网站制作公 司-VeiSun.Com)首先来说一下,为什么我们要了解函数调用栈机制。很多人会说,我从未关注函数调用栈机制,同样可以写出工作的很好的程序,知道 这个又有什么用。但是,对于任何技术而言,我们对其了解的越透彻,才能越好的驾驭他;另一方面,在实际工作中,我们经常会遇到通过故障转储文件 (.dump)文件来定位BUG的问题,如果不对函数调用栈机制有一个清晰的认识,就很难从dump中得到函数参数,返回值的宝贵信息。
本文的讨论基于以下假设,做这些假设是为了讨论结果更为确定,避免二义性。
1. 讨论的是C语言中的函数调用机制,基本上也适用于C++。
2. 调用使用__cdecl约定,也就是由调用者(下文也称caller)而非被调用者(下文也称callee)负责压入与清理参数和返回地址。
3. 不考虑FPO等优化技术。
本文的讨论基于以下的示例代码,代码功能很简单,但是足以阐述清楚本文的主题。
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;
}
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
; 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所存储的值来解答这个问题。
做一个注明,以下所称函数运行到某某指令都是指该指令将执行而未执行,和在这行打断点是一个意思。
1. 程序运行至mov eax, DWORD PTR _b$[ebp],假设此时esp=0x0012fe78,ebp=0x0012ff68。当然了,这也不是随便假设的,在程序运行中,始终是有esp& lt;=ebp这样的不大于关系存在的,否则只有一种可能,栈已经被破坏了。
2. 程序运行至call ?Add@@YAHHH@Z,由于压入了两个参数,esp的值减8。此时esp=0x0012fe70,ebp=0x0012ff68。
3. 程序运行至push ebp,这里已经进入了Add函数,函数的返回地址也被压入栈中,所以esp的值再减4。此时esp=0x0012fe6c,ebp=0x0012ff68。
4. 程序运行至mov ebp, esp,由于将ebp压栈,esp的值再减4。此时esp=0x0012fe68,ebp=0x0012ff68。
5. 将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局部变量。
6. 程序运行到mov esp, ebp,由于Add函数中有局部变量,esp减小了一些。此时esp=0x0012fd9c,ebp=0x0012fe68。
7. 将ebp寄存器的值赋给esp寄存器。这绝不应该理解为一句简单的赋值,这次赋值意味着Add函数的局部变量都已经被抹去,如果只考虑栈,可以说Add函 数对栈的影响已经消除。此时esp=ebp=0x0012fe68,和步骤5时一致,我们应该意识到,栈的恢复开始了。
8. 从栈中pop出一个值,并赋给ebp寄存器。由于ebp在步骤4中记录的是运行完push ebp的栈顶,恰好此时弹出的是当时压入的值,当然了,由于pop操作,esp需要加4。此时 esp=0x0012fe6c,ebp=0x0012ff68,与步骤3一致。
9. 程序运行至ret 0,此时如果观察eip寄存器的值,就会发现和步骤5中的ebp+4的值一样,由于eip寄存器存储的是CPU将要运行的下一条指令,这里也可以看出函数返回地址的含义。
10. 函数返回,将函数返回地址弹出栈,esp加4。此时esp=0x0012fe70,ebp=0x0012ff68,与步骤2一致。
11. 清理参数,此时程序又回到了main函数中,由于是两个参数,所以esp加8。此时esp=0x0012fe78,ebp=0x0012ff68,与步骤1一致,栈恢复完成。
通过以上运行时的分析,我们可以看到在函数调用过程中栈的扩展与恢复的动态过程,应该说C里面这个函数调用栈机制的设计是颇为精巧的,关键是esp和 ebp这两个寄存器之间的赋值时机,正是caller和callee职责交替的时机,正是这个时机的正确,才能实现正确的恢复。
如果清楚了C中的函数调用栈机制,还是有一些立竿见影的效果,举两个例子。
1. 网上盛传的Google面试题,如何通过C/C++编程来判断栈的生长方向。很明显,如果比较函数中两个局部变量的地址,这是很不靠谱,在函数调用中,由 于局部变量导致的esp偏移是一次性完成的,没有什么机制保证先声明或者先使用的局部变量更靠近栈底或者栈顶,不过参数总是比局部变量先压栈的,拿个参数 和局部变量的地址比较一下直接就出结果了。
2. 栈的正确恢复极大程度的依赖于压入的ebp的值的正确性,但是ebp和局部变量是挨的很近的,如果编程过程中有意无意的通过局部变量的地址偏移窜改了 ebp,程序的行为将变得非常危险,至于有意与无意的区别无非就在与恶意软件与BUG之分。虽然Visual Studio 2005以后有了一些保护措施,不过我们编程中依然需要对此非常小心。:)
本文来自CSDN博客