先说说函数栈帧的概念,函数栈帧又叫函数运行时堆栈,栈帧也叫过程活动记录,是编译器用来实现函数调用的一种数据结构。这个该概念说起来比较抽象,简单的说就是函数在被调用时的一块空间,这个空间由 esp 寄存器和 ebp 寄存器共同维护。首先应该明白,栈是从高地址向低地址延伸的。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器 ebp 指向当前的栈帧的底部 (高地址),寄存器 esp 指向当前的栈帧的顶部 (低地址)。
main 函数的调用
程序的入口必须是 main 吗?每当我们在点击一个 (或者在命令行打开) 一个 C 程序的时候,程序立马就能运行起来,从刚开始学习 C 语言的时候我们都直到 main 函数是程序的入口。至于为什么这么说完全是因为 ANSIC 就是这么规定的,一个程序的执行并不一定需要 main 函数,但是一定需要一个入口,做过单片机的同学应该深有体会。起来从 CPU 角度来看,将要执行的指令地址放在程序计数器里,程序需要执行必然需要一个入口地址。通用的可执行文件格式总会指定一个入口地址,这样操作系统才可以调度这样一个程序执行指令。所谓的 main 函数,就是执行时把这个程序装入任务调度器中,调度器执行调度的入口函数而已,而 main 函数只是个程序员和调度器之间的约定。
mainCRTStartup 函数:扯得有点远了,在 VC++ 下,连接器对控制台程序设置的入口函数是 mainCRTStartup,mainCRTStartup 再调用 main 函数,mainCRTStartup 所做的初始化准备工作,例如获取命令行参数、获取环境变量值,是通过调用相应的 Windows 系统调用来实现的。
1 |
|
下面是其反汇编代码:
1 | int Add (int x, int y){ |
由此也可以看出编程语言的进化历程,要是现在还是由我们来写这些汇编代码,想想都难受…
汇编指令简述
数据传送指令
这部分指令包括通用数据传送指令 MOV、条件传送指令 CMOVcc、堆栈操作指令 PUSH/PUSHA/PUSHAD/POP/POPA/POPAD、交换指令 XCHG/XLAT/BSWAP、地址或段描述符选择子传送指令 LEA/LDS/LES/LFS/LGS/LSS 等。注意,CMOVcc 不是一条具体的指令,而是一个指令簇,包括大量的指令,用于根据 EFLAGS 寄存器的某些位状态来决定是否执行指定的传送操作。
在本例中,可以将 MOV 指令理解为赋值语句,例如 mov ebp,esp 就是将 esp 中的值赋给 ebp;push 指令理解为压栈,例如 push ebx 就是将 ebx 寄存器压栈 (入栈),同样的道理,pop ebx 就是将 ebx 寄存器弹栈 (出栈),例如 lea edi,[ebp-0E4h] 就是取源操作数地址的偏移量,并把它传送到目的操作数所在的单元
整数和逻辑运算指令
这部分指令用于执行算术和逻辑运算,包括加法指令 ADD/ADC、减法指令 SUB/SBB、加一指令 INC、减一指令 DEC、比较操作指令 CMP、乘法指令 MUL/IMUL、除法指令 DIV/IDIV、符号扩展指令 CBW/CWDE/CDQE、十进制调整指令 DAA/DAS/AAA/AAS、逻辑运算指令 NOT/AND/OR/XOR/TEST 等。
在本例中用到了 add 和 sub 指令,例如 add esp,8,就是给 esp 寄存器中的值加上 8,执行加法运算;还有一个是 cmp 指令,cmp 是比较指令,cmp 的功能是相当于减法指令,只是不保存结果.cmp 指令执行后,将对标志寄存器产生影响。其他相关指令通过识别这些被影响的标志寄存器来得知比较结果.
串操作指令
这部分指令用于对数据串进行操作,包括串传送指令 MOVS、串比较指令 CMPS、串扫描指令 SCANS、串加载指令 LODS、串保存指令 STOS,这些指令可以有选择地使用 REP/REPE/REPZ/REPNE 和 REPNZ 的前缀以连续操作。
本例中使用到了 rep stos 等等
lea edi,[ebp-0C0h]
mov ecx,30h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
rep 指令的目的是重复其上面的指令.ECX 的值是重复的次数.
STOS 指令的作用是将 eax 中的值拷贝到 ES:EDI 指向的地址.
LEA 叫做 loadEffectiveAddress,加载有效的地址
其他指令在本例中未出现,故在此不再赘述
函数栈帧解析
看完上述的汇编指令简述,再次看到汇编语言应该就不那么陌生了,接下来正式进入函数栈帧的分析环节 main 的函数栈帧
由前面我们已经知道,是 mainCRTStartup 函数调用的 main 函数,main 函数终究也是一个函数,是函数调用就肯定需要空间的,由 esp 寄存器和 ebp 寄存器来维护这块空间,这块空间就叫函数栈帧
1 | 00961410 push ebp |
首先将 ebp 寄存器压栈,然后将 ebp 的位置移向了 esp 的位置,esp 有向上移动了 04E 个位置,然后将 ebx、esi、edi 寄存器压栈,然后让 edi 寄存器加载红线所示的地址,从 edi 的位置开始 copy,重复拷贝 ecx 次数据,一次拷贝的大小是双字节 (4 个字节),拷贝到 eax 里面!
0CCCCCCCC 在被解析的时候就是乱码,对应的 ACCSI 码也就是” 烫”, 所以我们在使用未初始化的变量的时候又时会打印出” 烫烫烫…” 这也就不足为奇了
变量的创建以及函数调用时参数拷贝:
1 | int a = 10; |
可以看出,首先创建了 a 和 b 变量并做了相应的初始化操作,然后将 b 的值放入到 eax 寄存器中,然后将 eax 寄存器压栈,同样把 a 变量放入 ecx 寄存器中,然后将 ecx 压栈:
也可以得出结论:
- C 语言中所有的参数传递都是值传递,形式参数只是实参的一份临时拷贝
- 后面的参数先被压入栈中,即参数传递的顺序是右向左的
Add 函数的函数的函数栈帧
1 | 00961444 call _Add (09610E6h) |
call 指令先将下一条指令语句的给地址存起来了,方便在函数执行完毕之后执行下一条语句,这一点至关重要,一个函数执行完毕一定要跳到下一条语句的地方继续执行才可以。Add 函数栈帧的创建于 main 函数栈帧的创建与初始化操作是一样的,同样的道理 Add 函数也是采用同样的方式初始化
1 | 009613D0 push ebp |
1 | // 接下来就是创建 Z 变量并且执行加法操作将结果返回 |
首先将 x 的值放入 eax 寄存器中,然后执行加法运算将结果放入 eax 寄存器中,然后把 eax 寄存器中的值放入变量 z,然后将 z 的值存入 eax 寄存器作为返回,接着将 edi、esi、ebx 三个寄存器分别弹栈,又将 ebp 移向了 esp 的位置,此时 Add 函数的栈帧销毁,代表整个 Add 函数的结束!当 pop ebp 的时候,将 ebp 弹栈,此时下面正好就是 Call 指令下一条指令的地址,这样就能接着 Add 的函数的结束继续执行下面的代码了!
1 | 00961449 add esp,8 |
然后把 eax 寄存器中的值放入变量 c,这样就拿到了 Add 函数的返回值,所以说返回值是寄存器带回来的!
VC6.0 之坑
1 |
|
在 VC6.0 的编译器下打印结果是 20(VC6.0 是多么可怕从这里也就可以看出了…),只要了解函数栈帧的话本题就不难解释,tmp 的地址加一再解引用刚好就是 main 函数的 ebp 的地址,然后 p-1 得到的就是 main 函数中 a 的地址,这样的话打印出 20 也就不足为奇了!
函数栈帧的理解还是很重要的,其中涉及到函数栈帧的初始化,参数传递,返回值传递等等问题,学习函数栈帧就是明白函数的整个调用过程,还是非常重要的一部分!