本文主要讨论一下反汇编里面的函数调用与栈帧。内容将会比较偏理论,但是你看过《CSAPP》的话,也不难理解。有兴趣的可以去B站搜一下该内容,有个up讲的不错,图文并茂,总共百来个小时,花几个月时间看完还是很划得来的。
栈帧
当函数被调用时,函数的调用方可能希望以参数(实参)的方式向该函数传递信息,这些参数需要存储到函数能够找到它们的位置。其二,函数的编写过程中,程序员通常会使用到局部变量,这些变量将在函数内部使用,完成函数调用以后,就无法再访问它们。
编译器通过栈帧对函数参数和局部变量进行分配和释放,这个过程对程序员是透明的。在将控制权转交给函数之前,编译器会插入代码,将函数参数放入栈帧内,并分配足够的内存,以保存函数的局部 变量。鉴于栈帧的结构,该函数的返回地址也存储在新的栈帧内。这样就可以进行函数的递归,因为每个递归函数调用都有它自己的栈帧。
下面是调用一个函数时的详细操作步骤:
- 调用方将被调用函数所需的任何参数放入到该函数所采用的调用约定指定的位置。如果参数被放到运行时栈上,该操作可能导致程序的栈指针发生改变。
- 调用方将控制权转交给被调用的函数,这个过程常由x86 CALL 指令执行。然后,返回地址被保存到程序栈或CPU 寄存器中。
- 如有必要,被调用的函数会配置一个栈指针 ,并保存调用方希望保持不变的任何寄存器值。
-
- 帧指针 是一个指向栈帧位置的寄存器。通常,栈帧内的变量根据它们与帧指针所指向的位置的相对距离来引用。
- 被调用的函数为它可能需要的任何局部变量分配空间。一般,通过调整程序栈指针在运行时栈上保留空间来完成这一任务。
- 被调用的函数执行其操作,可能生成一个结果。在执行操作的过程中,被调用的函数可能会访问调用函数传递给它的参数。如果函数返回一个结果,此结果通常被放置到一个特定的寄存器中,或者放置到函数返回后调用方可立即访问的寄存器中。
- 函数完成其操作后,任何为局部变量保留的栈空间将被释放。通常,逆向执行第4步中的操作,即可完成这个任务。
- 如果某个寄存器的值还为调用方保存(第3步)着,那么将其恢复到原始值。这包括恢复调用方的帧指针寄存器。
- 被调用的函数将控制权返还给调用方。实现这一操作的主要指令包括 x86 RET 。根据所使用的调用约定,这一操作可能还会从程序栈中清除一个或多个参数。
- 调用方一旦重新获得控制权,它可能需要删除程序栈中的参数。这时可能需要对栈进行调整,以将程序栈指针恢复到第(1)步以前的值。
调用约定
上面对栈帧的介绍提到了一个东西:调用约定。
创建栈帧时最重要的步骤是,通过调用函数将函数参数存入栈中。调用函数必须存储被调用函数所需的参数,否则可能导致严重的问题。各个函数会选择并遵照某一特定的调用约定,以表明它们希望以何种方式接收参数。
调用约定指定调用方放置函数所需参数的具体位置。调用约定可能要求将参数放置在特定的寄存器、程序栈、或者寄存器和栈中。同样重要的是,在传递参数时,程序栈还要决定:被调用函数完成其操作后,由谁负责从栈中删除这些参数。一些调用约定规定,由调用方负责删除它放置在栈中的参数,而另一些调用约定则要求被调用函数负责删除栈中的参数。遵照指定的调用约定对于维护程序栈指针的完整性尤为重要。
C调用约定
x86体系结构的许多C编译器使用的默认调用约定叫做C调用约定 。一般,我们把这种调用约定叫做cdecl 调用约定。
cdecl 调用约定规定:调用方按从右到左的顺序将函数参数放入栈中,在被调用的函数完成其操作时,调用方(而不是被调用方)负责从栈中清除参数。
举个例子:
void demo_cdecl(int w, int x, int y, int z);
默认情况下,这个函数将使用cdecl 调用约定,并希望你按从右到左的顺序压入4个参数,同时要求调用方清除栈中的参数。编译器可能会为这个函数的调用生成以下代码:
; demo_cdecl(1, 2, 3, 4); //programmer calls demo_cdecl
➊ push 4 ; push parameter z
push 3 ; push parameter y
push 2 ; push parameter x
push 1 ; push parameter w
call demo_cdecl ; call the function
➋ add esp, 16 ; adjust esp to its former value
从➊ 开始的4 个push 操作使程序栈指针(ESP )减少了16 个字节(在32位体系结构上为4*sizeof(int) ),栈是向下增长的。
从demo_cdecl 函数返回后,esp 在➋处增加了16个字节,这里就是要维护栈的平衡,函数的调用前后不能改变栈指针的位置。
我们可以看到,这里是由调用方主动调整了栈指针的值。
我们看另外一个例子,下面的例子同样遵照cdecl 调用约定,但是,在每次调用demo_cdecl 后,调用方不需要删除栈中的参数:
; demo_cdecl(1, 2, 3, 4); //programmer calls demo_cdecl
mov [esp+12], 4 ; move parameter z to fourth position on stack
mov [esp+8], 3 ; move parameter y to third position on stack
mov [esp+4], 2 ; move parameter x to second position on stack
mov [esp], 1 ; move parameter w to top of stack
call demo_cdecl ; call the function
在这个例子中,在函数的“序言”阶段,编译器已经在栈顶为demo_cdecl的参数预先分配了存储空间。在demo_cdecl 的参数放到栈上时,并不需要修改程序栈指针,因此,在调用demo_cdecl 结束后,也就不需要调整栈指针。GNU编译器(gcc和g++ )正是利用这种技巧将函数参数放到栈上的。注意,无论采用哪一种方法,在调用函数时,栈指针都会指向最左边的参数。
标准调用约定
这里的“标准”似乎有些用词不当,因为它是微软为自己的调用约定所起的名称。这种约定在函数声明中使用了修饰符_stdcall ,如下所示:
void _stdcall demo_stdcall(int w, int x, int y);
我们将这种调用约定称为 stdcall 调用约定。
和 cdecl 调用约定一样,stdcall 调用约定按从右到左的顺序将函数参数放在程序栈上。使用stdcall 调用约定的区别在于:函数结束执行时,应由被调用的函数负责删除栈中的函数参数。
对被调用的函数而言,要完成这个任务,它必须清楚知道栈中有多少个参数,这只有在函数接受的参数数量固定不变时才有可能。因此,printf 这种接受数量可变的参数的函数不能使用stdcall 调用约定。
x86 编译器使用RET 指令的一种特殊形式,同时从栈顶提取返回地址,并给栈指针加上12,以清除函数参数。比如,demo_stdcall 可能会使用以下指令返回到调用方:
ret 12 ; return and clear 12 bytes from the stack
使用stdcall 的主要优点在于,在每次函数调用之后,不需要通过代码从栈中清除参数,因而能够生成体积稍小、速度稍快的程序。
x86 fastcall约定
fastcall 约定是stdcall 约定的一个变体,它向CPU 寄存器(而非程序栈)最多传递两个参数。
如果指定使用fastcall 约定,则传递给函数的前两个参数将分别位于ECX和EDX寄存器中。剩余的其他参数则以类似于stdcall 约定的方式从右到左放入栈上。同样与stdcall 约定类似的是,在返回其调用方时,fastcall 函数负责从栈中删除参数。
举个例子:
void fastcall demo_fastcall(int w, int x, int y, int z);
调用这个函数时,编译器可能会生成以下代码:
; demo_fastcall(1, 2, 3, 4); //programmer calls demo_fastcall
push 4 ; move parameter z to second position on stack
push 3 ; move parameter y to top position on stack
mov edx, 2 ; move parameter x to edx
mov ecx, 1 ; move parameter w to ecx
call demo_fastcall ; call the function
调用demo_fastcall 返回后,并不需要调整栈,因为demo_fastcall 负责在返回到调用方时从栈中清除参数y 和z 。由于有两个参数被传递到寄存器中,被调用的函数仅仅需要从栈中清除8字节,即使该函数拥有4个参数也是如此,理解这一点很重要。
C++ 调用约定
C++ 类中的非静态成员函数与标准函数不同,它们需要使用this 指针,该指针指向用于调用函数的对象。用于调用函数的对象的地址必须由调用方提供,因此,它在调用非静态成员函数时作为参数提供。
C++ 语言标准并未规定应如何向非静态成员函数传递this 指针,因此,不同编译器使用不同的技巧来传递this 指针,这点也就不足为奇了。Microsoft Visual C++ 提供thiscall 调用约定,它将this 传递到ECX寄存器中,并且和在stdcall 中一样,它要求非静态成员函数清除栈中的参数。
GNU g++编译器将this 看成是任何非静态成员函数的第一个隐含参数,而在所有其他方面与使用cdecl 约定相同。因此,对使用g++编译的代码来说,在调用非静态成员函数之前,this 被放置到栈顶,且调用方负责在函数返回时删除栈中的参数(至少有一个参数)。
局部变量布局
存在规定如何向函数传递参数的调用约定,但不存在规定函数的局部变量布局的约定。编译器的第一个任务是,计算出函数的局部变量所需的空间。编译器的第二个任务,则是确定这些变量是否可在CPU 寄存器中分配,或者它们是否必须在程序栈上分配。
举个例子:
void bar(int j, int k); // a function to call
void demo_stackframe(int a, int b, int c) {
int x;
char buffer[64];
int y;
int z;
// body of function not terribly relevant other than
bar(z, y);
}
计算得出,局部变量最少需要76 字节的栈空间(3个4字节整数和1个64字节缓冲区)。这个函数可能使用stdcall 或cdecl 调用约定,它们的栈帧完全相同。
一个用于调用 demo_stackframe 的栈帧实现可能如下:
其中的“偏移量”栏显示的是引用栈帧中的任何局部变量或参数所需的基址+位移地址。
生成利用栈指针计算所有变量引用的函数需要编译器做更多工作,因为栈指针会频繁变化,编译器必须确保它在引用栈帧中的任何变量时始终使用了正确的偏移量。
以对demo_stack- frame 函数中bar 的调用代码为例:
➊ push dword [esp+4] ; push y
➋ push dword [esp+4] ; push z
call bar
add esp, 8 ; cdecl requires caller to clear parameters
➊ 处的push 准确地将局部变量y 压入栈中。初看起来,似乎➋ 处的push 错误地再次引用了局部变量y 。但是,因为我们处理的是一个基于ESP 的帧,且➊ 处的push 修改ESP ,所以每次ESP 发生改变,图6-3中的所有偏移量都会临时进行调整。于是,在➊ 之后,➋处的push 中正确引用的局部变量z 的新偏移量变为[esp+4] 。
demo_stackframe 完成后,它需要返回调用方。使用ret 指令从栈顶弹出所需返回地址,并将其赋值给指令指针寄存器(此时为EIP)。在弹出返回地址之前,需要从栈顶删除局部变量,以便在ret 指令执行时,栈指针正确地指向所保存的返回地址。
对栈指针的恢复如下:
add esp, 76 ; adjust esp to point to the saved return address
ret ; return to the caller
默认情况下,多数编译器会使用另外一种方式来计算变量的偏移,因为这种方式更简单,为了好记,我们叫它为帧指针。下面是一个例子:
➌ push ebp ; save the caller's ebp value
➍ mov ebp, esp ; make ebp point to the saved register value
➎ sub esp, 76 ; allocate space for local variables
➌ 处的push 指令保存当前调用方使用的EBP 的值。
遵循用于Intel 32 位处理器的系统V 应用程序二进制接口(System V Application Binary Interface)的函数可以修改EAX、ECX和EDX寄存器,但需要为所有其他寄存器保留调用方的值。因此,如果希望将EBP 作为帧指针,那么,在修改它之前,必须保存EBP 的当前值,并且在返回调用方时恢复EBP 的值。
EBP 被保存后,就可以对其进行修改,使它指向当前的栈位置。这由➍处的mov 指令来完成,它将栈指针的当前值复制到EBP 中。最后,和在非基于EBP 的栈帧中一样,局部变量的空间在➎处分配。得到的栈帧布局如图:
使用一个专用的帧指针,所有变量相对于帧指针寄存器的偏移量都可以计算出来。许多时候(尽管并无要求),正偏移量用于访问函数参数,而负偏移量则用于访问局部变量。 使用专用的帧指针,我们可以自由更改栈指针,而不至影响帧内其他变量的偏移量。现在,对函数bar 的调用可以按以下方式执行:
➏ push dword [ebp-72] ; push y
push dword [ebp-76] ; push z
call bar
add esp, 8 ; cdecl requires caller to clear parameters
在执行➏处的push 指令后,栈指针已经发生改变,但这不会影响到随后的push 指令对局部变量z 的访问。
最后,函数完成其操作后,必须恢复调用方的帧指针。由于当前的帧指针指向最初的帧指针,这个任务可轻松完成:
mov esp, ebp ; clears local variables by reseting esp
pop ebp ; restore the caller's value of ebp
ret ; pop return address to return to the caller
由于这项操作十分常见,因此,x86体系结构提供了leave 指令,专门用于处理这个工作。
leave ; copies ebp to esp AND then pops into ebp
ret ; pop return address to return to the caller
IDA 栈视图
IDA 为任何函数栈帧都提供了两种视图:摘要视图和详细视图。为了解这两种视图,我们以下面使用gcc编译的demo_stackframe 函数为例:
int main() {
return 0;
}
void bar(int j, int k) {
} // a function to call
void demo_stackframe(int a, int b, int c) {
int x;
char buffer[64];
int y;
int z;
// body of function not terribly relevant other than
bar(z, y);
}
这个函数对应的IDA 反汇编代码如下:
```nasm
.text:00401090 ; ========= S U B R O U T I N E ===========================
.text:00401090
.text:00401090 ; Attributes: ➊ bp-based frame
.text:00401090
.text:00401090 demo_stackframe proc near ; CODE XREF: sub_4010C1+41↓ p
.text:00401090
➍ .text:00401090 var_60 = dword ptr -60h
.text:00401090 var_5C = dword ptr -5Ch
.text:00401090 var_58 = byte ptr -58h
.text:00401090 var_C = dword ptr -0Ch
.text:00401090 arg_4 = dword ptr 0Ch
.text:00401090 arg_8 = dword ptr 10h
.text:00401090
.text:00401090 push ebp
.text:00401091 mov ebp, esp
.text:00401093 sub esp, ➋ 78h
.text:00401096 mov eax, [ebp+ ➎ arg_8]
.text:00401099 ➏ mov [ebp+var_C], eax
.text:0040109C ➐ mov eax, [ebp+arg_4]
.text:0040109F ➐ mov [ebp+var_5C], eax
.text:004010A2 ➑ mov [ebp+var_60], 0Ah
.text:004010A9 ➒ mov [ebp+var_58], 41h
.text:004010AD mov eax, [ebp+var_5C]
.text:004010B0 ➌ mov [esp+4], eax
.text:004010B4 mov eax, [ebp+var_60]
.text:004010B7 ➌ mov [esp], eax
.text:004010BA call bar
.text:004010BF leave
.text:004010C0 retn
.text:004010C0 demo_stackframe endp
➊处,IDA 认为这个函数使用EBP寄存器作为栈指针,根据最后的 leave 指令来看,它就是使用的帧指针的方式。
➋处,gcc在栈帧中分配了120字节(78h等于120)的局部变量空间。根据程序代码来看,应该只有76 字节才对,这是因为编译器有时会用额外的字节填补局部变量空间,以确保栈帧内的特殊对齐方式。
从➍开始,IDA 提供了一个简单的栈视图,列出了栈帧内被直接引用的每一个变量,以及变量的大小和它们与帧指针的偏移距离。
IDA 会根据变量相对于被保存的返回地址的位置,为变量取名。局部变量位于被保存的返回地址之上,而函数参数则位于被保存的返回地址之下。
局部变量名称以var_ 为前缀,后面跟一个表示变量与被保存的帧指针之间距离(以字节为单位)的十六进制后缀。 在本例中,局部变量var_C 是一个4 字节(dword )变量,它位于所保存的帧指针之上,距离为12字节([ebp-0Ch] )。
函数参数名则以arg_ 为前缀,后面跟一个表示其与最顶端的参数之间的相对距离的十六进制后缀。 因此,最顶端的4字节参数名为arg_0 ,而随后的参数则分别为arg_4 、arg_8 、arg_C ,以此类推。在这个特例中,arg_0 并未列出,因为函数没有使用参数a 。由于IDA 无法确定任何对[ebp+8] (第一个参数的位置)的内存引用,所以arg_0 并未在摘要栈视图中列出。
IDA只会为那些在函数中直接引用的栈变量自动生成名称。
除摘要栈视图外,IDA 还提供一个详细栈帧视图,这种视图会显示一个栈帧所分配到的每一个字节。双击任何与某一给定的栈帧有关的变量名称,即可进入详细视图。在前一个列表中,双击var_C将打开如图所示的栈帧视图(按ESC 键关闭该窗口):
详细视图显示栈帧中的每一个字节,它占用的空间会比摘要视图(仅列出被引用的变量)多许多。
两个特殊值分别为s 和r (前面均带有空格)。这些伪变量是IDA 表示被保存的返回地址(r )和被保存的寄存器值(s ,在本例中,s 仅代表EBP )的特殊方法。
编译器在保存的帧指针s 与局部变量x(var_C) 之间额外插入了8字节。
编译器给位于var_58 的字符缓冲区分配了76字节(而非源代码中的64字节)。
多数情况下,可以将分配这些额外字节的原因归结成为对齐所做的填补,而且这些字节通常不会影响程序的行为。