反汇编从入门到精通

  1. 逆向简介
  2. 基本数据类型的表现形式
    1. 整数类型
      1. 无符号整数
      2. 有符号整数
        1. 取反加一(重要)
    2. 浮点数类型
      1. 浮点数的编码方式
        1. float类型的IEEE编码
      2. 基本的浮点数指令
      3. 例子
    3. 字符和字符串
      1. 字符的编码
      2. 字符串的存储方式
    4. 布尔类型
    5. 地址、指针和引用
      1. 指针和地址的区别
      2. 各类型指针的工作方式
      3. 引用
    6. 常量
      1. #define和const的区别
  3. 程序的真正入口
  4. 计算方式反汇编
    1. 常量折叠
    2. 常量传播
    3. 窥孔优化
  5. if
    1. 代码优化
      1. 代码内联
      2. 代码外提
  6. switch
  7. 浮点数
  8. 数组
  9. 变量在内存中的位置和访问方式
    1. 变量的作用域
    2. 变量的生命周期
    3. 全局变量和局部变量的区别
    4. 局部静态变量的工作方式
    5. 堆变量
  10. 数组和指针的寻址
    1. 数组在函数内
    2. 数组作为参数
    3. 数组作为返回值
    4. 下标寻址和指针寻址
      1. 下标值为整型常量的寻址
      2. 下标值为整型表达式的寻址
      3. 寻址代码
    5. 多维数组
    6. 存放指针类型数据的数组
    7. 指向数组的指针变量
    8. 函数指针
  11. 结构体和类
    1. 对象的内存布局
      1. 1. 空类
      2. 2.内存对齐
    2. this指针
    3. 静态数据成员
    4. 对象作为函数参数
  12. 构造函数和析构函数
    1. 构造函数的出现时机
      1. 局部对象
      2. 堆对象
      3. 参数对象
      4. 返回对象
      5. 全局对象与静态对象
        1. 直接定位初始化函数
        2. 利用栈回溯
    2. 每个对象是否都有默认的构造函数
      1. 本类和本类中定义的成员对象或者父类中存在 虚函数
      2. 父类或本类中定义的成员对象带有构造函数
    3. 析构函数的出现时机
      1. 局部对象
      2. 堆对象
  13. 虚函数
    1. 1.虚函数的机制
    2. 2.虚函数的识别
    3. 3.本章小结
  14. 从内存角度看继承和多重继承
    1. 识别类和类之间的关系
    2. 多重继承
  15. 异常处理
    1. 异常处理的相关知识
    2. 异常类型为基本数据类型的处理流程

逆向简介

逆向分析其实是反向的编译原理编译原理∶高级代码->低级代码

逆向分析:低级代码->高级代码
逆向分析本身和破解、外挂、内核驱动等无关仅仅是逆向分析,无任何实际用途。
逆向分析+密码学=破解
逆向分析+目标协议+保护对抗=外挂

逆向分析+操作系统=内核
逆向分析+病毒查杀规则=反病毒

逆向分析+软件开发=竞品分析

基本数据类型的表现形式

整数类型

C++提供的整数数据类型有:int、long、short和long long。int类型与long类型都占4字节,short类 型占2字节,long long类型占8字节

在C++中,整数类型又可以分为有符号型和无符号型两种。有符 号整数可用来表示负数与正数,而无符号整数则只能表示正数

无符号整数

在内存中,无符号整数的所有位都用来表示数值。以无符号整型 数unsigned int为例,此类型的变量在内存中占4字节,由8个十六进 制数组成,取值范围为0x00000000~0xFFFFFFFF,如果转换为十 进制数,则表示范围为0~4294967295

当无符号整型不足32位时,用0来填充剩余高位,直到占满4字节 内存空间为止。例如,数字5对应的二进制数为101,只占了3位,按4字 节 大 小 保 存 , 剩 余 29 个 高 位 将 用 0 填 充 , 填 充 后 结 果 为 :00000000000000000000000000000101; 转 换 成 十 六 进 制 数0x00000005之后,在内存中以“小尾方式”存放。

无符号整数不存在正负之分,都是正数,故无符号整数在内存中 都是以真值的形式存放的,每一位都可以参与数据表达。无符号整数 可表示的正数范围是补码的1倍。

有符号整数

有符号整数中用来表示符号的是最高位,即符号位。最高位为0表 示正数,最高位为1表示负数。

有符号整数int在内存中同样占4字节, 但由于最高位为符号位,不能用来表示数值,因此有符号整数的取值 范 围 要 比 无 符 号 整 数 取 值 范 围 少 1 位 , 即 0x80000000 ~0x7FFFFFFF,如果转换为十进制数,则表示范围为-2 147 483 648~2 147 483 647。

在 有 符 号 整 数 中 ,正 数 的 表 示 区 间 为 0x00000000~0x7FFFFFFF;负数的表示区间为0x80000000~0xFFFFFFFF。

取反加一(重要)

负数在内存中都是以补码形式存放的,补码的规则是用0减去这个 数的绝对值,也可以简单地表达为对这个数值取反加1。例如,对 于-3,可以表达为0-3,而0xFFFFFFFD+3等于0(进位丢失),

所 以-3的补码就是0xFFFFFFFD了。相应地,0xFFFFFFFD作为一个补 码,最高位为1,视为负数,

转换回真值同样也可以用0-0xFFFFFFFD的方式表示,于是得到-3。为了计算方便,人们也常用取反加一的方 式 求 补 码 ,因 为 对 于 任 何 4 字 节 的 数 值 x , 都 有 x+x ( 反 )=0xFFFFFFFF,于是x+x(反)+1=0,接下来就可以推导出0-x=x(反)+1了

在我们讨论的C/C++中,有符号整数都是以补码形式存储的,而 且在几乎所有的编程语言中都是如此,这是为什么呢?因为计算机只 会做加法,所以需要把减法转换为加法。

设有符号数x,y,求x-y的值,我们可以推导出x-y=x+(0- |y|),根据补码的规则,当y为负数的时候,0-|y|等价于y的补码。对 于y的补码,我们记为y(补),所以x-y=x+y(补)。

例如,(3-2)可转换成(3+(-2)),运算过程为3的十六进制 原码0x00000003加上-2的十六进制补码0xFFFFFFFE,从而得到0x100000001。由于存储范围为4字节大小,两数相加后产生了进 位,超出了存储范围,超出的1将被舍弃。进位被舍弃后,结果为0x00000001。 值得一提的是,对于4字节补码,0x80000000所表达的意义可 以是负数0,也可以是0x80000001减去1。因为0的正负值是相等 的 , 没 有 必 要 再 用 负 数 0 , 所 以 就 把 这 个 值 的 意 义 规 定 为0x80000001减去1,这样0x80000000也就成为4字节负数的最小 值了。这也是有符号整数的取值范围中,负数区间总是比正数区间多 一个最小值的原因。 在数据分析中,如果将内存解释为有符号整数,则查看用十六进 制数表示时的最高位,最高位小于8则为正数,大于8则为负数。如果 是负数,则须转换成真值,从而得到对应的负数数值

那么,如何判断一段数据是有符号类型还是无符号类型呢?

这就 需要查看指令或者已知的函数操作内存地址的方式,根据操作方式或 函数相关定义得出该地址的数据类型。如API调用MessageBoxA,它 有4个参数,查看帮助手册得知,第4个参数为一个无符号整数,从而 可分析出这个传入数值的类型。

浮点数类型

在C/C++中,使用浮点方式存储实数,用两种数据类型来保存浮 点数:float、double(float在内存中占4字 节,double在内存中占8字节。由于占用空间大,double可描述的精 度更高。这两种数据类型在内存中同样以十六进制方式存储,但与整 型类型有所不同。

整型类型是将十进制转换成二进制保存在内存中,以十六进制方 式显示。浮点类型并不是将一个浮点小数直接转换成二进制数保存, 而是将浮点小数转换成的二进制码重新编码,再进行存储。C/C++的 浮点数是有符号的。

浮点数的操作不会用到通用寄存器,而是会使用浮点协处理器的 浮点寄存器,专门对浮点数进行运算处理

浮点数的编码方式

IEEE规定的浮点数编码会将一个浮点数转换为二进制 数。以科学记数法划分,将浮点数拆分为3部分:符号、指数、尾数

float类型的IEEE编码

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

在进行二进制转换前,需要对单精度浮点数进行科学记数法转 换。

例如,将float类型的12.25f转换为IEEE编码,须将12.25f转换成 对应的二进制数1100.01,整数部分为1100,小数部分为01;小数 点向左移动,每移动1次,指数加1,移动到除符号位的最高位为1处,停止移动,这里移动3次。对12.25f进行科学记数法转换后二进制 部分为1.10001,指数部分为3。在IEEE编码中,由于在二进制情况 下,最高位始终为1,为一个恒定值,故将其忽略不计。这里是一个正 数,所以符号位添加0

12.25f经IEEE转换后各位如下。

符号位:0。

指数位:十进制3+127=130,转换为二进制为10000010。

尾数位:10001 000000000000000000(当不足23位时,低 位补0填充)。 由于尾数位中最高位1是固定值,故忽略不计,只要在转换回十进 制数时加1即可。为什么指数位要加127呢?这是因为指数可能出现负 数,十进制数127可表示为二进制数01111111,IEEE编码方式规定 , 当 指 数 小 于 0111111 时 为 一 个 负 数 , 反 之 为 正 数 , 因 此01111111为0。 将示例中转换后的符号位、指数位和尾数位按二进制拼接在一 起 , 就 成 为 一 个 完 整 的 IEEE 浮 点 编 码 01000001010001000000000000000000。转换成十六进制数为0x41440000,内存中以小尾方式进行排列,故为00 00 44 41,分 析结果如图2-3所示。

-0.125fIEEE转换后各位的情况如下。

符号位:1。

指数位:十进制127+(-3),转换为二进制是01111100,如果 不足8位,则高位补0。

尾数位:00000000000000000000000。

-0.125f 转 换 后 的 IEEE 编 码 二 进 制 拼 接 为10111110000000000000000000000000。转换成十六进制数为0xBE000000,内存中显示为00 00 00 BE

基本的浮点数指令

前面介绍了浮点数的编码方式,下面我们来学习浮点数指令。浮 点数的操作指令与普通数据类型不同,浮点数操作是通过浮点寄存器 实现的,而普通数据类型使用的是通用寄存器,它们分别使用两套不 同的指令。 在早期CPU中,浮点寄存器是通过栈结构实现的,由ST(0)~

ST(7)共8个栈空间组成,每个浮点寄存器占8字节。每次使用浮点 寄存器都是率先使用ST(0),而不能越过ST(0)直接使用ST

(1)。浮点寄存器的使用就是压栈、出栈的过程。当ST(0)中存在 数据时,执行压栈操作后,ST(0)中的数据将装入ST(1)中,如无 出栈操作,将按顺序向下压栈,直到将浮点寄存器占满为止。常用浮 点数指令的介绍如表2-1所示,其中,IN表示操作数入栈,OUT表示操 作数出栈。

其他运算指令和普通指令类似,只须在前面加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)中的数据信息。

1997年开始,Intel和AMD都引入了媒体指令(MMX),这些指 令允许多个操作并行,允许对多个不同的数据并行执行同一操作。近 年来,这些扩展有了长足的发展。名字经过了一系列的修改,从MMX

到SSE(流SIMD扩展),以及最新的AVX(高级向量扩展)。每一代 都有一些不同的版本。每个扩展都用来管理寄存器中的数据,这些寄 存器在MMX中被称为MM寄存器,在SSE中被称为XMM寄存器,在

AVX中被称为YMM寄存器。MM寄存器是64位的,XMM是128位的, 而YMM是256位的。每个YMM寄存器可以存放8个32位值或4个64位 值,可以是整数,也可以是浮点数。YMM寄存器一共有16个(YMM0

~YMM15),而XMM是YMM的低128位。常用SSE浮点数指令的介 绍如表2-2所示。

下面通过一个简单的示例介绍各指令的使用流程,帮助读者熟悉 浮点指令的使用方法,如代码清单2-2所示。

例子

1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>
int main()
{
int a = -1;
unsigned int ab=0x12;
float b = 1.25f;
float bc=-0.125f
_int64 c = 0x1234;
short d = 0x10;
return 0;
}

字符和字符串

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

字符的编码

在C++中,字符的编码格式分为两种:ASCII和Unicode。

Unicode是ASCII的升级编码格式,它弥补了ASCII的不足,也是编码 格式的发展趋势。

ASCII编码在内存中占1字节,由0~255之间的数字组成。每个数字表示一个符号,具体表示方式可查看ASCII表。由于ASCII编码也是 由数字组成的,所以可以和整数互相转换,但整数不可超过ASCII的最 大表示范围,因为多余部分将被舍弃。 由于ASCII原来的表示范围太小,只能表示26个英文字母和常用符 号。在亚洲,ASCII的表示范围完全不够用,仅汉字就可以轻易占ASCII编码。

因此,占双字节、表示范围为0~65535的Unicode编码 产生了。Unicode编码是世界通用的编码,ASCII编码也包含在其中。 使用char定义ASCII编码格式的字符,使用wchar_t定义Unicode

编码格式的字符。wchar_t中保存ASCII编码,不足位补0,如字 符’a’的ASCII编码为0x61,Unicode编码为0x0061。汉字的编码方 式有些特殊,ASCII与Unicode都有与之匹配的编码格式。 在程序中使用中文、韩文、日文等时,经常出现显示的内容都是 乱码的情况。这是因为系统缺少该语种字符表,这个字符表用于解释所需语种的字符编码,所以程序中的字符编码错误地对应到其他字符 表中,显示出的文字是其他语种字符表中的信息。

ASCII编码与Unicode编码都可以用来存储汉字,但是它们对汉字 的编码方式各不相同,所以存储同样的汉字,它们在内存中的编码是 不同的,如图2-5所示。

ASCII使用GB2312-80,又名汉字国标码,保存了6763个常用汉 字编码,用两个字节表示一个汉字。在GB2312-80中用区和位来定 位,第一个字节保存每个区,共94个区;第二个字节保存每个区中的 位,共94位。详细信息可查看GB 2312-80编码的说明。

Unicode使用UCS-2编码格式,最多可存储65536个字符。汉字 博大精深,其中有简体字、繁体字,还有网络中流行的火星文,它们 的总和远远超过了UCS-2的存储范围,所以UCS-2编码格式中只保存 了常用字。为了将所有的汉字都容纳进来,Unicode也采用了与ASCII

类似的方式——用两个Unicode编码解释一个汉字,一般称之为UCS4编码格式。UCS-2编码表的使用和ASCII编码表的使用是一样的,每 个数字编号在表中对应一个汉字,从0x4E00到0x9520为汉字编码 区。例如,在UCS-2中,“烫”字的编码为0x70EB。更多关于UCS-2

编码的信息可查看随书文件的UCS-2编码表。

字符串的存储方式

字符串是由一系列按照一定的编码顺序线性排列的字符组成的。 在图形中,两点可以确定一条直线;在程序中,只要知道字符串的首地址和结束地址就可以确定字符串的长度和大小。字符串的首地址很 容易确定,因为在定义字符串的时候都会先指定好首地址。但是结束 地址如何确定呢?有两种方法,一种是在首地址的4字节中保存字符串 的总长度;另一种是在字符串的结尾处使用一个规定好的特殊字符, 即结束符,下面分析这两种方法的优缺点。

保存总长度 优点:获取字符串长度时,不用遍历字符串中的每个字符,取得 首地址的前n字节就可以得到字符串的长度(n的取值一般是1、2、4)。 缺点:字符串长度不能超过n字节的表示范围,且要多开销n字节 空间保存长度。如果涉及通信,双方交互前必须事先知道通信字符串 的长度。 结束符 优点:没有记录长度的开销,即不需要存储空间记录长度信息; 另外,如果涉及通信,通信字符串可以根据实际情况随时结束,结束 时附上结束符即可。 缺点:获取字符串长度需要遍历所有字符,寻找特殊结尾字符, 在某些情况下处理效率低。

C++使用结束符’\0’作为字符串结束标志。ASCII编码使用一个字 节’\0’,Unicode编码使用两个字节’\0’。需要注意的是,不能使用处 理ASCII编码的函数对Unicode编码进行处理,因为如果Unicode编码 中出现了只占用1字节的字符,就会发生解释错误。

在程序中,一般都会使用一个变量来存放字符串中第一个字符的 地址,以便于查找使用字符串。在程序中使用字符型指针char、wchar_t来保存字符串首地址。

ch所在地址0x01177b30以ASCII字符进行组合;wch所在地址

0x01177bf4以Unicode字符进行组合,两个字节为一个字符。 字符串的识别也相对简单,同样是结合上下文,查看调用地址处 对该地址的处理过程。在通常情况下,OllyDbg和IDA都会自动识别出 程序中的字符串。在使用IDA的过程中,有时会无法识别字符串,此时 可进行手动修改,

布尔类型

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

地址、指针和引用

在C++中,地址标号使用十六进制表示,取一个变量的地址使 用“&”符号,只有变量才存在内存地址,常量没有地址 (不包括const定义的伪常量)。例如,对于数字100,我们无法取出它的地址。

取出的地址是一个常量值,无法再对其取地址了。 指针的定义使用“TYPE*”格式,TYPE为数据类型,任何数据类型 都可以定义指针。

指针本身也是一种数据类型,用于保存各种数据类 型在内存中的地址。指针变量同样可以取出地址,

所以会出现多级指 针。 引用的定义格式为“TYPE&”,TYPE为数据类型。在C++中是不 可以单独定义的,并且在定义时就要进行初始化。引用表示一个变量 的别名,对它的任何操作本质上都是在操作它所表示的变量。

指针和地址的区别

地址为了便于 查看,转换成十六进制数字显示出来,用于标识内存编号。指针是用 于保存这个编号的一种变量类型,它包含在内存中,所以可以取出指 针类型变量在内存中的位置——地址。由于指针保存的数据都是地址,

所以无论什么类型的指针,32位程序都占据4字节的内存空间,64位程序都占据8字节的内存空间

各类型指针的工作方式

在C++中,任何数据类型都有对应的指针类型。这时就需要根据对应的类型来寻找解释数据的结束地址。

同 一地址使用不同类型的指针进行访问,取出的内容就会不一样

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main(int argc, char* argv[]) {
int n = 0x12345678;
int *p1 = &n;
char *p2 = (char*)&n;
short *p3 = (short*)&n;
printf("%08x \r\n", *p1);
printf("%08x \r\n", *p2);
printf("%08x \r\n", *p3);
return 0;
}

在C++中,所有指针类型都只支持 加法和减法。指针是用来保存数据地址、解释地址的,因此只有加法 与减法才有意义, 指针加法用于地址偏移,但并不像数学中的加法那样简单。指针 加1后,指针内保存的地址值并不一定会加1,运算结果取决于指针类 型,如指针类型为int,地址值将会加4,这个4是根据类型大小所得到 的值。

C++为什么要用这种烦琐的地址偏移方法呢?因为当指针中保 存的地址为数组首地址时,为了能够利用指针加1后访问到数组内下一 成员,所以加的是类型长度,而非数字1

引用

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

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
void add(int &ref){
ref++;
}
int main(int argc, char* argv[]) {
int n = 0x12345678;
int &ref = n;
add(ref);
return 0;
}

引用类型的存储方式和指针是一样的,都 是使用内存空间存放地址值。所以,在C++中,除了引用是通过编译 器实现寻址,

而指针需要手动寻址外,引用和指针没有太大区别。指 针虽然灵活,但如果操作失误将产生严重的后果,而使用引用则不存 在这种问题。

因此,C++极力提倡使用引用类型,而非指针。 引用类型也可以作为函数的参数类型和返回类型使用。因为引用 实际上就是指针,所以它同样会在参数传递时产生一份备份,如

1
2
3
4
5
6
7
8
9
10
00401000 push ebp
00401001 mov ebp, esp
00401003 mov eax, [ebp+8] ;取出参数ref的内容放入eax
00401006 mov ecx, [eax]
00401008 add ecx, 1
0040100B mov edx, [ebp+8]
0040100E mov [edx], ecx ;对eax做取内容操作,间接访问实参

00401010 pop ebp
00401011 ret

通过对参数加1的方式修改实参数据。从汇编 代码中可以看出,引用类型的参数也占用内存空间,其中保存的数据 是一个地址值。取出这个地址中的数据并加1,再将加1后的结果放回 地址,如果没有源码对照,指针和引用都一样难以区分。在反汇编 下,没有引用这种数据类型。

常量

前几节介绍的数据类型都是以变量形式进行演示的,在程序运行 中可以修改其保存的数据。从字面上理解,常量是一个恒定不变的 值,它在内存中也是不可修改的。在程序中出现的1、2、3这样的数 字或“Hello”这样的字符串,以及数组名称,都属于常量,

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

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

#define和const的区别

#define修饰的符号名称是一个真量数值,而const修饰的栈常 量,是一个“假”常量。在实际中,使用const定义的栈变量,最终还是 一个变量,只是在编译期间对语法进行了检查,发现代码有对const修 饰的变量存在直接修改行为则报错。 被const修饰过的栈变量本质上是可以被修改的。我们可以利用指 针获取const修饰过的栈变量地址,强制将const属性修饰去掉,就可 以修改对应的数据内容

程序的真正入口

main或WinMain是“语法规 定的用户入口”,而不是“应用程序入口入 口 代 码 其 实 并 不 是 main 或 WinMain 函 数 , 通 常 是mainCRTStartup 、 wmainCRTStartup 、 WinMainCRTStartup 或wWinMainCRTStartup , 具 体 视 编 译 选 项 而 定 。 其 中mainCRTStartup和wmainCRTStartup是控制台环境下多字节编码 和 Unicode 编 码 的 启 动 函 数 , 而 WinMainCRTStartup 和wWinMainCRTStartup则是Windows环境下多字节编码和Unicode编码的启动函数。在开发过程中,C++也允许程序员自己指定入口

VS C++ 在 控 制 台 和 多 字 节 编 码 环 境 下 的 启 动 函 数 为mainCRTStartup , 由 系 统 库 KERNEL32.dll 负 责 调 用 , 在mainCRTStartup中再调用main函数。

使用VS2019进行调试时,入 口断点总是停留在main函数的首地址处。

如何挖掘main函数之前的 代码呢?我们可以利用VS2019的栈回溯功能。在调试环境下,

依次选 择菜单“调试”→“窗口”→“调用堆栈”,打开出栈窗口(快捷键:Ctrl+Alt+C)。

程 序 运 行 时 调 用 的 8 个 函 数 , 依 次 是__RtlUserThreadStart@8 、 __RtlUserThreadStart 、@BaseThreadInitThunk@12 、 mainCRTStartup 、__scrt_common_main 、 __scrt_common_main_seh 、invoke_main 和 main 。 其 中 @BaseThreadInitThunk@12 调 用

mainCRTStartup,我们无法查看mainCRTStartup函数之前的高级源码,而VS 2019则提供了mainCRTStartup函数的源码,安装完整 版的VS2019并下载符号文件就可以查看。双击调用栈窗口中的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
extern "C" int mainCRTStartup()
{
return __scrt_common_main();
}
static __forceinline int __cdecl __scrt_common_main()
{
//初始化缓冲区溢出全局变量

__security_init_cookie();
return __scrt_common_main_seh();
}
static __declspec(noinline) int __cdecl
__scrt_common_main_seh()
{
//用于初始化C语法中的全局数据

if (_initterm_e(__xi_a, __xi_z) != 0)
return 255;
//用于初始化C++语法中的全局数据

_initterm(__xc_a, __xc_z);
//初始化线程局部存储变量

_tls_callback_type const* const tls_init_callback =
__scrt_get_dyn_tls_init_callback();
if (*tls_init_callback != nullptr &&
__scrt_is_nonwritable_in_current_image(tls_init_callback))
{
(*tls_init_callback)(nullptr, DLL_THREAD_ATTACH, nullptr);
}
//注册线程局部存储析构函数

_tls_callback_type const * const tls_dtor_callback =
__scrt_get_dyn_tls_dtor_callback();
if (*tls_dtor_callback != nullptr &&
__scrt_is_nonwritable_in_current_image(tls_dtor_callback))
{
_register_thread_local_exe_atexit_callback(*tls_dtor_callba
ck);
}
//初始化完成调用main()函数

int const main_result = invoke_main();
//main()函数返回执行析构函数或atexit注册的函数指针,并结束程序
if (!__scrt_is_managed_app())
exit(main_result);
}
static int __cdecl invoke_main()
{
//调用main函数,传递命令行参数信息

return main(__argc, __argv,
_get_initial_narrow_environment());
}

计算方式反汇编

未传递运算结果,不传递编译,比如说随便一段算式 36+36*6-6

常见的传递运算结果方式:赋值运算,函数传参,函数返回值

常量折叠

在编译期间,常量表达式或者是表达式中的部分常量计算会直接由编译器计算出结果,不生成运算指令,不同的编译器作者,完成常量折叠的水平不同

debug选项的目的是用于调试,为了调试体验放弃优化,但是不影响调试功能的时候,优化也存在

常量传播

当某个变量值可以确定为常量的时候,可以直接使用常量值代替之

这里直接把一块内存区 域赋值给8了 没有加法运算过程

窥孔优化

就是单单控制一段内容进 不看全局 不停的反复优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int n=3+5;
argc=n*7;
printf("%d\r\n",argc/5);
我们来手动优化一下,
第一次改是
int n=8;
第二次改是
argc=56;
第三次改是
printf("%d\r\n",11);
第四次改删除了int n=8;
第五次改删除了argc=56;
因为发现这两个变量没有使用
就直接变成了printf("%d\r\n",11);
依据就是不停的循环优化一两句代码,发现改变不了源码的 优化停止
窥孔只考虑局部 不考虑全局

idiv是有符号 div是无符号,除法向下取整

这里我解释一下,被除数给了eax, cdq 使eax的符号位给了edx,A>=0,edx是0

A<0 ,edx是-1,这里and edx,如果A<0 and了以后还是7,如果A>=0,and了edx以后就是0

如果此时A,也就是被除数argc小于0,我们看一下,结果就是A+8-1 向右移三位 ,2的几次方就是移动几位

下面这个图是

argc是无符号的

edx移动了一位 实际上是对乘积移了33位

if

单分支if花括号的上界是test下面一行下界是jne的跳转位置

if里面的判断与汇编里面是反的,比如说if(a%2==0)里面有等于0,那就是 jnz跳转

那双分支是什么情况呢

我们看00401039的跳转是0040104a 那我们画一个下届,但是同时要注意 00401048这个jmp,别慌还原因为不确定是否为if,这个jmp是当前地址增量,没有这个jmp就不能还原为if else指令

我们此时再画一个上届 就是401039,从401039到40104a是if

401048的jmp 401057是else 的下届,401048是else的上届

一点全亮 就是多分支

代码优化

代码内联

代码内联就是增加节点,减少路径

我们可以看到是单分支的

代码外提

找到公共代码段,减少节点

比如说上面的if 里面 都有call printf

我们看到401012有个push,咋还原 肯定是代码外提了

switch

多于三条的时候

先求值 减去最小case值 然后判断 大于最小界限 也就是case的最大值-最小case值就结束了

小于就跳到一张表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>

int main()
{
int argc=10;
switch(argc/2)
{
case 1:printf("1");
case 2:printf("2");
case 3:printf("3");
case 11:printf("4");
break;
}
return 0;
}

那要是case不连续 或者是大于10怎么办

浮点数

3.5+4.3=7.8

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>

int main()
{
int n1=35;
int n2=43;
int n3=n1+n2;
printf("%d.%d\n",n3/10,n3%10);
return 0;
}

我们计算机的精度是有限的 精度扩展怎么办

4个字节 有效数值是多少 10 位

那123.45678a 能表达吗

数组

数组两个特性 连续性 数据连续存放 不重叠不对齐

一致性 连续存放的数据类型一致 且作用一致

论证以上两点,需要从2个地方考察

1.循环结构 循环结构可以得到一致性和连续性的结论 通常还能定义数组的范围

2.比例因子寻址 ,类类似[base+reg*N]这类寻址,按数学归纳法论证可得在reg允许的范围内,该值连续且作用一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>

int main()
{
int ary[5]={0};
for(int i =0;i<5;i++)
{
ary[i]=i;
} for(int i =0;i<5;i++)
{
printf("%d", ary[i]);
}

return 0;
}

变量在内存中的位置和访问方式

变量的作用域

变量的作用域指变量在源码中可以被访问到的范围。

全局变量属 于进程作用域,也就是说,在整个进程中都能够访问这个全局变量;

静态变量属于文件作用域,在当前源码文件内可以访问到;

局部变量 属于函数作用域,在函数内可以访问到;在“{ }”语句块内定义的变 量,属于块作用域,只能在定义变量的“{ }”块内访问。

变量的生命周期

变量的生命周期指变量所在的内存从分配到释放的时间。我们可 以将分配变量内存比喻为变量的诞生,将释放变量内存比喻为变量的消亡。

全局变量和局部变量的区别

常量与全局变量有着相似的特征,都是在程序执行前就存在了。

通常在PE文件的只读数据节中,常量的节属性被修饰为不可写,而全局变量和静态变量则在属性为可读写的数据节中。

下面定义全局变量。

int g_num = 0x12345678;获取全局变量的内存地址

通过调试获取全局变量所在地址0x001FC000, 地址中的数据为0x12345678。

全局变量在文件中的地址定位和常量相同,也需要减去基地址,然后查阅节表得到文件地址。

具有初始值的全局变量,其值在链接时被写入创建 的PE文件中,当用户执行该文件时,操作系统先分析这个PE中的数 据,将各节中的数据填入对应的虚拟内存地址中。

这时全局变量就已经存在了,PE的分析和加载工作完成后,才开始执行入口点的代码。

因此全局变量不受作用域的影响,在程序中的任何位置都可以被访问和使用。

全局变量和局部变量都是变量,它们都可以被赋值和修改。 那么全局变量和局部变量有什么区别呢?

在反汇编代码中该如何区分 二者呢? 通过对全局变量的初步分析可知,它和常量类似,被写入文件, 因此生命周期与所在模块相同。

全局变量和局部变量的最大区别就是 生命周期不同。

全局变量生命周期起始于所在执行文件被操作系统加载后,执行第一条代码前,这个时候已经具有内存地址了。程序结束运行并退出后,全局变量将被销毁。

对于由“{ }”划分 的块作用域,其内部的局部变量生命周期和函数作用域一致,但是编译器会在编译前检查语法,限制块外代码的访问。 在访问方式上,局部变量是通过栈指针相对间接访问的,而全局 变量的内存地址在全局数据区中,通过栈指针无法访问到。那么全局 变量又是如何访问寻址的呢?

1
2
3
4
5
6
7
#include <stdio.h>
int g_gobal = 0x12345678;
int main(int argc, char* argv[]) {
scanf("%d", &g_gobal);
printf("%d\n", g_gobal);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
00401000 push ebp
00401001 mov ebp, esp
00401003 push offset dword_41E000 ;push 0x0041E000;将全局变量的地址作为参数压入栈,与常量的处理方法相同,模块基址为
00400000
00401008 push offset aD ;"%d"
0040100D call sub_4010F0 ;调用scanf函数
00401012 add esp, 8 ;平衡scanf函数的两个参数
00401015 mov eax, dword_41E000 ;mov eax, dword ptr ds:
[0x0041E000]
;取全局变量内容传入eax,eax=g_gobal
0040101A push eax
0040101B push offset aD_0 ;"%d\n"
00401020 call sub_4010B0 ;调用printf函数
00401025 add esp, 8 ;平衡printf函数的两个参数
00401028 xor eax, eax
0040102A pop ebp
0040102B retn
0041E000 dword_41E000 dd 12345678h ;全局变量

访问全局变量与访问常量类似,都是通过立即数访问。

由于全局变量在编译期就已经确定了地址,因此编译器在编译的过程中可以计算出一个固定的地址值。

而局部变量需要进入作用域内,通过申请栈空间存放,利用栈指针ebp或esp间接访问,其地址是一个未知可变值,编译器无法预先计算。

上面介绍了全局变量与局部变量在指令中寻址方式以及生命周期的差别。

在同时连续定义多个全局变量时,这些全局变量在内存中的 地址顺序与局部变量也不一定相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
int g_gobal1 = 0x11111111;
int g_gobal2 = 0x22222222;
int main(int argc, char* argv[]) {
int n1 = 1;
int n2 = 2;
scanf("%d, %d", &n1, &n2); //scanf与printf的使用避免了常量传播优


printf("%d %d\n", n1, n2);
scanf("%d, %d", &g_gobal1, &g_gobal2);
printf("%d %d\n", g_gobal1, g_gobal2);
return 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
48
49
50
51
52
53
54
55
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 8
00401006 mov dword ptr [ebp-8], 1 ;n1=1,先定义的局部变量在低地


0040100D mov dword ptr [ebp-4], 2 ;n1=2,后定义的局部变量在高地

00401014 lea eax, [ebp-4]
00401017 push eax
00401018 lea ecx, [ebp-8]
0040101B push ecx
0040101C push offset aDD ;"%d, %d"
00401021 call sub_401140 ;调用scanf函数

00401026 add esp, 0Ch
00401029 mov edx, [ebp-4]
0040102C push edx
0040102D mov eax, [ebp-8]
00401030 push eax
00401031 push offset aDD_0 ;"%d %d\n"
00401036 call sub_401100 ;调用printf函数

0040103B add esp, 0Ch
0040103E push offset dword_41E004 ;g_gobal2,后定义的全局变量在
高地址

00401043 push offset dword_41E000 ;g_gobal1,先定义的全局变量在
低地址

00401048 push offset aDD_1 ;"%d, %d"
0040104D call sub_401140 ;调用scanf函数

00401052 add esp, 0Ch
00401055 mov ecx, dword_41E004 ;g_gobal2,后定义的全局变量在高地


0040105B push ecx
0040105C mov edx, dword_41E000 ;g_gobal1,先定义的全局变量在低地


00401062 push edx
00401063 push offset aDD_2 ;"%d %d\n"
00401068 call sub_401100 ;调用printf函数

0040106D add esp, 0Ch
00401070 xor eax, eax
00401072 mov esp, ebp
00401074 pop ebp
00401075 retn
0041E000 dword_41E000 dd 11111111h ;全局数据区

0041E004 dword_41E004 dd 22222222h

全局变量在内存中的地址顺序是先定义的 变量在低地址,后定义的变量在高地址。

有此特性即可根据反汇编代码中全局变量的所在地址,还原出高级代码中全局变量定义的先后顺 序,更进一步接近源码。

下面对全局变量和局部变量的特征进行总结。全局变量的特征如 下所示。

所在地址为数据区,生命周期与所在模块一致。 使用立即数间接访问。

局部静态变量的工作方式

静态变量分为全局静态变量和局部静态变量,全局静态变量和全局变量类似,只是全局静态变量只能在当前文件内使用。

但这只是在编译之前的语法检查过程中,对访问外部的全局静态变量做出的限制。

全局静态变量的生命周期和全局变量是一样的,而且在反汇编代 码中它们也并无二致。也就是说,全局静态变量和全局变量在内存结 构和访问原理上都是一样的,相当于全局静态变量等价于编译器限制 外部源码文件访问的全局变量。有鉴于此,本书不再重复讲解了。 局部静态变量比较特殊,它不会随作用域的结束而消失,并且在 未进入作用域之前就已经存在了,其生命周期也和全局变量相同。那 么编译器是如何做到使局部静态变量与全局变量的生命周期相同,但 作用域不同的呢?实际上,局部静态变量和全局变量都保存在执行文 件的数据区中,但由于局部静态变量被定义在某一作用域内,让我们 产生了错觉,误认为此处为它的生命起始点。实则不然,局部静态变 量会预先被作为全局变量处理,而它的初始化部分只是在做赋值操作 而已。 既然是赋值操作,另一个问题就出现了。当某函数被频繁调用 时,C++语法中规定局部静态变量只能被初始化一次,那么编译器如 何确保每次进入函数体时,赋值操作只被执行一次呢?我们一起分析 代码清单7-3,揭开这个谜底

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// C++ 源码

#include <stdio.h>
void showStatic(int n){
static int g_static = n; //定义局部静态变量,赋值为参数

printf("%d\n", g_static); //显示静态变量

}int main(int argc, char* argv[]) {
for (int i = 0; i < 5; i++) {
showStatic(i); //循环调用显示局部静态变量的函数,每次传入不同值

}
return 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
//x86_vs对应汇编代码讲解

00401000 push ebp ;showStatic函数

00401001 mov ebp, esp
00401003 mov eax, TlsIndex
00401008 mov ecx, large fs:2Ch
0040100F mov edx, [ecx+eax*4]
00401012 mov eax, dword_4198BC
00401017 cmp eax, [edx+4]
0040101D jle short loc_40104B
0040101F push offset dword_4198BC
00401024 call sub_401229
00401029 add esp, 4
0040102C cmp dword_4198BC, 0FFFFFFFFh;检查局部静态变量是否初始化
的标志

00401033 jnz short loc_40104B ;如果不为0FFFFFFFFh,表示局部静态
变量已初始化,跳转到输出

00401035 mov ecx, [ebp+8]
00401038 mov dword_4198B8, ecx ;初始化局部静态变量g_static=n,
与全局变量的访问一致

0040103E push offset dword_4198BC
00401043 call sub_4011DF ;调用函数多线程同步函数设置初始化标志

00401048 add esp, 4
0040104B mov edx, dword_4198B8 ;edx保存局部静态变量的值

00401051 push edx
00401052 push offset aD ;"%d \r\n"
00401057 call sub_4010E0
0040105C add esp, 8
0040105F pop ebp
00401060 retn

在代码清单7-3中,代码0x0040102C处地址0x004198BC中保 存了一个局部静态变量的标志,这个标志占4个字节。以此判断局部静 态变量是否已经被初始化过。识别局部静态变量的标志位地址并不是 目的,而是根据这个标志来区分全局变量与局部静态变量。多个局部 静态变量的定义如图7-3所示。

图 7-3中定 义 了 两个局部静态变量 , 分 别 为 g_static1 和g_static2 。 g_static1 使 用 pOnce 作 为 标 志 , g_static2 使 用dword_4033B8作为标志。 当局部静态变量被初始化为常量值时,这个局部静态变量在初始 化过程中不会产生任何代码,如图7-4所示。 因为初始化的数值为常量,所以多次初始化也不会产生变化。因 此无须再做初始化标志,编译器直接以全局变量的方式进行处理,优 化了代码,提升了效率。虽然转换为了全局变量,但仍然不可以超出 作用域访问。那么编译器是如何让其他作用域对局部静态变量不可见 的呢?答案是通过名称粉碎法,在编译期将静态变量重新命名。对图

7-4中的静态变量g_static进行名称粉碎后,结果如图7-5所示。

堆变量

堆变量是所有变量表现形式中最容易识别的。在C\C++中,使用malloc与new申请堆空间,返回的数据便是申请的堆空间地址。使用free与delete释放堆空间,但需要在申请堆空间时得到的首地址,如果这个首地址丢失,将无法释放堆空间,最终导致内存泄漏。

其访问方式按作用域划分,C++中的new与delete属于运算符,在没有定义重载的情况下, 它们的执行过程与malloc、free类似。我们以malloc和new为例进行 介绍,如代码清单7-4所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// C++ 源码说明(VS2019 Debug编译选项):new与malloc
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char* argv[]) {
char * buffer1 = (char*)malloc(10); // 申请堆空间

char * buffer2 = new char[10]; // 申请堆空间

if (buffer2 != NULL){
delete[] buffer2; // 释放堆空间

buffer2 = NULL;
}
if (buffer1 != NULL){
free(buffer1); // 释放堆空间

buffer1 = NULL;
}
return 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
// malloc内部实现

extern "C" _CRT_HYBRIDPATCHABLE __declspec(noinline)
_CRTRESTRICT void* __cdecl malloc(size_t const size)
{
return _malloc_dbg(size, _NORMAL_BLOCK, nullptr, 0); //申请
堆空间

}
// new 内部实现

void* __CRTDECL operator new(size_t const size)
{
for (;;)
{
if (void* const block = malloc(size)) //申请堆空间

{
return block;
}
if (_callnewh(size) == 0)
{
if (size == SIZE_MAX)
{
__scrt_throw_std_bad_array_new_length();
}
else
{
__scrt_throw_std_bad_alloc();
}
}
}
}

使用new申请堆 空间最终也会用到malloc。当它们被执行后,会返回申请堆空间的首地址

堆空间的分配类似于商场中的商铺管理,malloc是从商场的空地 中划分出一块作为商铺,而new则是直接租用划分好的商铺。由于malloc没有经过商场的营业规定,

因此需要将申请好的堆进行强制转 换以说明其类型,而new则无须这种操作,可以直接使用。

当不再使用堆内存时,需要调用free与delete释放对应的堆。相当于退租时将商铺归还给商场,商场将商铺回收,用于下次出租。 那么这个出租、回收、再出租的过程如何实现呢?

物业部门利用 表格记录每次租出的商铺,回收商铺后,再修改表格中对应的记录, 将对应铺位的状态置为“空闲”。当再次租用时,便会检查空闲的商铺 是否符合要求,然后再次分配出租。堆空间的管理也是如此,通过表 格记录每次申请的堆空间的信息。 确定变量空间属于堆空间只要找到如下两个关键点。 空间申请:malloc与new等。 空间释放:free与delete等。 在使用IDA分析反汇编代码时,需要安装对应的SIG符号文件,这 样才可以在反汇编代码中快速识别malloc与new(高版本的IDA中默 认装有此符号文件,可直接识别),

通过对malloc与new的识别,可以得知此处在申请堆空间,进而 得到堆空间的首地址。知道了堆空间的起始处,如何找到其销毁处 呢?与malloc和new对应的是free和delete,只要确定free与delete释放的地址和malloc与new所申请的堆空间地址一致,即可确定该堆 空间的生命周期,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char* argv[]) {
char * buffer1 = (char*)malloc(10); // 申请堆空间

char * buffer2 = new char[10]; // 申请堆空间

if (buffer2 != NULL){
delete[] buffer2; // 释放堆空间

buffer2 = NULL;
}
if (buffer1 != NULL){
free(buffer1); // 释放堆空间

buffer1 = NULL;
}
return 0;
}

代码清单7-5分别使用了delete与free释放new和malloc申请的 堆空间。free与delete的识别原理和malloc与new相同,都需要装有 对应的SIG符号文件,如图7-10所示。

对比并分析图7-9与图7-10可得申请的堆空间的生命周期。

在分析过程中,关于堆空间的释放不能只看delete与free,还要结合new和malloc确认操作的是同一个堆空间。

数组和指针的寻址

虽然数组和指针都是针对地址操作的,但是它们也有许多不同之 处。

数组是相同数据类型的集合,以线性方式连续存储在内存中,

而指针只是一个保存地址值的4字节变量。在使用中,数组名是一个地址常量值,保存数组首元素地址,不可修改,只能以此为基地址访问内 存数据。

而指针是一个变量,只要修改指针中保存的地址数据,就可以随意访问,不受约束。本章将深入介绍数组的构成以及两种寻址方式

数组在函数内

在函数内定义数组时,如果无其他声明,该数组即为局部变量, 拥有局部变量的所有特性。数组中的数据存储在内存中是线性连续 的,数据排列顺序由低地址到高地址,数组名称表示该数组的首地址,如:

int ary[5] = {1,2,3,4,5};

此数组为5个int类型数据的集合,其占用的内存空间大小为sizeof(数据类型)×数组中元素个数,即4×5=20字节。

如果数组ary第一 项所在地址为0x0012FF00,那么第二项所在地址为0x0012FF04, 其寻址方式与指针相同。

这样看上去很像在函数内连续 定义了5个int类型的变量,但也不完全相同。

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
// C++ 源码

#include <stdio.h>
int main(int argc, char* argv[]) {
int ary[5] = {1, 2, 3, 4, 5};
int n1 = 1;
int n2 = 2;
int n3 = 3;
int n4 = 4;
int n5 = 5;
return 0;
}
//x86_vs对应汇编代码讲解

0040100B xor eax, ebp
0040100D mov [ebp-4], eax00401010 mov dword ptr [ebp-18h], 1 ;ary[0]=1
00401017 mov dword ptr [ebp-14h], 2 ;ary[1]=2
0040101E mov dword ptr [ebp-10h], 3 ;ary[2]=3
00401025 mov dword ptr [ebp-0Ch], 4 ;ary[3]=4
0040102C mov dword ptr [ebp-8], 5 ;ary[4]=5
00401033 mov dword ptr [ebp-1Ch], 1 ;n1=1
0040103A mov dword ptr [ebp-20h], 2 ;n2=2
00401041 mov dword ptr [ebp-24h], 3 ;n3=3
00401048 mov dword ptr [ebp-28h], 4 ;n4=4
0040104F mov dword ptr [ebp-2Ch], 5 ;n5=5
00401056 xor eax, eax
00401058 mov ecx, [ebp-4]
0040105B xor ecx, ebp

在编译器下,为字符类型的数组赋值(初始化)其实是复制字符串的过程。这里并不是单字节复制,而是每次复制一个寄存器大小的数据。两个内存间的数据传递需要借用寄存器,而每个寄存器一次可 以保存4或者8字节的数据,如果以单字节的方式复制就会浪费3或者7字节的空间,

而且多次数据传递也会降低执行效率,所以编译器采用4或者8个字节的复制方式,

1
2
3
4
#include <stdio.h>
int main(int argc, char* argv[]) {
char s[] = "Hello World";
return 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
//x86_vs对应汇编代码讲解

0040100B xor eax, ebp
0040100D mov [ebp-4], eax
00401010 mov eax, dword ptr ds:aHelloWorld ;eax="Hell"
00401015 mov [ebp-10h], eax
00401018 mov ecx, dword ptr ds:aHelloWorld+4 ;ecx="o Wo"
0040101E mov [ebp-0Ch], ecx
00401021 mov edx, dword ptr ds:aHelloWorld+8 ;edx="rld\0"
00401027 mov [ebp-8], edx
0040102A xor eax, eax
0040102C mov ecx, [ebp-4]
0040102F xor ecx, ebp

每个寄存器保存4或者8个字节的数据,并依次复制到字符数组s中。字符串长度为12字节,即4的倍数。若字符串长度不为4的倍数,

该如何复制数据呢?这个问题很好解决,只要在最后一次不等于4字节的数据复制过程中按照1或者2字节的方式复制即可。

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
//C++源码

#include <stdio.h>
int main(int argc, char* argv[]) {
char s[] = "Hello Worl";
return 0;
}//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 10h
00401006 mov eax, ___security_cookie
0040100B xor eax, ebp
0040100D mov [ebp-4], eax
00401010 mov eax, dword ptr ds:aHelloWorl ;"Hell"
00401015 mov [ebp-10h], eax
00401018 mov ecx, dword ptr ds:aHelloWorl+4 ;"o Wo"
0040101E mov [ebp-0Ch], ecx
00401021 mov dx, word ptr ds:aHelloWorl+8 ;"rl"
00401028 mov [ebp-8], dx
0040102C mov al, byte ptr ds:aHelloWorl+0Ah ;"\0"
00401031 mov [ebp-6], al
00401034 xor eax, eax
00401036 mov ecx, [ebp-4]
00401039 xor ecx, ebp

数组作为参数

当作为参数传递时,数组所占内存通常大于4字节,那么它是如何将数据传递到目标 函数中并使用的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <string.h>
void show(char buffer[]) { //参数为字符数组类型

strcpy(buffer, "Hello World"); //字符串复制

printf(buffer);
}
int main(int argc, char* argv[]) {
char buffer[20] = {0}; //字符数组定义

show(buffer); //将数组作为参数传递

return 0;
}

这是因为当数组作为函数形参时,函数参数中保存的是数组的首地址,这是一个指针变量。

虽然参数是指针变量,但需要特别注意的是,实参数组名为常量值,而指针或形参数组为变量。

使用sizeof(数组名)可以获取数组 的大小,而对指针或者形参中保存的数组名使用sizeof只能得到当前平台的指针长度。

在32位程序中,指针的长度为4字节,64位程序中指针的长度为8字节。因此,在编写代码的过程中应避免如下错误。

1
2
3
4
5
6
7
8
9
10
11
void show(char buffer[]){ //保存字符串长度变量

int len = 0;

//错误的使用方法,此时buffer为指针类型,并非数组,只能得到4或者8字节长度

len = sizeof(buffer);

//正确的使用方法,使用获取字符串长度函数

strlen len = strlen(buffer); }

字符串处理函数在Debug版下非常容易识别,而在Release版 下,它们会被作为内联函数进行编译处理,因此没有了函数调用指令call。

本节将以字符串复制函数strcpy作为示例进行讲解。在分 析strcpy前,需要先了解求字符串长度的函数strlen(),

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++源码

#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[]) {
char buffer[20] = {0}; //字符数组定义

strcpy(buffer, argv[0]); //字符串复制

printf(buffer);
return 0;
}
//x86_vs对应汇编代码讲解

00401010 sub esp, 18h
00401013 mov eax, ___security_cookie ;缓冲区溢出检查代码

00401018 xor eax, esp
0040101A mov [esp+14h], eax
0040101E mov eax, [esp+20h] ;eax=argv
00401022 lea edx, [esp] ;edx=buffer
00401025 xorps xmm0, xmm0 ;xmm0=0
00401028 mov dword ptr [esp+10h], 0 ;buffer最后4个字节初始化为000401030 movups xmmword ptr [esp], xmm0 ;使用xmm寄存器优化,

buffer前16字节初始化为0
00401034 mov eax, [eax] ;eax=argv[0]
00401036 sub edx, eax ;edx,保存两个缓冲区地址差值

00401038 nop dword ptr [eax+eax+00000000h] ;代码对齐

00401040 mov cl, [eax] ;取出argv字符

00401042 lea eax, [eax+1] ;地址指向下一个字符

00401045 mov [edx+eax-1], cl ;复制字符,通过argv[0]的地址加上差值
算出buffer的地址

00401049 test cl, cl
0040104B jnz short loc_401040 ;如果字符为\0,结束循环

0040104D lea eax, [esp]
00401050 push eax ;参数buffer入栈

00401051 call sub_401070 ;调用printf函数

在字符串初始化时,利用xmm寄存器初始化 数组的值,因为xmm是一个16个字节的寄存器,所以一次可以初始化

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
//C++源码

#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[]) {
char buffer[20] = {0}; //字符数组定义

strcpy(buffer, argv[0]); //字符串复制

printf(buffer);
return 0;
}
//x86_vs对应汇编代码讲解

00401010 sub esp, 18h
00401013 mov eax, ___security_cookie ;缓冲区溢出检查代码

00401018 xor eax, esp
0040101A mov [esp+14h], eax
0040101E mov eax, [esp+20h] ;eax=argv
00401022 lea edx, [esp] ;edx=buffer
00401025 xorps xmm0, xmm0 ;xmm0=0
00401028 mov dword ptr [esp+10h], 0 ;buffer最后4个字节初始化为000401030 movups xmmword ptr [esp], xmm0 ;使用xmm寄存器优化,

buffer前16字节初始化为0
00401034 mov eax, [eax] ;eax=argv[0]
00401036 sub edx, eax ;edx,保存两个缓冲区地址差值

00401038 nop dword ptr [eax+eax+00000000h] ;代码对齐

00401040 mov cl, [eax] ;取出argv字符

00401042 lea eax, [eax+1] ;地址指向下一个字符

00401045 mov [edx+eax-1], cl ;复制字符,通过argv[0]的地址加上差值
算出buffer的地址

00401049 test cl, cl
0040104B jnz short loc_401040 ;如果字符为\0,结束循环

0040104D lea eax, [esp]
00401050 push eax ;参数buffer入栈

00401051 call sub_401070 ;调用printf函数

00401056 mov ecx, [esp+18h]
0040105A add esp, 4
0040105D xor ecx, esp ;缓冲区溢出检查代码

0040105F xor eax, eax
00401061 call @__security_check_cookie@4
00401066 add esp, 18h
00401069 retn

在字符串初始化时,利用xmm寄存器初始化 数组的值,因为xmm是一个16个字节的寄存器,所以一次可以初始化16个字节的值,这样效率更高。最后使用循环拷贝字符串,直到复制 到’\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
// C++ 源码

#include <stdio.h>
char* retArray(){
char buffer[] = {"Hello World"};
return buffer;
}
int main(int argc, char* argv[]) {
printf("%s\r\n", retArray());
return 0;
}

//x86_vs对应汇编代码讲解
00401043 call sub_401000 ;调用retArray()函数
00401048 push eax ;使用RetArray返回数组作为printf参数使用
00401049 push offset aS ;"%s\r\n"
0040104E call sub_4010A0 ;调用printf函数




0040100B xor eax, ebp
0040100D mov [ebp-4], eax
00401010 mov eax, ds:dword_412160 ;字符串数组初始化为字符串

00401015 mov [ebp-10h], eax
00401018 mov ecx, ds:dword_412164
0040101E mov [ebp-0Ch], ecx
00401021 mov edx, ds:dword_412168
00401027 mov [ebp-8], edx
0040102A lea eax, [ebp-10h] ;使用eax保存数组首地址,作为函数返回
值。虽然eax保存的地址存在,但是当函数结束调用后,此地址中的数据将不稳定,在其他对栈空间读写操作
时可能破坏此数据

函数retArray()中定义了数组buffer,由于数组buffer为局部变量,因此其所占内存空间的位置在栈空间内,生命周期随函数的退出而结束。

而在函数结束后,将数组的首地址赋值到eax中作为返回值。

虽然这个地址始终存在,但它是栈空间中的某段内存空间,其中的数据会在作用域切换时被新数据替换,

因此返回局部变量的地址随时会产生错误。在编译期间,VS编译器也对此做出了警告处理。

为了更好地帮助大家了解这个错误的严重性,我们通过图8-4查看 进入函数后栈中数据的变化。

在图8-4中,返回了函数GetNumber()中定义的局部数组的首地 址nArray,其所在地址处于0x0012FF00~0x0012FF1C。

当函数调用结束后,栈顶指向了地址0x0012FF1C。此时数组nArray中的数据 已经不稳定,任何栈操作都有可能将其破坏。在执行“printf(“%d”, pArray[7]);” 后 ,

由于需要将参数压栈地址0x0012FF1C~0x0012FF18之间的数据已经被破坏,无法输出正常 结果。

如果既想使用数组作为返回值,

可以使用全局数组、静态数组或上层调用函数中定义的局部数组。全局数组与静态数组都属于变量,它们的特征与全局变量、静态变量相同,看 上去就是连续定义的多个同类型的变量,如图8-5所示

图8-5定义了5个4字节数据,分别为1、2、3、4、5,是不是和 全局变量非常相似呢?在分析全局数组的过程中,应考察数据的访问 方式以及元素长度,

静态数组在全局情况下和全局数组相同。作为局部作用域定义 时,则同样会检查相应的标志位,并对局部静态数组元素赋值。与局部静态变量有些不同,无论局部静态数组有多少个元素,也只会检查 一次初始化标志位,

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++ 源码

#include <stdio.h>
int main(int argc, char* argv[]) {
int n1;
int n2;
scanf("%d%d", &n1, &n2);
static int ary[5] = {n1, n2, 0}; //局部静态数组初始化第二项为常量

return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 8
00401006 lea eax, [ebp-8] ;eax=&n2
00401009 push eax
0040100A lea ecx, [ebp-4] ;ecx=&n1
0040100D push ecx
0040100E push offset aDD ;"%d%d"00401013 call sub_4010D0 ;调用scanf函数

00401018 add esp, 0Ch
0040101B mov edx, TlsIndex
00401021 mov eax, large fs:2Ch
00401027 mov ecx, [eax+edx*4]
0040102A mov edx, dword_41A8CC
00401030 cmp edx, [ecx+4]
00401036 jle short loc_401084
00401038 push offset dword_41A8CC
0040103D call sub_401219
00401042 add esp, 4
00401045 cmp dword_41A8CC, 0FFFFFFFFh
0040104C jnz short loc_401084 ;检测初始化标志

0040104E mov eax, [ebp-4]
00401051 mov dword_41A8B8, eax ;ary[0]=n1
00401056 mov ecx, [ebp-8]
00401059 mov dword_41A8BC, ecx ;ary[1]=n2
0040105F mov dword_41A8C0, 0 ;ary[2]=0
00401069 xor edx, edx
0040106B mov dword_41A8C4, edx ;ary[3]=0
00401071 mov dword_41A8C8, edx ;ary[4]=0
00401077 push offset dword_41A8CC
0040107C call sub_4011CF ;调用printf函数s

下标寻址和指针寻址

访问数组的方法有两种:通过下标访问(寻址)和通过指针访问 (寻址)。

指针寻址的方式不但没有下标寻址便利,而且效率也比下标寻址低。

因为指针是存放地址数据的变量类型,所以在数据访问的过程中需要先取出指针变量中的数据,然后针对此数据进行地址偏移计算,从而寻址到目标数据。

数组名本身就是常量地址,可直接针对数组名代替的地址值进行偏移计算。

对比这两种寻址方式的差别,看一看两者间的效率差距。

1
2
3
4
5
6
7
8
9
10
11
// C++ 源码
#include <stdio.h>
int main(int argc, char* argv[]) {
char *p = NULL;
char buffer[] = "Hello";
p = buffer;
printf("%c", *p);
printf("%c", buffer[0]);
return 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
//x86_vs对应汇编代码讲解
0040100B xor eax, ebp
0040100D mov [ebp-4], eax
00401010 mov dword ptr [ebp-10h], 0 ;初始化指针变量为空指针,

p=NULL
00401017 mov eax, dword ptr ds:aHello
0040101C mov [ebp-0Ch], eax
0040101F mov cx, word ptr ds:aHello+400401026 mov [ebp-8], cx ; 初 始 化 数 组 , [ebp0Ch]=buffer[]="Hello"
0040102A
lea edx, [ebp-0Ch] ;获取数组首地址,edx=buffer
0040102D mov [ebp-10h], edx ;p=buffer
00401030 mov eax, [ebp-10h] ;取出指针变量中保存的地址数据

00401033 movsx ecx, byte ptr [eax] ;字符型指针的间接访问间接访问后传参

00401036 push ecx
00401037 push offset unk_412168
0040103C call sub_4010B0 ;调用printf函数

00401041 add esp, 8
00401044 mov edx, 1
00401049 imul eax, edx, 0
0040104C movsx ecx, byte ptr [ebp+eax-0Ch]
00401051 push ecx ;将取出数据作为参数

00401052 push offset unk_41216C
00401057 call sub_4010B0 ;调用printf函数

分别使用了指针寻址和下标寻址两种方式访问字符 数组buffer。

从这两种访问方式的代码实现来看,指针寻址方式要经 过2次寻址才能得到目标数据,而下标寻址方式只需要1次。指针寻址 比下标寻址多了一次寻址操作,效率自然要低。

虽然使用指针寻址方式需要经过2次间接访问,效率较低,但其灵活性更强,可通过修改指针中保存的地址数据,访问其他内存中的数 据,

而数组下标在没有越界使用的情况下只能访问数组内的数据。

下标值为整型常量的寻址

在下标值为常量的情况下,由于类型大小为已知数,编译器可以 直接计算得出数据所在的地址。其寻址过程和局部变量相同,分析过 程如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int ary[5] = {1, 2, 3, 4, 5};
mov dword ptr [ebp-14h],1 ; 数组初始化,首地址为 ebp-14h
mov dword ptr [ebp-10h],2
mov dword ptr [ebp-0Ch],3
mov dword ptr [ebp-8],4
mov dword ptr [ebp-4],5
printf("%d \r\n", ary[2]);
; 由于下标值为常量2,可直接计算出地址值,其运算过程如下:

; ebp-14h + sizeof(int)*2h = ebp - 14h + 4h*2h = ebp - 14h + 8最

; 终得到地址ebp-0Ch
mov eax,dword ptr [ebp-0Ch]

当下标值为变量时,编译器无法算出对应的地址,只能先进行地 址偏移计算,再得出目标数据所在的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
; 数组各元素的地址同上

printf("%d \r\n", ary[argc]);
;变量argc类型为整型,所在地址为ebp+8
mov[0] ecx,dword ptr [ebp+8]; 取得下标值存入ecx
; 使用ecx乘以数据类型的大小(4字节长度),得到数据偏移地址

; 根据ebp+ecx*4-14h可以确认这是数组的下标寻址

; 根据我们给出的公式,这样写可能更容易理解:ebp-14h+ecx*4
; ebp-14h为数组首地址,ecx是下标,4是元素类型的大小

mov edx,dword ptr [ebp+ecx*4-14h]

下标值为整型表达式的寻址

当下标值为表达式时,会先算出表达式的结果,然后将其作为下 标值。

如果表达式为常量计算,则编译过程中执行常量折叠,编译时 提前计算出结果,其结果依然是常量,最后还是以常量作为下标,藉 此寻址数组内元素。以表达式ary[2*2]为例,编译过程中计算2×2得 到4,将4作为整型常量下标值进行寻址,

其结果等价于ary[4]。 接下来我们通过下面的代码看看表达式中使用未知变量的寻址过 程。

1
2
3
4
5
6
7
8
9
10
数组中各元素的地址同上

printf("%d \r\n", ary[argc * 2]);
; 变量argc的类型为整型,所在地址为ebp+8
mov eax,dword ptr [ebp+8] ; 取下标变量数据存入eax
shl eax,1 ; 对eax执行左移1位,这一步等同于乘以2
; 将argc乘以2的结果作为下标值乘以数组的类型大小为4,从而寻址到数组中元素的
地址

mov ecx,dword ptr [ebp+eax*4-14h]

数组下标寻址使用的方案和指针寻址公式非常相似,都是利用首 地址加偏移量。

数组的3种下标寻址方案同样也可以应用在指针寻址 中。 在编译器中,不会对数组的下标进行访问检查,使用数组时很容易出现越界访问的错误。当下标值小于0或大于数组下标最大值时,会访问到数组邻近定义的数据,造成越界访问,进而导致程序崩溃,或 者产生更为严重的后果,如代码清单8-10所示

1
2
3
4
5
6
7
8
9
10
// C++ 源码

#include <stdio.h>int main(int argc, char* argv[]) {
int ary[4] = {1, 2, 3, 4};
int n = 5;
printf("%d", ary[-1]); //利用数组越界访问,读取变量n并显示

return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//x86_vs对应汇编代码讲解


00401010 mov dword ptr [ebp-14h], 1
00401017 mov dword ptr [ebp-10h], 2
0040101E mov dword ptr [ebp-0Ch], 3
00401025 mov dword ptr [ebp-8], 4 ;ary[4] = {1, 2, 3, 4};
0040102C mov dword ptr [ebp-18h], 5 ;n=5
00401033 mov eax, 4
00401038 imul ecx, eax, -1 ;ecx=-1*4
0040103B mov edx, [ebp+ecx-14h] ;ary[-1] , edx=[ebp-14h+ecx]=[ebp-18h]
0040103F push edx ;n入栈

00401040 push offset unk_412160
00401045 call sub_4010A0 ;调用printf函数

数组寻址使用了负数作为下标值,将数组下标 寻址ary[-1]代入寻址公式

1
2
3
4
5
ary[-1]=ary + sizeof(int) * (-1)
=ebp - 14h + 4 * (-1)
=ebp - 14h - 4
=ebp - 18h

最终访问到地址ebp-18h处,这正是变量n的地址。根据局部变量 的定义顺序,人为将变量定义在数组下,从而造成负数下标的越界访 问。注意:局部变量的定义顺序和对齐由编译器决定,因此同样的代 码在其他编译器上可能会访问未知数。同理,变量n定义在数组前,使 用下标值6也将会越界访问到变量n

下标寻址方式也可以被指针寻址方式代替,但指针寻址方式需要 经过两次间接访问,

第一次是访问指针变量,第二次才能访问到数组元素,故指针寻址的执行效率不会高于下标寻址,但是在使用的过程 中会更加方便。

数组下标和指针寻址如此相似,如何在反汇编代码中区分它们 呢?

只要抓住一点即可,那就是指针寻址需要两次以上间接访问才可 以得到数据。

因此,在出现了两次间接访问的反汇编代码中,如果第 一次间接访问得到的值作为地址,则必然存在指针。图8-6中使用寄存 器作为指针变量,保存全局数组的地址,从而利用保存了全局数组首 地址寄存器对该数组进行间接访问操作。 数组下标寻址的识别相对复杂,下标为常量时,由于数组的元素 长度固定,sizeof(type)*n也为常量,产生了常量折叠,编译前可直 接算出偏移量,因此只须使用数组首地址作为基址加偏移即可寻址相 关数据,不会出现二次寻址的情况。当下标为变量或者变量表达式 时,会明显体现出数组的寻址公式,且发生两次内存访问,但是和指 针寻址明显不同,第一次访问的是下标,这个值一般不会作为地址使 用,且代入公式计算后才得到地址。

寻址代码

1
2
3
4
5
6
7
8
9
10
11
// C++ 源码
#include <stdio.h>
int main(int argc, char* argv[]) {
int ary[5] = {1,2,3,4,5 };
int b=2;
printf("%d \r\n", ary[2]);
printf("%d \r\n", ary[b]);
printf("%d \r\n", ary[argc * 2]);
return 0;
}

多维数组

实际上,编译器 采用了非常简单有效的方法,将多维数组通过转化重新变为一维数组,如二维整型数组:int n Array[2] [2],经过转换后可用一维数组表示为int n Array[4]。

它们 在内存中的存储方式相同

两者在内存中的排列顺序相同,可见在内存中根本没有多维数组。

二维数组甚至多维数组的出现只是为了方便开发者计算偏移地址、寻址数组数据。

计算二维数组的大小非常简单,一维数组使用类型大小乘以下标值,得到一维数组占用内存大小。

二维数组中的二维下标值为一维数组的个数,因此只要将二维数组的下标值乘以一维数组占用内存大小,即可得知二维数组的大小。

求得二维数组的大小后,如何计算地址偏移呢?

根据之前的学习,我们知道一维数组的寻址公式为数组首地址+类型大小*下标值。

计算二维数组的地址偏移要先分析二维数组的组成部分,如整型二维数组int nArray[2] [3]可拆分为如下三个部分。

数组首地址:nArray。 一维元素类型:int[3],此下标值记作j。

类型:int。 元素个数:[3]。 一维元素个数:[2],此下标值记作i。

上述二维数组的组成可理解为两个一维整型数组的集合,而这两个一维数组又各自拥有3个整型数据。

在地址偏移的计算过程中,先计算首地址到一维数组间的偏移量,利用数组首地址加上偏移量,得到某个一维数组所在地址。

以此地址为基地址,加上一维数组中数据地 址偏移,寻址到二维数组中的某个数据。

寻址公式如下。

数组首地址+sizeof(type[J])*二维下标值+sizeof(type)*一维下标值

二维以上数组的寻址同理,多维数组的组成可看作一个包裹套小包裹。

如三维数组int nArray[2] [3] [4],最左侧的int nArray[2]为第一层包内数据,下标值2说明在第一层包裹中有两个二维数组int [3] [4]小包裹。

打开其中一个小包裹,里面包着一个一维数组int[4]。

打开另一个小包裹,里面包含一个int类型的数据。

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
// C++ 源码

#include <stdio.h>
int main(int argc, char* argv[]) {
int i = 0;
int j = 0;
int ary1[4] = {1, 2, 3, 4}; //一维数组
int ary2[2][2] = {{1, 2},{3, 4}}; //二维数组

scanf("%d %d", &i, &j);
printf("ary1 = %d\n", ary1[i]);
printf("ary2 = %d\n", ary2[i][j]);
return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 2Ch
00401006 mov eax, ___security_cookie
0040100B xor eax, ebp
0040100D mov [ebp-4], eax
00401010 mov dword ptr [ebp-28h], 0 ;i=0
00401017 mov dword ptr [ebp-2Ch], 0 ;j=0
0040101E mov dword ptr [ebp-14h], 1
00401025 mov dword ptr [ebp-10h], 2
0040102C mov dword ptr [ebp-0Ch], 3
00401033 mov dword ptr [ebp-8], 4 ;一维数组初始化, ary1[4] =
{1, 2, 3, 4}
0040103A mov dword ptr [ebp-24h], 1
00401041 mov dword ptr [ebp-20h], 2
00401048 mov dword ptr [ebp-1Ch], 3
0040104F mov dword ptr [ebp-18h], 4 ;二维数组初始化和一维数组没
有任何区别

;从初始化反汇编代码中无法区分一维数组与二维数组

00401056 lea eax, [ebp-2Ch] ;eax=&j
00401059 push eax
0040105A lea ecx, [ebp-28h] ;ecx=&i
0040105D push ecx
0040105E push offset aDD ;"%d %d"
00401063 call sub_401170 ;调用scanf函数

00401068 add esp, 0Ch
0040106B mov edx, [ebp-28h] ;edx=i
0040106E mov eax, [ebp+edx*4-14h] ;此处获取数组中数据的地址偏移,
寻址下标值为i
;被保存在edx中,使用edx*4等同于公式中的

;sizeof(type)*下标值,这样就剩下ebp-14h
;它是数组ary1首地址,寻址到偏移地址处,取出

;其中数据,存入eax
00401072 push eax
00401073 push offset aAry1D ;"ary1 = %d\n"
00401078 call sub_401130 ;调用printf函数
0040107D add esp, 8
00401080 mov ecx, [ebp-28h] ;ecx=i
00401083 lea edx, [ebp+ecx*8-24h] ;同样是计算偏移,但这里获取的不
是数据,而是地

;址值,与一维数组ary1有些类似,同样是使用首

;地址加偏移,二维数组aryt2首地址为ebp-24h
;剩下ecx*8为偏移,此处计算为公式中sizeof
;(int[2])* 下标值,得出一维数组首地址并

;保存到edx
00401087 mov eax, [ebp-2Ch] ;eax=j
0040108A mov ecx, [edx+eax*4] ;获取下标值j到eax中,此处又回归到一
维数组寻

;址,edx为数组首地址,eax*4为偏移计算

;sizeof(type)*下标值

0040108D push ecx
0040108E push offset aAry2D ;"ary2 = %d\n"
00401093 call sub_401130 ;调用printf函数

00401098 add esp, 8
0040109B xor eax, eax
0040109D mov ecx, [ebp-4]
004010A0 xor ecx, ebp
004010A2 call @__security_check_cookie@4 ;
__security_check_cookie(x)
004010A7 mov esp, ebp
004010A9 pop ebp
004010AA retn

存放指针类型数据的数组

存放指针类型数据的数组中,各数据元素都是由相同 类型的指针。

指针数组的语法如下。组成部分1 组成部分2 组成部分3 类型名* 数组名称 [元素个数];

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
#include <stdio.h>
int main(int argc, char* argv[]) {
char * ary[3] = {"Hello ", "World ", "!\n"};//字符串指针数组定


for (int i = 0; i < 3; i++) {
printf(ary[i]); //显示输出字符串数组中的各项

}
return 0;
}


0040100B xor eax, ebp
0040100D mov [ebp-4], eax
00401010 mov dword ptr [ebp-10h], offset aHello
;ary[0]="Hello "
00401017 mov dword ptr [ebp-0Ch], offset aWorld
;ary[1]="World "
0040101E mov dword ptr [ebp-8], offset asc_412170
;ary[2]="!\n"
00401025 mov dword ptr [ebp-14h], 0 ;i=0
0040102C jmp short loc_401037 ;for循环

0040102E mov eax, [ebp-14h] ;eax=i
00401031 add eax, 1
00401034 mov [ebp-14h], eax ;i++
00401037 cmp dword ptr [ebp-14h], 3
0040103B jge short loc_40104F
0040103D mov ecx, [ebp-14h] ;ecx=i
00401040 mov edx, [ebp+ecx*4-10h] ;edx=ary[i]
00401044 push edx ;字符串地址入栈

00401045 call sub_4010A0 ;调用printf函数

定义了字符串数组,该数组由3个指针变量组成,故32位程序长度为12字节;

64位程序长度为24字节。该数组指向的字符串长度和数组本身没有关系,而二维字符数组则与之不同。

指针数组用二维数组表示如下。

1
char ary[3] [10] = {{"Hello "},{"World "},{"!\r\n"}};

同样存储了3个字符串,指针数组中存储的是各字符串的首地址, 而二维字符数组中存储的是每个字符串的字符数据。

二维字符数组的初始化如下。

1
2
3
4
5
6
7
8
9
10
char ary[3][10] = { "Hello ",
mov eax,[string "Hello " (00420f84)] ; 一维数组初始化过程
mov dword ptr [ebp-30h],eax
mov cx,word ptr [string "Hello "+4 (00420f88)]
mov word ptr [ebp-2Ch],cx
mov dl,byte ptr [string "Hello "+6 (00420f8a)]mov byte ptr [ebp-2Ah],dl
xor eax,eax
mov word ptr [ebp-29h],ax
mov byte ptr [ebp-27h],al
{"World "},{"!\r\n"}}; // 初始化分析略

在二维字符数组初始化的过程中,赋值的不是字符串地址,而是 字符数据,据此可以明显地将二维字符数组与字符指针数组区分开。

如果代码中没有初始化操作,就需要分析如何寻址数据。获取二维字 符数组中的数据过程如下

1
2
3
4
5
6
7
8
9
10
11
12
printf(ary[i]);
mov edx,dword ptr [ebp-24h]
在二维字符数组ary中,一维数组大小为10
用下标值乘以0Ah以偏移到下一个一维数组首地址

imul edx,edx,0Ah
lea eax,[ebp+edx-20h] ; 取一维数组地址

push eax
call printf (00401160)
add esp,4

虽然二维字符数组和指针数组的寻址过程非常相似,但依然有一 些不同。字符指针数组寻址后,得到的是数组成员内容

,而二维字符 数组寻址后得到的是数组中某个一维数组的首地址。

指向数组的指针变量

当指针变量保存的数据为数组的首地址,且将此地址解释为数组时,该指针变量被称为数组指针。指向数组元素的指针很简单,只要是指针变量,都可以用于寻址该类型一维数组的各元素,得到数组中的数据。而指向一维数组的数组指针会有些变化,指向一维数组的数组指针定义格式如下。

组成部分1 组成部分2 组成部分3

类型名 (*指针变量名称) [一维数组大小];

例 如 , 对 于 二 维 字 符 数 组 “

1
char ary[3][10] = {{"Hello"}, {"World"},{"!\r\n"}};”

,定义指向这个数组的指针为“char (*p) [10] = ary;”,那么数组指针如何访问数组成员呢?见代码清单8- 14。

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
#include <stdio.h>
int main(int argc, char* argv[]) {
char ary[3][10] = {"Hello ","World ","!\n"};
char (*p)[10] = ary;
for (int i = 0; i < 3; i++) {
printf(*p);
p++;
}
return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 2Ch00401006 mov eax, ___security_cookie ;缓冲区溢出检查代码

0040100B xor eax, ebp
0040100D mov [ebp-4], eax
00401010 mov eax, dword ptr ds:aHello
00401015 mov [ebp-24h], eax
00401018 mov cx, word ptr ds:aHello+4
0040101F mov [ebp-20h], cx
00401023 mov dl, byte ptr ds:aHello+6
00401029 mov [ebp-1Eh], dl ;ary[0] ={”Hello \0”}
0040102C xor eax, eax ;剩余3个字节空间补’\0
0040102E mov [ebp-1Dh], ax
00401032 mov [ebp-1Bh], al
00401035 mov ecx, dword ptr ds:aWorld
0040103B mov [ebp-1Ah], ecx
0040103E mov dx, word ptr ds:aWorld+4
00401045 mov [ebp-16h], dx
00401049 mov al, byte ptr ds:aWorld+6
0040104E mov [ebp-14h], al ;ary[1] ={”World \0”}
00401051 xor ecx, ecx ;剩余3个字节空间补’\0
00401053 mov [ebp-13h], cx
00401057 mov [ebp-11h], cl
0040105A mov dx, word ptr ds:asc_412170 ; "!\n"
00401061 mov [ebp-10h], dx
00401065 mov al, byte ptr ds:asc_412170+2
0040106A mov [ebp-0Eh], al ;ary[2] ={”!\n\0”}
0040106D xor ecx, ecx ;剩余7个字节空间补’\0
0040106F mov [ebp-0Dh], ecx
00401072 mov [ebp-9], cx
00401076 mov [ebp-7], cl
00401079 lea edx, [ebp-24h] ;edx=ary
0040107C mov [ebp-2Ch], edx ;p=ary
0040107F mov dword ptr [ebp-28h], 0 ;i=0
00401086 jmp short loc_401091 ;for循环

00401088 mov eax, [ebp-28h]
0040108B add eax, 1
0040108E mov [ebp-28h], eax ;i++
00401091 cmp dword ptr [ebp-28h], 3
00401095 jge short loc_4010AE ;如果i>=3结束循环

00401097 mov ecx, [ebp-2Ch] ;ecx=*p
0040109A push ecx
0040109B call sub_401100 ;调用printf函数

004010A0 add esp, 4
004010A3 mov edx, [ebp-2Ch]
004010A6 add edx, 0Ah
004010A9 mov [ebp-2Ch], edx ;p=p+10004010AC jmp short loc_401088
004010AE xor eax, eax
004010B0 mov ecx, [ebp-4]
004010B3 xor ecx, ebp
004010B5 call @__security_check_cookie@4 ;缓冲区溢出检查代码

004010BA mov esp, ebp
004010BC pop ebp
004010BD retn

代码清单8-14中的数组指针p保存了二维字符数组ary的首地址, 对p执行+=1操作后,指针p中保存的地址值增加了10字节。这个数值 是如何计算出来的呢?请看如下指针加法公式

指针变量 += 数值⇔指针变量地址数据 +=(sizeof( 指针类型 ) * 数值)

代码清单8-14中的数组指针p类型为char[10],求得其大小为10

字节。对p执行加1操作,实质是对p中保存的地址加10。加1后偏移到 地址为二维字符数组ary中的第二个一维数组首地址,即&(ary[1])。 对指向二维数组的数组指针执行取内容操作后,得到的还是一个 地址值,再次执行取内容操作才能寻址到二维字符数组中的单个字符 数据。这个过程看上去与二级指针相似,实际上并不一样。二级指针 是指针类型,其偏移长度在32位下固定为4字节,而数组指针的类型 为数组,其偏移长度随数组而定,两者的偏移计算不同,不可混为一 谈。 二级指针可用于保存一维指针数组。如对于一维指针数组char* p[3],可用char* pp保存其数组首地址。通过对二级指针pp进行3次 寻址即可得到数据。在第3章我们接触过数组指针,在利用C++生成 的控制台工程中,main()函数的定义(main( int argc, charargv[ ], char *envp[ ] ))中有3个参数。

argc:命令行参数个数,整型。

argv:命令行信息,保存字符串数组首地址的指针变量,是一个 指向数组的指针。

envp:环境变量信息,和argv类型相同。 参数argv与envp就是两个指针数组。当数组作为参数时,实际上 以指针方式进行数据传递。这里两个参数可转换为char**二级指针类 型,修改为main(int argc, char **argv, char **envp)。 通过运行程序传入3个命令行参数,查看数组指针argv的寻址过 程。命令行参数的传入方式有很多种,本节通过运行程序传入3个命令 行参数:“Hello”“World”“!”,命令行设置如图8-10所示。

在默认情况下,使用C++编译器生成的控制台会有一个命令行参 数,这个参数信息为当前程序所在路径,因此在命令行字符串数组

argv的第0项中保存着路径字符串首地址。通过图8-10所示的命令行 设置,数组argv的第1项为字符串“Hello”的首地址。编写代码,显示 命令行中的信息并分析指针数组如何寻址,如代码清单8-15所示。

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
#include <stdio.h>
int main(int argc, char* argv[]) {
for (int i = 1; i < argc; i++){ //跳过第一个命令行参数

printf(argv[i]); //获取命令行参数信息

}
return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 push ecx
00401004 mov dword ptr [ebp-4], 1 ;i=1
0040100B jmp short loc_401016 ;for循环

0040100D mov eax, [ebp-4]
00401010 add eax, 1
00401013 mov [ebp-4], eax ;i++
00401016 mov ecx, [ebp-4]
00401019 cmp ecx, [ebp+8]
0040101C jge short loc_401032 ;如果i>=argc结束循环

0040101E mov edx, [ebp-4] ;edx=i00401021 mov eax, [ebp+0Ch] ;eax=argv,得到组首地址

00401024 mov ecx, [eax+edx*4] ;ecx=argv[i]
00401027 push ecx
00401028 call sub_401080 ;调用printf函数

0040102D add esp, 4
00401030 jmp short loc_40100D
00401032 xor eax, eax
00401034 mov esp, ebp
00401036 pop ebp
00401037 retn

代码清单8-15中的字符串指针数组寻址过程和一维数组相同,都 是取下标、取数组首地址。利用相对比例因子寻址方式,访问内存得 到数据。需要注意的是,代码清单8-15中的argv是一个参数,它保存 着 字 符 串 数 组 的 首 地 址 , 因 此 需 要 执 行 “mov eax,dword ptr [ebp+0Ch]”指令取其内容,得到数组首地址。 在使用数组指针的过程中,经常在定义数组指针中出现类型匹配 错误。有什么方法可以根据多维数组的类型,快速匹配出对应的数组 指针类型呢?通过指定数组下标可以实现这一目标,如三维数组“int ary[2][3][4];”,其数组指针定义如下。

int (*p)[3][4] = ary;

三维数组指针变量名称为*p,替换原三维数组中的数组名称及三 维下标ary[2]。数组转换数组指针的规则总结如下。

数组 数组指针 类型 数组名称 [ 最高维数 ] [X][Y]…… 类型 (* 数组指针名称 ) [X][Y]……

在定义数组指针时,为什么只有最高维数可以省去?先来看普通 的指针变量寻址过程。

假设:整型指针变量*p中保存的地址为0x0012FF00,对其执行+=1操作

p += 1; p = 0x0012FF00 + sizeof(int); p = 0x0012FF04

指针在运算过程中需要计算偏移量,因此需要知道数据类型。在 多维数组中,可以将最高维看作一维数组,其后数据是这个一维数组 中各元素的数据类型。例如:int ary[3][4][5]同int[4][5] ary[3]一 样 , 可 将 int[4][5] 看 作 一 个 整 体 的 数 据 类 型 , 记 作 int[4][5] * p=ary。由于C++语法中没有此种语法格式,故无法使用,正确的语 法格式为int (*p)[4][5] =ary;,使用括号是为了与指针数组进行区 分。虽然指针与数组间的关系千变万化,错综复杂,但只要掌握了它 们的寻址过程,就可通过偏移量获得它们的类型以及之间的关系。

函数指针

既然是地址,当然可以使用指针变量存 储,用于保存函数首地址的指针变量被称为函数指针。 函数指针的定义很简单,和函数的定义相似,由以下4部分组成。

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

函数指针的类型由返回值、参数信息、调用约定组成,它们决定 了函数指针在函数调用过程中参数的传递、返回值信息以及如何平衡 栈顶。在没有特殊说明的情况下,调用约定与C++编译器中的设置相 同。如何区分函数调用与函数指针的调用呢?

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
#include <stdio.h>
void _cdecl show() { //函数定义

printf("show\n");
}
int main(int argc, char* argv[]) {
void (_cdecl *pfn)(void) = show; //函数指针赋值

pfn(); //使用函数指针调用函数

show(); //直接调用函数

return 0;
}

//x86_vs对应汇编代码讲解
00401020 push ebp
00401021 mov ebp, esp
00401023 push ecx
00401024 mov dword ptr [ebp-4], offset sub_401000
;pfn=show,函数名称即为函数首地址,这是一个常量地址值

0040102B call dword ptr [ebp-4] ;pfn(),间接调用函数
0040102E call sub_40100 ;show(),直接调用函数
00401033 xor eax, eax
00401035 mov esp, ebp
00401037 pop ebp
00401038 retn

演示了函数指针的赋值和调用过程,与函数调用的最大区别在于函数是直接调用的,而函数指针需要取出指针变量中保 存的地址数据,间接调用函数。

函数指针是比较特殊的指针类型,因为其保存的地址数据为代码段内的地址信息,而非数据区,所以不存在地址偏移的情况。

指针的操作非常灵活,为了防止函数指针发生错误的地址偏移,C++编译器 在编译期间对其进行检查,不允许对函数指针类型变量执行加法和减 法等没有意义的运算

函数指针类型的参数和返回值都为void类 型,只能存储相同类型的函数地址,否则无法传递函数的参数、返回值,无法正确平衡栈顶。

分析带参数与返回信息 的函数指针类型

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
// C++ 源码

#include <stdio.h>
int _stdcall show(int n) { //函数定义

printf("show : %d\n", n);
return n;
}
int main(int argc, char* argv[]) {
int (_stdcall *pfn)(int) = show; //函数指针定义并初始化

int ret = pfn(5); //使用函数指针调用函数并获取返回值

printf("ret = %d\n", ret);
return 0;
}
//x86_vs对应汇编代码讲解

00401020 push ebp
00401021 mov ebp, esp
00401023 sub esp, 8
00401026 mov dword ptr [ebp-4], offset sub_401000
;初始化过程没有变换,仍然为获取函数首地址并保存

0040102D push 5 ;压入参数5
0040102F call dword ptr [ebp-4] ;获取函数指针中地址,间接调用函数

00401032 mov [ebp-8], eax ;接收函数返回值数据

00401035 mov eax, [ebp-8]
00401038 push eax
00401039 push offset aRetD ;"ret = %d\n"
0040103E call sub_401090 ;调用printf函数

00401043 add esp, 8
00401046 xor eax, eax
00401048 mov esp, ebp
0040104A pop ebp
0040104B retn

代码清单8-17中函数指针的调用只是多了传递参数和接收返回 值,其他内容和代码清单8-16中的函数指针并无实质区别。它们有着 共同特征——都是间接调用函数,这是识别函数指针的关键。

由于数组的本质是同类元素的集合,各元素在内存中按顺序排 列,因此类型为type的数组ary第n个元素的地址可以表达为&ary[n] = ary的首地址+ sizeof(type)*n,编译器在此基础上开展各类优 化。

看到等价于这个公式的行为就可以确定是数组访问。数组元素的访问代码被编译器优化 后,可能会直接看到[ebp-n]这样的访问,

虽然在开始分析时这样的情况只能定性为局部变量,但是如果后来发现这类变量在内存中连续且 类型一致,就可以考虑将其还原为数组。

结构体和类

在C++中,结构体和类都具有构造函数、析构函数和成员函数,

两者只有一个区别:结构体的访问控制默认为public,而类的默认访问控制是private。

对于C++中的结构体而言,public、private、protected的访问控制都是在编译期进行检查,当越权访问时,编译过 程中会检查此类错误并给予提示。

编译成功后,程序在执行过程中不 会在访问控制方面做任何检查和限制。

因此,在反汇编中,C++的结 构体与类没有分别,两者的原理相同,只是类型名称不同,本章使用 的示例多为类。

对象的内存布局

结构体和类都是抽象的,在真实世界中它们只可以表示某个群 体,无法确定这个群体中的某个独立个体,而对象则是群体中独立存 在的个体。

例如,地球上最智慧的生物是人,人便是抽象事物,可以 看作是一个类。“人”只能描述这个类型的事物具有哪些特征,无法得 知具体是哪一个人。

在“人”这个类中,如关羽、张飞等都是独立存在 的实体,可被看作“人”这个类中的实体对象。 人 → 类、结构,抽象的概念 关羽 → 实例对象,

实际存在的事物 由于类是抽象概念,当两个类的特征相同时,它们之间就是相等 的关系。

而对象是实际存在的,即使它们之间包含的数据相同,也不 能视为同一个对象。

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
//C++源码

#include <stdio.h>
class Person { //Person为抽象类名称,如同“人”这个名称

public:
Person() {
age = 18;
height = 180;
}
int getAge() { //类成员函数,如人类的行为,吃、喝、睡等

return age;
}
int getHeight() {
return height;}
private:
int age; //类数据成员,如人类的身高、体重等

int height;
};
int main(int argc, char* argv[]) {
Person person;
return 0;
}

定义了自定义类型Person类以及该类的实例对象

person。Person与C++中的int都属于数据类型。整型变量的数据大 小为4字节,

使用class关键字的自定义类型如何分配各数据成员呢? 分析对象person的各成员在内存中的布局

对象person中先定义的数据成员 在低地址处,后定义的数据成员在高地址处,依次排列。

对象的大小只包含数据成员,类成员函数属于执行代码,不属于类对象的数据。

从内存布局上看,类与数组非常相似,都是由多个数据元素构成的,但类的能力要远远大于数组。

类成员的数据类型定义非常广泛,除本身的对象外,任何已知的数据类型都可以在类中定义。

为什么在类中不能定义自身的对象呢?这是因为类需要在申请内 存的过程中计算出自身的大小,以用于实例化。

如果在类中定义了自身的对象,在计算各数据成员的长度时,又会回到自身,这样就形成 了递归定义,而这个递归并没有出口,是一个无限循环的递归定义, 所以不能定义自身对象作为类成员。

但是,自身类型的指针除外,因 为任何类型的指针在32位下占用的内存大小始终为4字节,64位下占8个字节,等同于一个常量值,因此将其作为类的数据成员不会影响长度的计算。

根据以上知识,可以总结出如下对象长度计算公式。

对象长度=sizeof(数据成员1)+sizeof(数据成员2)+…+sizeof(数据成员n) 这个公式是否正确呢?

从表面上看,这个公式没有问题,但对象的大小计算远没有这么 简单。

即使类中没有 继承和虚函数的定义,仍有三种特殊情况能推翻此公式:空类、 内存对齐、静态数据成员。

1. 空类

空类中没有任何数据成员,按照该公式计算得出的对象长度为0字节。类型长度为0,则此类的对象不占内存空间。

而实际情况是,空类 的长度为1字节。如果对象完全不占用内存空间,空类就无法取得实例 对象的地址,this指针失效,因此不能被实例化。

而类的定义是由成员 数据和成员函数组成的,在没有成员数据的情况下,还可以有成员函 数,因此仍然需要做实例化。分配1字节的空间用于类的实例化,这1字节的数据并没有被使用。

2.内存对齐

在C++中,类和结构体中的数据成员是根据它们出现的顺序,依次申请内存空间的。

由于内存对齐的原因,它们并不一定会像数组那样连续地排列;

由于数据类型不同,占用的内存空间大小也会不同, 在申请内存时,会遵守一定的规则。

在为结构体和类中的数据成员分配内存时,结构体中当前数据成 员类型的长度为M,指定的对齐值为N,那么实际对齐值为q = min(M, N),成员的地址安排在q的倍数上,代码如下所示。

struct stTest

{ short s; //应占2字节内存空间,假设所在地址为0x010FFB64

int n; //应占4字节内存空间

};

数据成员s类型为short,占2字节内存 空间。C++中通常指定的对齐值默认为8,short的长度为2,实际对齐值取两者较小者,即2。

接下来我们讨论对齐 值对结构体整体大小的影响。如果按照C++默认的8字节对齐,那么 结构体的整体大小要能被8整除

1
2
3
4
5
struct{
double d;
int n;
short s;
};

上例中结构体成员的总长度为8+4+2=14,按默认的对齐值设置 要求,结构体的整体大小要能被8整除,于是编译器在最后一个成员s所占内存之后加入2字节空间填补到整个结构体中,

使总大小变为8+4+2+2=16,这样就满足了对齐的要求。

但是,并非设定了默认对齐值就能将结构体的对齐值锁定。如果 结构体中的数据成员类型最大值为M,指定的对齐值为N,那么实际对 齐值就是min(M, N)

this指针

在类中没有对this指针的定义,而在成员函数中却可以使用this指针。

因为this指针的使用过程被编译器隐藏起来了 ,this指针应属于指针类型。

this指针在32位环境 下占4字节空间,在64位环境下占8字节空间,保存的数据为地址信息。

this指针中保存了所属对象的首地址。

接下来,我们从访问对象的数据成员和成员函数入手,分析this指针的使用过程。

先来了解一下使用指针访问结构体或类成员的公式,假设type为某个正确定义的结构体或者类,member是type中可以访问的成员。

type *p; // 此处略去p的赋值

// 以下是整型加法

p->member的地址=指针p的地址值+ member在type中的偏移量

1
2
3
4
5
6
7
8
9
10
11
12
13
举个例子,如果有以下定义。

struct A {
int n; // 在结构体内的偏移量为0
float f; // 在结构体内的偏移量为4
};
struct A a; //假设这个结构体变量a的地址为0x0012ff00
struct A *p = &a; //定义结构体指针并赋初值

printf("%p", &p->f); //结果
我们知道,p中保存的地址为0x0012ff00,f在结构体内的偏移量
为4,于是可以得到:p-> f的地址=0x0012ff00+4=0x0012ff04。

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
// C++ 源码

#include <stdio.h>
class Person {
public:
void setAge(int age) { //公有成员函数

this->age = age;
}
public:
int age; //公有数据成员

};
int main(int argc, char* argv[]) {
Person person;
person.setAge(5); //调用成员函数

printf("Person : %d\n", person.age); //获取数据成员

return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 push ecx ;申请person对象空间

00401004 push 5 ;压入参数5
00401006 lea ecx, [ebp-4] ;传参,取出对象person的首地址存入ecx
00401009 call sub_401030 ;调用setAge成员函数

0040100E mov eax, [ebp-4] ;取出对象首地址处4字节的数据age存入eax
00401011 push eax ;将eax中保存的数据成员存入传参

00401012 push offset aPersonD ;"Person:%d\n"
00401017 call sub_401090 ;调用printf函数

0040101C add esp, 8
0040101F xor eax, eax
00401021 mov esp, ebp
00401023 pop ebp
00401024 retn
00401030 push ebp
00401031 mov ebp, esp
00401033 push ecx ;申请局部变量空间,

00401034 mov [ebp-4], ecx ;注意,ecx中保存了对象person的首地址

00401037 mov eax, [ebp-4] ;eax=this
0040103A mov ecx, [ebp+8] ;ecx=age
0040103D mov [eax], ecx ;this->age = age
0040103F mov esp, ebp
00401041 pop ebp
00401042 retn 4

在C++的环境下,识别this指针的关键点是在函数的调用过程中

使用ecx作为第一个参数,在ecx中保存的数据为对象的首地址,但并非所有this指针的传递都是如此。

成员函数SetAge的调用方式为thiscall。thiscall的栈平衡方式与__stdcall相 同,都是被调用方负责平衡。

但是,两者传递参数的过程却不一样, 声明为thiscall的函数,第一个参数使用寄存器ecx传递,而非通过栈顶传递。

而且thiscall并不属于关键字,它是C++中成员函数特有的 调用方式,在C语言中是没有这种调用方式的。由于在C++环境下,thiscall不属于关键字,因此函数无法显式声明为thiscall调用方式, 而类的成员函数默认为thiscall调用方式。

所以,在分析过程中,如果 看到某函数使用ecx传递参数,且ecx中保留了对象的this指针以及在 函数实现代码内,存在this指针参与的寄存器相对间接访问方式,如[reg+8],即可怀疑此函数为成员函数。

因为64位程序本来就使用rcx传递参数,所以无此特征。 当使用其他调用方式(如__stdcall)时,this指针将不再使用ecx传递参数,而是改用栈传递参数。

SetAge()修改为__stdcall调用方式,查看this指针的传递与使用过程

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++ 源码

#include <stdio.h>
class Person {
public:
void _stdcall setAge(int age) { //修改调用方式

this->age = age;
}
public:
int age;
};
int main(int argc, char* argv[]) {
Person person;
person.setAge(5);
printf("Person : %d\n", person.age);
return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp00401001 mov ebp, esp
00401003 push ecx
00401004 push 5
00401006 lea eax, [ebp-4] ;获取对象首地址并存入eax
00401009 push eax ;将eax作为参数压栈

0040100A call sub_401030 ;调用setAge成员函数

0040100F mov ecx, [ebp-4]
00401012 push ecx
00401013 push offset aPersonD ; "Person : %d\n"
00401018 call sub_401080 ;调用printf函数

0040101D add esp, 8
00401020 xor eax, eax
00401022 mov esp, ebp
00401024 pop ebp
00401025 retn
00401030 push ebp
00401031 mov ebp, esp
00401033 mov eax, [ebp+8] ;取出this指针并存入eax
00401036 mov ecx, [ebp+0Ch] ;取出参数age并存入ecx
00401039 mov [eax], ecx ;使用eax取出成员并赋值

0040103B pop ebp
0040103C retn 8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在成员函数中访问数据成员也是通过this指针间接访问的,
这便是在成员函数内可以直接使用数据成员的原因。在类中使用数据成员和
成员函数时,编译器隐藏了如下操作。

class Person {
public:
void show() {
//隐藏传递了this指针,这里实际为this->getAge()
printf("%d\n", getAge());
}
int getAge() {
//隐藏传递了this指针,这里实际为retrun this->age
return age;
}
public:
int age;
};

静态数据成员

当类中定义了静态数据成员时, 因为静态数据成员和静态变量原理相同(都是含有作用域的特殊全局变量),

所以该静态数据成员的初值会被写入编译链接后的执行文件。

当程序被加载时,操作系统将执行文件中的数据读到对应的内存单元里,静态数据成员已经存在,而这时类并没有实例对象。

静态数据成员和对象之间的生命周期不同,并且静态数据成员也不属于某一对象,与对象之间是一对多的关系。静态数据成员仅仅和类相关,和对象无关,多个对象可以拥有同一个静态数据成员,

定义了两个Static类对象obj1和obj2。可 见,类中的普通数据成员对于同类对象而言是独立存在的,而静态数 据成员则是所有同类对象的共用数据,静态数据成员和对象是一对多 的关系。

因为静态数据成员有此特性,所以在计算类和对象的长度时,静态数据成员属于特殊的独立个体,不被计算在其中,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
class Static { //类CStatic的定义
public:
static int staticNum; //静态数据成员
int num; //普通数据成员
};
int Static::staticNum = 0; //静态数据成员初始化
int main() {
Static obj;
int size = sizeof(obj); //计算对象长度
//转换后的汇编代码,得到长度为4
//mov dword ptr [ebp-14h],4
printf("Static : %d\n", size); //显示对象长度
return 0;
}

通过sizeof获得对象obj占用的内存长度为4。静态数据成员

staticNum没有参与对象obj的长度计算。staticNum为静态数据成 员,num为普通数据成员,两者所属的内存地址空间不同,这也是静 态数据成员不参与长度计算的原因之一,两者对比如下。

1
2
3
4
5
6
7
8
9
10
11
12
printf("0x%08x\n", &obj.staticNum); //使用对象直接调用静态数据成员

push 0A2A2D4h //静态成员所在地址为0x0A2A2D4
//部分printf代码分析略

printf("0x%08x\n", &obj.num); //获取普通数据成员地址

//获取对象的首地址并存入ecx,得到数据成员num的地址

lea eax,[ebp-0Ch]
//部分printf代码分析略

在以上代码分析中,静态数据成员所在的地址为0x0A2A2D4, 而普通数据成员的地址在ebp-0Ch中,是一个栈空间地址。

在使用的 过程中,静态数据成员是常量地址,可通过立即数间接寻址的方式访 问。普通数据成员只有在类对象产生后才出现,地址值无法确定,只 能以寄存器相对间接寻址的方式进行访问。在成员函数中使用这两种 数据成员时,因为静态数据成员属于全局变量,

并且不属于任何对象,所以访问时无须this指针。而普通的数据成员属于对象所有,访问 时需要使用this指针

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
/ C++ 源码

#include <stdio.h>
class Person {
public:
void show();
static int count; //静态数据成员

int age; //普通数据成员

};
int Person::count = 0;
void Person::show() {
printf("age = %d , count = %d", age, count);
}
int main(int argc, char* argv[]) {
Person person;
person.age = 1;
person.count = 2;
person.show();
return 0;
}
//x86_vs对应汇编代码讲解

00401030 push ebp
00401031 mov ebp, esp
00401033 push ecx ;申请对象空间

00401034 mov dword ptr [ebp-4], 1 ;person.age=1,普通数据成员
赋值

0040103B mov dword_4198B0, 2 ;person.count=2,静态数据成员赋值

00401045 lea ecx, [ebp-4] ;传递this指针

00401048 call sub_401000 ;调用show成员函数

0040104D xor eax, eax
0040104F mov esp, ebp00401051 pop ebp
00401052 retn
00401000 push ebp
00401001 mov ebp, esp
00401003 push ecx
00401004 mov [ebp-4], ecx ;获取this指针

00401007 mov eax, dword_4198B0 ;直接访问静态数据成员count
0040100C push eax
0040100D mov ecx, [ebp-4] ;获取this指针

00401010 mov edx, [ecx] ;通过this指针访问数据成员age
00401012 push edx
00401013 push offset aAgeDCountD ;"age = %d , count = %d"
00401018 call sub_4010A0 ;调用printf函数

0040101D add esp, 0Ch
00401020 mov esp, ebp
00401022 pop ebp
00401023 retn

静态数据成员在反汇编代码中很难被识别,这是因为其展示形态 与全局变量相同,很难被还原成对应的高级代码。可参考其代码的功能,酌情处理。

对象作为函数参数

对象的传参过程与数组不同,数组变量的名称代表数组的首地址,而对象的变量名称却不能代表对象的首地址。

传参时不会像数组 那样以首地址作为参数传递,而是先将对象中的所有数据进行备份 (复制),将备份的数据作为形参传递到调用函数中使用。

在基本的数据类型中,32位程序下,除双精度浮点和long long类型外,其他所有数据类型的大小都不超过4字节,64位程序类型不 超过8字节,使用一个栈元素即可完成数据的复制和传递。

而类对象是 自定义数据类型,是除自身外的所有数据类型的集合,各个对象的长 度不定。对象在传参的过程中是如何被复制和传递的呢?

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
// C++ 源码
#include <stdio.h>
class Person {
public:
int age;
int height;
};
void show(Person person) { //参数为类Person的对象

printf("age = %d , height = %d\n", person.age,
person.height);
}
int main(int argc, char* argv[]) {
Person person;
person.age = 1;
person.height = 2;
show(person);
return 0;
}
//x86_vs对应汇编代码讲解

00401020 push ebp
00401021 mov ebp, esp
00401023 sub esp, 8
00401026 mov dword ptr [ebp-8], 1 ;person.age=1,对象首地址为ebp-8
0040102D mov dword ptr [ebp-4], 2 ;person.height=2
00401034 mov eax, [ebp-4]
00401037 push eax ;传递参数2,person.age
00401038 mov ecx, [ebp-8]
0040103B push ecx ;传递参数1,person.age
0040103C call sub_401000 ;调用show函数

00401041 add esp, 8
00401044 xor eax, eax
00401046 mov esp, ebp
00401048 pop ebp
00401049 retn

//show函数
00401000 push ebp
00401001 mov ebp, esp
00401003 mov eax, [ebp+0Ch] ;对象首地址为ebp+8
00401006 push eax ;传递参数3,person.height
00401007 mov ecx, [ebp+8]
0040100A push ecx ;传递参数2,person.age
0040100B push offset aAgeDHeightD ;参数1"age = %d , height
= %d\n"
00401010 call sub_401090 ;调用printf函数

00401015 add esp, 0Ch
00401018 pop ebp
00401019 retn

类Person的体积不大,只有两个数据成员, 编译器在调用函数传参的过程中分别将对象的两个成员依次压栈,也 就是直接将两个数据成员当成两个int类型数据,并将它们当作printf函数的参数。

64位程序中直接使用一个寄存器存储类的两个数据成员。

同理,它们也是一份复制数据,除数据相同外,与对象中的两个数据成员没有关系。

类对象中数据成员的传参顺序为最先定义的数据成员最后压栈, 最后定义的数据成员最先压栈。当类的体积过大,或者其中定义有数 组类型的数据成员时,会将数组的首地址作为参数压栈吗?

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
// C++ 源码#include <stdio.h>
#include <stdio.h>
#include <string.h>
class Person {
public:
int age;
int height;
char name[32]; //定义数组类型的数据成员

};
void show(Person person) {
printf("age = %d , height = %d name:%s\n", person.age,
person.height, person.name);
}
int main(int argc, char* argv[]) {
Person person;
person.age = 1;
person.height = 2;
strcpy(person.name, "tom"); //赋值数据成员数组

show(person);
return 0;
}
//x86_vs对应汇编代码讲解

00401020 push ebp
00401021 mov ebp, esp
00401023 sub esp, 2Ch
00401026 mov eax, ___security_cookie
0040102B xor eax, ebp
0040102D mov [ebp-4], eax
00401030 push esi
00401031 push edi
00401032 mov dword ptr [ebp-2Ch], 1 ;person.age=1,对象首地址

ebp-2Ch
00401039 mov dword ptr [ebp-28h], 2 ;person.height=2
00401040 push offset aTom ;参数2,"tom"
00401045 lea eax, [ebp-24h] ;eax=person.name地址

00401048 push eax ;参数1,person.name
00401049 call sub_404610 ;调用strcpy函数

0040104E add esp, 0FFFFFFE0h ;调整栈顶,抬高32字节。

00401051 mov ecx, 0Ah ;设置循环次数为10
00401056 lea esi, [ebp-2Ch] ;获取对象的首地址并保存到esi
00401059 mov edi, esp ;设置edi为当前栈顶

0040105B rep movsd ;执行10次4字节内存复制,将esi所指向的数据复
;制到edi中,类似memcpy的内联方式

0040105D call sub_401000 ;调用show函数

00401062 add esp, 28h
00401065 xor eax, eax
00401067 pop edi
00401068 pop esi
00401069 mov ecx, [ebp-4]
0040106C xor ecx, ebp
0040106E call @__security_check_cookie@4 ;
__security_check_cookie(x)
00401073 mov esp, ebp
00401075 pop ebp
00401076 retn

构造函数和析构函数

构造函数常用来完成对象生成时的数据初始化工作,

而析构函数则常用于在对象销毁时释放对象申请的资源。

当对象生成时,编译器会自动产生调用其类构造函数的代码,在编码过程中可以为类中的数据成员赋予恰当的初始值。

当对象被销毁时,编译器同样会产生调用其类析构函数的代码。

构造函数与析构函数都是类中特殊的成员函数,构造函数支持函数重载,而析构函数只能是一个无参函数。

它们不可定义返回值,调用构造函数后,返回值为对象首地址,也就是this指针。

在某些情况下,编译器会提供默认的构造函数和析构函数,但并不是任何情况下编译器都会提供。

构造函数的出现时机

对象生成时会自动调用构造函数。只要找到定义对象的地方就找到了构造函数的调用时机。

这看似简单,实际情况却是不同作用域的对象生命周期不同,如局部对象、全局对象、静态对象等的生命周期各不相同,

而当对象作为函数参数与返回值时,构造函数的出现时机又有所不同。

将对象进行分类,不同类型对象的构造函数被调用的时机会发生变化,但都会遵循C++语法,

即定义的同时调用构造函数。那么,只要知道了对象的生命周期,便可推断构造函数的调用时机。

下面根据 生命周期将对象进行分类,然后分析各类对象构造函数和析构函数的 调用时机。

局部对象

局部对象下的构造函数出现时机比较容易识别。当对象产生时, 便有可能引发构造函数的调用。

编译器隐藏了构造函数的调用过程, 使编码者无法看到调用细节。

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
代码清单10-1 无参构造函数的调用过程(Debug版)

// C++ 源码

#include <stdio.h>
class Person {
public:
Person() { //无参构造函数

age = 20;
}
int age;
};
int main(int argc, char* argv[]) {
Person person; //类对象定义

return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 push ecx
00401004 lea ecx, [ebp-4] ;取得对象首地址,传入ecx作为参数,

ecx=&person
00401007 call sub_401020 ;调用构造函数

0040100C xor eax, eax
0040100E mov esp, ebp
00401010 pop ebp
00401011 retn
00401020 push ebp ;构造函数
00401021 mov ebp, esp
00401023 push ecx
00401024 mov [ebp-4], ecx ;[ebp-4]就是this指针

00401027 mov eax, [ebp-4] ;eax保存了对象的首地址

0040102A mov dword ptr [eax], 14h ;将数据成员age设置为20
00401030 mov eax, [ebp-4] ;将this指针存入eax,作为返回值

00401033 mov esp, ebp
00401035 pop ebp
00401036 retn

当进入对象的作用域时,编译器会产生调用构造函数的代码。因为构造函数属于成员函数,所以在调用的过程中同样需要传递this指针。

构造函数调用结束后,会将this指针作为返回值。返回this指针便是构造函数的特征之一,结合C++的语法,我们可以总结识别局部对象构造函数的必要条件(请读者注意,并不是充分条件)。

该成员函数是这个对象在作用域内调用的第一个成员函数,根据

this指针可以区分每个对象。 这个成员函数是通过thiscall方式调用的。

这个函数返回this指针。 构造函数必然满足以上3个条件,缺一不可。为什么构造函数会返 回this指针呢?请继续看下面的讲解

堆对象

堆对象的识别重点在于识别堆空间的申请与使用。在C++的语法中,堆空间的申请需要使用malloc函数、new运算符或者其他同类功 能的函数。

因此,识别堆对象就有了重要依据,代码如下所示。

Person* p = new Person();

这行代码看上去是申请了一个类型为Person的堆对象,使用指针p保存了对象的首地址。

因为产生了对象,所以此行代码将会调用

Person类的无参构造函数

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
// C++ 源码

#include <stdio.h>
class Person {
public:
Person() {
age = 20;
}
int age;
};
int main(int argc, char* argv[]) {
Person *p = new Person;
//为了突出本节讨论的问题,这里没有检查new运算的返回值

p->age = 21;
printf("%d\n", p->age);
return 0;
}
//x86_vs对应汇编代码讲解
00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 0Ch
00401006 push 4 ;压入类的大小,用于堆内存申请
00401008 call sub_4010FA ;调用new函数
0040100D add esp, 4
00401010 mov [ebp-4], eax ;使用临时变量保存new返回值
00401013 cmp dword ptr [ebp-4], 0;检测堆内存是否申请成功
00401017 jz short loc_401026; ;申请失败则跳过构造函数调用
00401019 mov ecx, [ebp-4] ;申请成功,将对象首地址传入ecx
0040101C call sub_401060 ;调用构造函数
00401021 mov [ebp-8], eax ;构造函数返回this指针,保存到临时变量
ebp-8
00401024 jmp short loc_40102D
00401026 mov dword ptr [ebp-8], 0;申请堆空间失败,设置指针值为
NULL
0040102D mov eax, [ebp-8]
00401030 mov [ebp-0Ch], eax ;当没有打开/02时,对象地址将在几个临
时变量中倒换,最终保存到[ebp-0Ch]中,这是指针变量p
00401033 mov ecx, [ebp-0Ch] ;ecx得到this指针
00401036 mov dword ptr [ecx], 15h;为成员变量age赋值21
0040103C mov edx, [ebp-0Ch]
0040103F mov eax, [edx]
00401041 push eax ;参数2,p->age
00401042 push offset aD ;参数1"%d\n"
00401047 call sub_4010C0 ;调用printf函数
0040104C add esp, 8
0040104F xor eax, eax
00401051 mov esp, ebp
00401053 pop ebp
00401054 retn

VS编译器使用new申请堆空间之后,需要调用构造函数,以完成对象的数据成员初始化。

如果堆空间申请失败, 则会避开构造函数的调用。

因为在C++语法中,如果new运算执行成功,返回值便是对象的首地址,否则为NULL。

因此,需要编译器检查堆空间的申请结果,产生一个双分支结构,以决定是否触发构造函数。

在识别堆对象的构造函数时,应重点分析此双分支结构。

找到new运算的调用后,可立即在下文寻找判定new返回值的代码,在判定成功(new的返回值非0)的分支处迅速定位并得到构造函数。

C中的malloc函数和C++中的new运算区别很大,尤其是malloc不负责触发构造函数,它也不是运算符,无法进行运算符重载。

在使用new申请对象堆空间时,许多初学者很容易将有参构造函 数与对象数组搞混,在申请对象数组时很容易写错,将申请对象数组 写成调用有参构造函数。

以int类型的堆空间申请为例,代码如下所示。

//圆括号是调用有参构造函数,最后只申请了一个int类型的堆变量并赋初值

10 int *p= new int(10); // 方括号才是申请了10个int元素的堆数组

int *p = new int[10];

类的堆空间申请与以上情况相似,本想申请对象数组,但是写成 了调用有参构造函数。

虽然在编译时编译器不会报错,但需要该类中 提供匹配的构造函数。

当程序流程执行到释放对象数组时,则会触发 错误,

参数对象

参数对象属于局部对象中的一种特殊情况。当对象作为函数参数时,调用一个特殊的构造函数——复制构造函数。

该构造函数只有一个参数,类型为对象的引用。当对象为参数时,会触发此类对象的复制构造函数。

如果在函数调用时传递参数对象,参数会进行复制,形参是实参的副本,相当于复制构造了一个全新的对象。由于定义了新对象,因此会触发复制构造函数,在这个特殊的构造函数中完成两个对象间数据的复制。

如没有定义复制构造函数,编译器会对原对象与复制对象中的各数据成员直接进行数据复制,

称为默认复制构造函数,这种复制方式属于浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
class Person {
public:
Person() { //无参构造函数
age = 20;
}
int age;
};
int main(int argc, char* argv[]) {
Person person; //类对象定义
Person obj1; //类Person的定义参考代码清单10-1
Person obj2(obj1); //Person中没有提供参数为对象引用的构造函数
return 0;
}

00401006 lea ecx, [ebp-4] ;ecx=&obj1

00401009 call Person:Person ;调用构造函数

0040100E mov eax, [ebp-4] ;取出对象obj1中的数据成员信息

00401011 mov [ebp-8], eax ;赋值对象obj2中的数据成员信息

虽然使用编译器提供的默认复制构造函数很方便,但在某些特殊情况下,这种复制会导致程序错误,如资源释放错误。

当类中有资源申请,并以数据成员来保存这些资源时,就需要使用者自己提供一个复制构造函数。

在复制构造函数中,要处理的不仅仅是源对象的各数据成员,还有它们指向的资源数据。

把这种源对象中的数据成员间接访问到的其他资源,并制作副本的复制构造函数称为深拷贝,

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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
// C++ 源码

#include <stdio.h>
#include <string.h>
class Person {
public:
Person() {
name = NULL;//无参构造函数,初始化指针

}
Person(const Person& obj) {
// 注:如果在复制构造函数中直接复制指针值,那么对象内的两个成员指针会指向同一个资源,这属于浅拷贝

// this->name = obj.name;
// 为实参对象中的指针所指向的堆空间制作一份副本,这就是深拷贝了
int len = strlen(obj.name);
this->name = new char[len + sizeof(char)]; // 为便于讲解,这里没有检查指针
strcpy(this->name, obj.name);
}
void setName(const char* name) {
int len = strlen(name);
if (this->name != NULL) {
delete [] this->name;
}
this->name = new char[len + sizeof(char)]; // 为便于讲解,这里没有检查指针

strcpy(this->name, name);
}
public:
char * name;
};

void show(Person person){ // 参数是对象类型,会触发复制构造函数
printf("name:%s\n", person.name);
}
int main(int argc, char* argv[]) {
Person person;
person.setName("Hello");
show(person);
return 0;
}
//x86_vs对应汇编代码讲解
00401020 push ebp
00401021 mov ebp, esp
00401023 sub esp, 8
00401026 lea ecx, [ebp-4] ;ecx=&person
00401029 call sub_4010D0 ;调用构造函数
0040102E push offset aHello;参数1"Hello"
00401033 lea ecx, [ebp-4] ;ecx=&person
00401036 call sub_401130 ;调用成员函setName
0040103B push ecx ;这里的“push ecx”等价于“sub esp,4”,但是“push
ecx”的机器码更短,效率更高,Person的类型长度为4字节,所以传递参数对象的时候需要在栈顶留下4字节,以作为参数对象的空间,此时esp保存的内容就是参数对象的地址

0040103C mov ecx, esp ;获取参数对象的地址,保存到ecx中
0040103E lea eax, [ebp-4] ;获取对象person的地址并保存到eax中
00401041 push eax ;参数1,将person地址作为参数
00401042 call sub_401070 ;调用复制构造函数
00401047 call sub_401000 ;此时栈顶上的参数对象传递完毕,开始调用show函数

0040104C add esp, 4
0040104F mov dword ptr [ebp-8], 0
00401056 lea ecx, [ebp-4] ;ecx=&person
00401059 call sub_4010F0 ;调用对象person的析构函数

0040105E mov eax, [ebp-8]
00401061 mov esp, ebp
00401063 pop ebp
00401064 retn
00401070 push ebp ;复制构造函数

00401071 mov ebp, esp
00401073 sub esp, 0Ch
00401076 mov [ebp-4], ecx ;[ebp-4]保存this指针

00401079 mov eax, [ebp+8] ;eax=&obj
0040107C mov ecx, [eax] ;ecx=obj.name
0040107E push ecx ;参数1
0040107F call sub_404AA0 ;调用strlen函数
00401084 add esp, 4
00401087 mov [ebp-8], eax ;len=strlen(obj.name)
0040108A mov edx, [ebp-8]
0040108D add edx, 1
00401090 push edx ;参数1,len +1
00401091 call sub_40121A ;调用new函数

00401096 add esp, 4
00401099 mov [ebp-0Ch], eax
0040109C mov eax, [ebp-4]
0040109F mov ecx, [ebp-0Ch]
004010A2 mov [eax], ecx ;this->name = new char[len +sizeof(char)];
004010A4 mov edx, [ebp+8]
004010A7 mov eax, [edx]
004010A9 push eax ;参数2,obj.name
004010AA mov ecx, [ebp-4]
004010AD mov edx, [ecx]
004010AF push edx ;参数1,this->name
004010B0 call sub_4049A0 ;调用strcpy函数

004010B5 add esp, 8
004010B8 mov eax, [ebp-4] ;返回this指针

004010BB mov esp, ebp
004010BD pop ebp
004010BE retn 4
00401000 push ebp ;show函数

00401001 mov ebp, esp
00401003 mov eax, [ebp+8]
00401006 push eax ;参数2,person.name
00401007 push offset aNameS ;参数1"name:%s\n"
0040100C call sub_4011E0 ;调用printf函数

00401011 add esp, 8
00401014 lea ecx, [ebp+8] ;ecx=&person
00401017 call sub_4010F0 ;调用析构函数

0040101C pop ebp
0040101D retn

在执行函数show之前,先进入Person的复制构造函数中。在复制构造函数中,我们使用的是深拷贝方式。这时数据成员this->name和obj.name保存的地址不同,但其中的数据内容却是相同的,

由于使用了深拷贝方式,对对象中的数据成员指向的堆空间数据也进行了数据复制,

因此当参数对象被销毁时,释放的堆空间数据是复制对象制作的数据副本,对源对象没有任何影响。另外需要注意的是,对于GCC编译器,show函数的参数对象的析构函数是在main()函数中调用的

返回对象

返回对象与参数对象相似,都是局部对象中的一种特殊情况。由于函数返回时需要对返回对象进行复制,因此同样会使用复制构造函数。但是,两者使用复制构造函数的时机不同。当对象为参数时,在进入函数前使用复制构造函数,而返回对象则在函数返回时使用复制构造函数,

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

Person getObject() {
Person person;
person.setName("Hello");
return person; //返回类型为对象
}
int main(int argc, char* argv[]) {
Person person = getObject();
return 0;
}
//x86_vs对应汇编代码讲解

00401040 push ebp
00401041 mov ebp, esp
00401043 sub esp, 8
00401046 lea eax, [ebp-4] ;取对象person的首地址
00401049 push eax ;将对象的首地址作为参数传递
0040104A call sub_401000 ;调用getObject函数
0040104F add esp, 4
00401052 mov dword ptr [ebp-8], 0
00401059 lea ecx, [ebp-4] ;将对象person的首地址作为参数传递
0040105C call sub_4010F0 ;调用析构函数
00401061 mov eax, [ebp-8]
00401064 mov esp, ebp
00401066 pop ebp
00401067 retn
00401000 push ebp ;getObject函数
00401001 mov ebp, esp
00401003 push ecx
00401004 lea ecx, [ebp-4] ;将局部对象的首地址作为参数传递
00401007 call sub_4010D0 ;调用构造函数
0040100C push offset aHello ; "Hello"
00401011 lea ecx, [ebp-4] ;将局部对象的首地址作为参数传递
00401014 call sub_401130 ;调用成员函数setName
00401019 lea eax, [ebp-4] ;获取局部对象的首地址
0040101C push eax ;将局部对象的地址作为参数
0040101D mov ecx, [ebp+8] ;获取参数中保存的this指针。(还记得吗?
9章讲过,;将对象作为返回值时,函数将会隐式传递一个参数,;其内容为返回对象的this指针)
00401020 call sub_401070 ;调用复制构造函数
00401025 lea ecx, [ebp-4] ;将局部对象的首地址作为参数传递

00401028 call sub_4010F0 ;调用析构函数

0040102D mov eax, [ebp+8] ;将参数作为返回值

00401030 mov esp, ebp
00401032 pop ebp
00401033 retn

getObject将返回对象的地 址作为函数参数。在函数返回之前,利用复制构造函数将函数中局部 对象的数据复制到参数指向的对象中,起到了返回对象的作用。虽然编译器会对返回值为对象类型的函数进行调整,修改其参数 与返回值,但是它留下了一个与返回指针类型不同的象征,就是在函 数中使用构造函数。返回值和参数是对象指针类型的函数,不会使用 以参数为目标的构造函数,而是直接使用指针保存对象首地址,代码 如下所示。

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
//函数的返回类型与参数类型都是对象的指针类型

Person* getObject(Person* p) {
Person person; // 定义局部对象

person.setName("World");
p = &person;
return p;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 8
00401006 lea ecx, [ebp-4]
00401009 call sub_401070
0040100E push offset aWorld ;"World"
00401013 lea ecx, [ebp-4]
00401016 call sub_4010D0
0040101B lea eax, [ebp-4]
0040101E mov [ebp+8], eax
00401021 mov ecx, [ebp+8]
00401024 mov [ebp-8], ecx ;直接保存局部对象首地址

00401027 lea ecx, [ebp-4]
0040102A call sub_401090
0040102F mov eax, [ebp-8] ;将局部对象作为返回值

00401032 mov esp, ebp
00401034 pop ebp
00401035 retn

在使用指针作为参数和返回值时,函数内没有对构造函数的调 用。以此为依据,便可以分辨参数或返回值是对象还是对象的指针。 如果在函数内为参数指针申请了堆对象,就会存在new运算和构造函 数的调用,因此更容易分辨参数和返回值了。

全局对象与静态对象

全局对象与全局静态对象的构造时机相同,它们构造函数的调用被隐藏在深处,但识别过程很简单这是因为程序中所有全局对象会在同一地点以初始化数据调用构造函数。既然调用构造函数被固定在了某一个点上,无论这个点被隐藏得多深,只须找到一次即可。

我们在第3章讲解启动函数时分析过

_cinit函数(位于VS2019的启动函数mainCRTStartup中)。全局对象的构造函数初始化就是在此函数中实现的。 _

_在函数_cinit的_initterm函数调用中,初始化了全局对象。_initterm实现的代码片段如下

1
2
3
4
5
6
7
8
9
10
extern "C" void __cdecl _initterm(_PVFV* const first, _PVFV*
const last)
{
for (_PVFV* it = first; it != last; ++it)
{
if (*it == nullptr)
continue;
(**it)();
}
}

当it不为NULL时,执行(**it)();后并不会进入全局对象的构造函数,而是进入编译器提供的构造代理函数,由一个负责全局对象的构造代理函数完成调用全局构造函数,

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
// C++ 源码
#include <stdio.h>
#include <string.h>
class Person {public:
Person() {
printf("Person()");
}
~Person(){
printf("~Person()");
}
};
Person g_person1; //定义全局对象

Person g_person2; //定义全局对象

int main(int argc, char* argv[]) {
printf("main");
return 0;
}
//x86_vs对应汇编代码讲解

0040149D push offset dword_412120 ;参数2,代码析构函数数组终止地


004014A2 push offset dword_412110 ;参数1,代理析构函数数组起始地


004014A7 call __initterm ;遍历调用代理析构函数数组

00412110 dd 0 ;代理析构函数数组

00412114 dd 0040141E
00412118 dd 00401000 ;g_person1代理析构函数

0041211C dd 00401020 ;g_person2代理析构函数

00412120 dd 0
00401000 push ebp ;g_person1代理析构函数

00401001 mov ebp, esp
00401003 mov ecx, offset unk_4198B8 ;ecx=&g_person1
00401008 call sub_401060 ;调用构造函数

0040100D push 00411660
00401012 call _atexit ;注册g_person1析构代理函数

00401017 add esp, 4
0040101A pop ebp
0040101B retn
00401020 push ebp ;g_person2代理析构函数

00401021 mov ebp, esp
00401023 mov ecx, offset unk_4198B9 ;ecx=&g_person2
00401028 call sub_401060 ;调用构造函数
0040102D push 00411670
00401032 call _atexit ;注册g_person2析构代理函数

00401037 add esp, 4
0040103A pop ebp
0040103B retn

通过对代码的分析可以了解全局对象的定义过程。由于构造函数需要传递对象的首地址作为this指针,而且构造函数可以携带各类参数,因此编译器将为每个全局对象生成一段传递this指针和参数 的代码,然后使用无参代理函数调用构造函数。

对于全局对象和静态对象,能不能取消代理函数,直接在main()函数前调用构造函数呢?

答案见本章小结。 全局对象构造函数的调用被隐藏在深处,那么在分析的过程中该 如何跟踪全局对象的构造函数呢?可使用两种方法:直接定位初始化 函数和利用栈回溯。

直接定位初始化函数

先进入mainCRTStartup函数,顺藤摸瓜找到初始化函数_cinit, 在_cinit函数的第二个_initterm处设置断点。运行程序后,进入_initterm的实现代码内,断点在(**it)();执行处,单步进入代理构 造,即可得到全局对象的构造函数。读者可以先在源码环境下单步跟踪,待熟悉后就可以脱离源码,直接在反汇编的条件下利用OllyDbg或者WinDbg等调试工具熟悉反汇编代码,尝试用自己的方法总结出 快速识别初始化函数的规律。对于GCC编译器,则通过main()函数定 位___main函数的位置。

利用栈回溯

如果反汇编代码中出现了全局对象,因为全局对象的地址固定 (对于有重定位表的执行文件中的全局对象,也可以在执行文件被加 载后至执行前计算得到全局对象的地址),所以可以在对象的数据成 员中设置读写断点,调试运行程序,等待调用构造函数。

利用栈回溯 窗口,找到程序的执行流程,依次向上查询即可找到构造函数调用的 起始处。

其实,最简单的办法是对atexit设置断点,这是因为构造代理函数 中会注册析构函数,其注册的方式是使用atexit,在讲解虚函数的时候 我们会做详细介绍。

每个对象是否都有默认的构造函数

当没有定义构造函数 时,编译器会提供默认的构造函数,这个函数什么事情都不做,其内 容类似于“{}”的形式。

但是笔者经过研究发现,编译器不是在任何情 况下都提供默认构造函数的。

在许多情况下,编译器并没有提供默认 的构造函数,而且02选项优化编译后,某些结构简单的类会被转换为 连续定义的变量,哪里还会需s要构造函数呢?

在前面的学习过程中, 我们也碰到了在类对象定义过程中没有触发构造函数的情况,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
没有定义构造函数的类(C++源码)

#include <stdio.h>
class Person {
public:
void setAge(int age) {
this->age = age;
}
int getAge() {
return age;
}
private:
int age;
};
int main(int argc, char* argv[]) {
Person person;
person.setAge(20);
printf("%d\n", person.getAge());
return 0;
}

没有构造函数的定义,编译器会为类Person提供默认的构造函数吗?

见图10-2中对象person的定义过程。

对象person的定义处没有任何对应的汇编代码,也没有构造函数的调用过程,可见编译器并没有为其提供默认的构造函数。

那么,在何种情况下编译器会提供默认的构造函数呢?有以下两 种情况。

本类和本类中定义的成员对象或者父类中存在 虚函数

因为需要初始化虚表,且这个工作理应在构造函数中隐式完成, 所以在没有定义构造函数的情况下,编译器会添加默认的构造函数, 用于隐式完成虚表的初始化工作(详细讲解见第11章)。

父类或本类中定义的成员对象带有构造函数

在对象被定义时,因为对象本身为派生类,所以构造顺序是先构 造父类再构造自身。当父类中带有构造函数时,将会调用父类构造函 数,而这个调用过程需要在构造函数内完成,因此编译器添加了默认 的构造函数来完成这个调用过程(详细讲解见第12章)。

成员对象带 有构造函数的情况与此相同。 在没有定义构造函数的情况下,当类中没有虚函数存在,父类和 成员对象也没有定义构造函数时,提供默认的构造函数已没有任何意 义,只会降低程序的执行效率,

因此编译器没有对这种情况的类提供 默认的构造函数。关于虚函数与类的继承关系会在12章详细讲解

析构函数的出现时机

构造函数是对象诞生的象征,对应的析构函数则是对象销毁的特征。 对象何时被销毁呢?

根据对象所在的作用域,当程序流程执行到作用域结束处时,会释放该作用域内的所有对象,在释放的过程中会 调用对象的析构函数。

析构函数与构造函数的出现时机相同,但并非有构造函数就一定会有对应的析构函数。析构函数的触发时机也需要 视情况而定,主要分如下几种情况。

局部对象:作用域结束前调用析构函数。

堆对象:释放堆空间前调用析构函数。

参数对象:退出函数前,调用参数对象的析构函数。

返回对象:如无对象引用定义,退出函数后,调用返回对象的析 构函数,否则与对象引用的作用域一致。

全局对象:main()函数返回后调用析构函数。

静态对象:main()函数返回后调用析构函数。

局部对象

要考察局部对象析构函数出现的时机,应重点考察其作用域的结束处。与构造函数相比较而言,析构函数的出现时机相对固定。

对于局部对象,当对象所在作用域结束后,将销毁该作用域所有变量的栈 空间,此时便是析构函数出现的时机

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
局部对象的析构函数调用(Debug版)

// C++ 源码

#include <stdio.h>
class Person {
public:
Person() {
age = 1;
}
~Person() {
printf("~Person()\n");
}
private:
int age;
};
int main(int argc, char* argv[]) {
Person person;
return 0; //退出函数后调用析构函数

}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 8
00401006 lea ecx, [ebp-4] ;获取对象的首地址,作为this指针

00401009 call sub_401030 ;调用构造函数

0040100E mov dword ptr [ebp-8], 0
00401015 lea ecx, [ebp-4] ;获取对象的首地址,作为this指针

00401018 call sub_401050 ;调用析构函数
0040101D mov eax, [ebp-8]
00401020 mov esp, ebp
00401022 pop ebp
00401023 retn
00401050 push ebp ;析构函数

00401051 mov ebp, esp
00401053 push ecx
00401054 mov [ebp-4], ecx
00401057 push offset aPerson ;参数1"~Person()\n"
0040105C call sub_4010B0 ;调用printf函数

00401061 add esp, 4
00401064 mov esp, ebp
00401066 pop ebp
00401067 retn ;无返回值

类Person提供了析构函数,在对象Person所在的作用域结束处,调用了析构函数~Person()。

析构函数同样属于成员函数,因此在调用的过程中也需要传递this指针。

析构函数与构造函数略有不同,析构函数不支持函数重载,只有一个参数,即this指针,而且编译器隐藏了这个参数的传递过程。

对于开发者而言,它是一个隐藏了this指针的无参函数。

堆对象

堆对象比较特殊,编译器将它的生杀大权交给了使用者。

一些粗 心的使用者只知道创造堆对象,而忘记了销毁,导致程序中永远存在 一些无用的堆对象,其他堆类型数据也是如此。程序中的资源是有限 的,只申请资源不释放资源会造成内存泄漏,这点在设计服务器端程 序时尤其要注意。

使用new申请堆对象空间后,何时释放对象要看开发者在哪里调 用delete释放对象所在的堆空间。delete的使用便是找到堆对象调用 析构函数的关键点。我们先来看看释放堆空间前调用析构函数的过 程

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
92
93
94
95
96
97
98
// C++ 源码说明

#include <stdio.h>
class Person {
public:
Person() {
age = 20;
}
~Person() {
printf("~Person()\n");
}
int age;
};
int main(int argc, char* argv[]) {
Person *person = new Person();
person->age = 21; //为了便于讲解,这里没检查指针

printf("%d\n", person->age);
delete person;
return 0;
}//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 14h
00401006 push 4 ;参数1
00401008 call sub_40116A ;调用new函数申请内存空间

0040100D add esp, 4
00401010 mov [ebp-8], eax ;保存申请的内存地址到临时变量

00401013 cmp dword ptr [ebp-8], 0
00401017 jz short loc_401026 ;检查内存空间是否申请成功

00401019 mov ecx, [ebp-8] ;传递this指针

0040101C call sub_401080 ;申请内存成功,调用构造函数

00401021 mov [ebp-0Ch], eax ;保存构造函数返回值到临时变量

00401024 jmp short loc_40102D
00401026 mov dword ptr [ebp-0Ch], 0 ;申请内存失败,赋值临时变量

NULL
0040102D mov eax, [ebp-0Ch]
00401030 mov [ebp-4], eax ;保存申请的地址到指针变量person
00401033 mov ecx, [ebp-4] ;ecx=person
00401036 mov dword ptr [ecx], 15h ;person->age=21
0040103C mov edx, [ebp-4]
0040103F mov eax, [edx]
00401041 push eax ;参数2,person->age
00401042 push offset aD ;参数1"%d\n"
00401047 call sub_401130 ;调用printf函数

0040104C add esp, 8
0040104F mov ecx, [ebp-4]
00401052 mov [ebp-10h], ecx
00401055 cmp dword ptr [ebp-10h], 0
00401059 jz short loc_40106A ;检查内存空间是否申请成功

0040105B push 1 ;标记,以后讲多重继承时会详谈

0040105D mov ecx, [ebp-10h] ;传递this指针

00401060 call sub_4010C0 ;内存申请成功,调用析构代理函数

00401065 mov [ebp-14h], eax
00401068 jmp short loc_401071
0040106A mov dword ptr [ebp-14h], 0
00401071 xor eax, eax
00401073 mov esp, ebp
00401075 pop ebp
00401076 retn
004010C0 push ebp ;析构代理函数

004010C1 mov ebp, esp
004010C3 push ecx004010C4 mov [ebp-4], ecx
004010C7 mov ecx, [ebp-4] ;传递this指针

004010CA call sub_4010A0 ;调用析构函数

004010CF mov eax, [ebp+8]
004010D2 and eax, 1
004010D5 jz short loc_4010E5 ;检查析构函数标记,以后讲多重继承时会
详谈

004010D7 push 4
004010D9 mov ecx, [ebp-4]
004010DC push ecx ;参数1,堆空间的首地址

004010DD call sub_40119A ;调用delete函数,释放堆空间

004010E2 add esp, 8
004010E5 mov eax, [ebp-4]
004010E8 mov esp, ebp
004010EA pop ebp
004010EB retn 4

看似简单的释放堆对象过程实际上做了很多事情。VS中的析构函数比较特殊,在释放过程中,需要使用析构代理 函数间接调用析构函数。。为什么不直接调 用析构函数呢?原因有很多,其中一个就是在某些情况下,需要释放 的对象不止一个,如果直接调用析构函数,无法完成多对象的析构, 代码如下所示。

1
2
3
4
5
6
7
8
9
10
//Person类的定义见代码清单10-1,请读者自行加入析构函数

int main(int argc, char* argv[]) {
Person *objs = new Person[3]; //申请对象数组

delete[] objs; //释放对象数组

return 0;
}

在以上代码中,使用new申请对象数组。由于数组中有两个对象,因此申请和释放堆空间时,构造函数和析构函数各需要调用两 次。编译器通过代理函数完成这一系列的操作

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
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 14h
00401006 push 10h ;每个对象占4字节,却申请了16字节大小的空间,

;多出的4字节数据是什么呢?在申请对象数组时,

;会使用堆空间的首地址处的4字节内容保存对象

;总个数

00401008 call sub_401238 ;调用new函数
0040100D add esp, 4
00401010 mov [ebp-4], eax ;[ebp-4]保存申请的堆空间的首地址

00401013 cmp dword ptr [ebp-4], 0
00401017 jz short loc_401042 ;检查堆空间的申请是否成功

00401019 mov eax, [ebp-4]
0040101C mov dword ptr [eax], 3 ;设置首地址的4字节数据为对象个数

00401022 push offset sub_401080 ;参数4,构造函数的地址,作为构造
代理函数参数

00401027 push 3 ;参数3,对象个数,作为函数参数

00401029 push 4 ;参数2,对象大小,作为函数参数

0040102B mov ecx, [ebp-4]
0040102E add ecx, 4 ;跳过首地址的4字节数据

00401031 push ecx ;参数1,第一个对象地址,作为函数参数

00401032 call sub_401140 ;构造代理函数调用,该函数的讲解见代码清单

10-10
00401037 mov edx, [ebp-4]
0040103A add edx, 4 ;跳过堆空间首4字节的数据

0040103D mov [ebp-8], edx ;保存堆空间中的第一个对象的首地址

00401040 jmp short loc_401049 ;跳过申请堆空间失败的处理

00401042 mov dword ptr [ebp-8], 0 ;申请堆空间失败,赋值空指针

00401049 mov eax, [ebp-8]
0040104C mov [ebp-10h], eax
0040104F mov ecx, [ebp-10h]
00401052 mov [ebp-0Ch], ecx ;数据最后到objs,打开02就简洁了

00401055 cmp dword ptr [ebp-0Ch], 0
00401059 jz short loc_40106A ;检查对象指针是否为NULL
0040105B push 3 ;参数2,释放对象类型标志,1为单个对象,3为释

;放对象数组,0表示仅执行析构函数,不释放堆空

;间(其作用会在讲解多重继承时详细介绍)。这个标

;志占2位,使用delete[]时标志为二进制11,直

;接用delete为二进制01
0040105D mov ecx, [ebp-0Ch] ;参数1,释放堆对象首地址

00401060 call sub_4010C0 ;释放堆对象函数,该函数有两个参数,更多信
息见

;代码清单10-11中的讲解

00401065 mov [ebp-14h], eax
00401068 jmp short loc_401071
0040106A mov dword ptr [ebp-14h], 0
00401071 xor eax, eax
00401073 mov esp, ebp
00401075 pop ebp
00401076 retn

虚函数

对于具有虚函数的类而言,构造函数和析构函数的识别过程更加简单。

而且,在类中定义虚函数之后,如果没有提供构造函数,编译器会生成并提供默认的构造函数。 对象的多态性需要通过虚表和虚表指针完成,虚表指针被定义在对象首地址处,因此虚函数必须作为成员函数使用

因为非成员函数 没有this指针,所以无法获得虚表指针,进而无法获取虚表,也就无法访问虚函数。

为什么类有了虚函数后还需要提供默认的构造函数呢?构造函数 内发生了哪些变化呢?

1.虚函数的机制

在C++中,使用关键字virtual声明函数为虚函数。当类中定义有 虚函数时,

编译器会将该类中所有虚函数的首地址保存在一张地址表中,这张表被称为虚函数地址表,简称虚表。

同时,编译器还会在类中添加一个隐藏数据成员,称为虚表指针。该指针保存着虚表的首地 址,用于记录和查找虚函数。我们先来看一个包含虚函数的类的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//C++源码

#include <stdio.h>
class Person {
public:
virtual int getAge() { //虚函数定义

return age;
}
virtual void setAge(int age) { //虚函数定义

this->age = age;
}
private:
int age;
};
int main(int argc, char* argv[]) {
Person person;
return 0;
}

定义了两个虚函数和一个数据成员。

如果这个类没有定义虚函数,则其长度为4,

定义了虚函数后,因为还含有隐藏数据成员(虚表指针),所以32位程序大小为8,64程序大小为16

类Person确实多出了一个指针大小数据,这个数据用于保存虚表指针。

在虚表指针指向的函数指针数组中,保存着虚 函数getAge和setAge的首地址。

对于开发者而言,虚表和虚表指针 都是隐藏的,在常规的开发过程中感觉不到它们的存在。

对象中的虚 表指针和虚表的关系

有了虚表指针,就可以通过 该指针得到类中所有虚函数的首地址。

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
//虚表指针的初始化过程

#include <stdio.h>
class Person {
public:
virtual int getAge() { //虚函数定义

return age;
}
virtual void setAge(int age) { //虚函数定义

this->age = age;
}
private:
int age;
};
int main(int argc, char* argv[]) {
Person person;
return 0;
}

//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 8
00401006 lea ecx, [ebp-8] ;获取对象首地址

00401009 call sub_401020 ;调用构造函数,类Person中并没有定义构造函数,此调用为默认构造函数

0040100E xor eax, eax
00401010 mov esp, ebp
00401012 pop ebp
00401013 retn


00401020 push ebp ;默认构造函数分析
00401021 mov ebp, esp
00401023 push ecx
00401024 mov [ebp-4], ecx ;[ebp-4] 存储this指针
00401027 mov eax, [ebp-4] ;取出this指针并保存到eax中,这个地址将
会作为指针保存虚函数表的首地址中
0040102A mov dword ptr [eax], offset ??_7Person@@6B@;取虚表的
首地址,保存到虚表指针中
00401030 mov eax, [ebp-4] ;返回对象首地址
00401033 mov esp, ebp
00401035 pop ebp
00401036 retn


编译器为类Person提供了默认的构造函数。
该默认构造函数先取得虚表的首地址,然后赋值到虚表指针中,虚表
信息如图所示。


显示了虚表中的两个地址信息,分别为成员函数getAge和setAge的地址。

因此,得到虚表指针就相当于得到了类中所有虚函数的首地址。对象的虚表指针初始化是通过编译器在构造函数内插入代码完成的。

在用户没有编写构造函数时,因为必须初始化虚表指针, 所以编译器会提供默认的构造函数,以完成虚表指针的初始化。

因为虚表信息在编译后会被链接到对应的执行文件中,所以获得的虚表地址是一个相对固定的地址。

虚表中虚函数的地址排列顺序因虚函数在类中的声明顺序而定,先声明的虚函数的地址会被排列在虚表靠前的位置。

第一个被声明的虚函数的地址在虚表的首地址处。

展示了默认构造函数初始化虚表指针的过程。对于含有构造函数的类而言,其虚表初始化过程和默认构造函数相同,都是在对象首地址处保存虚表的首地址。

在虚表指针的初始化过程中,对象执行了构造函数后,就得到了虚表指针,当其他代码访问这个对象的虚函数时,会根据对象的首地址,取出对应的虚表元素。

当函数被调用时,会间接访问虚表,得到对应的虚函数首地址并调用执行。

这种调用方式是一个间接的调用过程,需要多次寻址才能完成。 上述通过虚表间接寻址访问的情况只有在使用对象的指针或引用调用虚函数的时候才会出现。

当直接使用对象调用自身虚函数时,没有必要查表访问。这是因为已经明确调用的是自身成员函数,根本没有构成多态性,查询虚表只会画蛇添足,降低程序的执行效率,所以将这种情况处理为直接调用方式,

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
#include <stdio.h>
class Person {
public:
virtual int getAge() { //虚函数定义

return age;
}
virtual void setAge(int age) { //虚函数定义

this->age = age;
}
private:
int age;
};

int main(int argc, char* argv[]) {
Person person;
person.setAge(20);
printf("%d\n", person.getAge());
return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 8
00401006 lea ecx, [ebp-8] ;传递this指针

00401009 call sub_401040 ;调用默认构造函数

0040100E push 14h
00401010 lea ecx, [ebp-8] ;传递this指针

00401013 call sub_401080 ;直接调用函数setAge
00401018 lea ecx, [ebp-8] ;传递this指针

0040101B call sub_401060 ;直接调用函数getAge
00401020 push eax
00401021 push offset unk_412160
00401026 call sub_4010E0 ;调用printf函数

0040102B add esp, 8
0040102E xor eax, eax
00401030 mov esp, ebp
00401032 pop ebp
00401033 retn
00401080 push ebp ;setAge函数

00401081 mov ebp, esp
00401083 push ecx
00401084 mov [ebp-4], ecx
00401087 mov eax, [ebp-4] ;eax=this
0040108A mov ecx, [ebp+8] ;[ebp+8]为age
0040108D mov [eax+4], ecx ;this->age=age
00401090 mov esp, ebp
00401092 pop ebp
00401093 retn 4 ;分析显示,虚函数与其他非虚函数的成员函数的实现流程
一致,函数内部无差别

直接通过对象调用自身的成员虚函数,因此编译器使用了直接调用函数的方式,没有访问虚表指针,间接获取虚函数地址。

对象的多态性常常体现在派生和继承关系中,仔细分析虚表指针的原理,我们会发现编译器隐藏了初始化虚表指针的实现代码,

当类中出现虚函数时,必须在构造函数中对虚表指针执行初始化操作,而没有虚函数类对象构造时,不会进行初始化虚表指针的操作。

由此可见,在分析构造函数时,又增加了一个新特征——虚表指针初始化。根据以上分析,如果排除开发者伪造编译器生成的代码误导分析人员的情况,

我们就可以给出一个结论:对于单线继承的类结构,在某个成员函数中,将this指针的地址初始化为虚表首地址时,可以判定这个成员函数为构造函数。

构造函数可以通过识别虚表指针的初始化来简化分析,那么析构函数中是否有对虚表指针的操作呢?

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
#include <stdio.h>
class Person {
public:
virtual int getAge() { //虚函数定义

return age;
}
virtual void setAge(int age) { //虚函数定义

this->age = age;
}
~Person() {
printf("~Person()\n");
}

private:
int age;
};

int main(int argc, char* argv[]) {
Person person;
person.setAge(20);
printf("%d\n", person.getAge());
return 0;
}
//x86_vs对应汇编代码讲解

00401070 push ebp ;析构函数
00401071 mov ebp, esp
00401073 push ecx
00401074 mov [ebp-4], ecx ;[ebp-4]保存this指针
00401077 mov eax, [ebp-4] ;eax得到this指针,这是虚表的位置
0040107A mov dword ptr [eax], offset ??_7Person@@6B@;将当前类
虚表首地址赋值到虚表指针中
00401080 push offset aPerson ;"~Person()\n"
00401085 call sub_401120 ;调用printf函数
0040108A add esp, 4
0040108D mov esp, ebp
0040108F pop ebp
00401090 retn

构造函数中完成的是初始化虚表指针的工作,此时虚表指针并没有指向虚表地址,而执行析构函数时,对象的虚表指针已经指向了某个虚表首地址。

大家是否觉得在析构函数中填写虚表是没必要的呢? 这里实际上是在还原虚表指针,让其指向自身的虚表首地址,防止在析构函数中调用虚函数时取到非自身虚表,从而导致函数调用错误。

判定析构函数的依据和虚表指针相关,识别析构函数的充分条件是写入虚表指针,但是请注意,

它与前面讨论的虚表指针初始化不同。

所谓虚表指针初始化,是指对象原来的虚表指针位置不是有效的,经过初始化才指向了正确的虚函数表。

而写入虚表指针,是指对象的虚表指针可能是有效的,已经指向了正确的虚函数表,将对象的 虚表指针重新赋值后,其指针可能指向了另一个虚表,虚表的内容不一定和原来的一样。

结合IDA中的引用参考可知,只要确定一个构造函数或者析构函数,我们就能顺藤摸瓜找到其他构造函数以及类之间的关系。

2.虚函数的识别

在判断是否为虚函数时,我们要做的是鉴别类中是否出现了以下 特征。

类中隐式定义了一个数据成员。 该数据成员在首地址处,占一个指针大小。 构造函数会将此数据成员初始化为某个数组的首地址。

这个地址属于数据区,是相对固定的地址。 在这个数组内,每个元素都是函数指针。 仔细观察这些函数,它们被调用时,第一个参数必然是this指针 (要注意调用约定)。 在这些函数内部,很有可能对this指针使用相对间接的访问方式。

有了虚表,类中所有虚函数都被囊括其中。查找这个虚表需要得到指向它的虚表指针,虚表指针又在构造函数中被初始化为虚表首地址。

由此可见,要想找到虚函数,就要得到虚表的首地址。 经过层层分析,识别虚函数最终转变成识别构造函数或者析构函数。

构造函数与虚表指针的初始化有依赖关系。对于构造函数而言, 初始化虚表指针会简化识别构造函数的过程,而初始化虚表指针又必须在构造函数内完成,

因此在分析构造函数时,应重点考察对象首地址处被赋予的值。

查询this指针指向的地址处的内存数据,跟踪并分析其数据是否为地址信息,是否对这个指针的内容进行赋值操作,赋值后的数据是否指向了某个地址表,表中各单元项是否为函数首地址。

有了这一系列的鉴定后,就可得知此成员函数是否为构造函数。识别出构造函数后,即可顺藤摸瓜找到所有的虚函数。我们来看如下一段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
; 具有成员函数特征,传递对象首地址作为this指针

lea ecx,[ebp-8] ;获取对象首地址

call XXXXXXXXh ;调用函数

;调用函数的实现代码内

mov reg, this ;某寄存器得到对象首地址

;向对象首地址处写入地址数据,查看并确认此地址数据是否为函数地址表的首地址

mov dword ptr [reg], XXXXXXXXh

在分析过程中遇到上述代码时,应高度怀疑其为构造函数或者析构函数。

查看并确认此地址数据是否为函数地址表的首地址,即可判断是否为构造或析构函数。

引用此虚表首地址的函数所在的地址标号。

只有构造函数和析构函数中存在对虚表指针的修改操作,等同于定位到了引用此虚表的所有构造函数和析构函数,这使得识别类中的构造函数和析构变得更为简单,也更为准确

因为构造函数可以被重载,分析起来相对复杂,所以我们先从任意一个构造函数或者析构函数入手,找到虚表的操作部分,

使用IDA的交叉参考找到所有对此虚表指针有修改的函数的地址,除析构函数的地址外,剩余的就是构造函数。

下面先观察带有全局对象的C++源码,

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
//C++源码
#include <stdio.h>
class Global {
public:
Global() { //无参构造函数
printf("Global\n");
}
Global(int n) { //有参构造函数

printf("Global(int n) %d\n", n);
}
Global(const char *s) { //有参构造函数

printf("Global(char *s) %s\n", s);
}
virtual ~Global() { //虚析构函数

printf("~Global()\n");
}
void show(){
printf("Object Addr: 0x%p", this);
}
};
Global g_global1;
Global g_global2(10);
Global g_global3("hello C++");
int main(int argc, char* argv[]) {
g_global1.show();
g_global2.show();
g_global3.show();
return 0;
}
//x86_vs对应汇编代码讲解
;main函数:
004010A0 push offset off_419000 ; 参 数 1 :获取对象首地址 ,
&g_global1
004010A5 push offset aObjectAddr0xP ; 参 数 2 :"Object Addr:
0x%p"
004010AA call sub_4010E0 ;调用printf函数
004010AF push offset off_419004 ; 参 数 1 :获取对象首地址 ,
&g_global2
004010B4 push offset aObjectAddr0xP ; 参 数 2 : "Object Addr:
0x%p"
004010B9 call sub_4010E0 ;调用printf函数
004010BE push offset off_419008 ; 参 数 1 :获取对象首地址 ,
&g_global3
004010C3 push offset aObjectAddr0xP ; 参 数 2 : "Object Addr:
0x%p"
004010C8 call sub_4010E0 ;调用printf函数

004010CD add esp, 18h
004010D0 xor eax, eax
004010D2 retn

定义了3个全局对象,分别调用了3种不同的构造函数。main()函数中使用全局对象调用了成员函数show。在分析过程 中,全局对象调用成员函数的操作非常容易识别。第一步是定位全局 对象,

经过内联优化后,没有了成员函数show的调用过程,直接内联使用printf函数显示全局对象首地址。虽然没有了成员函数传递this指针的过程,但因为在成员函数中使用了printf,经过内联 优化,必须存在等价类成员函数的功能,所以成员函数的实现代码不 会被删除。我们只须逐一检查3个全局地址标号off_419000 、off_419004和off_419008,即可得知是否为全局对象。有了全局对 象的地址标号以后,接下来要对它们重新命名,如下所示。

off_419000 g_global1 off_419004 g_global2 off_419008 g_global3

代码清单10-5全局对象构造代理函数的分析中有个神秘的调用, 如下所示。

00401000 push offset aGlobal_0 ;”Global\n”

00401005 call sub_4010E0 ;调用printf函数,构造函数内联

0040100A push offset sub_411690 ;void (__cdecl *)()

0040100F call _atexit

00401014 add esp, 8

00401017 retn

这个函数的关键之处是调用atexit,查阅相关文档可知,该函数可 以在退出main()函数后执行开发者自定义的函数(即注册终止函 数),其函数声明如下。

int cdecl atexit(void ( cdecl *)(void));

只有一个无参且无返回值的函数指针作为atexit的参数,这个函数 指针会添加在终止函数的数组中,在main()函数执行完毕后,由

_execute_onexit_table函数倒序执行数组中的每个函数。 了解这个函数后,请读者观察atexit的参数sub_411690。将地 址411690的内容反汇编之后不难发现,这个411690就是析构函数的 代理。为了不使本书的篇幅过长,这里就不粘贴代码了,请读者自行 动手验证并观察。 那么,atexit函数理所当然地成为我们寻找全局对象析构函数的指 路灯。注意,在IDA的环境下,C的调用约定是在函数名前加上下划 线“_”。查找函数_atexit,查看调用它的地址,如图11-8所示。

3.本章小结

虚函数在面向对象领域中的应用十分广泛,可以说,没有虚函数 也就没有多态性,更谈不上面向对象的软件设计了。虚函数在面向对象软件设计中无处不在通过本章的介绍可知,虚函数的调用不难识 别,如下所示

1
2
3
4
5
6
7
8
9
00401092 mov ecx,dword ptr [ebp-14h] ; ecx得到this指针

00401095 mov edx,dword ptr [ecx] ; edx得到虚表指针

00401097 mov esi,esp 00401099 mov ecx,dword ptr [ebp-14h] ; 成员函数调用,传递this指针

; 关键,这里是个间接调用,且为成员函数,可怀疑是虚函数

0040109C call dword ptr [edx+4] ; 64位应用程序同理,有兴趣的读者可以尝试独立分析,练习一下

如何确定[edx+4]是虚函数地址的呢?证实edx是虚函数表的首 地址是关键,于是识别构造函数和析构函数尤为重要,IDA的引用参考 能给我们很大帮助。

我们先假设edx是虚表指针,然后查询引用参考。 如果假设成立,就能找到所有的构造函数和唯一的析构函数,再看构造函数和析构函数是否满足第10章讨论的必要条件。充要条件都满足 了,就可以将其认定为虚函数,同时找到所有的构造函数和唯一的析构函数。反之,如果假设不成立,那就应该怀疑是开发者自定义的函 数指针数组。

关于atexit的实现原理 , 请 查 阅 VS2019 安 装 目 录 下 的\Community\VC\Tools\MSVC\ 14.22.27905\crt\src\vcruntime\utility.cpp文件。如果程序存在全 局对象、静态对象或者调用了atexit函数,那么在执行_initterm函数(**it)()的时候会执行_register_onexit_function函数,这个函数用于注册终止函数,这个终止函数由_onexit函数负责维护。在main()函数 退出后,调用exit函数,exit函数又会调用_execute_onexit_table。 在_execute_onexit_table函数内,遍历终止所有终止函数。请读者 阅 读 源 码 文 件 VS2019 SDK 目 录下 的

\Source\10.0.10240.0\ucrt\startup\onexit.cpp , 或 者 单 步 跟 入_initterm和atexit,对此代码进行分析印证。

从内存角度看继承和多重继承

类的继承与派生 是一个从抽象到具体的过程。 什么是从抽象到具体的过程呢?

指向父类对象的指针除了可以操作父类对象外,还能操作子类对象

指向子类对象的指针不能操作父类对象,如果强制将父类对象的指针转换为子类对象的指针,如下所示。

Derive *p = (Derive *)&base; // base 为父类对象,Derive 继承自 base

这条语句虽然可以编译通过,但是有潜在的危险。

识别类和类之间的关系

在C++的继承关系中,子类具备父类所有成员数据和成员函数。 子类对象可以直接使用父类 中声明为公有 ( public ) 和 保 护 (protected)的数据成员与成员函数。

对于在父类中声明为私有 (private)的成员,虽然子类对象无法直接访问,但是在子类对象的 内存结构中,父类私有的成员数据依然存在。

C++语法规定的访问控制仅限于编译层面,在编译的过程中由编译器进行语法检查,因此访问控制不会影响对象的内存结构。本节将以公有继承为例进行讲解,

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
//C++源码

#include <stdio.h>
class Base { //基类定义

public:
Base() {
printf("Base\n");
}
~Base() {
printf("~Base\n");
}
void setNumber(int n) {
base = n;
}
int getNumber() {
return base;
}
public:
int base;
};
class Derive : public Base { //派生类定义

public:
void showNumber(int n) {
setNumber (n);
derive = n + 1;
printf("%d\n", getNumber());
printf("%d\n", derive);
}
public:
int derive;
};
int main(int argc, char* argv[]) {
Derive derive;
derive.showNumber(argc);
return 0;
}

//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 0Ch
00401006 lea ecx, [ebp-0Ch] ;获取对象首地址作为this指针
00401009 call sub_401050 ;调用类Derive的构造函数,编译器为Derive
提供了默认的构造函数
0040100E mov eax, [ebp+8]
00401011 push eax ;参数2:argc
00401012 lea ecx, [ebp-0Ch] ;参数1:传入this指针
00401015 call sub_4010E0 ;调用成员函数showNumber
0040101A mov dword ptr [ebp-4], 0
00401021 lea ecx, [ebp-0Ch] ;传入this指针
00401024 call sub_401090 ;调用类Derive的析构函数,编译器为Derive
提供了默认的析构函数
00401029 mov eax, [ebp-4]
0040102C mov esp, ebp
0040102E pop ebp
0040102F retn


00401050 push ebp ;子类Derive的默认构造函数分析
00401051 mov ebp, esp
00401053 push ecx
00401054 mov [ebp-4], ecx
00401057 mov ecx, [ebp-4] ;以子类对象首地址作为父类的this指针
0040105A call sub_401030 ;调用父类构造函数
0040105F mov eax, [ebp-4]
00401062 mov esp, ebp
00401064 pop ebp
00401065 retn
00401090 push ebp ;子类Derive的默认析构函数分析
00401091 mov ebp, esp
00401093 push ecx
00401094 mov [ebp-4], ecx
00401097 mov ecx, [ebp-4] ;以子类对象首地址作为父类的this指针
0040109A call sub_401070 ;调用父类析构函数
0040109F mov esp, ebp
004010A1 pop ebp
004010A2 retn

定义了两个具有继承关系的类。父类Base中定义了数据成员base、构造函数、析构函数和两个成员函数。

子类中只有 一个成员函数showNumber和一个数据成员derive。根据C++的语 法规则,子类Derive将继承父类中的成员数据和成员函数。

编译器提供了默认构造函数与 析构函数。当子类中没有构造函数或析构函数,父类却需要构造函数和析构函数时,编译器会为子类提供默认的构造函数与析构函数。

由于子类继承了父类,因此子类中需要拥有父类的各成员,类似在子类中定义了父类的对象作为数据成员使用。

类关系如果转换成以下代码,它们的内存结构是等价的。

1
2
3
4
5
6
class Base{...}; //类定义见代码清单12-1
class Derive {
public:
Base base; //原来的父类Base 成为成员对象
int derive; // 原来的子类派生数据
};

原来的父类Base成为Derive的一个成员对象,当产生Derive类的对象时,会先产生成员对象base,这需要调用其构造函数。

当Derive类没有构造函数时,为了能够在Derive类对象产生时调用成员对象的构造函数,编译器同样会提供默认的构造函数,以实现成员构造函数的调用。

但是,如果子类含有构造函数,而父类不存在构造函数,则编译器不会为父类提供默认的构造函数。

在构造子类时,因为父类中没有虚表指针,也不存在构造祖先类的问题,所以添加默认构造函数对父 类没有任何意义。

父类中含有虚函数的情况则不同,此时父类需要初 始化虚表工作,因此编译器会为其提供默认的构造函数,以初始化虚 表指针。

当子类对象被销毁时,其父类也同时被销毁,为了可以调用父类 的析构函数,编译器为子类提供了默认的析构函数。在子类的析构函 数中,析构函数的调用顺序与构造函数相反,先执行自身的析构代 码,再执行父类的析构代码。

依照构造函数与析构函数的调用顺序,不仅可以顺藤摸瓜找出各 类之间的关系,还可以区别出构造函数与析构函数。

子类对象在内存中的数据排列:先安排父类的数据,后安排子类 、新定义的数据。当类中定义了其他对象作为成员,并在初始化列表中 指定了某个成员的初始化值时,构造的顺序会是怎样的呢?我们先来 看下面的代码。

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
//C++源码:

class Member{
public:
Member() {
member = 0;
}
int member;};
class Derive : public Base {
public:
Derive():derive(1) {
printf("使用初始化列表\n");
}
public:
Member member; //类中定义其他对象作为成员

int derive;
};
int main(int argc, char* argv[]) {
Derive derive;
return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 10h
00401006 lea ecx, [ebp-10h] ;传递this指针
00401009 call sub_401050 ;调用Derive的构造函数
0040100E mov dword ptr [ebp-4], 0
00401015 lea ecx, [ebp-10h] ;传递this指针
00401018 call sub_4010D0 ;调用Derive的析构函数
0040101D mov eax, [ebp-4]
00401020 mov esp, ebp
00401022 pop ebp
00401023 retn


00401050 push ebp ; Derive构造函数
00401051 mov ebp, esp
00401053 push ecx
00401054 mov [ebp-4], ecx ;[ebp-4]保存了this指针
00401057 mov ecx, [ebp-4] ;传递this指针
0040105A call sub_401030 ;调用父类构造函数
0040105F mov ecx, [ebp-4]
00401062 add ecx, 4 ;根据this指针调整到类中定义的对象member的首地址处
00401065 call sub_401090 ;调用Member构造函数
0040106A mov eax, [ebp-4]
0040106D mov dword ptr [eax+8], 1 ;执行初始化列表,this指针传递给eax后,[eax+8]是
;对成员数据derive进行寻址
00401074 push offset unk_412170 ;最后才是执行Derive的构造代码
00401079 call sub_401130 ;调用printf函数
0040107E add esp, 4
00401081 mov eax, [ebp-4]
00401084 mov esp, ebp
00401086 pop ebp
00401087 retn

根据以上分析,在有初始化列表的情况下,会优先执行初始化列表中的操作,其次才是自身的构造函数。

构造的顺序:先构造父类,然后按声明顺序构造成员对象和初始化列表中指定的成员,最后才是 自身的构造代码。

读者可自行修改类中各个成员的定义顺序,初始化列表的内容,然后按以上方法分析并验证构造的顺序。

在子类对象Derive的内存布局中,首地址处的第一个数据是父类数据成员base,向后的4字节数据为自 身数据成员derive

有了这样的内存结构,不但可以使用指向子类对象的子类指针间 接寻址到父类定义的成员,还可以使用指向子类对象的父类指针间接 寻址到父类定义的成员。

在使用父类成员函数时,传递的this指针也可 以是子类对象的首地址。

因此,在父类中,可以根据以上内存结构将 子类对象的首地址视为父类对象的首地址实现对数据的操作,而且不 会出错。因为父类对象的长度不超过子类对象,而子类对象只要派生 新的数据,其长度即可超过父类,所以子类指针的寻址范围不小于父 类指针。

在使用子类指针访问父类对象时,如果访问的成员数据是父 类对象定义的,则不会出错;如果访问的是子类派生的成员数据,则 会造成访问越界。 我们先看看正确的情况,

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
子类调用父类函数(Debug版)

void showNumber(int n) {
setNumber (n);
derive = n + 1;
printf("%d\n", getNumber());
printf("%d\n", derive);
}
//x86_vs对应汇编代码讲解

004010E0 push ebp ;showNumber函数
004010E1 mov ebp, esp
004010E3 push ecx
004010E4 mov [ebp-4], ecx ;[ebp-4]中保留了this指针

004010E7 mov eax, [ebp+8]
004010EA push eax ;参数2:n
004010EB mov ecx, [ebp-4] ;参数1:因为this指针同时也是对象中父类
部分的

;首地址,所以在调用父类成员函数时,this指针的

;值和子类对象等同

004010EE call sub_4010C0 ;调用基类成员函数setNumber
004010F3 mov ecx, [ebp+8]
004010F6 add ecx, 1 ;将参数n值加1
004010F9 mov edx, [ebp-4] ;edx拿到this指针

004010FC mov [edx+4], ecx ;参考内存结构,edx+4是子类成员derive的


;址,derive=n+1
004010FF mov ecx, [ebp-4] ;传递this指针

00401102 call sub_4010B0 ;调用基类成员函数getNumber
00401107 push eax ;参数2:Base.base
00401108 push offset aD ;参数1:"%d\n"
0040110D call sub_401170 ;调用printf函数

00401112 add esp, 8
00401115 mov eax, [ebp-4]
00401118 mov ecx, [eax+4]
0040111B push ecx ;参数2:derive
0040111C push offset aD ;参数1:"%d\n"
00401121 call sub_401170 ;调用printf函数

00401126 add esp, 8
00401129 mov esp, ebp
0040112B pop ebp
0040112C retn 4

父类中成员函数setNumber在子类中并没有被定义,但根据派生 关系,在子类中可以使用父类的公有函数。编译器是如何实现正确匹 配的呢? 如果使用对象或对象的指针调用成员函数,编译器可根据对象所 属作用域通过“名称粉碎法” [1]实现正确匹配。

在成员函数中调用其他 成员函数时,可匹配当前作用域。在调用父类成员函数时,虽然其this指针传递的是子类对象的首地 址,但是在父类成员函数中可以成功寻址到父类中的数据。回想之前 提到的对象内存布局,父类数据成员被排列在地址最前端,之后是子 类数据成员。showNumber运行过程中的内存信息如图12-1所示。

这 时 , 首 地 址 处 为 父 类 数 据 成 员 , 而 父 类 中 的 成 员 函 数

setNumber在寻址此数据成员时,会将首地址的4字节数据作为数据 成员base。由此可见,父类数据成员被排列在最前端是为了在添加派 生类后方便子类使用父类中的成员数据,并且可以将子类指针当作父 类指针使用。

按照继承顺序依次排列各个数据成员,这样一来,不管 是操作子类对象还是父类对象,只要确认了对象的首地址,对父类成 员数据的偏移量而言都是一样的。对子类对象而言,使用父类指针或 者子类指针都可以正确访问其父类数据。

反之,如果使用一个父类对 象的指针去访问子类对象,则存在越界访问的危险,如代码清单12-4所示。

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
父类对象的指针访问子类对象存在的危险(Debug版)
// C++ 源码说明:类型定义见代码清单12-1
int main(int argc, char* argv[]) {
int n = 0x12345678;
Base base;
Derive *derive = (Derive*)&base;
printf("%x\n", derive->derive);return 0;
}
//x86_vs对应汇编代码讲解

00401000 push ebp
00401001 mov ebp, esp
00401003 sub esp, 10h
00401006 mov dword ptr [ebp-10h], 12345678h ;局部变量赋初值

0040100D lea ecx, [ebp-4] ;传递this指针

00401010 call sub_401050 ;调用构造函数

00401015 lea eax, [ebp-4]
00401018 mov [ebp-8], eax ;指针变量[ebp-8]得到base的地址

0040101B mov ecx, [ebp-8]
0040101E mov edx, [ecx+4] ; 注 意 , ecx 中 保 留 了 base 的 地 址 , 而

[ecx+4]
;的访问超出了base的内存范围

00401021 push edx
00401022 push offset unk_412160
00401027 call sub_4010D0 ;调用printf函数

0040102C add esp, 8
0040102F mov dword ptr [ebp-0Ch], 0
00401036 lea ecx, [ebp-4] ;传递this指针

00401039 call sub_401070 ;调用析构函数

0040103E mov eax, [ebp-0Ch]
00401041 mov esp, ebp
00401043 pop ebp
00401044 retn

学习虚函数时,我们分析了类中的隐藏数据成员——虚表指针。 正因为有这个虚表指针,调用虚函数的方式改为查表并间接调用,在 虚表中得到函数首地址并跳转到此地址处执行代码。利用此特性即可 通过父类指针访问不同的派生类。在调用父类中定义的虚函数时,根 据指针指向的对象中的虚表指针,可得到虚表信息,间接调用虚函 数,即构成了多态。 以“人”为基类,可以派生出不同国家的人:中国人、美国人、德 国人等。这些人有一个共同的功能——说话,但是他们实现这个功能 的过程不同,例如中国人说汉语、美国人说英语、德国人说德语。每 个国家的人都有不同的说话方法,为了让“说话”这个方法有一个通用 接口,可以设立一个“人”类将其抽象化。使用“人”类的指针或引用调 用具体对象的“说话”方法,就形成了多态。此关系的描述如代码清单12-5所示

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
代码清单12-5 人类说话方法的多态模拟类结构(C++源码)

#include <stdio.h>class Person{ // 基类——“人”类

public:
Person() {}
virtual ~Person() {}
virtual void showSpeak() {} // 这里用纯虚函数更好,相关的知识点后
面会讲到

};
class Chinese : public Person { // 中国人:继承自人类

public:
Chinese() {}
virtual ~Chinese() {}
virtual void showSpeak() { // 覆盖基类虚函数

printf("Speak Chinese\r\n");
}
};
class American : public Person { //美国人:继承自人类

public:
American() {}
virtual ~American() {}
virtual void showSpeak() { //覆盖基类虚函数

printf("Speak American\r\n");
}
};
class German : public Person { //德国人:继承自人类

public:
German() {}
virtual ~German() {}
virtual void showSpeak() { //覆盖基类虚函数

printf("Speak German\r\n");
}
};
void speak(Person* person){ //根据虚表信息获取虚函数首地址并调用

person->showSpeak();
}
int main(int argc, char* argv[]) {
Chinese chinese;
American american;
German german;
speak(&chinese);
speak(&american);speak(&german);
return 0;
}

在代码清单12-5中,利用父类指针可以指向子类的特性,可以间 接调用各子类中的虚函数。虽然指针类型为父类,但是因为虚表的排 列顺序是按虚函数在类继承层次中首次声明的顺序排列的,所以只要 继承了父类,其派生类的虚表中父类部分的排列就与父类一致,子类 新定义的虚函数会按照声明顺序紧跟其后。因此,在调用过程中,我 们给speak函数传递任何一个基于Person的派生对象地址都可以正确 调用虚函数showSpeak。在调用虚函数的过程中,程序是如何通过虚 表指针访问虚函数的呢?具体分析如代码清单12-6所示。

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
代码清单12-6 虚函数调用过程(Debug版)

// main函数分析略

// speak函数讲解

//x86_vs对应汇编代码讲解

00401000 push ebp ;speak函数

00401001 mov ebp, esp
00401003 mov eax, [ebp+8] ;eax获取参数person的值

00401006 mov edx, [eax] ;取虚表首地址并传递给edx
00401008 mov ecx, [ebp+8] ;传递this指针

0040100B mov eax, [edx+4]
0040100E call eax ;利用虚表指针edx,间接调用函数,回顾父类Person的
类型

;声明,第一个声明的虚函数是析构函数,第二个声明的是

;showSpeak,所以showSpeak在虚函数表中的位置排第二,

;[edx+4]即showSpeak的函数地址

00401010 pop ebp
00401011 ret

在代码清单12-6中,虚函数的调用过程使用了间接寻址方式,而 非直接调用函数地址。由于虚表采用间接调用机制,因此在使用父类 指针person调用虚函数时,没有依照其作用域调用Person类中定义的 成员函数showSpeak。需要注意的是,GCC编译器虚析构函数会生成 两个虚表项,因此showSpeak函数在第三项(后续例子将详细分 析)。 对比代码清单11-3中的虚函数调用可以发现,当没有使用对象指 针或者对象引用时,调用虚函数指令的寻址方式为直接调用,从而无 法构成多态。因为代码清单12-6中使用了对象指针调用虚函数,所以 会产生间接调用方式,进而构成多态。代码清单11-3的代码片段如 下。

24: setNumber(n); 010F2C3D mov eax,dword ptr [ebp+8] 010F2C40 push eax 010F2C41 mov ecx,dword ptr [ebp-8] 010F2C44 call 010F13BB ;这里直接调用,无法构成多态

当父类中定义有虚函数时,将会产生虚表。当父类的子类产生对 象时,根据代码清单12-2的分析,会在调用子类构造函数前优先调用 父类构造函数,并以子类对象的首地址作为this指针传递给父类构造函 数。在父类构造函数中,会先初始化子类虚表指针为父类的虚表首地 址。此时,如果在父类构造函数中调用虚函数,虽然虚表指针属于子 类对象,但指向的地址却是父类的虚表首地址,这时可判断出虚表所 属作用域与当前作用域相同,于是会转换成直接调用方式,最终造成 构造函数内的虚函数失效。修改代码清单12-5,在Person类的构造函 数中添加虚函数调用,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:Person() {
showSpeak(); //调用虚函数,不多态

}
virtual ~Person() {
}
virtual void showSpeak() {
printf("Speak No\n");
}
};

图12-2演示了构造函数中使用虚函数的流程。按C++规定的构造 顺序,父类构造函数会在子类构造函数之前运行,在执行父类构造函 数时将虚表指针修改为当前类的虚表指针,也就是父类的虚表指针, 因此导致虚函数的特性失效。如果父类构造函数内部存在虚函数调 用,这样的顺序能防止在子类中构造父类时,父类根据虚表错误地调 用子类的成员函数。 虽然在构造函数和析构函数中调用虚函数会使其多态性失效,但 是为什么还要修改虚表指针呢?编译器直接把构造函数或析构函数中 的虚函数调用修改为直接调用方式,不就可以避免这类问题了吗?大 家不要忘了,程序员仍然可以自己编写其他成员函数,间接调用本类 中声明的其他虚函数。假设类A中定义了成员函数f1()和虚函数f2(), 而且类B继承自类A并重写了f2()。根据前面的讲解我们可以知道,在 子类B的构造函数执行前会调用父类A的构造函数,此时如果在类A的 构造函数中调用f1(),显然不会构成多态,编译器会产生直接调用f1()

的代码。但是,如果在f1()中又调用了f2(),就会产生间接调用的指 令,形成多态。如果类B对象的虚表指针没有更换为类A的虚表指针, 会导致在访问类B的虚表后调用到类B中的f2()函数,而此时类B的对象 尚未构造完成,其数据成员是不确定的,这时在f2()中引用类B的对象 中的数据成员是很危险的。 同理,在析构类B的对象时,会先执行类B的析构函数,然后执行 类A的析构函数。如果在类A的析构函数中调用f1(),显然也不能构成 多态,编译器同样会产生直接调用f1()的代码。但是,如果f1()中又调 用了f2(),此时会构成多态,如果这个对象的虚表指针没有更换为类A

的虚表指针,同样也会导致访问虚表并调用类B中的f2()。但是,此时

B类对象已经执行过析构函数,所以B类中定义的数据已经不可靠了, 对其进行操作同样是很危险的。 稍后我们会以IDA为分析工具将各个知识点串联起来一起讲解。 在析构函数中,同样需要处理虚函数的调用,因此也需要处理虚 函数。按C++中定义的析构顺序,首先调用自身的析构函数,然后调 用成员对象的析构函数,最后调用父类的析构函数。在对象析构时, 首先设置虚表指针为自身虚表,再调用自身的析构函数。如果有成员 对象,则按声明的顺序以倒序方式依次调用成员对象的析构函数。最 后,调用父类析构函数。在调用父类的析构函数时,会设置虚表指针 为父类自身的虚表。 我们修改代码清单12-5中构造函数和析构函数的实现过程,通过 调试来分析其执行过程,如代码清单12-7所示。

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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
代码清单12-7 构造函数和析构函数中调用虚函数的流程

// 修改代码清单12-5的示例,在构造函数与析构函数中添加虚函数调用

class Person{ // 基类——“人”类

public:
Person() {showSpeak(); //添加虚函数调用

}
virtual ~Person() {
showSpeak(); //添加虚函数调用

}
virtual void showSpeak() {} //纯虚函数,后面会讲解

};
// main()函数实现过程

int main(int argc, char* argv[]) {
Chinese chinese;
return 0;
}
//x86_vs对应汇编代码讲解

00401020 push ebp
00401021 mov ebp, esp
00401023 sub esp, 8
00401026 lea ecx, [ebp-4] ;传递this指针

00401029 call sub_401050 ;调用构造函数

0040102E mov dword ptr [ebp-8], 0
00401035 lea ecx, [ebp-4] ;传递this指针

00401038 call sub_401090 ;调用析构函数

0040103D mov eax, [ebp-8]
00401040 mov esp, ebp
00401042 pop ebp
00401043 retn
00401050 push ebp ;Chinese构造函数

00401051 mov ebp, esp
00401053 push ecx
00401054 mov [ebp-4], ecx
00401057 mov ecx, [ebp-4] ;传入当前this指针,将其作为父类的this指


0040105A call sub_401070 ;调用父类构造函数

0040105F mov eax, [ebp-4]
00401062 mov dword ptr [eax], offset ??_7Chinese@@6B@ ;将虚
表设置为Chinese类的虚表

00401068 mov eax, [ebp-4] ;返回值设置为this指针

0040106B mov esp, ebp
0040106D pop ebp
0040106E retn
00401070 push ebp ;Person构造分析

00401071 mov ebp, esp00401073 push ecx
00401074 mov [ebp-4], ecx
00401077 mov eax, [ebp-4]
0040107A mov dword ptr [eax], offset ??_7Person@@6B@;将虚表设
置为Person类的虚表

00401080 mov ecx, [ebp-4] ;虚表是父类的,可以直接调用父类虚函数

00401083 call sub_401150 ;调用showSpeak函数

00401088 mov eax, [ebp-4] ;返回值设置为this指针

0040108B mov esp, ebp
0040108D pop ebp
0040108E retn
00401090 push ebp ;Chinese 析构函数

00401091 mov ebp, esp
00401093 push ecx
00401094 mov [ebp-4], ecx
00401097 mov eax, [ebp-4] ;返回值设置为this指针

0040109A mov dword ptr [eax],offset ??_7Chinese@@6B@;将虚表设
置为Chinese类的虚表

004010A0 mov ecx, [ebp-4] ;传递this指针

004010A3 call sub_4010B0 ;调用父类析构

004010A8 mov esp, ebp
004010AA pop ebp
004010AB retn
004010B0 push ebp ;Person析构函数

004010B1 mov ebp, esp
004010B3 push ecx
004010B4 mov [ebp-4], ecx
004010B7 mov eax, [ebp-4] ;返回值设置为this指针

;因为当前虚表指针指向了子类虚表,所以需要重新修改为父类虚表,防止调用到
子类的虚函数

004010BA mov dword ptr [eax], offset ??_7Person@@6B@;将虚表设
置为Person类的虚表

004010C0 mov ecx, [ebp-4] ;虚表是父类的,可以直接调用父类虚函数

004010C3 call sub_401150 ;调用showSpeak函数

004010C8 mov esp, ebp
004010CA pop ebp
004010CB retn

在代码清单12-7的子类构造函数代码中,先调用了父类的构造函 数,然后设置虚表指针为当前类的虚表首地址。而析构函数中的顺序 却与构造函数相反,先设置虚表指针为当前类的虚表首地址,然后调 用父类的析构函数,其构造和析构的过程描述如下。 构造:基类→基类的派生类→……→当前类。 析构:当前类→基类的派生类→……→基类。 在代码清单12-5中,析构函数被定义为虚函数。为什么要将析构 函数定义为虚函数呢?因为可以使用父类指针保存子类对象的首地 址,所以当使用父类指针指向子类堆对象时,就会出问题。当使用

delete函数释放对象的空间时,如果析构函数没有被定义为虚函数, 那么编译器会按指针的类型调用父类的析构函数,从而引发错误。而使用了虚析构函数后,会访问虚表并调用对象的析构函数。两种析构 函数的调用过程如以下代码所示。

//没有声明为虚析构函数

Person * p = new Chinese; delete p; //部分代码分析略

00D85714 mov ecx,dword ptr [ebp+FFFFFF08h] ;直接调用父类的析构函数

00D8571A call 00D81456 // 声明为虚析构函数

Person * p = new Chinese; delete p; //部分代码分析略

000B5716 mov ecx,dword ptr [ebp+FFFFFF08h] ;获取p并保存至ecx 000B571C mov edx,dword ptr [ecx] ;取得虚表指针

000B571E mov ecx,dword ptr [ebp+FFFFFF08h] ;传递this指针

000B5724 mov eax,dword ptr [edx] ;间接调用虚析构函数

000B5726 call eax

以上代码对普通析构函数与虚析构函数进行了对比,说明了类在 有了派生与继承关系后,需要声明虚析构函数的原因。对于没有派生 和继承关系的类结构,是否将析构函数声明为虚析构函数并不会影响 调用的过程,但是在编写析构函数时应养成习惯,无论当前是否有派 生或继承关系,都应将析构函数声明为虚析构函数,以防止将来更新 和维护代码时发生析构函数的错误调用。 了解了派生和继承的执行流程与实现原理后,又该如何利用这些 知识识别代码中类与类之间的关系呢?最好的办法还是先定位构造函 数,有了构造函数就可根据构造的先后顺序得到与之有关的其他类。 在构造函数中只构造自己的类很明显是基类,对于构造函数中存在调 用父类构造函数的情况,可利用虚表,在IDA中使用引用参考的功能, 便可得到所有的构造函数和析构函数,进而得到它们之间的派生和继 承关系。 将代码清单12-5修改为如下所示的代码,我们以Release选项组 对这段代码进行编译,然后利用IDA对其进行分析。

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
92
// 综合讲解(建议读者先用VC++ 分析一下Debug 选项组编译的过程,然后再看以下
内容)

#include <stdio.h>
class Person{ //基类:人类

public:
Person() {
showSpeak(); //注意,构造函数调用了虚函数

}
virtual ~Person(){
showSpeak(); //注意,析构函数调用了虚函数

}
virtual void showSpeak(){
//在这个函数里调用了其他的虚函数getClassName();
printf("%s::showSpeak()\n", getClassName());
return;
}
virtual const char* getClassName()
{
return "Person";
}
};
class Chinese : public Person { //中国人,继承自"人"类

public:
Chinese() {
showSpeak();
}
virtual ~Chinese() {
showSpeak();
}
virtual const char* getClassName() {
return "Chinese";
}
};
int main(int argc, char* argv[]) {
Person *p = new Chinese;
p->showSpeak();
delete p;
return 0;
}
//x86_vs对应汇编代码

;在IDA中打开执行文件,载入sig,定位到main()函数并修改函数名称为main,得到如下代码

004010E0 ; int __cdecl main(int argc, const char **argv, const
char **envp)
004010E0 main proc near ; CODE XREF: start-8D↓p
004010E0 push esi
004010E1 push 4
004010E3 call ??2@YAPAXI@Z ;operator new(uint)申请4字节堆空间

004010E8 mov esi, eax ;esi保存new调用的返回值

004010EA add esp, 4 ;平衡new调用的参数

004010ED test esi, esi ;编译器插入了检查new返回值的代码,若返回值为0,
则跳过

;构造函数的调用

;点击下面这个跳转指令的标号loc_40113A,目标处会高亮,结合目标处的上面一条
指令(地址004010F0处),

; 可以看出这是一个分支结构,跳转的目标是new调用返回0时的处理(esi置为0),
读者可以按照命名规范重新

; 定义这些标号(IDA中重命名的快捷键是N,选中标号以后按N键即可)

004010EF jz short loc_40113A
;如果new返回值不为0,则ecx保存堆地址,结合004010F9地址处的call指令,可怀
疑是thiscall的调用方式,

;需要到004010F9中看看有没有访问ecx才能进一步确定

004010F1 mov ecx, esi
;这个地方很关键,我们去看看??_7Person@@6B@里面的内容

004010F3 mov dword ptr [esi], offset ??_7Person@@6B@ ; const
Person::`vftable'
004010F9 call sub_4010A0
??_7Person@@6B@中的内容为

.rdata:00412164 ??_7Person@@6B@ dd offset sub_401050;DATA XREF:
sub_401000+1C↑o
.rdata:00412164 ;sub_401050+3↑o ...
.rdata:00412168 dd offset sub_4010B0
.rdata:0041216C dd offset sub_4010A0

IDA 以 注 释 的 形 式 给 出 了 反 汇 编 代 码 中 所 有 引 用 了 标 号 ?? _7Person@@6B@ 的 指 令 地 址 , 供 我 们 分 析 时 参 考 。 如 “DATAXREF: sub_401000+1C↑”,表示sub_401000函数的首地址偏移

1Ch字节处的指令引用了标号??_7Person@@6B@,最后的上箭 头“↑”表示引用处的地址在当前标号的上面,也就是说引用处的地址值 比这个标号的地址值小。 接着观察sub_401000和sub_401050中的内容,双击后可以看 到,这两个名称都是函数名称,可证实??_7Person@@6B@是函数指 针数组的首地址,而且其中每个函数都有对ecx的引用。在引用前没有 给ecx赋值,说明这两个函数都是将ecx作为参数传递的。结合

004010F3 处 的 指 令 “mov dword ptr [esi], offset ?? _7Person@@6B@”,其中esi保存的是new调用申请的堆空间首地 址,这条指令在首地址处放置了函数指针数组的地址。 结合以上种种信息,我们可以认定,esi中的地址是对象的地址, 而函数指针数组就是虚表。退一步讲,即使源码不是这样,我们按此 还原后的C++代码在功能和内存布局上也是等价的。 接 着 按 N 键 , 重 命 名 ??_7Person@@6B@ , 这 里 先 命 名 为

Person_vtable,在接下来的分析中如果找到更详细的信息,还可以 继续修改这个名称,使代码的可读性更强。

004010F3 mov dword ptr [esi], offset Person_vtable

既然是对虚表指针进行初始化,就要满足构造函数的充分条件, 但是我们看到这里并没有调用构造函数,而是直接在main()函数中完 成了虚表指针的初始化,这说明构造函数被编译器内联优化了。接下 来我们看到一个函数调用如下所示。

call sub_4010A0

先 看 看 这 个 函 数 的 功 能 。 双 击 地 址 sub_4010A0 , 定 位 到

sub_4010A0的代码实现处,此处内容如下所示。

004010A0 sub_4010A0 proc near ; CODE XREF: sub_401000+24↑p 004010A0 ; sub_401050+9↑p …004010A0 mov eax, offset aPerson ; “Person”; 功能很简单,返回名称字 符串

004010A5 retn 004010A5 sub_4010A0 endp

顺 手 修 改 sub_4010A0 的 名 称 , 这 里 先 修 改 为

Person_getClassName,以后有更多信息时再进一步修改,接着分 析其后的代码。

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
004010FE push eax
004010FF push offset aSShowspeak ;"%s::showSpeak()\n"
00401104 call sub_401150
00401109 add esp, 8 ;调用printf并平衡参数

0040110C mov dword ptr [esi], offset ??_7Chinese@@6B@ ;const
;Chinese::`vftable'
00401112 mov ecx, esi
00401114 call sub_401090

双击sub_401090,其功能如下所示。

00401090 sub_401090 proc near ; CODE XREF: sub_401000+9↑p
00401090 ; main+34↓p
00401090 ; DATA XREF: ...
00401090 mov eax, offset aChinese ; "Chinese" ;功能很简单,返回名称
字符串

00401095 retn
00401095 sub_401090 endp

修改函数名称为Chinese_getClassName。接着分析后面的代
码。

00401119 push eax
0040111A push offset aSShowspeak ; "%s::showSpeak()\n"
0040111F call sub_401150
00401124 mov eax, [esi]
00401126 add esp, 8 ; 调用printf并平衡参数

至此,我们分析了new调用后的整个分支结构。当new调用成功
时,会执行对象的构造函数,而编译器对这里的构造函数进行了内联
优化,但这不会影响我们对构造函数的鉴定。首先存在写入虚表指针的充分条件,同时也满足了前面章节讨论的必要条件,还要出现在

new调用的正确分支中,因此,我们可以把new调用的正确分支中的
代码判定为构造函数的内联方式。在new调用的正确分支内,esi指向
的对象有两次写入虚表指针的代码,如下所示。

004010F3 mov dword ptr [esi], offset Person_vtable
; 中间代码略

0040110C mov dword ptr [esi], offset Chinese_vtable

我们可以借此得到派生关系,在构造函数中先填写父类的虚表,
然 后 按 继 承 的 层 次 关 系 逐 层 填 写 子 类 的 虚 表 , 由 此 可 以 判 定

Person_vtable是父类的虚表,Chinese_vtable是子类的虚表。以写
入虚表的指令为界限,可以粗略划分出父类构造函数和子类构造函数
的实现代码,但是细节上要按照程序逻辑找到界限之内其他函数传递
参数的几行代码,并排除在外,如下所示。

; 先定位到new调用的正确分支处

004010E0 push esi
004010E1 push 4
004010E3 call ??2@YAPAXI@Z ;调用new
004010E8 mov esi, eax
004010EA add esp, 4
004010ED test esi, esi ;判定new调用后的返回值

004010EF jz short loc_40113A ;返回值为0,则跳转到错误逻辑处

; 从这里开始就是正确的逻辑,同时也是父类构造函数的起始代码处

004010F1 mov ecx, esi

004010F3 mov dword ptr [esi], offset Person_vtable

004010F9 call Person_getClassName
004010FE push eax
004010FF push offset aSShowspeak ; "%s::showSpeak()\n"
00401104 call printf
00401109 add esp, 8

0040110C mov dword ptr [esi], offset Chinese_vtable

; 注意这里的传参(this指针),从这里开始就不是父类的构造函数实现代码了

00401112 mov ecx, esi

00401114 call Chinese_getClassName
00401119 push eax
0040111A push offset aSShowspeak ; "%s::showSpeak()\n"0040111F call printf
00401124 mov eax, [esi]
00401126 add esp, 8

00401129 mov ecx, esi

;子类构造函数的结束处

继续看后面的代码。

00401124 mov eax, [esi] ;取得虚表指针

00401129 mov ecx, esi ;传递this指针

0040112B call dword ptr [eax+4] ;调用虚表第二项的函数

分析这里的虚函数调用,先看看最后一次写入虚表的地址,单击

esi,观察高亮处,寻找最后一次写入的指令,如图12-3所示。 细心的读者一定找到了,没错,正是0040110C地址处!指 令“call dword ptr [eax+4]”揭示了虚表中至少有两个元素。接下来 分析在0040110C处写入虚表Chinese_vtable中的第二项内容到底是 什么。

.rdata:00412190 Chinese_vtable dd offset sub_401000 ;虚表偏移0处, 也就是虚表的第一项

.rdata:00412194 dd offset sub_4010B0;虚表偏移4处,也就是虚表的第二项

.rdata:00412198 dd offset Chinese_getClassName;现在不能确定这一项是

;否为虚表的内容

双击sub_4010B0,得到以下代码。

; 未赋值就直接使用ecx,说明ecx是在传递参数

004010B0 mov eax, [ecx] ;eax得到虚表

004010B2 call dword ptr [eax+8] ;调用虚表第三项,形成了多态

指令“call dword ptr [eax+8]”揭示了虚表中至少有3个元素, 根据Chinese_vtable得出第三项为Chinese_getClassName函数。 接着往下看。

004010B5 push eax ;向printf传入Chinese_getClassName的返回值,是一个 字符串首地址

004010B6 push offset aSShowspeak ;”%s::showSpeak()\n” 004010BB call printf 004010C0 add esp, 8 ;调用printf 显示字符串并平衡参数

004010C3 retn

这个函数的作用是调用虚表第三项元素,得到字符串,并将字符 串格式化输出。因为是按虚表调用的,所以会形成多态性。顺便把这 个函数的名称修改为showSpeak,修改后虚表结构如下所示。

.rdata:00412190 Chinese_vtable dd offset sub_401000; DATA XREF: sub_401000+3↑o .rdata:00412190 ; main+2C↑o

.rdata:00412194 dd offset showSpeak

.rdata:00412198 dd offset Chinese_getClassName

我们回到main()函数处,继续分析。

0040112E mov eax, [esi] ;eax得到虚表

00401130 mov ecx, esi ;传递this指针

00401132 push 1 ;传入参数

00401134 call dword ptr [eax] ;调用虚表中的第一项

00401136 xor eax, eax 00401138 pop esi 00401139 retn ;函数返回,所以这里是个单分支结构

call dword ptr [edx]命令调用虚表的第一项。在详细分析虚表第 一项之前,我们体验一下IDA的交叉参考功能,一次性定位所有的构造 函数和析构函数,先定位到虚表Chinese_vtable处,然后单击鼠标右 键,如图12-4所示。

右键菜单选择“Chart of xrefs to”,得到所有直接引用这个地址 的位置,如图12-5所示。

可以看到,除了main()函数访问了虚表Chinese_vtable之外,

sub_401000也访问了虚表Chinese_vtable。通过前面的分析可知, 因为main()函数中内联的构造函数存在写入虚表的操作,所以导致

Chinese_vtable被访问到。因为存在虚表,就算类中没有定义析构函数,编译器也会产生默认的析构函数,所以毫无疑问,另一个访问虚 表的函数sub_401000就是析构函数。交叉参考这个功能很好用,如 果你发现了一个父类的构造函数,想知道这个父类有多少个派生类, 也能利用这个功能快速定位。 以代码清单12-5的Debug版为例,使用IDA对其进行分析,先找 到某个子类的构造函数。因为子类的构造函数必然会先调用父类的构 造函数,所以我们利用交叉参考功能即可查询出所有引用这个父类构 造函数的指令的位置,当然也包括这个父类的所有直接子类构造函数 的位置,借此即可判定父类派生的所有直接子类,如图12-6所示。

接下来分析sub_401000函数的功能,反汇编代码如下所示。

00401000 push esi 00401001 mov esi, ecx ; 在虚表指针处写入子类虚表地址

00401003 mov dword ptr [esi], offset Chinese_vtable

00401009 call Chinese_getClassName 0040100E push eax ;获取字符串并给printf传递参数

0040100F push offset aSShowspeak ;”%s::showSpeak()\n” 00401014 call printf 00401019 add esp, 8 ;执行printf并平衡参数

;在虚表指针处写入父类虚表地址

0040101C mov dword ptr [esi], offset Person_vtable

00401022 mov ecx, esi ;传递this指针

00401024 call Person_getClassName 00401029 push eax ;获取字符串并给printf传递参数

0040102A push offset aSShowspeak ;”%s::showSpeak()\n” 0040102F call printf00401034 add esp, 8 ;执行printf并平衡参数

00401037 test [esp+4+arg_0], 1 ;检查delete标志

0040103C jz short loc_401049 ;如果参数为1,则以对象首地址为目标释放内 存,

;否则本函数仅执行对象的析构函数

0040103E push 4 00401040 push esi 00401041 call ??2@YAPAXIHPBDH@Z 00401046 add esp, 8 ;调用delete并平衡参数

00401049 mov eax, esi 0040104B pop esi 0040104C retn 4

以上代码存在虚表的写入操作,其写入顺序和前面分析的构造函 数相反,先写入子类自身的虚表,然后写入父类的虚表,因此满足了 析构函数的充分条件。我们将虚构函数命名为Destructor_401000,

IDA会提示符号名称过长,不必理会,单击“确定”按钮即可。 显而易见,这是一个析构函数的代理,它的任务是调用析构函 数 , 然 后 根 据 参 数 值 调 用 delete 。 将 这 个 函 数 重 命 名 为

_Destructor_401000,重命名后,虚表结构如下所示。

.rdata:00412190 Chinese_vtable dd offset Destructor_401000

.rdata:00412194 dd offset showSpeak .rdata:00412198 dd offset Chinese_getClassName

_Destructor_401000 函 数 是 虚 表 的 第 一 项 , 我 们 可 以 回 到

main()函数中观察其参数传递的过程。

00401129 mov ecx, esi 0040112B call dword ptr [eax+4] 0040112E mov eax, [esi] ;eax获得虚表

00401130 mov ecx, esi ;传递this指针

00401132 push 1 ;传递参数值1

00401134 call dword ptr [eax] ;调用Destructor_401000

在 main() 函 数 中 调 用 虚 表 第 一 项 时 传 递 的 值 为 1 , 那 么 在

Destructor_401000函数中,执行完析构函数就会调用delete释放对象的内存空间。为什么要用这样一个参数控制函数内释放空间的行为 呢?为什么不能直接释放呢? 这是因为析构函数和释放堆空间是两回事,有的程序员喜欢自己 维护析构函数,或者反复使用同一个堆对象,此时显式调用析构函数 的同时不能释放堆空间,如下代码所示。

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
#include <stdio.h>
#include <new.h>
class Person{ // 基类——“人”类

public:
Person() {}
virtual ~Person() {}
virtual void showSpeak() {} // 纯虚函数,后面会讲解

};
class Chinese : public Person { // 中国人:继承自人类

public:
Chinese() {}
virtual ~Chinese() {}
virtual void showSpeak() { // 覆盖基类虚函数

printf("Speak Chinese\r\n");
}
};
int main(int argc, char* argv[]) {
Person *p = new Chinese;
p->showSpeak();
p->~Person(); //显式调用析构函数

//将堆内存中p指向的地址作为Chinese的新对象的首地址,调用Chinese的构造函数

//这样可以重复使用同一个堆内存,以节约内存空间

p = new (p) Chinese();
delete p;
return 0;
}

因为显式调用析构函数时不能马上释放堆内存,所以在析构函数 的代理函数中通过一个参数控制是否释放内存,便于程序员管理析构 函数的调用。这个代理函数的反汇编代码很简单,请读者自己上机验证。需要注意的是,对于GCC编译器并不是采用此种设计,而是将析 构函数和析构代理函数全部放入虚表来解决问题,因此GCC虚表项会 比VS、Clang多一项。GCC的汇编代码如下所示。

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
92
93
94
95
96
97
98
99
00401510 push ebp
00401511 mov ebp, esp
00401513 push ebx
00401514 and esp, 0FFFFFFF0h
00401517 sub esp, 20h
0040151A call ___main
0040151F mov dword ptr [esp], 4
00401526 call __Znwj ;调用new函数申请空间

0040152B mov ebx, eax
0040152D mov ecx, ebx ;传递this指针

0040152F call __ZN7ChineseC1Ev ; 调 用 构 造 函 数 ,

Chinese::Chinese(void)
00401534 mov [esp+1Ch], ebx
00401538 mov eax, [esp+1Ch]
0040153C mov eax, [eax]
0040153E add eax, 8 ;虚析构占两项,第三项为showSpeak
00401541 mov eax, [eax]
00401543 mov edx, [esp+1Ch]
00401547 mov ecx, edx ;传递this指针

00401549 call eax ;调用虚函数showSpeak
0040154B mov eax, [esp+1Ch]
0040154F mov eax, [eax]
00401551 mov eax, [eax] ;虚表第一项为析构函数,不释放堆空间

00401553 mov edx, [esp+1Ch]
00401557 mov ecx, edx ;传递this指针

00401559 call eax ;显式调用虚析构函数

0040155B mov eax, [esp+1Ch]
0040155F mov [esp+4], eax ;参数2:this指针

00401563 mov dword ptr [esp], 4 ;参数1:大小为4字节

0040156A call __ZnwjPv ;调用new函数重用空间

0040156F mov ebx, eax
00401571 mov ecx, ebx ;传递this指针

00401573 call __ZN7ChineseC1Ev ; 调 用 构 造 函 数 ,

Chinese::Chinese(void)
00401578 mov [esp+1Ch], ebx
0040157C cmp dword ptr [esp+1Ch], 0
00401581 jz short loc_401596 ;堆申请成功释放堆空间

00401583 mov eax, [esp+1Ch]
00401587 mov eax, [eax]00401589 add eax, 4
0040158C mov eax, [eax] ;虚表第二项为析构代理函数,释放堆空间

0040158E mov edx, [esp+1Ch]
00401592 mov ecx, edx ;传递this指针

00401594 call eax ;隐式调用虚析构函数

00401596 mov eax, 0
0040159B mov ebx, [ebp-4]
0040159E leave
0040159F retn
;Chinese虚表:

00412F8C off_412F8C dd offset __ZN6PersonD1Ev ;Person::~Person()
{
0040D87C push ebp
0040D87D mov ebp, esp
0040D87F sub esp, 4
0040D882 mov [ebp-4], ecx
0040D885 mov edx, offset off_412F8C
0040D88A mov eax, [ebp-4]
0040D88D mov [eax], edx
0040D88F nop
0040D890 leave
0040D891 retn ;不释放堆空间

}
00412F90 dd offset __ZN6PersonD0Ev ;Person::~Person()
{
0040D854 push ebp
0040D855 mov ebp, esp
0040D857 sub esp, 28h
0040D85A mov [ebp+var_C], ecx
0040D85D mov eax, [ebp+var_C]
0040D860 mov ecx, eax
0040D862 call __ZN6PersonD1Ev ;调用析构函数

0040D867 mov dword ptr [esp+4], 4
0040D86F mov eax, [ebp+var_C]
0040D872 mov [esp], eax ;void *
0040D875 call __ZdlPvj ;调用delete释放堆空间

0040D87A leave
0040D87B retn
}
00412F94 dd offset __ZN6Person9showSpeakEv
;Person::showSpeak(void)

在通过分析反汇编代码识别类关系时,对于含有虚函数的类而 言,利用IDA的交叉参考功能可简化分析识别过程。根据以上分析可 知,具有虚函数,必然存在虚表指针。为了初始化虚表指针,必然要 准备构造函数,有了构造函数就可利用以上方法,顺藤摸瓜得到类关 系,还原对象模型。

多重继承

12.1节讲解了类与类之间的关系,但涉及的派生类都只有一个父 类。当子类拥有多个父类(如类C继承自类A同时也继承自类B)时, 便构成了多重继承关系。在多重继承的情况下,子类继承的父类变为 多个,但其结构与单一继承相似。 分析多重继承的第一步是了解派生类中各数据成员在内存的布局 情况。在12.1节中,子类继承自同一个父类,其内存中首先存放的是 父类的数据成员。当子类产生多重继承时,父类数据成员在内存中又 该如何存放呢?我们通过代码清单12-8看看多重继承类的定义。

多重继承类的定义(C++源码)

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
#include <stdio.h>
class Sofa {
public:
Sofa() {
color = 2;
}
virtual ~Sofa() { // 沙发类虚析构函数

printf("virtual ~Sofa()\n");
}
virtual int getColor() { // 获取沙发颜色

return color;
}
virtual int sitDown() { // 沙发可以坐下休息

return printf("Sit down and rest your legs\r\n");
}
protected:
int color; // 沙发类成员变量

};
//定义床类

class Bed {public:
Bed() {
length = 4;
width = 5;
}
virtual ~Bed() { //床类虚析构函数

printf("virtual ~Bed()\n");
}
virtual int getArea() { //获取床面积

return length * width;
}
virtual int sleep() { //床可以用来睡觉

return printf("go to sleep\r\n");
}
protected:
int length; //床类成员变量

int width;
};
//子类沙发床定义,派生自Sofa类和Bed类

class SofaBed : public Sofa, public Bed{
public:
SofaBed() {
height = 6;
}
virtual ~SofaBed(){ //沙发床类的虚析构函数

printf("virtual ~SofaBed()\n");
}
virtual int sitDown() { //沙发可以坐下休息

return printf("Sit down on the sofa bed\r\n");
}
virtual int sleep() { //床可以用来睡觉

return printf("go to sleep on the sofa bed\r\n");
}
virtual int getHeight() {
return height;
}protected:
int height;
};
int main(int argc, char* argv[]) {
SofaBed sofabed;
return 0;
}

代码清单12-8中定义了两个父类:沙发类和床类,通过多重继 承,以它们为父类派生出沙发类,它们拥有各自的属性及方法。

main()函数中定义了子类SofaBed的对象,其中包含两个父类的数据 成员,此时SofaBed在内存中占多少字节呢?如图12-7所示为对象

SofaBed占用内存空间的大小。

根据图12-7所示,对象SofaBed占用的内存空间大小为0x18字 节。这些数据的内容是什么?它们又是如何存放在内存中的?具体如 图12-8所示。

如图12-8所示,对象SofaBed的首地址在0x012FF9B8处,在图 中可看到子类和两个父类中的数据成员。数据成员的排列顺序由继承父类的顺序决定,从左向右依次排列。除此之外,还剩余两个地址 值,分别为0x002E8BB8与0x002E8BD0,这两个地址处的数据如 图12-9所示。

异常处理

C++标准中规定了异常处理的语法,各编译器厂商必须遵守这些 语法。因为C++标准中并没有规定异常处理的实现过程,所以经不同 厂商的编译器编译后产生的异常处理代码各不相同。GCC、Clang编 译器的异常处理代码取决于使用的异常库,

因此本章主要针对VS编译 器进行讲解。VS编译器的异常处理与Windows的SEH机制密切相关, 大家在学习本章之前,应熟练掌握SEH机制。

异常处理的相关知识

C++中的异常处理机制由try、throw、catch语句组成。

try语句块负责监视异常。

throw用于发送异常信息,也称为抛出异常。

catch用于捕获异常并做出相应处理。 异常处理的基本C++语法如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
try { // 检测异常

// 执行代码

throw 异常类型 ; // 抛出异常

}
catch (捕获异常类型){ // 捕获异常

// 处理代码

}
catch (捕获异常类型){ // 捕获异常

// 处理代码

}
……

异常的处理流程为检测异常→产生异常→抛出异常→捕获异常。对于用户而言,编译器隐藏了捕获异常的流程。在运行过程中,异常产生时会自动匹配到对应的处理代码中,而这个过程的代码较为复杂,由编译器产生。

异常处理通常是由编译器和操作系统共同完成的,所以不同操作系统环境下的编译器 对异常捕获和异常处理的分派过程是不同的。在用户数最多的Windows操作系统环境下,各个编译器也是基于操作系统的异常接口来分别实现C++中的异常处理的,因此即使在Windows环境下,不同 的编译器处理异常的实现方式也不同。

VS C++在处理异常时会在具有异常处理功能的函数入口处注册 一个异常回调函数,当该函数内有异常抛出时,会执行这个已注册的 异常回调函数。

所有的异常信息都会被记录在相关表格中,异常回调 函数根据这些表格中的信息进行异常的匹配处理工作。想要了解异常 的处理流程,就需要从这些记录相关信息的表格入手。

那么,如何找到这些记录异常信息的表格呢?我们可以从异常回 调函数入手,如图13-1所示。

从图13-1中可以看出,在调用函数__CxxFrameHandler前,向eax传入了一个全局地址,这是一个以寄存器方式传参的函数,eax便 是这个函数的参数。地址标号stru_426658就是要找的第一张表—— FuncInfo函数信息表。

FuncInfo表的大小为0x14字节,有5个数据成 员,记录了try块的信息以及每个try块中对应的catch块的信息等。有 了FuncInfo表便可以顺藤摸瓜找到记录catch块信息的表格。查看地 址标号stru_426658中的数据,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
13-2中的数据就是FuncInfo函数信息表的相关数据,其结构如
下所示。

FuncInfo struc ; (sizeof=0x14)
magicNumber dd ? ; 编译器生成标记固定数字0x19930520maxState dd ? ; 最大栈展开数的下标值

pUnwindMap dd ? ; 指向栈展开函数表的指针,指向UnwindMapEntry表结构

dwTryCount dd ? ; try块数量

pTryBlockMap dd ? ; try块列表,指向TryBlockMapEntry表结构

FuncInfo ends

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FuncInfo 表 结 构 中 提 供 了 两 个 表 格 信 息 , 分 别 为

UnwindMapEntry和TryBlockMapEntry。UnwindMapEntry表结
构配合maxState项使用,maxState中记录了异常需要展开的次数,
展开时需要执行的函数由UnwindMapEntry表结构记录,其结构信息
如下。

UnwindMapEntry struc ; (sizeof=0x08)
toState dd ? ; 栈展开数下标值

lpFunAction dd ? ; 展开执行函数

UnwindMapEntry ends

因为展开过程中可能存在多个对象,所以以数组形式记录每个对 象 的 析 构 信 息 。 toState 项 用 于 判 断 结 构 是 否 处 于 数 组 中 ,

lpFunAction项则用于记录析构函数所在的地址。 结合图13-2找到用来记录try块信息表TryBlockMap-Entry的地 址标号stru_426688,查看此地址标号中的数据,如图13-3所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
表TryBlockMapEntry中有5个数据成员,结构如下。

TryBlockMapEntry struc ;(sizeof=0x14)
tryLow dd ? ;try块的最小状态索引,用于范围检查

tryHigh dd ? ;try块的最大状态索引,用于范围检查

catchHigh dd ? ;catch块的最高状态索引,用于范围检查

dwCatchCount dd ? ;catch块个数

pCatchHandlerArray dd ? ;catch块描述,指向_msRttiDscr表结构

TryBlockMapEntry ends

TryBlockMapEntry表结构用于判断异常产生在哪个try块中。

tryLow项与tryHigh项用于检查产生的异常是否来自try块,而

catchHigh块则是用于匹配catch块的检查项。每个catch块都对应一 个_msRttiDscr表结构,由表结构中的pCatchHandlerArray项记 录。结合图13-2,找到_msRttiDscr表的相关信息,如图13-4所示

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
13-4中的数据对应的便是_msRttiDscr表结构,该结构用于描
述try块中的某一个catch块的信息,由4个数据成员组成,如下所示。

_msRttiDscr struc ; (sizeof=0x10)
nFlag dd ? ; 用于catch块的匹配检查

pType dd ? ; catch块要捕捉的类型,指向TypeDescriptor表结构

dispCatchObjOffset dd ? ; 用于定位异常对象在当前EBP中的偏移位置

CatchProc dd ? ; catch块的首地址

_msRttiDscr ends

nFlag标记用于检查catch块类型的匹配,标记值所代表的含义如
下。
标记值1:常量。
标记值2:变量。
标记值4:未知。
标记值8:引用。

_msRttiDscr表结构中的pType项与CatchProc项最为关键。在
抛 出 异 常 对 象 时 , 需 要 复 制 抛 出 的 异 常 对 象 信 息 ,

dispCatchObjOffset项用于定位异常对象在当前EBP中的偏移位置。

CatchProc项中保存了异常处理catch块的首地址,这样在匹配异常后
便可正确执行catch语句块。异常的匹配信息记录在pType指向的结构
中。Type指向的结构描述如下所示。
TypeDescriptor struc
hash dd ? ; 类型名称的Hash数值

spare dd ? ; 保留,可能用于RTTI名称记录

name db ? ; 类型名称

TypeDescriptor ends

TypeDescriptor为异常类型结构,其中name项用于记录抛出异
常的类型名称,是一个字符型数组,图13-4中的地址标号??_R0H@8

保存了TypeDescriptor表结构的首地址,跟踪到此地址处,如图13-5

所示。

图13-5中的数据显示,name项为’.H’,表示异常捕获为int类 型。当抛出异常类型为对象时,由成员name保存包含类型名称的字符 串,代码如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CmyException {
public:
char szShow[32];
};
void main(){
try{
CMyException MyException;
strcpy(MyException.szShow, "err...");
throw &MyException;
}
catch (CMyException* e){
printf("%s \r\n",e->szShow);}
}

按照以上结构的对应关系,找到TypeDescriptor表结构的信息, 如图13-6所示。 根据图13-6显示的信息,此时spare项保存了类的名称。有了这 些信息后,就可以与抛出异常时的信息进行对比,得到对应的表结 构,通过_msRttiDscr表结构中的CatchProc项得到catch块的首地 址。根据图13-4中显示的信息,可知处理int类型异常的catch语句块 的首地址在地址标号sub_40107D处,跟踪到此地址处,相关信息如 图13-7所示

到此,在处理异常过程中接触到的表结构已经被找到,接下来还 需要找到抛出异常时产生的表格信息。抛出异常的工作由throw语句完 成,找到调用throw时的代码信息,如图13-8所示。

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
观察图13-8,在调用抛出异常函数时传递了一个全局参数

__TI1H。这个标号便是抛出异常时需要的表结构信息ThrowInfo,其结构说明如下。

ThrowInfo struc ; (sizeof=0x10)
nFlag dd ? ; 抛出异常类型标记

pDestructor dd ? ; 异常对象的析构函数地址

pForwardCompat dd ? ; 未知

pCatchTableTypeArray dd ? ; catch块类型表,指向CatchTableTypeArray

表结构

ThrowInfo ends

ThrowInfo表结构中携带了类型信息,用于匹配抛出的异常类
型。当nFlag为1时,表示抛出常量类型的异常;当nFlag为2时,表示
抛出变量类型的异常。因为在try块中产生的异常被处理后不会再返回

try块中,所以pDestructor的作用就是记录try块中异常对象的析构函
数地址,当异常处理完成后调用异常对象的析构函数。
抛 出 的 异 常 对 应 的 catch 块 类 型 信 息 被 记 录 在

pCatchTableTypeArray 指 向 的 结 构 中 。 借 助 图 13-8 显 示 的

ThrowInfo表结构地址和pCatchTableTypeArray项保存的地址,可
以找到表结构CatchTableTypeArray,如图13-9所示。

图13-9显示了CatchTableTypeArray表结构中的数据,结构说明 如下。

CatchTableTypeArray struc ; (sizeof=0x8) dwCount dd ? ; CatchTableType数组包含的元素个数

ppCatchTableType dd ? ; catch 块的类型信息,类型为CatchTableType** CatchTableTypeArray ends

ppCatchTableType指向一个指针数组,dwCount用于描述数组 中的元素个数。图13-9只显示了一个元素,该元素数据为__CT?? _R0H@84 , 这 个 地 址 标 号 指 向 了 CatchTableType 表 结 构 。

CatchTableType中含有处理异常时所需的相关信息,如图13-10所 示。

图13-10中的第二项数据是不是很眼熟呢?回看图13-5,地址标 号??_ROH@8指向一个TypeDescriptor表结构,于是在处理异常时 可 以 根 据 这 一 项 进 行 对 比 , 找 到 正 确 的 catch 块 并 处 理 。

CatchTableType表结构还包含了其他信息,如下所示。

CatchTableType struc ; (sizeof=0x1C) flag dd ? ; 异常对象类型标志

pTypeInfo dd ? ; 指向异常类型结构,TypeDescriptor表结构

thisDisplacement PMD ? ; 基类信息

sizeOrOffset dd ? ; 类的大小

pCopyFunction dd ? ; 复制构造函数的指针

CatchTableType ends

flag标记用于判断异常对象属于哪种类型,如指针、引用、对象 等。标记值所代表的含义如下。 标记值0x1:简单类型复制。 标记值0x2:已被捕获。 标记值0x4:有虚表基类复制。 标记值0x8:指针和引用类型复制。 当异常类型为对象时,因为对象存在基类等相关信息,所以需要 将它们也记录下来,thisDisplacement保存了记录基类信息结构的首 地址。

PMD struc ; (sizeof=0xC) dwOffsetToThis dd ? ; 基类偏移

dwOffsetToVBase dd ? ; 虚基类偏移

dwOffsetToVbTable dd ? ; 基类虚表偏移

PMD ends

图13-11是异常回调与异常抛出的结构关系图。

异常类型为基本数据类型的处理流程

到此,异常处理过程中需要的结构信息全部介绍完毕。有了对异 常处理的初步认识,接下来我们结合实践深入了解异常处理的流程, 先来看代码清单13-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
37
38
39
40
41
42
43
44
45
46
47
// C++ 源码

#include <stdio.h>
int main(int argc, char* argv[]) {
try {
throw 1; // 抛出异常

}
catch ( int e ) {
printf(" 触发int异常\r\n");
}
catch ( float e) {
printf(" 触发float异常\r\n");
}
return 0;
}
// C++ 源码与对应汇编代码讲解

int mainint main(int argc, char* argv[]){
00401010 push ebp
00401011 mov ebp,esp
00401013 push 0FFh
; 异常回调函数,ehhandler$_main函数分析见代码清单13-2
00401015 push offset ehhandler$_main (00413450)
0040101A mov eax,fs:[00000000]
00401020 push eax
00401021 mov dword ptr fs:[0],esp ; 注册异常回调处理函数

; Debug环境初始化部分略

try{
00401041
throw 1; mov dword ptr [ebp-4],0 // 抛出异常

00401048 mov dword ptr [ebp-14h],1 ; 设置异常编号
0040104F push offset TI1H (00426630) ; 压入异常结构

00401054 lea eax,[ ebp-1Ch]
00401057 push eax ; 压入异常编号

; CxxThrowException@8函数中调用API函数Raise Exception
00401058 call CxxThrowException@8 (004017a0) ; 调用异常分配函


}
;异常捕获处理部分略


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1204342476@qq.com

💰

×

Help us with donation