zoukankan      html  css  js  c++  java
  • 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

  • 相关阅读:
    springboot日志框架
    springboot创建一个可执行的jar
    springboot整合Thymeleaf模板引擎
    springboot自定义SpringApplication启动类
    springboot配置mybatis的mapper路径
    使用@SpringBootApplication注解
    HDU1269 迷宫城堡 —— 强连通分量
    POJ3177 Redundant Paths —— 边双联通分量 + 缩点
    HDU3394 Railway —— 点双联通分量 + 桥(割边)
    UVA796 Critical Links —— 割边(桥)
  • 原文地址:https://www.cnblogs.com/hoodlum1980/p/3505802.html
Copyright © 2011-2022 走看看