memset 的實現分析

  memset 是 msvcrt 中的一個函數,其做用和用途是顯而易見的,一般是對一段內存進行填充,就其做用自己不具備任何歧義性。但就有人必定要糾結對數組的初始化必定要寫成以下形式:程序員

 

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

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

 

  而認爲以下使用 memset 的寫法不明就裏的被其排斥和拒絕:less

 

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

 

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

 

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

  所以,假設有一個整數數組 a[],若是把全部元素賦值爲 0,能夠用 memset (a, 0, sizeof ( a )); // 這多是 memset 使用中最多見的狀況
oop

  若是把全部元素賦值爲 -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

相關文章
相關標籤/搜索