hoodlum1980 [ 发发 ] 的技术博客

博客园 首页 新随笔 联系 订阅 管理

  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

posted on 2014-01-05 12:32  hoodlum1980  阅读(9967)  评论(3编辑  收藏  举报