8.4 下标寻址和指针寻址
访问数组的方法有两种:通过下标访问(寻址)和通过指针访问(寻址)。因为使用方便,通过下标访问的方式比较常用,其格式为“数组名[标号]”。指针寻址的方式不但没有下标寻址的方式便利,而且效率也比下标寻址低。由于指针是存放地址数据的变量类型,因此在数据访问的过程中需要先取出指针变量中的数据,然后再针对此数据进行地址偏移计算,从而寻址到目标数据。数组名本身就是常量地址,可直接针对数组名所代替的地址值进行偏移计算。我们来分析一下代码清单8-8,对比这两种寻址方式的差别,看一看两者间的效率差距。
代码清单8-8 数组的下标寻址和指针寻址的区别—Debug版
//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"Hello"(00420030)];初始化数组
00401104 mov dword ptr[ebp-0Ch],eax
00401107 mov cx, word ptr[string"Hello"+4(00420034)]
0040110E mov word ptr[ebp-8],cx
pChar=szBuff;
00401112 lea edx,[ebp-0Ch];获取数组首地址,然后使用edx保存
0040115 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
}
代码清单8-8中分别使用了指针寻址和下标寻址两种方式对字符数组szBuff进行了访问。从这两种访问方式的代码实现上来看,指针寻址方式要经过2次寻址才能得到目标数据,而下标寻址方式只需要1次寻址就可以得到目标数据。因此,指针寻址比下标寻址多一次寻址操作,效率自然要低。
虽然使用指针寻址方式需要经过2次间接访问,效率要比下标寻址方式低,但其灵活性更强,可修改指针中保存的地址数据,访问其他内存中的数据,而数组下标在没有越界使用的情况下只能访问数组内的数据。
在以下标方式寻址时,如何才能准确定位到数组中数据所在的地址呢?由于数组内的数据是连续排列的,而且数据类型又一致,所以只需要数组首地址、数组元素的类型和下标值,就可以求出数组某下标元素的地址。假设首地址为aryAddr,数组元素的类型为type,元素个数为M,下标为n,要求数组中某下标元素的地址,其寻址公式如下:
type Ary[M];
&Ary[n]==(type*)((int)aryAddr+sizeof(type)*n);
容易理解的写法如下(注意这里是整型加法,不是地址加法):
ary[n]的地址=ary的首地址+sizeof(type)*n
由于数组的首地址是数组中第一个元素的地址,因此下标值从0开始。首地址加偏移量0自然就得到了第一个数组元素的首地址。
下标寻址方式中的下标值可以使用三种类型来表示:整型常量、整型变量、计算结果为整型的表达式。接下来我们以数组“int nAry[5]={1,2,3,4,5};”为例来具体讲解一下这三种以不同方式作为下标值的寻址。
1.下标值为整型常量的寻址
在下标值为常量的情况下,由于类型大小为已知数,编译器可以直接计算出数据所在的地址。其寻址过程和局部变量相同,分析过程如下:
int nArry[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",nArry[2]);
;由于下标值为常量2,可直接计算出地址值,其运算过程如下:
;ebp-14h+sizeof(int)*2h=ebp-14h+4h*2h=ebp-14h+8最
;终得到地址ebp-0Ch
mov eax, dword ptr[ebp-0Ch]
;printf函数分析略
2.下标值为整型变量的寻址
当下标值为变量时,编译器无法计算出对应的地址,只能先进行地址偏移计算,然后得出目标数据所在的地址。
;数组各元素的地址同上
printf("%d\r\n",nArry[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]
;printf函数分析略
3.下标值为整型表达式的寻址
当下标值为表达式时,会先计算出表达式的结果,然后将其结果作为下标值。如果表达式为常量计算,则编译过程中将会执行常量折叠,编译时提前计算出结果,其结果依然是常量,所以最后还是以常量作为下标,藉此寻址数组内元素。以表达式nArry[2*2]为例,编译过程中将计算2×2得到4,并将4作为整型常量下标值来寻址。其结果等价于nArry[4]。
接下来我们通过下面的代码来看看表达式中使用未知变量的寻址过程。
;数组中各元素的地址同上
printf("%d\r\n",nArry[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]
;printf函数分析略
数组下标寻址使用的方案和指针寻址公式非常相似,都是利用首地址加偏移量。数组的三种下标寻址方案同样也可以应用在指针寻址中。
在VC++6.0中,不会对数组的下标进行访问检查,使用数组时很容易导致越界访问的错误。当下标值小于0或大于数组下标最大值时,就会访问到数组邻近定义的数据,造成越界访问,进而导致程序崩溃,或者产生更为严重的其他隐患,如代码清单8-9所示。
代码清单8-9 数组下标寻址越界访问—Debug版
//C++源码说明:利用数组越界访问,读取变量nNumber并显示
void main(){
int nArray[4]={1,2,3,4};
int nNumber=5;
printf("%d",nArray[-1]);
}
//C++源码与对应汇编代码讲解
void main(){
int nArray[4]={1,2,3,4};
004010F8 mov dword ptr[ebp-10h],1;数组初始化
004010FF mov dword ptr[ebp-0Ch],2
00401106 mov dword ptr[ebp-8],3
0040110D mov dword ptr[ebp-4],4
70:int nNumber=5;
00401114 mov dword ptr[ebp-14h],5;局部变量初始化
71:printf("%d",nArray[-1]);
0040111B lea eax,[ebp-10h];获取数组首地址
0040111E mov ecx, dword ptr[eax-4];偏移到地址ebp-14h处
00401121 push ecx
00401122 push offset string"%d"(0042002c)
00401127 call printf(00401170)
0040112C add esp,8
}
代码清单8-9中的数组寻址使用了负数作为下标值。将数组下标寻址“nArray[-1]”代入寻址公式(见2.5.2节)中为
nArray[-1]=nArray+sizeof(int)*(-1)
=ebp-10h+4*(-1)
=ebp-10h-4
=ebp-14h
最终访问到地址ebp-14h处,这正是变量nNumber所在的地址。根据局部变量的定义顺序,人为将变量定义在数组之下,从而造成负数下标的越界访问。同理,变量nNumber定义在数组前,使用下标值4也将会越界访问到变量nNumber。如图8-8所示。
图 8-8 VC 8.0中使用数组下标为负数的越界访问
下标寻址方式也可以被指针寻址方式所代替,但指针寻址方式需要两次间接访问才能访问到数组内的元素,第一次是访问指针变量,第二次才能访问到数组元素,故指针寻址的执行效率不会高于下标寻址,但是在使用的过程中更加方便。
数组下标和指针的寻址如此相似,如何在反汇编代码中区分它们呢?只要抓住一点即可,那就是指针寻址需要两次以上间接访问才可以得到数据。因此,在出现了两次间接访问的反汇编代码中,如果第一次间接访问得到的值作为地址,则必然存在指针。图8-6就使用寄存器作为指针变量,保存全局数组的地址,从而利用保存了全局数组首地址的寄存器对该数组进行间接访问操作。
数组下标寻址的识别相对复杂,下标为常量时,由于数组的元素长度固定,sizeof(type)*n也为常量,产生了常量折叠,编译前可直接算出偏移量,因此只需使用数组首地址作为基址加偏移即可寻址相关数据,不会出现二次寻址现象。当下标为变量或者变量表达式时,会明显体现出数组的寻址公式,且发生两次内存访问,但是和指针寻址明显不同,第一次访问的是下标,这个值一般不会作为地址使用,且代入公式计算后才得到地址。值得注意的是,在打开优化选项O2后,需留心各种优化方式。