当问到C语言中常见的未定义行为时,人们有时会提到严格的混叠规则。 他们在谈论什么?
当前回答
Note
本文节选自我的文章《什么是严格的混叠规则,我们为什么要在意?》
什么是严格混叠?
In C and C++ aliasing has to do with what expression types we are allowed to access stored values through. In both C and C++ the standard specifies which expression types are allowed to alias which types. The compiler and optimizer are allowed to assume we follow the aliasing rules strictly, hence the term strict aliasing rule. If we attempt to access a value using a type not allowed it is classified as undefined behavior (UB). Once we have undefined behavior all bets are off, the results of our program are no longer reliable.
不幸的是,在严格的混叠违规情况下,我们经常会得到我们期望的结果,这使得未来版本的编译器在进行了新的优化后可能会破坏我们认为有效的代码。这是不可取的,理解严格的混叠规则以及如何避免违反它们是一个有价值的目标。
为了更好地理解我们为什么要关心这个问题,我们将讨论在违反严格的混叠规则时出现的问题,类型双关,因为在类型双关中使用的常见技术经常违反严格的混叠规则,以及如何正确地输入双关。
初步的例子
让我们看一些例子,然后我们可以讨论标准到底说了什么,检查一些进一步的例子,然后看看如何避免严格的混叠和捕捉我们错过的违规。下面是一个不应该感到惊讶的例子(活生生的例子):
int x = 10;
int *ip = &x;
std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";
我们有一个int*指向被int占用的内存,这是一个有效的混叠。优化器必须假设通过ip赋值可以更新x占用的值。
下一个例子显示了导致未定义行为的别名(现场示例):
int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *i;
}
int main() {
int x = 0;
std::cout << x << "\n"; // Expect 0
x = foo(reinterpret_cast<float*>(&x), &x);
std::cout << x << "\n"; // Expect 0?
}
In the function foo we take an int* and a float*, in this example we call foo and set both parameters to point to the same memory location which in this example contains an int. Note, the reinterpret_cast is telling the compiler to treat the expression as if it had the type specified by its template parameter. In this case we are telling it to treat the expression &x as if it had type float*. We may naively expect the result of the second cout to be 0 but with optimization enabled using -O2 both gcc and clang produce the following result:
0
1
这可能不是预期的,但完全有效,因为我们调用了未定义的行为。浮点数不能有效地别名int对象。因此,优化器可以假设取消引用i时存储的常量1将是返回值,因为通过f进行存储不能有效地影响int对象。在编译器资源管理器中插入代码显示这正是正在发生的事情(现场示例):
foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 0
mov eax, 1
ret
使用基于类型的别名分析(TBAA)的优化器假设将返回1,并直接将常量值移动到携带返回值的寄存器eax中。TBAA使用语言规则来定义允许别名的类型,以优化加载和存储。在这种情况下,TBAA知道浮点数不能作为int的别名,并优化了i的负载。
现在来看规则手册
标准到底说我们可以做什么,不可以做什么?标准语言并不简单,因此对于每一项,我将尝试提供演示其含义的代码示例。
C11标准是怎么说的?
C11标准在第6.5节第7段中的表述如下:
对象的存储值只能由具有以下类型之一的左值表达式访问: -与对象的有效类型兼容的类型,
int x = 1;
int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type int which is compatible with int
-与对象的有效类型兼容的类型的限定版本,
int x = 1;
const int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type const int which is compatible with int
-与对象的有效类型相对应的有符号或无符号类型,
int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to
// the effective type of the object
Gcc /clang有一个扩展,允许将无符号int*赋值给int*,即使它们是不兼容的类型。
-有符号或无符号类型对应于对象有效类型的限定版本,
int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type const unsigned int which is a unsigned type
// that corresponds with to a qualified version of the effective type of the object
-在其成员中包含前述类型之一的聚合或联合类型(递归地包括子聚合或包含的联合的成员),或
struct foo {
int x;
};
void foobar( struct foo *fp, int *ip ); // struct foo is an aggregate that includes int among its members so it
// can alias with *ip
foo f;
foobar( &f, &f.x );
-字符类型。
int x = 65;
char *p = (char *)&x;
printf("%c\n", *p ); // *p gives us an lvalue expression of type char which is a character type.
// The results are not portable due to endianness issues.
c++ 17标准草案说了什么
c++ 17标准草案在[基本。第11段说:
如果程序试图通过非下列类型之一的glvalue访问对象的存储值,则行为未定义
(11.1) -对象的动态类型,
void *p = malloc( sizeof(int) ); // We have allocated storage but not started the lifetime of an object
int *ip = new (p) int{0}; // Placement new changes the dynamic type of the object to int
std::cout << *ip << "\n"; // *ip gives us a glvalue expression of type int which matches the dynamic type
// of the allocated object
(11.2) -对象的动态类型的cv限定版本,
int x = 1;
const int *cip = &x;
std::cout << *cip << "\n"; // *cip gives us a glvalue expression of type const int which is a cv-qualified
// version of the dynamic type of x
(11.3) -类似于对象的动态类型(如7.5中定义)的类型,
(11.4) -与对象的动态类型相对应的有符号或无符号类型,
// Both si and ui are signed or unsigned types corresponding to each others dynamic types
// We can see from this godbolt(https://godbolt.org/g/KowGXB) the optimizer assumes aliasing.
signed int foo( signed int &si, unsigned int &ui ) {
si = 1;
ui = 2;
return si;
}
(11.5) -有符号或无符号类型,对应于对象动态类型的cv限定版本,
signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing
(11.6)—在其元素或非静态数据成员中包含上述类型之一的聚合或联合类型(递归地包括子聚合或包含的联合的元素或非静态数据成员),
struct foo {
int x;
};
// Compiler Explorer example(https://godbolt.org/g/z2wJTC) shows aliasing assumption
int foobar( foo &fp, int &ip ) {
fp.x = 1;
ip = 2;
return fp.x;
}
foo f;
foobar( f, f.x );
(11.7) -对象的动态类型的基类类型(可能是cv限定的),
struct foo { int x; };
struct bar : public foo {};
int foobar( foo &f, bar &b ) {
f.x = 1;
b.x = 2;
return f.x;
}
(11.8) - char、unsigned char或std::byte类型。
int foo( std::byte &b, uint32_t &ui ) {
b = static_cast<std::byte>('a');
ui = 0xFFFFFFFF;
return std::to_integer<int>( b ); // b gives us a glvalue expression of type std::byte which can alias
// an object of type uint32_t
}
值得注意的是,signed char不包括在上面的列表中,这与C语言表示的字符类型有显著区别。
什么是双关语类型
说到这里,我们可能会想,为什么要用别名?答案通常是输入双关,通常使用的方法违反严格的混叠规则。
有时我们想绕过类型系统,将对象解释为不同的类型。这被称为类型双关语,将一段内存重新解释为另一种类型。类型双关语对于希望访问对象的底层表示以查看、传输或操作的任务非常有用。我们发现使用类型双关语的典型领域是编译器、序列化、网络代码等等……
传统上,这是通过获取对象的地址,将其转换为我们想要重新解释它的类型的指针,然后访问值,或者换句话说,通过别名来实现的。例如:
int x = 1;
// In C
float *fp = (float*)&x; // Not a valid aliasing
// In C++
float *fp = reinterpret_cast<float*>(&x); // Not a valid aliasing
printf( "%f\n", *fp );
正如我们前面所看到的,这不是一个有效的别名,因此我们正在调用未定义的行为。但是传统的编译器并没有利用严格的混叠规则,这种类型的代码通常只是工作,不幸的是开发人员已经习惯了这样做。一种常见的类型双关替代方法是通过联合,这在C中有效,但在c++中未定义行为(参见现场示例):
union u1
{
int n;
float f;
};
union u1 u;
u.f = 1.0f;
printf( "%d\n", u.n ); // UB in C++ n is not the active member
这在c++中是无效的,一些人认为联合的目的仅仅是为了实现变体类型,并认为将联合用于类型双关语是一种滥用。
我们如何正确地输入双关?
在C和c++中,类型双关语的标准方法是memcpy。这可能看起来有点笨重,但优化器应该识别出memcpy用于类型双关语的使用,并对其进行优化,并生成一个寄存器来注册移动。例如,如果我们知道int64_t的大小与double相同:
static_assert( sizeof( double ) == sizeof( int64_t ) ); // C++17 does not require a message
我们可以使用memcpy:
void func1( double d ) {
std::int64_t n;
std::memcpy(&n, &d, sizeof d);
//...
在足够的优化级别上,任何像样的现代编译器都会生成与前面提到的reinterpret_cast方法或用于类型双关的联合方法相同的代码。检查生成的代码,我们看到它只使用寄存器mov(编译器资源管理器实例)。
c++ 20和bit_cast
在c++ 20中,我们可以获得bit_cast(实现可在提案链接中获得),它提供了一种简单而安全的方式来使用type-pun,并且可以在constexpr上下文中使用。
下面是一个例子,如何使用bit_cast输入双关的unsigned int浮点数,(看现场):
std::cout << bit_cast<float>(0x447a0000) << "\n"; //assuming sizeof(float) == sizeof(unsigned int)
在To和From类型没有相同大小的情况下,需要使用中间结构体15。我们将使用一个包含sizeof(unsigned int)字符数组(假设为4字节unsigned int)的结构体作为From类型,使用unsigned int作为to类型:
struct uint_chars {
unsigned char arr[sizeof( unsigned int )] = {}; // Assume sizeof( unsigned int ) == 4
};
// Assume len is a multiple of 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++中,我们没有很多好的工具来捕获严格的混叠,我们拥有的工具将捕获一些违反严格混叠的情况以及一些加载和存储不对齐的情况。
gcc使用-fstrict-aliasing和-Wstrict-aliasing标志可以捕获一些情况,尽管并非没有错误的阳性/阴性。例如,以下情况将在gcc中生成一个警告(参见现场):
int a = 1;
short j;
float f = 1.f; // Originally not initialized but tis-kernel caught
// it was being accessed w/ an indeterminate value below
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 bytes: [0,7].
int *u = (int*)((char*)x + 6); // regardless of alignment of x this will not be an aligned address
*u = 1; // Access to range [6-9]
printf( "%d\n", *u ); // Access to range [6-9]
我要推荐的最后一个工具是c++专用的,不是严格意义上的工具,而是一种编码实践,不允许C风格的强制转换。gcc和clang都将使用- word -style-cast生成c风格强制转换的诊断结果。这将强制任何未定义的类型双关语使用reinterpret_cast,通常reinterpret_cast应该是一个标志,以便进一步检查代码。在代码库中搜索reinterpret_cast以执行审计也更容易。
对于C语言,我们已经涵盖了所有的工具,我们还有tis-interpreter,这是一个静态分析器,可以详尽地分析C语言的一个大子集的程序。给出前面示例的C版本,其中使用-fstrict-aliasing会遗漏一个case(请现场查看)
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-interpeter能够捕获所有三个,下面的例子调用了tis-kernel作为tis-interpreter(为简洁起见,输出经过了编辑):
./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.
最后是目前正在开发中的泰生。该杀菌剂在阴影内存段中添加类型检查信息,并检查访问是否违反混叠规则。该工具可能能够捕获所有别名违规,但可能会有很大的运行时开销。
其他回答
作为Doug T.已经写过的附录,在这里 是一个简单的测试用例,可能会触发GCC:
check.c
#include <stdio.h>
void check(short *h,long *k)
{
*h=5;
*k=6;
if (*h == 5)
printf("strict aliasing problem\n");
}
int main(void)
{
long k[1];
check((short *)k,k);
return 0;
}
编译gcc -O2 -o check check.c。 通常(我尝试过的大多数gcc版本)这会输出“严格的混叠问题”,因为编译器假设“h”不能与“check”函数中的“k”地址相同。因此,编译器会优化if (*h == 5),并始终调用printf。
对于那些感兴趣的人,这里有x64汇编代码,由gcc 4.6.3生成,运行在ubuntu 12.04.2的x64上:
movw $5, (%rdi)
movq $6, (%rsi)
movl $.LC0, %edi
jmp puts
所以if条件从汇编代码中完全消失了。
严格的混叠不只是指指针,它也影响引用,我为boost开发者wiki写了一篇关于它的论文,它很受欢迎,我把它变成了我的咨询网站上的一个页面。它完全解释了它是什么,为什么它让人们如此困惑,以及如何应对它。严格的混叠白皮书。它特别解释了为什么联合对于c++来说是危险的行为,以及为什么使用memcpy是唯一可以在C和c++之间移植的修复程序。希望这对你有帮助。
从技术上讲,在c++中,严格的混叠规则可能永远都不适用。
注意indirection(*运算符)的定义:
一元*运算符执行间接操作:它所对应的表达式 是指向对象类型的指针,还是指向对象类型的指针 函数类型,结果是指向对象或的左值 表达式所指向的函数。
同样来自glvalue的定义
glvalue是一个表达式,其求值决定的标识 一个对象,(…剪)
因此,在任何定义良好的程序跟踪中,glvalue都指向对象。所以所谓的严格混叠规则并不适用。这可能不是设计师想要的。
根据C89的基本原理,标准的作者不想要求编译器给出如下代码:
int x;
int test(double *p)
{
x=5;
*p = 1.0;
return x;
}
应该要求在赋值和返回语句之间重新加载x的值,以便允许p指向x的可能性,而对*p的赋值可能因此改变x的值。编译器应该有权假定在上述情况下不会出现混叠的概念是没有争议的。
不幸的是,C89的作者写规则的方式,如果从字面上读,甚至会使下面的函数调用未定义行为:
void test(void)
{
struct S {int x;} s;
s.x = 1;
}
because it uses an lvalue of type int to access an object of type struct S, and int is not among the types that may be used accessing a struct S. Because it would be absurd to treat all use of non-character-type members of structs and unions as Undefined Behavior, almost everyone recognizes that there are at least some circumstances where an lvalue of one type may be used to access an object of another type. Unfortunately, the C Standards Committee has failed to define what those circumstances are.
大部分问题是缺陷报告#028的结果,它询问了程序的行为,如:
int test(int *ip, double *dp)
{
*ip = 1;
*dp = 1.23;
return *ip;
}
int test2(void)
{
union U { int i; double d; } u;
return test(&u.i, &u.d);
}
缺陷报告#28指出,程序调用了未定义行为,因为写入类型为“double”的联合成员并读取类型为“int”的联合成员的操作调用了实现定义的行为。这样的推理是毫无意义的,但却形成了有效类型规则的基础,这些规则不必要地使语言复杂化,而对解决原始问题毫无帮助。
解决原始问题的最好办法可能是治疗 关于规则目的的脚注,就像它是规范的一样,并作出 除非实际涉及使用别名的冲突访问,否则该规则不可执行。假设是这样的:
void inc_int(int *p) { *p = 3; }
int test(void)
{
int *p;
struct S { int x; } s;
s.x = 1;
p = &s.x;
inc_int(p);
return s.x;
}
在inc_int中没有冲突,因为所有通过*p访问的存储都是使用int类型的左值完成的,在test中也没有冲突,因为p明显地派生于结构体S,并且在下次使用S时,所有通过p访问的存储都已经发生了。
如果代码稍微改变一下……
void inc_int(int *p) { *p = 3; }
int test(void)
{
int *p;
struct S { int x; } s;
p = &s.x;
s.x = 1; // !!*!!
*p += 1;
return s.x;
}
这里,p和对s.x的访问在被标记的行上存在别名冲突,因为在执行时存在另一个引用,该引用将用于访问相同的存储。
如果缺陷报告028说原始示例调用UB是因为两个指针的创建和使用之间有重叠,那么事情就会变得更清楚,而不必添加“有效类型”或其他类似的复杂性。
通过指针强制转换(而不是使用联合)的类型双关是打破严格混叠的一个主要例子。