什么是严格别名,为什么我们要关心? 第二部分

(或古怪的打字,含糊的行为和对齐方式,哦,天哪!)

朋友,在“ C ++开发人员”课程上启动新线程之前,剩下的时间很少。 现在是时候发布该材料第二部分的译文了,它讲述了双关语的输入。

什么是双关语?

我们到了一个奇怪的地步,为什么我们可能根本需要假名? 通常用于执行双关语打字,tk。 经常使用的方法违反了严格的别名规则。



有时我们想绕过类型系统并将对象解释为另一种类型。 将内存段重新解释为另一种类型称为类型punning pun 。 键入双关语对于需要访问对象的基本表示以查看,传输或操纵提供的数据的任务很有用。 我们可能会遇到使用双关语的典型领域:编译器,序列化,网络代码等。
传统上,这是通过获取对象的地址,将其强制转换为指向我们要解释的类型的指针,然后访问该值或使用别名来实现的。 例如:

int x = 1 ; //   C float *fp = (float*)&x ; //   //  C++ float *fp = reinterpret_cast<float*>(&x) ; //   printf( “%f\n”, *fp ) ; 

如前所述,这是不可接受的别名,这将导致未定义的行为。 但是传统上,编译器并不使用严格的别名规则,而这种类型的代码通常只能工作,而且不幸的是,开发人员习惯于允许这种事情。 一种常见的替代双打类型方法是通过并集(union),该方法在C语言中有效,但会在C ++中导致未定义的行为( 请参见示例 ):

 union u1 { int n; float f; } ; union u1 u; uf = 1.0f; printf( "%d\n”, un ); // UB(undefined behaviour)  C++ “n is not the active member” 

这在C ++中是不可接受的,并且一些人认为联合仅用于实现变量类型,并认为使用联合键入双关语是一种滥用。

如何实施双关语?

在C和C ++中键入双关语的标准加持方法是memcpy。 这似乎有些复杂,但是优化程序需要识别双关语对memcpy的使用,对其进行优化并生成一个寄存器来记录移动。 例如,如果我们知道int64_t与double大小相同:

 static_assert( sizeof( double ) == sizeof( int64_t ) ); // C++17    

我们可以使用memcpy

 void func1( double d ) { std::int64_t n; std::memcpy(&n, &d, sizeof d); //… 

有了足够的优化水平,任何体面的现代编译器都会生成与前面提到的reinterpret_cast方法或join方法相同的代码,以得到双关语。 研究生成的代码,我们看到它仅使用mov寄存器( 示例 )。

双关语类型和数组

但是,如果我们想将一个无符号char数组的双关实现为一系列无符号int,然后对每个无符号int值执行一个运算,该怎么办? 我们可以使用memcpy将无符号的char数组转换为临时的非单int类型。 优化器仍然可以通过memcpy查看所有内容,并优化临时对象和副本,并直接使用基础数据( 例如 ):

 //  ,    int foo( unsigned int x ) { return x ; } // ,  len  sizeof(unsigned int) int bar( unsigned char *p, size_t len ) { int result = 0; for( size_t index = 0; index < len; index += sizeof(unsigned int) ) { unsigned int ui = 0; std::memcpy( &ui, &p[index], sizeof(unsigned int) ); result += foo( ui ) ; } return result; } 

在此示例中,我们采用char*p ,假设它指向几个sizeof(unsigned int)数据片段,将每个数据片段解释为unsigned int ,为pun的每个片段计算foo() ,将其求和,然后返回最终值。

循环主体的程序集显示,优化器将主体转换为对unsigned char基数组的直接访问,并将其作为unsigned int并将其直接添加到eax

 add eax, dword ptr [rdi + rcx] 

相同的代码,但是使用reinterpret_cast实现双关语(违反严格的别名):

 // ,  len  sizeof(unsigned int) int bar( unsigned char *p, size_t len ) { int result = 0; for( size_t index = 0; index < len; index += sizeof(unsigned int) ) { unsigned int ui = *reinterpret_cast<unsigned int*>(&p[index]); result += foo( ui ); } return result; } 

C ++ 20和bit_cast

在C ++ 20中,我们有bit_cast ,它提供了一种简单而安全的解释方法,也可以在constexpr的上下文中使用。

以下是如何使用bit_cast解释float的无符号整数的示例示例 ):

 std::cout << bit_cast<float>(0x447a0000) << "\n" ; //,  sizeof(float) == sizeof(unsigned int) 

如果类型To和From的大小不同,则需要我们使用中间结构。 我们将使用一个结构,该结构包含一个字符数组,该数组的sizeof(unsigned int)sizeof(unsigned int) (假定为4字节无符号int),作为From类型,而unsigned int作为To。

 struct uint_chars { unsigned char arr[sizeof( unsigned int )] = {} ; //  sizeof( unsigned int ) == 4 }; //  len  4 int bar( unsigned char *p, size_t len ) { int result = 0; for( size_t index = 0; index < len; index += sizeof(unsigned int) ) { uint_chars f; std::memcpy( f.arr, &p[index], sizeof(unsigned int)); unsigned int result = bit_cast<unsigned int>(f); result += foo( result ); } return result ; } 

不幸的是,我们需要这种中间类型-这是当前的bit_cast限制。

对齐方式

在前面的示例中,我们看到违反严格的别名规则会导致在优化过程中排除存储。 违反严格的混叠也可能导致违反对齐要求。 C标准和C ++都说对象要符合对齐要求,从而限制了对象可以放置(在内存中)并因此可以访问的位置。 C11第6.2.8节“对象对齐状态”

完整类型的对象具有对齐要求,这些要求对可放置此类型对象的地址施加了限制。 对齐是实现定义的整数值,表示可以放置此对象的连续地址之间的字节数。 对象的类型对这种类型的每个对象都要求有对齐要求:可以使用_Alignas请求更严格的对齐方式。

第1节[basic.align]中的C ++ 17项目标准

对象类型具有对齐要求(6.7.1,6.7.2),该要求对可放置此类型对象的地址施加了限制。 对齐是实现定义的整数值,表示可以放置给定对象的连续地址之间的字节数。 对象类型对这种类型的每个对象都要求对齐要求; 可以使用比对说明符(10.6.2)要求更严格的比对。

C99和C11都明确指出导致指针未对齐的转换是未定义的行为,第6.3.2.3节。 指针说:
指向对象或部分类型的指针可以转换为指向另一个对象或部分类型的指针。 如果生成的指针未针对指针类型正确对齐,则行为未定义。 ...
尽管C ++不太明显,但我认为第1款[basic.align]中的这一句话[basic.align]足够了:
...对象的类型对这种类型的每个对象都要求对齐要求; ...
例子

因此,我们假设:

  • alignof(char)和alignof(int)分别为1和4
  • sizeof(int)是4

因此,将大小为4的char数组解释为int违反严格的别名,如果数组的对齐方式为1或2个字节,也可能违反对齐要求。

 char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; //        1  2  int x = *reinterpret_cast<int*>(arr); // Undefined behavior   

在某些情况下,这可能会导致性能降低或总线错误。 而使用alignas强制对int中的数组进行相同的对齐将防止对齐要求被破坏:

 alignas(alignof(int)) char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; int x = *reinterpret_cast<int*>(arr); 

原子性

对于不平衡访问的另一个意外惩罚是,它违反了某些架构的原子性。 如果x86中的其他线程未对齐,则原子存储可能不会显示为原子。

捕获严格的混叠违规

我们没有很多好的工具来跟踪C ++中的严格别名。 我们拥有的工具将捕获某些违规情况以及某些不正确的加载和存储情况。

gcc使用-fstrict-aliasing-Wstrict-aliasing标志可以捕获某些情况,尽管并非没有误报/麻烦。 例如,以下情况将在gcc中产生警告( example ):

 int a = 1; short j; float f = 1.f; //   ,   TIS ,         printf("%i\n", j = *(reinterpret_cast<short*>(&a))); printf("%i\n", j = *(reinterpret_cast<int*>(&f))); 

尽管他不会遇到这种额外的情况( 例如 ):

 int *p; p=&a; printf("%i\n", j = *(reinterpret_cast<short*>(p))); 

尽管clang解析了这些标志,但它似乎并未真正实现警告。

我们拥有的另一个工具是ASan,它可以捕获未对齐的记录和存储。 尽管它们并非直接违反严格的别名,但这是相当普遍的结果。 例如,以下情况将在使用-fsanitize=address clang进行汇编的过程中生成运行时错误

 int *x = new int[2]; // 8 : [0,7]. int *u = (int*)((char*)x + 6); //     x    *u = 1; //    [6-9] printf( "%d\n", *u ); //    [6-9] 

我推荐的最后一个工具是特定于C ++的,实际上,不仅是一种工具,而且是不允许C风格转换的编码实践, gccclang都将使用-Wold-style-cast对C -Wold-style-cast进行诊断。 -Wold-style-cast 。 这将强制所有未定义的双关语使用reinterpret_cast。 通常, reinterpret_cast应该是对代码进行更彻底分析的信标。
在代码库中搜索reinterpret_cast来执行审核也更加容易。

对于C,我们已经描述了所有工具,还拥有tis-interpreter (静态分析器),可以对程序的大量C语言子集进行详尽的分析,在上一个示例的C版本中,使用-fstrict-aliasing会跳过一种情况( 示例

 int a = 1; short j; float f = 1.0 ; printf("%i\n", j = *((short*)&a)); printf("%i\n", j = *((int*)&f)); int *p; p=&a; printf("%i\n", j = *((short*)p)); 

TIS解释器可以拦截全部三个,下面的示例将TIS内核称为TIS解释器(为简洁起见对输出进行了编辑):

 ./bin/tis-kernel -sa example1.c ... example1.c:9:[sa] warning: The pointer (short *)(& a) has type short *. It violates strict aliasing rules by accessing a cell with effective type int. ... example1.c:10:[sa] warning: The pointer (int *)(& f) has type int *. It violates strict aliasing rules by accessing a cell with effective type float. Callstack: main ... example1.c:15:[sa] warning: The pointer (short *)p has type short *. It violates strict aliasing rules by accessing a cell with effective type int. 

最后是正在开发的TySan 。 该清理程序将类型检查信息添加到影子内存段,并检查访问以确定它们是否违反别名规则。 该工具可能应该能够跟踪所有混叠违规行为,但是在运行时可能会有很大的开销。

结论

我们了解了C和C ++中的别名规则,这意味着编译器希望我们严格遵守这些规则,并接受不履行这些规则的后果。 我们已经了解了一些可以帮助我们识别假名滥用行为的工具。 我们已经看到,别名的通常用法是典型的双关语。 我们还学习了如何正确实施它。

优化器正在逐步改进基于类型的别名分析,并且已经破坏了一些基于严格别名冲突的代码。 我们可以期望优化会变得更好,并破坏更多的代码。

我们有标准的现成兼容方法来解释类型。 有时,对于调试版本,这些方法应该是免费的抽象。 我们有几种检测严重混叠违规的工具,但是对于C ++,它们仅捕获一小部分情况,对于使用tis解释器的C,我们可以跟踪大多数违规。

感谢那些对本文发表评论的人:JF Bastien,Christopher Di Bella,Pascal Quoc,Matt P. Dziubinski,Patrice Roy和Olafur Vaage
当然,最后所有错误均归作者所有。

因此,相当大的材料的翻译工作已经结束,可以在此处阅读其第一部分。 传统上,我们邀请您参加开放日 ,该日由Rambler&Co- Dmitry Shebordaev的技术开发部门负责人于3月14日举行

Source: https://habr.com/ru/post/zh-CN443602/


All Articles