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


当前回答

在阅读了许多答案后,我觉得有必要补充一些东西:

严格的混叠(我将在后面描述)很重要,因为:

内存访问可能是昂贵的(就性能而言),这就是为什么数据在写回物理内存之前要在CPU寄存器中进行操作。 如果两个不同CPU寄存器中的数据将被写入相同的内存空间,那么当我们使用C语言编码时,我们无法预测哪些数据将“存活”下来。 在汇编中,我们手动编写CPU寄存器的加载和卸载代码,我们将知道哪些数据保持完整。但是C(谢天谢地)抽象了这个细节。

由于两个指针可以指向内存中的相同位置,这可能导致处理可能冲突的复杂代码。

这些额外的代码速度很慢,并且会损害性能,因为它执行额外的内存读/写操作,这些操作既慢又(可能)不必要。

严格混叠规则允许我们在假定两个指针不指向同一个内存块是安全的情况下避免冗余机器码(另请参阅restrict关键字)。

严格别名表示,可以安全地假设指向不同类型的指针指向内存中的不同位置。

如果编译器注意到两个指针指向不同的类型(例如,int *和float *),它会假设内存地址是不同的,并且不会防止内存地址冲突,从而导致更快的机器代码。

例如:

让我们假设下面的函数:

void merge_two_ints(int *a, int *b) {
  *b += *a;
  *a += *b;
}

为了处理a == b(两个指针都指向同一个内存)的情况,我们需要排序并测试从内存加载数据到CPU寄存器的方式,因此代码可能会像这样结束:

从内存中加载a和b。 把a加到b。 保存b并重新加载a。 (从CPU寄存器保存到内存,从内存加载到CPU寄存器)。 把b和a相加。 (从CPU寄存器)保存到内存。

步骤3非常慢,因为它需要访问物理内存。但是,为了防止a和b指向相同内存地址的情况,这是必需的。

严格的混叠将允许我们通过告诉编译器这些内存地址明显不同来防止这种情况(在这种情况下,这将允许进一步的优化,如果指针共享内存地址则无法执行)。

这可以通过两种方式告诉编译器,通过使用不同的类型来指向。例如: Void merge_two_numbers(int *a, long *b){…} 使用restrict关键字。例如: Void merge_two_ints(int * restrict a, int * restrict b){…}

现在,通过满足严格别名规则,可以避免步骤3,代码将运行得更快。

实际上,通过添加restrict关键字,整个函数可以优化为:

从内存中加载a和b。 把a加到b。 将结果保存到a和b。

这种优化在以前是不可能完成的,因为可能会发生冲突(a和b将是三倍而不是两倍)。

其他回答

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

一个典型的情况下,你遇到严格的混叠问题是当覆盖一个结构(如设备/网络msg)到你的系统的字大小的缓冲区(如uint32_ts或uint16_ts指针)。当你将一个结构叠加到这样的缓冲区上,或者通过指针强制转换将一个缓冲区叠加到这样的结构上时,你很容易违反严格的混叠规则。

在这种设置中,如果我想发送消息到某个对象,我必须有两个不兼容的指针指向同一块内存。然后我可能会天真地编写如下代码:

typedef struct Msg
{
    unsigned int a;
    unsigned int b;
} Msg;

void SendWord(uint32_t);

int main(void)
{
    // Get a 32-bit buffer from the system
    uint32_t* buff = malloc(sizeof(Msg));
    
    // Alias that buffer through message
    Msg* msg = (Msg*)(buff);
    
    // Send a bunch of messages    
    for (int i = 0; i < 10; ++i)
    {
        msg->a = i;
        msg->b = i+1;
        SendWord(buff[0]);
        SendWord(buff[1]);   
    }
}

严格的混叠规则使得这种设置是非法的:对一个指针进行解引用,该指针的混叠对象不是兼容类型或C 2011 6.5第71段允许的其他类型之一,这是未定义的行为。不幸的是,您仍然可以以这种方式编码,可能会得到一些警告,让它编译良好,但在运行代码时却会出现奇怪的意外行为。

(GCC在给出别名警告的能力上似乎有些不一致,有时给我们一个友好的警告,有时则不是。)

To see why this behavior is undefined, we have to think about what the strict aliasing rule buys the compiler. Basically, with this rule, it doesn't have to think about inserting instructions to refresh the contents of buff every run of the loop. Instead, when optimizing, with some annoyingly unenforced assumptions about aliasing, it can omit those instructions, load buff[0] and buff[1] into CPU registers once before the loop is run, and speed up the body of the loop. Before strict aliasing was introduced, the compiler had to live in a state of paranoia that the contents of buff could change by any preceding memory stores. So to get an extra performance edge, and assuming most people don't type-pun pointers, the strict aliasing rule was introduced.

请记住,如果您认为这个示例是虚构的,那么即使您将缓冲区传递给另一个为您执行发送的函数,也可能会发生这种情况。

void SendMessage(uint32_t* buff, size_t size32)
{
    for (int i = 0; i < size32; ++i) 
    {
        SendWord(buff[i]);
    }
}

并重写了之前的循环来利用这个方便的函数

for (int i = 0; i < 10; ++i)
{
    msg->a = i;
    msg->b = i+1;
    SendMessage(buff, 2);
}

The compiler may or may not be able to or smart enough to try to inline SendMessage and it may or may not decide to load or not load buff again. If SendMessage is part of another API that's compiled separately, it probably has instructions to load buff's contents. Then again, maybe you're in C++ and this is some templated header only implementation that the compiler thinks it can inline. Or maybe it's just something you wrote in your .c file for your own convenience. Anyway undefined behavior might still ensue. Even when we know some of what's happening under the hood, it's still a violation of the rule so no well defined behavior is guaranteed. So just by wrapping in a function that takes our word delimited buffer doesn't necessarily help.

我该怎么解决这个问题呢?

Use a union. Most compilers support this without complaining about strict aliasing. This is allowed in C99 and explicitly allowed in C11. union { Msg msg; unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)]; }; You can disable strict aliasing in your compiler (f[no-]strict-aliasing in gcc)) You can use char* for aliasing instead of your system's word. The rules allow an exception for char* (including signed char and unsigned char). It's always assumed that char* aliases other types. However this won't work the other way: there's no assumption that your struct aliases a buffer of chars.

初学者要小心

这只是将两种类型叠加在一起时的一个潜在雷区。您还应该了解字节顺序、单词对齐,以及如何通过正确打包结构来处理对齐问题。

脚注

C 2011 6.5 7允许左值访问的类型有:

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.

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

摘自文章:

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

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

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

通过指针强制转换(而不是使用联合)的类型双关是打破严格混叠的一个主要例子。

在阅读了许多答案后,我觉得有必要补充一些东西:

严格的混叠(我将在后面描述)很重要,因为:

内存访问可能是昂贵的(就性能而言),这就是为什么数据在写回物理内存之前要在CPU寄存器中进行操作。 如果两个不同CPU寄存器中的数据将被写入相同的内存空间,那么当我们使用C语言编码时,我们无法预测哪些数据将“存活”下来。 在汇编中,我们手动编写CPU寄存器的加载和卸载代码,我们将知道哪些数据保持完整。但是C(谢天谢地)抽象了这个细节。

由于两个指针可以指向内存中的相同位置,这可能导致处理可能冲突的复杂代码。

这些额外的代码速度很慢,并且会损害性能,因为它执行额外的内存读/写操作,这些操作既慢又(可能)不必要。

严格混叠规则允许我们在假定两个指针不指向同一个内存块是安全的情况下避免冗余机器码(另请参阅restrict关键字)。

严格别名表示,可以安全地假设指向不同类型的指针指向内存中的不同位置。

如果编译器注意到两个指针指向不同的类型(例如,int *和float *),它会假设内存地址是不同的,并且不会防止内存地址冲突,从而导致更快的机器代码。

例如:

让我们假设下面的函数:

void merge_two_ints(int *a, int *b) {
  *b += *a;
  *a += *b;
}

为了处理a == b(两个指针都指向同一个内存)的情况,我们需要排序并测试从内存加载数据到CPU寄存器的方式,因此代码可能会像这样结束:

从内存中加载a和b。 把a加到b。 保存b并重新加载a。 (从CPU寄存器保存到内存,从内存加载到CPU寄存器)。 把b和a相加。 (从CPU寄存器)保存到内存。

步骤3非常慢,因为它需要访问物理内存。但是,为了防止a和b指向相同内存地址的情况,这是必需的。

严格的混叠将允许我们通过告诉编译器这些内存地址明显不同来防止这种情况(在这种情况下,这将允许进一步的优化,如果指针共享内存地址则无法执行)。

这可以通过两种方式告诉编译器,通过使用不同的类型来指向。例如: Void merge_two_numbers(int *a, long *b){…} 使用restrict关键字。例如: Void merge_two_ints(int * restrict a, int * restrict b){…}

现在,通过满足严格别名规则,可以避免步骤3,代码将运行得更快。

实际上,通过添加restrict关键字,整个函数可以优化为:

从内存中加载a和b。 把a加到b。 将结果保存到a和b。

这种优化在以前是不可能完成的,因为可能会发生冲突(a和b将是三倍而不是两倍)。