字符串函数strlen的深入研究

描述

还记得初学C的时候,对于字符串操作一类函数的记忆显得尤为深刻,各种考试会考strlen、strlen等函数的实现,到了毕业找工作,很多公司的笔试题里,也包含有strlen、strcpy等函数的实现。可见字符串操作类函数是受到了老师和公司出题者的青睐啊。那么本文就来研究一下strlen这个函数吧!

C语言

可能你这时已经在BS我了,心想就这么个东西,还需要研究的么。我能瞬间完成,于是你写下了这段代码:

[cpp] view plain copy

int strlen( constchar* str )  

{  

int length = 0;  

while ( *str++ )  

++length;  

return ( length );  

}  

哇!你还真快,真的瞬间写下了这个简洁精炼的strlen,不错,你的C语言考题过关了,公司笔试也过了,值得恭喜。但是,似乎这么快就解决了问题,那本文要怎么进行下去呢?那就先分析一下你瞬间秒杀出来的这个strlen吧,她简直太完美了,和MS的工程师们写得如出一辙,总体看下来也就几行代码完事,那么,为啥这么几行就能解决问题?还有没有更优的方案?你灵机一动,又瞬间想出一种:

[cpp] view plain copy

int strlen( constchar* str )  

{  

constchar* ptr = str;  

while ( *str++ )  

;  

return ( str - ptr - 1 );  

}  

所谓代码简短不一定就是最优的,当然这里不能扯到软件工程里去了,我们可以看出这两种实现,str++是逐字节向后移动的,时间复杂度都是O(n),所以这个strlen可以很简单的完成,那么更优的方案是什么呢?试想,如果能够几个字节一跳,不是能够更快的完成求长度,不就降低了复杂度?先拭目以待吧。

本系列是为了剖析crt库中intel模块下的那些函数的,那么我们去找找那里面有没有strlen的实现,呀!居然找到了,它就位于VC/crt/src/intel/strlen.asm里。打开看看,咦,有点晕。不过最亮眼的就是,在前面的注释里,MS的工程师们写了个“注释版”的strlen,与你前面实现的strlen简直是一摸一样的。可是,它是注释版的,不会编译进程序运行。那么继续看下面的汇编实现,代码如下:

[cpp] view plain copy

CODESEG  

public  strlen  

strlen  proc \  

buf:ptr byte  

OPTION PROLOGUE:NONE, EPILOGUE:NONE  

.FPO    ( 0, 1, 0, 0, 0, 0 )      

string  equ     [esp + 4]  

mov     ecx,string              ; ecx -> string  

test    ecx,3                   ; test if string is aligned on 32 bits  

je      short main_loop  

str_misaligned:  

; simple byte loop until string is aligned  

mov     al,byte ptr [ecx]  

add     ecx,1  

test    al,al  

je      short byte_3  

test    ecx,3  

jne     short str_misaligned  

add     eax,dword ptr 0         ; 5 byte nop to align label below  

align   16                      ; should be redundant  

main_loop:  

mov     eax,dword ptr [ecx]     ; read 4 bytes  

mov     edx,7efefeffh  

add     edx,eax  

xor     eax,-1  

xor     eax,edx  

add     ecx,4  

test    eax,81010100h  

je      short main_loop  

; found zero byte in the loop  

mov     eax,[ecx - 4]  

test    al,al                   ; is it byte 0  

je      short byte_0  

test    ah,ah                   ; is it byte 1  

je      short byte_1  

test    eax,00ff0000h           ; is it byte 2  

je      short byte_2  

test    eax,0ff000000h          ; is it byte 3  

je      short byte_3  

jmp     short main_loop         ; taken if bits 24-30 are clear and bit  

; 31 is set  

byte_3:  

lea     eax,[ecx - 1]  

mov     ecx,string  

sub     eax,ecx  

ret  

byte_2:  

lea     eax,[ecx - 2]  

mov     ecx,string  

sub     eax,ecx  

ret  

byte_1:  

lea     eax,[ecx - 3]  

mov     ecx,string  

sub     eax,ecx  

ret  

byte_0:  

lea     eax,[ecx - 4]  

mov     ecx,string  

sub     eax,ecx  

ret  

strlen  endp  

end  

只看主体部分的汇编代码,我们进行逐句研究。

首先,是声明了strlen的公共符号,以及strlen的函数参数等声明,OPTION一句代码是为了让汇编程序不生成开始代码和结束代码(这个可以查阅相关文献资料,这里不进行详细解释),下一句.FPO,是与堆栈指针省略(FramePointOmission)相关的,在MSDN里面的解释如下:

FPO (cdwLocals, cdwParams, cbProlog, cbRegs, fUseBP, cbFrame)

cdwLocals :Number of local variables, an unsigned 32 bit value.

cdwParams :Size of the parameters, an unsigned 16 bit value.

cbProlog :Number of bytes in the function prolog code, an unsigned 8 bit value.

cbRegs :Number of bytes in the function prolog code, an unsigned 8 bit value.

fUseBP: Indicates whether the EBP register has been allocated. either 0 or 1.

cbFrame :Indicates the frame type.在这里只需要关注第二个参数,它为1,表示有一个参数。strlen本身也就是一个参数。其他参数,看上面的英文注释应该很简单了,这里不作解释。你也可以点击这里查阅。

继续向下,关注这三句:

[cpp] view plain copy

string  equ     [esp + 4]  

mov     ecx,string              ; ecx -> string  

test    ecx,3                   ; test if string is aligned on 32 bits  

je      short main_loop     

第一句,esp+4这个就简单了,在《【动态分配栈内存】之alloca内幕》一文中有详细的解释,这里只做简单解释,esp+4正是strlen参数的地址,这个地址属于栈内存空间,再[esp+4]取值,则得到strlen参数指向的地址(strlen的参数为const char*)。假如代码是这样的:

[cpp] view plain copy

char szName[] = "masefee";  

strlen( szName );  

那么,上面的[esp+4]所得的地址值就是szName数组的首地址。前面的string equ [esp+4]并不会产生任何代码,string只相当于是一个宏定义(至于为什么需要这个string,到后面就知道了,你要相信,这一切都是有理有据的,这也正是研究的乐趣之一),于是mov ecx,string就等价于mov ecx,[esp+4],这句是直接将参数指向的地址值赋值给ecx寄存器,ecx此刻就是字符串的首地址了。再下一句,test ecx,3,这句是测试ecx存放的这个地址值是不是4字节(32bits)对齐的,如果是,则跳转到main_loop进行执行,否则,则继续向下。我们先看未对齐的情况,自然就是紧接着的str_misaligned节:

[cpp] view plain copy

str_misaligned:  

mov     al,byte ptr [ecx]  

add     ecx,1  

test    al,al  

je      short byte_3  

test    ecx,3  

jne     short str_misaligned  

add     eax,dword ptr 0         ; 5 byte nop to align label below  

align   16                      ; should be redundant  

先不看这段代码,我们先推断一下,前面说到了不对齐的情况,一般对于操作系统来说,对于内存的分配总是会对齐的,所以这里strlen一进来就检查是否对齐,那么不对齐的情况是什么时候呢?如下:

[cpp] view plain copy

char szName[] = "masefee";  

char* p = szName;  

p++;  // 使p向后移动一个字节,本身假设以4字节对齐,移动之后就不再4字节对齐了  

strlen( p );  

当然,这里是我故意写成这样的,在实际中还有其他的情况,例如一个结构体里面有一个字符串,这个结构体是一字节对齐的,字符串的位置不确定时,那么字符串的首地址也就可能不是4字节对齐的。继续前面的推断,如果不对齐时,就会先让其对齐,然后再继续求长度,如果在让其重新对齐的过程中,发现了结束符则停止,立刻返回长度。好了,推断完毕。再看上面的汇编代码,果然是这样干的。

先是向ecx指向的内存里取一个字节到al里,然后ecx加1向后移动一个字节,再判断al是否为0,如果为0则跳转到byte_3节,否则继续测试ecx当前的地址值是否已经对齐,未对齐则继续取一个字节的值,再加ecx,直到对齐或者碰到结束符。当没有碰到结束符且ecx存放的地址值已经对齐时,下面一句add eax,dword ptr 0,后面有注释,表明这句代码无实际意义。align 16和前面的add共同作用是为了将代码以16字节对齐,后面的main_loop就是16字节对齐开始的地址了(又一次感受到了MS工程师们的聪明之处,考虑很周到)。

接下来该进入到main_loop了,很明显这是主循环的意思,也是strlen的核心。这里用了很巧妙的算法,先分析前半部分代码:

[cpp] view plain copy

mov     eax,dword ptr [ecx]     ; read 4 bytes  

mov     edx,7efefeffh  

add     edx,eax  

xor     eax,-1  

xor     eax,edx  

add     ecx,4  

test    eax,81010100h  

je      short main_loop  

首先,第一句向ecx所指向的内存里读取了4个字节到eax中,很明显是想4个字节处理一次。然后再看第二句,将edx赋值为0x7efefeff,这个数字看起来有什么规律,有什么用呢?来看看这个数字的二进制:

01111110   11111110   11111110   11111111        看看这个数字的二进制,我们注意到有4个红色的0,他们都有一个特征,就是在每个字节的左边,这有什么用?再联想一下,在左边,什么时候会被修改?很明显,当右边有进位时,会修改到这个0,或者这几个0的位置与另外一个数相运算时会被改变。先不忙分析,先看下一句add edx,eax,这一句是将从ecx指向的内存里取出来的4字节整数与0x7efefeff相加,奇怪了,这样相加有什么意义呢?仔细一想,惊讶了,原理这样相加就能知道这个4字节整数中哪个或哪几个字节为0了。为0则达到了strlen的目的,strlen就是为了找到结束符,然后返回长度。        再看这个加法的过程,加法的目的就是为了让上面4个红色的0中某些0被改变,如果有哪个0没有改变并且最高位的0未改变,那说明这4个字节中存在某个或某些字节为0。这几个红色的0可以被称为是洞(hole),而且也很形象。举个例子:

byte3           byte2             byte1            byte0

????????   00000000   ????????   ????????            // eax

+   01111110    11111110    11111110     11111111            // edx = 0x7efefeff        上面是假设两个数相加,问号代表0或者1,但整个字节不全0,eax的byte2为全0,与edx的byte2相加,不管byte1和byte0怎么相加,最后进位都只能最多为1,那么byte3的最低位永远不可能改变。以此类推,如果byte0为0,byte1的最低位永远不可能改变,只有byte0有1位不为0,byte1的最低位都会收到进位,这也就是为什么edx的byte0为0xff了。所有byte都靠进位进行判断,只要右边没有进位则必然存在byte为0。

继续向下看,xor eax,-1则是将eax(从ecx指向的内存里取得的4字节)取反。然后xor eax,edx,这句的意图是取出执行前面的加法之后的值(add     edx,eax后edx的值)中未改变的那些位,继续,add ecx,4则表示将ecx向后移动4个字节,方便下次进行运算。再之后,一句test eax,81010100h,这个0x81010100就是前面0x7efefeff取反,也就是几个hole的位置为1。再与前面取出来的加法之后的值(add     edx,eax后edx的值)中未改变的那些位相比较:如果结果为0,则表示加法之后的值(add     edx,eax后edx的值)与原始值eax(取出来的原始字符串的4个字节)作比较,并且相对于0x7efefeff中的4个0(hold)的位置上,每一个0的位置(hole)都被改变了(或者相对于0x81010100中4个1(同样是hold的位置)的位置上,每一个1的位置(hole)都被改变了);如果不为0,同理比较,则发现有字节为0。由此看来,与0x81010100进行test就是为了判断从字符串取出来的4个字节与0x7efefeff相加之后的值的那几个hold的位置相对于原始的4个字节中的那几个hole的位置里,哪些hole位置的位是被改变了的。如果每个hole的位置都改变了则test结果为0,表示没有字节为0,否则,则表示有字节为0。

当发现有字节为0时,则应该对取出来的4字节进行逐字节判断哪个字节为0了,如下:

[cpp] view plain copy

mov     eax,[ecx - 4]  

test    al,al                   ; is it byte 0  

je      short byte_0  

test    ah,ah                   ; is it byte 1  

je      short byte_1  

test    eax,00ff0000h           ; is it byte 2  

je      short byte_2  

test    eax,0ff000000h          ; is it byte 3  

je      short byte_3  

jmp     short main_loop         ; taken if bits 24-30 are clear and bit  

; 31 is set  

如上,第一句[ecx-4]的原因是因为ecx在前面加了4,因此要减4重新去开始的4字节,然后逐字节判断哪个字节为0,代码很简单,这里就不详细说明了。这里如果发现了某个字节为0,则跳转到相应的尾部节中,如下:

[cpp] view plain copy

byte_3:  

lea     eax,[ecx - 1]  

mov     ecx,string  

sub     eax,ecx  

ret  

byte_2:  

lea     eax,[ecx - 2]  

mov     ecx,string  

sub     eax,ecx  

ret  

byte_1:  

lea     eax,[ecx - 3]  

mov     ecx,string  

sub     eax,ecx  

ret  

byte_0:  

lea     eax,[ecx - 4]  

mov     ecx,string  

sub     eax,ecx  

ret  

以byte_3为例,也就是取出来的四个字节中,第4个字节为0,前3个字节不为0,于是eax就应该等于ecx-1,然后将ecx重新赋值为字符串的首地址(到这里你应该明白了为啥要有string这个宏了吧)。最后sub eax,ecx则直接获得了字符串的长度。然后ret返回到上层。整个strlen就结束了。

通过前面的分析,我们已经知道了strlen的原理,并且更深刻领略了算法的美妙。我们可以将这个汇编版本的strlen翻译成C语言版,如下:

[cpp] view plain copy

size_t strlen( constchar* str )  

{  

constchar* ptr = str;  

for ( ; ( ( int )ptr & 0x03 ) != 0; ++ptr )  

{  

if ( *ptr == '\0' )  

return ptr - str;  

}  

unsigned int* ptr_d = ( unsigned int* )ptr;  

unsigned int magic = 0x7efefeff;  

while ( true )  

{  

unsigned int bits32 = *ptr_d++;  

if ( ( ( ( bits32 + magic ) ^ ( bits32 ^ -1 ) ) & ~magic ) != 0 ) // bits32 ^ -1 等价于 ~bits32  

{  

ptr = ( constchar* )( ptr_d - 1 );  

if ( ptr[ 0 ] == 0 )  

return ptr - str;  

if ( ptr[ 1 ] == 0 )  

return ptr - str + 1;  

if ( ptr[ 2 ] == 0 )  

return ptr - str + 2;  

if ( ptr[ 3 ] == 0 )  

return ptr - str + 3;  

}  

}  

}   

好了,strlen就差不多分析完了,最后面的C语言版本还可以变化,例如根据字符的编码集,进行特殊化。不过一般是不需要的,通用一些更好。我做了一个测试,将本文开头的C语言版本、最后的C语言版本以及crt的汇编版本的性能进行对比,求相同字符串的长度,求10000000次,开启O2优化,三者平均耗时为:

普通C语言版本:723毫秒

后面的翻译C版本:315毫秒

CRT汇编版本:218毫秒        可见,后两者的性能有一定的提升,这里需要说明一点,crt的strlen函数属于intrinsic函数,所谓intrinsic函数,可以称作为内部函数,这与inline函数有点类似,但是不是inline之意。inline不是强制的,在编译器编译时也是有所区别的。intrinsic函数相当于是在编译器在编译时根据上下文等情况来确定是否将函数代码进行汇编级内联,在内联的同时进行优化,由此既省去了函数调用开销,同时优化也更直接明了。编译器熟悉intrinsic函数的内在功能,很多时候又称为内建函数,因此编译器可以更好的整合及优化,目的只有一个,在特定的环境下,选择最优的方案。就拿strlen来说,例如这样一段代码:

[cpp] view plain copy

int main( int argc, char** argv )  

{  

int len = strlen( argv[ 0 ] );  

printf( "%d", len );  

return 0;  

}  

在debug下禁用优化、release下禁用优化或release下最小化大小(/O1)时,可以强制开启intrinsic内部函数选项(/Oi),这样开启之后,上面的strlen函数将不再调用crt的汇编版本函数,而是直接内嵌到main函数代码里,如下(debug或release下禁用优化并开启内部函数(/Oi)):

[cpp] view plain copy

int len = strlen( argv[ 0 ] );  

0042D8DE  mov         eax,dword ptr [argv]   

0042D8E1  mov         ecx,dword ptr [eax]   

0042D8E3  mov         dword ptr [ebp-0D0h],ecx   

0042D8E9  mov         edx,dword ptr [ebp-0D0h]   

0042D8EF  add         edx,1   

0042D8F2  mov         dword ptr [ebp-0D4h],edx   

0042D8F8  mov         eax,dword ptr [ebp-0D0h]<------   

0042D8FE  mov         cl,byte ptr [eax]             |  

0042D900  mov         byte ptr [ebp-0D5h],cl        | // 逐字节计算  

0042D906  add         dword ptr [ebp-0D0h],1        |  

0042D90D  cmp         byte ptr [ebp-0D5h],0         |  

0042D914  jne         main+38h (42D8F8h) // ---------       

0042D916  mov         edx,dword ptr [ebp-0D0h]   

0042D91C  sub         edx,dword ptr [ebp-0D4h]   

0042D922  mov         dword ptr [ebp-0DCh],edx   

0042D928  mov         eax,dword ptr [ebp-0DCh]   

0042D92E  mov         dword ptr [len],eax   

如果在release下开启最小化大小(/O1)并开启内部函数(/Oi)时,编译后代码如下:

[cpp] view plain copy

int len = strlen( argv[ 0 ] );  

00401000  mov         eax,dword ptr [esp+8]   

00401004  mov         eax,dword ptr [eax]   

00401006  lea         edx,[eax+1]   

00401009  mov         cl,byte ptr [eax]<------  

0040100B  inc         eax                    | // 逐字节计算  

0040100C  test        cl,cl                  |  

0040100E  jne         main+9 (401009h) -------  

00401010  sub         eax,edx   

代码简洁多了,同样没有函数调用开销(其实,你会惊讶的发现,这几句代码正是本文开篇第二个C语言版的strlen的反汇编代码,当然是经过优化后的代码,这里省去了调用开销。其实,本文前面开头的两个strlen,在开启较高优化级别时,编译器也会将这两个函数进行优化内嵌,也就与intrinsic函数一致了。这说明一点,编译器是人性化的,只要能够满足优化的条件,就会果断进行优化)。在开启最小化大小(/O1)优化并开启内部函数(/Oi)优化与release下开启最大化速度(/O2)或完全优化(/Ox)时,产生的代码是一致的。与release下开启最大化速度(/O2)或完全优化(/Ox)时,就算你不开启内部函数(/Oi)优化,编译器同样会将strlen处理掉产生上面的代码。这个跟优化的级别有关,级别高了,自然就会更全面的优化,不管你是否强制设置一些东西。也算是一个人性化设计吧。        要开启某函数进行内部函数优化,可以通过代码来开启,如下:

[cpp] view plain copy

#pragma intrinsic( strlen )  

有开启,自然也有关闭,如下:

[cpp] view plain copy

#pragma function( strlen )  

强制将strlen的优化关闭,这样就算你是最大化速度(/O2)或完全优化(/Ox),照样会调用crt的strlen函数。这两者的具体详细说明,请查阅MSDN,或点击这里。

关于这个intrinsic pragma,MSDN有详细准确的解释,还是英文原文更能体会其本意:

The intrinsic pragma tells the compiler that a function has known behavior. The compiler may call the function and not replace the function call with inline instructions, if it will result in better performance. .........

Programs that use intrinsic functions are faster because they do not have the overhead of function calls but may be larger due to the additional code generated.

对了,不要试图用这两个东西来强制开启或关闭一个普通函数的(/Oi)优化,所谓intrinsic,当然是编译器内定的一些函数,也算是做了一些细节上优化的可选择性吧。如果你不信我的,那你肯定会得到一个警告:

warning C4163: “xxxxx”: 不可用作内部函数.

对于intrinsic的相关优化,编译器处理得比较灵活,这代表它并不是强制性的,如果开启SSE,编译器还会考虑SSE优化,在原理上,知道有这么回事就是了,本文的重点在于如何去挖掘和思考诸多细节。至于具体的内定的函数有哪些,以及有哪些详细说明,请查阅MSDN,或者点击前面的链接。这里就不再累述了,已经写了这么长了。。

与此同时再一次感叹MS的工程师们,细节做得很好。这也值得国内IT行业浮躁环境下的coder们深思。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分