memset 的實現分析


  memset 是 msvcrt 中的一個函數,其作用和用途是顯而易見的,通常是對一段內存進行填充,就其作用本身不具有任何歧義性。但就有人一定要糾結對數組的初始化一定要寫成如下形式:

 

  int a[...] = { 0 };

  int a[100] = { 1, 2 };

 

  而認為如下使用 memset 的寫法不明就里的被其排斥和拒絕:

 

  memset(a, 0, sizeof(a));

 

  這種看法首先是毫無道理的,在代碼風格,可讀性,可維護性上根本不構成一個命題,且 memset 在開發中的使用是非常常見的。這種錯誤觀點來自於對代碼風格和語言的僵硬理解,之后我們將看到在編譯器處理后兩者的等效性。

 

  【補充】在討論之前,需要先明確一個基本常識,即 memset 中提供的那個填充值的參數,是以字節為單位填充內存,因此實際的 memset 處理中只把它當作字節處理(即只有 0-7 bit 重要,高位被忽略),將其低位字節擴展成 32 位(例如參數值為 0x12345678,則實際被擴展成 0x78787878),然后用 rep stosd 填充。因此 memset 不能像循環賦值一樣,完成對內存完成 4 bytes 為周期的周期性填充(而只能把所有字節都賦值為相同值),但匯編語言可以。

  因此,假設有一個整數數組 a[],如果把所有元素賦值為 0,可以用 memset (a, 0, sizeof ( a )); // 這可能是 memset 使用中最常見的情況

  如果把所有元素賦值為 -1 ( signed ) / 最大值 (unsigned) , 可以用 memset (a, 0xFF, sizeof ( a ));

  如果要把所有元素賦值為任意一個常數值,則 memset 不能達到要求,需要用高級語言的循環進行賦值。

 

  -- hoodlum1980 on 2014年6月19日 補充。

 

  本文討論的前提條件是:操作系統平台為 windows 系統,編譯器為 VS2005 中的 VC,編譯輸出選項主要為 Release,反匯編工具為 VC 本身和 IDA。下面將給出一些經過實際觀察和分析得到的基本結論,

 

  (1)在數組被聲明時提供初始化列表(且語言上僅能在聲明時提供),其語法定義時對於缺省元素將使用 0 填充。在 MSVC 編譯器的 release 輸出中,將后續元素使用 memset 進行初始化。

 

  (2)對數組用循環初始化時(這里假設數組元素類型為 int),編譯器將其處理為 rep stosd 指令。

 

  這個情況的匯編代碼比較簡單,因此忽略。根據這一點可以看到,不論在代碼風格層面還是運行效率層面,認為使用初始化列表優於 memset 都是一種毫無理由的主觀臆測。事實上,兩者在運行效率上等效,且代碼風格上不存在優劣之分。所以,當程序員對結構體,數組進行初始化時,不需要在這里產生猶豫。后面我們還會看到,對數組用循環的方法初始化,和調用 memset 初始化,在多數條件下的等效性。

 

  (3)memset 的實現。

 

  這里分析 memset 這個函數在匯編語言層面的實現方式。首先,memset 的原型如下:

  

  void* __cdecl memset (void* _Dst, int _Val, size_t _Size);

 

  第二個參數雖然為 int 類型,但是函數針對的目標是字節,所以它實際上提供的是一個字節的值。首先給出該函數的常規實現過程(后面我們將分析在 CPU 支持 sse2 時的分支)的基本結論:

 

  (3.1)如果 _Dst 沒有對齊到 DWORD,則先把前面未對齊部分(1~3 bytes),以字節為單位循環設置。

  (3.2)主要循環部分 rep stosd 串存儲指令,以 DWORD (4 bytes) 為基本單位循環設置。

  (3.3)如果還有一些字節(1~3 bytes)未被設置,則以字節為單位循環設置。

 

  以上是 memset 的方法的過程,后面我們將看到當 CPU 支持 SSE2 時的分支和上述步驟相同,只是第二步中基本單位的粒度更大(128 bit / 16 bytes)。

  下面給出的是 memset 在 IDE 中的匯編代碼,來自於 Micrsoft Visual Studio X\VC\crt\src\intel\memset.asm 的內容(下面的匯編代碼在以字節為單位時使用的是 MOV [EDI], AL, 而在實際編譯結果中是 rep stosb):

 

        CODESEG

    extrn   _VEC_memzero:near
    extrn   __sse2_available:dword

        public  memset
memset proc \
        dst:ptr byte, \
        value:byte, \
        count:dword

        OPTION PROLOGUE:NONE, EPILOGUE:NONE

        .FPO    ( 0, 3, 0, 0, 0, 0 )

        mov     edx,[esp + 0ch] ; edx = "count"
        mov     ecx,[esp + 4]   ; ecx points to "dst"

        test    edx,edx         ; 0?
        jz      short toend     ; if so, nothing to do

        xor     eax,eax
        mov     al,[esp + 8]    ; the byte "value" to be stored

; Special case large block zeroing using SSE2 support
    test    al,al ; memset using zero initializer?
    jne     dword_align
    cmp     edx,0100h ; block size exceeds size threshold?
    jb      dword_align
    cmp     DWORD PTR __sse2_available,0 ; SSE2 supported?
    je      dword_align

    jmp     _VEC_memzero ; use fast zero SSE2 implementation
    ; no return

; Align address on dword boundary
dword_align:

        push    edi             ; preserve edi
        mov     edi,ecx         ; edi = dest pointer

        cmp     edx,4           ; if it's less then 4 bytes
        jb      tail            ; tail needs edi and edx to be initialized

        neg     ecx
        and     ecx,3           ; ecx = # bytes before dword boundary
        jz      short dwords    ; jump if address already aligned

        sub     edx,ecx         ; edx = adjusted count (for later)
adjust_loop:
        mov     [edi],al
        add     edi,1
        sub     ecx,1
        jnz     adjust_loop

dwords:
; set all 4 bytes of eax to [value]
        mov     ecx,eax         ; ecx=0/0/0/value
        shl     eax,8           ; eax=0/0/value/0

        add     eax,ecx         ; eax=0/0val/val

        mov     ecx,eax         ; ecx=0/0/val/val

        shl     eax,10h         ; eax=val/val/0/0

        add     eax,ecx         ; eax = all 4 bytes = [value]

; Set dword-sized blocks
        mov     ecx,edx         ; move original count to ecx
        and     edx,3           ; prepare in edx byte count (for tail loop)
        shr     ecx,2           ; adjust ecx to be dword count
        jz      tail            ; jump if it was less then 4 bytes

        rep     stosd
main_loop_tail:
        test    edx,edx         ; if there is no tail bytes,
        jz      finish          ; we finish, and it's time to leave
; Set remaining bytes

tail:
        mov     [edi],al        ; set remaining bytes
        add     edi,1

        sub     edx,1           ; if there is some more bytes
        jnz     tail            ; continue to fill them

; Done
finish:
        mov     eax,[esp + 8]   ; return dest pointer
        pop     edi             ; restore edi

        ret

toend:
        mov     eax,[esp + 4]   ; return dest pointer

        ret
memset.asm

 

  上面的代碼相對簡單,這里就不詳細解釋了。可以看到有一個名為 _VEC_memset 的標簽(是一個具體函數)在滿足條件時接管了此函數。即當同時滿足:(1)_Val 為 0;(2) CPU 支持 SSE2,(3)_Size 達到某個閾值(這里是256字節)時,memset 將會跳轉到 _VEC_memzero 分支。

  關於 SSE2,我將引用 Intel 的文檔內容簡要介紹如下:

 

  SSE2 全稱是 Streaming SIMD Extention2, SIMD 全稱是 Single-Instruction, Multiple-Data,是 Intel MMX 技術支持的一種單指令多數據運行模型,其目的為提高多媒體和通訊應用程序的性能。

 

  由於多媒體數據處理的特征是,常見在大量的小元素(BYTE,WORD,DWORD 等)組成的連續數據上進行相同的操作,所以可以在一條指令中提高數據吞吐能力來提高效率(即每次把多個數據打包成一組進行相同的並行操作),即 SIMD。(我的解釋性評論,2014年5月3日補充 -- hoodlum1980)

 

  SSE2 在 Pentium 4 和 Intel Xeon 處理器中引入,提高了 3-D 圖形,視頻編碼解碼,語音識別,互聯網,科學技術和工程應用程序的性能。提供 128-bit 的數據類型和相關指令,8 個 128-bit XMM 寄存器(XMM0~XMM7)。后面可以看到,當 CPU 支持 SSE2 時,memset 將采用 SSE2 進行批量設置,每條指令可賦值 16 Bytes。

 

  通過 CPUID.01H (EAX=01H) 指令,如果 EDX.SSE2 [ bit 26 ] = 1,則支持 SSE2 擴展。

 

  memset 是 msvcrt.dll (這個 Dll 有名稱不同的多個版本)中的一個導出函數,但如果寫一個簡單的程序作為觀察,編譯器將不會讓目標程序導入對應的 Dll,而是把 memset 直接插入到目標程序的代碼段。

  下面給出的是 _VEC_memzero 的匯編代碼:

 

; void* _VEC_memzero(void* _Dst, int _Val(=0), size_t _Size); 
 _VEC_memzero    proc near               ; CODE XREF: memset+27j
                                         ; _VEC_memzero+7Dp

 var_10          = dword ptr -10h
 var_C           = dword ptr -0Ch
 var_8           = dword ptr -8
 var_4           = dword ptr -4
 arg_0           = dword ptr  8          ;void*  _Dst;
 arg_8           = dword ptr  10h        ;size_t _Size;

                 push    ebp
                 mov     ebp, esp
                 sub     esp, 10h
                 mov     [ebp+var_4], edi   ; 保護 EDI 寄存器
                 mov     eax, [ebp+arg_0]  ; 以下是計算 EDI = _Dst % 16;
                 cdq    ; 把EAX有符號擴展到 Quadword (EDX:EAX) 
                 mov     edi, eax
                 xor     edi, edx
                 sub     edi, edx
                 and     edi, 0Fh
                 xor     edi, edx
                 sub     edi, edx
                 test    edi, edi
                 jnz     short loc_4085A5;  if(_Dst % 16 != 0) goto...
                 mov     ecx, [ebp+arg_8]
                 mov     edx, ecx
                 and     edx, 7Fh
                 mov     [ebp+var_C], edx
                 cmp     ecx, edx
                 jz      short loc_40858A
                 sub     ecx, edx
                 push    ecx
                 push    eax
                 call    fastzero_I    ; 調用 fastzero_I 進行設置(SSE2)
                 add     esp, 8
                 mov     eax, [ebp+arg_0]
                 mov     edx, [ebp+var_C]

 loc_40858A:                             ; 處理尾端的零散字節
                 test    edx, edx
                 jz      short loc_4085D3
                 add     eax, [ebp+arg_8]
                 sub     eax, edx
                 mov     [ebp+var_8], eax
                 xor     eax, eax
                 mov     edi, [ebp+var_8]
                 mov     ecx, [ebp+var_C]
                 rep stosb
                 mov     eax, [ebp+arg_0]
                 jmp     short loc_4085D3


 loc_4085A5:                             ; 處理未對齊到 128-bit 的首端的零散字節
                 neg     edi
                 add     edi, 10h        ;
                 mov     [ebp+var_10], edi
                 xor     eax, eax
                 mov     edi, [ebp+arg_0] ; EDI = _Dst;
                 mov     ecx, [ebp+var_10]; ECX = 16 - (_Size % 16);
                 rep stosb
                 mov     eax, [ebp+var_10]
                 mov     ecx, [ebp+arg_0]
                 mov     edx, [ebp+arg_8]
                 add     ecx, eax
                 sub     edx, eax
                 push    edx
                 push    0
                 push    ecx
                 call    _VEC_memzero; _Dst 已經對齊,再次調用自身
                 add     esp, 0Ch
                 mov     eax, [ebp+arg_0]

 loc_4085D3:                             ; CODE XREF: _VEC_memzero+41j
                                         ; _VEC_memzero+58j
                 mov     edi, [ebp+var_4]
                 mov     esp, ebp
                 pop     ebp
                 retn
_VEC_memzero

 

  上面的代碼,和前面提到的三部是基本一致的。但它主要是完成(3.1)和(3.3)部分,對應與(3.1)為處理不能達到對齊粒度(16 Bytes)的那些零散字節(1~15 Bytes),對應於(3.3)是處理結尾的零散字節(1~127 Bytes)。中間已經對齊到 oword(這里我將稱其為八字,由 16 bytes 組成)的部分,是通過調用 fastzero_I (其處理的內存塊以 128 bytes 為一個基本單位循環處理,即循環體每次采用連續 8 條指令設置 128 Bytes)來完成的。

  下面先給出上面的匯編代碼翻譯到 C 語言的代碼:

 

void* _VEC_memzero(void* _Dst, int _Val, size_t _Size)
{
    int remain, count, i;
    BYTE *pBytes;
    
    //(2.1)處理起始位置未對齊到 128-bit 的字節;
    remain = ((int)_Dst) % 16;
    if(remain != 0)
    {
        count = 16 - remain1;

        pBytes = (BYTE*)_Dst;
        for(i = 0; i < count; i++)
        {
            pBytes[i] = 0;
        }

        _VEC_memzero(pBytes + count, 0, _Size - count);
        return _Dst;
    }

    remain = _Size & 127;

    //(2.2)利用 SSE2 擴展快速初始化
    if(remain != _Size)
    {
        fastzero_I(_Dst, _Size);
    }

    //(2.3)處理結尾剩余的字節
    if(remain != 0)
    {
        pBytes = (BYTE*)(_Dst) + _Size - remain;
        for(i = 0; i < remain; i++)
        {
            pBytes[i] = 0;
        }
    }
    return _Dst;
}
_VEC_memzero.c

 

  上面的代碼,和使用 rep stosd 的方式相同,只是需要地址對齊的基本單位粒度更大。下面給出實現了的(3.2)的 fastzero_I 函數的匯編代碼。可以看到這個函數也是使用循環來處理的,假設我們把 oword (128-bit,16Bytes)看做一行,則下面的循環每次處理 8 行(128 bytes)。

 

  這是一種擴充循環體的寫法,加大跳轉之間的跨度,以減小因跳轉帶來的性能懲罰,提高 CPU 流水線效率。當然,以現在的 CPU 技術來說,程序員或許不必顯示的這樣寫,CPU 執行時也可能有能力得到相同的優化結果。(2014年5月3日補充 --hoodlum1980)

 

  因為此函數沒有觸碰 EAX,所以認為其原型為 void fastzero_I ( void* _Dst, size_t _Size );

 

;
; void fastzero_I(void* _Dst, size_t _Size);
;

fastzero_I      proc near

 var_4           = dword ptr -4
 arg_0           = dword ptr  8
 arg_4           = dword ptr  0Ch

                 push    ebp
                 mov     ebp, esp
                 sub     esp, 4
                 mov     [ebp+var_4], edi
                 mov     edi, [ebp+arg_0]
                 mov     ecx, [ebp+arg_4]
                 shr     ecx, 7
                 pxor    xmm0, xmm0
                 jmp     short loc_408514

                 lea     esp, [esp+0]
                 nop

 loc_408514:                             ; CODE XREF: fastzero_I+16j
                                         ; fastzero_I+4Ej
                 movdqa  oword ptr [edi], xmm0
                 movdqa  oword ptr [edi+10h], xmm0
                 movdqa  oword ptr [edi+20h], xmm0
                 movdqa  oword ptr [edi+30h], xmm0
                 movdqa  oword ptr [edi+40h], xmm0
                 movdqa  oword ptr [edi+50h], xmm0
                 movdqa  oword ptr [edi+60h], xmm0
                 movdqa  oword ptr [edi+70h], xmm0
                 lea     edi, [edi+80h]
                 dec     ecx
                 jnz     short loc_408514
                 mov     edi, [ebp+var_4]
                 mov     esp, ebp
                 pop     ebp
                 retn
fastzero_I.asm

 

  上面的代碼中,ECX 和 EDI 寄存器依然作為循環次數和目標地址索引來使用,和串操作中的用法相同,只是這里用的是 movdqa 指令,所以需要編譯器“手工”更新 ECX 和 EDI 寄存器。

  同時,可以看出在 _VEC_memzero 中調用 fastzero_I 的幾個前提條件是:

  (a).CPU支持 SSE2(因為使用了 SSE2 擴展的指令和寄存器)。

  (b)._Dst 已經對齊到 16-byte,即需滿足 _Dst & 0xF = 0。否則將引發 (GP#, general-protection) 異常。

  (c)._Size 大於等於 128 bytes。(因為 fastzero_I 中的循環體每次設置 128 Bytes)。

 

  總結上面的代碼,可以得到如下結論:

 

  memset 在常規條件下以 DWORD 為粒度對內存設置,在特定條件下(當要對內存初始化為 0 ,且需要初始化的內存達到某個閾值,且 CPU 支持 SSE2),則使用 SSE2 特性進行快速初始化。

 

  (4)總結:

  (3.1)對數組使用初始化列表,或 memset 兩者在底層上可能等效。(msvc編譯器將前者處理為后者)。

  (3.2)對數組用循環初始化,和使用 memset 初始化相比,很有可能等效。即使不等效(memset 調用了 SSE2 擴展),也不可能達到成為一個優化命題和關注點。

  (3.3)如果一定要說有點區別,那就是如果是對一個整數數組用初始化列表或者循環初始化,那么編譯器不需要考慮地址對齊的問題(因為編譯器必然把數組分配到對齊的地址),而 memset 則需要考慮傳入的地址是否已對齊到某個基本粒度,並對此未對齊部分作處理。

  (3.4)當對一個隨機數據組成的內存塊進行清零操作,memset 看起來仿佛是唯一正確的可選方式(如果所在平台無此函數,則可以用手寫循環替代)。聲明數組時提供初始化列表,聲明后再調用 memset 或者使用循環初始化(顯然,在能夠使用 memset 時,循環寫法在高級語言層面不如前者簡潔),無論是代碼規范還是性能層面,這些寫法都不存在值得強調的絕對優劣關系。也就是說,“盡可能避免使用 memset ”這種說法是一種無根據、不負責任、臆測性的個人主觀結論。(2014年5月3日 補充 -- hoodlum1980)

 

  所以綜上,我認為糾纏哪個寫法正確或者更正確是毫無意義的。例如數組的聲明位置,有人認為應該采用另其生存周期盡可能短的原則,而把數組聲明在生命周期更小的循環體中:

 

  while(scanf(...) != EOF)

  {

    int a[1000] = 0;

    ...

  }

  這個問題同樣不成為一個值得討論的命題(編譯器在轉換時,將函數臨時變量的分配和釋放時機集中發生在函數的起始和返回,這是自然的處理方式)。生命周期更短的變量,反而因此而不利於調試。這里一個主要問題在於變量的聲明和使用越接近則對程序員越有利,因此C++等其他語言都已經去除了變量必須在函數開始位置全部聲明的限制。

 

  可以看到,編譯器在優化時很聰明,乃至於會超出我們的預期。以至於為了寫出能夠觀察編譯器行為的測試代碼,有時你不得不動點腦筋。例如,如果寫一個函數對數據進行循環的初始化,則編譯器會把它內聯。如果你寫了一些在編譯器看來沒有用處的代碼和變量,則編譯器會把它們全都去掉。有些局部變量,也可能不會出現在棧上(被編譯器優化掉或者暫存於寄存器)。

 

  例如,除數為常量的除法或取余,會被轉化為整數乘法和移位操作(如果需要移位的話),例如 x = y / 10 會被等效為:

  x = ( y * 0x 6666 6667) >> 34;

 

  例如,計算 x = y * 9 + 17;

  lea eax, [ecx + ecx * 8 + 11h]

 

  (5)參考資料:

  (5.1)Intel 64 and IA-32 Architectures Software Developer's Manual Volume 1: Basic Architecture;

  (5.2)Source Code Optimization. (Felix von Leitner, Code Blau GmbH), October 2009;


  【補充討論】

  ZeroMemory / RtlZeroMemory 宏(分別在 <winbase.h> 和 <winnt.h> 中定義)的定義是調用 memset 函數。

  SecureZeroMemory / RtlSecureZeroMemory 宏為一個強制 inline 函數,目的是為了保證不會被編譯器優化掉。在 MSDN 中舉了下面的例子來說明這樣做的意義。下面的代碼片段范例來自於 MSDN:

  如果下面的代碼中使用 ZeroMemory,由於編譯器認為 szPassword 在結束生命周期前沒有被任何代碼讀取,所以可能會把 ZeroMemory 完全優化掉。這樣密碼內容將會遺留在棧上,導致風險。

 

// 以下代碼來自於 MSDN 文檔:

WCHAR szPassword[MAX_PATH];

// Retrieve the password
if (GetPasswordFromUser(szPassword, MAX_PATH))    
    UsePassword(szPassword);

// Clear the password from memory
SecureZeroMemory(szPassword, sizeof(szPassword));

 

 

  --hoodlum1980 2014-6-19 補充。


  參考資料:
  (1)SecureZeroMemory(@MSDN), ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.WIN32COM.v10.en/memory/base/securezeromemory.htm


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM