• 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 XVCcrtsrcintelmemset.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

  • 相关阅读:
    tyvj 1031 热浪 最短路
    【bzoj2005】 [Noi2010]能量采集 数学结论(gcd)
    hdu 1394 Minimum Inversion Number 逆序数/树状数组
    HDU 1698 just a hook 线段树,区间定值,求和
    ZeptoLab Code Rush 2015 C. Om Nom and Candies 暴力
    ZeptoLab Code Rush 2015 B. Om Nom and Dark Park DFS
    ZeptoLab Code Rush 2015 A. King of Thieves 暴力
    hdoj 5199 Gunner map
    hdoj 5198 Strange Class 水题
    vijos 1659 河蟹王国 线段树区间加、区间查询最大值
  • 原文地址:https://www.cnblogs.com/hoodlum1980/p/3505802.html
Copyright © 2020-2023  润新知