下面通过研究一个简单的C语言加法函数,来了解在x86上是如何实现函数调用以及返回的。
(实验是使用vc++编译器完成的,其他编译器可能略有不同,但大体一致。)
int Add(int a,int b)
{
return a+b;
}
int main()
{
int c = Add(1,2);
}
为了能更容易看懂下面的代码我先做一些简单的说明:
函数的参数是通过堆栈传递的,返回地址是在call语句时被压入堆栈的,返回值通过eax传递(这是对于泛整型来说)。
堆栈朝低地址增长,esp指向栈顶元素(而不是栈顶的下一个空位)。
这样一个加法函数对应的汇编代码如下,我用颜色做了标记:
淡蓝色的字是函数出入口对应的标准流程:在入口保护栈顶指针,分配局部变量空间,保护其他寄存器;在出口处还原寄存器,还原栈顶指针。通过在MSDN中搜索__LOCAL_SIZE关键字可以得到详细的说明如下
This example shows the standard prolog code that might appear in a 32-bit function:
push ebp ; Save ebp
mov ebp, esp ; Set stack frame pointer
sub esp, localbytes ; Allocate space for locals
push <registers> ; Save registers
The localbytes
variable represents the number of bytes needed on the stack for local variables, and the <registers>
variable is a placeholder that represents the list of registers to be saved on the stack. After pushing the registers, you can place any other appropriate data on the stack. The following is the corresponding epilog code:
pop <registers> ; Restore registers
mov esp, ebp ; Restore stack pointer
pop ebp ; Restore ebp
ret ; Return from function
The stack always grows down (from high to low memory addresses). The base pointer (ebp
) points to the pushed value of ebp
. The locals area begins at ebp-2
. To access local variables, calculate an offset from ebp
by subtracting the appropriate value from ebp
.
红色的数字就是局部变量的空间,如果我们使用__declspec ( naked )来编写汇编函数,那么需要手动进行这个偏移,在汇编代码中可以使用__LOCAL_SIZE来表示这个偏移。
橙黄色的字是编译器在DEBUG版本下插入的将局部变量全部填充成0xcc并进行安全校验的保障代码,可以不必理会。
下面对相应的反汇编代码做逐行的说明,最好从main函数看起。
@ILT+145(_GetProcAddress@8):
00411096 jmp GetProcAddress (41394Eh)
Add:
0041109B jmp Add (411410h) //这些类似函数的跳转表,走到这里会直接jmp到某个函数的函数头,只需要注意这条语句,之后会用到。
@ILT+155(@_RTC_CheckStackVars@8):
004110A0 jmp _RTC_CheckStackVars (411950h)
…
int Add(int a,int b)
{
00411410 push ebp
00411411 mov ebp,esp
00411413 sub esp,0C0h
00411419 push ebx
0041141A push esi
0041141B push edi
0041141C lea edi,[ebp-0C0h]
00411422 mov ecx,30h
00411427 mov eax,0CCCCCCCCh
0041142C rep stos dword ptr es:[edi]
return a+b;
0041142E mov eax,dword ptr [a] //取出函数参数a的值到eax
00411431 add eax,dword ptr [b] //将函数参数b的值累加到eax,而eax就是用来保存函数返回值的,不需要在对计算结果进行移动了
}
00411434 pop edi
00411435 pop esi
00411436 pop ebx
00411437 mov esp,ebp
00411439 pop ebp
0041143A ret //取出当前的栈顶,就是call语句调用时压入的下一条指令地址,然后jmp过去,从而实现函数返回
…
int main()
{
00411450 push ebp
00411451 mov ebp,esp
00411453 sub esp,0CCh
00411459 push ebx
0041145A push esi
0041145B push edi
0041145C lea edi,[ebp-0CCh]
00411462 mov ecx,33h
00411467 mov eax,0CCCCCCCCh
0041146C rep stos dword ptr es:[edi]
int c = Add(1,2);
0041146E push 2 //将函数参数压入堆栈,先从最后一个参数压起,这个顺序与调用约定有关
00411470 push 1 //将函数参数压入堆栈,默认的调用约定是__cdecl,可以参考下面的调用约定表
00411472 call Add (41109Bh) //调用Add函数,(将下一条指令的地址00411477压入堆栈,并跳转到0041109B,
而0041109B处的指令如最上面的跳转表所示,直接跳到另Add函数的首地址)
00411477 add esp,8 //弹出栈中的2个整型函数参数,不需要赋值给任何寄存器,这里直接操作栈顶指针而没有使用pop
0041147A mov dword ptr [c],eax //将返回值赋给变量c
}
0041147D xor eax,eax //用eax异或自身将eax清零,用来作为main函数的返回值
0041147F pop edi
00411480 pop esi
00411481 pop ebx
00411482 add esp,0CCh
00411488 cmp ebp,esp
0041148A call @ILT+350(__RTC_CheckEsp) (411163h)
0041148F mov esp,ebp
00411491 pop ebp
00411492 ret
调用约定表
Keyword
|
Stack cleanup
|
Parameter passing
|
__cdecl
|
Caller
|
Pushes parameters on the stack, in reverse order (right to left)
|
__clrcall
|
n/a
|
Load parameters onto CLR expression stack in order (left to right).
|
__stdcall
|
Callee
|
Pushes parameters on the stack, in reverse order (right to left)
|
__fastcall
|
Callee
|
Stored in registers, then pushed on stack
|
__thiscall
|
Callee
|
Pushed on stack; this pointer stored in ECX
|
了解了以上知识,我们可以做一些实验比如自己实现一个更简单的汇编Add函数,去掉其中不必要寄存器保护和变量填充代码,如下:
__declspec ( naked )
int Add2(int a,int b)
{
__asm
{
push ebp
mov ebp, esp
sub esp, __LOCAL_SIZE
mov eax, dword ptr[a]
add eax, dword ptr[b]
mov esp, ebp
pop ebp
ret
}
}
甚至我们可以去掉标准的函数头尾对esp的保护,甚至不需要使用变量名a,b,因为我们知道它们在堆栈上的位置,如下:
__declspec ( naked )
int Add3(int /*a*/,int /*b*/)
{
__asm
{
mov eax, dword ptr [esp+4]
add eax, dword ptr [esp+8]
ret
}
}