C++逆向之数组和指针的寻址

虽然数组和指针都是针对地址操作,但他们有许多不同之处。数组是相同数据类型的数据集合,以线性方式连续存储在内存中;而指针只是一个保存地址值的4字节变量。在使用中,数组名是一个地址常量值,保存数组首元素地址,不可修改,只能以此为基地址访问内存数据;而指针却是一个变量,只要修改指针中所保存的地址数据就可以随意访问,不受约束。

数组在函数内

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

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

此数组为5个int类型的数据的集合,其占用内存空间大小为sizeof(数据类型)* 数组中元素的个数,即4 * 5 = 20字节。如果数组nArray第一项所在地址为0x0012FF00,那么第二项所在地址为0x0012FF04,其寻址方式与指针相同。这样看上去很像是在函数内连续定义了5个int类型的变量,但也不完全相同。
对于数组的识别,应判断数据在内存中是否连续并且类型是否一致,均符合即可将此段数据视为数组。
数组在Release版下不会有太大变化。类似于局部变量的优化方案,在寻址的过程中,数组不同于局部变量,不会因为被赋予了常量值而使用常量传播。
在C++中,字符串本身就是数组,根据约定,该数组的最后一个数据统一使用0作为字符串结束符。为字符类型的数组赋值(初始化)其实是复制字符串的过程。这里并不是单字节复制,而是每次复制4字节的数据。两个内存间的数据传递需要借用寄存器,而每个寄存器一次性可以保存4字节的数据,如果以单字节的方式复制就会浪费掉3字节的空间,而且多次数据传递也会降低执行效率,所以编译器采用4字节的复制方式。

数组作为参数

1
2
3
4
5
6
7
8
9
10
11
void show(char szBuff[])
{
strcpy(szBuff, "Hello World");
printf(szBuff);
}
int main(int argc, char* argv[])
{
char szHello[20] = {0};
show(szHello);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
;debug保存环境与栈初始化省略
0040D828 C645 EC 00 mov byte ptr ss:[ebp-0x14],0x0 ; ebp-14h为数组szHello首地址,数组初始化为0
0040D82C 33C0 xor eax,eax
0040D82E 8945 ED mov dword ptr ss:[ebp-0x13],eax
0040D831 8945 F1 mov dword ptr ss:[ebp-0xF],eax
0040D834 8945 F5 mov dword ptr ss:[ebp-0xB],eax
0040D837 8945 F9 mov dword ptr ss:[ebp-0x7],eax
0040D83A 66:8945 FD mov word ptr ss:[ebp-0x3],ax
0040D83E 8845 FF mov byte ptr ss:[ebp-0x1],al
0040D841 8D4D EC lea ecx,dword ptr ss:[ebp-0x14] ; 去数组首地址放入ecx
0040D844 51 push ecx ; 将ecx作为参数压栈
0040D845 E8 C537FFFF call test.0040100F ; 调用show函数
0040D84A 83C4 04 add esp,0x4 ; 平衡参数
1
2
3
4
5
6
7
8
9
0040D7D8    68 AC2F4200     push test.00422FAC                       ; ASCII "Hello World"
0040D7DD 8B45 08 mov eax,dword ptr ss:[ebp+0x8] ; 取函数参数szBuff地址放入eax中
0040D7E0 50 push eax ; 将eax压栈作为strcpy参数
0040D7E1 E8 1A98FFFF call test.strcpysgzeHeaderListle_pages
0040D7E6 83C4 08 add esp,0x8 ; 堆栈平衡
0040D7E9 8B4D 08 mov ecx,dword ptr ss:[ebp+0x8]
0040D7EC 51 push ecx
0040D7ED E8 6E38FFFF call test.printfgvdbgind_blockeressges ; printf
0040D7F2 83C4 04 add esp,0x4

当数组作为参数时,数组的下标值被省略了。这是因为,当数组作为函数形参时,函数参数中保存的是数组的首地址,是一个指针变量。
虽然参数是指针变量,但需要特别注意的是,实参数组名为常量值,而指针或形参数组为变量。使用sizeof(数组名)可以获取数组的总大小,而对指针或形参中保存的数组名使用sizeof只能得到当前平台的指针长度,这里是32位的环境,所以指针的长度为4字节,因此在写代码的过程中应避免如下错误:

1
2
3
4
5
6
void show(char szBudd[])
{
int nLen = 0; // 保存字符串长度变量
nLen = sizeof(szBuff); // 错误的使用方法,此时szBuff为指针类型, 并非数组,只能得到4字节长度
nLen = strlen(szBuff); // 正确的使用方法,使用获取字符串长度函数strlen
}

字符串处理函数在Debug版下非常容易识别,而在Release版下,它们会被作为内联函数编译处理,因此没有了函数调用指令call。但是只需认真分析一次,总结出内联库函数的特点和识别要领即可。下面以strlen与strcpy为例讲解。

1
2
3
4
int GetLen(char szBuff[])
{
return strlen(szBuff);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
00401000  /$  57            push edi                                 ;  test.<ModuleEntryPoint>
00401001 |. 8B7C24 08 mov edi,dword ptr ss:[esp+0x8] ; 获取参数内容,向edi中赋值字符串首地址
00401005 |. 83C9 FF or ecx,-0x1 ; 将ecx置为-1,为了配合repne scas指令
00401008 |. 33C0 xor eax,eax
;repne/repnz与scas指令结合使用,表示串为结束(ecx!=0)
;当eax与串元素不相等(ZF=0)时,继续重复执行串搜索指令
;可用来在字符串中查找和eax相同的数据位置
0040100A |. F2:AE repne scas byte ptr es:[edi] ; 执行该指令后,包含了字符串长度的相关信息
0040100C |. F7D1 not ecx ; 对ecx取反
0040100E |. 49 dec ecx ; ecx减一,得到字符串长度
0040100F |. 5F pop edi ; test.0040104D
00401010 |. 8BC1 mov eax,ecx ; 设置eax为字符串长度,用于函数返回
00401012 \. C3 retn

优化后的strlen函数被编译为内联函数,其实现过程为,先将eax清零,然后通过指令repne scas遍历字符串,寻找和eax匹配的字符。由于指令repne scas byte ptr es:[edi]中的前缀是用来考察ecx的值,因此在ecx不为0且ZF标志为0时才重复操作,在操作过程中对ecx自动减1。可见,不适合将ecx作为从0开始的字符计数器。由于目标字符串的长度不可预知,所以将其置为-1(0xffffffff)可以满足32位平台的字符串的最大需求。统计完成后,可以根据ecx的值推算出字符串的长度。
ecx初始值为0xffffffff,有符号数为-1,repne前缀每次执行时会自动减1,如果edi指向的内容为字符串结束符,则重复操作结束。注意,重复操作完成时ecx的计数包含了字符串末尾0.假设字符串长度为Len,可以得到如下等式:

ecx(终值) = ecx(初值) - (Len + 1)
ecx(终值) = -1 - (Len + 1) = -(Len + 2)
定义neg为求补运算,则有
neg(ecx(终值)) = Len + 2
求补运算等价于取反加1,定义not为取反运算,则有
not(ecx(终值)) + 1 = Len + 2
Len = not(ecx(终值)) - 1

1
2
3
4
5
void StrCpy(char szBuff[])
{
char temp[20];
strcpy(temp,szBuff);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
00401003  |.  83C9 FF       or ecx,-0x1
00401006 |. 33C0 xor eax,eax
00401008 |. 8D5424 00 lea edx,dword ptr ss:[esp] ; 取出temp首地址
0040100C |. 56 push esi ; test.<ModuleEntryPoint>
0040100D |. 57 push edi ; test.<ModuleEntryPoint>
0040100E |. 8B7C24 20 mov edi,dword ptr ss:[esp+0x20]
;优化之后的求字符串长度函数
00401012 |. F2:AE repne scas byte ptr es:[edi]
00401014 |. F7D1 not ecx ; 对ecx取反,得到字符串长度加1
00401016 |. 2BF9 sub edi,ecx ; 执行repne scasb后,edi指向字符串末尾,减去ecx重新指向字符串首地址
00401018 |. 8BC1 mov eax,ecx ; 设置eax保存字符串长度
0040101A |. 8BF7 mov esi,edi ; esi设置为常量字符串首地址
0040101C |. 8BFA mov edi,edx ; edi设置为参数地址
0040101E |. C1E9 02 shr ecx,0x2 ; ecx右移2位等同于将字符串长度除以4
;此指令为拷贝字符串,每次复制4字节长度,根据ecx中的数值决定复制的次数。将esi中的指向数据每次以4字节
;复制到edi所指向的内存中,每次复制后,esi与edi自加4
00401021 |. F3:A5 rep movs dword ptr es:[edi],dword ptr ds:[esi]
00401023 |. 8BC8 mov ecx,eax ; 重新将字符串长度存入ecx中
00401025 |. 83E1 03 and ecx,0x3 ; 将ecx与3做位与运算,等同于ecx对4求余
;与rep movsd类似,不过是按单字节复制字符串
00401028 |. F3:A4 rep movs byte ptr es:[edi],byte ptr ds:[esi]
0040102A |. 5F pop edi ; 023E0000
0040102B |. 5E pop esi ; 023E0000

字符串拷贝函数strcpy嵌套使用了求字符串长度函数strlen,在这里,strlen在计算长度时少执行了一个减1操作,这是因为strcpy需要将整个字符串(包括最后的0)一起复制。
求得字符串长度是为了以4字节为单位拷贝字符串,从而最大化利用32位寄存器。使用shr ecx, 0x2指令将字符串长度对4求商,得出以4字节为单位需要的复制次数。最后使用and ecx, 0x3指令将字符串长度对4取余,得到以4字节为单位进行复制后的剩余字节数。再通过单字节复制完成复制。

数组作为返回值

数组作为函数的返回值与作为函数的参数大同小异,都是将数组的首地址以指针的方式进行传递,但是它们也有不同。当数组作为参数时,其定义所在的作用域必然在函数调用以外,在调用之前已经存在。所以,在函数中对数组进行操作是没有问题的,而数组作为函数返回值则存在一定风险。
当数组为局部变量数据时,便产生了稳定性问题。当退出函数时,需要平衡栈,而数组作为局部变量存在,其内存空间在当前函数的栈内。如果此时函数退出,栈中定义的数据将变得不稳定。由于函数退出后esp会回到调用前的位置上,而函数内的局部数组在esp之下,随时都有可能在其他函数的调用过程中产生的栈指令操作将其数据破坏。数据的破坏将导致函数返回结果具备不确定性,影响程序的结果。
所以如果想使用数组作为返回值,可以使用全局数组、静态数组或是上一层调用函数中定义的局部数组。
全局数组与静态数组都属于变量,它们的特征与全局变量、静态变量相同,看上去就是连续定义的多个同类型变量。

下标寻址和指针寻址

访问数组的方法有两种:通过下标访问(寻址)和通过指针访问(寻址)。因为使用方便,通过下标访问的方式比较常用,其格式为“数组名[标号]”。指针寻址的方式不但没有下标寻址的方式便利,而且效率也比下标寻址低。由于指针是存放地址数据的变量类型,因此在数据访问的过程中需要先取出指针变量中的数据,然后再针对数组名所代替的地址值进行偏移计算,从而寻址到目标数据。数组名本身就是常量地址,可直接针对数组名所代替的地址值进行便宜计算。

1
2
3
4
5
6
7
8
9
#include "stdafx.h"
int main(int argc, char* argv[])
{
char * pChar = NULL;
char szBuff[] = "Hello";
pChar = szBuff;
printf("%c", *pChar);
printf("%c", szBuff[0]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
00401028    C745 FC 0000000>mov dword ptr ss:[ebp-0x4],0x0           ; 初始化指针变量为空指针
0040102F A1 20204200 mov eax,dword ptr ds:[0x422020]
00401034 8945 F4 mov dword ptr ss:[ebp-0xC],eax ; 初始化数组
00401037 66:8B0D 2420420>mov cx,word ptr ds:[0x422024]
0040103E 66:894D F8 mov word ptr ss:[ebp-0x8],cx
00401042 8D55 F4 lea edx,dword ptr ss:[ebp-0xC]
00401045 8955 FC mov dword ptr ss:[ebp-0x4],edx ; pChar = szBuff
00401048 8B45 FC mov eax,dword ptr ss:[ebp-0x4] ; 取出指针变量中保存的地址数据
0040104B 0FBE08 movsx ecx,byte ptr ds:[eax] ; 字符型指针间接访问
0040104E 51 push ecx ; 间接后传参
0040104F 68 1C204200 push test.0042201C ; ASCII "%c"
00401054 E8 47000000 call test.printfgvdbgind_blockeressges
00401059 83C4 08 add esp,0x8
0040105C 0FBE55 F4 movsx edx,byte ptr ss:[ebp-0xC] ; 直接从ebp-0Ch处取出1字节的数据
00401060 52 push edx ; 将取出数据作为参数
00401061 68 1C204200 push test.0042201C ; ASCII "%c"
00401066 E8 35000000 call test.printfgvdbgind_blockeressges
0040106B 83C4 08 add esp,0x8

代码分别使用了指针寻址和下标寻址两种方式对字符数组szBuff进行了访问。从这两种访问方式的代码实现上看,指针寻址方式要经过2次寻址才能得到目标数据,而下标寻址方式只需要1次寻址就可以得到目标数据。因此,指针寻址比下标寻址多一次寻址操作,效率自然要低。
虽然使用指针寻址方式需要经过2次间接访问,效率要比下标寻址方式低,但其灵活性要强,可修改指针中保存的地址数据,访问其他内存中的数据,而数组下标在没有越界使用的情况下只能访问数组内的数据。
在以下标方式寻址时,如何才能准确定位到数组中数据所在的地址呢?由于数组内的数据是连续排列的,而且数据类型又一致,所以只需要数据首地址、数组元素的类型和下标值,就可以求出数组某下标元素的地址。假设首地址为aryAddr,数组袁术的类型为type,元素个数为M,下标为n,要求数组中某下标元素的地址,其寻址公式如下:

type Ary[M];
&Ary[n] == (type *) ((int)aryAddr + sizeof(type)*n);

下标寻址方式中的下标值可以使用三种类型来表示:整形常量、整形变量、计算结果为整形的表达式。接下来以数组“int nAry[5] = {1,2,3,4,5};”为例来具体讲解一下这三种以不同方式作为下标值的寻址。

下标值为整形常量的寻址

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

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

1
2
3
4
5
mov dowrd ptr [ebp-14h], 1      ;数组初始化,首地址为ebp-14h
mov dowrd ptr [ebp-10h], 2
mov dowrd ptr [ebp-0Ch], 3
mov dowrd ptr [ebp-8], 4
mov dowrd ptr [ebp-4], 5

nArry[2];

1
2
3
;由于下标值为常量2,可直接计算出地址值,运算过程如下:
;ebp-14h + sizeof(int)*2h = ebp-14h+4h*2h=ebp-0Ch
mov eax, dowrd ptr [ebp-0Ch]

下标值为整形变量的寻址

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

nArry[argc];

1
2
3
mov ecx, dword ptr [ebp+8]    ;取得下标值存入ecx中
;使用ecx乘以数据类型的大小(4字节长度),得到下标值存入ecx中
mov eax, dword ptr [ebp+ecx*4-14h]

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

当下标值为表达式时,会先计算出表达式的结果,然后将其结果作为下标值。如果表达式为常量计算,则编译过程中将会执行常量折叠,编译时提前计算出结果,其结果依然是常量,所以最后还是以常量作为下标。下面看看表达式中使用未知变量的寻址过程。

nArry[argc * 2]

1
2
3
mov eax, dword ptr [ebp+8]           ;取下标变量数据存入eax中
shl eax,1 ;堆eax执行左移1位运行,等同于乘以2
mov ecx, dword ptr [ebp+eax*4-14h] ;寻址到数组中元素的地址


数组下标和指针的寻址如此相似,如何在反汇编中区分他们呢?只要抓住一点即可,那就是指针寻址需要两次以上间接访问才可以得到数据。因此,在出现了两次间接访问的反汇编代码中,如果第一次间接访问得到的值作为地址,则必然存在指针。
数组下标寻址的识别相对复杂,下标为常量时,由于数组的元素固定长度,sizeof(type)*n也为常量,产生了常量折叠,编译前可直接算出偏移量,因此只需要使用数组首地址作为基址加偏移即可寻址相关数据,不会出现二次寻址现象。当下标为变量或者变量表达式时,会明显体现出数组的寻址公式,且发生二次访问内存,但是和指针寻址明显不同,第一次访问的是下标,这个值一般不会作为地址使用,且带入公式计算后才得到地址。值得注意的是,在打开O2优化后,需留心各种优化方式。

多维数组

内存中的数据是线性排列的。多维数组看上像是在内存中使用了多块空间来存储数据,事实是这样的吗?编译器采用了非常简单有效的手法,将多维数组通过转化重新变成一维数组。
二维数组的大小计算非常简单,一维数组使用类型大小乘以下标值,得到一维数组占用内存大小。二维数组中的二维下标值为一维数组个数,因此只要将二维下标值乘以一维数组占用内存大小,即可得知二维数组的大小。
计算二维数组的地址偏移要先分析二维数组的组成部分,如整形二维数组int nArray[2][3]可拆分成三部分:

  • 数组首地址: nArray
  • 一维元素类型: int[3],此下标值记为j
    • 类型: int
    • 元素个数: [3]
  • 一维元素个数: [2],此下标值记作i
    此二维数组的组成可理解为两个一维整形数组的集合,而这两个一维数组又各自拥有三个整形数据。在地址偏移的计算过程中,先计算出首地址到一维数组间的偏移量,利用数组首地址加上偏移量,得到某个一维数组所在地址。以此地址作为基地址,加上一维数组中数据地址偏移,寻址到二维数组中某个数据。其寻址公式为:
    数组首地址 + sizeof(type[3]) * 二维下标值 + sizeof(type) * 一维下标值
    二维以上数组的寻址同理。多维数组的组成可看做是一个包裹中套小包裹。如三维数组int nArray[2][3][4],最左侧的int nArrat[2]为第一层包内数据,下标值2说明在第一层包裹中有两个二维数组int[3][4]小包裹。打开小包裹中的一个,里面包着一个一维数组int[4]。再次打开其中一个包裹,里面包含一个int数据。依照这个拆包过程,结合公式,就可以准确定位到多维数组的数据。

    存放指针类型数据的数组

    顾名思义,存放指针类型数据的数组就是数组中各数据元素都是有相同类型的指针组成,我们称之为指针数组。其语法为:
组成部分1 组成部分2 组成部分3
类型名 * 数组名称 [元素个数];

指针数组主要用于管理同种类型的指针,一般用于处理若干个字符串(如二维字符数组)的操作。使用指针数组处理多字符串数据更加方便、简洁、高效。
掌握了如何识别数组后,识别指针数组就会相对简单。既然都是数组,必然遵循数组所拥有的相关特性。但是指针数组中的数据为地址类型,需要再次进行间接访问获取数据。

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, char* argv[])
{
char * pBuff[3] = {
"Hello",
"World",
"!\r\n"
};
for(int i=0;i<3;i++)
{
printf(pBuff[i]);
}
}
1
2
3
4
5
6
7
8
9
00401028  |.  C745 F4 28204>mov [local.3],test.00422028              ;  ASCII "Hello"
0040102F |. C745 F8 20204>mov [local.2],test.00422020 ; ASCII "World"
00401036 |. C745 FC 1C204>mov [local.1],test.0042201C ; ASCII "!\r\n"
0040103D |. C745 F0 00000>mov [local.4],0x0
00401055 |. 8B4D F0 |mov ecx,[local.4] ; 取下标值
00401058 |. 8B548D F4 |mov edx,dword ptr ss:[ebp+ecx*4-0xC] ; 一维数组寻址
0040105C |. 52 |push edx ; 将字符串首地址压入栈
0040105D |. E8 3E000000 |call test.printfgvdbgind_blockeressges ; print
00401062 |. 83C4 04 |add esp,0x4 ; 堆栈平衡

代码定义了字符串数组,该数组由3个指针变量组成,故长度为12字节,该数组所指向的字符串长度和数组本身没有关系,而二维字符数组则与之不同。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
00401028  |.  A1 28204200   mov eax,dword ptr ds:[0x422028]          ;  一维数组初始化过程
0040102D |. 8945 E0 mov [local.8],eax
00401030 |. 66:8B0D 2C204>mov cx,word ptr ds:[0x42202C]
00401037 |. 66:894D E4 mov word ptr ss:[ebp-0x1C],cx
0040103B |. 33D2 xor edx,edx ; test.<ModuleEntryPoint>
0040103D |. 8955 E6 mov dword ptr ss:[ebp-0x1A],edx ; test.<ModuleEntryPoint>
00401040 |. A1 20204200 mov eax,dword ptr ds:[0x422020]
00401045 |. 8945 EA mov dword ptr ss:[ebp-0x16],eax
00401048 |. 66:8B0D 24204>mov cx,word ptr ds:[0x422024]
0040104F |. 66:894D EE mov word ptr ss:[ebp-0x12],cx
00401053 |. 33D2 xor edx,edx ; test.<ModuleEntryPoint>
00401055 |. 8955 F0 mov [local.4],edx ; test.<ModuleEntryPoint>
00401058 |. A1 1C204200 mov eax,dword ptr ds:[0x42201C]
0040105D |. 8945 F4 mov [local.3],eax
00401060 |. 33C9 xor ecx,ecx ; test.<ModuleEntryPoint>
00401062 |. 894D F8 mov [local.2],ecx ; test.<ModuleEntryPoint>
00401065 |. 66:894D FC mov word ptr ss:[ebp-0x4],cx

在二维字符数组初始化过程中,赋值的不是字符串地址,而是其中的字符数据,据此可以明显地区分它与字符指针数组。如果代码中没有初始化操作,那么就需要分析它们如何寻址数据。获取二维数组中的数据的过程如下:

1
2
3
4
5
6
mov edx, dword ptr [ebp-24h]
imul edx, edx, 0Ah
lea eax, [ebp+edx-20h]
push eax
call printf
add esp, 4

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

指向数组的指针变量

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

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

例如,对于二维字符数组“char cArray[3][10]”,定义指向这个数组的指针为“char (* pArray)[10] = cArray”,那么数组指针如何访问数组成员呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, char* argv[])
{
char cArray[3][10] = {
"Hello",
"World",
"!\r\n"
};
char (* pArray)[10] = cArray;
for(int i=0;i<3;i++)
{
printf(\*pArray);
pArray++;
}
}
1
2
3
4
5
6
7
00401087  |.  8B4D DC       |mov ecx,[local.9]                       ;  取出指针pArray保存数据到ecx中
0040108A |. 51 |push ecx
0040108B |. E8 50000000 |call test.printfgvdbgind_blockeressges ; printf
00401090 |. 83C4 04 |add esp,0x4 ; 堆栈平衡
00401093 |. 8B55 DC |mov edx,[local.9] ; 取出指针pArray保存数据到edx中
00401096 |. 83C2 0A |add edx,0xA ; 对edx执行加10操作
00401099 |. 8955 DC |mov [local.9],edx ; 重新赋值指针pArray为edx中数据

数组指针pArray保存了二维字符数组cArray首地址,当对pArray执行加等于1操作后,指针pArray中保存的地址值增加了10字节长。根据指针加法公式:
指针变量 += 数值 <==> 指针变量地址数据 += sizeof(指针类型) * 数值
数值指针pArray类型为char [10],求得其大小为10字节。对pArray加1操作,实质是对pArray中保存的地址加10.加1后偏移到地址为二维字符数组cArray中的第二个一维数组首地址,即&(cArray[1])。
对指向二维数组的数组指针执行取内容操作后,得到的还是一个地址值,再次执行取内容操作才能寻址到二维字符数组中的单个字符数据。看上去与二级指针相似,实际上并不一样。二级指针的类型为指针类型额,其偏移长度在32位下固定为4字节,而数组指针的类型为数组,其偏移长度随数组而定,二者的偏移计算不同,不可混为一谈。
二级指针可用于保存一维指针数组,如对于一维指针数组char p[3],可用char pp来保存其数组首地址。通过对二级指针pp使用三次寻址即可达到数据。在控制台main函数的定义(main(int argc, char \argv[], char *envp[]))中有3个参数:

  1. argc:命令行参数个数,整形
  2. argv:命令行信息,保存字符串数组首地址的指针变量,是一个指向数组的指针。
  3. envp:环境变量信息,和argv类型相同。
    参数argv与envp就是两个指针数组。当数组作为参数时,实际上以指针方式进行数据传递。这里两个参数可转换为char 二级指针类型,修改为:main(int argc, char argv, char ** envp)。

    函数指针

    函数的调用过程,通过call指令跳转到函数首地址处,执行函数内的指令代码。既然是地址,当然就可以使用指针变量进行存储。用于保存函数首地址的指针比那里被称为函数指针。
    函数指针的定义很简单,和函数定义非常相似,由四部分组成:
    返回值类型 ([调用约定, 可选] * 函数指针变量名称) (参数信息)
    函数指针的类型由返回值、参数信息、调用约定组成,它们决定了函数指针在函数调用过程中参数的传递、返回值信息,以及如何平衡栈顶。在没有特殊说明的情况下,调用约定为__cdecl。那么函数调用与函数指针调用如何区分呢?
1
2
3
4
5
6
7
8
9
10
void Show()
{
printf("show!\n");
}
int main(int argc, char* argv[])
{
void (* pShow) (void) = Show;
pShow();
Show();
}
1
2
3
4
5
6
7
8
00401068  |.  C745 FC 0A104>mov [local.1],test.0040100A              ;  函数名称即为函数首地址,这是一个常量地址值
0040106F |. 8B45 FC mov eax,[local.1]
00401072 |. 8945 FC mov [local.1],eax
00401075 |. 8BF4 mov esi,esp
00401077 |. FF55 FC call [local.1] ; 间接调用函数
0040107A |. 3BF4 cmp esi,esp ; 栈平衡检查,Debug下特有
0040107C |. E8 DF000000 call test._chkespleBufferstringsWtetApag>; 栈平衡检查
00401081 |. E8 84FFFFFF call test.0040100A ; 直接调用

函数指针与函数调用的最大区别在于函数是直接调用,而函数指针的调用需要取出指针变量中保存的地址数据,间接调用函数。
函数指针是比较特殊的指针类型,由于其保存的地址数据为代码段内的地址信息,而非数据区,因此不存在地址偏移的情况。指针的操作非常灵活。为了防止函数指针发生错误的地址偏移,编译器在编译期间对其进行检查,不允许对函数指针类型变量执行加法和减法。

小结

由于数组的本质是同类元素的集合,各元素在内存中顺序排列,因此类型为type的数组ary第n个元素的地址可以表达为: &ary[n] = ary首地址 + sizeof(type) * n,编译器在此基础上开展各类优化。在数组元素的访问代码被编译器优化后,可能会直接看到[ebp-n]这样的访问,虽然在开始分析时这样的情况只能定性为局部变量,但是如果后来发现这类变变量在内存中连续,且类型一直,就可以考虑还原为数组,这样更方便,

文章目录
  1. 1. 数组在函数内
  2. 2. 数组作为参数
  3. 3. 数组作为返回值
  4. 4. 下标寻址和指针寻址
    1. 4.1. 下标值为整形常量的寻址
    2. 4.2. 下标值为整形变量的寻址
    3. 4.3. 下标值为整形表达式的寻址
  5. 5. 多维数组
  6. 6. 存放指针类型数据的数组
  7. 7. 指向数组的指针变量
  8. 8. 函数指针
  9. 9. 小结