马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
x
函数是任何一门高级语言中必须要存在的,使用函数式编程可以让程序可读性更高,充分发挥了模块化设计思想的精髓,今天我将带大家一起来探索函数的实现机理,探索编译器到底是如何对函数这个关键字进行实现的,并使用汇编语言模拟实现函数编程中的参数传递调用规范等。
说到函数我们必须要提起调用约定这个名词,而调用约定离不开栈的支持,栈在内存中是一块特殊的存储空间,遵循先进后出原则,使用push与pop指令对栈空间执行数据压入和弹出操作。栈结构在内存中占用一段连续存储空间,通过esp与ebp这两个栈指针寄存器来保存当前栈起始地址与结束地址,每4个字节保存一个数据。
当栈顶指针esp小于栈底指针ebp时,就形成了栈帧,栈帧中可以寻址的数据有局部变量,函数返回地址,函数参数等。不同的两次函数调用,所形成的栈帧也不相同,当由一个函数进入另一个函数时,就会针对调用的函数开辟出其所需的栈空间,形成此函数的独有栈帧,而当调用结束时,则清除掉它所使用的栈空间,关闭栈帧,该过程通俗的讲叫做栈平衡。而如果栈在使用结束后没有恢复或过度恢复,则会造成栈的上溢或下溢,给程序带来致命错误。
一般情况下在Win32环境默认遵循的就是STDCALL,而在Win64环境下使用的则是FastCALL,在Linux系统上则遵循SystemV的约定,这里我整理了他们之间的异同点.
- CDECL:C/C++默认的调用约定,调用方平栈,不定参数的函数可以使用,参数通过堆栈传递.
- STDCALL:被调方平栈,不定参数的函数无法使用,参数默认全部通过堆栈传递.
- FASTCALL32:被调方平栈,不定参数的函数无法使用,前两个参数放入(ECX, EDX),剩下的参数压栈保存.
- FASTCALL64:被调方平栈,不定参数的函数无法使用,前四个参数放入(RCX, RDX, R8, R9),剩下的参数压栈保存.
- System V:类Linux系统默认约定,前八个参数放入(RDI,RSI, RDX, RCX, R8, R9),剩下的参数压栈保存.
首先先来写一段非函数版的堆栈使用案例,案例中模拟了编译器如何生成Main函数栈帧以及如何对栈帧初始化和使用的流程,笔者通过自己的理解写出了Debug版本的一段仿写代码。- .386p
- .model flat,stdcall
- option casemap:none
- include windows.inc
- include kernel32.inc
- includelib kernel32.lib
- .code
- main PROC
- push ebp ; 保存栈底指针ebp
- mov ebp,esp ; 调整当前栈底指针到栈顶
-
- sub esp,0e4h ; 抬高栈顶esp开辟局部空间
-
- push ebx ; 保存寄存器
- push esi
- push edi
-
- lea edi,dword ptr [ ebp - 0e4h ] ; 取出当前函数可用栈空间首地址
-
- mov ecx,39h ; 填充长度
- mov eax,0CCCCCCCCh ; 填充四字节数据
- rep stosd ; 将当前函数局部空间填充初始值
-
- ; 使用当前函数可用局部空间
- xor eax,eax
-
- mov dword ptr [ ebp - 08h ],1
- mov dword ptr [ ebp - 014h ],2 ; 使用局部变量
- mov dword ptr [ ebp - 020h ],3
-
- mov eax,dword ptr [ ebp - 014h ]
- add eax,dword ptr [ ebp - 020h ]
-
- ; 如果指令影响了堆栈平衡,则需要平栈
- push 4 ; 此情况,由于入栈时没有修改过,平栈只需add esp,12
- push 5
- push 6 ; 如果代码没有自动平栈,则需要手动平
- add esp,12 ; 每个指令4字节 * 多少条影响
-
- push 10
- push 20
- push 30 ; 使用3条指令影响堆栈
- pop eax
- pop ebx ; 弹出两条
- add esp,4 ; 修复堆栈时只需要平一个变量
-
- pop edi ; 恢复寄存器
- pop esi
- pop ebx
-
- add esp,0e4h ; 降低栈顶esp开辟的局部空间,局部空间被释放
-
- cmp ebp,esp ; 检测堆栈是否平衡,不平衡则直接停机
- jne error
-
- pop ebp
- mov esp,ebp ; 恢复基址指针
- int 3
-
- error:
- int 3
-
- main ENDP
- END main
复制代码 5.1 CDECL
CDECL是C/C++中的一种默认调用约定(调用者平栈)。这种调用方式规定函数调用者在将参数压入栈中后,再将控制权转移到被调用函数,被调用函数通过栈顶指针ESP来访问这些参数。函数返回时,由调用者程序负责将堆栈平衡清除。CDECL调用约定的特点是简单易用,但相比于其他调用约定,由于栈平衡的操作需要在函数返回后再进行,因此在一些情况下可能会带来一些性能上的开销。
该调用方式在函数内不进行任何平衡参数操作,而是在退出函数后对esp执行加4操作,从而实现栈平衡。该约定会采用复写传播优化,将每次参数平衡的操作进行归并,在函数结束后一次性平衡栈顶指针esp,且不定参数函数也可使用此约定。- .386p
- .model flat,stdcall
- option casemap:none
- include windows.inc
- include kernel32.inc
- includelib kernel32.lib
- .code
- function PROC
- push ebp
- mov ebp,esp
- sub esp,0cch
- push ebx
- push esi
- push edi
-
- lea edi,dword ptr [ ebp - 0cch ] ; 初始化局部变量
- mov ecx,33h
- mov eax,0CCCCCCCCh
- rep stosd
- mov eax,dword ptr [ ebp + 08h ] ; 第一个变量(传入参数1)
- add eax,dword ptr [ ebp + 0Ch ] ; 第二个变量(传入参数2)
- add eax,dword ptr [ ebp + 10h ] ; 第三个变量(传入参数3)
-
- mov dword ptr [ ebp - 08h ],eax ; 将结果放入到局部变量
- mov eax,dword ptr [ ebp - 08h ] ; 给eax寄存器返回
-
- pop edi
- pop esi
- pop ebx
- mov esp,ebp
- pop ebp
- ret
- function endp
- main PROC
- ; 单独调用并无优势
- push 3
- push 2
- push 1
- call function ; __cdecl functin(1,2,3)
- add esp,12
-
- ; 连续调用则可体现出优势
- push 5
- push 4
- push 3
- call function ; __cdecl function(3,4,5)
- mov ebx,eax
-
- push 6
- push 7
- push 8
- call function ; __cdecl function(8,7,6)
- mov ecx,eax
-
- add esp,24 ; 一次性平两次栈
-
- int 3
- main ENDP
- END main
复制代码 5.2 STDCALL
STDCALL 调用约定规定由被调用者负责将堆栈平衡清除。STDCALL是一种被调用者平栈的约定,这意味着,在函数调用过程中,被调用函数使用栈来存储传递的参数,并在函数返回之前移除这些参数,这种方式可以使调用代码更短小简洁。STDCALL与CDECL只在参数平衡上有所不同,其余部分都一样,但该约定不定参数函数无法使用。
通过以上分析发现_cdecl与_stdcall两者只在参数平衡上有所不同,其余部分都一样,但经过优化后_cdecl调用方式的函数在同一作用域内多次使用,会在效率上比_stdcall髙,这是因为_cdecl可以使用复写传播优化,而_stdcall的平栈都是在函数内部完成的,无法使用复写传播这种优化方式。- .386p
- .model flat,stdcall
- option casemap:none
- include windows.inc
- include kernel32.inc
- includelib kernel32.lib
- .code
- function PROC
- push ebp
- mov ebp,esp
- sub esp,0cch
- push ebx
- push esi
- push edi
-
- lea edi,dword ptr [ ebp - 0cch ] ; 初始化局部变量
- mov ecx,33h
- mov eax,0CCCCCCCCh
- rep stosd
- mov eax,dword ptr [ ebp + 08h ] ; 第一个变量(传入参数1)
- add eax,dword ptr [ ebp + 0Ch ] ; 第二个变量(传入参数2)
- add eax,dword ptr [ ebp + 10h ] ; 第三个变量(传入参数3)
-
- mov dword ptr [ ebp - 08h ],eax ; 将结果放入到局部变量
- mov eax,dword ptr [ ebp - 08h ] ; 给eax寄存器返回
-
- pop edi
- pop esi
- pop ebx
- mov esp,ebp
- pop ebp
-
- ret 12 ; 应用stdcall时,通过ret对目标平栈
- function endp
- main PROC
- push 3
- push 2
- push 1
- call function ; __stdcall functin(1,2,3)
- mov ebx,eax ; 获取返回值
-
- push 4
- push 5
- push 6
- call function ; __stdcall function(6,5,4)
- mov ecx,eax ; 获取返回值
-
- add ebx,ecx ; 结果相加
- int 3
- main ENDP
- END main
复制代码 5.3 FASTCALL
FASTCALL是一种针对寄存器的调用约定。它通常采用被调用者平衡堆栈的方式,类似于STDCALL调用约定。但是,FASTCALL约定规定函数的前两个参数在ECX和EDX寄存器中传递,节省了压入堆栈所需的指令。此外,函数使用堆栈来传递其他参数,并在返回之前使用类似于STDCALL约定的方式来平衡堆栈。
FASTCALL的优点是可以在发生大量参数传递时加快函数的处理速度,因为使用寄存器传递参数比使用堆栈传递参数更快。但是,由于FASTCALL约定使用的寄存器数量比CDECL和STDCALL约定多,因此它也有一些限制,例如不支持使用浮点数等实现中需要使用多个寄存器的数据类型。
FASTCALL效率最高,其他两种调用方式都是通过栈传递参数,唯独_fastcall可以利用寄存器传递参数,一般前两个或前四个参数用寄存器传递,其余参数传递则转换为栈传递,此约定不定参数函数无法使用。
- 对于32位来说使用ecx,edx传递前两个参数,后面的用堆栈传递。
- 对于64位则会使用RCX,RDX,R8,R9传递前四个参数,后面的用堆栈传递。
- .386p
- .model flat,stdcall
- option casemap:none
- include windows.inc
- include kernel32.inc
- includelib kernel32.lib
- .code
- function PROC
- push ebp
- mov ebp,esp
- sub esp,0e4h
- push ebx
- push esi
- push edi
- push ecx
-
- lea edi,dword ptr [ ebp - 0e4h ] ; 初始化局部变量
- mov ecx,39h
- mov eax,0CCCCCCCCh
- rep stosd
- pop ecx
-
- mov dword ptr [ ebp - 14h ],edx ; 读入第二个参数放入局部变量
- mov dword ptr [ ebp - 8h ],ecx ; 读入第一个参数放入局部变量
-
- mov eax,dword ptr [ ebp - 8h ] ; 从局部变量内读入第一个参数
- add eax,dword ptr [ ebp - 14h ] ; 从局部变量内读入第二个参数
-
- add eax,dword ptr [ ebp + 8h ] ; 从堆栈中读入第三个参数
- add eax,dword ptr [ ebp + 0ch ] ; 从堆栈中读入第四个参数
- add eax,dword ptr [ ebp + 10h ] ; 从堆栈中读入第五个参数
- add eax,dword ptr [ ebp + 14h ] ; 从堆栈中读入第六个参数
-
- mov dword ptr [ ebp - 20h ],eax ; 将结果给第三个局部变量
- mov eax,dword ptr [ ebp - 20h ] ; 返回数据
-
- pop edi
- pop esi
- pop ebx
- mov esp,ebp
- pop ebp
-
- ret 16 ; 平栈
- function endp
- main PROC
- push 6
- push 5
- push 4
- push 3
- mov edx,2
- mov ecx,1 ; __fastcall function(1,2,3,4,5,6)
- call function ; 调用函数
-
- int 3
- main ENDP
- END main
复制代码 5.4 使用ESP寄存器寻址
编译器开启了O2优化模式选项,则为了提高程序执行效率,只要栈顶是稳定的,编译器编译时就不再使用ebp指针了,而是利用esp指针直接访问局部变量,这样可节省一个寄存器资源。
在程序编译时编译器会自动为我们计算ESP基地址与传入变量的参数偏移,使用esp寻址后,不必每次进入函数后都调整栈底ebp,从而减少了ebp的使用,因此可以有效提升程序执行效率。但如果在函数执行过程中esp发生了变化,再次访问变量就需要重新计算偏移了。- .386p
- .model flat,stdcall
- option casemap:none
- include windows.inc
- include kernel32.inc
- includelib kernel32.lib
- .code
- function PROC
- push ebp
- mov ebp,esp
- sub esp,0ch
- push esi
-
- ; 动态计算出四个参数
- lea eax,dword ptr [ esp - 4h + 01ch ] ; 计算参数1 [esp+18]
- lea ebx,dword ptr [ esp - 0h + 01ch ] ; 计算参数2 [esp+1c]
- lea ecx,dword ptr [ esp + 4h + 01ch ] ; 计算参数3 [esp+20]
- lea edx,dword ptr [ esp + 8h + 01ch ] ; 计算参数4 [esp+24]
-
- ; 如果ESP被干扰则需要动态调整
- lea eax,dword ptr [ esp - 4h + 01ch ] ; 当前参数1的地址
- push ebx
- push ecx ; 指令让ESP被减去8
- lea eax,dword ptr [ esp - 4h + 01ch + 8h ] ; 此处需要+8h修正堆栈
-
- add esp,0ch
- pop esi
- mov esp,ebp
- pop ebp
- ret
- function endp
- main PROC
- push 5
- push 3
- push 4
- push 1
- call function
- int 3
- main ENDP
- END main
复制代码 5.5 使用数组指针传值
这里我们以一维数组为例,二维数组的传递其实和一维数组是相通的,只不过在寻址方式上要使用二维数组的寻址公式,此外传递数组其实本质上就是传递指针,所以数组与指针的传递方式也是相通的。
使用汇编仿写数组传递方式,在main函数内我们动态开辟一块栈空间,并将数组元素依次排列在栈内,参数传递时通过lea eax,dword ptr [ ebp - 18h ]获取到数组栈地址空间,由于main函数并不会被释放所以它的栈也是稳定的,调用function函数时只需要将栈首地址通过push eax的方式传递给function函数内,并在函数内通过mov ecx,dword ptr [ ebp + 8h ]获取到函数基地址,通过比例因子定位栈空间。- .386p
- .model flat,stdcall
- option casemap:none
- include windows.inc
- include kernel32.inc
- includelib kernel32.lib
- .code
- function PROC
- push ebp
- mov ebp,esp
- sub esp,0cch
- push ebx
- push esi
- push edi
- lea edi,dword ptr [ ebp - 0cch ]
- mov ecx,33h
- mov eax,0CCCCCCCCh
- rep stosd
-
- ; 检索数组第一个元素
- mov eax,1
- mov ecx,dword ptr [ ebp + 8h ] ; 定位数组基地址
- mov edx,dword ptr [ ecx + eax * 4 ] ; 定位元素
-
- ; 检索数组第二个元素
- mov eax,2
- mov ecx,dword ptr [ ebp + 8h ]
- mov edx,dword ptr [ ecx + eax * 4 ]
-
- pop edi
- pop esi
- pop ebx
- add esp,0cch
- mov esp,ebp
- pop ebp
- ret
- function ENDP
- main PROC
- push ebp
- mov ebp,esp
- sub esp,0dch
- push ebx
- push esi
- push edi
- lea edi,dword ptr [ ebp - 0dch ]
- mov ecx,37h
- mov eax,0CCCCCCCCh
- rep stosd
-
- mov dword ptr [ ebp - 18h ],1 ; 局部空间存储数组元素
- mov dword ptr [ ebp - 14h ],2
- mov dword ptr [ ebp - 10h ],3
- mov dword ptr [ ebp - 0ch ],4
- mov dword ptr [ ebp - 8h ],5
-
- push 5
- lea eax,dword ptr [ ebp - 18h ] ; 取数组首地址并入栈
- push eax
- call function ; 调用函数 function(5,eax)
- add esp,8 ; 平栈
- pop edi
- pop esi
- pop ebx
- add esp,0dch
- mov esp,ebp
- pop ebp
- ret
- main ENDP
- END main
复制代码 5.6 指向函数的指针
程序通过CALL指令跳转到函数首地址执行代码,既然是地址那就可以使用指针变量来存储函数的首地址,该指针变量被称作函数指针。
在编译时编译器为函数代码分配一段存储空间,这段存储空间的起始地址就是这个函数的指针,我们可以调用这个指针实现间接调用指针所指向的函数。- #include <iostream>
- void __stdcall Show(int x, int y)
- {
- printf("%d --> %d \n",x,y);
- }
- int __stdcall ShowPrint(int nShow, int nCount)
- {
- int ref = nShow + nCount;
- return ref;
- }
- int main(int argc, char* argv[])
- {
- // 空返回值调用
- void(__stdcall *pShow)(int,int) = Show;
- pShow(1,2);
- // 带参数调用返回
- int(__stdcall *pShowPrint)(int, int) = ShowPrint;
- int Ret = pShowPrint(2, 4);
- printf("返回值 = %d \n", Ret);
- return 0;
- }
复制代码 首先我们使用汇编仿写ShowPrint函数以及该函数所对应的int(__stdcall *pShowPrint)(int, int)函数指针,看一下在汇编层面该如何实现这个功能。- .386p
- .model flat,stdcall
- option casemap:none
- include windows.inc
- include kernel32.inc
- includelib kernel32.lib
- .code
- function PROC
- push ebp
- mov ebp,esp
- sub esp,0cch
- push ebx
- push esi
- push edi
- lea edi,dword ptr [ ebp - 0cch ]
- mov ecx,33h
- mov eax,0CCCCCCCCh
- rep stosd
-
- mov eax,dword ptr [ ebp + 4h ] ; 此处+4得到的是返回后上一条指令地址
- mov eax,dword ptr [ ebp + 8h ] ; 得到第一个堆栈传入参数地址
- mov ebx,dword ptr [ ebp + 0ch ] ; 得到第二个堆栈传入参数地址
- add eax,ebx ; 递增并返回到EAX
-
- pop edi
- pop esi
- pop ebx
- add esp,0cch
- mov esp,ebp
- pop ebp
- ret
- function ENDP
- main PROC
- push ebp
- mov ebp,esp
- sub esp,0d8h
- push ebx
- push esi
- push edi
- lea edi,dword ptr [ ebp - 0d8h ]
- mov ecx,36h
- mov eax,0CCCCCCCCh
- rep stosd
-
- lea eax,function ; 获取函数指针
- mov dword ptr [ ebp - 8h ],eax ; 将指针放入局部空间
-
- push 4
- push 2 ; 传入参数
- call dword ptr [ ebp - 8h ] ; 调用函数
- add esp,8 ; 平栈
- pop edi
- pop esi
- pop ebx
- add esp,0d8h
- mov esp,ebp
- pop ebp
- ret
- main ENDP
- END main
复制代码 本文作者: 王瑞
本文链接: https://www.lyshark.com/post/17fb1a42.html
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |