介绍
本文章介绍C/C++编写的32位程序的函数调用过程,主要关注寄存器以及堆栈的变化,讲解栈帧,堆栈平衡,参数传递过程等
一个示例程序
首先出场的是一个简单的示例程序
#include <iostream>
int add_func(int a, int b)
{
int sum = 0;
sum = a + b;
return sum;
}
int main()
{
int a = 5;
int b = 6;
int sum = 0;
sum = add_func(a, b);
printf("sum: %d\n", sum);
return0;
}
这是一个很简单的示例程序,main 函数中调用 add_func 接口,计算两个数的和,本次我们主要关注 add_func 接口的调用过程
参数传递
首先我们来看一下下图中 main 函数的汇编代码
这个截图是main函数的全部内容,首先我们需要关注的是main函数在调用add_func接口的参数传递过程,在我们的截图中,0x6118c8 (call demo.6110BE) 地址处为实际调用 add_func 接口,那参数是如何传递的呢?
我们从该条指令向上看,从地址 0x006118C0
处开始的4条指令就是用于传递参数的,首先使用 mov 指令从 ebp-0x14
地址处获取到了一个值,复制到 eax 寄存器中,然后将 eax 寄存器中的值压入栈中,然后使用相同的方法将 ebp - 0x08
处的值复制到 ecx 寄存器中,然后再将 ecx 寄存器中的值压入栈中
那EBP寄存器是什么呢?EBP用于保存栈帧的基址,基址我们可以理解为栈底的指针,由于栈是向下增长的,所以栈帧内部保存的值需要使用 EBP 减一个偏移值来访问;那栈帧又是什么呢?我们一会再说。
我们在寄存器窗口可以观察到EBP的指针为 0x0071F718
然后我们在栈数据窗口查看一下该
EBP-0x14
和 EBP-0x08
处的值
我们可以观察到首先保存在栈中的数据EBP-0x14处的值为6,后保存的数据EBP-0x08处的数据是5,而我们的程序中,5是第一个参数,6是第二个参数,由此我们可以得出结论,在该程序中函数调用时按照从后向前顺序依次将参数压入栈中。接下来我们看一下压入栈后的结果是什么样的
这里我们要再介绍一个寄存器,ESP寄存器,它保存着我们的栈顶指针。也就是是说它指向我们的栈顶,我们在寄存器窗口中查阅到ESP指针的值为 0X0071F620
,根据此我们可以找到栈顶处的内容,也就是我们刚刚压入的参数
函数的返回地址
那函数在执行完成后是如何找到返回地址的呢?答案是在使用call指令调用一个函数的时候,会将返回地址保存在栈中。
我们首先记住call
指令的下一条指令的地址0x006118CD
,在函数调用完成后会返回到该地址继续执行,接下来我们执行call
指令进入add_func
函数,我们可以发现刚刚记录的返回地址被压入到了栈中
栈帧的初始化
栈帧(Stack Frame) 是程序运行时调用栈(Call Stack)中的一个数据结构,用于管理函数调用过程中的局部变量、参数、返回地址等信息。每个函数调用都会创建一个对应的栈帧,函数执行结束后,栈帧会被销毁。
首先我们看一下 add_func
接口的返回汇编内容
我们可以发现,在函数的最开始的部分有两条指令,push ebp;mov ebp,esp。这两条指令的意思是将现有ebp寄存器中的值压入栈中,然后将ESP的值复制给ebp。刚刚我们说了ebp保存的是栈帧的基址,esp保存的栈顶指针,那么这两句话的意思也就是说我将上一个栈帧的基址保存在了栈中,然后从现在的栈顶位置开始作为新的栈帧的基址。
然后我们可以发现下一条指令为 sub esp,0xCC,这是一条减法指令,指令的含义是用 esp 的值减 0xCC,然后就将结果保存在ESP中,刚刚我们说栈是向下增长的,所以这句话的含义是在新的栈帧中开辟局部变量的存储空间
接下来的一系列PUSH指令是用于将寄存器中的内容保存在栈中,防止在程序执行过程中修改了这些寄存器的值,这些寄存器的值可会会在上层函数中用到,如果这里修改了就会导致上层函数计算出现错误,所以这里首先将寄存器保存在栈中,在函数返回时在从栈中恢复到寄存器中。
最后我们来看一下现在栈的结构是什么样子的,需要注意的是因为栈是逆向增长的,所以EBP的值是大于ESP的也就是说这个图下面的地址大于上边的地址
参数的使用
接下来我们看一下函数的计算过程,首先将执行 mov dword ptr ss:[ebp-0x8], 0x0
,将 EBP-x8
处的内容设置为0,这是因为 ebp-0x08
就是我们刚刚说的局部变量的空间,先将其设置为0
然后程序执行mov eax, dword ptr ss:[ebp+0x8]
,将ebp+0x8
处的值复制给 eax
,我们可以观察到ebp + 0x8
其实就是我们在函数调用前压入的第一个参数5,然后执行add eax, dword ptr ss:[ebp+0xC]
,将ebp+0xc
处的内容加上eax
然后结果保存在eax
中。到此就计算出了两个参数相加的和
然后程序执行mov dword ptr ss:[ebp-0x8], eax
将eax
中计算出来的和保存至局部变量中
返回值
函数的返回值是通过eax寄存器进行传递的,所以我们可以看到接下来的一条指令为mov eax, dword ptr ss:[ebp-0x8]
,将局部变量中的和保存在eax
寄存器中,其实eax
寄存器中保存的已经是刚刚的和了,这里又保存了一遍,这是因为我为了让大家能够直观的看到数据的流转,特意使用一个局部变量保存了计算的结果,而且在编译的时候关闭了优化选项,所以我们这里可以看到具体的执行过程,虽然它没有什么必要
栈帧的销毁
现在函数执行完成后,该要进行返回了,在函数返回时会将栈帧进行销毁,恢复上一个函数的调用栈
我们可以看到首先依次执行一系列的POP
指令,将栈中保存的寄存器的值恢复到寄存器中,这里我们可以发现保存的顺序和恢复的数据是相反的,这是因为栈是先进后出的,所以先保存的数据要放在最后弹出
接下来的指令是mov esp, ebp
,这条指令的含义是将ebp
寄存起复制给esp
寄存器,刚刚我们是esp
是栈顶指针,ebp
是栈基址,所以这条指令的含义就是将刚刚开辟的栈空间恢复,局部变量也就在这时全都销毁了,如果我们观察栈上的内存就会发现局部变量的值依旧保存在栈上,但是由于栈顶指针已经回复到调用函数之前的栈顶了所以后面再压栈时会覆盖这些值
紧接着是一条 pop ebp
指令,该指令对应进入函数时的push ebp
指令,含义是恢复函数调用前的EBP
内容
最后执行ret
指令,ret
指令会从栈中弹出调用call
指令保存的返回地址,并将其设置到eip
寄存器,eip
寄存器为下一条要执行的指令的地址
函数返回后我们可以看到首先会执行add esp, 0x8
指令,该指令的含义是将调用函数前压入栈的参数销毁,然后执行mov dword ptr ss:[ebp-0x20], eax
指令,从eax寄存起中取出返回值并保存到局部变量中,到此函数的调用过程就结束了
安全问题
我们可以看到使用调试工具函数的整个执行过程中都暴露了出来,同时还可以对其进行修改,那我们的程序也就毫无安全性可言了,其他人可以随意的分析我们的程序,修改我们的程序,那我们应该如何应对呢?
答案是我们可以使用Virbox protector工具进行加壳保护,经加壳的程序可以检测调试器不让其进行调试,还可以对我们的代码进行保护,让其他人无法分析我们的代码,在程序开发中,程序的安全性问题往往是我们所忽略的,破解者如果修改了我们的程序自己使用还好,如果进行传播,那将会带来很大的损失。