当问到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将是三倍而不是两倍)。

其他回答

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

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

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

同样来自glvalue的定义

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

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

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

一个典型的情况下,你遇到严格的混叠问题是当覆盖一个结构(如设备/网络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.

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