C++逆向之基本数据类型的表现形式

C++提供的基本数据类型有整数类型、浮点数类型、字符和字符串、布尔类型。
由此引申的有地址、指针、引用和常量。

整数类型

C++提供的整数数据类型有三种:int、long、short。int与long在内存中都占4个字节,short类型在内存中占2个字节。
由于二进制数不方便显示和阅读,因此内存中的数据采用十六进制数显示。一个字节由两个十六进制数组成,在进制转换中,一个十六进制数可用4个二进制数表示,每个二进制数表示1位,因此一个字节在内存中占8位。

无符号整数

在内存中,无符号整数的所有位都用来表示数值。以无符号整型数据unsigned int 为例,此类型的变量在内存中占4字节,由8个十六进制数组成,取值范围为0x00000000-0xFFFFFFFF,如果转换成十进制则表示范围为0-4294967295。
在内存中十六进制数以“小尾方式”存放。“小尾方式”存放是以字节为单位,按照数据类型长度,低数据位排放在内存的低端,高数据排放在内存的高端,如0x12345678将会存储位78 56 34 12。
由于是无符号整数,不存在正负之分,都是正数,故无符号整数在内存中都是以真值的形式存放的,每一位都可以参与数据表达。无符号整数可表示的正数范围是补码的一倍。

有符号整数

有符号整数中用来表示符号的同样是最高位———符号位。最高位为0表示正数,最高位为1表示负数。有符号整数在内存中同样占4字节,但由于最高位为符号位,不能用来表示数值,因此有符号整数的取值范围要比无符号整数取值范围少1位,即0x80000000 ~ 0x7FFFFFFF,如果转换为十进制数,则表示范围为-2147483648 ~ 2147483647。
在有符号整数中,正数的表示区间为0x00000000 ~ 0x7FFFFFFF;负数的表示区间为0x80000000 ~ 0xFFFFFFFF。
负数在内存中都是以补码形式存放的,补码的规则是用0减去这个数的绝对值,也可以简单地表达为对这个数值取反加1。因为对于任何4字节的数值x,都有x+x(反)=0xFFFFFFFF,于是x+x(反)+1=0,接下来就可以推导出-x=x(反)+1。

浮点数类型

计算机也需要运算和储存数学中的实数。在计算机的发展过程中,曾产生过多种存储实数的方式,有的现在已经很少使用了。不管如何存储,我们都可以划分为定点实数存储方式和浮点实数存储方式这两种。所谓定点实数,就是约定整数位和小数位的长度,比如4字节存储实数,我们可以约定两个高字节存放整数部分,两个低字节存储小数部分。这样的好处是计算的效率高,缺点也显而易见,存储不灵活。对应地,也有浮点实数存储方式,道理很简单,就是用一部分二进制位存放小数点的位置信息,我们可以称之为“指数域”,其他的数据位用来存储没有小数点时的数据和符号,我们可以称之为“数据域”、“符号域”。
在C++中,使用浮点方式存储实数,用两种数据类型来保存浮点数:float(单精度)、double(双精度)。float在内存中占4字节空间,double在内存中占用8字节空间。由于占用空间更大,double可描述的精度更高。这两种数据类型在内存中同样以十六进制方式进行存储,但与整数类型有所不同。
整形类型是将十进制转换成二进制保存在内存中的。浮点类型则是转换成二进制制码重新编码再进行存储。
在C++中,将浮点数强制转化为整数时,不会采用数学上四舍五入的方式,而是舍弃掉小数部分。

浮点数的编码方式

浮点数编码转换采用的时IEEE规定的编码标准,float和double这两种类型数据的转换原理相同,但由于表示的范围不一样,编码方式有些许区别。IEEE规定的浮点数编码会将一个浮点数转化为二进制数。以科学计数法划分,将浮点数拆分为3部分:符号、指数、尾数。

float类型的IEEE编码

float类型在内存中占4字节(32位)。最高为用于表示符号;剩余的31位中,从左向右取8位用于表示指数,其余用于表示尾数。
在进行二进制转换前,需要对单精度浮点数进行科学计数法转换。例如,将float类型的12.25f转换为IEEE编码,需要将12.25f转换成对应的二进制数1100.01,整数部分为1100,小数部分为01;小数点向左移动,每移动1次指数加1,移动到符号位的最高位为1处,停止移动,这里移动3次,为1.10001,指数部分为3。在IEEE编码中,由于在二进制情况下,最高位始终为1,为一个恒定值,故将其忽略不计。这里是一个正数,所以符号为添0。

12.25f经IEEE转换后各位的情况:

  • 符号位:0
  • 指数位:十进制3+127,转化为二进制是 10000010
  • 尾数位:10001 000000000000000000(当不足23位时,低位补0填充)

由于位数中最高位1是恒定值,故忽略不计,只要在转换回十进制时加1即可。为什么指数位要加127呢?由于指数可能出现负数,十进制数127可表示位二进制数01111111。IEEE编码方式规定,当指数域小于01111111时为一个负数,反之为正数,因此01111111为0。
12.25f转换后的IEEE编码按照二进制拼接为01000001010001000000000000000000。转换成十六进制数为0x41440000。
写个程序来验证一下。

1
2
3
4
5
6
7
8
9
#include<iostream>
using namespace std;
int main()
{
float f=12.25;
int *a=(int *)&f;
printf("%f\n%x",f,*a);
return 0;
}

运行结果吻合。

将浮点数小数部分转化为二进制时都是有穷的,如果小数部分转化为二进制时得到一个无穷值,则会根据位数部分的长度舍弃多余的部分。单精度浮点数1.3f,小数部分转换为二进制就会产生无穷值,依次转换为:0.3、0.6、1.2、0.4、0.8、1.6、1.2、0.4、0.8 …转换后得到的二进制数为 1.01001100110011001100110,到第23位终止,尾数部分无法保存更大的值。

1.3f经IEEE转换后各位的情况:

  • 符号位:0
  • 指数位:十进制0+127,转化为二进制是 01111111
  • 尾数位:01001100110011001100110(当不足23位时,低位补0填充)

1.3f转换后的IEEE编码二进制拼接为00111111101001100110011001100110。转换成十六进制数为0x3FA66666,由于在转换二进制过程中产生了无穷值,舍弃了部分位数,所以进行IEEE编码转换后得到的是一个近似值,存在一定的误差。再次将这个IEEE编码值转换成十进制小数,得到的值为1.2999999523162841796875,四舍五入后为1.3。这就解释了为什么C++在比较浮点数值是否为0时要做区间比较而不是进行等值比较。

double类型的IEEE编码

double类型和float类型大同小异,只是double类型表示的范围更大,占用空间更多,是float类型所占用空间的两倍。当然,精确度也会更高。
double类型占8字节的内存空间,同样最高位也用于表示符号,指数位占11位,剩余52位用于表示位数。
在float中,指数位范围用8位表示,加127后用于判断指数符号。在double中,由于扩大了精度,因此指数范围使用11位正数表示,加1023后用于指数符号判断。
double类型的IEEE编码转换过程和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)中。
FILD FILD IN 将整数IN压入ST(0)中。
FLDZ FLDZ 将0.0压入ST(0)中
FLD1 FLD1 将1.0压入ST(0)中
FST FST OUT ST(0)中的数据以浮点形式存入OUT地址中。
FSTP FSTP OUT 和FST指令一样,但会执行一次出栈操作
FIST FIST OUT ST(0)数据以整数形式存入OUT地址中
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)中保存

其他运算指令和普通指令类似,只需要再前面加F即可,如FSUB和FSUBP等。
在使用浮点指令时,都要先利用ST(0)进行运算。当ST(0)中有值时,便会将ST(0)中的数据顺序向下存放到ST(1)中,然后再将数据放入ST(0)中。如果再次操作ST(0),则会先将ST(1)中的数据放入ST(2)中,然后将ST(0)中的数据放入ST(1)中,最后才将新的数据放到ST(0)。以此类推,在8个浮点寄存器都有值得情况下继续向ST(0)中存放数据,这时会丢弃ST(7)中得数据信息。

下面通过一个简单的示例来了解指令的使用

1
2
3
4
5
6
7
8
9
#include<iostream>
using namespace std;
int main()
{
int temp;
scanf("%d",&temp);
float fFloat=(float) temp;
printf("%f",fFloat);
}

编译后,OD载入找到main函数。注意编译时要关闭所有优化。

scanf(“%d”,&temp);

1
2
3
4
00401660  |.  8D4424 28     lea eax,dword ptr ss:[esp+0x28]                    ;  取出temp的地址,即&temp
00401664 |. 894424 04 mov dword ptr ss:[esp+0x4],eax ; 将取出地址作为参数传入
00401668 |. C70424 450041>mov dword ptr ss:[esp],1.00410045 ; 传入字符串参数"%d"
0040166F |. E8 8CFFFFFF call 1.00401600 ; 调用scanf

float fFloat=(float) temp;

1
2
3
4
00401674  |.  8B4424 28     mov eax,dword ptr ss:[esp+0x28]
00401678 |. 894424 1C mov dword ptr ss:[esp+0x1C],eax
0040167C |. DB4424 1C fild dword ptr ss:[esp+0x1C] ; 将地址esp+0x1C处的整数数据转换成浮点型,并放入ST(0)中
00401680 |. D95C24 2C fstp dword ptr ss:[esp+0x2C] ; 从ST(0)取出数据以浮点编码方式放入esp+0x2C中,对应变量fFloat

printf(“%f”,fFloat);

1
2
3
4
00401684  |.  D94424 2C     fld dword ptr ss:[esp+0x2C]                        ;  将esp+0x2C处的浮点编码类型压入ST(0)中
00401688 |. DD5C24 04 fstp qword ptr ss:[esp+0x4] ; 从ST(0)中取出浮点编码数据,放入esp+0x4中,即传入参数
0040168C |. C70424 480041>mov dword ptr ss:[esp],1.00410048 ; 传入字符串参数"%f"
00401693 |. E8 91FFFFFF call 1.00401629 ; 调用printf

从以上示例可以发现,float类型的浮点数虽然占4字节,但是都是以8字节方式进行处理。当浮点数作为参数时,不能直接压栈。push指令(这里使用的是mov指令)只能传入4字节数据到栈中,这样会丢失4字节数据。所以需要使用ST(0)作为中转。

字符和字符串

字符串是由多个字符按照一定排列顺序组成的,在C++中,以’\0’作为字符串结束标记。每个字符都记录在一张表中,它们对应一个唯一的编号,系统通过这些编号查找到对应的字符并显示。字符表中的编号便是字符的编码格式。

字符的编码

在C++中,字符的编码格式分为两种ASCII和Unicode。Unicode是ASCII的升级编码格式,它弥补了ASCII的不足。
ASCII编码在内存中占一个字节的大小,由0 ~ 255之间的数字组成。每个数字表示一个符号,具体表示方式可查看ASCII表。由于ASCII编码也是由数字组成的,故可以和整形互相转换,但整数不可超过ASCII的最大表示范围,因为多余部分将被舍弃。

字符串的储存方式

字符串是由一系列按照一定的编码顺序线性排列的字符组成的。C++使用结束符’\0’作为字符串结束符。ASCII编码使用一个字节’\0’,Unicode编码使用两个字节’\0’.需要注意的是,不能使用处理ASCII的编码函数对Unicode编码进行处理,因为如果Unicode编码中出现了只占用一个字节的字符,就会发生解释错误。

布尔类型

布尔类型是用于判断执行结果的数据类型,它的判断比较值只有两种情况:0与非0。C++中定义0为假,非0为真。使用bool定义布尔类型变量。布尔类型在内存中占1字节。由于布尔类型只比较两个结果值:真、假,实际上任何一种数据类型都可以将其代替,如整型、字符型,甚至可以用位代替。在实际案例中也是难以将布尔类型数据还原成源码的,但是可以将其还原成等价代码。布尔类型出现的场合都是在做真假判断,有了这个特性,还原成等价代码还是相对简单的。

地址、指针和引用

  • 地址
    在C++中,地址标号使用十六进制表示。取一个变量的地址使用“&”符号,只有变量才存在内存地址,常量没有地址(不包括const定义的伪常量)。例如,对于数字100,我们无法取出它的地址。取出的地址是一个常量值,无法再对其取地址了。
  • 指针
    指针的定义使用”TYPE*”,TYPE为数据类型,任何数据类型都可以定义指针。指针本身也是一种数据类型,它用于保存各种数据类型在内存中的地址。指针变量同样也可以取出地址,所以会出现多级指针
  • 引用
    引用的定义使用”TYPE&”,TYPE为数据类型。在C++中是不可以单独定义的,并且在定义时就要进行初始化。引用表示一个变量的别名,对它的任何操作,本质上都是在操作它所表示的变量。

指针和地址的区别

在32位操作系统下,地址是一个由32位二进制数字组成的值。为了便于查看,转换成十六进制数字进行显示,用于标识内存编号。指针是用于保存这个编号的一种变量类型,它包含在内存中,所以可以取出指针类型变量在内存中的位置——地址。由于指针保存的数据都是地址,所以无论什么类型的指针都占据4字节的内存空间。
指针可以根据指针类型对地址对应的数据进行解释,而一个地址值无法单独解释数据。

*指针和地址之间的不同点*
指针 地址
变量,保存变量地址 常量,内存标号
可修改,再次保存其他变量地址 不可修改
可以对其执行取地址操作得到地址 不可执行取地址操作
包含对保存地址的解释信息 仅仅有地址值无法解释数据
*指针和地址之间的共同点*
指针 地址
取出指向地址内存中的数据 取出地址对应内存中的数据
对地址偏移后,取数据 偏移后取数据,自身不变
求两个地址的差 求两个地址的差

各类型指针的工作方式

在C++中,任何数据类型都有对应的指针类型。从可以了解到,指针中保存的都是地址,为什么还需要类型作为修饰呢?因为需要用类型去解释这个地址中的数据。每种数据类型在内存中所占的内存空间不同,指针只保存了存放数据的首地址,而没有指明改在哪里结束。这时间就需要根据对应的类型来寻找解释数据的结束地址。例如,同一地址,使用不同类型的指针进行访问,取出的内容就会不一样。
在C++中,所有指针类型只支持加法和减法。指针是用于保存数据地址、解释地址而存在的。因此,只有加法与减法才有意义,其他运算对于指针而言没有任何意义。
指针加法用于地址偏移,但指针的加法并不像数学中的加法那样简单。指针加1后,指针内保存的地址值并不一定会加1,具体的值取决于指针类型,如指针类型位int,地址值将会加4。这个4是根据类型大小所得到的值。

引用

引用类型在C++中被描述为变量的别名。实际上,C++为了简化指针操作,对指针的操作进行了封装,产生了引用。实际上引用类型就是指针类型,只不过它用于存放地址的内存空间对使用者是因隐藏的。引用类型的储存方式和指针是一样的,都是使用内存空间存放地址值。所以,在C++中,引用和指针没有分别。只是引用是通过编译器实现寻址,而指针需要手动寻址。指针虽然灵活,但操作失误将产生严重的后果,而使用引用则不会存在这种问题。因此,C++极力提倡使用应用类型,而非指针。如果没有源码对照,指针和引用都一样难以区分。在反汇编下没有引用这种数据类型。

常量

常量数据在程序运行前就已经存在,它们被编译到可执行文件中。当程序启动后,它们便会被加载进来。这些数据通常都会在常量数据区中保存,该节区的属性中是没有可写权限的,所以在对常量进行修改时,程序会报错。试图修改它们的数据都将引发异常,导致程序崩溃。

常量的定义

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

#define 和 const的区别

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

*#define与const区别*

define|const

:-|:-
编译期间查找替换|编译期间检查const修饰的变量是否被修改
由系统判断是否被修改|由编译器限制修改
字符串定义在文件只读数据区,数据常量编译为立即数寻址方式,成为二进制代码的一部分|根据作用域觉得所占的内存位置和属性

这两者在连接生成可执行文件后将不复存在,在二进制编码中也没有这两种类型存在。在实际分析中,要根据自身经验进行还原。

文章目录
  1. 1. 整数类型
    1. 1.1. 无符号整数
    2. 1.2. 有符号整数
  2. 2. 浮点数类型
    1. 2.1. 浮点数的编码方式
      1. 2.1.1. float类型的IEEE编码
      2. 2.1.2. double类型的IEEE编码
    2. 2.2. 基本的浮点数指令
  3. 3. 字符和字符串
    1. 3.1. 字符的编码
    2. 3.2. 字符串的储存方式
  4. 4. 布尔类型
  5. 5. 地址、指针和引用
    1. 5.1. 指针和地址的区别
    2. 5.2. 各类型指针的工作方式
    3. 5.3. 引用
  6. 6. 常量
    1. 6.1. 常量的定义
    2. 6.2. #define 和 const的区别
  7. 7. define是一个真常量,而const却是由编译器判断实现的常量,是一个假常量。在实际使用中,使用const定义的常量,最终还是一个变量,只是在编译器内进行了检查,发现有修改就报错。
  8. 8. define|const