目录
- 1、调用函数流程(main函数调用print函数):
-
- [Step1 保存main函数现场地址等信息](#Step1 保存main函数现场地址等信息)
- [Step2 跳转到print函数的位置](#Step2 跳转到print函数的位置)
- [Step3 执行print函数的指令](#Step3 执行print函数的指令)
- [Step4 返回main函数,执行下一条指令](#Step4 返回main函数,执行下一条指令)
- 流程连续性总结
- 2、其他知识总结
1、调用函数流程(main函数调用print函数):
Step1. 保存main函数现场地址等信息
Step2. 跳转到print函数的位置
Step3. 执行print函数的指令
Step4. 返回main函数,执行下一条指令
Step1 保存main函数现场地址等信息
要存储的内容,主要有3个:
(1)传入print函数的形参【父函数中实现】
(2)原eip(main函数call指令的下一条指令的地址)【父函数中实现】
(3)原ebp(main函数的ebp地址,因为是main函数直接调用print函数)【子函数中实现】
实现方式:
在main函数
中,依次执行以下指令:
bash
push eax; # 传入函数的形参arg
call print; #作用是保存eip。当然,也有跳转的作用(见Step2)
在print函数
开头,依次执行以下指令:
bash
push ebp; # 保存父函数main函数的栈底地址,方便调用函数结束后pop回去
mov ebp esp; # 指定print函数的栈帧基地址(print函数对应的栈底地址)
Step2 跳转到print函数的位置
核心是修改eip寄存器,把eip寄存器的值,修改为print函数的入口地址。
实现方式: 在main函数
中调用call
指令
bash
call print.77c70888; # 相当于esp = esp - 4; push eip(此时的eip值,就是下面"main函数中的下一条指令"的地址); mov eip print函数的地址77c70888(跳转到新函数)
main函数中的下一条指令;
Step3 执行print函数的指令
子函数最开始,一般要执行以下的命令:
bash
push ebp; # 保存原ebp
mov ebp esp; # ebp指向当前函数的栈底
...
sub esp, num; # 为子程序分配栈空间
...
pop ebp; # 让ebp指向父函数的栈帧基地址
ret n; # 让eip指向"main函数call指令的下一条指令的地址";n是指平衡堆栈,即让最先push进去的形参args清空
Step4 返回main函数,执行下一条指令
核心是修改eip寄存器的值,把eip寄存器的值,修改为main函数中下一条指令的地址。
实现方式 :在print函数
中,调用ret
命令
bash
ret ; #相当于pop eip; esp = esp + 4
注意,如果原先栈中还有其他数据,esp 没有归位会导致主函数引用栈中数据出错。在这种背景下,出现了堆栈平衡的概念。即,还需对esp 进行单独操作,才能将 esp 指向原函数栈顶。以常见的 c 语言,函数有好几种调用规则。比如 cdecl 方式和 stdcall 方式。
cdecl 方式中,由主程序执行 add esp, n
指令调整 esp,达到堆栈平衡。在 stdcall 方式中,由子程序在返回时,执行 ret n
平衡堆栈。n 其实就是函数的参数所占的空间大小。
流程连续性总结
重要参考:《从汇编角度理解 ebp&esp 寄存器、函数调用过程、函数参数传递以及堆栈平衡》
函数调用
在一个函数中,调用另外一个函数,往往有以下几个步骤:
汇编指令 | 指令归属函数 | SP 变化 | 作用 |
---|---|---|---|
push arg2 | 主函数 | sp-4 | |
push arg1 | 主函数 | sp-4 | |
call function |
主函数 | sp-4 | 开始调用子程序,同时保存返回地址 |
push ebp |
子函数 | sp-4 | |
mov ebp, esp | 子函数 | sp-4 | 将当前esp 存入 ebp,目的是定位函数参数 |
sub sp, #num | 子函数 | sp-num | 为子程序分配栈空间 |
... | 子函数 | ... | 函数的具体实现逻辑 |
pop ebp | 子函数 | sp+4 | |
ret | 子函数 | sp+4 |
说明:
push arg
在调用一个函数之前,需要把传递的参数压入栈,因此需要有。每次 push 之后,栈多了一个字长(32 位系统 --> 4 字节),因此栈顶需要往上移动 4 字节,该指令暗含sub sp, #4
call
call 指令用来调用某个函数,该指令有3个操作(1)sp = sp - 4(2)将返回地址压入栈; (3)修改eippush ebp
,mov ebp, esp
这样的操作,你会在各个函数的开头见到,保存上一个函数栈的基址,并更新本函数的基址ret
,即 return,此时 sp 应该指向 call 指令刚刚压入的返回地址;执行ret
其实就是将此时栈中的数据弹出,存至 eip 寄存器。eip 存放的是下一条即将执行的指令的地址。 同时 sp = sp + 4ret
指令相当于pop eip(esp = esp + 4)
call
指令相当于push eip(esp = esp - 4)
;mov eip 新函数的地址
下图左边是主函数调用子函数,右边是子函数返回主函数:
2、其他知识总结
1、esp寄存器,永远指向整个栈帧的栈顶(esp寄存器保存的值是堆栈的地址值);栈顶中的内容,可能是一个地址值,也可能是一个立即数等等。esp寄存器指向的堆栈地址始终有值!所以push是先移esp后压栈,pop是先弹栈后移esp。
2、ebp寄存器,永远指向当前函数所在栈帧的栈底(ebp寄存器保存的值是堆栈的地址值);栈底中的内容,永远保存的是上一个函数(父函数)的ebp地址!(方便函数执行完后pop回去,即修改ebp)
- ebp 的作用之一就是找到函数的形参(通过
ebp + 偏移量
),当然栈中的局部变量也是可以通过 ebp 来定位的(ebp - 偏移量
)
3、父函数调用子函数,子函数的栈帧(低地址)是紧挨着父函数的栈帧(高地址)。
-
所以父函数调用子函数,一定是①args先压榨 ② 然后 原eip压栈(by call)③ 再 ebp压栈 ④ 最后函数的局部变量压榨
-
父函数栈帧 和 子函数栈帧,隔着的东西就是:args 和 eip
4、栈的增长方向,永远向着低地址的方向增长。
5、call 指令相当于:esp = esp - 4; push eip; mov eip 函数的地址.77c70888
(注意,push中已包含esp移动,这里只是表示先后)
6、ret指令相当于:pop eip; esp = esp + 4
(注意,pop中已包含esp移动,这里只是表示先后)
7、push eax指令,只会修改栈和esp:① esp = esp - 4 ② mov [esp], eax 这个[esp]表示esp指向的栈值(内存值)被赋值。
8、pop eax指令,不仅会修改栈和esp,而且还会修改eax(寄存器被赋值):① mov eax, [esp] ②esp = esp + 4
9、寄存器的地址值是不会变的,变的只是寄存器中装的堆栈地址值,以及这个堆栈地址中的内存内容值。这个类似并区别于C语言的指针变量。C语言指针变量有2个地址值,一个是指针变量装的堆栈地址值,另一个是指针本身的地址值(也位于堆栈)。指针变量声明后,它存在于内存中(堆栈中),指针释放了,这个指针的地址值也就无了。区别点在于 指针有本身的地址值(可以通过&取出来),而寄存器没有本身的地址值的说法(取不出来,或者有也是固定的但不常用)。指针变量的常操作数据有3个:①p ②*p ③&p;esp寄存器常操作的数据有2个:①esp ②[esp]
- esp:esp寄存器的内容值,即栈/内存的某个地址
- [esp]:栈中的值 / 内存中的值