我是目录
- 环境
- 理解栈帧
- 函数栈帧图
- 预备知识
- 寄存器
- MOV 指令
- SUB 指令
- PUSH 指令
- POP 指令
- LEA 指令
- CALL 指令
- REP STOS 指令
- 一个简单的C程序
- 栈帧创建
- 栈帧销毁
- 如何传参
- 数值参数
- 变量参数
- 如何返回值
- 数值返回
- 变量返回
环境
集成环境:VS2022 x86
编辑语言:C
汇编语言:MASM
理解栈帧
为何函数创建需要栈帧?
我知道函数的代码段地址,直接调用就好啦,为什么还需要给他创建一个栈帧来管理呢?
- 管理函数的局部变量和参数:每个函数都有自己独立的局部变量和参数,它们的生命周期仅在该函数执行期间有效。栈帧为每个函数分配一块内存空间,以便保存这些局部变量和参数。这样当函数调用结束后,栈帧会从栈中释放,局部变量的内存也会随之回收,不会影响到其他函数。
- 保存返回地址:在函数调用过程中,需要记录调用者的返回地址,以便在函数执行完毕后能够返回到正确的位置继续执行。而栈帧中通常包含了调用函数的返回地址,确保调用关系能够正确恢复。
以上两点是主要原因
为何要学习栈帧的创建与销毁?
大家都说,函数栈帧对我们而言是透明的,那为什么我们还要学习他的创建与销毁呢?
- 掌握底层编程和逆向工程:在低级语言编程(如汇编、C语言)或逆向工程中,栈帧知识尤为重要。在汇编语言中,栈帧的创建和管理是编程者的直接责任。而逆向工程和安全领域的很多操作都基于栈帧的概念,比如分析函数调用关系、漏洞利用中的缓冲区溢出等。
- 优化程序性能:栈帧分配和释放开销较小,但在高频函数调用中,栈帧的频繁创建与销毁会影响性能。理解栈帧结构可以帮助我们写出更高效的代码,尤其是在需要手动优化的场景中。比如在一些嵌入式系统中,栈空间有限,优化栈帧的使用能避免不必要的内存开销。
函数栈帧图
main函数的栈帧由esp、ebp负责管理。当调用函数时,被调用的函数会被压入栈帧(从高地址往低地址走)
预备知识
由于函数栈帧的创建与汇编代码紧密结合,所以必须要有一点汇编基础(一点就够啦),才能彻底看懂栈帧的创建与销毁
寄存器
通用寄存器:eax
,ebx
,ecx
,edx
,ebp
,esp
…。这些寄存器中可以存放数值(如:1、2、0aH、01001010B…)、也可存放地址(如:12345678H…)
其中ebp
,esp
是今天的主角:他们分别用来管理栈底&栈顶地址,用来维护函数栈帧
MOV 指令
MOV 操作数1,操作数2
将操作数2的值赋给操作数1
SUB 指令
SUB 操作数1,操作数2
将操作数1的值减去操作数2
PUSH 指令
PUSH 操作数
将操作数的值压入栈顶,并且将当前栈顶指针esp
的值-=操作数的占用空间(类型所占空间)
POP 指令
POP 操作数
将栈顶指针esp
所指向的地址按照类型取值赋给操作数,然后esp
的值+=操作数的占用空间(类型所占空间)(如:操作数是word类型,便取出esp所指向的两个字节赋给操作数)
LEA 指令
LEA 操作数1,操作数2
将操作数2的地址(而不是值)放入操作数1中(如:lea eax,buffer,让eax存放buffer的地址)
CALL 指令
CALL 操作数
调转到指定地址
REP STOS 指令
该指令结合了重复操作前缀 (REP
) 和 存储字符串操作 (STOS
),实现对一段内存的快速初始化。
一个简单的C程序
栈帧创建
我们将从一个最简单的C程序开始,学习栈帧的创建与销毁。
在这个程序中,没有形参、返回值
#include<stdio.h>
void func()
{printf("i am func\n");
}
int main()
{func();return 0;
}
反汇编代码如下(只需关注func函数的创建、销毁过程):
//main
int main()
{
//...func();
002A18F2 call _func (02A1104h)
//...
}//func
void func()
{
002A1870 push ebp
002A1871 mov ebp,esp
002A1873 sub esp,0C0h
002A1879 push ebx
002A187A push esi
002A187B push edi
002A187C mov edi,ebp
002A187E xor ecx,ecx
002A1880 mov eax,0CCCCCCCCh
002A1885 rep stos dword ptr es:[edi]
002A1887 mov ecx,offset _74B3F4D1_test@c (02AC008h)
002A188C call @__CheckForDebuggerJustMyCode@4 (02A132Fh)
002A1891 nop printf("i am func\n");
002A1892 push offset string "i am func\n" (02A7B30h)
002A1897 call _printf (02A10D2h)
002A189C add esp,4
}
-
**002A18F2 call _func (02A1104h) **
在
main
函数中调用func
,执行call
跳转指令,跳转到func
函数的地址。这时开始准备创建新的栈帧。 -
002A1870 push ebp
将当前的
EBP
寄存器值压入栈中。这一步的作用是在将来函数返回时能够恢复调用者的栈帧(即main
函数的栈帧)。同时,ESP
会减少 4 字节(ESP = ESP - 4
),指向栈顶的低 4 字节。需要注意的是,栈是从高地址向低地址生长的,因此栈顶的地址小于栈底。register before after esp 0x00cff81c 0x00cff818 ebp 0x00cff8ec 0x00cff8ec -
002A1871 mov ebp,esp
002A1873 sub esp,0C0h将当前的
ESP
值赋给EBP
,使得EBP
成为当前栈帧的基指针。此时,EBP
指向该函数栈帧的底部。将
ESP
向下移动 0C0h(192 字节),为该函数的局部变量和其他数据分配栈空间。此时,EBP
和ESP
之间的空间就构成了该函数的栈帧,栈帧的大小是 192 字节。 -
002A1879 push ebx
002A187A push esi
002A187B push edi这三条指令用于将
EBX
、ESI
和EDI
寄存器的值压入栈中。根据 x86 调用约定,非易失性寄存器(如EBX
、ESI
、EDI
)在函数调用后必须保持不变。如果函数需要修改这些寄存器的值,它必须在使用前将其保存,并在函数结束时恢复。因此,这三条指令将这些寄存器的值保存到栈中,确保它们在函数结束时能够恢复。注意:每次push
操作时,ESP
的值也会随之变化。 -
002A187C mov edi,ebp
将当前栈帧的基指针(
EBP
)的值存储到EDI
寄存器中。这样做的目的是为了在访问局部变量时减少对EBP
的多次访问。通过EDI
寄存器和偏移量,可以直接定位局部变量的地址。 -
002A187E xor ecx,ecx
通过
xor
操作将ECX
的值置为 0。xor
是异或操作,ECX
和自身异或的结果是 0,类似于对ECX
寄存器进行清零。 -
002A1880 mov eax,0CCCCCCCCh
将值
0xCCCCCCCC
移动到EAX
寄存器中。这个值通常用于在调试过程中标记未初始化的内存区域。 -
002A1885 rep stos dword ptr es:[edi]
使用
REP STOS
指令将EAX
的值(即0xCCCCCCCC
)以dword
(4 字节)大小填充到EDI
寄存器指向的地址。由于ECX
为 0,循环不会执行,因此这一步实际上并没有进行任何内存填充。这一点说明,虽然这条指令本应填充内存区域,但由于
ECX = 0
,它实际上不会执行任何填充操作。代码可能认为这段内存不需要填充,因此直接跳过了该操作,从而节省了资源。可以确认,栈帧初始化部分并没有进行实际的内存填充,因此栈帧中的内存部分并未被初始化为
0xCCCCCCCC
。
到此为止,函数的栈帧已经完成初始化。接下来的步骤涉及其他操作,这些操作与栈帧的创建关系不大,属于函数执行过程中的其他额外步骤。
栈帧销毁
反汇编代码如下:
002A189F pop edi
002A18A0 pop esi
002A18A1 pop ebx
002A18A2 add esp,0C0h
002A18A8 cmp ebp,esp
002A18AA call __RTC_CheckEsp (02A1253h)
002A18AF mov esp,ebp
002A18B1 pop ebp
002A18B2 ret
-
002A189F pop edi
002A18A0 pop esi
002A18A1 pop ebx这三条指令将栈顶的
EDI
、ESI
和EBX
寄存器值从栈中弹出,恢复它们在函数调用前的值。每次执行POP
指令时,栈指针ESP
都会增加 4 字节(ESP = ESP + 4
),因此总共会增加 12 字节,使ESP
恢复到这些寄存器值被保存之前的位置。 -
002A18A2 add esp,0C0h
002A18A8 cmp ebp,esp
002A18AA call __RTC_CheckEsp (02A1253h)这一指令将
ESP
的值增加 0C0h(192 字节),恢复栈指针到之前为局部变量分配栈空间的位置。这步操作是为了清理当前函数栈帧的空间,确保栈指针正确指向调用函数(main
)的栈顶。通过
CMP
指令比较EBP
和ESP
的值。EBP
保存了函数栈帧的底部地址,而ESP
保存了栈顶的地址。如果栈帧没有被修改,EBP
和ESP
的值应当相同。如果两者不相等,说明栈帧遭到了非法修改或者栈溢出,可能会导致数据损坏。如果
EBP
和ESP
的值不同,就会执行call
跳转到 Microsoft 运行时检查函数__RTC_CheckEsp
。该函数用于检测栈是否被非法修改或栈是否越界,通常用于调试时捕捉栈相关的错误。 -
002A18AF mov esp,ebp
002A18B1 pop ebp
002A18B2 ret无论是否发生栈溢出或非法修改,执行完运行时检查后,将
ESP
恢复到EBP
的位置。这样做是为了保证栈指针在函数返回时处于正确的位置,从而避免栈损坏。恢复
EBP
寄存器的值,即恢复原来保存的main
函数的栈帧基指针。通过这一步,栈指针恢复到main
函数调用func
前的状态。最后,
RET
指令将控制权返回给调用者,即返回到main
函数的后续代码。这也标志着当前函数(func
)的执行结束,栈帧被清理。
如何传参
数值参数
让我们对代码进行一些小小的修改,以便验证如何使用栈帧进行传参的
void func(int a)
{printf("i am func %d\n",a);
}int main()
{func(1);return 0;
}
反汇编代码如下:
int main()
{
//...func(1);
005318F2 push 1
005318F4 call _func (0531104h)
//...
}void func(int a)
{
00531870 push ebp
00531871 mov ebp,esp
00531873 sub esp,0C0h
00531879 push ebx
0053187A push esi
0053187B push edi
0053187C mov edi,ebp
0053187E xor ecx,ecx
00531880 mov eax,0CCCCCCCCh
00531885 rep stos dword ptr es:[edi]
00531887 mov ecx,offset _74B3F4D1_test@c (053C008h)
0053188C call @__CheckForDebuggerJustMyCode@4 (053132Fh)
00531891 nop printf("i am func %d\n",a);
//...
}
-
005318F4 call _func (0531104h)
在
call
指令之前,通常会有一条push
指令将函数参数(例如1
)压入栈中。这是因为在 x86 架构中,函数的参数通常是通过栈传递的。栈的操作是从高地址到低地址依次压入参数。 -
中间过程与前面创建的一样
-
00531887 mov ecx,offset _74B3F4D1_test@c (053C008h)
之前将
1
压入栈中后,指令mov ecx, offset _74B3F4D1_test@c (053C008h)
将1
的值传递到ecx
寄存器。这是 x86 调用约定中的一种常见做法,尤其是在使用快速调用约定(如fastcall
)时,函数的第一个参数通常会通过ecx
寄存器传递。
变量参数
-
单个变量传递
int main() {int a=10;func(a);return 0; }
009618FD mov eax,dword ptr [a] 00961900 push eax 00961901 call _func (0961104h)
这段代码将实参
a
的值加载到eax
寄存器中,然后将其压入栈中,最终调用func
函数。对于单个形参的函数,传递实参的过程非常简单,就是将实参的值拷贝到eax
中,然后将其压入栈中。对于传值的方式,所有的传值都会涉及到将原始的值拷贝一次,然后传递给调用函数。这就是为什么在函数内部修改值,函数外部不会有任何效果,因为传递的是值的副本。
-
多个变量传递
func(a,b,c,d,e,f); 01014700 mov eax,dword ptr [f] 01014703 push eax 01014704 mov ecx,dword ptr [e] 01014707 push ecx 01014708 mov edx,dword ptr [d] 0101470B push edx 0101470C mov eax,dword ptr [c] 0101470F push eax 01014710 mov ecx,dword ptr [b] 01014713 push ecx 01014714 mov edx,dword ptr [a] 01014717 push edx 01014718 call _func (01011104h)
在这段代码中,我们看到传递了多个参数。实参依然是从右向左依次压入栈中的,确保栈顶是最右边的参数(
f
),而栈底是最左边的参数(a
)。形参和实参的对应关系:
对于只有单个形参的函数,栈中只有一个参数,形参对应的地址可以直接访问。然而,对于多形参函数,如何确保每个形参与其对应的实参正确匹配呢?
- 参数的压栈顺序:
根据调用约定(如cdecl
),栈中最先压入的就是最右侧的参数(即函数调用中的最后一个参数),最后压入的是最左侧的参数(即函数声明中的第一个形参)。这种顺序保证了形参与实参的正确对应。 - 栈帧结构:
栈帧是由ebp
指针标识的,它提供了一个固定的基准点。每个参数都会根据栈帧的布局,通过一个固定的偏移量来访问。具体来说,ebp
指向栈帧的底部,紧接着是函数的返回地址和保存的上一层ebp
,然后才是函数的各个形参。
通过栈帧的结构,编译器能够准确地通过相对于
ebp
的偏移量来访问每个参数。例如,在调用func(a, b, c, d, e, f)
时:f
会存储在栈的最顶端,ebp+12
e
存储在ebp+16
d
存储在ebp+20
c
存储在ebp+24
b
存储在ebp+28
a
存储在ebp+32
这样,通过栈帧指针
ebp
和相应的偏移量,编译器可以确保形参和实参的正确对应。 - 参数的压栈顺序:
-
指针传递
指针也是变量,所以和变量的方法一样:压栈、压栈、压栈!!不过由于其是指针,所以形参声明的类型也是指针,这样在被调用的函数中就拥有访问变量地址以实现永久修改变量值的能力了
如何返回值
数值返回
int func()
{printf("i am func\n");return 1;
}int main()
{func();return 0;
}
return 1;
0081189F mov eax,1
将要返回的值放入eax
寄存器中,然后在外面就可以通过访问该寄存器访问到返回值
变量返回
-
变量返回
int func() {printf("i am func\n");int a=10;return a; }int main() {func();return 0; }
return a; 00C418AA mov eax,dword ptr [a]
将要返回的变量的值放入
eax
中,然后在外面就可以通过访问该寄存器访问到返回值 -
指针返回
int* func() {printf("i am func\n");int* a = malloc(sizeof(int));*a = 10;return a; }int main() {//int a=0, b=0, c=0,d=0,e=0,f=0;func();return 0; }
return a; 008418B9 mov eax,dword ptr [a]
和变量返回一样,不过由于声明的是指针,所以编译器和你都知道该通过解引用的方式访问。然后在外面就可以对
eax
中存储的地址进行访问,进而访问到返回值了