8.2 数组作为参数
我们在8.1节中学习了局部数组的定义以及初始化过程。数组中的数据元素连续存储,并且数组是同类型数据的集合。当作为参数传递时,数组所占的内存大小通常大于4字节,那么它是如何将数据传递到目标函数中并使用的呢?我们先来看看代码清单8-4。
代码清单8-4 数组作为参数传递
//C++源码说明:数组作为参数
//参数类型为字符型数组
void Show(char szBuff[]){//参数为字符数组类型
strcpy(szBuff,"Hello World");//复制字符串
printf(szBuff);
}
void main(){
char szHello[20]={0};//字符数组定义
Show(szHello);//将数组作为参数传递
}
//C++源码与对应汇编代码讲解
void main(){
;Debug保存环境初始化栈略
char szHello[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-7],eax
0040B7DA mov word ptr[ebp-3],ax
0040B7DE mov byte ptr[ebp-1],al
Show(szHello);
0040B7E1 lea ecx,[ebp-14h];取数组首地址存入ecx
0040B7E4 push ecx;将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);
}
在代码清单8-4中,当数组作为参数时,数组的下标值被省略了。这是因为,当数组作为函数形参时,函数参数中保存的是数组的首地址,是一个指针变量。
虽然参数是指针变量,但需要特别注意的是,实参数组名为常量值,而指针或形参数组为变量。使用sizeof(数组名)可以获取数组的总大小,而对指针或者形参中保存的数组名使用sizeof只能得到当前平台的指针长度,这里是32位的环境,所以指针的长度为4字节。因此,在编写代码的过程中应避免如下错误:
void Show(char szBuff[]){
int nLen=0;//保存字符串长度变量
//错误的使用方法,此时szBuff为指针类型,并非数组,只能得到4字节长度
nLen=sizeof(szBuff);
//正确的使用方法,使用获取字符串长度函数strlen
nLen=strlen(szBuff);
}
字符串处理函数在Debug版下非常容易识别,而在Release版下,它们会被作为内联函数编译处理,因此没有了函数调用指令call。但是,我们只需认真分析一次,总结出内联库函数的特点和识别要领即可。本节将以字符串拷贝函数strcpy作为示例进行讲解。在分析strcpy前,需要先认识一下求字符串长度的函数strlen,代码如下:
//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;函数终止处
优化后的strlen函数被编译为内联函数,其实现过程为,先将eax清零,然后通过指令repne scasb遍历字符串,寻找和eax匹配的字符。由于指令repne scasb中的前缀repne是用来考察ecx的值,因此在ecx不为0且ZF标志为0时才重复操作,在操作过程中对ecx自动减1。可见,不适合将ecx作为从0开始的字符计数器。由于目标字符串的长度不可预知,所以将其置为0xffffffff(-1)可以满足32位平台的字符串的最大需求。统计完成后,可以根据ecx的值推算出字符串的长度,推算过程如下。
ecx的初始值为0xffffffff,有符号数值为-1,repne前缀每次执行时会自动减1,如果edi指向的内容为字符串结束符(asc值0),则重复操作结束。注意,重复操作完成时ecx的计数包含了字符串末尾的0。假设字符串长度为Len,我们可得到等式:
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
至此,对求字符串长度函数strlen的分析就完成了,有了它作为基础,就可以继续分析字符串拷贝函数strcpy,如代码清单8-5所示。
代码清单8-5 识别strcpy的内联形式—Release版
;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;调用printf函数,参数为之前压入的eax
add esp,4;平衡栈4字节,只有一个参数
pop edi
pop esi
retn
sub_401000 endp
从对代码清单8-5的分析中得知,字符串拷贝函数strcpy嵌套使用了求字符串长度函数strlen,在这里,strlen在计算长度时少执行了一个减1操作,这是因为strcpy需要将整个字符串(包括最后的0)一起复制。
求得字符串长度是为了以4字节为单位拷贝字符串,从而最大化利用32位寄存器。使用“shr ecx,2”指令将字符串长度对4求商,得出以4字节为单位需要的复制次数。最后使用“and ecx,3”指令将字符串长度对4取余,得到以4字节为单位进行复制后剩余的字节数。
在字符数组初始化时,会将剩余的3字节数据以双字节加单字节的方式复制。因为编译器可以计算出数组长度,而字符串拷贝函数无法预先得知字符串的长度,所以,如果先使用4字节的复制方式,最后再对剩余字节进行对2求商并取余就太过复杂了。如果直接采用单字节复制,最多也才复制3次,效率明显高于对2求商并取余的方式。
通过对上面这两个关键的字符串处理函数的分析,相信大家应该可以自行分析其他库函数的实现方式,并总结出其中的方法和要领。希望大家认真地分析其他库函数,这样再一次遇到分析过的反汇编代码时,就可以快速识别,减少分析的工作量。