C++反汇编与逆向分析技术(四)

记录一下《C++反汇编与逆向分析技术》(四)

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

  • 变量的作用域

    指的是变量在源码中可以被访问到的范围。全局变量属于进程作用域,也就是说,在整个进程中都能够访问到这个全局变量;静态变量属于文件作用域,在当前源码文件内可以访问到:局部变量属于函数作用域,在函数内可以访问到;在“{}”语句块内定义的变量,属于块作用域,只能在定义变量的“{ }”块内访问到。

  • 变量的生命周期

    指的是变量所在的内存从分配到释放的这段时间。变量所在的内存被分配后,我们可以形象地将这比喻为变量的生命开始;变量所在的内存被释放后,我们可以将这比喻为变量的消亡。

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

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

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
int g_nVariableType = 117713190;

void main() {
008F19D0 55 push ebp
008F19D1 8B EC mov ebp,esp
008F19D3 81 EC C0 00 00 00 sub esp,0C0h
008F19D9 53 push ebx
008F19DA 56 push esi
008F19DB 57 push edi
008F19DC 8D BD 40 FF FF FF lea edi,[ebp-0C0h]
008F19E2 B9 30 00 00 00 mov ecx,30h
008F19E7 B8 CC CC CC CC mov eax,0CCCCCCCCh
008F19EC F3 AB rep stos dword ptr es:[edi]
008F19EE B9 26 C0 8F 00 mov ecx,offset _DC5A83E8_main@cpp (08FC026h)
008F19F3 E8 33 F8 FF FF call @__CheckForDebuggerJustMyCode@4 (08F122Bh)
scanf_s("%d", &g_nVariableType);
008F19F8 68 00 A0 8F 00 push offset g_nVariableType (08FA000h)
008F19FD 68 30 7B 8F 00 push offset string "%d" (08F7B30h)
008F1A02 E8 4D F7 FF FF call _scanf_s (08F1154h)
008F1A07 83 C4 08 add esp,8
printf("%d \r\n", g_nVariableType);
008F1A0A A1 00 A0 8F 00 mov eax,dword ptr [g_nVariableType (08FA000h)]
008F1A0F 50 push eax
008F1A10 68 34 7B 8F 00 push offset string "%d \r\n" (08F7B34h)
008F1A15 E8 2C F6 FF FF call _printf (08F1046h)
008F1A1A 83 C4 08 add esp,8
}
008F1A1D 33 C0 xor eax,eax
008F1A1F 5F pop edi
008F1A20 5E pop esi
008F1A21 5B pop ebx
008F1A22 81 C4 C0 00 00 00 add esp,0C0h
008F1A28 3B EC cmp ebp,esp
008F1A2A E8 06 F8 FF FF call __RTC_CheckEsp (08F1235h)
008F1A2F 8B E5 mov esp,ebp
008F1A31 5D pop ebp
008F1A32 C3 ret

通过对代码的分析可知,访问全局变量与访问常量类似——都是通过立即数来访问。由于全局变量在编译期就已经确定了具体的地址,因此编译器在编译的过程中可以计算出一个固定的地址值。而局部变量需要进入作用域内,通过申请栈空间存放,利用栈指针ebp或esp间接访问,其地址是-一个未知可变值,编译器无法预先计算。

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

全局变量的特征:

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

局部变量的特征:

  • 所在地址为栈区,生命周期与所在的函数作用域一致
  • 使用ebp或esp间接访问

局部静态变量的工作方式

静态变量分为全局静态变量和局部静态变量。

全局静态变量等价于编译器限制外部源码文件访问的全局变量。

局部静态变量比较特殊,它不会随作用域的结束而消失,并且在未进人作用域之前就已经存在,其生命周期也和全局变量相同。局部静态变量会预先被作为全局变量处理,而它的初始化部分只是在做赋值操作。

来个demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//C++源码说明:全局变量的访问
void ShowStatic(int nNumber){
static int g_snNumber = nNumber; //定义局部静态变量,赋值为参数
printf("%d \r\n",g_snNumber); //显示静态变量
}
void main(){
for(int i = 0;i < 5;i++){
ShowStatic(i); //循环调用显示局部静态变量的函数,每次传入不同值
}
}

// ShowStatic 函数内实现过程
void ShowStatic (int nNumber) {
;在Debug版下保存环境、开辟栈、初始化部分略
static int g_snNumber = nNumber; //定义局部静态变量
0040D9D8 xor eax,eax ;清空eax
;取地址0x004257CC处1字节数据到al中
0040D9DA mov al, [' ShowStatic' : :'2': :$S1 (004257cc) ]
;将eax与数值1做位与运算,eax最终结果只能是0或1
0040D9DF and eax,1
0040D9E2 test eax,eax
;比较eax, 不等于0则执行跳转,跳转到地址0x0040D9FE处
0040D9E4 jne ShowStatic+3Eh (0040d9fe)
;将之前比较是否为0值的地址取出数据到c1中
0040D9E6 mov c1,byte ptr [' ShowStatic' ::'2': :$S1 (004257cc)]
;将c1与数值1做位或运算,cl的最低位将被置1,其他位不变
0040D9EC or cl,1
;再将置位后的cl存回地址0x004257CC处
0040D9EF mov byte ptr ['ShowStatic' ::'2'::SS1 (004257cc)],cl
;取出参数信息放入edx中
0040D9F5 mov edx, dword ptr [ebp+8]
;将edx赋值到地址0x004257C8处,即将局部静态变量赋值为edx中保存的数据
0040D9F8 mov dword ptr [___sbh_sizeHeaderList+4 (004257c8)] , edx
printf("%d \r\n", g_snNumber); // 显示局部静态变量中的数据
;局部静态变量的访问,和全局变量的访问方式一样
0040D9FE mov eax,[__ sbh_sizeHeaderList+4 (004257c8)]
;printf略

地址0x004257CC中保存了局部静态变量的一个标志,这个标志占位1个字节。通过位运算,将标志中的一位数据置1,以此判断局部静态变量是否已经被初始化过。由于一个静态变量只使用了1位,而1个字节数据占8位,因此这个标志可以同时表示8个局部静态变量的初始状态。通常,在VC++6.0中,标志所在的内存地址在最先定义的局部静态变量地址的附近,如最先定义的整型局部静态变量在地址0x004257C0处,那么标记位通常在地址0x004257C4或0x004257BC处。当同一作用域内超过8个局部静态变量时,下一个标记位将会在第9个定义的局部静态变量地址附近。识别局部静态变量的标志位地址并不是目的,主要是根据这个标志位来区分全局变量与局部静态变量。

但是当局部静态变量被初始化为一个常量值时,这个局部静态变量在初始化过程中不会产生任何代码

由于初始化的数值为常量,即多次初始化不会产生变化。这样无需再做初始化标志,编译器采用了直接以全局变量方式处理,优化了代码,提升了效率。虽然转换为了全局变量,但仍然不可以超出作用域访问。那么编译器是如何让其他作用域对局部静态变量不可见的呢?通过名称粉碎法,在编译期将静态变量重新命名。

来个总结:

1
2
3
4
5
6
7
8
9
10
11
12
; reg_flag表示存放初始化标志的寄存器r8,通常使用寄存器中的低位,如al等
;INIT__FLAG表示初始化标记
mov reg_flag, INIT_FLAG
; reg_data表示存放静态变量初值的寄存器
mov reg_data, mem ; reg_data值为初值,其来源可能因程序不同而不同
test reg_data, 1\2\8..0x80 ;测试标志位
jxx INIT_END ;跳转成功,表示已经被初始化过
or reg_flag, 1\2\8..0x80 ;修改标志寄存器中的数据
; STATIC_DATA表示静态变量
mov STATIC_DATA,reg_data ;初始化静态变量
mov INIT_FLAG,reg_flag ;修改该静态变量初始化标志位
INIT_END;

堆变量

new和malloc的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// C++ 源码说明(Debug 编译选项) ; new与malloc
// mal1oc 内部实现
char * pCharMalloc = (char*) malloc (10) ;
_CRTIMP void * __cdecl malloc (
size_t nSize
){
//使用_nh_malloc_dbg申请堆空间
return _nh_malloc_dbg (nSize,_newmode,_NORMAL_BLOCK, NULL, 0) ;
}

//new内部实现
char * pCharNew = new char [10] ;
void * operator new( unsigned int cb ) {
return _nh_malloc( cb, 1 ) ;
}
void * __cdecl _nh_malloc (
size_t nSize,
int nhFlag
){
//使用_nh_malloc_dbg申请堆空间
return _nh_malloc_dbg (nSize,_newmode,_NORMAL_BLOCK, NULL, 0) ;
}

堆空间的分配类似于商场中的商铺管理,malloc是从商场的空地中划分出一块作为商铺,而new则可以将划分好的商铺直接租用。由于malloc缺少商铺的营业范围规定,因此需要将申请好的堆强制转换以说明其类型方可使用,而new则无需这种操作,直接可以使用。

申请堆空间的过程中调用了函数 _ heap_ alloc_dbg, 其中使用 _CrtMemBlockHeader结构描述了堆空间中的各个成员。在内存中,堆结构的每个节点都是使用双向链表的形式存储的,在 _CrtMemBlockHeader 结构中定义了前指针pBlockHeaderPrev 和后指针pBlockHeaderNext,通过这两个指针就可遍历程序中申请的所有堆空间。成员lRequest记录了当前堆是第几次申请的,例如第10次申请堆操作对应的数值为0x0A; 成员gap为保存堆数据的数组,在Debug版下,这个数据的前后4个字节被初始化为0xFD,用于检测堆数据访问过程中是否有越界访问。 _CrtMemBlockHeader 结构的原型如下:

1
2
3
4
5
6
7
8
9
10
typedef struct _CrtMemBlockHeader{
struct _CrtMemBlockHeader *pBlockHeaderNext ; //下一块堆空间首地址(实际上指向的是前一次申请的堆信息)
struct _CrtMemBlockHeader *pBlockHeaderPrev; //上一块堆空间首地址(实际上指向的是后一次申请的堆信息)
char * szFileName;
int nLine;
size_t nDataSize; //堆空间数据大小
int nBlockUse;
long lRequest; //堆申请次数
unsigned char gap[nNoMansLandSize]; //堆空间数据
} _CrtMenBlockHeader;

来个实操,我太懒了直接拿书上的图吧,嘻嘻嘻

堆空间数据说明

在图中,内存监视窗口的数据为使用malloc后申请的堆空间数据。new或malloc函数返回的地址为堆数据地址0x00431BF0,堆数据地址减4后,其数据为0xFDFDFDFD,这是往上越界的检查标志。堆数据地址减8后数据为0x2A,表示此堆空间为第0x2A次申请堆操作,说明在其之前多次申请过堆空间。堆数据空间的容量存储在地址0x00431BE0处,该堆空间占10个字节大小。地址0x00431BD0处为上一个堆空间首地址。地址0x00431BD4处的数据为0,表示没有下个一堆空间。在堆数据的末尾也加入了0xFDFDFDFD,这是往下越界的检查标志,这是程序编译方式为Debug版的重要特征之一。

数组和指针的寻址

数组在函数内

对于数组的识别,判断数据在内存中是否连续并且类型是否一致,均符合即可将此段数据视为数组。

在C++中,字符串本身就是数组,根据约定,该数组的最后-一个数据统一使用0作为字符串结束符。在VC++ 6.0编译器下,为字符类型的数组赋值(初始化)其实是复制字符串的过程。这里并不是单字节复制,而是每次复制4字节的数据。两个内存间的数据传递需要借用寄存器,而每个寄存器一次性可以保存4字节的数据,如果以单字节的方式复制就会浪费掉3字节的空间,而且多次数据传递也会降低执行效率。如果字符串字节数不满足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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//C++源码说明:数组作为参数
void Show(char szBuff[]){
strcpy(szBuff,"Hello,World");
printf("%S",szBuff);
}
void main(){
char szHello[20] = {0};
Show(szHello);
}

// C++ 源码与对应汇编代码讲解
void main() {
; Debug保存环境初始化栈略
char szHe11o[20] = {0};
; ebp-14h为数组szHello首地址,数组初始化为0
0040B7C8 mov byte ptr [ebp-14h] ,0
0040B7CC xor eax,eax
0040B7CE mov dword ptr [ebp-13h] ,eax
0040B7D1 mov dword ptr [ebp-0Fh] ,eax
0040B7D4 mov dword ptr [ebp-0Bh] ,eax
0040B7D7 mov dword ptr [ebp-7h] ,eax
0040B7DA mov word ptr [ebp-3h] ,ax
0040B7DE mov byte ptr [ebp-1h] ,al
Show(szHello);
0040B7E1 lea ecx,[ebp-14h] ;取数组首地址存入ecx
0040B7E4 push ecx
0040B7E5 call @ILT+5 (Show) (0040100a) ;调用Show函数
0040B7EA add esp,4
; 略
}
//Show函数实现部分
void Show (char szBuff []) {
strcpy (szBuff, "Hello world") ;
;获取常量首地址,并将此地址压入栈中作为strcpy参数
0040B488 push offset string "Hello world" (0041f01c)
;取函数参数 szBuff地址存入eax中
0040B48D mov eax , dword ptr [ebp+8]
;将eax压栈作为strcpy参数
0040B490 push eax
0040B491 call strcpy (00404570)
0040B496 add esp,8
printf(szBuff);
}

当数组作为参数时,数组的下标值被省略了。这是因为,当数组作为函数形参时,函数参数中保存的是数组的首地址,是一个指针变量。

在release版下,字符串处理函数会被作为内联函数编译处理,康康:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// C++源码对照
int GetLen (char szBuff[] ) {
return strlen(szBuff);
}
//使用O2选项后的优化代码
sub_401000 proc near ;函数起始处
arg_0 = dword ptr 4 ;参数标号
push edi
mov edi, [esp+4+arg_0] ;获取参数内容,向edi中赋值字符串首地址
or ecx,0FFFFFFFFh ;将ecx置为-1,是为了配合repne scasb指令
xor eax,eax
;repne/repnz与scas指令结合使用,表示串未结束(ecx!=0)
;当eax与串元素不相同(ZF=0)时,继续重复执行串搜索指令
;可用来在字符串中查找和eax值相同的数据位置
repne scasb ;执行该指令后,ecx中保存了字符串长度的补码
not ecx ;先对ecx取反
dec ecx ;对取反后的ecx减1,得到字符串长度
pop edi
mov eax,ecx ;设置eax为字符串长度,用于函数返回
retn
sub_401000 endp ;函数终止处

​ 这个实现过程为先将eax清零,然后通过指令repne scasb遍历字符串,寻找和eax匹配的字符。由于指令repne scasb中的前缀repne是用来考察ecx的值,因此在ecx不为0且ZF标志为0时才重复操作,在操作过程中对ecx自动减1。

​ ecx的初始值为0xffffffff,有符号数值为-1,repne前缀每次执行时会自动减1,如果edi指向的内容为字符串结束符(asc 值0),则重复操作结束。注意,重复操作完成时ecx的计数包含了字符串末尾的0。假设字符串长度为Len,我们可得到等式:

1
2
3
4
5
6
7
8
9
ecx(终值) = ecx(初值) - (Len+1)
将ecx初值 -1 代入得:
ecx(终值) = -1 - (Len+1) = -(Len+2)
定义neg为求补运算,则有:
neg(ecx(终值)) = Len + 2
求补运算等价于取反加1,定义not为取反运算,则有:
neg(ecx(终值))+1 = Len + 2
解方程求Len:
Len = not(ecx(终值)) - 1

那strcpy函数原型也比较好分析了,release版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
;main函数讲解略
;Show函数实现
;int __cdecl sub_401000 (char *Format) ;函数类型识别
sub_401000 proc near

Format = dword ptr 4 ;函数参数识别
push esi
push edi
; ===============================================================
;这段代码似曾相识,就是之前所分析的优化后的求字符串长度函数strlen的内联方式
mov edi,offset aHelloWorld ;"Hello World"
or ecx,0FFFFFFFFh
xor eax,eax
repne scasb
mov eax,[esp+8+Format] ;取参数所在地址存入eax中
not ecx ;对ecx取反,得到字符串长度加1
; ===============================================================
;执行指令repne scasb后,edi指向字符串末尾,减去ecx重新指向字符串首地址
sub edi,ecx
push eax ;将保存参数地址eax压栈
mov edx,ecx ;使用edx保存常董字符串长度
mov esi,edi ;将esi设置为常量字符串首地址
mov edi,eax ;将edi设置为参数地址
shr ecx,2 ;将ecx右移2位等同于将字符串长度除以4
;此指令为拷贝字符串,每次复制4字节长度,根据ecx中的数值决定复制次数。将esi中的指向数据每次以4字节复制到edi所指向的内存中,每次复制后,esi与edi自加4
rep movsd
mov ecx,edx ;重新将字符串长度存入ecx中
;将ecx与3做位与运算,等同于ecx对4求余
and ecx,3
;和rep movsd指令功能奥似,不过是按单字节复制字符串
rep movsb
call _printf
add esp,4
pop edi
pop esi
retn
sub_401000 endp

数组作为返回值

​ 数组作为函数得返回值与作为函数的参数差不多,都是将数组的首地址以指针的方式进行传递。不同点是当数组作为参数时,其定义所在的作用域必然在函数调用以外,在调用前已经存在,所以,在函数中对数组进行操作是没有问题的,而数组作为函数返回值则存在着一定的风险。

当数组为局部变量数据时,便产生了稳定性问题。当退出函数时,需要平衡栈,而数组是作为局部变量存在,其内存空间在当前函数的栈内。如果此时函数退出,栈中定义的数据将变得不稳定。由于函数退出后esp会回归到调用前的位置上,而函数内的局部数组在esp之下,随时都有可能由在其他函数的调用过程中产生的栈操作指令将其数据破坏。数据的破坏将导致函数返回结果具备不确定性,影响程序的结果,如图所示。

栈平衡错误

在图中,返回了函数GetNumber中定义的局部数组的首地址nArray,其所在地址处于0x0012FF00~0x0012FF1C之间。当函数调用结束后,栈顶指向了地址0x0012FF1C。此时数组nArray中的数据已经不稳定,任何栈操作都有可能将其破坏。

在执行“print(“%d”, pAray[7]);”后,由于需要将参数压栈,地址0x0012FF1C~0x0012FF18之间的数据已经被破坏,无法输出正常结果。

如果既想使用数组作为返回值,又要避免图中的错误,可以使用全局数组、静态数组或是上层调用函数中定义的局部数组。

well,全局数组就是很常见的那种,懒得放了,看一下静态数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void main(){
int nOne;
int nTwo;
scanf("%d%d",&nOne,&nTwo);
static int g_snArry[5] = {nOne,nTwo,0};
}
// C++ 源码与对应汇编代码讲解
void main() {
int nOne;
int nTwo;
scanf("%d%d",&nOne,&nTwo);
static in g_snArry[5] = {nOne,nTwo,0};
0040B84D xor edx,edx
0040B84F mov dl,byte ptr ['main' ::'2' :: $Sl (004237c8)]
0040B855 and edx,1
0040B858 test edx,edx
0040B85A jne main+70h (0040b890) ;检测初始化标志位
0040B85C mov al, ['main' :: '2' :: $S1 (004237c8) ]
0040B861 or al,1
;将初始化标志位置1
0040B863 mov ['main' : : '2': :$S1 (004237c8)] ,al
0040B868 mov ecx, dword ptr [ebp-4]
0040B86B mov dword ptr ['main' :: '2'::$S1+4 (004237cc)] , ecx
0040B871 mov edx, dword ptr [ebp-8]
0040B874 mov dword ptr ['main' :: '2' ::$S1+8 (004237d0)] , edx
0040B87A mov dword ptr ['main' :: '2' ::$S1+0Ch (004237d4)] , 0
0040B884 xor eax,eax
0040B886 mov ['main' :: '2' : : $S1+10h (004237d8)] ,eax
0040B88B mov ['main' :: '2' : : $S1+14h (004237dc)] , eax
}

下标寻址和指针寻址

下标访问(寻址): a[n] 效率高

指针访问(寻址): *(a+n)

看一下区别,Debug版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//C++源码说明:两种寻址方式演示
void main(){
char *pChar = NULL;
char szBuff[] = "Hello";
pChar = szBuff;
printf("%c",*pChar);
printf("%c",szBuff[0]);
}
// C++源码与对应汇编代码讲解
void main(){
char *pChar = NULL;
004010F8 mov dword ptr [ebp-4] ,0 ;初始化指针变量为空指针
char szBuff[] = "Hello";
004010FF mov eax, [string "Hel1o" (00420030)] ;初始化数组
00401104 mov dword ptr [ebp-0Ch] , eax
00401107 mov cx,word ptr [string "He11o"+4 (00420034)]
0040110E mov word ptr [ebp-8] ,Cx

pChar = szBuff;
00401112 lea edx, [ebp-0Ch] ;荻取数组首地址,然后使用edx保存
00401115 mov dword ptr [ebp-4] , edx

printf("%c",*pChar);
00401118 mov eax, dword ptr [ebp-4] ;取出指针变量中保存的地址数据
0040111B movsx ecx,byte ptr [eax] ;字符型指针的间接访问
0040111E push ecx
0040111F push offset string "%C" (0042002c)
00401124 call printf(00401170)
00401129 add esp,8

printf("%c",szBuff[0]);
;直接从地址ebp-0Ch处取出1字节的数据
0040112C movsx edx,byte ptr [ebp-0Ch]
00401130 push edx ;将取出数据作为参数
00401131 push offset string "%c" (0042002c)
00401136 call printf(00401170)
0040113B add esp,8
}

指针寻址比下标寻址多一次寻址操作,效率自然低,但是指针寻址可以修改地址中保存的数据,访问其他内存中的数据,而数组下标在没有越界使用的情况下只能访问数组内的数据。

下标值的三种表现方式

  1. 下标值为整型常量的寻址

    编译器可以直接计算出数据所在的地址

  2. 下标值为整型变量的寻址

    编译器先进行地址偏移计算,然后得出目标数据所在的地址

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

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

顺带一提,数组越界就是下标越范围,蛮好懂的

多维数组

多维数组在内存中的存储方式和一维数组是一样的,但在寻址方面,二维数组的寻址过程比一维数组多一步操作,先取得二维数组中某个一维数组的首地址,再利用此地址作为基址寻址到一维数组中某个数据地址处。

若其中某一下标值为常量,则不会出现二次寻址计算,二维数组寻址转换成汇编后的代码和一维数组差不多,因为下标值为常量,且类型大小可预先计算出,因此变成两常量计算。利用常量折叠可直接计算出偏移地址。

来个release版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void main() {
004B1090 55 push ebp
004B1091 8B EC mov ebp,esp
004B1093 83 EC 2C sub esp,2Ch
004B1096 A1 04 30 4B 00 mov eax,dword ptr ds:[004B3004h]
004B109B 33 C5 xor eax,ebp
004B109D 89 45 FC mov dword ptr [ebp-4],eax
int i = 0;
int j = 0;
int nArray[4] = { 1,2,3,4 };
004B10A0 0F 28 05 40 21 4B 00 movaps xmm0,xmmword ptr ds:[004B2140h]
int nTwoArray[2][2] = { {1,2},{3,4} };
scanf_s("%d %d", &i, &j);
004B10A7 8D 45 F4 lea eax,[ebp-0Ch]
int nTwoArray[2][2] = { {1,2},{3,4} };
scanf_s("%d %d", &i, &j);
004B10AA 50 push eax
004B10AB 8D 45 F8 lea eax,[ebp-8]
004B10AE C7 45 F8 00 00 00 00 mov dword ptr [ebp-8],0
004B10B5 50 push eax
004B10B6 68 08 21 4B 00 push 4B2108h
004B10BB C7 45 F4 00 00 00 00 mov dword ptr [ebp-0Ch],0
004B10C2 0F 11 45 E4 movups xmmword ptr [ebp-1Ch],xmm0
004B10C6 0F 11 45 D4 movups xmmword ptr [ebp-2Ch],xmm0
004B10CA E8 81 FF FF FF call 004B1050
printf("nArray = %d\r\n", nArray[i]);
004B10CF 8B 45 F8 mov eax,dword ptr [ebp-8]
004B10D2 FF 74 85 E4 push dword ptr [ebp+eax*4-1Ch]
004B10D6 68 10 21 4B 00 push 4B2110h
004B10DB E8 40 FF FF FF call 004B1020
printf("nTwoArray = %d\r\n", nTwoArray[i][j]);
004B10E0 8B 4D F8 mov ecx,dword ptr [ebp-8]
004B10E3 8B 45 F4 mov eax,dword ptr [ebp-0Ch]
004B10E6 8D 04 48 lea eax,[eax+ecx*2]
004B10E9 FF 74 85 D4 push dword ptr [ebp+eax*4-2Ch]
004B10ED 68 20 21 4B 00 push 4B2120h
004B10F2 E8 29 FF FF FF call 004B1020
}

这个”eax+ecx*2”保存了二维数组的两个下标值:i、j。寻址过程如下:

1)使用数组首地址加二维数组下标i乘以-维数组大小,得到一维数组首地址。
2)通过1)获取一维数组首地址后,加下标j乘以类型大小,得到的数据如下:
二维数组type nArry[M][N];使用i、j作为下标寻址
nArray + i * sizeof(type [N]) + j * sizeof(type)
= nArray+i* N * sizeof(type) +j * sizeof(type)
= nArray + sizeof(type)*(i *N +j)

三维数组类似,不列举了

存放指针类型数据的数组

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

组成部分1 组成部分2 组成部分3
类型名* 数组名称 [元素个数]

举个栗子,存储三个字符串”Hello”,”World”,”!\r\n”,指针数组是一维的,存储的是字符串的首地址,字符数组是二维的,将每个字符存储进去

这样寻址的时候,会发生差异:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
	printf(pBuff[1]);
01001958 B8 04 00 00 00 mov eax,4
0100195D C1 E0 00 shl eax,0
01001960 8B 4C 05 EC mov ecx,dword ptr [ebp+eax-14h] ;一维数组寻址
01001964 51 push ecx
01001965 E8 DC F6 FF FF call 01001046
0100196A 83 C4 04 add esp,4
printf(cArray[1]);
0100196D B8 0A 00 00 00 mov eax,0Ah
01001972 C1 E0 00 shl eax,0
01001975 8D 4C 05 C4 lea ecx,[ebp+eax-3Ch]
01001979 51 push ecx
0100197A E8 C7 F6 FF FF call 01001046
0100197F 83 C4 04 add esp,4
}

字符数组寻址后得到的是某一维数组的首地址

指向数组的指针变量

当指针变量保存的数据为数组的首地址,且将此地址解释为数组时,此指针变量被称为数组指针。

指向一维数组的数组指针定义格式:

组成部分1 组成部分2 组成部分3
[类型名] [*指针变量名称] [一维数组大小]

对指向二维数组的数组指针执行取内容操作后,得到的还是一个地址值,再次执行取内容操作才能寻址到二维字符数组中的单个字符数据。看上去与二级指针相似,实际上并不一样。二级指针的类型为指针类型,其偏移长度在32位下固定为4字节,而数组指针的类型为数组,其偏移长度随数组而定,两者的偏移计算不同,不可混为一谈。

main函数的三个参数(main(int argc,char *argv[],char *envp[]))分别为:

  1. argc:命令行参数个数,整型。
  2. argv:命令行信息,保存字符串数组首地址的指针变量,是一个指向数组的指针。
  3. envp:环境变量信息,和argv类型相同。

来个main函数参数的demo看一看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// C++源码与对应汇编代码讲解
void main(int argc, char **argv, char **envp } {
for(inti=1;i<argc;i++){
printf (argv[i]) ;
00401112 mov edx, dword ptr [ebp-4] ;取下标值i并将其保存到edx中
;对指针变量取内容,得到数组首地址
00401115 mov eax, dword ptr [ebp+0Ch]
;一维数组寻址,将得到的数组数据保存到ecx中
00401118 mov ecx, dword ptr [eax+edx*4]
0040111B push ecx
0040111C call printf(00401160)
00401121 add esp,4
}
}

argv是一个参数,保存着字符串数组的首地址,因此需要”mov eax,dword ptr[ebp+0Ch]”指令队其取内容,得到数组首地址。

对三维数组 int nArray[2] [3] [4] 可转换成int (*pnArray) [3] [4] = nArray;

指针在地址偏移过程中需要计算出偏移量,因此需要所指向的数据类型来配合计算偏移长度。在多维数组中,可以将最高维看做是- -维 数组,其后数据为这个- -维 数组中各元素的数据类型。所以第一个维数可以省去。

函数指针

用于保存函数首地址的指针变量被称为函数指针。函数指针是不允许执行加法和减法运算的

来两份代码看看:

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
函数指针与函数——Debug版
void __cdecl Show(){
printf("Show\r\n");
}
void main(){
void (__cdecl *pShow)(void) = Show //函数指针赋值
pShow(); //使用函数指针调用函数
Show(); //直接调用函数
}

// C++ 源码与对应汇编代码讲解
void main() {
void (__cdecl *pShow) (void) = Show;
;函数名称即为函数首地址,这是一个常量地址值
0040B90B mov dword ptr [ebp-38h] , offset @ILT+15 (Show) (00401014)
0040B915 mov edx, dword ptr [ebp-38h]
0040B918 mov dword ptr [ebp-38h] , edx
pShow();
0040B91B mov esi,esp
0040B91D call dword ptr [ebp-38h] ;间接调用函数
0040B920 cmp esi,esp ;栈平衡检查,Debug下特有
0040B922 call __chkesp (004012d0) ;栈平衡检查,Debug下特有
Show();
0040B927 call @ILT+15 (Show) (00401014) ;直接调用函数
}
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
带参数和返回值的函数指针——Debug版
//C++源码说明:带参数与返回类型的函数指针
int _stdcall Show(int nShow){
printf("Show : %d\r\n",nShow);
return nShow;
}
void main(){
int (__stdcall *pShow)(int) = Show; //函数指针定义并初始化
int nRet = pShow(5); //使用函数指针调用函数,并获取返回值
printf("ret = %d \r\n",nRet);
}

// C++ 源码与对应汇编代码讲解
void main() {
int (__stdcall *pShow) (int) = Show;
;初始化过程没有变化,仍然为荻取函数首地址并保存
00408868 mov dword ptr [ebp-4] ,offset @ILT+20 (Show) (00401019)
0040886F mov eax, dword ptr [ebp-4]
00408872 mov dword ptr [ebp-4] ,eax
int nRet = pShow(5);
00408875 mov esi,esp ;保存进入函数前的栈顶,用于栈顶检查
00408877 push 5 ;压入参数5
00408879 call dword ptr [ebp-4] ;获取函数指针中的地址,间接调用函数
0040887C cmp esi,esp ;栈顶检查
00408883 mov dword ptr [ebp-8],eax ;接收函数返回值数据
printf("ret = %d \r\n",nRet);
}

结构体和类

在C++中,结构体和类都具有构造函数、析构函数和成员函数,两者只有一个区别:结构体的访问控制默认为public,而类的默认访问控制是private。

对象的内存布局

一般来说,类对象的计算公式如下:

对象长度 = sizeof(数据成员1) + sizeof(数据成员2) + sizeof(数据成员3) + … + sizeof(数据成员n)

但是会出现以下例外:

  1. 空类。空类中没有任何数据成员,按照该公式计算得出的对象长度为0字节。类型长度为0,则此类的对象不占据内存空间。而实际情况是,空类的长度为1字节。如果对象完全不占用内存空间,那么空类就无法取得实例对象的地址,this 指针失效,因此不能被实例化。而类的定义是由成员数据和成员函数组成,在没有成员数据的情况下,还可以有成员函数,因此仍然需要实例化,分配了1字节的空间用于类的实例化,这1字节的数据并没有被使用。
  2. 静态数据成员。当类中的数据成员被修饰为静态时,对象的长度计算又会发生变化。虽然静态数据成员在类中被定义,但它与静态局部变量类似,存放的位置和全局变量一致。只是编译器增加了作用域的检查,在作用域之外不可见,同类对象将共同享有静态数据成员的空间。
  3. 内存对齐。在VC++6.0中,类和结构体中的数据成员是根据它们在类或结构体中出现的顺序来依次申请内存空间的,由于内存对齐的原因,它们并不- -定会像数组那样连续地排列。由于数据类型不同,因此占用的内存空间大小也会不同,在申请内存时,会遵守一定的规则。

重点讲一下第三种:

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

1
2
3
4
Struct tagTEST{
short sShort;
int nInt;
}

数据成员sShort 的地址为0x0012FF74,类型为short,占2字节内存空间。VC++ 6.0指定的对齐值默认为8,short 的长度为2,于是实际的对齐值取较小者2。所以,short 被分配在地址0x0012FF74处,此地址是2的倍数,可分配。此时,轮到为第二个数据成员分配内存了,如果分配在sShort后,应在地址0x0012FF76处,但第二个数据成员为int类型,占4字节内存空间,与指定的对齐值比较后,实际对齐值取int类型的长度4,而地址0x0012FF76不是4的倍数,需要插入两个字节填充,以满足对齐条件,因此第二个数据成员被定义在地址0x0012FF78处。

上例讲到了结构体成员对齐值的问题,现在讨论一下对齐值对结构体整体大小的影响。如果按VC++ 6.0默认的8字节对齐,那么结构体的整体大小要能被8整除,如以下代码所示:

1
2
3
4
5
Sruct{
double dDouble; //所在地址: 0x0012FF00~0x0012FF08之间,占8字节
int nInt; //所在地址: 0x0012FF08~0x0012FF0C之间,占4字节
short sShort; //所在地址: 0x0012FF0C-0x0012FF10之间,占2字节
};

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

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

1
2
3
4
5
Struct{
char cChar; //应占1字节内存空间,如所在地址为0x0012FF00
int nInt; //应占4字节内存空间
short sShort; //应占2字节内存空间
};

以上结构应该按照4字节的方式对齐,其布局格式如下所示:

1
2
3
cChar		所在地址; 0x0012FF00~0x0012FF04之间,占4字节,对齐nInt
nInt 所在地址: 0x0012FF04~0x0012FF08之间,占4字节
sShort 所在地址: 0x0012FF08~0x0012FF0C之间,占2字节,另外填充2字节

既然有默认的对齐值,就可以在定义结构体时进行调整,VC++ 6.0中可使用预编译指令#pragma pack(N)来调整对齐大小。修改以上示例,调整对齐值为1,如以下代码所示:

1
2
3
4
5
6
#pragma pack(1)
Struct{
char cChar; //应占1字节内存空间
int nInt; //应占4字节内存空间
short sShort; //应占2字节内存空间
};

​ 调整对齐值后,根据对齐规则,在分配nInt时无需插入空白数据。对齐值为1, nInt 占4字节大小,很明显,使用pack设定的对齐值更小,因此采用对齐值1的倍数来计算分配内存空间的首地址,nInt 只需紧靠在cChar之后即可。这样cChar只占用1字节内存空间。由于设定的对齐值小于等于结构体中所有数据成员的类型长度,因此结构总长度只要是1的倍数即可。在这个例子中,结构总长度为7。

​ 使用pack修改对齐值也并非一定会生效, 与默认对齐值一样,都需要参考结构体中的数据成员类型。当设定的对齐值大于结构体中的数据成员类型大小时,此对齐值同样是无效的

当结构体中以数组作为成员时,将根据数组元素的长度计算对齐值,而不是按数组的整体大小去计算,如以下代码所示:

1
2
3
4
5
struct{
char cChar; //应占1字节内存空间, 如所在地址为0x0012FF00
char cArray[4]; //应占4字节内存空间
short sShort; //应占2字节内存空间
};

​ 按照对齐规定,cChar 与cArray的对齐没有缝隙,无需插入空白数据,当cArray与sShort进行对齐时,cChar 与cArray在内存中将会占5字节,此时按照结构中当前的数据类型short进行对齐,插入1字节的数据即可,其结构布局如下所示:

1
2
3
cChar						所在地址: 0x0012FF00~0x0012FF01之间,占1字节
cArray[4] 所在地址: 0x0012FF01-0x0012FF06之间,占5字节
sShort 所在地址: 0x0012FF06-0x0012FF08之间,占2字节

​ 当结构体中出现结构体类型的数据成员时,不会将嵌套的结构体类型的整体长度参与到对齐值计算中,而是以嵌套定义的结构体所使用的对齐值进行对齐,如以下代码所示:

1
2
3
4
5
6
7
8
9
struct tagOne{
char cChar; //应占1字节内存空间
char cArray[4]; //应占4字节内存空间
short sShort; //应占2字节内存空间
};
struct tagTwo{
int nInt; //应占4字节内存空间
tagOne one; //应占8字节内存空间
};

​ 在以上结构中,虽然tagOne结构占8字节大小,但由于其对齐值为2,因此tagTwo结构体中的最大类型便是int,以4作为对齐值。所以,结构tagTwo的总大小并非以8字节对齐的16字节,而是以4字节对齐的12字节。

this指针

指针访问结构体或类成员的公式:

假设type为某个正确定义的结构体或者类,member是type中可以访问的成员:

1
2
3
4
type *p
//此处略去p的赋值
//以下是整型加法
p->member的地址 = 指针p的地址值 + member在type中的偏移量

举个栗子~如果有以下定义:

1
2
3
4
5
6
7
struct A{
int m_int; //在结构体内的偏移量为0
float m_float; //在结构体内的偏移量为4
};
struct A a; //假设这个结构体变量a的地址为0x0012ff00
struct A *pA = &a; //定义结构体指针,并赋初值
printf("%p",&pA->m_float); //结果

那么pA->m_float的地址=0x0012ff00 + 4 = 0x0012ff04

ok~,来看一份Debug版的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//C++源码说明:类定义以及数据成员的访问
class CTest{
public:
void SetNumber(int nNumber){ //公有函数成员
m_nInt = nNumber;
}
public:
int m_nInt; //公有数据成员
};
void main(){
CTest Test;
Test.SetNumber(5); //调用成员函数
printf("CTest : %d\r\n",Test.m_Int); //获取数据成员
}

//C++源码与对应汇编代码讲解
//main函数分析
void main(){
CTest Test;
Test.SetNumber(5); //类对象定义
0040B768 push 5 ;压入参数5
0040B76A lea ecx, [ebp-4] ;取出对象Test的首地址存入ecx中
;调用成员函数

0040B76D call @ILT+10 (CTest::SetNumber) (0040100f)
printf ("CTest : %d\r\n", Test.m_nInt);
;取出对象首地址处4字节的数据m_nInt存入eax中
0040B772 mov eax, dword ptr [ebp-4]
0040B775 push eax ;将eax中保存的数据成员m_ nInt向成员函数传参
0040B776 push offset string "CTest : d\r\n" (0042001c)
0040B77B call printf(00401060)
0040B780 add esp,8
}

// SetNumber函数讲解
void SetNumber(int nNumber){ // SetNumber成员函数实现
0040B7B0 push ebp
0040B7B1 mov ebp,esp
0040B7B3 sub esp,44h
0040B7B6 push ebx
0040B7B7 push esi
0040B7B8 push edi
0040B7B9 push ecx ;注意,ecx中保存了对象Test的首地址
0040B7BA lea edi,[ebp-44h]
0040B7BD mov ecx,11h
0040B7C2 mov eax,0CCCCCCCCh
0040B7C7 rep stos dword ptr [edi]
0040B7C9 pop ecx ;还原ecx
;将ecx中的数据存入ebp-4地址处,该地址处保存着调用对象的首地址,即this指针
0040B7CA mov dword ptr [ebp-4],ecx
m_nInt = nNumber;
;取出对象的首地址并存入eax
0040B7CD mov eax, dword ptr [ebp-4]
;取出参数中的数据并保存到ecx中
0040B7D0 mov ecx, dword ptr [ebp+8]
;这里是给成员m_ nInt賦值。由于eax是对象的首地址,成员m_ nInt的偏移量为0,如果写成这样可能更容易理解: mov dword ptr [eax+0] ,ecx
0040B7D3 mov dword ptr [eax] , ecx
}

在使用默认的调用约定时,在调用成员函数的过程中,编译器做了一个“小动作”:利用寄存器ecx保存了对象的首地址,并以寄存器传参的方式传递到成员函数中,这便是this指针的由来。这样的默认调用约定称为thiscall。

在VC++的环境下,识别this指针的关键点是在函数的调用过程中使用了ecx作为第一个参数,并且在ecx中保存的数据为对象的首地址,但并非所有的this指针的传递都是如此。thiscall的栈平衡方式与__stdcall相同,都是由被调用方负责平衡。但是,两者在传参的过程中却不一样,声明为thiscall的函数,第一个参数使用寄存器ecx传递,而非通过栈顶传递。而且thiscall并不属于关键字,它是C++中成员函数特有的调用方式,在C语言中是没有这种调用方式的。因此函数无法显式声明为thiscall调用方式,而类的成员函数默认是thiscall调用方式。所以,在分析过程中,如果看到某函数使用ecx传参,且ecx中保留了对象的this指针,以及在函数实现代码内,存在this指针参与的寄存器相对间接访问方式,如[reg+8],即可怀疑此函数为成员函数。

当使用其他调用方式(如stdcall) 时,this 指针将不再使用ecx传递,而是改用栈传递,举个栗子~

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
//C++源码说明:数组和局部变量的定义以及初始化
class CTest{
public:
void __stcall SetNumber(int nNumber){ //公有函数成员
m_nInt = nNumber;
}
public:
int m_nInt; //公有数据成员
};
void main(){
CTest Test;
Test.SetNumber(5); //调用__stdcall成员函数
printf("CTest : %d\r\n",Test.m_Int); //获取数据成员
}

//成员函数调用过程
Test.SetNumber(5) ;
0040B808 push 5
0040B80A lea eax,[ebp-8] ;荻取对象首地址并存入eax中
0040B80D push eax ;将eax作为参数压栈
0040B80E call @ILT+15 (CTest::SetNumber) (00401014)

//成员函数SetNumber的实现过程
void __stdcall SetNumber (int nNumber) {
;Debug 初始化过程略
m_nInt = nNumber;
0040B7C8 mov eax, dword ptr [ebp+8] ;取出this指针并存入eax中
0040B7CB mov ecx, dword ptr [ebp+0Ch] ;取出参数nNumber并存入ecx中
0040B7CE mov dword ptr [eax] , ecx ;使用eax取出成员并赋值
}

这种 __ cdecl 调用方式和 __gstdcall 调用方式只是在参数平衡时有所区别,this指针不容易识别。

使用thiscall调用放式的成员函数的要点分析:

1
2
3
4
5
6
7
lea		ecx,[mem]							;取对象首地址并存入ecx中,要注意观察内存
call FUN_ADDRESS ;调用成员函数
;在函数调用内,ecx尚未重新赋值之前
mov XXX,ecx ;发现函数内使用ecx中的数据,说明函数调用前对ecx的赋值
;实际上是在传递参数
;其后ecx中的内容会传递给其他寄存器
mov [reg+i],XXX ;发现了寄存器相对间接寻址方式,如果能排除数组访问,那就能说明reg中保存的是结构体或者类对象的首地址

符合以上特点,基本可判定这是调用类的成员函数。通过分析函数代码中访问ecx的方式,并结合内存窗口,以ecx中的值为地址去观察其数据,可以进一-步分析并还原出对象中的各数据成员。

__ stdcall与 __cdecl调用方式的成员函数分析:

1
2
3
4
lea		reg,[mem]							;取出对象首地址并存入寄存器变量中
push reg ;将保存对象首地址的寄存器作为参数压栈
call FUN_ADDRESS ;调用成员函数
;在函数调用内,将第一个函数参数作为指针变量,以寄存器相对间接寻址方式访问

静态数据成员

当类中定义了静态数据成员时,由于静态数据成员和静态变量原理相同(是一个含有作用域的特殊全局变量),因此该静态数据成员的初值会被写入编译链接后的执行文件中。当程序被加载时,操作系统将执行文件中的数据读到对应的内存单元里,静态数据成员便已经存在,而这时类并没有实例对象。所以静态数据成员和对象之间的生命周期不同,并且静态数据成员也不属于某一对象,与对象之间是一对多的关系。静态数据成员仅仅和类相关,和对象无关,多个对象可以共同拥有同一个静态数据成员。

两个对象各自的成员数据在内存中的地址不同,而静态数据成员的地址却相同。如图

普通数据成员和静态数据成员

在计算类和对象的长度时,静态数据成员不被计算在内。

普通数据成员的地址是一个栈空间地址,而静态数据成员的是一个常量地址,可通过立即数间接寻址的方式访问。访问时无需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
//C++源码说明:参数为对象的函数调用
class CFunTest{
public:
int m_nOne;
int m_nTwo;
};

void ShowFunTest(CFunTest FunTest){
printf("%d %d\r\n",FunTest.m_nOne,FunTest.m_nTwo);
}

void main(){
CFunTest FunTest;
FunTest.m_nOne = 1;
FunTest.m_nTwo = 2;
ShowFunTest(FunTest);
}

void main() {
CFunTest FunTest;
;注意,这里没有任何调用默认构造函数的汇编代码
FunTest.m_nOne = 1;
00401098 mov dword ptr [ebp-8],1 ;数据成员m_nOne所在地址为ebp-8
FunTest.m_nTwo = 2;
0040109F mov dword ptr [ebp-4],2 ;数据成员m_nTwo所在地址ebp-4
ShowFunTest (FunTest);
004010A6 mov eax, dword ptr [ebp-4]
004010A9 push eax ;传入数据成员m_nTwo
004010AA mov ecx, dword ptr [ebp-8]
004010AD push ecx ;传入数据成员m_ nOne
004010AE call @ILT+10 (ShowFunTest) 0040100f)
004010B3 add esp,8
}

void ShowFunTest (CFunTest FunTest) {
printf ("%d %d\r\n" , FunTest.m_nOne, FunTest.m_nTwo);
;取出数据成员m_nTwo作为printf函数的第三个参数
00401038 mov eax, dword ptr [ebp+ 0Ch]
0040103B push eax
;取出数据成员m_nOne作为printf函数的第二个参数
0040103C mov ecx, dword ptr [ebp+8]
0040103F push ecx
00401040 push offset string "%d %d\r\n" (0042001c)
00401045 call printf(00401120)
0040104A add esp,0Ch
}

类的体积不大,只有两个数据成员,编译器在调用函数传参的过程中分别将对象的两个成员荡产两个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
// C++源码说明;此代码为修改版,添加了数组成员char m_szName [32]
class CFunTest {
public:
int m_nOne;
int m_nTwo;
char m_szName[32];
};

void ShowFunTest(CFunTest FunTest){
//显示对象中各数据成员的信息
printf ("%d %d %s\r\n", FunTest.m_nOne, FunTest.m_nTwo, FunTest.m_szName);
}

void main() {
CFunTest FunTest;
FunTest.m_nOne = 1;
FunTest.m_nTwo = 2;
strcpy(FunTest.m_szName, "Name"); //赋值数据成员数组
ShowFunTest(FunTest);
}

//C++源码与对应汇编代码讲解
void ShowFunTest(CFunTest FunTest){
;初始化部分略
printf("%d %d %s\r\n", FunTest.m_nOne, FunTest.m_nTwo, FunTest.m_szName);
00401038 lea eax,[ebp+10h] ;取成员m_szName的地址
0040103B push eax ;将成员m_ szName的地址作为参数压栈
0040103C mov ecx,dword ptr [ebp+0Ch] ;取成员m_nTwo中的数据
0040103F push ecx
00401040 mov edx, dword ptr [ebp+8] ;取成员m_nOne中的数据
00401043 push edx
00401044 push offset string "%d%d%s\r\n" (0042002c)
00401049 call printf (00401120)
0040104E add esp,10h
}

// C++源码对照,main函数分析
void main(){
CFunTest FunTest;
;没有任何调用默认构造函数的汇编代码
FunTest.m_nOne = 1;
0040B7E8 mov dword ptr [ebp-28h],1 ;数据成员m_nOne所在地址为ebp-28h
FunTest.m_nTwo = 2;
0040B7EF mov dword ptr [ebp-24h],2 ;数据成员m_nTwo所在地址为ebp-24h
strcpy (FunTest.m_szName, "Name");
0040B7F1 push offset string "Name" (0041302c)
0040B7F6 lea eax,[ebp-20h] ;数组成员m_szName所在地址为ebp-20h
0040B7FE push eax
0040B7FF call strcpy(00404650)
ShowFunTest (FunTest);
0040B804 add esp,0FFFFFFEOh ;调整栈顶,抬高32字节
0040B807 mov ecx,0Ah ;设置循环次数为10
0040B80C lea esi,[ebp-28h] ;获取对象的首地址并保存到esi中
0040B80F mov edi,esp ;设置edi为当前栈顶
;执行10次4字节内存复制,将esi所指向的数据复制到edi中,类似memcpy的内联方式
0040B811 rep movs dword ptr [edi],dword ptr [esi]
0040B813 call @ILT+10 (ShowFunTest) (0040100f)
0040B818 add esp,28h
}

在代码清单9-6中,在传递类对象的过程中使用了“add esp, 0FFFFFFE0h”来调整栈顶指针esp,0FFFFFEOh 是补码,转换后为-20h,等同于esp-20h。前面讲过,参数变量在传递时,需要向低地址调整栈顶指针esp,此处申请的32字节栈空间,加上strcpy未平衡的8字节参数空间,都用于存放参数对象FunTest的数据。将对象FunTest中的数据依次复制到申请的栈空间中。

上面两份代码定义的类都没有定义构造函数和析构函数。由于对象作为参数在传递过程中会制作一份对象的复制数据,当向对象分配内存时,如果有构造函数,编译器会再调用一次构造函数,并做一些初始化工作。当代码执行到作用域结束时,局部对象将被销毁,而对象中可能会涉及资源释放的问题,同样,编译器也会再调用一次局部对象的析构函数,从而完成资源数据的释放。

当对象作为函数的参数时,由于重新复制了对象,等同于又定义了一个对象,在某些情况下会调用特殊的构造函数——拷贝构造函数。当函数退出时,复制的对象作为函数内的局部变量,将被销毁。当析构函数存在时,则会调用析构函数,这时候会出现问题,来个栗子!

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
//C++源码说明:涉及资源申请与释放的类的对象
class CMString{
public:
CMString(){
m_pString = new char[10]; //申请堆空间,只要不释放,进程退出前将一直存在
if(m_pString == NULL){
return;
}
strcpy(m_pString,"Hello");
}
~CMString(){
if(m_pString != NULL){ //检查资源
delete m_pString; //释放空间
m_pString = NULL;
}
}
char *GetString(){
return m_pString;
}
private:
char *m_pString; //数据成员定义,保存堆的首地址
}

//参数CMString类对象的函数
void ShowMyString (CMyString MyStringCpy) {
printf (MyStringCpy.GetString());
}
//main函数实现
void main(){
CMyString MyString; //类对象定义
ShowMyString (MyString);
}
//C++源码与对应汇编代码讲解
//C++源码对照,main函数分析
void main(){
CMyString MyString;
;获取对象的首地址,放入ecx中作为this指针
0040121D lea ecx, [ebp- 10h]
;调用构造函数
00401220 call @ILT+5 (CMyString::CMyString) (0040100a)
;记录同一作用域内该类的对象个数
00401225 mov dword ptr [ebp-4],0
ShowMyString (Mystring);
;MyString对象长度为4,一个寄存器单元刚好能存放
;于是eax获取对象首地址处4字节的数据,即数据成员m_pString
0040122C mov eax,dword ptr [ebp-10h]
0040122F push eax
00401230 call @ILT+15 (ShowMyString) (00401014)
00401235 add esp,4
} // main函数结束处
;由于对象被释放,修改对象个数
00401238 mov dword ptr [ebp-4],0FFFFFFFFh
;获取对象首地址,传入ecx作为this指针
0040123F lea ecx, [ebp-10h]
;调用析构函数
00401242 call @ILT+20 (CMyString::~CMyString) (00401019)
0040111E ret
//构造函数与析构函数讲解略

//ShowMyString函数的实现过程分析
vold ShowMyString (CMyString MyStringCpy) {
004010B0 push ebp
004010B1 mov ebp,esp
;=====================异常链处理过程======================
004010B3 push 0FFh
004010B5 push offset __ehhandler$?ShowMyString@@YAXVCMyString@@@Z (00410d39)
004010BA mov eax,fs:[00000000]
004010C0 push eax
004010C1 mov dword ptr fs:[0],esp
004010C8 sub esp,40h
004010CB push ebx
004010CC push esi
004010CD push edi
004010CE lea edi,[ebp-4Ch]
004010D1 mov ecx,10h
004010D6 mov eax,0CCCCCCCCh
004010DB rep stos dword ptr [edi]
004010DD mov dword ptr [ebp-4],0 ;作用域内的对象个数
printf(MyStringCpy.GetString());
;取参数1的数据成员m_pString的地址(即对象酋地址)并保存到ecx中作为this指针
;注意,此m_pString地址非main函数中的对象MyString的首地址
004010E4 lea ecx,[ebp+8] ;取参数1的地址
;调用成员函数GetString, 该方法的讲解略
004010E7 call @ILT+0 (CMyString::GetString) (00401005)
004010EC push eax ;将返回eax中保存的字符串的首地址作为参数压栈
004010ED call printf (00401310)
004010F2 add esp,4
} //ShowMyString函数的结尾处
;由于对象被释放,修改对象个数
004010F5 mov dword ptr [ebp-4],0FFFFFFFFh
;取参数1的地址,作为this指针调用析构函数
004010FC lea ecx,[ebp+8]
004010FF call @ILT+20 (CMyString::~CMyString) (00401019)
0040111E ret

在代码中,当对象作为参数被传递时,参数MyStringCpy复制了对象MyString中的数据成员m_pString,产生了两个CMyString类的对象。由于没有编写拷贝构造函数,因此在传参的时候就没有被调用,这个时候编译器以浅拷贝处理,它们的数据成员m_pString都指向了同一个堆地址,如图所示。

复制对象与原对象对比

根据图所示,两个对象中的数据成员m_pString指向了相同地址,当函数ShowMyString调用结束后,便会释放对象MyStringCpy,以对象MyStringCpy的首地址作为this指针调用析构函数。在析构函数中,调用delete函数来释放掉对象MyStringCpy的数据成员m_ pString 所保存的堆空间的首地址。但对象MyStringCpy是MyString的复制品,真正的MyString还存在,而数据成员m_pString所保存的堆空间的首地址却被释放,如果出现以下代码便会产生错误:

1
2
3
4
CMyString MyString;
//当该函数调用结束后,对象MyString中的数据成员m_pString所保存的堆空间已经被释放掉,再次使用此对象中的数据成员m_pString便无法得到堆空间的数据
ShowMyString (MyString);
ShowMyString (MyString); ;显示地址中为错误数据

两个方案解决这个问题

  1. 深拷贝数据:在复制对象时,编译器会调用一次该类的拷贝构造函数,给编码者一次机会。深拷贝利用这次机会将原对象的数据成员所保存的资源信息也制作一份副本。这样,当销毁复制对象时,销毁的资源是复制对象在拷贝构造函数中制作的副本,而非原对象中保存的资源信息。
  2. 设置引用计数:在进入拷贝构造函数时,记录类对象被复制引用的次数。当对象被销毁时,检查这个引用计数中保存的引用复制次数是否为0。如果是,则释放掉申请的资源,否则引用计数减1。

对象作为返回值

对象作为返回值与对象作为参数的处理方式非常类似。对象作为参数时,进入函数前预先将对象使用的栈空间保留出来,并将实参对象中的数据复制到栈空间中。该栈空间作为函数参数,用于函数内部使用。同理,对象作为返回值时,进入函数后将申请返回对象使用的栈空间,在退出函数时,将返回对象中的数据复制到临时的栈空间中,以这个临时栈空间的首地址作为返回值。

来个栗子!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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
//C++源码说明:在函数内定义对象并将其作为返回值
class CReturn{
public:
int m_nNumber;
int m_nArry[10];
};
CReturn GetCReturn(){
CReturn RetObj;
RetObj.m_nNumber = 0;
for(int i = 0;i < 10;i++){
RetObj.m_Arry[i] = i+1;
}
return RetObj; //返回局部对象
}
void main(int argc,char* argv[]){
CReturn objA;
objA = GetCReturn();
printf("%d %d %d",objA.m_nNumber,objA.m_nArry[0],objA.m_nArry[9]);
}

//构造函数和析构函数略
//main函数代码分析

void main(int argc, char* argv[]) {
00401290 push ebp
00401291 mov ebp,esp
00401293 sub esp,0C4h ;预留返回对象的栈空间
00401299 push ebx
0040129A push esi
0040129B push edi
0040129C lea edi,[ebp-0C4h]
004012A2 mov ecx,31h
004012A7 mov eax,0CCCCCCCCh
004012AC rep stos dword ptr[edi]
CReturn objA;
objA= GetCReturn();
004012AE lea eax,[ebp- 84h] ;获取返回对象的栈空间首地址
;将返回对象的首地址压入栈中,用于保存返回对象的数据
004012B4 push eax
;调用函数GetCReturn, 见下文对GetCReturn的实现过程的分析
004012B5 call @ILT+45 (GetCReturn) (00401032)
004012BA add esp,4
;函数调用结束后,eax中保存着地址ebp-84h, 即返回对象的首地址
004012BD mov esi,eax ;将返回对象的首地址存入esi中
004012BF mov ecx,0Bh ;设置循环次数
004012C4 lea edi,[ebp-58h] ;获取临时对象的首地址
;每次从返回对象中复制4字节数据到临时对象的地址中,共复制11次
004012C7 rep movs dword ptr [edi],dword ptr [esi]
004012C9 mov ecx,0Bh ;重新设置复制次数
004012CE lea esi,[ebp-58h] ;获取临时对象的首地址
004012D1 lea edi,[ebp-2Ch] ;获取对象objA的首地址
;将数据复制到对象objA中
004012D4 rep movs dword ptr [edi],dword ptr [esi]
}
//GetCReturn 的实现过程分析
CReturn GetCReturn() {
0040CE90 push ebp
0040CE91 mov ebp,esp
0040CE93 sub esp,70h ;调整栈空间,预留临时返回对象与局部对象的内存空间
0040CE96 push ebx
0040CE97 push esi
0040CE98 push edi
0040CE99 lea edi,[ebp-70h]
0040CE9C mov ecx,1Ch
0040CEA1 mov eax,0CCCCCCCCh
0040CEA6 rep stos dword ptr [edi]
CReturn RetObj;
RetObj.m_nNumber = 0;
;为数据成员nNumber賦值0,地址ebp-2Ch便是对象Retobj的酋地址
0040CEA8 mov dword ptr [ebp-2Ch],0
for(int i = 0;i < 10;i++){
RetObj.m_Arry[i] = i+1;
}
0040CED4 jmp GetCReturn+28h(0040ceb8) ;for循环分析略,直接看退出函数时的处理
return RetObj;
0040CED6 mov ecx,0Bh ;设置循环次数为11次
0040CEDB lea esi,[ebp-2Ch] ;获取局部对象的首地址
0040CEDE mov edi,dword ptr [ebp+8] ;荻取返回对象的首地址
;将局部对象RetObj中的数据复制到返回对象中
0040CEE1 rep movs dword ptr [edi],dword ptr [esi]
0040CEE3 mov eax,dword ptr [ebp+8] ;获取返回对象的首地址并保存到eax中,作为返回值
}

代码演示了函数返回对象的全过程。在调用GetCReturn前,编译器将在main函数中申请的返回对象的首地址作为参数压栈,在函数GetCReturn调用结束后进行了数据复制,将GetCReturn函数中定义的局部对象RetObj的数据复制到这个返回对象的空间中,再将这个返回的对象复制给目标对象objA,从而达到返回对象的目的。因为在这个示例中不存在函数返回后为对象的引用赋值,所以这里的返回对象是临时存在的,也就是C++中的临时对象,作用域仅限于单条语句。

这个临时对象的产生原因主要是C++程序员可能采用这类写法,如GetCReturn().m_nNumber,这只是针对返回对象的操作,而此时函数已经退出,其栈帧也被关闭。函数退出后去操作局部对象显然不合适,因此只能由函数的调用方准备空间,建立临时对象,然后将函数中的局部对象复制给临时对象,再把这个临时对象交给调用方去操作。本例中的objA = GetCReturn();是个赋值运算,由于赋值时GetCReturn函数已经退出,其栈空间已经关闭,同理objA不能直接和函数内局部对象做赋值运算,因此需要临时对象记录返回值以后再来参与赋值。

虽然使用临时对象进行了数据复制,但是同样存在出错的风险。这与对象作为参数时遇到的情况一样,由于使用了临时对象进行数据复制,当临时对象被销毁时,会执行析构函数。如果析构函数中有对资源释放的处理,就有可能造成同一个资源多次释放的错误。

编译器在处理简单的结构体和类结构的时候,开启O2优化后,难以识别他们与局部变量的区别

来个栗子!

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
struct tagUnknow{
short m_sShort;
int m_nInt;
};

;int ___cdec1 main(int arge, const char **argv, const char **envp)
sub esp,8
lea eax,[esp+8+var_8] ;获取局部变量的地址并存入eax中
mov [esp+8+var_8],3 ;赋值局部变量1
push eax ;将局部变量的地址作为参数传递
mov [esp+0Ch+var_4],2 ;腻值局部变量2
call sub_401000 ;调用函数sub_401000
add esp,0ch
retn
main endp

sub_401000 proc near
arg_0 = dword ptr 4 ;有一个参数
mov eax,[esp+arg_0] ;获取参数并保存到eax中
;从eax保存的地址中取出2字节数据,结合后面一条指令可推断这是对象成员的寻址,因为参数指针指向的数据类型不一致
movsx ecx,word ptx [eax]
mov edx,[eax+4] ;寄存器相对间接寻址方式,这是对象成员的寻址
push ecx ;将荻取数据作为参数压栈
push edx
push offset aDD
call printf
add esp,0Ch
retn
sub_401000 endp

在通常情况下,VC++6.0编译的代码默认以thiscall方式调用成员函数,因此会使用ecx来保存this 指针,从而进行参数传递,但并非具有ecx传参的函数就一定是成员函数。当使用__fastcall 时,同样可以在反汇编代码中体现出ecx传参。

1
2
3
4
5
6
&((struct A*)NULL)-> m_ float 不会崩溃,这时求m_ float 的地址,根据前面提出的结构体寻址公式:
p->member的地址=指针p的地址值+ member在type中的偏移量
代入得:
&((struct A*)NULL)-> m_ float=0 +4=4,这个表达式实际上是求结构体内成员的偏移量。
可以定义如下宏,用于在不产生对象的情况下取得成员偏移量:
#define offsetof(s,m) (size_t)&(((s *)0)->m)

这个结束了~