avatar

C++反汇编与逆向分析技术揭秘

第一章-熟悉工作环境和相关工具

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 添加结构体

👴第一次看到这么多快捷键学到了

image-20200421013142819

三个选项

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
2
3
4
5
6
7
void ShowSig(){
printf("showSig");
}
int main(int argc,char* argv[]){
ShowSig();
return 0;
}

image-20200421015638523

没有加载sig之前,那么我们制作一下

image-20200421020608733

image-20200421020601223

image-20200421020703962

直接我们在ida中有

image-20200421020907638

打开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
2
3
4
5
6
7
AC
--{code
}
F3 AC
--{Prefix}{code
}
//F3为Prefix的域

这里说一下他们的顺序不可以变举个例子:

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源码片段

image-20200421215049107

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
//机器码解析函数
/*
DISASSEMBLY 结构说明
typedef struct Decoded{
char Assembly[256]; //汇编指令信息
char Remarks[256]; //汇编指令说明信息
char Opcode[30]; //Opcode机器码信息
DWORD Address; //当前指令地址
BYTE OpodeSize //Opcode机器码长度
BYTE PrefixSize //指令前缀长度
}DISASSEMBLY;
*/
void Decode(DISASSEMBLY *Disasm,char *Opcode,DWORD *Index){
/*源码中函数说明信息,便令局部定义略*/
//机器码格式分析略
//判断是否符合Opcode机器码格式,Op为参数Opcode[0]项
switch(Op){//分析Op对应机器码
case 0x68:
//0x68为PUSH后面的四个字节入栈的机器码 0x6A为PUSH后面的一个字节入栈的机器码
//方式1:push 4字节内存地址信息
//判断寄存器指令前缀
if(RegPrefix == 0){
//PUSH指令后按4字节方式解释
//例子当前机器码为:6800304000
//由于在内存中为小尾方式排序,因此取出内容需要重新排列数据
//此函数对指令地址+1,偏移到00304000处,将其排序为00403000
//提取出的机器指令存放在dwOp中
//转换中的地址信息保存在dwMem中
SwapDword((BYTE *)(Opcode+i+1),&dwOp,&dwMem);
//将机器指令信息转换为汇编指令信息
wsprintf(menemonic,push %08X,dwMem);
//保存汇编指令语句到Disasm结构中,用于返回
lstrcat(Disasm->Assembly,menemonic);
//组装机器码信息,用空格将指令码与操作数分离
wsprintf(menemonic,68 %08X",dwOp);
//将机器码信息保存到Disasm结构中,用于返回
lstrcat(Disasm->Opcode,menemonic);
//设置指令要占用的内存空间
Disasm->OpcodeSize = 5;
//设置指令前缀长度
Disasm->PrefixSize = PrefixesSize;
//对当前分析指令地址下标加4字节偏移量
(*Index)+=4;
}
else{
//push指令后按2字节方式解释
//解析机器码,以上代码相同
SwapDword((BYTE *)(Opcode+i+1),&wOp,&wMem);
//按2字节解释操作数:"push %04X"
wsprintf(menemonic,push %04X,wMem);
lstrcat(Disasm->Assembly,menemonic);
//按2字节解释操作数:"push %04X"
wsprintf(menemonic,68 %04X",wOp);
lstrcat(Disasm->Opcode,menemonic);
//设置指令长度
Disasm->OpcodeSize = 3;
//设置指令前缀长度
Disasm->PrefixSize = PrefixesSize;
//对当前分析指令地址下标加2字节偏移量
(*Index)+=2;
}
}
break;
case 0x6A:
//方式2:push指令的操作数是小于等于1字节的立即数
{
//有符号数判断,负数处理
if((BYTE)Opcode[i+1]>=0x80){
//负数在内存中为补码,用0x100-补码得回源码(自己理解是因为0x100满足海哥说的那个圆而且是一字节)
//"push -%02X"中对原码加符号
wsprintf(menemonic,"push -%02X",(0x100-(BYTE)Opcode[i+1]));
}
//有符号处理,正数处理
else{
//正数直接转换
wsprintf(menemonic,"push %02X",(0x100-(BYTE)Opcode[i+1]));
}
//保存汇编语句
lstrcat(Disasm->Assembly,menemonic);
//组装机器码信息
wsprintf(menemonic,"6A%02X",(BYTE)*(Opcode+i+1));
//保存机器码信息
lstrcat(Disasm->Opcode,menemonic);
//设置指令长度与指令前缀长度
Disasm->OpcodeSize = 2;
Disasm->PrefixSize = PrefixesSize;
//对当前分析指令地址下标加2字节偏移量
++(*Index);
}
break;
}

刚把柴佬的任务完成,急忙🏃‍♂️回来,继续整理笔记😁

代码省略了其他机器码的解析过程,列举了push的两种机器指令方式,自己大概了解了:通过解析Opcode指令操作码,找到对应的解析方式,将机器码重组为汇编代码。通过第一个参数DISASSEMBLY *Disasm传出解析结果,将机器码指令长度由参数Index传出,用于寻找下一个Opcode指令操作码

Decode对机器码进行分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//假设此字符数组为机器指令编码
unsigned char szAsmData[]={
0x6A,0x00, //push 00
0x68,0x00,0x30,0x40,0x00, //push 00403000
0x50, //push eax
0x51, //push ecx
0x52, //push edx
0x53, //push ebx
};
char szCode[256]={0}; //存放汇编指令信息
unsigned int nIndex = 0; //每条机器指令的长度,用于地址偏移
unsigned int nLen = 0l //分析机器码总长度
unsigned char *pCode = szAsmData;

//获取分析机器码长度
nLen = sizeof(szAsmData);IN
while(nLen){
//检查是否超出分析范围
if(nLen < nIndex){
break;
}
//修改pCode偏移
pCode + = nIndex;
//解析机器码(转下一个函数)Decode2Asm函数
//pCode:分析机器码首地址
//szCode:返回值,保存解析后的汇编指令语句信息
//nIndex: 返回值,保存机器码指令长度
//模拟机器码,没有对应代码地址,因此传入0
Decode2Asm(pCode,szCode,&nIndex,0);
//显示汇编指令
puts(szCode);
memset(szCode,0,sizeof(szCode));
}

通过函数Decode2Asm,启动反汇编引擎Proview,解析出对应汇编指令语句代码,并输出

Decode2Asm实现流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void_stdcall
Decode2Asm(IN PBYTE pCodeEntry, //分析Opcode地址,无符号字符型指针
OUT char* strAsmCode, //传出值,保存汇编指令的语句信息
OUT OINT* pnCodeSize, //传出值,保存机器码指令的大小
UINT nAddress) //分析机器码所在地址
{
DISASSEMBLY Disasm; //结构体在上上面啦
//保存Opcode指针,用于传递函数参数
char *Linear = (char *)pCodeEntry;
//初始化指令长度
DWORD Index = 0;
//设置机器码所在地址
Disasm.Address = nAddress;
//初始化Disasm
FlushDecoded(&Disasm);
//调用Decode进行机器码分析
Decode(&Disasm,Linear,&Index);
//保存汇编指令语句信息
strcpy(strAsmCode,Disasm.Assembly);
//组装汇编语句的字符串,从参数strAsmCode返回信息
if(strstr((char*)Disasm.Opcode,":")){
Disasm.OpcodeSize++;
char ch='';
strncat(strAsmCode,&ch,sizeof(char));
}
strcat(strAsmCode,Disasm.Remarks);
*pnCodeSize = Disasm.OpcodeSize;
FlushDecoded(&Disasm);
return;
}

对汇编引擎Proview的使用进行了封装,以简化Decode函数的调用过程,方便使用者调用

第二章-基本数据类型的表现方式

2.1 整数类型

感觉这边有点基础,就不总结了

2.2 浮点数类型

上次自己的问题就是 Opcodemenemonic对应的原则,群里的大佬给出了答案

img

浮点数编码方式

浮点数编码采用的是IEEE规定的编码标准,IEEE规定的浮点数编码会将一个浮点数转换为二进制数,以科学计数法划分,将浮点数拆分为3部分:符号,指数,尾数

float类型的IEEE编码

float 4字节(32位),最高位用于表示符号:在剩余的31位中,从左向右取8位用于表示指数,其余用于表示尾数

image-20200422223744183

假设: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,由于小端序所以内存变成了这个样子

image-20200422225402526

-0.125f 经过IEEE转化后的情况

符号位:1

指数位:127+(-3),二进制 ‭00011000‬,如果不足8为,则高位补0

尾数位:00000000000000000000000

转化为16进制为0xBE000000

image-20200422231512042

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

image-20200422232126544

所以说这个浮点数计算是一个近似值,存在一定的误差,如果把这个转化成小数的话,那么就是 1.299999523162841796875 四舍五入之后为1.3,所以这就解释了为什么C++在比较浮点数值是否为0时候,要进行一个区间比较,并不是等值比较

浮点数比较代码

1
2
3
4
float fTemp = 0.0001f;	//精确范围
if (fFloat >= -fTemp && fFloat <= fTemp){
//fTemp等于0
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//c++源码对比,argc为命令行参数
float fFloat = (float)argc;
//将地址ebp+8的整数类型数据转换成浮点型,并放入ST[0]中,对应的变量是argc
0040E9D8 fild dword ptr [ebp+0x8]
//从ST[0]中取出数据以浮点编码的形式放入ebp-4,对应变量fFloat
0040E9D8 fst dword ptr [ebp-0x4]
总结一下:将argc转化为浮点型,然后存入fFloat的变量中
//c++源码对比,浮点数作为函数参数进行传递
printf("%f",fFloat);
//esp-8,需要转化成双精度浮点数,提前准备好8个字节的栈空间,便于存访double数组
0040E9DE sub esp,0x8
//将ST(0)中的数据传入esp中,并弹出ST(0)
0040E9E1 fstp qword ptr [esp]
//printf调用,略了 都懂
//c++源码对比,将float类型转换成int类型
argc = (int)fFloat
//将ebp-4处额数据以浮点型压入ST[0]中
0040E9F1 fld dword ptr [ebp-0x4]
//调用函数_ftol进行浮点数转换(下面源码会写)
0040E9F4 call _ftol(0040e688)
//转换后结果放入eax中,并传递到ebp+8地址处
0040E9F9 mov dword ptr [ebp+0x8],eax

总结:float类型浮点数,占4个字节,但是都是以8个字节方式处理即double形式,当浮点数作为参数的时候,不能直接压入栈,push传的4字节,会丢失4个字节,所以printf使用整数方式输出浮点数会报错,printf以整数方式输出的时候,将对应参数作为4字节数据,按照补码的方式解释,而压入参数为浮点数类型的时候,数据长度为8字节,需要按浮点编码方式解释

浮点数作为返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//c++源码对比,返回值为浮点数的函数调用
fFloat = GetFloat();
//调用函数GetFloat
0040EA3D call @ILT+5(GetFloat)(0040100a)
//由于浮点数要进行特殊处理,浮点数占8个字节,无法使用eax传递,使用浮点寄存器ST[0]作为返回值
0040EA42 fst dword ptr [ebp-4]

//c++源码对比,GetFloat函数实现
float GetFloat()
{
//C++源码对比,返回浮点数12.25f
return 12.25f
//将浮点数保存在ST[0],返回时浮点数情况无法使用eax
0040E9D8 fld dword ptr[(0042301c)]
//附加代码 略
0040E9E4 ret
}

类型转换函数_ftol的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//提升堆栈
push ebp
mov ebp,esp
add esp,0F4h
//浮点异常检查,CPU与FPU同步工作(FPU是专用于浮点运算的处理器,以前的FPU是一种单独芯片,在486之后,英特尔把FPU集成在CPU之内)
wait
fnstcw word ptr [ebp-0x2] //FNSTCW浮点不检查保存控制器
wait
mov ax,word ptr [ebp-0x2]
or ah,0ch
mov word ptr [ebp-0x4],ax
fldcw word ptr [ebp-0x4] //FLDCW 浮点加载控制器
//从ST[0]中取出8字节数据转换成整型并存入ebp-0ch中
//将ST[0]从栈中弹出来
fistp qword ptr [ebp-0ch]
fldcw word ptr [ebp-0x2]
//用eax保存长整型数据的低四位用于返回
mov eax,dword ptr [ebp-0ch]
//使用edx保存长整型数据的高4字节用于返回
mov edx,dword ptr [ebp-0x8]
//释放栈
leave
ret

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会根据编译选项定义对应的字符类型,还是第一次知道

字符串的存储方式

确定字符串的总长度有两种方法:

  1. 在首地址的4字节中保存字符串的总长度
  2. 在结尾处规定一个特殊字符

优缺点

  • 保存总长度
  • 优点:不需要遍历每个字符,取前n个字节就可以知道总长度,一般来说就是(1,2,4)字节
  • 缺点:不能超过n的长度,且要多开销n字节,通信情况,双方需要事先知道通信字符串的长度
  • 结束符
  • 优点:没有记录开销,设计通信可以通过实际情况来决定
  • 缺点:获取字符时候要遍历所有字符,比较慢

C++使用结束符’\0’为字符串结束标识,ASCII码使用1个字节\0,Unicode使用两个字节\0,不能使用ASCII的处理函数对Unicode处理,会报错

image-20200423013907882

一般程序中会使用一个字符型指针来保存字符串首地址,char* wchar_t* TCHAR*

IDA这里有个操作是快捷键A,直接分析到’\0‘,解释字符串!

2.4 布尔类型

布尔类型不说了!(内存占1位 0 1)

2.5 地址 指针和引用

地址,指针,引用,,地址就是&那个,只有变量才存在内存地址(除了const),指针用来保存地址的,引用就是取别名,别名的操作就是对源变量的操作

指针和地址的区别关系:

image-20200423014925082

不同点:

指针 地址
变量,保存变量地址 常量,内存编号
可修改,再次保存其他变量地址 不可以修改
可以对其执行地址操作得到地址 不可以执行取地址操作
包含对保存地址的解释信息 仅仅有地址值无法解释数据

相同点:

指针 地址
取出指向地址内存中的数据 取出地址对应内存中的数据
对地址偏移后,取数据 偏移后取数据,自身不变
求两个地址的差 求两个地址的差

指针保存的都是地址,每种数据类型在内存中所占的内存空间不同,指针中只保存了存放数据的首地址,没有指明在哪里结束,所以需要根据对应的类型来寻找解释数据的结束地址,同一个地址使用不同类型指针进行访问,取出的内容就会不一样

各类指针访问同一地址代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//c++源码对比,定义int类型变量,初始化为0x12345678
int nVar = 0x12345678;
//为地址赋值4字节数据12345678h
0040EB1D mov dword ptr [ebp-10h],12346578h
//c++源码对比,定义int类型指针变量,初始化为变量nVar地址
int *pnVar = &nVar;
0040EB24 lea ecx,[ebp-10h]
0040EB27 mov dword ptr [ebp-14h],ecx
//c++源码对比,定义char类型指针变量,初始化为变量nVar地址
char *pcVar = (char*)&nVar;
0040EB2A lea edx,[ebp-10h]
0040EB2D mov dword ptr [ebp-18h],edx
//c++源码对比,定义short类型指针变量,初始化为变量nVar地址
short *psVar = (short*)&nVar;
0040EB30 lea eax,[ebp-10h]
0040EB33 mov dword ptr [ebp-1Ch],eax
//c++源码对比,取出指针pnVar指向的地址内容并显示
printf("%08x \r\n",*pnVar);
//取出pnVar中保存的地址值并放入ecx中
0040EB36 mov ecx,dword ptr [ebp-14h]
//从ecx保存的地址中,以4字节方式读取数据,存入edx中
0040EB39 mov edx,dword ptr [ecx]
//printf函数调用部分略
//c++源码对比,取出指针pnVar指向的地址内容并显示
printf("%08x \r\n",*pcVar);
//取出pcVar中保存的地址并放入eax中
0040EB49 mov eax,dword ptr [ebp-18h]
//从eax保存的地址中,以1字节的方式读取数据,存入ecx中
0040EB4C movsx ecx,byte ptr [eax]
//printf函数调用部分略
//c++源码对比,取出指针pnVar指向的地址内容并显示
printf("%08x \r\n",*psnVar);
//取出psnVar中保存的地址并放入edx中
0040EB5D mov edx,dword ptr [ebp-1Ch]
//从edx保存的地址中,以2字节方式读取数据,存入eax中
0040EB60 movsx eax,word ptr [edx]
//printf函数调用部分略

取出来的地址会根据指针类型的长读来取出来

image-20200424100537599

这里测试没有问题的,我们去反汇编看一下

image-20200424100932740

看了一下反汇编,和源码解析的差不多,反汇编的意思大概差不多,差别就是编译器的不同,只要有汇编,他编译器干什么就都会知道了

总结:在我存入的是0x12345678的时候再内存中存放是根据小端序存放78 56 34 12,首地址从7 8 开始,指针pnVar为int类型指针,以int类型在内存中占用的空间大小和排列的方式对地址进行解释,然后取出数据,int占4字节,所以取出来12345678,我们知道如上图所示,大地址到小地址会进行截取,eax本身就是4字节的空间,那么我分给word和byte的时候,会把前面的低地址位置进行一个截取,取高地址的位置,所以说会把前面的数据砍掉,然后用movsx进行分析符号位的填充

所有类型的指针对地址的解释都取决于自己本身的指针类型,指针做加法和减法比较有意义,因为指针是保存和解析地址而存在的,我们对指针的地址偏移时,偏移会根据自身的指针类型来决定

各类型指针寻址方式代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//c++源码对比,定义字符型数组,占5字节内存空间
//内存对齐的原则,cVar实际占用8字节 (第5个开始的时候,要根据上面加一起的4字节进行一个对齐的原则)
char cVar[5] = {0x01,0x23,0x45,0x67,0x89};
//数组成员赋值,数组为char类型,每次赋值使用的时byte ptr
0040EB1D mov byte ptr [ebp-14h],1
0040EB21 mov byte ptr [ebp-13h],23h
0040EB25 mov byte ptr [ebp-12h],45h
0040EB29 mov byte ptr [ebp-11h],67h
0040EB2D mov byte ptr [ebp-10h],89h
//c++源码对比,定义int类型的指针变量保存数组的首地址
int *pnVar = (int*)cVar;
//取出数组的首地址保存在ebp-18h中(ebp-0x4往上都是局部变量)(测试一下)
0040EB31 lea ecx,[ebp-14h]
0040EB34 mov,dword ptr [ebp-18h],ecx
//c++源码对比,定义char类型的指针变量保存数组的首地址
char *pcVar = (char*)cVar;
//取出数组的首地址并保存到ebp-1ch中
0040EB37 lea edx,[ebp-14h]
0040EB3A mov dword ptr [ebp-1Ch],edx
//c++源码对比,定义short类型的指针变量保存数组的首地址
short *psnVar = (short*)cVar;
//取出数组首地址保存在ebp-20h
0040EB3D lea eax,[ebp-14h]
0040EB40 mov dword ptr [ebp-20h],eax
//c++源码对比,对int指针加1
pnVar += 1;
//取出指针内保存的地址并放入ecx中
0040EB43 mov ecx,dword ptr [ebp-18h]
//保存指针类型获取便宜长度,int类型的指针,追加偏移值4
0040EB46 add ecx,4
//存回指针中
0040EB49 mov dword ptr [ebp-18h],ecx
//c++源码对比,对char指针加1
pcVar += 1;
0040EB49 mov edx,dword ptr [ebp-1Ch]
//根据指针类型获取偏移长度,char类型指针,追加偏移值1
0040EB4F add edx,1
0040EB52 mov dword ptr [ebp-1Ch],edx
//c++源码对比,对short指针加1
psnVar += 1;
0040EB55 mov eax,dword ptr [ebp-20h]
//根据指针类型获取编译长度,short类型指针,追加偏移值2
0040EB58 add eax,2
0040EB5B mov dword ptr [ebp-20h],eax

总结:编译量的计算方式为指针类型长度乘以移动次数,因此得出指针寻址公式如下
$$
(p+n)目标地址 = 首地址 + sizeof(指针类型)*n (n为移动次数)
$$
两指针做减法,可以求出两地址之间的元素个数(必须同类指针相减),两指针相加没有作用,公式如下
$$
p-q = ((int)p - (int)q) / sizeof(指针类型)
$$

引用

引用类型在c++中被描述为变量的别名,c++为了简化指针的操作,对指针的操作进行了封装,产生了引用类型,实际上引用类型就是指针类型,只不过它用于存放地址的内存空间对使用者而言是隐藏的

引用类型代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//c++源码对比,定义int类型的变量并附初始值0x12345678
int nVar = 0x12345678;
0040E6F8 mov dword ptr [ebp-4],12345678h
//c++源码对比,定义变量nVar的引用类型nVarType
int &nVarType = nVar;
//取出变量nVar的地址并放入eax中
0040E6FF lea eax,[ebp-4]
//将变量nVar的地址存入地址ebp-8处,这个ebp-8就是引用类型nVarTpye的地址
0040E702 mov dword ptr [ebp-8],eax
//调用函数Add,Add的参数为int引用类型,将变量nVar作为参数传递
Add(nVar);
//取出变量nVar的地址并放入ecx中
0040E705 lea ecx,[ebp-4]
//将ecx作为参数入栈,也就是传递变量nVar的地址作为参数
//Add函数代码在下面了,马上就写!
0040E708 push ecx
0040E709 call @ILT+15(ADD)(00401014)
0040E70E add esp,4

引用类型的存储方式和指针是一样的,都是使用内存空间存访地址值,所以在C++中,引用和指针没有区别,引用时通过编译器实现寻址,而指针需要手动寻址,所以说如果操作失误会有比较糟糕的结果,但是引用就不会存在这种问题,所以c++很推荐使用引用类型,并不是指针

引用类型作为函数参数代码

1
2
3
4
5
6
7
8
9
10
11
12
void Add(int &nVar){
//c++源码对比,对引用类型进行nVar执行++操作
nVar++;
//取出参数nVar中的内容放入eax中
00401078 mov eax,dword ptr[ebp+8]
//对eax执行取内容操作
0040107B mov ecx,dword ptr [eax]
0040107D add ecx,1
00401080 mov edx,dword ptr [ebp+8]
00401083 mov dword ptr [edx],ecx
}
0040108B ret

这里我看到的结论就是引用的时候,并没有像指针一样进行了数据类型的那种的移动,而就是单纯的进行了数据+1,我们测试一下

image-20200424124405103

因为前面有一个lea的操作把地址给了eax,然后eax给了ebp-0x24,所以引用也相当于取别名

2.6 常量

常量数据在程序运行前就已经存在,他们被编译到可执行文件中,当程序启动后,他们会加载进来,数据通常会在常量数据区中保存,该节区的属性没有写权限,所以不可以修改

常量数据的地址减去基质就是文件中的编译地址

image-20200424134603028

常量的定义

C++中,使用#define来定义常量,也可以使用const将变量定义为一个常量,#define定义的常量名称,编译器对其进行编译时,会将代码中的宏名称替换成对应信息,宏的使用可以增加代码的可读性,const是为了增加程序的健壮性而参在的,常用字符串处理函数stcpy的第二个参数被定义为一个常量,为了防止该参数在函数内被修改,对原字符串造成破坏

宏与const的使用

1
2
3
4
5
6
//定义NUMBER_ONE 为常量1
#define NUMBER_ONE 1
//将常量NUMBER_ONE 赋值给const常量nVar
const int nVar = NUMBER_ONE;
//输出
printf("const = %d #define =%d \r\n",nVar,NUMBER_ONE);

image-20200424142250055

#define是一个真常量,而const却是由编译器判断实现的常量,是一个假常量,实际中使用const,最终还是一个变量,只是在编译器内进行了检查,发现有修改则报错

由于编译器在编译期间对const变量进行检查,因此被const修饰过的变量是可以修改的,利用指针获取到const修饰过的变量地址,强制将指针的const修饰去掉,就可以修改对应的数据内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//c++源码对比,将变量nConst修饰为const
const int nConst = 5;
//将地址ebo-4赋值为4字节数据5
004010B8 mov dword ptr [ebp-4],5
//c++源码对比,定义int类型的指针,保存nConst地址
int *pConst = (int*)&nConst;
//获取ebp-4地址并存入eax中
004010BF lea eax,[ebp-4]
//将eax中的数据赋值到地址ebp-8处
004010C2 mov dword ptr [ebp-8],eax
//c++源码对比,修改指针pConst并指向地址中的数据
*pConst = 6;
//获取地址ebp-8的数据并存入ecx
004010C5 mov ecx,dword ptr [ebp-8]
//将地址ebp-8中保存的数据修改为6
004010C8 mov dword ptr [ecx],6
//C++源码对比,将修饰为const的变量nConst赋值给nVar
int nVar = nConst;
//将5赋值到ebp-0Ch处
004010CE mov dword ptr [ebp-0Ch],5

image-20200424144617844

所以说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是语法规定的用户入口,而不是应用程序入口,在应用程序被操作系统加载的时候,操作系统会分析执行文件内的数据,分配相关的资源,读取执行文件中的代码和数据到合适的内存单元,然后才是执行入口代码,入口代码通常是mainCRTStartupwmianCRTStartupWinMainCRTStartupwWinMainCRTStartup,具体的要根据编译选项来定夺,其中mainCRTStartup和wmainCRTStartup是控制台环境下多字节编码和Unicode编码的启动函数,而WinMainCRTStartup和wWinMainCRTStartup是Windows环境下多字节编码和Unicode编码的启动函数,vc++也可以让自己去指定入口

3.2 了解VC++ 6.0的启动函数

VC++6.0 控制台和多字节编码环境下的启动函数mainCRTStartup,由系统库KERNEL32.dll负责调用

image-20200425191405630

去安装了个vc++ 6.0 可以看到程序运行时调用了三个函数,KERNEL32.dll,mainCRTStartup和main

其中KERNEL32!76606359()表示在76606359地址调用了mainCRTStartup,VC++提供了mainCRTStartup的源码,直接过去看一下,分析一下(自己没有安装完整版的,导致看不到,直接用书上的一点一点分析了)

mainCRTStartup函数代码片段分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
//预编译宏
#else /* _WINMAIN_ */
#ifdef WPRFLAG
//宽字符版控制台启动函数
void wmainCTRStartup(
#else /* WPRFLAG* /
//多字节版控制台启动函数
void mainCRTStartup(
#endif /* WPRFLAG */
#endif /* _WINMAIN_ */
void
)
{
//获取版本信息
_osver = GetVersion();
_winminor = (_osver >> 8) & 0x00FF;
_winmajor = _osver & 0x00FF;
_winver = (_winmajor << 8) + _winminor;
_osver = (_osver >>16) & 0x00FFFF;

//堆空间初始化过程,在这个函数中,制定了程序中堆空间的起始位置
//_MT是多线程标记
#ifdef _MT
if(!_heap_init(1))
#else /* _MT */
if(!_heap_init(0))
#endif /*_MT */
fast_error_exit(_RT_HEAPINIT);
//初始化多线程环境
#ifdef _MT
if(!_mtinit())
fast_error_exit(_RT_THREAD);
#endif /* _MT* /
_try{
//宽字节处理代码略

//多字节版获取命令行
_acmdln = (char*)GetCommandLineA();
//多字节版获取环境变量信息
_aenvptr = (char*)_crtGetEnvironmentStringsA();
//多字节版获取命令行信息
_setargv();
//多字节版获取环境变量信息
_setenvp();
#endif /* WPRFLAG */
//初始化全局数据和浮点寄存器
_cinit();
//窗口程序处理代码略

//宽字符处理代码略

//获取环境变量信息
_initenv = _environ;
//调用main函数,传递命令行参数信息
mainret = main(_argc,_argv,_environ);
#endif /* WPRFLAG */

#endif /* _WINMAIN_ */
//检查main函数返回值执行析构函数或atexit注册的函数指针,并结束程序
exit(mainret);
}
//退出结束代码略
}

有很多的函数不知道干什么的,总结一下:

  • 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
2
3
4
5
6
7
//用于初始化寄存器
if(_FPinit != NULL)
(*_FPinit)(); //初始化浮点寄存器
//用于初始化C语法中的数据
_initterm( _xi_a, _xi_z);
//用于初始化C++语法中的数据
_initterm( _xc_a , _xc_z);

这里_Fpinit是一个全局函数指针,类型是_PVFV,如果编译器扫描代码的时候,发现有浮点计算,则这个指针保存了初始化浮点寄存器的代码地址,否则就是0,如果浮点寄存器没有被出刷,会有异常错误👴在上周的时候说过了,参数xi_a为函数指针数组的其实地址,_xi_z为结束地址,第一个_initterm初始化的都是C支持库中所需要的数据

_initterm函数代码

1
2
3
4
5
6
7
8
9
10
11
static void _cdecl _initterm (
_PVFV * pfbegin, _PVFV * pfend )
{
//遍历数组的各元素
while(pfbegin < pfend){
//若函数指针不为空,则执行该函数
if(*pfbegin!=NULL)
(**pfbegin)();
++pfbegin;
}
}

C++初始化操作会在第二次的_initterm进行调用,一般是全局变量或者静态对象的初始化函数(这里不咋懂,书上说第10章会说)

正常的mainCRTStartup函数会根据编译器版本的不同所有不同!如vs2005,其中的mainCRTStartup变成_tmainCRTStartup,在我们的默认情况下,入口函数是main,这时候会从mainCRTStartup启动,再传入main所需要的三个参数argcargvenv,最后再调用main

如果我们重新指定入口函数

image-20200427145345539

image-20200427145438829

可以看到没有调用mainCRTStartup函数,我们可以再测试一下

image-20200427145535373

我再写一个新的函数调用并不是main来看一下

image-20200427145843968

如果我没有调用mainCRTStartup函数,我们的堆空间是没有被初始化的,所以用到堆就会报错

image-20200427151105530

我们看一下,正常的情况

image-20200427151458866

张长是可以分配的,所以说如果书用了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的大概流程

image-20200427160426209

我们用od去看看做了什么,od会直接暂停在程序的入口处,并不是定位在main函数的位置,我们试着通过main函数的特性查找一下他在哪个位置

image-20200427160802795

image-20200427161042351

自己根据分析,我在断点的位置就是main的函数入口

我用书上的代码做一个例子,便于分析!

OD反汇编信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//省略部分代码
//OD识别函数名称为GetCommandLineA
00401210 |. FF15 38514200 call dword ptr ds:[<&KERNEL32.GetCommand>]
//得到命令行参数
00401216 |. A3 444F4200 mov dword ptr ds :[424F44],eax
//根据main函数的特性,此处为_crtGetEnvirinmentStringsA()调用
0040121B |. E8 E0240000 call ProgramE.00403700
00401220 |. A3 BC354200 mov dword ptr ds:[4235BC],eax
//根据main函数特性,此处为函数_setargv()调用
00401225 |. E8 C61F0000 call ProgramE.004031F0
//根据main函数特性,此处为函数_setenvp()调用
0040122A |. E8 711E0000 call ProgramE.004030A0
//根据main函数特性,此处为函数_cinit()调用
004012FF |. E8 8C1A0000 call PromgramE.00402CC0
00401234 |. 8B0D 00364200 mov ecx,dword ptr ds:[423600]
0040123A |. 890D 04364200 mov dword ptr ds:[423604],ecx
00401240 |. 8B15 00364200 mov edx,dword ptr ds:[423600]
//压栈传参,环境变量信息
00401246 |. 52 push edx
00401247 |. A1 F8354200 mov eax,dword ptr ds:[4235F8]
//压栈传参,命令行参数信息
0040124C |. 50 push eax
0040124D |. 8B0D F4354200 mov ecx,dword ptr ds:[4235F4]
//压栈传参,命令行参数个数
00401253 |. 51 push ecx
//此处为卖弄函数的调用出,跟进到函数中便是main函数的实现代码流程
00401254 |. E8 ACFDFFFF call ProgramE.00401005

总结一下,一般我们用od的时候,来到的应该是mainCRTStartup的位置,那么我们寻找main函数的时候,我们需要去找到相应的调用前的函数的流程结束,从GetVersion()获取平台版本号 _heap_init初始化堆空间 GetCommandLineA获取命令行参数信息的首地址,_crtGetEnvironmentStringA获取环境变量信息的首地址,_setargv得到命令行参数个数,以及命令行参数信息,_setenvp获取环境变量字符串的首地址 _cinit全局数据和浮点寄存器初始化,之后main函数所需要的参数压入堆栈,调用main

第四章-观察各种表达式的求值过程

4.1 算数运算和赋值

看书的时候感觉,这个位置好像在哪里学过,想了海哥有讲过,这里稍微说一下,举一个例子,赋值运算类似于数学中的等于,本质就是将一个内存空间的数据传递到另一个内存空间中,但是内存没有处理器那种的控制能力,所以各个内存单元之间是无法直接传递数据的,必须用过处理器访问并中转,才可以实现两个内存单元之间的数据传输(cpu里的寄存器包括通用寄存器、专用寄存器和控制寄存器),正常来说,算术运算与传递计算结果的代码组合才可以被看作是一个有效的语句,单独的运算会被视为空语句,应该在上周测试过了,我再测试一下

image-20200428141456757

看到了吗,如果算术运算,没有赋值的结合是一条无意义的语句,下面的就直接压在了ebp-0x4局部变量那个位置

各种算术运算的工作形式

加法:

加法运算对应的汇编指令 ADD,执行加法运算的时候,针对不同的操作数,转换的指令也会不一样,编译器会根据优化方式选择比较好的匹配方案,VC++ 6.0中常用的优化方案有两种方案:

  • 1方案:生成文件占用空间最小
  • 2方案:执行效率最快

Release编译选项组的默认选项为2方案 (执行效率优先) ,在Debug编译选项组中,使用的是Od+ZI选项,这个选项使编译器产生的代码都便于调试,为了便于单步跟踪,以及源码和目标代码块的对应阅读,从而可能增加冗余代码,在不影响调试的前提下,尽可能的优化

加法运算——Debug版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//C++源码说明:加法运算
//无效语句,不参与编译
15+20
//变量定义
int nVarOne = 0;
int nVarTwo = 0;
//变量加常量的加法运算
nVarOne = nVarOne + 1;
//两个常量加法的加法运算
nVarOne = 1 + 2;
//两个变量相加的加法运算
nVarOne = nVarOne + nVarTwo
printf("nVarOne = %d \r\n", nVarOne);

//C++源码与对应汇编代码

//C++源码对比,变量赋值
int nVarOne = 0;
//将立即数0,传入地址ebp-0x4中,即变量nVarOne所在的地址
00401028 mov dword ptr [ebp-4],0
//C++源码对比,变量赋值
int nVarTwo = 0;
0040102F mov dword ptr [ebp-8],0
//C++源码对比,变量 + 常量
nVarOne = nVarOne + 1;
//取出变量nVarOne数据放入eax中
00401036 mov eax,dword ptr [ebp-4]
//对eax执行加等于1运算
00401039 add eax,1
//将结果返回变量nVarOne中,完成加法运算
0040103C mov dword ptr [ebp-4],eax
//C++源码对比,常量 + 常量
nVarOne = 1 + 2;
//这里编译器直接计算出了两个常量相加后的结构,放入变量nVarOne中
0040103F mov dword ptr [ebp-4],3
//C++源码对比,变量 + 变量
nVarOne = nVarOne + nVarTwo;
//使用ecx存放变量nVarOne
00401046 mov ecx,dword ptr [ebp-4]
//使用ecx对变量nVarTwo执行加等于操作
00401049 add ecx,dword ptr [ebp-8]
//将结果存入地址ebp-4处,即变量nVarOne
0040104C mov dword ptr [ebp-4],ecx

总结一下:

  • 两个常量相加的情况下,编译器在编译期间就计算出两个常量相加后的结果,将这个结果作为立即数参与运算,减少了运行期间的计算
  • 变量参与加法运算的时候,会取出内存中的数据,放入通用寄存器,再通过加法的指令来完成计算的结果,最后存入内存空间中

如果我们开启Release的时候,会发生比较大的变化,我记得这里我和小哲哲研究过好像,由于效率优先,编译器会把无用的代码去除,并将可合并的代码进行归并处理,我们测试一下刚才的那一段Debug的那段代码

擦!我的vc++ 6.0 release 不好使!

image-20200428160856279

可以看到,类似于nVarOne = nVarOne +1 这样的代码会被删除,因为后面对其进行了重新赋值操作,所以编译器判定这句话的代码是可以被删除的,可以看到唯一的add是进行的参数平衡,并没有源码中的加法运算,在编译过程中,编译器常常会采用 常量传播常量折叠这样的方案对代码中的变量与常量进行优化

常量传播

将编译期间可计算出的结果的变量转换成常量,这样就减少了变量的使用

1
2
3
4
void main(){
int nVar = 1;
printf("nVar = %d \r\n", nVar);
}

image-20200428162906092

变量nVar是一个在编译期间可以计算出结果的变量,因此,程序中引用nVar的地方直接引用常量1,代码等于

1
2
3
void main(){
printf("nVar = %d \r\n",1);
}

image-20200428175354770

反汇编的release的改变代码和上面的一样,那么结论就是正确的

常量折叠

当计算公式中出现,多个常量计算的时候,编译器会在编译期间计算出结果时,源码中所有的常量计算都会被计算结果代替,测试代码

1
2
3
4
void main(){
int nVar = 1 + 5 - 3*6;
printf("nVar = %d \r\n", nVar);
}

image-20200428180821687

我们看到release版本的反汇编,我们看到将值也是直接算了出来 push 0FFFFFFF4h,从而推断一下,在编译过程中计算出来的计算出 -12 从而代替原来表达式,替换代码1:

1
2
3
4
void main(){
int nVar = -12;
printf("nVar = %d \r\n", nVar);
}

替换代码2(最终等价):

1
2
3
void main(){
printf("nVar = %d \r\n", -12);
}

测试release的代码想法

我们对常量折叠和常量传播了解过后,就知道上面Debug的时候,为什么直接push 3了,那么我们按照自己想法,做一个测试,如果变量的值是命令行参数argc,那么argc在编译期间无法确定,所以编译器无法在提前计算出结果,那么变量就不会被编译器计算出的常量替换掉,测试代码

1
2
3
4
5
6
7
8
9
int main(int argc, char* argv[])
{
int nVarOne = argc;
int nVarTwo = argc;
nVarOne = nVarOne + 1;
nVarOne = 1 + 2;
nVarOne = nVarOne + nVarTwo;
printf("nVarOne = %d \r\n", nVarOne);
}

image-20200428190325576

我们只看到了一个参数变量偏移,而我们应该定义了两个局部变量都没了

1
2
3
4
5
6
7
8
9
10
11
int main(int argc, char* argv[])
{
int nVarOne = argc; //在后面的代码中被常量代替
int nVarTwo = argc; //虽然不能用常量代替,但是由于后面没有对nVarTwo进行修改,所以nVarTwo等价于引用argc,nVarTwo被删除掉:叫做复写传播
nVarOne = nVarOne + 1; //后面对nVarOne赋值了,所以删除了这句话
nVarOne = 1 + 2; //常量折叠,等价于nVarOne = 3
nVarOne = nVarOne + nVarTwo; //常量传播加复写传播,nVarOne = 3 + argc
printf("nVarOne = %d \r\n", nVarOne);
//后面没有nVarOne被访问,所以用3+argc代替
printf("nVarOne = %d")
}

总结: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//C++源码说明:减法运算
//变量定义
int nVarOne = argc;
int nVarTwo = 0;
//获取变量nVarTwo的数据,使用scanf防止被常量化
scanf("%d",&nVarTwo);
//变量减常量的减法运算
nVarOne = nVarOne - 100;
//减法与加法混合运算
nVarOne = nVarOne + 5 - nVarTwo;
printf("nVarOne = %d \r\n",nVarOne);

//C++源码对应汇编代码讲解
//C++源码对比,变量 - 常量
nVarOne = nVarOne - 100;
//取变量nVarOne的数据到eax中
00401125 mov eax,dword ptr [ebp-4]
//是用减法指令sub,对eax执行减等于100操作
00401128 sub eax,64h
//将结果赋值回nVarOne中
0040112B mov dword ptr [ebp-4],eax
//C++源码对比,减法与加法混合运算
nVarOne = nVarOne +5 - nVarTwo;
//按照自左向右顺序依次执行
0040112E mov ecx,dword ptr [ebp-4]
00401131 add ecx,5
00401134 sub ecx,dword ptr [ebp-8]
00401137 mov dword ptr [ebp-4],ecx
//printf函数调用显示略

image-20200428205809617

总结:实际分析中,根据加法操作数的情况,当加数为负数的时候,执行的并不是加法,而是减法的操作,release和加法同就不说了

乘法:

乘法运算对应的汇编指令为有符号imul和无符号mul两种,由于乘法指令的执行周期比较长,在编译过程中,编译器会先尝试将乘法转换成加法,或者使用移位等周期较短的指令,当他们都不可以转换的时候,才会使用乘法指令

乘法转换——Debug版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//C++源码说明,乘法运算
//防止被视为无效代码,将每条运算作为printf参数使用
//变量定义
int nVarOne = argc;
int nVarTwo = argc;
//变量乘常量(常量为非2的幂)
printf("nVarOne*15 = %d \r\n",nVarOne * 15);
//变量乘常量(常量值为2的幂)
printf("nVarOne*16 = %d",nVarOne * 16)
//两常量相乘
printf("2*2 = %d",2 * 2);
//混合运算
printf("nVarTwo *4 +5 = %d",nVarTwo * 4 + 5);
//两变量相乘
printf("nVarOne * nVarTwo = %d",nVarOne * nVarTwo);

//C++源码对应汇编
//C++源码对比,变量 * 常量
printf("nVarOne*15 = %d \r\n",nVarOne * 15);
0040B8A4 mov edx,dword ptr [ebp-4]
//直接使用有符号乘法指令imul
0040B8A7 imul edx,edx,0Fh

//C++源码对比 常量*常量(常量值为2的幂)
printf("nVarOne*16 = %d",nVarOne * 16)
0040B8B8 mov eax,dword ptr [ebp-4]
//使用左移运算代替乘法运算
0040B8BB shl eax,4

//C++源码对比,常量*常量
printf("2*2 = %d",2 * 2);
//在编译期间计算出2*2的结果,将表达式转换为常量值
0040B8CC push 4
0040B8CE push offest string "2 * 2 = %d"(0041ffac)
0040B8D3 call printf(0040B750)
0040B8D8 add esp,8

//c++源码对比,变量*常量+常量(组合运算)
printf("nVarTwo *4 +5 = %d",nVarTwo * 4 + 5);
0040B8DB mov ecx,dword ptr [ebp-8]
//利用lea指令完成组合运算
0040B8DE lea edx,[ecx*4+5]

//c++源码对比,变量*变量
printf("nVarOne * nVarTwo = %d",nVarOne * nVarTwo);
0040B90A mov ecx,dword ptr [ebp-4]
//直接使用有符号乘法指令
0040B90D imul ecx,dword ptr [ebp-8]

image-20200428223154371

image-20200428223212478

上面的代码大概👴都能搞明白,总结一下😋:在二进制数中乘以2的时候等同于位依次向左移动(shl)1位,假设十进制3的二进制数为0011,3乘2以后等于6,相当于0110,当乘数和被乘数都是未知变量的时候,无法运用优化的方式,这时候处理器不会优化处理,直接变成乘法指令完成乘法计算(imul)

我们可以看到这里面有几个地方用了lea指令,😡我也不太懂,于是乎了解一下嘛!分析了一下!如果我们乘法运算与加法的运算结合了编译器会采用lea指令来处理,我们按照猜想去测试一下

image-20200428223929170

测试一下我猜想的1 2 4 8为lea

image-20200428224044646

1不行,1不就相当于一个加法了,👴真是傻了

那么我们如果组合运算中的乘数,不等于2 4 8如何处理

1
2
3
4
5
6
7
8
9
//C++源码对比 变量*常量*常量 (乘数超过8)
printf("nVarTwo * 9 + 5 = %d",nVarTwo * 9 + 5);
0040B8F3 mov eax,dword ptr [ebp-8]
0040B8F6 imul eax,eax,9
0040B8F9 add eax,5
0040B8FC push eax
0040B8FD push offset string "nVarTwo * 9 + 5 = %d"(0041ff7c)
0040B902 call printf(0040b750)
0040B907 add esp,8

我们看一下release版本的

各类型乘法转换——release版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//IDA直接将参数作为局部变量使用
arg_0 = dword ptr 4
//保存环境
push esi
//取出参数变量存入esi中
mov esi,[esp+4+arg_0]
//经过优化后,将nVarOne*15先转化为 乘2加自身,相当于乘3
//eax = esi*2+esi = 3*esi
lea eax,[esi+esi*2]
//将上一步操作结果乘4加自身,等同于乘15
//eax = eax * 4 + eax = 5 * eax = 5 * (3*esi)
lea eax,[eax+eax*4]
push eax
push offset aNvaronel15D; "nVarOne * 15 = %d"
call _printf
// esi中的数据传送到ecx,esi中保存的为参数数据
mov ecx,esi
//将ecx中的数据左移4为,ecx乘以2^4
shl ecx,4
push ecx
push offset aNvarone15D; "nVarOne * 16 = %d"
call _printf
//两常量相乘直接转换常量值
push 4
push offset a22D; "2*2 = %d"
call _printf
//这句话等等同于lea edx,[esi*4+5]都是混合运算
lea edx,ds:5[esi*4]
push edx
push offset aNvartwo45D; "nVarTwo * 4 +5 =%d"
call _printf
//此处为乘数不等于2 4 8 情况,编译优化9进行分解:(nVarTwo*1+nVarTwo*8),这样就可以使用lea了
lea eax,[esi+esi*8+5]
push eax
push _offset aNvartwo95D; "nVarTwo * 9 +5 =%d"
call _printf
//此处为两个变量相乘,都是未知数,无忧化
mov ecx,esi
imul ecx,esi
push ecx
push offest aNvaroneNvartwo; "nVarOne * nVarTwo = %d"
call _printf
add esp,30h
pop esi

可以看到除了两个未知变量的乘法没有优化外,其它形式的乘法运算都可以进行优化

除法

除法计算约定,对应汇编指令分有符号idiv和无符号div两种,除法指令的执行周期比较长,效率比较低,所以编译器会想尽办法用其他运算指令代替除法指令,所以C++中的除法和数学中的除法不同,在C++中,除法运算补保留余数,有专门求取余数的运算(运算符为%),叫做取模运算,对于整数除法,C++的规定是仅仅保留整数部分,小数部分完全舍弃

计算机整数除法,a/b两个无符号整数相除,结果是无符号,两个有符号相除,结果是有符号,一个有符号,一个无符号相除,结果是无符号的,有符号数的最高位(符号位)被作为数据位对持,然后作为无符号数参与计算

对于除法而言,计算机面临着如何处理小数部分的问题,正常来说 7/2 = 3.5,而计算机而言,整数除法的结果必须为整数,对于3.5这样的,计算机取整数部分的方式有如下几种:

向下取整

根据整数值的取值范围,可以画出以下坐标轴:

image-20200429172745689

向下取整:就是取像负无穷方向最接近x的整数值,换而言之也就是取得不大于x的最大整数

例如:+3.5向下取整得到3;-3.5向下取整得到-4

数学描述中,这个符号有点打不出来,我直接画一下

image-20200429174930773

C语言中math.h中,定义了floor函数,作用就是向下取整,也叫为地板取整,向下取整的除法,除数为2的幂时,直接用右移指令 sar来完成

向上取整

所谓对x向上取整,就是取 正无穷方向接近x的整数值,取得不小于x的最小整数

例如:+3.5向下取整得到4;-3.5向下取整得到-3

数学描述中,这个符号有点打不出来,我直接画一下

image-20200429174329067

在C语言的math.h中定义了ceil函数,作用就是向上取整,称为天花板取整

向零取整

所谓对x向零取整,就是取得往0方向最接近x的整数值,换而言之也就是放弃小数部分

例如:+3.5向零取整得到3;-3.5向零取整得到-3

在我们的数学描述中,[x] 表示对 x向零取整

这三个问题都会存在一个定律,我直接用ipad写吧,这个画板不好写

image-20200429184749337

在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,👴就不举例子了,大家都会 很基础

直接说定理吧:

image-20200429215858565

image-20200429221612549

VC++ 6.0对除数为整形常量的除法的处理,如果除数是变量,则只能使用除法指令,如果除数为常量,就有了优化的余地,根据除数值的相关特性,编译器有对应的处理方式,测试一下除数为2的幂,非2的幂,负数等各类情况的处理方式,假设整形为4字节补码的形式

各类型除法转换——Debug版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
//C++源码说明:除法运算
//变量定义
int nVarOne = argc;
int nVarTwo = argc;
//两个变量做除法
printf("nVarOne / nVarTwo = %d", nVarOne / nVarTwo);
//变量除以常量,常量为2的1次方
printf("nVarOne / 2 = %d", nVarOne / 2);
//变量除以非2的幂
printf("nVarTwo / 7 = %d", nVarTwo / 7);
//变量对非2的幂取模
printf("nVarTwo % 7 = %d", nVarTwo % 7);
//变量除以常量,常量为2的3次方
printf("nVarOne / 8 = %d", nVarOne / 8);

//C++源码与对应汇编代码讲解
//C++源码对比,变量定义
int nVarOne = argc;
0040B7E8 mov eax,dword ptr [ebp+8]
0010B7EB mov dword ptr [ebp-4],eax
//C++源码对比,变量定义
int nVarTwo = argc;
0040B7EE mov ecx,dword ptr [ebp+8]
0040B7F1 mov dword ptr [ebp-8],ecx
//除法运算转换特性
//C++源码对比,变量 / 变量
printf("nVarOne / nVarTwo = %d", nVarOne / nVarTwo);
//取出被除数放入eax中
0040B7F4 mov eax,dword ptr [ebp-4]
//扩展高位(EDX:EAX,这里表示EDX,EAX连用表示64位数 )
0040B7F7 cdq
//两变量相除,直接使用有符号除法指令idiv
0040B7F8 idiv eax,dword ptr [ebp-8]
//eax保存商值,作为参数压栈,调用函数printf
0040B7FB push eax
0040B7FC push offset string "nVarOne / nVarTwo = %d" (00420034)
0040B801 call printf (0040B750)
0040B806 add esp,8
//C++源码对比,变量 / 常量 (常量值为2的1次方)
printf("nVarOne / 2 = %d", nVarOne / 2);
0040B809 mov eax,dword ptr [ebp-4]
0040B80C cdq
//自身减去扩展高位
0040B80D sub eax,edx
//和乘法运算类似,乘法是左移
0040B80F sar eax,1
//printf函数说明略
//C++源码对比,变量 / 常量 (非2的幂)
printf("nVarTwo / 7 = %d", nVarTwo / 7);
0040B81F mov eax,dword ptr [ebp-8]
00040B22 cdq
0040B823 mov ecx,7
//无忧化直接使用有符号除法指令idiv
0040B828 idiv eax,ecx
//printf函数说明略
//C++源码对比,变量 % 常量
printf("nVarTwo % 7 = %d", nVarTwo % 7);
0040B838 mov eax,dword ptr [ebp-8]
0040B83B cdq //执行 CDQ 后, CDQ 把第 31 bit 复制至 EDX 所有 bit
0040B83C mov ecx,7
//无忧化,直接使用有符号指令idiv
0040B841 idiv eax,ecx
//除法指令过后,余数保存在扩展为edx中
0040B843 push edx
//printf 函数说明略
//C++源码对比,变量 / 常量(常量值为2的3次方)
printf("nVarOne / 8 = %d",nVarOne / 8);
//取出被除数放入eax
0040B851 mov eax,dword ptr [ebp-4]
//扩展eax高位到edx
0040B854 cdq
//如果eax为负数
0040B855 and edx,7
//使用eax加edx,若eax为负数则加7,反之加0
0040B858 add eax,edx
//将eax右移3位
0040B85A sar eax,3
//printf函数说明略

👴这里最后的那步操作没太懂,我决定调试去看看咋回事

image-20200429231134465

我去试试正负数

image-20200429233828187

我们对除数为2的幂,进行分析

image-20200429235133925

那么上面的代码中拿出来仔细分析一下

1
2
3
4
5
6
7
8
9
//C++源码对比,变量 / 常量 (常量值为2的1次方)
printf("nVarOne / 2 = %d", nVarOne / 2);
0040B809 mov eax,dword ptr [ebp-4]
0040B80C cdq
//自身减去扩展高位
0040B80D sub eax,edx
//和乘法运算类似,乘法是左移
0040B80F sar eax,1
//printf函数说明略

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
2
3
4
5
6
7
8
9
10
11
12
13
//C++源码对比,变量 / 常量(常量值为2的3次方)
printf("nVarOne / 8 = %d",nVarOne / 8);
//取出被除数放入eax
0040B851 mov eax,dword ptr [ebp-4]
//扩展eax高位到edx
0040B854 cdq
//如果eax为负数
0040B855 and edx,7
//使用eax加edx,若eax为负数则加7,反之加0
0040B858 add eax,edx
//将eax右移3位
0040B85A sar eax,3
//printf函数说明略

0040B854的cdq是符号扩展到高位edx,在0040B855处对edx做位与运算,当被除数为负数时,edx的值为7,在0040B858处的add eax,edx就是被除数为负数时加上2^n -1,不为负数则加0,最后sar右移完成除法

各类型输出转换——Release版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//IDA中的参数标识,经过优化后,省去了局部变量,直接使用参数
arg_0 = dword ptr 4
//变量 / 变量 和Debug版相同,此处省略
//......
//变量 / 常量 (常量值为2的幂)和Debug版相同,此处省略
//.....
//变量 / 常量 (常量值为非2的幂),这里的汇编代码和Debug版的汇编代码差别很大
//将数值 92492493h放到eax中
mov eax,92492493h
//有符号乘法,用esi乘以eax,esi中保存被除数
imul esi
//edx为扩展的高位
add edx,esi
//右移2位
sar edx,2
//结果放回eax
mov eax,edx
//将eax右移31次
shr eax,1Fh
//加以右移结果,放入edx中
add edx,eax
push edx
push offset aNvarTwo7D; "nVarTwo / 7 = %d"
call _printf
//其余代码和Debug版类似,略
//......

这个方式有点没看懂,出现了一个大数据0x92492493,我去搞一下数学证明:

image-20200501154537929

搞几个例子,反推一下吧,这样便于多方面的理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.text:00401000	_main proc near ; CODE XREF: start+AF p
.text:00401000 arg_0 = dword ptr 4
.text:00401000 mov ecx,[esp+arg_0]
.text:00401004 mov eax,38E38E39h
.text:00401000 imul ecx //ecx乘以参数
.text:0040100B sar edx,1 //有符号移位
.text:0040100D mov eax,edx
.text:0040100F shr eax,1Fh //无符号移位
.text:00401012 add edx,eax
.text:00401014 push edx
.text:00401015 push offset Format ; "%d"
.text:0040101A call _printf
.text:0040101F add esp,8
.text:00401022 retn
.text:00401022_main endp

看一下我们的.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,满足向零取,数学证明:

image-20200501165014099

所以我们可以反推出C代码

1
printf("%d",argc / 9);

总结:

1
2
3
4
5
6
7
mov	eax,MagicNumber
imul ...
sar edx,...
mov reg,edx
shr reg,1Fh
add edx,reg
//之后就直接用edx的值,eax的不用

遇到上面你的指令序列的时候,基本可以判定是除法优化后的代码,除法的原型为a/oimul是表明的有符号计算,操作数是优前的被除数a,右移的总次数确定n的值,用公式o= 2^n/c,将MagicNumber作为c代入公式求我们的除数o的近似值,四舍五入取整,就可以恢复除法原型!

👴再搞个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.text:00401080 _main proc near ; CODE xREF: start+AF p
.text:00401080 arg_0 = dword ptr 4
.text:00401080 mov ecx,[esp+arg_0]
.text:00401084 mov eax,24924925h
.text:00401089 mul ecx
.text:0040108B sub ecx,edx
.text:0040108D shr ecx,1
.text:0040108F add ecx,edx
.text:00401091 shr ecx,2
.text:00401094 push ecx
.text:00401095 push offset Format
.text:0040109A call _printf
.text:0040109F add esp,8
.text:004010A2 xor eax,eax
.text:004010A4 retn
.text:004010A4 _ma in endp

我们再去用公式推导一下:

image-20200501180545772

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
2
3
4
5
6
7
mov eax,MagicNumber
//reg表示通用寄存器
mul reg
sub reg,edx
shr reg,1
add reg,edx
shr reg,A //这句没有,那么n就是1,否则这里就是n-1的值

如果遇到上面的指令序列,基本可以判定出发优化后的代码,其除法原型为a/常量o,mul代表的无符号计算,用公式o = 2^(32+n)/(2^32+c)将MagicNumber作为c值代入公式求解常数除数o,即可恢复除法原型

那么我们返回去深度分析最开始的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//IDA中的参数标识,经过优化后,省去了局部变量,直接使用参数
arg_0 = dword ptr 4
//变量 / 变量 和Debug版相同,此处省略
//......
//变量 / 常量 (常量值为2的幂)和Debug版相同,此处省略
//.....
//变量 / 常量 (常量值为非2的幂),这里的汇编代码和Debug版的汇编代码差别很大
//将数值 92492493h放到eax中
mov eax,92492493h
//有符号乘法,用esi乘以eax,esi中保存被除数
imul esi
//edx为扩展的高位
add edx,esi
//右移2位
sar edx,2
//结果放回eax
mov eax,edx
//将eax右移31次
shr eax,1Fh
//加以右移结果,放入edx中
add edx,eax
push edx
push offset aNvarTwo7D; "nVarTwo / 7 = %d"
call _printf
//其余代码和Debug版类似,略
//......

编译器在计算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),以使除法优化成乘法,提高效率

image-20200512180523744

手动的推导过程,图片中的证明表示:被除数(正数)乘以magic_number后再右移n位,即为除法的结果;如果是负数需要 +1,最后统一减去符号扩展形成的数即可,也就是高位存放符号位的地方

https://ctftime.org/task/5294?tdsourcetag=s_pcqq_aiomsg

image-20200512181826245

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ cat foo.c
long long div(long long x) {
return x / N;
}
$ gcc -DN=$N -c -O2 foo.c
$ objdump -d foo.o

foo.o: file format elf64-x86-64


Disassembly of section .text:

0000000000000000
:
0: 48 89 f8 mov %rdi,%rax
3: 48 ba 01 0d 1a 82 9a movabs $0x49ea309a821a0d01,%rdx
a: 30 ea 49
d: 48 c1 ff 3f sar $0x3f,%rdi
11: 48 f7 ea imul %rdx
14: 48 c1 fa 30 sar $0x30,%rdx
18: 48 89 d0 mov %rdx,%rax
1b: 48 29 f8 sub %rdi,%rax
1e: c3 retq
$ echo “HarekazeCTF{$N}” > /dev/null

手算吧!

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

image-20200512194355299

974873638438446

算术结果溢出

假设如果我们占据4字节32位内存空间数据进行运算,得到的结果超过了存储空间大小,就会产生溢出

int型数据0xFFFFFFFF加2就会得到超出int类型的存储范围,超出的部分就叫溢出数据,溢出的数据不能保存,会丢失,有符号数来说,原数据是负数,溢出后表示符号的最高位被进位,原来的1变成0,那么负数也被变成了正数0

image-20200512210122860

一个无符号数产生溢出后会从一个最大数变成最小数,有符号数溢出会修改符号位

1
2
3
4
//利用溢出跳出循环
for(int i = 1 ; i > 0 ; i++){
printf("%d\r\n",i)
}

int的类型i是一个有符号数,当i等于它允许取到的最大整数值0x7FFFFFFF时,再加上1,数值会产生进位,将符号位0修改为1,最终结果为0x80000000,这时候最高位为1,有符号来说是一个负数,也就是最大的负数,for直接跳出循环

  • 进位

    无符号超出存储范围叫做进位,因为没有符号位,不会破坏数据,而多出的1位数据在进位标志为CF保存,数据产生了进位,只是进位后的1位数据1不在自身的存储空间,而在标志的CF位的位置,通过观察CF位来判断无符号的进位情况

  • 溢出

    有符号数超过存储的范围叫做溢出,因为数据进位,从而破坏了有符号的最高位——符号位,只有有符号数才有符号位,针对有符号数,可以查看溢出标志位OF,从而检查数据是否溢出

自增和自减

vc6.0 使用 ++ – 来实现自增自减的操作,一般形式分为两种,一种为自增运算符在语句块之后,另一种是自增运算符在语句块之前,如果在之前,先执行自增自减,在执行语句块,如果在之后,先执行语句块在自增自减,直接看汇编代码吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//自增自减代码
//C++源码说明:除法运算
//变量定义并出初始化
int nvarOne = argc;
int nvarTwo = argc;
//变量后缀自增参与表达式运算
nvarTwo = 5 + (nvarOne++);
//变量前缀自增参与表达式运算
nvarTwo = 5 + (++nvarOne);
//变量后缀自减参与表达式运算
nvarone = 5 + (nvarTwo--);
//变量前缀自减参与表达式运算
nvarone = 5 + (--nvarTwo);

//c++源码对应汇编
//变量对应初始化

//后缀自增运算
nvarTwo = 5 + (nvarTwo++);
//取出变量nvarOne,保存在edx中
0040BA34 mov edx,dword ptr [ebp-4]
//讲edx执行加等于5
0040BA37 add edx,5
//讲edx赋值给变量nvarTwo,可以看到没有对变量nvarOne执行自增操作
0040BA3A mov dword ptr [ebp-8],edx
//再次取出变量nvarOne数据存入eax中
0040BA3D mov eax,dword ptr [ebp-4]
//执行eax加等于1
0040BA40 add eax,1
将eax赋值给变量nvarOne,等同于对变量nvarOne执行自增1操作
0040BA43 mov dword ptr [ebp-4],ecx

//c++源码对比,前缀自增运算
nVarTwo = 5 + (++nvarOne);
//取出变量nvarOne数据放入ecx中
0040BA46 mov ecx,dword ptr [ebp-4]
//对ecx执行加等于1的操作
0040BA49 add ecx,1
//将ecx赋值给变量nvarOne,完成自增1操作
0040BA4C mov dword ptr [ebp-4],ecx
//取出变量nvarOne放到edx中
0040BA4F mov edx,dword ptr [ebp-4]
//对edx执行加等于5
0040BA52 add edx,5
//将结果edx赋值给变量nvarTwo
0040BA55 mov dword ptr [ebp-8],edx
//自增和自减差不多相同,add替换成sub就可

上面的汇编可以看出来,他们会把操作分离,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//C++源码说明:递归函数,用于计算整数累计,nNumber为累加值
int Accumulation(int nNumber){
//当nNumber等于0时,逻辑与运算符左边的值为假,不会执行右边的语句
//形成表达式短路,从而找到递归出口
nNumber && (nNumber += Accumulation(nNumber - 1));
return nNumber
}
//对应汇编
int Accumulation(int nNumber){
nNumber && (nNumber += Accumulation(nNumber - 1));
//这里为短路模式汇编代码,比较变量nNumber是否等于0
0040BAA8 cmp dword ptr [ebp+8],0
//通过JE跳转,检查ZF标记位等于1跳转
0040BAAC je Accumulation+35h (0040bac5)
//跳转失败,进入递归调用
0040BAAE mov eax,dword ptr [ebp+8]
//对变量nNumber减1后,结果作为参数压栈
0040BAB1 sub eax,1
0040BAB4 push eax
//继续调用自己,形成递归
0040BAB5 call @ILT+30(Accumulation) (00401023)
0040BABA add esp,4
0040BABD mov ecx,dword ptr [ebp+8]
0040BAC0 add ecx,eax
0040BAC2 mov dword ptr [ebp+8],ecx
//返回变量nNumber
return nNumber
0040BAC5 mov eax,dword ptr [ebp+8]
}

测试一下

image-20200605085223132

image-20200605085446570

所以通过递归函数Accumulation进行了整数的累加和计算,之后用&&的原则,左边如果为0就输出出去,用这个原则来决定跳转的过程,逻辑运算 || 与 && 有些不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//c++源码说明,和上面的类似
int Accumulation (int nNumber){
//当nNumber等于0时,逻辑或运算符的左边如果为真就不执行右边的语句
//形成表达式短路,从而找到递归的出口
(nNumber == 0) || (nNumber += Accumulation(nNumber - 1));
return nNumber;
}
//C++源码与对应汇编代码
int Accumulation(int nNumber){
(nNumber == 0) || (nNumber += Accumulation(nNumber - 1));
00401618 cmp dword ptr [ebp+8],0
0040161C je Acumulation+35h (00401635)
0040161E mov eax,dword ptr [ebp+8]
00401621 sub eax,1
00401624 push eax
00401625 call @ILT+30(Accumulation) (00401023)
0040162A add esp,4
0040162D mov ecx,dword ptr [ebp+8]
00401630 add ecx,eax
00401632 mov dword ptr [ebp+8],ecx
return nNumber
00401635 mov eax,dword ptr [ebp+8]
}

条件表达式

条件表达式就是我们说的三目运算,也就是 表达式1 ?表达式2 :表达式3

表达式2 ,3为常量的时候,条件表达式可以被优化,而表达式2或者表达式3中的一个为变量的时候,条件表达式不可以被优化,会转换成分支结构,而表达式1为一个常量值,编译器会在编译期间得到答案,就不会有条件表达式存在

  • 表达式1为简单比较,而表达式2,3的差值等于1
  • 表达式1为简单比较,而表达式2,3的差值大于1
  • 表达式1为复杂比较,而表达式2,3的差值大于1
  • 表达式2,3有一个为变量,于是没有优化

转换方案1

1
2
3
4
5
6
7
8
9
10
11
12
13
//c++源码说明,条件表达式
int Condition(int argc , int n){
//比较参数argc是否等于5,真值返回5,假值返回6
return argc == 5 ? 5 : 6;
}
//汇编比较
//清空eax
00401678 xor eax,eax
0040167A cmp dword ptr [ebp+8],5
//setne检查ZF标记位,当ZF==1,则赋值al为0,反之则赋值al为1
0040167E setne al
//若argc等于5则al==0,反之al==1,执行这句后,eax正好为5/6
00401681 add eax,5

测试的时候

image-20200605104331427

大概读了一下,这个用了两个mov进行付给eax,之后跳转到各自的位置上,进行一个赋值的

转换方案2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//c++源码说明,条件表达式
int Condition(int argc , int n){
//比较参数argc是否等于5,真值返回5,假值返回6
return argc == 5 ? 4 : 10;
}
//汇编比较
return argc == 5 ? 4 : 10;
00401678 mov eax,dword ptr [ebp+8]
0040167B sub eax,5
0040167E neg eax
00401680 sbb eax,eax
//eax的取值只可能为0或者0xFFFFFFFF
00401682 and eax,6
00401685 add eax,4

对于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
2
3
4
5
6
7
总结:
sub reg,A
neg reg
sbb reg,reg
and reg,B
add reg,C
//reg == A ? C : B

转换方案3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//C++源码说明,条件表达式
int Condition(int argc,int n){
return argc < = 8 ? 4 :10;
}
//汇编代码
return argc < = 8 ? 4 :10;
//清空eax,与方案1类似
00401678 xor eax,eax
0040167A cmp dword ptr [ebp+8],8
//根据变量与8进行比较的结果,使用setg指令,当标记位SF=OF且ZF=0赋值al为1
符号标志位SF(Sign Flag),符号标志SF用来反映运算结果的符号位,他与运算结果的最高位相同
溢出标志位OF(Overflow Flag):比如有一个杯子,放水放满了再放就出去了,叫溢出,但是怎么区别看起来都是最高位的问题
零标志位ZF(Zero Flag),零标志ZF用来反映运算结果是否为0,如果运算结果为0,则值为1,否则值为0,在判断运算结果是否为0时,可以用此标志位
//用于检查变量数据是否大于8,大于则赋值1,小于就赋值0
0040167E setg al
//此时al中只能为0或1,执行自减的操作,eax中为0xFFFFFFFF或0
00401681 dec eax
//使用al和0xFA做与运算,eax中是0xFFFFFFFA或者0
//2-3=0xFFFFFFFA
00401682 and al,0FAh
//由于eax只能有两个结果0xFFFFFFFA(-6)或0,加0x0A后结果比然为4,10
00401684 add eax,0Ah

无忧化使用分支结果

如果表达式2或者表达式3中的值为未知数时候,就无法使用之前的方案去优化,编译器会那招正常的语句流程进行比较和判断,选择对应的表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//C++源码说明:条件表达式
int Condition(int argc,int n){
return argc ? 8 : n;
}
//汇编代码
return argc ? 8 : n;
//比较变量argc
00401448 cmp dword ptr [ebp+8],0
//使用JE跳转,检查变量argc是否等于0,跳转的地址为0x00401457位置
004014CC je Condition+27 (00401457)
//跳转失败说明操作数1为真,将表达式1的值(立即数8)存入局部变量ebp-4中
0040144E mov dword ptr [ebp-4],8
//跳转到返回值赋值处
00401455 jmp Condition+2Dh (00145d)
//参数2的数据存入eax中
00401457 mov eax,dword ptr [ebp+0Ch]
0040145A mov dword ptr [ebp-4],eax
0040145D mov eax,dword ptr [ebp-4]
//恢复现场
00401466 ret

如果经过O2选项优化的Release版中,这些代码都会被编译为分支结构

4.3 位运算

<<:左移运算,做高位左移到CF位置,最低位补0

>>:右移运算,最高位不变,最低位右移到CF中

|:位或运算,两个数的相同位上,只要有一个为1,则结果为1

&:位与运算,在两个数的相同位上,只要同时为1时,结果才为1

^:异或运算,在两个数的相同位上,当两个值相同时为0,不同时为1

~:取反运算,将操作数每一位上的1变0,0变1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//C++源码对应汇编,位运算(有符号数)
//左移运算3次
argc = argc << 3;
00401498 mov eax,dword ptr [ebp+8]
//左移运算对应汇编指令SHL
0040149B shl eax,3
0040149E mov dword ptr [ebp+8],eax
//C++源码对比,右移运算5
argc = argc >> 5;
004014A1 mov ecx,dword ptr [ebp+8]
//右移运算对应汇编指令SAR
004014A4 sar ecx,5
004014A7 mov dword ptr [ebp+8],ecx
//C++源码对比,位或运算,变量argc低16位不变,高16位设置为1
argc = argc | 0xFFFF0000
004014AA mov edx,dword ptr [ebp+8]
//位或运算对应汇编指令OR
004014AD or edx,0FFFF0000h
004014B3 mov dword ptr [ebp+8],edx
//C++源码对比,将变量argc低16位清0,高位不变
argc = argc & 0xFFFF0000
//位与运算对应汇编指令AND
004014B9 and eax,0xFFFFh
004014BE mov dword ptr [ebp+8],eax
//C++源码对比,对变量argc做异或运算
argc = argc ^ 0xFFFF0000
004014C1 mov ecx,dword ptr [ebp+8]
//异或运算对应汇编指令XOR
004014C4 xor ecx,0FFFF0000h
004014CA mov dword ptr [ebp+8],ecx
//C++源码对比,将argc按位取反
argc = ~argc;
004014CD mov edx,dword ptr [ebp+8]
//取反运算对应汇编指令NOT
004014D0 not edx
004014D2 mov dword ptr [ebp+8],edx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//C++源码说明:无符号数位移
int BitOperation(int argc){
unsigned int nVar = argc;
nVar <<= 3;
nVar >>= 5;
}
//C++源码对应汇编代码讲解
unsigned int nVar = argc;
004016C8 mov eax,dword ptr [ebp+8]
004016CB mov dword ptr [ebp-4],eax
//C++源码对比,对变量nVar左移3位
nVar <<= 3;
004016CE mov ecx,dword ptr [ebp-4]
//和有符号数左移一样
004016D1 shl ecx,3
004016D4 mov dword ptr [ebp-4],ecx
//C++源码对比,对变量nVar右移5位
004016D7 mov edx,dword ptr [ebp-4]
//使用shr进行右移位,最高位补0,最低位进CF
004016DA shr edx,5
004016DD mov dword ptr [ebp-4],edx

左移的时候,有符号数和无符号数(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 ) 优化

流水线优化规则

指令工作流程

  1. 取指令:CPU从高速缓存或内存中取机器码
  2. 指令译码:分析指令的长度,功能和寻址方式
  3. 按寻址方式确定操作数:指令的操作数可以是寄存器,内存单元或者立即数,如果操作数在内存单元里,这一步就要计算出有效地址
  4. 取操作数:按操作数存放的位置获得数值,并存放在临时寄存器中
  5. 执行指令:由控制单元或者计算单元执行指令规则的操作
  6. 存放计算结果

假设

1
2
执行指令: add	eax,dword ptr ds:[ebx+40DA44]
对应的机器代码: 0383 44DA4000

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
2
004010AA	mov	eax,92492493h
004010AF add esp,8

我们的处理器会先读取004010AA处的二进制指令,之后开始进行译码等操作,这些工作的每一个步骤都是需要时间的,如果我们取指令,内存管理单元开始工作,其他的部件进行闲置等待,等拿到了指令才进行下一步的工作,于是我们为了提高效率,开始了流水线的这一个机制

因为流水线的机制,我们在执行mov eax,92492493h过程中对第二条的地址进行读取以及译码,进行并行处理,那么我们提高了处理器的工作的效率

指令相关性

如果后一条指令的执行依赖前一条指令的硬件资源,那么这样的情况就是指令相关

1
2
add	edx,esi
sar edx,2

两条指令都需要访问并设置edx,所以只能执行完add edx,esi后才能执行 sar edx,2,这样的情况产生了寄存器的争用,影响了效率

地址相关性

这个和上一个指令相关差不多,这个等待的是内存地址的争用

1
2
add	[00401234],esi
mov eax,[00401234]

由于第一条指令访问的是0x401234地址,那么只能第一条指令操作完后再去执行第二条语句,会影响效率,VC++的O2的release选项生成的代码会考虑流水线执行的工作方式

1
2
3
4
5
6
7
8
9
10
11
0040101F	push eax
00401020 push offset aNvarone2D ; "nVarOne / 2 = %d"
00401025 call _printf
0040102A mov eax,92492493h
0040102F add esp,8
00401032 imul esi
00401034 add edx,esi
00401036 sar edx,2
00401039 mov eax,edx
0040103B shr eax,1Fh
0040103E add edx,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
2
3
4
5
6
7
8
for(int i=0; i < 10; i++){
//下面每次循环会预测成功9999次
//第一次没有预测,最后退出循环的时候预测失败1次
//重复10次
for(int j = 0 ; j < 10000 ; j++){
a[i][j]++;
}
}
1
2
3
4
5
6
7
8
for(int i=0; i < 10000; i++){
//下面每次循环会预测成功9次
//第一次没有预测,最后退出循环的时候预测失败1次
//重复10000次
for(int j = 0 ; j < 9 ; j++){
a[i][j]++;
}
}

所以说大循环在里面可以增加分支预测准确度

高速缓存 ( cache ) 优化规则

计算机内存的访问效率低于处理器,而且在程序的运行中,于是乎处理器准备了 cache来存放需要经常访问的数据和代码,这些数据内存用VA以表格方式一 一对应,从处理器访问内存数据时候,要去cache中看看这个VA有没有记录,如果有,则名终,就不需要访问内存单元,如果没有找到,就转换VA访问数据,然后保存到cache中,一般来说,cache会把读取指令需要的数据以及附近的数据都读取进来,从而节省cache宝贵的空间,VA值的二进制低位不会被保存,所以保存的数据是2^n字节为单位的

cache优化:

  1. 数据对齐
  2. 数据集中
  3. 减少体积

第五章-流程控制语句的识别

5.1 if语句

if语句只能判断两种情况:"0"为假,"非0"为真,if语句转换的条件跳转指令与if语句的判断结果是相反的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//C++源码说明:if语句结构组成
if( argc == 0 ){
printf("%d \r\n", argc);
}
//C++源码对应汇编
//如果argc = 0的情况,为真
if( argc == 0 )
//使用cmp指令,将ebp+8地址的4字节和0相减
//结果argc不改变,淡水会改变CF,ZF,OF,AF和PF
00401028 cmp dword ptr [ebp+8],0
//JNE看ZF,如果等于0跳转,表示现在的argc不等于0
//跳转到0x40103F
//这个地址为if的结束地址,之后跳转出if
0040102C jne main+2F(0040103f)
{
//printf略
}
//函数返回
return 0;
0040103F xor eax,eax

JNE代表了,ZF=0 跳转,当前代码也就是argc不等于0就跳转,正常我们的c语言来说是满足if的条件执行语句块,汇编中是满足某条件进行跳转,与C语言相反

因为我们C语言中,是根据我们代码行的位置来决定编译后二进制的高低,也就是低行数对应低地址,高行数,对应高地址,那么根据这个特性,如果我们是 argc > 0 ,那么跳转的条件就是小于等于0是否满足,满足的不跳转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//C++源码说明: if语句大于0比较
if( argc > 0 ){
printf("%d \r\n",argc);
}
//C++源码对应汇编代码
//C++源码对比,如果参数argc大于0,结果为真,进入执行语句
if(argc > 0)
//使用cmp,将ebp+8地址的4字节数据和0进行减法
0040103F cmp dword ptr [ebp+8],0
00401043 jle MyIf + 42h (00401052)
{
//printf略
}
00401052 pop edi

我们在使用反汇编的时候,表达式短路和if语句想像,实现的结构类似

5.2 if…else…语句

两种结构的对比

image-20200607004352276

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//C++源码说明: if  else
if(argc == 0){
printf("argc = 0");
}
else{
printf("argc != 0");
}
//对应汇编
004010B8 cmp dword ptr [ebp+8],0 //相减
004010BC jne IfElse+2Dh (004010cd){
//printf 略
}
004010CB jmp IfElse+3Ah (004010da)
{
//进入else
004010CD push offset string "argc != 0" (00420030)
004010D2 call printf (00401150)
004010D7 add esp,4
}
//结束
004010DA pop edi

正常流程和if差不多,这里纠正了自己的一个思想,就是跳转的时候,也是比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//C++源码说明,if...else..模拟条件表达式转换方式
if(argc == 0){
argc = 5;
}
else{
argc = 6;
}
printf("%d \r\n",argc); //防止argc被优化处理
//对应汇编
if(argc == 0){
00401098 cmp dword ptr [ebp+8],0
0040109c jne main+27h(004010a7)
argc = 5;
0040109E mov dword ptr [ebp+8],5
}
else{
004010A5 jmp main+2Eh(004010ae)
argc = 6;
004010A7 mov dword ptr [ebp+8],6
}
printf("%d \r\n",argc);
//略
004010AE ...

所以说汇编还是比较好看的,记住一下JNE即可

1
2
3
4
5
6
7
8
9
10
总结结构:
//产生修改标志位的指令
jxx else_begin
if begin:
//执行if中的内容
if end:
jmp else end
else begin:
//执行else的内容
else end

两处跳转可以准确的划分一下哪里是else哪里是if,在我们else begin之前会有一个 jmp的指令,那么那里之后就是我们lelse块的开始,从而分析if else

O2优化选项,反汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arg_0 = dword ptr 4
//取到的参数数据放在edx中
mov edx,[esp+argc_0]
//将eax清0
xor eax,eax
//对edx和edx进行想与的操作,结果不影响edx
test edx,edx
//检查ZF的标记位,edx不等于0的时候,我们的al = 1,等于0 我们的al = 0
setnz al
add eax,5 //这里可能是5/6,看al了,我们不等于0,那么就是6,我们等于0,那么就是5
push eax
push offset Format ; "%d \r\n"
call _printf
add esp,8
retn

所以分析一下可以知道我们的优化后的代码也可以做到这个分支结构的选项,主要用了setnz这个指令

5.3 用if构成的多分支流程

总体来说就是我们经常看到的 if … else if … else if … else …

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//C++源码说明,多分支结构
void IfElseIf(int argc){
if (argc > 0){
printf("argc > 0");
}
else if (argc == 0){
printf("argc == 0");
}
else{
printf("argc <= 0");
}
}
//对应汇编
if(argc > 0)
00401108 cmp dword ptr [ebp+8],0
//相减 argc-0 之后使用小于等于跳转的条件,jle
0040110C jle IfElseIf+2Dh(0040111d) //到else位置,目测那边是一个cmp 一个jne{
printf("argc > 0");
//printf 略
0040110E push offset string "argc > 0"(00420f9c)
00401113 call printf(00401150)
00401118 add esp,4
}
else if ( argc == 0 )
0040111B jmp IfElseIf+4Fh(0040113f)
//if比较转换,和我上面目测的差不多一个cmp,一个jne
0040111D cmp dword ptr [ebp+8],0
00401121 jne IfElseIf+42h(00401132) //到下一个else的位置,直接就是执行了
{
printf("argc == 0");
//printf 略
00401123 push offset string "argc == 0"(0042003c)
00401128 call printf(00401150)
0040112D add esp,4
}
//跳转到多分支结构结束地址
00401130 jmp IfElseIf+4Fh(0040113f)
{
printf("argc < = 0");//上面都不满足,无条件执行
//printf略
00401132 push offset string "argc != 0" (00420030)
00401137 call printf (00401150)
0040113C add esp,4
}
0040113F pop edi

上面的大部分结构都是个 cmp jxx jmp cmp jxx jmp 这样类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
总结:
//影响标志位
jxx else if begin
if begin:
...
if end:
jmp end
else if begin:
//影响标志位
jxx else begin
...
else if end:
jmp end
else begin
...
end:
...

这个属于没有优化的版本,如果开了O2编译选项,,我们将会对永远不能达到的地方进行不执行,不参与编译处理

假设我们的argc = 0,我们看一下O2版本的

1
2
3
4
5
6
sub_401000 proc near
push offset Format; "argc == 0"
call _printf
pop ecx
retn
sub_401000 endp

我们把不可以达到的分支直接删除,从而只剩下我们的一个必达分支语句块

如果我们把单分支if结构,每个位置加上一个return语句,那么我们没有了else,减少了一次JMP跳转,使代码执行效率提高

1
2
3
4
5
6
7
8
9
10
11
12
void IfElseIf(int argc){
if (argc > 0){
printf("argc > 0");
return;
}
if (argc == 0){
printf("argc == 0");
return;
}
printf("argc <= 0");
return;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.text:00401000 sub_401000	proc near ; CODE XREF: _main+5p
//arg_0为函数参数
.text:00401000 arg_0 = dword ptr 4
//取出参数数据,放入eax,进行第一次if比较
.text:00401000 mov eax,[esp+arg_0]
.text:00401004 test eax,eax
//比较后进行转移,目测jle
.text:00401006 jle short loc_401016
//执行
.text:00401008 push offset Format ; "argc > 0"
.text:0040100D call _printf
.text:00401012 add esp,4
//使用retn指令返回,结束函数调用
.text:00401015 retn

//代表此处代码被符号sub_401000地址加6的地方引用
.text:00401016 loc_401006: ;CODE XREF: sub_401000+6j
//这里目测因为有test的问题 直接jne就可以
.text:00401018 push offset aArgc0_0 ; "argc == 0"
.text:0040101D call _printf
.text:00401022 add esp,4
//返回
.text:00401025 retn

//前两次失败没返回直接执行此处
.text:00401026 loc_401026: ; CODE XREF: sub_401000:loc_401016j
.text:00401026 push offset aArgc0_1; "argc <= 0"
.text:0040102B call _printf
.text:00401030 pop ecx
.text:00401031 retn
.text:00401031 sub_401000 endp

可以看出来大大的节省了效率

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在索引表变化成一样的数值,这样就可以直接跳转到指定位置,截取书上的图比较明了image-20200609212845509

image-20200609212900843

5.6 判定树的高度

这里为差值大于255时候,会把switch的方法变成二叉树判定树,取中间节点,进行左边以及右边case的比较

image-20200609213051222

二叉判定树的左右树根据我们节点的多少可以转换成相对应的合适结构,比较左树 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时候形成栈帧,先进后出的原则,结束调用关闭栈帧,栈平衡

这里有一个之前遗留的不太完全的知识点,就是

image-20200609230004452

image-20200609230015052

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进行寻址,节约资源

image-20200611224827025

这里的esp+18h+var_5 也就是相当于了 原来的 esp+10+var_5,画一下图

image-20200611225628822

正数标号法和负数标号法,上面画的图是我们的负数标记法,我们的正数标记法,我们就把正常的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

image-20200612145608678

所以 ebp + 4是返回地址,ebp + 8 是参数

image-20200612153911113

当前函数代码进行了一个,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 局部静态变量的工作方式

image-20200613220108269

进行测试了一个静态局部变量,static静态局部变量的时候只会进行一次操作,直接来看汇编

image-20200613220151501

进行的操作就是,在内存中会有一个特定的位置进行储存一下校验的位置,这里也就是0x427e38,那么第一次我们的static 局部变量没有被赋值的时候,他的al是0,用来记录,才会直接后面的jne下面的位置ZF这里是1,所以第一次赋值了我们要输入的值,当二次,第三次的时候,al变成1,所以进行校验的时候,会告诉编译器,我们的值已经被赋值过了,直接jne跳转,于是就是图上的效果

结论:static 静态局部变量只赋值一次,标记位只用了一位,说明一个字节有8位,一个字节可以判断8个静态局部变量

image-20200613224924282

如果直接给常量,编译器不会进行校验等等,直接不判断了,因为会一直等于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保存堆数据的数组

image-20200614001623870

这里了解了一个知识点: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#define nNoMansLandSize 4

typedef struct _CrtMemBlockHeader
{
struct _CrtMemBlockHeader * pBlockHeaderNext;
struct _CrtMemBlockHeader * pBlockHeaderPrev;
char * szFileName; //存储的发起分配操作的那行代码所在的文件的路径和名称
int nLine; //行号
#ifdef _WIN64
/* These items are reversed on Win64 to eliminate gaps in the struct
* and ensure that sizeof(struct)%16 == 0, so 16-byte alignment is
* maintained in the debug heap.
*/
int nBlockUse;
size_t nDataSize;
#else /* _WIN64 */
size_t nDataSize;
int nBlockUse;
#endif /* _WIN64 */
long lRequest; //请求号
unsigned char gap[nNoMansLandSize];
/* followed by:
* unsigned char data[nDataSize];
* unsigned char anotherGap[nNoMansLandSize];
*/
} _CrtMemBlockHeader;

如果我们的某个堆空间释放了,那么我们再申请的时候会检查这个被释放的堆空间是否可以满足用户的要求,如果满足就再次申请的堆空间地址就是我们刚释放的对空间地址,形成回收空间的再次利用

第八章-数组和指针的寻址

8.1 数组在函数内

数组在函数的局部变量中的形式是从低地址像高地址传送,正常的连续变量是从高地址向低地址传送,这里了解了一下,字符串数组

image-20200614132631494

假设这个hello world 会把4*3=12字节的数据 分别给了 eax,ecx,edx进行存储,如果不是4的倍数

image-20200614132808612

假设剩三个就会分配一下 word byte进行寄存器的传送赋值

8.2 数组作为参数

数组作为参数的时候也是地址进行的传递

这里很好的了解了一下 sizeof以及strlen的原理,sizeof是我们类型的大小长度,所以如果对指针或者形参保存的数组名使用sizeof的时候只能获得当前平台的指针长度,所以这里用strlen,这里的代码说的很明白直接截图了

image-20200614153244316

这里的repne scasb代表的是repne会将ecx自动每次减1,如果我们的edi指向了末尾的0,那么我们就可以得到长度

1
2
3
4
5
ecx2 = ecx1 - (len + 1)
ecx1 = -1
ecx2 = -1 -(len + 1) = -(len + 2)
not ecx2 + 1 = len + 2
len = not ecx2 - 1

总结来说,sizeof是算术符,而strlen是函数,这里进行 -1 的操作是因为在进行汇编来看把最后一个ASCII码等于0的\0也算进了总的长度,所以他补码后需要进行+1的操作他有多算了一位就是+2,那么我们操作等同于了not - 1 等于了 neg后的 - 2

strcpy的内联形式,我觉得书上给的源码对应挺不错的

image-20200615000813235

image-20200615000820212

大概就是一个 复制的一个过程,中间的 rep movsd 是ecx为次数,也就是 x / 4 = y ,那么想与是因为 除以4 mod = 3这个有限域所以与3

8.3 数组作为返回值

数组作为返回值的时候,因为esp要回到原来的位置,那么很有可能这数组里面的数据会被接下来的操作进行破坏,书上的图很简洁

image-20200615145145759

进行这个操作的时候会破坏数组的0x12FF18和0x12FF1C的数据空间,对数组进行破坏

避免这种方式:使用全局数组来进行这种方式,因为取的地址是固定的不在局部也不在参数位置,是在一个固定的地址中,不会被改变

静态局部数组

image-20200615151859946

测试了一下大概和静态局部变量差不多,无论局部静态数组有多少个元素也只检查一次,上次说的静态局部变量有两个就会检查两次等等的

8.4 下标寻址和指针寻址

数组下标寻址要比指针寻址的速度快一些,因为指针寻址的时候,进行了两次取地址的操作,但是指针比较灵活

数组下标寻址公式:nvar + sizeof(类型)*数组下标

整形常量寻址会直接计算好,整型变量寻址会根据公式来定,因为变量会暂时无法计算对应地址,表达式寻址,会先进行计算

因为不会对数组的下标进行访问检查,所以很容易导致越界的问题,当小于0或者大于数组下标的时候,就会越界,如下

image-20200615171455494

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进行一个转换,从而节省步骤

三维数组可以这么理解:

image-20200616112722525

image-20200616114448101

搞明白了这里的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 存放指针类型数据的数组

我们的指针类型的数组,这里举个例子比较通俗易懂

image-20200616140410913

这里代表了我们说的返回的应该是定义的指针数组,所以里面的每一个成员项是地址,地址里面存的是数据,所以输出的时候是取地址里面的数据,所以我们可以取出来是多少

8.7 指向数组的指针变量

这里引入一个公式:指针变量地址数据 + = (sizeof (指针类型) * 数值)

image-20200616163849205

这里是0x20书上是0x2C,不清楚书上为什么是0x2C有点蒙 = = 蒙了很久,先过我认为0x20是对的,最后一次ebp - 4 为 0 0 c c

直接30+2 = 32 = 0x20

8.8 函数指针

image-20200616173353234

函数指针的定义:

返回值类型 ([调用约定,可选] * 函数指针变量名称) (参数信息)

第九章-结构体和类

9.1 对象的内容布局

大概看了看C++的一个视频,大概了解了C++的一个 对象 类 属性 功能的问题,private为私有,public为公有,如果我们没有定义private,那么我们的第一个Class那边的默认为private

这里有一个公式挺好的:对象长度 = sizeof(数据成员1)+sizeof(数据成员2)+...+sizeof(数据成员n)

image-20200618095935478

这里测试的number1对象就是8个字节

上面的公式不适用于三种情况:

  • 空类:如果类的对象不占内存空间的话,那么会分配1字节的空间用于类的实例化,而不是0
  • 内存对齐:之前有写过纸质的笔记
  • 静态数据成员

9.2 this指针

举例:

1
2
3
4
5
6
7
struct A{
int m_int;
float m_float;
};
struct A a;
struct A *pa = &a;
printf("%p",&pa->m_float);

假设我们的变量a的地址为0x12ff00,那么我们的m_float在结构体A中的偏移为0x4,所以&pa - > m_float = 0x12ff00 + 0x4,总结一下公式就是:pa->m_float的地址 = 指针pa的地址 + m_float在A中的偏移

image-20200618103030462

这里可以看到我们的this指针,保存着我们的调用对象的首地址,利用寄存器ecx来保存对象首地址,以寄存器传参方式传递到成员函数中,所以说我们的非静态成员函数都有一个隐藏参数,自身类型的指针,这就是this指针,默认调用约定叫thiscall,hhhh今天才很好的了解this和thiscall,thiscall是c++专有的方式,如果我们用了stdcall或者其他的,那么this指针不用ecx传递了

image-20200618104708343

对象首地址保存在ecx中

thicall格式总结:

1
2
3
4
5
lea	ecx,[mem]
call FUN_ADDRESS
调用:
mov XXX,ecx
mov [reg+i],XXX

_stdcall与__cdecl调用方式的成员函数:

1
2
3
lea reg,[mem]
push reg
call FUN_ADDRESS

9.3 静态数据成员

image-20200618120720705

静态数据成员这里理解起来和静态局部变量可以理解成一个东西,因为他是在程序被加载的时候,他就会存在,所以说,这个时候,类没有对应的实例对象

image-20200618121700829

可以看到两者所属的内存地址空间不同,所以静态成员不参与长度的计算

9.4 对象作为函数参数

书上给了个对象属性很少的例子,这个时候,对象作为函数参数调用过去的时候,他们复制了一份数据过去,然后压入堆栈,测试结果

image-20200618205454245

这里并不是push ebp-4那些,而是经过给寄存器赋值进行的传参,传参顺序先定义的后传

又举了个数组的方式,这个对象作为参数和上面的类同就不说了,这里有说到析构和拷贝函数,日后再详细说,书上给了个对象作为参数的资源释放错误的例子(学完析构和拷贝回来研究一下)

9.5 对象作为返回值

image-20200618215712821

作为返回地址的时候,会把我们的对象的首地址的数据依次复制到返回地址下面,等我们后面的printf调用的时候,直接调用就可以了

image-20200618220008107

用ebp调用,放入寄存器传参即可

补上的东西

大概想了一下,这章有析构和构造函数,大概去学习了一下大概的东西

析构函数:

  • 只能有一个析构函数,不能重载
  • 不能带任何参数
  • 不能带返回值
  • 主要用于清理工作
  • 编译器不要求必须提供

构造函数

  • 与类同名
  • 没有返回值
  • 创建对象的时候执行
  • 主要用于初始化
  • 可以有多( 最好有一个无参的 ),称为重载,其他函数也可以重载
  • 编译器不要求必须提供

那么我就把书上自己没看懂的位置理解一下 ( 不欠债!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class cMyString{
public:
cMyString(){
m_pstring = new char[10];
if(m_pstring == NULL){
return;
}
strcpy(m_pstring,"hello");
}
~cMyString(){
if(m_pstring != NULL){
deletle m_pstring;
m_pstring = NULL;
}
}
char *GetString(){
return m_pString;
}
private:
char *m_pString;
};
void ShowMyString(cMyString MyStringCpy){
printf(MyStringCpy.GetString());
}
void main(){
cMyString MyString;
ShowMyString(MyString);
}

这边出现的问题,就是析构函数和成员函数的返回值进行了异常的问题,书上的图很好解释这个问题

image-20200618233212735

堆的首地址被释放,但是我们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的一些作用,上面我也有写道,子类::父类,微软不建议使用多重继承,首先我们看一下多层继承的图

image-20200620225942322

这个是我们多层继承,那么我们再看一下多重继承

image-20200620230018577

其实虽然我们用反汇编看起来都一样,但是多重继承来说它增加了程序的复杂度,容易出错,所以不推荐使用

这里有一个很好玩的地方就是,我们的对象的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
2
3
4
5
6
7
8
9
10
try{//异常检测
//执行代码
throw 异常类型; //抛出异常
}
catch( 捕获异常类型 ) { //异常捕捉
//处理代码
}
catch( 捕获异常类型 ){ //异常捕获
//处理代码
}
  1. 在函数入口处设置异常回调函数,其回调函数先设置eax为FuncInfo数据的地址,然后跳往_CxxFrameHandler
  2. 异常的跑出由_CxxThrowException函数完成,该函数使用了两个参数,一个是抛出异常的关键字throw的参数的指针,一个是抛出信息类型的指针(ThrowInfo*)
  3. 在异常回调函数中,可以得到异常对象的地址和对应ThrowInfo数据的地址以及FunInfo表结构的地址,根据记录的异常类型,进行try块的匹配工作
  4. 如果没有找到try块,则异构异常对象,返回ExceptionContinueSearch,继续下一个异常回调函数的处理
  5. 当找到对应的try块时,通过TryBlockMapEntry表结构中的pCatch指向catch信息表,用ThrowInfo表结构中的异常类型遍历查找与之匹配的catch块,比较关键字名称(如整型为.h,单精度浮点为.m)找到有效的catch块
  6. 执行栈展开操作,并产生catch块中使用的异常对象(有4中不同的产生方法)
  7. 正确析构所有生命期已结束的对象
  8. 跳转到catch块,执行catch块代码
  9. 调用_JumpToContinuation函数,返回所有catch语句块的结束地址
Author: L0x1c
Link: https://l0x1c.github.io/2020/06/09/2020-6-09/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶