分享

memset 的实现分析

 戴维图书馆 2017-01-24

  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):

 

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 的汇编代码:

 

_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 语言的代码:

 

_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 );

 

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

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多