第一章-熟悉工作环境和相关工具
1.1 调试工具 Microsoft Visual C++6.0 和 OllyDBG
OD基本使用
快捷键 | 功能说明 |
---|---|
F2 | 断点 |
F3 | 加载一个可执行程序,进行分析 |
F4 | 程序执行到光标处 |
F7 | 单步步入 |
F8 | 单步步过 |
F9 | 直接运行程序,遇见断点处停止 |
Ctrl+F2 | 重新运行程序到起始位置 |
Ctrl+F9 | 执行函数到返回处,用于跳出函数实现 |
Alt+F9 | 执行到用户代码处,用于快速跳出系统函数 |
Ctrl+G | 定位地址 |
1.2 反汇编静态分析工具 IDA
IDA基本使用
快捷键 | 功能说明 |
---|---|
Enter | 跟进函数实现 |
Esc | 返回跟进位置 |
A | 解释光标处的地址为一个字符串的首地址 |
B | 十六进制转化为二进制 |
C | 解释光标处地址为一条指令 |
D | 解释该地址为数据 |
G | 查找地址 |
H | 十六进制转化成十进制 |
K | 数据解释为栈变量 |
: | 注释 |
M | 解释为枚举成员 |
N | 重命名 |
O | 字符串标号 |
T | 解释为结构体成员 |
X | 转化视图为交叉参考模式 |
Shift+F9 | 添加结构体 |
👴第一次看到这么多快捷键学到了
三个选项
1.分析文件为一个PE格式的文件
2.分析文件为一个DOS控制台下的一个文件
3.分析文件为一个二进制的文件
sig:
我们一般IDA可以识别函数的MessageBoxA等等参数信息,是因为IDA通过SIG文件来识别的,所以在安装的时候我们会有相应的.sig在pc那个文件夹下面了,所以我们可以利用这个,来去识别第三方的库函数,简化分析
SIG制作流程:
创建模式文件 pcf.exe xxx.lib xxx.pat
创建签名文件 sigmake.exe xxx.pat xxx.sig
直接举书上的例子好吧!
1 | void ShowSig(){ |
没有加载sig之前,那么我们制作一下
直接我们在ida中有
打开powershell,搞就完事,然后把生成的.sig丢到 sig文件下的pc文件夹即可
1.3 反汇编引擎的工作原理
X86平台下使用的汇编指令对应的二进制机器码是Intel指令集——Opcode
Prefix | code | Mode R/M | SIB Displacement | Immediate |
---|---|---|---|---|
指令前缀 | 指令操作码 | 操作数类型 | 辅助Mode R/M,计算地址偏移 | 立即数 |
Opcode这里书上没有给太多解释,我直接在CSDN找到了一篇文章,说的挺不错的,这里自己总结一下(网址:https://blog.csdn.net/kl195375/article/details/89788837)
我们的计算机只认识0与1是没毛病的,但是我们写的源程序并不是0与1,那么计算机如何知道我们程序的含义?
假设我们写一个汇编的NOP
,我们在编译的时候,汇编语言会扫描整个源代码,所以我们知道计算机只认识0与1,那么源代码NOP
无法运行,所以为了让计算机生成能运行的东西,我们要用0x90
来代替
这里的0x90
是我们说的Opcode,而Nop是我们说的助记符(menemonic
)
可是一个Opcode对应一个menemonic吗
Opcode | mnemonic |
---|---|
0x90 | NOP |
0x90 | XCHG AX,AX |
0x90 | XCHG EAX,EAX |
那么一个mnemonic对应一个Opcode吗
menemonic | Opcode |
---|---|
ADD EAX, 1 | 0x83C001 |
ADD EAX, 1 | 0x0501000000 |
ADD EAX, 1 | 0x81C001000000 |
所以:一个OpCode 不只对应一个menemonic ,一个menemonic不只对应一个OpCode
上面说的那个Opcode的6个域排列顺序,是不可以改变的,一般来说最不可少的就是code这个域
OpCode | menemonic |
---|---|
0xC3 | RETN |
0x2F | DAS |
0x90 | NOP |
0xAC | LODSB |
假设这几个Opcode都只用到了code这一项,那么我们拿出0xAC这个code,我们做个测试
0xAC ------- 0xF3AC:REP LODSB
,大概我们会猜到改变是因为F3的添加(Prefixes)的问题
1 | AC |
这里说一下他们的顺序不可以变举个例子:
OpCode | menemonic |
---|---|
4004 | INC EAX |
0440 | ADD AL, 40h |
-
Prefix:指令前缀,作为指令的补助
- 重复指令:如REP,REPE
- 跨段指令:如MOV DWORD PTR FS:[xxxxx],0
- 将操作数从32位转为16位:如MOV AX,WORD PTR DS:[EAX]
- 将地址从16位转为32位:如MOV EAX,DWORD PTR DS:[BX+SI]
-
code:指令操作码,有时候还需要Mode R/M , SIB , Displacement帮助,才可以完善信息
-
Mode R/M:操作数类型:用来辅助menemonic后的操作数类型,R寄存器,M内存单元,6 7位的4种可能,用来描述,0 1 2位是寄存器还是内存单元,以及3中寻址方式
7 6 5 4 3 2 1 0 指定寄存器及寻址方式 寄存器/Opcode 寄存器/内存单元 -
SIB:辅助Mode R/M,计算地址偏移,SIB寻址方式就是基址+变址,
MOV EAX,DWORD PTR DS:[EBX+ECX*2]
其中ECX和2都是SIB来制定的,这里的指定乘数,只有4种可能:1 2 4 8
7 6 5 4 3 2 1 0 指定乘数 指定变址寄存器 指定基质寄存器 -
Displacement:辅助Mode R/M,计算地址偏移,比如
MOV EAX,DWORD PTR DS:[EBX+ECX*2+3]
,其中+3是由Displacement决定的 -
Immediate:立即数,用于解释语句中操作数为常量的情况
反汇编引擎Proview源码片段
1 | //机器码解析函数 |
刚把柴佬的任务完成,急忙🏃♂️回来,继续整理笔记😁
代码省略了其他机器码的解析过程,列举了push的两种机器指令方式,自己大概了解了:通过解析Opcode指令操作码,找到对应的解析方式,将机器码重组为汇编代码。通过第一个参数DISASSEMBLY *Disasm传出解析结果,将机器码指令长度由参数Index传出,用于寻找下一个Opcode指令操作码
Decode对机器码进行分析
1 | //假设此字符数组为机器指令编码 |
通过函数Decode2Asm,启动反汇编引擎Proview,解析出对应汇编指令语句代码,并输出
Decode2Asm实现流程
1 | void_stdcall |
对汇编引擎Proview的使用进行了封装,以简化Decode函数的调用过程,方便使用者调用
第二章-基本数据类型的表现方式
2.1 整数类型
感觉这边有点基础,就不总结了
2.2 浮点数类型
上次自己的问题就是 Opcode
和menemonic
对应的原则,群里的大佬给出了答案
浮点数编码方式
浮点数编码采用的是IEEE规定的编码标准,IEEE规定的浮点数编码会将一个浮点数转换为二进制数,以科学计数法划分,将浮点数拆分为3部分:符号,指数,尾数
float类型的IEEE编码
float 4字节(32位),最高位用于表示符号:在剩余的31位中,从左向右取8位用于表示指数,其余用于表示尾数
假设:12.25f 转化成对应的二进制数 1100.01,整数部分是1100,小数部分01,小数点向左移动,每移动一次指数+1,移动到除符号位的最高位为1处,停止移动,这里移动了3次,变成1.10001,指数部分为3,在IEEE编码规则瞎,最高位一定是1的,所以如果这里如果是整数符号位就填写0
12.25f 经过IEEE转换后情况:
符号位:0
指数位:十进制 3+127,转换为二进制是10000010
尾数位10001 000000000000000000 (不足23位的时候,低位补0填充)
变换成16进制位0x41440000,由于小端序所以内存变成了这个样子
-0.125f 经过IEEE转化后的情况
符号位:1
指数位:127+(-3),二进制 00011000,如果不足8为,则高位补0
尾数位:00000000000000000000000
转化为16进制为0xBE000000
1.3f 经过IEEE转化情况 (1.3f转化比较奇怪,因为小数部分是一直有,由于规则尾数部分所以到23位停止的:0.3 0.6 1.2 0.4 0.8 1.6 1.2 0.4…)
符号位:0
指数位:127+0 二进制 01111111
尾数位:010011001001100100110
转化为16进制为0x3FA66666
所以说这个浮点数计算是一个近似值,存在一定的误差,如果把这个转化成小数的话,那么就是 1.299999523162841796875 四舍五入之后为1.3,所以这就解释了为什么C++在比较浮点数值是否为0时候,要进行一个区间比较,并不是等值比较
浮点数比较代码
1 | float fTemp = 0.0001f; //精确范围 |
double类型的IEEE编码
double类型为8字节 64位 指数11位 剩余52位标识位数 double中的指数为+1023 用于指数符号的判断,剩下的 同float
基本浮点数指令
浮点数操作是通过浮点寄存器来实现的,浮点寄存器通过栈结构来实现,由ST(0) ---- ST(7)的8个栈空间组成,每个浮点寄存器占8个字节,每次使用浮点寄存器的时候先使用ST(0),不能越过ST(0)直接使用ST(1),当ST(0)存在数据时,执行压栈操作后,将ST(0)的数据装入ST(1),如果没有出栈的操作就一直压,一直到浮点寄存器占满 IN
表示操作数入栈,OUT
表示操作数出栈
指令表
指令名称 | 使用格式 | 指令功能 |
---|---|---|
FLD | FLD IN | 将浮点数IN压入ST(0)中,IN (mem 32/64/80) |
FILD | FILD IN | 将整数IN压入ST(0)中,IN (mem 32/64/80) |
FLDZ | FLDZ | 将0.0 压入ST(0)中 |
FLD1 | FLD1 | 将1.0压入ST(0)中 |
FST | FST OUT | ST(0)中的数据以浮点形式存入OUT地址中 OUT (mem 32/64) |
FSTP | FSTP OUT | 和FST作用一样,但是会执行一次出栈的操作 |
FIST | FIST OUT | ST(0)数据以整数形式存入OUT地址中 OUT (mem 32/64) |
FISTP | FISTP OUT | 和FIST指令一样,但是会执行一次出栈的操作 |
FCOM | FCOM IN | 将IN地址数据与ST(0)进行实数比较,影响对应的标记为 |
FTST | FTST | 比较ST(0)是否为0.0,影响对应标记位 |
FADD | FADD IN | 将IN地址内的数据与ST(0)做加法运算,结果放到ST(0)中 |
FADDP | FADDP ST(N),ST | 将ST(N)中的数据与ST(0)中的数据做加法运算,N为 0-7 ,先执行一次出栈操作,然后将相加的结果放入ST(0)中 |
使用浮点指令的时候,都要用ST(0)先进行运算,如果ST(0)中有值时,会将ST(0)中的数据向下存放到ST(1)中,然后再将数据放到ST(0)中,如果再次执行ST(0),那么就把ST(1)放到ST(2)中,如果8个寄存器都有值,继续像ST(0)存入数据的时候,我们会舍弃ST(7)的数据
这里介绍个C语言函数:_ftol
将float型转化为int型
1 | //c++源码对比,argc为命令行参数 |
总结:float类型浮点数,占4个字节,但是都是以8个字节方式处理即double形式,当浮点数作为参数的时候,不能直接压入栈,push传的4字节,会丢失4个字节,所以printf使用整数方式输出浮点数会报错,printf以整数方式输出的时候,将对应参数作为4字节数据,按照补码的方式解释,而压入参数为浮点数类型的时候,数据长度为8字节,需要按浮点编码方式解释
浮点数作为返回值
1 | //c++源码对比,返回值为浮点数的函数调用 |
类型转换函数_ftol的实现
1 | //提升堆栈 |
2.3 字符和字符串
字符串的结束标记为’\0’,每个字符都记录在一张表中,他们各自对应了一个编号,系统通过标号来找到对应的字符来显示,一看这不说的ASCII码表吗
VC++6.0中,char定义ASCII编码格式字符,wchar_t定义Unicode编码格式的字符,如果wchar_t保存ASCII编码,不足位补0,假设’a’ ASCII码 0x61 那么Unicode就是0x0061
汉字编码使用ASCII GB2312-80 保存6763常用汉字编码 Unicode 使用UCS-2 为了让汉字都容纳进来,使用的两个Unicode编码解释一个汉字,UCS-4
VC6.0,为了使char于wchar_t通用,使用了预编译宏TCHAR来代替他们,TCHAR会根据编译选项定义对应的字符类型,还是第一次知道
字符串的存储方式
确定字符串的总长度有两种方法:
- 在首地址的4字节中保存字符串的总长度
- 在结尾处规定一个特殊字符
优缺点
- 保存总长度
- 优点:不需要遍历每个字符,取前n个字节就可以知道总长度,一般来说就是(1,2,4)字节
- 缺点:不能超过n的长度,且要多开销n字节,通信情况,双方需要事先知道通信字符串的长度
- 结束符
- 优点:没有记录开销,设计通信可以通过实际情况来决定
- 缺点:获取字符时候要遍历所有字符,比较慢
C++使用结束符’\0’为字符串结束标识,ASCII码使用1个字节\0,Unicode使用两个字节\0,不能使用ASCII的处理函数对Unicode处理,会报错
一般程序中会使用一个字符型指针来保存字符串首地址,char* wchar_t* TCHAR*
IDA这里有个操作是快捷键A,直接分析到’\0‘,解释字符串!
2.4 布尔类型
布尔类型不说了!(内存占1位 0 1)
2.5 地址 指针和引用
地址,指针,引用,,地址就是&那个,只有变量才存在内存地址(除了const),指针用来保存地址的,引用就是取别名,别名的操作就是对源变量的操作
指针和地址的区别关系:
不同点:
指针 | 地址 |
---|---|
变量,保存变量地址 | 常量,内存编号 |
可修改,再次保存其他变量地址 | 不可以修改 |
可以对其执行地址操作得到地址 | 不可以执行取地址操作 |
包含对保存地址的解释信息 | 仅仅有地址值无法解释数据 |
相同点:
指针 | 地址 |
---|---|
取出指向地址内存中的数据 | 取出地址对应内存中的数据 |
对地址偏移后,取数据 | 偏移后取数据,自身不变 |
求两个地址的差 | 求两个地址的差 |
指针保存的都是地址,每种数据类型在内存中所占的内存空间不同,指针中只保存了存放数据的首地址,没有指明在哪里结束,所以需要根据对应的类型来寻找解释数据的结束地址,同一个地址使用不同类型指针进行访问,取出的内容就会不一样
各类指针访问同一地址代码
1 | //c++源码对比,定义int类型变量,初始化为0x12345678 |
取出来的地址会根据指针类型的长读来取出来
这里测试没有问题的,我们去反汇编看一下
看了一下反汇编,和源码解析的差不多,反汇编的意思大概差不多,差别就是编译器的不同,只要有汇编,他编译器干什么就都会知道了
总结:在我存入的是0x12345678
的时候再内存中存放是根据小端序存放78 56 34 12
,首地址从7 8 开始,指针pnVar为int类型指针,以int类型在内存中占用的空间大小和排列的方式对地址进行解释,然后取出数据,int占4字节,所以取出来12345678
,我们知道如上图所示,大地址到小地址会进行截取,eax本身就是4字节的空间,那么我分给word和byte的时候,会把前面的低地址位置进行一个截取,取高地址的位置,所以说会把前面的数据砍掉,然后用movsx进行分析符号位的填充
所有类型的指针对地址的解释都取决于自己本身的指针类型,指针做加法和减法比较有意义,因为指针是保存和解析地址而存在的,我们对指针的地址偏移时,偏移会根据自身的指针类型来决定
各类型指针寻址方式代码
1 | //c++源码对比,定义字符型数组,占5字节内存空间 |
总结:编译量的计算方式为指针类型长度乘以移动次数,因此得出指针寻址公式如下
$$
(p+n)目标地址 = 首地址 + sizeof(指针类型)*n (n为移动次数)
$$
两指针做减法,可以求出两地址之间的元素个数(必须同类指针相减),两指针相加没有作用,公式如下
$$
p-q = ((int)p - (int)q) / sizeof(指针类型)
$$
引用
引用类型在c++中被描述为变量的别名,c++为了简化指针的操作,对指针的操作进行了封装,产生了引用类型,实际上引用类型就是指针类型,只不过它用于存放地址的内存空间对使用者而言是隐藏的
引用类型代码
1 | //c++源码对比,定义int类型的变量并附初始值0x12345678 |
引用类型的存储方式和指针是一样的,都是使用内存空间存访地址值,所以在C++中,引用和指针没有区别,引用时通过编译器实现寻址,而指针需要手动寻址,所以说如果操作失误会有比较糟糕的结果,但是引用就不会存在这种问题,所以c++很推荐使用引用类型,并不是指针
引用类型作为函数参数代码
1 | void Add(int &nVar){ |
这里我看到的结论就是引用的时候,并没有像指针一样进行了数据类型的那种的移动,而就是单纯的进行了数据+1,我们测试一下
因为前面有一个lea的操作把地址给了eax,然后eax给了ebp-0x24,所以引用也相当于取别名
2.6 常量
常量数据在程序运行前就已经存在,他们被编译到可执行文件中,当程序启动后,他们会加载进来,数据通常会在常量数据区中保存,该节区的属性没有写权限,所以不可以修改
常量数据的地址减去基质就是文件中的编译地址
常量的定义
C++中,使用#define来定义常量,也可以使用const将变量定义为一个常量,#define定义的常量名称,编译器对其进行编译时,会将代码中的宏名称替换成对应信息,宏的使用可以增加代码的可读性,const是为了增加程序的健壮性而参在的,常用字符串处理函数stcpy的第二个参数被定义为一个常量,为了防止该参数在函数内被修改,对原字符串造成破坏
宏与const的使用
1 | //定义NUMBER_ONE 为常量1 |
#define是一个真常量,而const却是由编译器判断实现的常量,是一个假常量,实际中使用const,最终还是一个变量,只是在编译器内进行了检查,发现有修改则报错
由于编译器在编译期间对const变量进行检查,因此被const修饰过的变量是可以修改的,利用指针获取到const修饰过的变量地址,强制将指针的const修饰去掉,就可以修改对应的数据内容
1 | //c++源码对比,将变量nConst修饰为const |
所以说const修饰的变量nConst被赋值了一个常量5,编译过程中发现nConst的初值是可知的,并修饰为const,之后所有使用的nConst的位置都用这个可预知值替换,所以最后nVar替换的是预知的常量值5,如果nConst是未知的值就不会进行这个优化
#define与const两者的区别
#define | const |
---|---|
编译期间查找替换 | 编译期间检查const修饰的变量是否被修改 |
由系统判断是否修改 | 由编译器限制修改 |
字符串定义在文件只读数据区,数据常量编译为立即数寻址方式,成为二进制代码的一部分 | 根据作用域决定所在的内存位置和属性 |
第三章-认识启动函数,找到用户入口
3.1 程序的真正入口
一般VC++调试的程序,一般都是在main或者WinMain函数开始的,所以说很多人包括很久之前就认为他们是程序的第一条指令处,这个是不对的,main或者是WinMain来说是一个函数,也是需要被调用的,他们没有被调用之前,编译器做了很多的事情,所以main和WinMain来说是语法规定的用户入口,并不是应用程序的入口,其实我们的程序被操作系统加载的时候,操作系统会分析执行文件内的数据,main或者WinMain是语法规定的用户入口,而不是应用程序入口,在应用程序被操作系统加载的时候,操作系统会分析执行文件内的数据,分配相关的资源,读取执行文件中的代码和数据到合适的内存单元,然后才是执行入口代码,入口代码通常是mainCRTStartup
,wmianCRTStartup
,WinMainCRTStartup
或wWinMainCRTStartup
,具体的要根据编译选项来定夺,其中mainCRTStartup和wmainCRTStartup是控制台环境下多字节编码和Unicode编码的启动函数,而WinMainCRTStartup和wWinMainCRTStartup是Windows环境下多字节编码和Unicode编码的启动函数,vc++也可以让自己去指定入口
3.2 了解VC++ 6.0的启动函数
VC++6.0 控制台和多字节编码环境下的启动函数mainCRTStartup
,由系统库KERNEL32.dll负责调用
去安装了个vc++ 6.0 可以看到程序运行时调用了三个函数,KERNEL32.dll,mainCRTStartup和main
其中KERNEL32!76606359()表示在76606359地址调用了mainCRTStartup,VC++提供了mainCRTStartup的源码,直接过去看一下,分析一下(自己没有安装完整版的,导致看不到,直接用书上的一点一点分析了)
mainCRTStartup函数代码片段分析
1 | //预编译宏 |
有很多的函数不知道干什么的,总结一下:
GetVersion
函数:获取当前运行平台的版本号,控制台程序运行在Windows模拟的DOS下,因此这里获取的版本号为MS-DOS的版本信息_heap_init
函数:用于初始化堆空间,在函数实现中使用HeapCreate申请堆空间,申请空间的大小由_heap_init
传递的参数决定,_sbh_heap_init
函数用于初始化堆结构信息GetCommandLineA
函数:获取命令行参数信息的首地址_crtGetEnvironmentStringsA
函数:获取环境变量信息的首地址_setargv
函数:根据GetCommandLineA
获取命令行参数信息的首地址并进行参数分析,将分离出的参数的个数保存在全局变量_argc
中,将分析出的每个命令行参数的首地址存放在数组中,并将这个字符指针数组的首地址保存在全局变量_argv
中,从而得到命令行参数的个数以及命令行参数的信息_setenvp
函数:根据_crtGetEnvironmentStringA
函数获取环境变量信息的首地址进行分析,将得到的每条环境变量字符串的首地址存放在字符指针数组内,并将这个数组的首地址存放在全局变量env
中
所以当我们的main函数得到这三个参数,_argc
,_argv
,env
三个全局变量,他们作为参数以栈传递的方式传到main函数中
-
_cinit
函数:用于全局数据和浮点寄存器的初始化,全局对象和IO流的初始化都是这个函数实现的,利用函数_initterm
进行数据链初始化,这个函数由两个参数组成,类型为_PVFV*
,这是一个函数指针数组,保留了每个初始化函数的地址,初始化函数的类型为_PVFV
1
typedef void(_cdecl *_PVFV)(void);
所以,这个初始化函数是无参数也是无返回值的,c++规定全局对象和静态对象必须在main函数前构造,在main函数返回后析构,所以这里是用来代理构造析构函数的
_cinit函数代码
1 | //用于初始化寄存器 |
这里_Fpinit
是一个全局函数指针,类型是_PVFV
,如果编译器扫描代码的时候,发现有浮点计算,则这个指针保存了初始化浮点寄存器的代码地址,否则就是0,如果浮点寄存器没有被出刷,会有异常错误👴在上周的时候说过了,参数xi_a
为函数指针数组的其实地址,_xi_z
为结束地址,第一个_initterm初始化的都是C支持库中所需要的数据
_initterm函数代码
1 | static void _cdecl _initterm ( |
C++初始化操作会在第二次的_initterm
进行调用,一般是全局变量或者静态对象的初始化函数(这里不咋懂,书上说第10章会说)
正常的mainCRTStartup函数会根据编译器版本的不同所有不同!如vs2005,其中的mainCRTStartup
变成_tmainCRTStartup
,在我们的默认情况下,入口函数是main,这时候会从mainCRTStartup启动,再传入main所需要的三个参数argc
,argv
,env
,最后再调用main
如果我们重新指定入口函数
可以看到没有调用mainCRTStartup函数,我们可以再测试一下
我再写一个新的函数调用并不是main来看一下
如果我没有调用mainCRTStartup函数,我们的堆空间是没有被初始化的,所以用到堆就会报错
我们看一下,正常的情况
张长是可以分配的,所以说如果书用了malloc函数,没有初始化堆空间会出错的
3.3 main函数识别
VC++6.0main函数特征,三个参数:命令行参数个数,命令行参数信息,环境变量信息,main是启动函数中唯一一个具有3个参数的函数,WinMain也是启动函数中为一具有4个参数的函数
VC++6.0,main函数被调用前,要先调用的函数:
GetVersion()
_heap_int()
GetCommandLineA()
_crtGetEnvironmentStringsA()
_setargv()
_setenvp()
_cinit()
这些函数调用结束后就会调用main函数,根据main函数调用的特征,将argc,argv,env压入栈作为参数
用ida可以看到_mainCRTStartup的大概流程
我们用od去看看做了什么,od会直接暂停在程序的入口处,并不是定位在main函数的位置,我们试着通过main函数的特性查找一下他在哪个位置
自己根据分析,我在断点的位置就是main的函数入口
我用书上的代码做一个例子,便于分析!
OD反汇编信息
1 | //省略部分代码 |
总结一下,一般我们用od的时候,来到的应该是mainCRTStartup的位置,那么我们寻找main函数的时候,我们需要去找到相应的调用前的函数的流程结束,从GetVersion()
获取平台版本号 _heap_init
初始化堆空间 GetCommandLineA
获取命令行参数信息的首地址,_crtGetEnvironmentStringA
获取环境变量信息的首地址,_setargv
得到命令行参数个数,以及命令行参数信息,_setenvp
获取环境变量字符串的首地址 _cinit
全局数据和浮点寄存器初始化,之后main函数所需要的参数压入堆栈,调用main
第四章-观察各种表达式的求值过程
4.1 算数运算和赋值
看书的时候感觉,这个位置好像在哪里学过,想了海哥有讲过,这里稍微说一下,举一个例子,赋值运算类似于数学中的等于,本质就是将一个内存空间的数据传递到另一个内存空间中,但是内存没有处理器那种的控制能力,所以各个内存单元之间是无法直接传递数据的,必须用过处理器访问并中转,才可以实现两个内存单元之间的数据传输(cpu里的寄存器包括通用寄存器、专用寄存器和控制寄存器),正常来说,算术运算与传递计算结果的代码组合才可以被看作是一个有效的语句,单独的运算会被视为空语句,应该在上周测试过了,我再测试一下
看到了吗,如果算术运算,没有赋值的结合是一条无意义的语句,下面的就直接压在了ebp-0x4局部变量那个位置
各种算术运算的工作形式
加法:
加法运算对应的汇编指令 ADD
,执行加法运算的时候,针对不同的操作数,转换的指令也会不一样,编译器会根据优化方式选择比较好的匹配方案,VC++ 6.0中常用的优化方案有两种方案:
- 1方案:生成文件占用空间最小
- 2方案:执行效率最快
Release编译选项组的默认选项为2方案 (执行效率优先) ,在Debug编译选项组中,使用的是Od+ZI选项,这个选项使编译器产生的代码都便于调试,为了便于单步跟踪,以及源码和目标代码块的对应阅读,从而可能增加冗余代码,在不影响调试的前提下,尽可能的优化
加法运算——Debug版
1 | //C++源码说明:加法运算 |
总结一下:
- 两个常量相加的情况下,编译器在编译期间就计算出两个常量相加后的结果,将这个结果作为立即数参与运算,减少了运行期间的计算
- 变量参与加法运算的时候,会取出内存中的数据,放入通用寄存器,再通过加法的指令来完成计算的结果,最后存入内存空间中
如果我们开启Release的时候,会发生比较大的变化,我记得这里我和小哲哲研究过好像,由于效率优先,编译器会把无用的代码去除,并将可合并的代码进行归并处理,我们测试一下刚才的那一段Debug的那段代码
擦!我的vc++ 6.0 release 不好使!
可以看到,类似于nVarOne = nVarOne +1 这样的代码会被删除,因为后面对其进行了重新赋值操作,所以编译器判定这句话的代码是可以被删除的,可以看到唯一的add是进行的参数平衡,并没有源码中的加法运算,在编译过程中,编译器常常会采用 常量传播 和 常量折叠这样的方案对代码中的变量与常量进行优化
常量传播
将编译期间可计算出的结果的变量转换成常量,这样就减少了变量的使用
1 | void main(){ |
变量nVar是一个在编译期间可以计算出结果的变量,因此,程序中引用nVar的地方直接引用常量1,代码等于
1 | void main(){ |
反汇编的release的改变代码和上面的一样,那么结论就是正确的
常量折叠
当计算公式中出现,多个常量计算的时候,编译器会在编译期间计算出结果时,源码中所有的常量计算都会被计算结果代替,测试代码
1 | void main(){ |
我们看到release版本的反汇编,我们看到将值也是直接算了出来 push 0FFFFFFF4h,从而推断一下,在编译过程中计算出来的计算出 -12 从而代替原来表达式,替换代码1:
1 | void main(){ |
替换代码2(最终等价):
1 | void main(){ |
测试release的代码想法
我们对常量折叠和常量传播了解过后,就知道上面Debug的时候,为什么直接push 3了,那么我们按照自己想法,做一个测试,如果变量的值是命令行参数argc,那么argc在编译期间无法确定,所以编译器无法在提前计算出结果,那么变量就不会被编译器计算出的常量替换掉,测试代码
1 | int main(int argc, char* argv[]) |
我们只看到了一个参数变量偏移,而我们应该定义了两个局部变量都没了
1 | int main(int argc, char* argv[]) |
总结:nVarTwo可以省略,因为它付给了第一个参数argc,在nVarOne被赋值了3以后,做了加法,等同于了nVarOne = 3 + argc,之后的printf引用nVarOne,等价于上面的 3 + argc 于是乎nVarOne也可以删除掉
减法:
减法对应的汇编指令是sub
,但是我们的计算机只会做加法,我们可以通过补码的方式转换将减法转化为加法形式来完成
假设有一个二进制数Y,反码为Y(反),假设二进制长度为8位:
$$
Y+Y反 = 1111\ 1111B
$$
$$
Y + Y反 + 1 = 0(进位丢失)
$$
$$
Y(反) + 1 =0-Y <==> Y(反) + 1 = -Y <==> Y(补) == -Y
$$
所以负数的补码可以简化为取反+1
假设一个 算术为 5 - 2
$$
5+(0-2)<==>5+(2(反)+1)<==>5+2(补)
$$
所以说根据这个公式,所有的减法都可以当作加法来运算
减法运算——Debug版
1 | //C++源码说明:减法运算 |
总结:实际分析中,根据加法操作数的情况,当加数为负数的时候,执行的并不是加法,而是减法的操作,release和加法同就不说了
乘法:
乘法运算对应的汇编指令为有符号imul
和无符号mul
两种,由于乘法指令的执行周期比较长,在编译过程中,编译器会先尝试将乘法转换成加法,或者使用移位等周期较短的指令,当他们都不可以转换的时候,才会使用乘法指令
乘法转换——Debug版
1 | //C++源码说明,乘法运算 |
上面的代码大概👴都能搞明白,总结一下😋:在二进制数中乘以2的时候等同于位依次向左移动(shl)1位,假设十进制3的二进制数为0011,3乘2以后等于6,相当于0110,当乘数和被乘数都是未知变量的时候,无法运用优化的方式,这时候处理器不会优化处理,直接变成乘法指令完成乘法计算(imul)
我们可以看到这里面有几个地方用了lea指令,😡我也不太懂,于是乎了解一下嘛!分析了一下!如果我们乘法运算与加法的运算结合了编译器会采用lea指令来处理,我们按照猜想去测试一下
测试一下我猜想的1 2 4 8为lea
1不行,1不就相当于一个加法了,👴真是傻了
那么我们如果组合运算中的乘数,不等于2 4 8如何处理
1 | //C++源码对比 变量*常量*常量 (乘数超过8) |
我们看一下release版本的
各类型乘法转换——release版
1 | //IDA直接将参数作为局部变量使用 |
可以看到除了两个未知变量的乘法没有优化外,其它形式的乘法运算都可以进行优化
除法
除法计算约定,对应汇编指令分有符号idiv
和无符号div
两种,除法指令的执行周期比较长,效率比较低,所以编译器会想尽办法用其他运算指令代替除法指令,所以C++中的除法和数学中的除法不同,在C++中,除法运算补保留余数,有专门求取余数的运算(运算符为%),叫做取模运算,对于整数除法,C++的规定是仅仅保留整数部分,小数部分完全舍弃
计算机整数除法,a/b
两个无符号整数相除,结果是无符号,两个有符号相除,结果是有符号,一个有符号,一个无符号相除,结果是无符号的,有符号数的最高位(符号位)被作为数据位对持,然后作为无符号数参与计算
对于除法而言,计算机面临着如何处理小数部分的问题,正常来说 7/2 = 3.5,而计算机而言,整数除法的结果必须为整数,对于3.5这样的,计算机取整数部分的方式有如下几种:
向下取整
根据整数值的取值范围,可以画出以下坐标轴:
向下取整:就是取像负无穷方向最接近x的整数值,换而言之也就是取得不大于x的最大整数
例如:+3.5向下取整得到3;-3.5向下取整得到-4
数学描述中,这个符号有点打不出来,我直接画一下
C语言中math.h
中,定义了floor
函数,作用就是向下取整,也叫为地板取整,向下取整的除法,除数为2的幂时,直接用右移指令 sar
来完成
向上取整
所谓对x向上取整,就是取 正无穷方向接近x的整数值,取得不小于x的最小整数
例如:+3.5向下取整得到4;-3.5向下取整得到-3
数学描述中,这个符号有点打不出来,我直接画一下
在C语言的math.h
中定义了ceil
函数,作用就是向上取整,称为天花板取整
向零取整
所谓对x向零取整,就是取得往0方向最接近x的整数值,换而言之也就是放弃小数部分
例如:+3.5向零取整得到3;-3.5向零取整得到-3
在我们的数学描述中,[x] 表示对 x向零取整
这三个问题都会存在一个定律,我直接用ipad写吧,这个画板不好写
在C语言中和其他高级语言中,对整数除法规定为向零取整,也称为 截断除法
除法数学定理以及推导
好的,👴感觉我学了一波计算机无关的东西,学了一堆数学,小盆友你是否有很多问号🤣,这里大部分我将会用iPad写一下
回顾一下除法以以及余数的原则:
假设被除数为a,除数为b,商为q,余数为r,会有如下一些重要的性质:
- r的绝对值 < b的绝对值
- a = b*q + r
- b = (a - r) / q
- q = (a - r) / b
- r = a - q*b
举例子:
1 | printf("8 %% -3 = %d \r\n",8 % -3) |
C语言规定的整数除法为向零取整,所以 8 / -3 = -2 那么余数就是2,👴就不举例子了,大家都会 很基础
直接说定理吧:
VC++ 6.0对除数为整形常量的除法的处理,如果除数是变量,则只能使用除法指令,如果除数为常量,就有了优化的余地,根据除数值的相关特性,编译器有对应的处理方式,测试一下除数为2的幂,非2的幂,负数等各类情况的处理方式,假设整形为4字节补码的形式
各类型除法转换——Debug版
1 | //C++源码说明:除法运算 |
👴这里最后的那步操作没太懂,我决定调试去看看咋回事
我去试试正负数
我们对除数为2的幂,进行分析
那么上面的代码中拿出来仔细分析一下
1 | //C++源码对比,变量 / 常量 (常量值为2的1次方) |
0040B80C的cdq的含义是符号扩展到高位edx,如果eax的最高位(符号位)为1,那么edx的值为0xFFFFFFFF,也就是-1,否则就是0,0040B80D地址处的sub eax,edx
执行的操作就是将eax减去高位edx,实际上就是被除数为负数的情况下,由于除数为整数(+2的幂),除法的商为负数,移位运算等同于向下取整,C语言的除法是向零取整,因此需要对商为负的情况进行+1处理,那么这里的sub eax,edx,如果edx 为0xFFFFFFFF 减去等同于+1,如果为0 减去就是0 说明 eax为正数,那么进行向下取整无问题,所以这样的设计可以避免分支结构的产生
1 | //C++源码对比,变量 / 常量(常量值为2的3次方) |
0040B854的cdq是符号扩展到高位edx,在0040B855处对edx做位与运算,当被除数为负数时,edx的值为7,在0040B858处的add eax,edx就是被除数为负数时加上2^n -1,不为负数则加0,最后sar右移完成除法
各类型输出转换——Release版
1 | //IDA中的参数标识,经过优化后,省去了局部变量,直接使用参数 |
这个方式有点没看懂,出现了一个大数据0x92492493,我去搞一下数学证明:
搞几个例子,反推一下吧,这样便于多方面的理解
1 | .text:00401000 _main proc near ; CODE XREF: start+AF p |
看一下我们的.text:00401004位置,是mov eax,38E38E39h
,之后做了乘法和移位的操作,最后push edx
,乘法指令中,edx
存放乘积数据的高4字节,所以直接用edx
相当于,将我们的乘积直接右移32位,之后又进行了一次右移1位,那么这里就相当于一个 2^33 的除法,在地址.text:0040100D处,eax得到了ecx的值,然后对eax右移了1F位,对应10进制也就是右移了31位,然后有一个加法,其实这里移位的目的是得到有符号数的符号位,如果结果是正数,那么add edx,eax
就是加0,等于什么都没干,如果结果是负数,那么后面的代码直接使用edx作为计算结果,需要对除法的商调整+1,满足向零取,数学证明:
所以我们可以反推出C代码
1 | printf("%d",argc / 9); |
总结:
1 | mov eax,MagicNumber |
遇到上面你的指令序列的时候,基本可以判定是除法优化后的代码,除法的原型为a/o
,imul
是表明的有符号计算,操作数是优前的被除数a,右移的总次数确定n的值,用公式o= 2^n/c
,将MagicNumber作为c代入公式求我们的除数o的近似值,四舍五入取整,就可以恢复除法原型!
👴再搞个例子
1 | .text:00401080 _main proc near ; CODE xREF: start+AF p |
我们再去用公式推导一下:
1 | printf("nVarTwo / 7 = %d\r\n", argc /7); |
计算得出MagicNumber后,如果其值超出4字节整数的表达范围,编译器会对其进行调整,如上个例子中的argc/7
,计算MagicNumber时,编译器选择 2^35/7
,其结果超出了4字节整数的表达范围,所以编译器调整MagicNumber的取值为2^35/7-2^32
公式:a/o = [(a-a*c/2^32)/2 + a*c/2^32]/2^(n-1)
总结:
1 | mov eax,MagicNumber |
如果遇到上面的指令序列,基本可以判定出发优化后的代码,其除法原型为a/常量o,mul代表的无符号计算,用公式o = 2^(32+n)/(2^32+c)
将MagicNumber作为c值代入公式求解常数除数o,即可恢复除法原型
那么我们返回去深度分析最开始的代码
1 | //IDA中的参数标识,经过优化后,省去了局部变量,直接使用参数 |
编译器在计算MagicNumber时是作为无符号处理的,而代入除法转换乘法的代码中又是作为有符号乘数处理的,因为有符号的最高位不是数值,而是符号位,所以对应的符号乘法指令是不会让最高位参与数值计算的,这会导致乘数的数学意义和MagicNumber不一致
除法优化
上次除法优化搞了搞,vct师傅给我发了他的除法优化的笔记,直接就他看的了,写的很好
先给一些背景:
(1)比如 7 / 2 = 3 …… 1 -7 / 2 = -3 …… -1
比较重要的是,余数的绝对值小于除数的绝对值,并且余数和被除数同正负
(2)由于C语言中除法是向0取整,也就是“截断除法”
不难发现,正数除以正数时,截断除法相当于向下取整(3.5 -> 3);而负数除以正数时,截断除法相当于向上取整( -3.5 -> -3 )
(3)除以2的k次幂通常会被优化成右移k位,这里考虑除以2时
用一个signed byte表示7,是00000111,右移一位变成00000011是3,是正确的
但是,考虑-7/2,-7是11111001,右移一位后变成11111100,这是-4,因为这是向下取整的结果,所以比正确的答案 -3少了1
代码中为了统一和效率,如果是32位的数字,会先右移31位扩展符号位。原先是正数则最高位是0,那么最后会变成32个0,也就是0,;原先是负数最高位是1,最后会变成32个1,也就是-1,暂且把这个扩展符号位后形成的数记作S,
那么,我们只需要把右移一位的结果,减去这个S,就可以得到正确的截断除法的值
7/2 = 7>>1 – (0) = 3 -7/2 == -7>>1 – (-1) = -3
( 这一点在例题代码中会再次提到 )
(4)当除以正数N,而N不是2的次幂时,编译器会生成一个magic_number(C),以使除法优化成乘法,提高效率
手动的推导过程,图片中的证明表示:被除数(正数)乘以magic_number后再右移n位,即为除法的结果;如果是负数需要 +1,最后统一减去符号扩展形成的数即可,也就是高位存放符号位的地方
https://ctftime.org/task/5294?tdsourcetag=s_pcqq_aiomsg
1 | $ cat foo.c |
手算吧!
0x49ea309a821a0d01为magic number给了rdx,我们右移了63位也就是剩余的符号位是给了rdi,如果是正数那么就是0,负数就是-1,imul扩展了乘积高位被放在rdx,低位被放在rax
这么看,因为rdx扩展了,大概就是已经移动64位,然后再移动了48位也就是112位,那么计算就是0x49ea309a821a0d01,n=112
公式就是 c = 2^n / y y是除数,那么y = 2^n / c
974873638438446
算术结果溢出
假设如果我们占据4字节32位内存空间数据进行运算,得到的结果超过了存储空间大小,就会产生溢出
int型数据0xFFFFFFFF加2就会得到超出int类型的存储范围,超出的部分就叫溢出数据,溢出的数据不能保存,会丢失,有符号数来说,原数据是负数,溢出后表示符号的最高位被进位,原来的1变成0,那么负数也被变成了正数0
一个无符号数产生溢出后会从一个最大数变成最小数,有符号数溢出会修改符号位
1 | //利用溢出跳出循环 |
int的类型i是一个有符号数,当i等于它允许取到的最大整数值0x7FFFFFFF时,再加上1,数值会产生进位,将符号位0修改为1,最终结果为0x80000000,这时候最高位为1,有符号来说是一个负数,也就是最大的负数,for直接跳出循环
-
进位
无符号超出存储范围叫做进位,因为没有符号位,不会破坏数据,而多出的1位数据在进位标志为CF保存,数据产生了进位,只是进位后的1位数据1不在自身的存储空间,而在标志的CF位的位置,通过观察CF位来判断无符号的进位情况
-
溢出
有符号数超过存储的范围叫做溢出,因为数据进位,从而破坏了有符号的最高位——符号位,只有有符号数才有符号位,针对有符号数,可以查看溢出标志位OF,从而检查数据是否溢出
自增和自减
vc6.0 使用 ++ – 来实现自增自减的操作,一般形式分为两种,一种为自增运算符在语句块之后,另一种是自增运算符在语句块之前,如果在之前,先执行自增自减,在执行语句块,如果在之后,先执行语句块在自增自减,直接看汇编代码吧!
1 | //自增自减代码 |
上面的汇编可以看出来,他们会把操作分离,nVarTwo = 5 + (nVarOne++) 会分解 nVarTwo = 5 + nVarOne,以及nVarOne +=1,同理,++在前的时候,两个算术表达式替换位置即可
4.2 关系运算和逻辑运算
关系运算用于判断两者之间的关系,大部分什么我就不说了,这里我只能说与非门牛逼,万能运算
- 或运算:比较运算符 || 有一个值为真,就是真,如果是假,则返回假
- 与运算:比较运算符&& 如果有一个为假就是假,都为真就是真
- 非运算:改变运算符!真就是假,假就是真
关系运算和条件跳转的对应
指令助记符 | 检查标记位 | 说明 |
---|---|---|
JZ | ZF == 1 | 等于0则跳转 |
JE | ZF == 1 | 相等则跳转 |
JNZ | ZF == 0 | 不等于0则跳转 |
JNE | ZF == 0 | 不相等则跳转 |
JS | SF == 1 | 符号为负则跳转 |
JNS | SF == 0 | 符号为正则跳转 |
JP/JPE | PF == 1 | "1"的个数为偶数则跳转 |
JNP/JPO | PF == 0 | "1"的个数为奇数则跳转 |
JO | OF == 1 | 溢出则跳转 |
JNO | OF == 0 | 无溢出则跳转 |
JC | CF == 1 | 进位则跳转 |
JB | CF == 1 | 小于则跳转 |
JNAE | CF == 1 | 不大于等于则跳转 |
JNC | CF == 0 | 无进位则跳转 |
JNB | CF == 0 | 不小于则跳转 |
JAE | CF == 0 | 大于则跳转 |
表达式短路
表达式短路时通过逻辑与运算和逻辑或运算使语句根据条件在执行的时候发生中断,看一下汇编代码
1 | //C++源码说明:递归函数,用于计算整数累计,nNumber为累加值 |
测试一下
所以通过递归函数Accumulation进行了整数的累加和计算,之后用&&的原则,左边如果为0就输出出去,用这个原则来决定跳转的过程,逻辑运算 || 与 && 有些不同
1 | //c++源码说明,和上面的类似 |
条件表达式
条件表达式就是我们说的三目运算,也就是 表达式1 ?表达式2 :表达式3
表达式2 ,3为常量的时候,条件表达式可以被优化,而表达式2或者表达式3中的一个为变量的时候,条件表达式不可以被优化,会转换成分支结构,而表达式1为一个常量值,编译器会在编译期间得到答案,就不会有条件表达式存在
- 表达式1为简单比较,而表达式2,3的差值等于1
- 表达式1为简单比较,而表达式2,3的差值大于1
- 表达式1为复杂比较,而表达式2,3的差值大于1
- 表达式2,3有一个为变量,于是没有优化
转换方案1
1 | //c++源码说明,条件表达式 |
测试的时候
大概读了一下,这个用了两个mov进行付给eax,之后跳转到各自的位置上,进行一个赋值的
转换方案2
1 | //c++源码说明,条件表达式 |
对于argc == 5这种等职的比较,VC++会使用减法和求补运算来判断是否为真值,如果我们的argc不为5,那么执行sub指令后的eax就不为0,neg的指令,会将eax的符号位发生改变,也就是求补的运算,如果eax为0,也就是argc为5的话,那么0进行补+1,也是0,那么CF=0,我们现在假设CF=1的情况,那么执行sbb eax,eax 等同于了 eax-eax-CF 那么eax会变成0xFFFFFFFF,另一个情况就是0,使用eax与6进行与运算,如果eax数值为0xFFFFFFFF那么 想与就是6,相加4就是10,如果是0的情况那么直接就是0+4 = 4
1 | 总结: |
转换方案3
1 | //C++源码说明,条件表达式 |
无忧化使用分支结果
如果表达式2或者表达式3中的值为未知数时候,就无法使用之前的方案去优化,编译器会那招正常的语句流程进行比较和判断,选择对应的表达式
1 | //C++源码说明:条件表达式 |
如果经过O2选项优化的Release版中,这些代码都会被编译为分支结构
4.3 位运算
<<
:左移运算,做高位左移到CF位置,最低位补0
>>
:右移运算,最高位不变,最低位右移到CF中
|
:位或运算,两个数的相同位上,只要有一个为1,则结果为1
&
:位与运算,在两个数的相同位上,只要同时为1时,结果才为1
^
:异或运算,在两个数的相同位上,当两个值相同时为0,不同时为1
~
:取反运算,将操作数每一位上的1变0,0变1
1 | //C++源码对应汇编,位运算(有符号数) |
1 | //C++源码说明:无符号数位移 |
左移的时候,有符号数和无符号数(unsigned)的移位操作是一样的,都不需要考虑符号位,但是右移的时候,有符号进行的是sar,无符号进行的是shr,所以无符号的时候不需要符号位,直接使用shr将最高位补0即可
4.4 编译器使用的优化技巧
讨论一下基于Pentiun微处理器的优化技术
代码优化一般有四个方向:
- 执行速度优化
- 内存存储空间优化
- 磁盘存储空间优化
- 编译时间优化
常量折叠
x = 1 + 2
1和2都是常量,结果可以直接知道,那么就是3,所以没有必要产生add的指令,直接生成x = 3即可
常量传播
如果上面的代码接着下面有一个 y = x + 3,由于上面最后生成了x = 3,那么结果还是可以遇见的,所以直接生成 y = 6即可
减少变量
假设一个 x = i*2 y = j*2 if(x>y){}
这里的x和y比较等价于i和j的比较,所以如果后面没有引用x,y,那么就会直接去掉x,y,生成 if(i>j)
,那么假设 x = i*2 y = i*2
所以 i*2
叫做公共表达式,可以归并为一个,x = i*2 y = x
复写传播
类似于常量传播,但是目标变成了变量
x = a; ...... y = x+c ;
如果中间的代码中没有修改变量x,那么可以直接用变量a代替x
y = a + c
减去不可以达分支(剪支优化)
if ( 1 > 2 )
如果代码用换不可能被执行,那么整个if代码块就会有不存在的理由
等等的方案
目标代码生成阶段的优化方案
- 流水线优化
- 分支优化
- 高速缓存 ( cache ) 优化
流水线优化规则
指令工作流程
- 取指令:CPU从高速缓存或内存中取机器码
- 指令译码:分析指令的长度,功能和寻址方式
- 按寻址方式确定操作数:指令的操作数可以是寄存器,内存单元或者立即数,如果操作数在内存单元里,这一步就要计算出有效地址
- 取操作数:按操作数存放的位置获得数值,并存放在临时寄存器中
- 执行指令:由控制单元或者计算单元执行指令规则的操作
- 存放计算结果
假设
1 | 执行指令: add eax,dword ptr ds:[ebx+40DA44] |
Intel处理器位小端序排列,数据的高位对应内存的高地址,低位对应内存的低地址
步骤:取指令,得到第一个十六进制字节:0x03,并且eip+1,译码知道这个指令是加法,但是信息不够,于是乎将0x03放入处理器的指令队列缓存中,取指令得到第二个十六进制字节:0x83,机器码放入处理器的指令队列缓存中,eip+1,译码后知道这个寄存器相对寻址方法的加法,而且参与寻址的寄存器是ebx,存放的目标是eax,后面还有4字节的偏移,指令长度确定后,机器码放入处理器的指令队列,取地址,得到第三个十六进制字节:0x44,这是指令中包含的4字节地址偏移量信息的第一个字节,放入内部暂存区,ebx保存在ALU,准备计算有效的地址,eip+1,之后依次开始取指令0xDA 0x40 0x00 放入寄存器,eip依次+1,这时候eax的值传给ALU,调度MMU,得到内存单元,传送到ALU,计算结果,最后将计算结果存回eax中
流水线
由于每条指令的工作流程都是由取指令,译码,执行,回写的步骤组成,所以很多处理器设计了多流程的结构,A流水线处理过程中,B流水线可以提前对下一条指令做处理
1 | 004010AA mov eax,92492493h |
我们的处理器会先读取004010AA处的二进制指令,之后开始进行译码等操作,这些工作的每一个步骤都是需要时间的,如果我们取指令,内存管理单元开始工作,其他的部件进行闲置等待,等拿到了指令才进行下一步的工作,于是我们为了提高效率,开始了流水线的这一个机制
因为流水线的机制,我们在执行mov eax,92492493h过程中对第二条的地址进行读取以及译码,进行并行处理,那么我们提高了处理器的工作的效率
指令相关性
如果后一条指令的执行依赖前一条指令的硬件资源,那么这样的情况就是指令相关
1 | add edx,esi |
两条指令都需要访问并设置edx,所以只能执行完add edx,esi后才能执行 sar edx,2,这样的情况产生了寄存器的争用,影响了效率
地址相关性
这个和上一个指令相关差不多,这个等待的是内存地址的争用
1 | add [00401234],esi |
由于第一条指令访问的是0x401234地址,那么只能第一条指令操作完后再去执行第二条语句,会影响效率,VC++的O2的release选项生成的代码会考虑流水线执行的工作方式
1 | 0040101F push eax |
恢复栈顶的指令add esp,8,中间有mov eax,92492493h指令,这里为流水线优化,因为后面的imul esi需要设置eax,吧计算结果的低位放在eax中,那么中间换成add esp,8防止了寄存器争用,后面的这里的 add edx,esi 与 sar edx,2不能移动是因为,在后面的mov eax,edx 与 edx有关系,如果位置移动的话,我们会出现edx的一个的计算结果不正确,于是乎不能改变顺序
分支优化规则
配合流水线的工作模式,处理器增加了一个分支目标缓冲器,在流水线的工作模式下,如果遇见分支结构,我们就可以利用分支目标缓冲器预测并且读取指令的目标地址,分支目标缓冲器在程序运行的时候将动态记录和调整转移指令的目标地址,可以记录多个地址,进行表格化的管理,当发生转移的时候秒如果分支目标缓冲器中有记录,下一条指令在取指令阶段就会将其作为目标地址,如果我们记录地址不等于实际目标地址,就会被流水线冲刷,用一个分支,多次与邪恶失败,就会更新记录目标地址,所以我们在编写多重循环的时候,要把大循环放在内层,可以增加分支预测的准确度
1 | for(int i=0; i < 10; i++){ |
1 | for(int i=0; i < 10000; i++){ |
所以说大循环在里面可以增加分支预测准确度
高速缓存 ( cache ) 优化规则
计算机内存的访问效率低于处理器,而且在程序的运行中,于是乎处理器准备了 cache来存放需要经常访问的数据和代码,这些数据内存用VA以表格方式一 一对应,从处理器访问内存数据时候,要去cache中看看这个VA有没有记录,如果有,则名终,就不需要访问内存单元,如果没有找到,就转换VA访问数据,然后保存到cache中,一般来说,cache会把读取指令需要的数据以及附近的数据都读取进来,从而节省cache宝贵的空间,VA值的二进制低位不会被保存,所以保存的数据是2^n字节为单位的
cache优化:
- 数据对齐
- 数据集中
- 减少体积
第五章-流程控制语句的识别
5.1 if语句
if语句只能判断两种情况:"0"为假,"非0"为真,if语句转换的条件跳转指令与if语句的判断结果是相反的
1 | //C++源码说明:if语句结构组成 |
JNE代表了,ZF=0 跳转,当前代码也就是argc不等于0就跳转,正常我们的c语言来说是满足if的条件执行语句块,汇编中是满足某条件进行跳转,与C语言相反
因为我们C语言中,是根据我们代码行的位置来决定编译后二进制的高低,也就是低行数对应低地址,高行数,对应高地址,那么根据这个特性,如果我们是 argc > 0 ,那么跳转的条件就是小于等于0是否满足,满足的不跳转
1 | //C++源码说明: if语句大于0比较 |
我们在使用反汇编的时候,表达式短路和if语句想像,实现的结构类似
5.2 if…else…语句
两种结构的对比
1 | //C++源码说明: if else |
正常流程和if差不多,这里纠正了自己的一个思想,就是跳转的时候,也是比较
1 | //C++源码说明,if...else..模拟条件表达式转换方式 |
所以说汇编还是比较好看的,记住一下JNE即可
1 | 总结结构: |
两处跳转可以准确的划分一下哪里是else哪里是if,在我们else begin之前会有一个 jmp的指令,那么那里之后就是我们lelse块的开始,从而分析if else
O2优化选项,反汇编代码
1 | arg_0 = dword ptr 4 |
所以分析一下可以知道我们的优化后的代码也可以做到这个分支结构的选项,主要用了setnz这个指令
5.3 用if构成的多分支流程
总体来说就是我们经常看到的 if … else if … else if … else …
1 | //C++源码说明,多分支结构 |
上面的大部分结构都是个 cmp jxx jmp cmp jxx jmp 这样类似
1 | 总结: |
这个属于没有优化的版本,如果开了O2编译选项,,我们将会对永远不能达到的地方进行不执行,不参与编译处理
假设我们的argc = 0,我们看一下O2版本的
1 | sub_401000 proc near |
我们把不可以达到的分支直接删除,从而只剩下我们的一个必达分支语句块
如果我们把单分支if结构,每个位置加上一个return语句,那么我们没有了else,减少了一次JMP跳转,使代码执行效率提高
1 | void IfElseIf(int argc){ |
1 | .text:00401000 sub_401000 proc near ; CODE XREF: _main+5p |
可以看出来大大的节省了效率
5.4 Switch的真相
小分支switch ( 分支数小于等于3 )
总结:cmp - je - cmp - je - cmp - je … end ( je到end/break )
if … else if … 为 cmp - jne - 执行 - jmp
if else 执行语句 接着 cmp jxx 位置,switch 小分支为 比较为一块,执行在另一块
如果没有 break那么我们就把执行语句后的 jmp end 去掉一直往下走即可
中等分支switch ( 分支数大于3 )
case为顺序,或者case之前的差值小于等于6时候,会进行一个case语句表,进行对应,方式为变化为数组的形式,也就是case - 1 = A,A*4+表首地址地址,进行判定寻找
5.5 难以构成跳转表的switch
这里说明了两个情况,一个是小于等于256的情况,一个是大于256的情况
小于255情况的时候,会生成两个表,一个是索引表,一个是地址表
索引表,放置了是case一个字节的数据,进行的判定,把default 或者是 不存在的case在索引表变化成一样的数值,这样就可以直接跳转到指定位置,截取书上的图比较明了
5.6 判定树的高度
这里为差值大于255时候,会把switch的方法变成二叉树判定树,取中间节点,进行左边以及右边case的比较
二叉判定树的左右树根据我们节点的多少可以转换成相对应的合适结构,比较左树 case数量小于3,那么我们就可以进行一个if else的一个方法结构,如果我们大于3有可能有是地址表,有可能是索引表+地址表,或者继续分树
5.7 do/while/for的比较
do 情况 为 操作 - cmp - jxx(回滚)
while 情况 为 cmp jxx 操作 jmp ( 回滚 )
这里的while操作因为进行了两次跳转,可以优化成 if do while 操作 后续只进行一次jxx操作,比两次执行效率高
for 情况 为 赋值 - jmp - cmp - jxx - 执行 - jmp - 执行2 - cmp - jxx - 执行
5.8 编译器对循环结构的优化
跟上面说的一样 while变换成 if do while
for的优化 转化为 初始化 if do while 形式
大部分都可以用do进行优化
在循环体判断结构中,没有进行值改变的表达式可以外提到循环体,进行少次数运算
循环强度降低优化,比如特定的乘法可以变成加法
第六章-函数的工作原理
6.1 栈帧的形成和关闭
ebp栈底 esp栈顶,他们进行esp<ebp时候形成栈帧,先进后出的原则,结束调用关闭栈帧,栈平衡
这里有一个之前遗留的不太完全的知识点,就是
cmp ebp,esp
这里比较是根据了pop现场数据后,进行 add原来 sub的值,进行比较,在进行chkesp,之前我的版本应该是直接mov ebp,esp
没有chk
6.2 各种调用方式的考察
cdecl:调用者 add esp,xxh
stdcall 被调用者 ret xxh
fastcall edx ecx传参 被调用者 ret xxh
参数个数为0时候 cdecl 等同于 stdcall,c语言默认 cdecl,在优化版本进行复写传播时候 cdecl比较出众,因为外部 add esp,xxh 不用挨个进行ret xxh,只需记录 push即可
6.3 使用ebp或esp寻址
正常我们理解的是ebp进行寻址,ebp-4开始为局部变量 ebp+4 返回地址 ebp+8 参数 但是是在非O2的选项
如果要提升优化,我们需要省一个寄存器ebp,我们通过esp进行寻址,节约资源
这里的esp+18h+var_5 也就是相当于了 原来的 esp+10+var_5,画一下图
正数标号法和负数标号法,上面画的图是我们的负数标记法,我们的正数标记法,我们就把正常的var的数值变成了正数,我们也是通过我们的push然后加上0x4,不同的就是其实位置不同,我们的正数标记法应该在我们的esp没有进行sub之前进行一个参与运算,而我们的负数标记法是根据了我们的一个sub esp,0x XXX 进行的,IDA一般会选择负数标记法
6.4 函数的参数
正常来说,我们的ebp+4是存放返回地址的,我们的ebp+8是存放的参数,在我们esp寻址的时候,假设我们call一下,我们call过去后,返回地址压入堆栈此时esp上升,那么我们如果不进行一个堆栈的提升,也就是调用函数中无,局部变量,那么当前的 esp + 0 这个0就是我们说的返回地址代表的var的数值,那么+4 就是返回地址,如果我们提升了里面有局部变量,那么我们的esp需要提升,那么也就是要返回到原来位置,所以esp + X - Y,那么我们的 X 就是esp提升的大小,-Y就是我们var
在esp寻址的时候,Y是参数,-Y局部变量,0返回地址
6.5 函数的返回值
正常我们都会看到一个指令 ret,ret就是将当前的地址pop eip的位置,因为call过去的时候,进行了esp-4的操作,所以ret pop出eip那么当前的esp进行+4
解释一下为什么ebp+4是放返回地址的:因为我们正常的操作的时候,会有一个步骤就是push ebp,那么我们push ebp,当前的esp又加了4,进行一个 mov ebp ,esp 进行了一个 ebp = esp
所以 ebp + 4是返回地址,ebp + 8 是参数
当前函数代码进行了一个,push 1的操作在 ebp + 8 ,也就是我们的nNumber = ebp + 8 ,因为是int,所以&nNumber - 1等同于 ebp + 8 - 4,也就是ebp + 4,那么 *(ebp + 4) = 返回地址 ,返回地址 = nAddr ,return nAddr 就是返回地址,一般又参数的返回都用eax进行保存,那么进行引用的时候,直接调用eax即可
结构体的时候,假设里面的成员有两个,我们进行返回值的时候,要用到eax,edx两个寄存器进行返回到相应的局部变量的位置上
第七章-变量在内存中的位置和访问方式
7.1 全局变量和局部变量的区别
全局变量在数据区,生命周期和所在模块一致,我们使用立即数间接访问
局部变量所在的地址为栈区,生命周期与所在的函数作用域一致,我们使用ebp或者esp间接进行访问
7.2 局部静态变量的工作方式
进行测试了一个静态局部变量,static静态局部变量的时候只会进行一次操作,直接来看汇编
进行的操作就是,在内存中会有一个特定的位置进行储存一下校验的位置,这里也就是0x427e38,那么第一次我们的static 局部变量没有被赋值的时候,他的al是0,用来记录,才会直接后面的jne下面的位置ZF这里是1,所以第一次赋值了我们要输入的值,当二次,第三次的时候,al变成1,所以进行校验的时候,会告诉编译器,我们的值已经被赋值过了,直接jne跳转,于是就是图上的效果
结论:static 静态局部变量只赋值一次,标记位只用了一位,说明一个字节有8位,一个字节可以判断8个静态局部变量
如果直接给常量,编译器不会进行校验等等,直接不判断了,因为会一直等于1,在固定的地址写了
7.3 堆变量
这里运用的就是 malloc free / new delete
new 这里也会运用到 malloc和_nh_malloc_dbg,他们执行返回的是申请堆空间的首地址
这里书用了个例子挺好的,用了商铺的原理解释了malloc new等等,malloc是划分出使用,new是划分好直接调用,所以malloc需要强制转换,new不用
申请堆空间使用的了_heap_alloc_dbg,里面使用了CrtMemBlockHeader,描述里面的成员,定义了两个重要的指针,pBlockHeaderPrev,pBlockHeaderNext,两个前后指针进行遍历,gap保存堆数据的数组
这里了解了一个知识点:0xcdcdcdcd,由Microsoft的C ++调试运行时库用来标记未初始化的堆内存
所以说这里初始化了10个0xCD的内存,FDFDFDFD前后4个字节代表了往上越界和往下越界的检查标志,这里我大概查了一下,这里是微软定义的地方,如果说我们进行越界了,那么0xFD这里会有数据改变,进而前后定义0xFD来检查前后是否越界
这里的 3A代表申请了多少次的堆空间,0xA这里代表了堆空间的大小 0x1F0E28位置说明了上一次堆空间首地址,下面的4个字节代表了下一次的,这里的0x1是堆使用的个数,这里对应了一个结构体CrtMemBlockheader
这里的nNoMansLandSize 4 对应了后面的 gap[4] = { 0xFD,0xFD,0xFD,0xFD},检查中如果gap中的值变化了,就会以Assert fail的方式报错
1 | #define nNoMansLandSize 4 |
如果我们的某个堆空间释放了,那么我们再申请的时候会检查这个被释放的堆空间是否可以满足用户的要求,如果满足就再次申请的堆空间地址就是我们刚释放的对空间地址,形成回收空间的再次利用
第八章-数组和指针的寻址
8.1 数组在函数内
数组在函数的局部变量中的形式是从低地址像高地址传送,正常的连续变量是从高地址向低地址传送,这里了解了一下,字符串数组
假设这个hello world 会把4*3=12字节的数据 分别给了 eax,ecx,edx进行存储,如果不是4的倍数
假设剩三个就会分配一下 word byte进行寄存器的传送赋值
8.2 数组作为参数
数组作为参数的时候也是地址进行的传递
这里很好的了解了一下 sizeof以及strlen的原理,sizeof是我们类型的大小长度,所以如果对指针或者形参保存的数组名使用sizeof的时候只能获得当前平台的指针长度,所以这里用strlen,这里的代码说的很明白直接截图了
这里的repne scasb代表的是repne会将ecx自动每次减1,如果我们的edi指向了末尾的0,那么我们就可以得到长度
1 | ecx2 = ecx1 - (len + 1) |
总结来说,sizeof是算术符,而strlen是函数,这里进行 -1 的操作是因为在进行汇编来看把最后一个ASCII码等于0的\0也算进了总的长度,所以他补码后需要进行+1的操作他有多算了一位就是+2,那么我们操作等同于了not - 1 等于了 neg后的 - 2
strcpy的内联形式,我觉得书上给的源码对应挺不错的
大概就是一个 复制的一个过程,中间的 rep movsd 是ecx为次数,也就是 x / 4 = y ,那么想与是因为 除以4 mod = 3这个有限域所以与3
8.3 数组作为返回值
数组作为返回值的时候,因为esp要回到原来的位置,那么很有可能这数组里面的数据会被接下来的操作进行破坏,书上的图很简洁
进行这个操作的时候会破坏数组的0x12FF18和0x12FF1C的数据空间,对数组进行破坏
避免这种方式:使用全局数组来进行这种方式,因为取的地址是固定的不在局部也不在参数位置,是在一个固定的地址中,不会被改变
静态局部数组
测试了一下大概和静态局部变量差不多,无论局部静态数组有多少个元素也只检查一次,上次说的静态局部变量有两个就会检查两次等等的
8.4 下标寻址和指针寻址
数组下标寻址要比指针寻址的速度快一些,因为指针寻址的时候,进行了两次取地址的操作,但是指针比较灵活
数组下标寻址公式:nvar + sizeof(类型)*数组下标
整形常量寻址会直接计算好,整型变量寻址会根据公式来定,因为变量会暂时无法计算对应地址,表达式寻址,会先进行计算
因为不会对数组的下标进行访问检查,所以很容易导致越界的问题,当小于0或者大于数组下标的时候,就会越界,如下
8.5 多维数组
多维数组的含义:在内存中都可以转换成一位数组的存储方式,只是我们用多维数组来看,自己本身比较好寻址
这里面有几个公式,多维数组的:数组首地址 + sizeof ( type [ j ] ) * 二维下标值 + sizeof ( type ) * 一维下标值
这个只是我们所用到的debug版本中的,在release版本中进行了公式的优化:
假设二维数组为 type nArray[M][N]
:使用 i,j作为下标寻址
nArray + i * sizeof( type [N] ) + j * sizeof ( type )
nArray + i * N * sizeof( type ) + j * sizeof ( type )
nArray + sizeof ( type ) * ( i * N + j )
所以通过转换下标值可以直接变成 i * N + j进行一个转换,从而节省步骤
三维数组可以这么理解:
搞明白了这里的24h 是我们的 y*z*sizeof(type)
3*3*4 =0x24
,所以最开始进行的是 x * 0x24 依次推导的话 这个里就是 x*0x24+y*0xC+z*0x4
那么理解成自己的公式就是 二维数组:x*y*sizeof(type)+y*sizeof(type)
这里说的明确一下就是 0x24 = 0x3*0x3*0x4 0xC = 0x3 * 0x4
8.6 存放指针类型数据的数组
我们的指针类型的数组,这里举个例子比较通俗易懂
这里代表了我们说的返回的应该是定义的指针数组,所以里面的每一个成员项是地址,地址里面存的是数据,所以输出的时候是取地址里面的数据,所以我们可以取出来是多少
8.7 指向数组的指针变量
这里引入一个公式:指针变量地址数据 + = (sizeof (指针类型) * 数值)
这里是0x20书上是0x2C,不清楚书上为什么是0x2C有点蒙 = = 蒙了很久,先过我认为0x20是对的,最后一次ebp - 4 为 0 0 c c
直接30+2 = 32 = 0x20
8.8 函数指针
函数指针的定义:
返回值类型 ([调用约定,可选] * 函数指针变量名称) (参数信息)
第九章-结构体和类
9.1 对象的内容布局
大概看了看C++的一个视频,大概了解了C++的一个 对象 类 属性 功能的问题,private为私有,public为公有,如果我们没有定义private,那么我们的第一个Class那边的默认为private
这里有一个公式挺好的:对象长度 = sizeof(数据成员1)+sizeof(数据成员2)+...+sizeof(数据成员n)
这里测试的number1对象就是8个字节
上面的公式不适用于三种情况:
- 空类:如果类的对象不占内存空间的话,那么会分配1字节的空间用于类的实例化,而不是0
- 内存对齐:之前有写过纸质的笔记
- 静态数据成员
9.2 this指针
举例:
1 | struct A{ |
假设我们的变量a的地址为0x12ff00,那么我们的m_float在结构体A中的偏移为0x4,所以&pa - > m_float = 0x12ff00 + 0x4,总结一下公式就是:pa->m_float的地址 = 指针pa的地址 + m_float在A中的偏移
这里可以看到我们的this指针,保存着我们的调用对象的首地址,利用寄存器ecx来保存对象首地址,以寄存器传参方式传递到成员函数中,所以说我们的非静态成员函数都有一个隐藏参数,自身类型的指针,这就是this指针,默认调用约定叫thiscall,hhhh今天才很好的了解this和thiscall,thiscall是c++专有的方式,如果我们用了stdcall或者其他的,那么this指针不用ecx传递了
对象首地址保存在ecx中
thicall格式总结:
1 | lea ecx,[mem] |
_stdcall与__cdecl调用方式的成员函数:
1 | lea reg,[mem] |
9.3 静态数据成员
静态数据成员这里理解起来和静态局部变量可以理解成一个东西,因为他是在程序被加载的时候,他就会存在,所以说,这个时候,类没有对应的实例对象
可以看到两者所属的内存地址空间不同,所以静态成员不参与长度的计算
9.4 对象作为函数参数
书上给了个对象属性很少的例子,这个时候,对象作为函数参数调用过去的时候,他们复制了一份数据过去,然后压入堆栈,测试结果
这里并不是push ebp-4那些,而是经过给寄存器赋值进行的传参,传参顺序先定义的后传
又举了个数组的方式,这个对象作为参数和上面的类同就不说了,这里有说到析构和拷贝函数,日后再详细说,书上给了个对象作为参数的资源释放错误的例子(学完析构和拷贝回来研究一下)
9.5 对象作为返回值
作为返回地址的时候,会把我们的对象的首地址的数据依次复制到返回地址下面,等我们后面的printf调用的时候,直接调用就可以了
用ebp调用,放入寄存器传参即可
补上的东西
大概想了一下,这章有析构和构造函数,大概去学习了一下大概的东西
析构函数:
- 只能有一个析构函数,不能重载
- 不能带任何参数
- 不能带返回值
- 主要用于清理工作
- 编译器不要求必须提供
构造函数
- 与类同名
- 没有返回值
- 创建对象的时候执行
- 主要用于初始化
- 可以有多( 最好有一个无参的 ),称为重载,其他函数也可以重载
- 编译器不要求必须提供
那么我就把书上自己没看懂的位置理解一下 ( 不欠债!)
1 | class cMyString{ |
这边出现的问题,就是析构函数和成员函数的返回值进行了异常的问题,书上的图很好解释这个问题
堆的首地址被释放,但是我们delete的时候,找不到首地址会出现问题,里面的数据保留且我们再次申请new的时候,会找不到堆的位置
&((struct A)*NULL) -> m_float 不会崩溃,因为求的是 m_float在结构体中的偏移:0+4
this指针这里科二师傅给我大概说了一个很清楚的记录一下:
类 -> 变量类型(如int)
对象 -> 具体变量(如int e 这个声明中的e)
this -> (&e)
第十章-关于构造函数和析构函数
这里大概自己又了解了一下继承
继承:
- 继承就是数据的复制
- 减少重复代码的编写
- 定义共同属性的类叫做父类或者基类
- 里面需要共同属性的叫做子类或者派生类
- 子类或者派生类中的对象,叫做对象或者实例
- 可以用父类指针指向子类的对象
10.1 构造函数的出现时机
这里大概了解了,局部对象,堆对象,参数对象,返回对象,全局对象,静态对象
10.2每个对象都有默认的构造函数吗
本类中定义的成员对象或者父类中有虚函数存在
父类或者本类中定义的成员对象带有构造函数
10.3 析构函数的出现时机
- 局部对象:作用域结束前调用析构函数
- 堆对象:释放对空间前调用析构函数
- 参数对象:退出函数前,调用参数对象的析构函数
- 返回对象:如无对象引用定义,退出函数后,调用返回对象的析构函数,否则与对象引用的作用域一致
- 全局对象:main函数退出后调用析构函数
- 静态对象:main函数推出后调用析构函数
第十一章-从内存角度看继承和多重继承
其实自己虚函数,以及继承这里没有按照书上的来了,而去看了海哥的视频,感觉书上说的不容易理解,所以从第十三章讲异常处理那个位置我再看
继承这里分了多层继承和多重继承
继承无非就是子类继承父类,来完善C的一些作用,上面我也有写道,子类::父类
,微软不建议使用多重继承,首先我们看一下多层继承的图
这个是我们多层继承,那么我们再看一下多重继承
其实虽然我们用反汇编看起来都一样,但是多重继承来说它增加了程序的复杂度,容易出错,所以不推荐使用
这里有一个很好玩的地方就是,我们的对象的sizeof大小,如果这里我们假设属性a,在爷爷x里存在,父亲y中也存在,子类z还存在,那么他们其实是有不同的存储空间的,取的时候要Z.X::a
这样进行取,如果单纯的Z.a
,那么会等同于Z.Z::a
的
第十二章-关于虚函数
class Base
{
public:
void Function_1()
{
printf("Function_1...\n");
}
virtual void Function_2()
{
printf("Function_2...\n");
}
};
观察反汇编:
void TestMethod()
{
Base base;
base.Function_1();
00401090 8D 4D FC lea ecx,[ebp-4]
00401093 E8 9F FF FF FF call @ILT+50(Base::Function_1) (00401037)
base.Function_2();
00401098 8D 4D FC lea ecx,[ebp-4]
0040109B E8 65 FF FF FF call @ILT+0(Base::Function_2) (00401005)
Base* pb = &base;
pb->Function_1();
004010A6 8B 4D F8 mov ecx,dword ptr [ebp-8]
004010A9 E8 89 FF FF FF call @ILT+50(Base::Function_1) (00401037)
pb->Function_2();
004010AE 8B 4D F8 mov ecx,dword ptr [ebp-8]
004010B1 8B 11 mov edx,dword ptr [ecx]
004010B3 8B F4 mov esi,esp
004010B5 8B 4D F8 mov ecx,dword ptr [ebp-8]
004010B8 FF 12 call dword ptr [edx]
}
总结:
1、通过对象调用时,virtual函数与普通函数都是E8 Call
2、通过指针调用时,virtual函数是FF Call,也就是间接Call
观察虚函数通过指针调用时的反汇编:
pb->Function_1();
0040D9E3 8B 4D F0 mov ecx,dword ptr [ebp-10h]
0040D9E6 8B 11 mov edx,dword ptr [ecx]
0040D9E8 8B F4 mov esi,esp
0040D9EA 8B 4D F0 mov ecx,dword ptr [ebp-10h]
0040D9ED FF 12 call dword ptr [edx]
pb->Function_2();
0040D9F6 8B 45 F0 mov eax,dword ptr [ebp-10h]
0040D9F9 8B 10 mov edx,dword ptr [eax]
0040D9FB 8B F4 mov esi,esp
0040D9FD 8B 4D F0 mov ecx,dword ptr [ebp-10h]
0040DA00 FF 52 04 call dword ptr [edx+4]
总结:
1、当类中有虚函数时,会多一个属性,4个字节
2、多出的属性是一个地址,指向一张表,里面存储了所有虚函数的地址
第十三章-异常处理
C++中的异常处理机制由try,throw,catch语句构成的
- try语句块负责监视异常
- throw用于异常信息的发送,也称之为抛出异常
- catch用于异常的捕获,并作出相应的处理
1 | try{//异常检测 |
- 在函数入口处设置异常回调函数,其回调函数先设置eax为FuncInfo数据的地址,然后跳往_CxxFrameHandler
- 异常的跑出由_CxxThrowException函数完成,该函数使用了两个参数,一个是抛出异常的关键字throw的参数的指针,一个是抛出信息类型的指针(ThrowInfo*)
- 在异常回调函数中,可以得到异常对象的地址和对应ThrowInfo数据的地址以及FunInfo表结构的地址,根据记录的异常类型,进行try块的匹配工作
- 如果没有找到try块,则异构异常对象,返回ExceptionContinueSearch,继续下一个异常回调函数的处理
- 当找到对应的try块时,通过TryBlockMapEntry表结构中的pCatch指向catch信息表,用ThrowInfo表结构中的异常类型遍历查找与之匹配的catch块,比较关键字名称(如整型为.h,单精度浮点为.m)找到有效的catch块
- 执行栈展开操作,并产生catch块中使用的异常对象(有4中不同的产生方法)
- 正确析构所有生命期已结束的对象
- 跳转到catch块,执行catch块代码
- 调用_JumpToContinuation函数,返回所有catch语句块的结束地址