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
上面的代码相对简单,这里就不详细解释了。可以看到有一个名为 _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
上面的代码,和前面提到的三部是基本一致的。但它主要是完成(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; }
上面的代码,和使用 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
上面的代码中,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