虽然数组和指针都是针对地址操作,但他们有许多不同之处。数组是相同数据类型的数据集合,以线性方式连续存储在内存中;而指针只是一个保存地址值的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 | void show(char szBuff[]) |
1 | ;debug保存环境与栈初始化省略 |
1 | 0040D7D8 68 AC2F4200 push test.00422FAC ; ASCII "Hello World" |
当数组作为参数时,数组的下标值被省略了。这是因为,当数组作为函数形参时,函数参数中保存的是数组的首地址,是一个指针变量。
虽然参数是指针变量,但需要特别注意的是,实参数组名为常量值,而指针或形参数组为变量。使用sizeof(数组名)可以获取数组的总大小,而对指针或形参中保存的数组名使用sizeof只能得到当前平台的指针长度,这里是32位的环境,所以指针的长度为4字节,因此在写代码的过程中应避免如下错误:1
2
3
4
5
6void show(char szBudd[])
{
int nLen = 0; // 保存字符串长度变量
nLen = sizeof(szBuff); // 错误的使用方法,此时szBuff为指针类型, 并非数组,只能得到4字节长度
nLen = strlen(szBuff); // 正确的使用方法,使用获取字符串长度函数strlen
}
字符串处理函数在Debug版下非常容易识别,而在Release版下,它们会被作为内联函数编译处理,因此没有了函数调用指令call。但是只需认真分析一次,总结出内联库函数的特点和识别要领即可。下面以strlen与strcpy为例讲解。1
2
3
4int GetLen(char szBuff[])
{
return strlen(szBuff);
}
1 | 00401000 /$ 57 push edi ; test.<ModuleEntryPoint> |
优化后的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 | void StrCpy(char szBuff[]) |
1 | 00401003 |. 83C9 FF or ecx,-0x1 |
字符串拷贝函数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
int main(int argc, char* argv[])
{
char * pChar = NULL;
char szBuff[] = "Hello";
pChar = szBuff;
printf("%c", *pChar);
printf("%c", szBuff[0]);
}
1 | 00401028 C745 FC 0000000>mov dword ptr ss:[ebp-0x4],0x0 ; 初始化指针变量为空指针 |
代码分别使用了指针寻址和下标寻址两种方式对字符数组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], 5nArry[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 | int main(int argc, char* argv[]) |
1 | 00401028 |. C745 F4 28204>mov [local.3],test.00422028 ; ASCII "Hello" |
代码定义了字符串数组,该数组由3个指针变量组成,故长度为12字节,该数组所指向的字符串长度和数组本身没有关系,而二维字符数组则与之不同。1
char cArray[3][10] = {{"Hello"},{"World"},{"!\r\n"}};
1 | 00401028 |. A1 28204200 mov eax,dword ptr ds:[0x422028] ; 一维数组初始化过程 |
在二维字符数组初始化过程中,赋值的不是字符串地址,而是其中的字符数据,据此可以明显地区分它与字符指针数组。如果代码中没有初始化操作,那么就需要分析它们如何寻址数据。获取二维数组中的数据的过程如下:
1 | mov edx, dword ptr [ebp-24h] |
虽然二维字符数组和指针数组的寻址过程非常相似,但依然有一些不同。字符指针数组寻址后,得到的是数组成员内容,而二维字符数组寻址后得到的却是数组中某个一维数组的首地址。
指向数组的指针变量
当指针变量保存的数据为数组的首地址,且将此地址解释为数组时,此指针变量被称为数组指针。指向数组元素的指针很简单,只要是指针变量,都可以用于寻址该类型的一维数组中各元素,得到数组中的数据。而指向一维数组的数组指针会有些变化,指针一维数组的数组指针定义格式如下:
组成部分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 | 00401087 |. 8B4D DC |mov ecx,[local.9] ; 取出指针pArray保存数据到ecx中 |
数组指针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个参数:
- argc:命令行参数个数,整形
- argv:命令行信息,保存字符串数组首地址的指针变量,是一个指向数组的指针。
- 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 | 00401068 |. C745 FC 0A104>mov [local.1],test.0040100A ; 函数名称即为函数首地址,这是一个常量地址值 |
函数指针与函数调用的最大区别在于函数是直接调用,而函数指针的调用需要取出指针变量中保存的地址数据,间接调用函数。
函数指针是比较特殊的指针类型,由于其保存的地址数据为代码段内的地址信息,而非数据区,因此不存在地址偏移的情况。指针的操作非常灵活。为了防止函数指针发生错误的地址偏移,编译器在编译期间对其进行检查,不允许对函数指针类型变量执行加法和减法。
小结
由于数组的本质是同类元素的集合,各元素在内存中顺序排列,因此类型为type的数组ary第n个元素的地址可以表达为: &ary[n] = ary首地址 + sizeof(type) * n,编译器在此基础上开展各类优化。在数组元素的访问代码被编译器优化后,可能会直接看到[ebp-n]这样的访问,虽然在开始分析时这样的情况只能定性为局部变量,但是如果后来发现这类变变量在内存中连续,且类型一直,就可以考虑还原为数组,这样更方便,