当问到C语言中常见的未定义行为时,人们有时会提到严格的混叠规则。 他们在谈论什么?


当前回答

从技术上讲,在c++中,严格的混叠规则可能永远都不适用。

注意indirection(*运算符)的定义:

一元*运算符执行间接操作:它所对应的表达式 是指向对象类型的指针,还是指向对象类型的指针 函数类型,结果是指向对象或的左值 表达式所指向的函数。

同样来自glvalue的定义

glvalue是一个表达式,其求值决定的标识 一个对象,(…剪)

因此,在任何定义良好的程序跟踪中,glvalue都指向对象。所以所谓的严格混叠规则并不适用。这可能不是设计师想要的。

其他回答

作为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条件从汇编代码中完全消失了。

这是严格的混叠规则,可以在c++ 03标准的3.10节中找到(其他答案提供了很好的解释,但没有一个提供了规则本身):

If a program attempts to access the stored value of an object through an lvalue of other than one of the following types the behavior is undefined: the dynamic type of the object, a cv-qualified version of the dynamic type of the object, a type that is the signed or unsigned type corresponding to the dynamic type of the object, a type that is the signed or unsigned type corresponding to a cv-qualified version of the dynamic type of the object, an aggregate or union type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union), a type that is a (possibly cv-qualified) base class type of the dynamic type of the object, a char or unsigned char type.

c++ 11和c++ 14的措辞(强调更改):

If a program attempts to access the stored value of an object through a glvalue of other than one of the following types the behavior is undefined: the dynamic type of the object, a cv-qualified version of the dynamic type of the object, a type similar (as defined in 4.4) to the dynamic type of the object, a type that is the signed or unsigned type corresponding to the dynamic type of the object, a type that is the signed or unsigned type corresponding to a cv-qualified version of the dynamic type of the object, an aggregate or union type that includes one of the aforementioned types among its elements or non-static data members (including, recursively, an element or non-static data member of a subaggregate or contained union), a type that is a (possibly cv-qualified) base class type of the dynamic type of the object, a char or unsigned char type.

有两个变化很小:glvalue代替了lvalue,并澄清了聚合/并集的情况。

第三个变化提供了更强的保证(放宽强混叠规则):类似类型的新概念现在可以安全地进行混叠。


还有C的措辞(C99;Iso / iec 9899:1999 6.5/7;在ISO/IEC 9899:2011§6.5¶7中使用了完全相同的措辞:

An object shall have its stored value accessed only by an lvalue expression that has one of the following types 73) or 88): a type compatible with the effective type of the object, a qualified version of a type compatible with the effective type of the object, a type that is the signed or unsigned type corresponding to the effective type of the object, a type that is the signed or unsigned type corresponding to a qualified version of the effective type of the object, an aggregate or union type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union), or a character type. 73) or 88) The intent of this list is to specify those circumstances in which an object may or may not be aliased.

我找到的最好的解释是Mike Acton的《Understanding Strict Aliasing》。本文主要关注PS3的开发,但这基本上只是GCC的工作。

摘自文章:

严格混叠是C(或c++)编译器的一个假设,即指向不同类型对象的指针的解引用永远不会指向相同的内存位置(即相互混叠)。

所以基本上,如果你有一个int*指向一些包含int型的内存,然后你把一个float*指向那个内存,并把它用作浮点数,你就违反了规则。如果你的代码不尊重这一点,那么编译器的优化器很可能会破坏你的代码。

该规则的例外是一个char*,它被允许指向任何类型。

严格的混叠不只是指指针,它也影响引用,我为boost开发者wiki写了一篇关于它的论文,它很受欢迎,我把它变成了我的咨询网站上的一个页面。它完全解释了它是什么,为什么它让人们如此困惑,以及如何应对它。严格的混叠白皮书。它特别解释了为什么联合对于c++来说是危险的行为,以及为什么使用memcpy是唯一可以在C和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.

最后是目前正在开发中的泰生。该杀菌剂在阴影内存段中添加类型检查信息,并检查访问是否违反混叠规则。该工具可能能够捕获所有别名违规,但可能会有很大的运行时开销。