1. 程式人生 > >【揭祕VC CRT庫Intel模組】-- strlen

【揭祕VC CRT庫Intel模組】-- strlen

        既然是開篇後的第一篇,就先來一個簡單且實用的函式吧,以增強你我的信心,然後再一步一步到複雜,這樣從前至後,也就很順其自然了。

        還記得初學C的時候,對於字串操作一類函式的記憶顯得尤為深刻,各種考試會考strlen、strlen等函式的實現,到了畢業找工作,很多公司的筆試題裡,也包含有strlen、strcpy等函式的實現。可見字串操作類函式是受到了老師和公司出題者的青睞啊。那麼本文就來研究一下strlen這個函式吧!

        可能你這時已經在BS我了,心想就這麼個東西,還需要研究的麼。我能瞬間完成,於是你寫下了這段程式碼:

int strlen( const char* str )
{
    int length = 0;
    while ( *str++ )
        ++length;
    return ( length );
}

         哇!你還真快,真的瞬間寫下了這個簡潔精煉的strlen,不錯,你的C語言考題過關了,公司筆試也過了,值得恭喜。但是,似乎這麼快就解決了問題,那本文要怎麼進行下去呢?那就先分析一下你瞬間秒殺出來的這個strlen吧,她簡直太完美了,和MS的工程師們寫得如出一轍,總體看下來也就幾行程式碼完事,那麼,為啥這麼幾行就能解決問題?還有沒有更優的方案?你靈機一動,又瞬間想出一種:

int strlen( const char* str )
{
    const char* ptr = str;
    while ( *str++ )
        ;
    return ( str - ptr - 1 );
}

       所謂程式碼簡短不一定就是最優的,當然這裡不能扯到軟體工程裡去了,我們可以看出這兩種實現,str++是逐位元組向後移動的,時間複雜度都是O(n),所以這個strlen可以很簡單的完成,那麼更優的方案是什麼呢?試想,如果能夠幾個位元組一跳,不是能夠更快的完成求長度,不就降低了複雜度?先拭目以待吧。

        本系列是為了剖析crt庫中intel模組下的那些函式的,那麼我們去找找那裡面有沒有strlen的實現,呀!居然找到了,它就位於VC/crt/src/intel/strlen.asm裡。開啟看看,咦,有點暈。不過最亮眼的就是,在前面的註釋裡,MS的工程師們寫了個“註釋版”的strlen,與你前面實現的strlen簡直是一摸一樣的。可是,它是註釋版的,不會編譯程序序執行。那麼繼續看下面的彙編實現,程式碼如下:

        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本身也就是一個引數。其他引數,看上面的英文註釋應該很簡單了,這裡不作解釋。你也可以點選這裡查閱。

        繼續向下,關注這三句:

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*)。假如程式碼是這樣的:

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節:

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一進來就檢查是否對齊,那麼不對齊的情況是什麼時候呢?如下:

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的核心。這裡用了很巧妙的演算法,先分析前半部分程式碼:

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了,如下:

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,則跳轉到相應的尾部節中,如下:

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語言版,如下:

size_t strlen( const char* str )
{
    const char* 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 = ( const char* )( 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來說,例如這樣一段程式碼:

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)):

    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)時,編譯後代碼如下:

    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處理掉產生上面的程式碼。這個跟優化的級別有關,級別高了,自然就會更全面的優化,不管你是否強制設定一些東西。也算是一個人性化設計吧。
        要開啟某函式進行內部函式優化,可以通過程式碼來開啟,如下:

#pragma intrinsic( strlen )

        有開啟,自然也有關閉,如下:

#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們深思。

        好了,本文到此結束,歡迎交流指導。thks~

        ****************如需轉載,請註明出處:http://blog.csdn.net/masefee,謝謝**********************