ChatGPT解决这个技术问题 Extra ChatGPT

什么是严格的别名规则?

当问及 common undefined behavior in C 时,人们有时会提到严格的别名规则。
他们在说什么?

可能还想看看我最近写的一篇文章What is the Strict Aliasing Rule and Why do we care?。它涵盖了此处未涵盖的许多材料,或者在某些领域采用了更现代的方法。

3
36 revs, 21 users 73%

遇到严格别名问题的典型情况是将结构(如设备/网络消息)覆盖到系统字长的缓冲区(如指向 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]);   
    }
}

严格的别名规则使此设置非法:取消引用一个别名的对象的指针不是 compatible type 或 C 2011 6.5 第 7 段1 允许的其他类型之一是未定义的行为。不幸的是,您仍然可以以这种方式编写代码,也许会收到一些警告,让它编译正常,只是在运行代码时出现奇怪的意外行为。

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

要了解为什么这种行为是未定义的,我们必须考虑严格的别名规则购买编译器的原因。基本上,有了这个规则,它就不必考虑插入指令来刷新 buff 每次循环运行的内容。相反,在优化时,通过一些关于别名的令人讨厌的非强制假设,它可以省略这些指令,在循环运行之前将 buff[0]buff[1] 加载到 CPU 寄存器中,并加速循环体。在引入严格别名之前,编译器不得不处于一种偏执的状态,即 buff 的内容可能会被任何先前的内存存储所改变。因此,为了获得额外的性能优势,并假设大多数人不键入双关指针,引入了严格的别名规则。

请记住,如果您认为该示例是人为的,那么如果您将缓冲区传递给另一个为您发送的函数(如果您有的话),甚至可能会发生这种情况。

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);
}

编译器可能会或可能不会或足够聪明地尝试内联 SendMessage,它可能会或可能不会决定再次加载或不加载 buff。如果 SendMessage 是另一个单独编译的 API 的一部分,它可能有加载 buff 内容的指令。再说一次,也许你在 C++ 中,这是编译器认为它可以内联的一些模板化头实现。或者,这只是您在 .c 文件中为您自己的方便而编写的内容。无论如何,可能仍会出现未定义的行为。即使我们知道幕后发生的一些事情,它仍然违反规则,因此不能保证明确定义的行为。因此,仅仅通过包装一个接受我们的单词分隔缓冲区的函数并不一定有帮助。

那么我该如何解决呢?

使用工会。大多数编译器都支持这一点,而不会抱怨严格的别名。这在 C99 中是允许的,在 C11 中是明确允许的。联合{消息消息; unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)]; };

您可以在编译器中禁用严格别名(gcc 中的 f[no-]strict-aliasing))

您可以使用 char* 代替系统的单词进行别名。规则允许 char* 例外(包括有符号字符和无符号字符)。始终假定 char* 为其他类型起别名。但是,这不会以另一种方式起作用:没有假设您的结构别名为字符缓冲区。

初学者小心

当两种类型相互叠加时,这只是一个潜在的雷区。您还应该了解 endiannessword alignment 以及如何通过 packing structs 正确处理对齐问题。

脚注

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

与对象的有效类型兼容的类型,

与对象的有效类型兼容的类型的限定版本,

与对象的有效类型相对应的有符号或无符号类型,

对应于对象有效类型的限定版本的有符号或无符号类型,

聚合或联合类型,在其成员中包括上述类型之一(递归地包括子聚合或包含联合的成员),或

一种字符类型。


我似乎是在战斗结束后来的.. 可以用 unsigned char* 代替 char* 吗?我倾向于使用 unsigned char 而不是 char 作为 byte 的基础类型,因为我的字节没有签名并且我不希望签名行为的怪异(尤其是溢出)
@Matthieu:签名对别名规则没有影响,所以使用 unsigned char * 是可以的。
从与最后一个写入的联合成员不同的联合成员中读取不是未定义的行为吗?
胡说八道,这个答案完全是倒退的。它显示为非法的示例实际上是合法的,它显示为合法的示例实际上是非法的。
您的 uint32_t* buff = malloc(sizeof(Msg)); 和随后的联合 unsigned int asBuffer[sizeof(Msg)]; 缓冲区声明将具有不同的大小,并且都不正确。 malloc 调用依赖于引擎盖下的 4 字节对齐(不要这样做),并且联合将比它需要的大 4 倍......我知道这是为了清楚起见,但它让我没有-少...
P
Palec

我找到的最佳解释是 Mike Acton,Understanding Strict Aliasing。它稍微专注于 PS3 开发,但基本上只是 GCC。

来自文章:

“严格别名是由 C(或 C++)编译器做出的假设,即取消引用指向不同类型对象的指针永远不会引用相同的内存位置(即彼此别名。)”

因此,基本上,如果您有一个 int* 指向包含 int 的某个内存,然后您将一个 float* 指向该内存并将其用作 float,那么您就违反了规则。如果您的代码不遵守这一点,那么编译器的优化器很可能会破坏您的代码。

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


那么,合法地将同一内存与两种不同类型的变量一起使用的规范方法是什么?还是每个人都只是复制?
Mike Acton 的页面有缺陷。至少“通过联合铸造(2)”部分是完全错误的;他声称是合法的代码是不合法的。
@davmac:C89 的作者从未想过它应该强迫程序员跳槽。我发现一个完全奇怪的概念是,为了优化而存在的规则应该以这样的方式解释,即要求程序员编写冗余复制数据的代码,以期优化器将删除冗余代码。
@curiousguy:错误。首先,联合背后的最初概念是,在任何时候,在给定的联合对象中只有一个成员对象“活跃”,而其他成员对象根本不存在。因此,您似乎不相信“同一地址的不同对象”。其次,每个人都在谈论的别名违规是指将一个对象作为不同的对象访问,而不是简单地让两个对象具有相同的地址。只要没有类型双关语访问,就没有问题。那是最初的想法。后来,允许通过工会进行类型双关语。
异常范围大于 char * -->适用于任何字符类型。
M
Michel de Ruiter

笔记

这摘自我的 "What is the Strict Aliasing Rule and Why do we care?" 文章。

什么是严格别名?

在 C 和 C++ 中,别名与允许我们访问存储值的表达式类型有关。在 C 和 C++ 中,标准都指定了允许哪些表达式类型为哪些类型设置别名。允许编译器和优化器假设我们严格遵循别名规则,因此术语严格别名规则。如果我们尝试使用不允许的类型访问值,则将其分类为 undefined behavior (UB)。一旦我们有未定义的行为,所有的赌注都被取消了,我们的程序的结果就不再可靠了。

不幸的是,由于严格的别名违规,我们通常会获得我们期望的结果,从而有可能具有新优化的编译器的未来版本会破坏我们认为有效的代码。这是不可取的,了解严格的别名规则以及如何避免违反它们是一个值得的目标。

为了更多地了解我们为什么关心,我们将讨论违反严格别名规则时出现的问题,类型双关,因为类型双关中使用的常用技术经常违反严格的别名规则以及如何正确键入双关。

初步示例

让我们看一些示例,然后我们可以确切地讨论标准所说的内容,检查一些进一步的示例,然后看看如何避免严格的混叠并捕获我们错过的违规行为。这是一个不应令人惊讶的示例 (live example):

int x = 10;
int *ip = &x;

std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";

我们有一个 int* 指向一个 int 占用的内存,这是一个有效的别名。优化器必须假设通过 ip 的赋值可以更新 x 占用的值。

下一个示例显示了导致未定义行为 (live example) 的别名:

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?
}

在函数 foo 中,我们采用 int*float*,在本例中,我们调用 foo 并设置这两个参数指向同一个内存位置,在这个例子中包含一个 int。请注意,reinterpret_cast 告诉编译器将表达式视为具有由其模板参数指定的类型。在这种情况下,我们告诉它将表达式 &x 视为其类型为 float*。我们可能天真地期望第二个 cout 的结果为 0,但使用 -O2 启用优化后 gcc 和 clang 都会产生以下结果:

0
1

这可能不是预期的,但完全有效,因为我们调用了未定义的行为。 float 不能有效地为 int 对象起别名。因此,优化器可以假定在解除引用 i 时存储的 constant 1 将是返回值,因为通过 f 进行的存储不能有效地影响 int 对象。在编译器资源管理器中插入代码表明这正是正在发生的事情(live example):

foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 0
mov eax, 1
ret

使用 Type-Based Alias Analysis (TBAA) 的优化器假定 1 将被返回,并直接将常量值移动到携带返回值的寄存器 eax 中。 TBAA 使用语言规则关于允许使用别名的类型来优化加载和存储。在这种情况下,TBAA 知道 float 不能为 int 别名,并优化了 i 的负载。

现在,到规则书

标准到底说我们被允许和不允许做什么?标准语言并不简单,因此对于每个项目,我将尝试提供代码示例来演示其含义。

C11 标准是怎么说的?

C11 标准在第 6.5 节表达式第 7 段中说明了以下内容:

对象的存储值只能由具有以下类型之一的左值表达式访问:88) — 与对象的有效类型兼容的类型,

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 has an extensionalso 允许将 unsigned 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 草案标准是怎么说的

[basic.lval] 第 11 节中的 C++17 标准草案说:

如果程序尝试通过非下列类型之一的泛左值访问对象的存储值,则行为未定义:63

(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
}

值得注意的是有符号字符不包括在上面的列表中,这与 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++ (see live example) 中未定义行为

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++ 中类型双关的标准方法是 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);
    //...

在足够的优化级别上,任何体面的现代编译器都会生成与前面提到的用于type punningreinterpret_cast方法或union方法相同的代码。检查生成的代码,我们看到它只使用了寄存器 mov (live Compiler Explorer Example)。

C++20 和 bit_cast

在 C++20 中,我们可能会获得 bit_cast (implementation available in link from proposal),它为类型双关语提供了一种简单而安全的方法,并且可以在 constexpr 上下文中使用。

以下是如何使用 bit_castunsigned int 类型双关为 float (see it live) 的示例:

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 (see it live) 中生成警告:

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)));

虽然它不会捕捉到这种额外的情况(see it live):

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 都将使用 -Wold-style-cast 生成 C 风格转换的诊断。这将强制任何未定义的类型双关语使用 reinterpret_cast,通常 reinterpret_cast 应该是更仔细的代码审查的标志。在代码库中搜索 reinterpret_cast 以执行审计也更容易。

对于 C,我们已经涵盖了所有工具,并且我们还有 tis-interpreter,这是一个静态分析器,可以详尽地分析 C 语言的大部分子集的程序。给定早期示例的 C 版本,其中使用 -fstrict-aliasing 会遗漏一种情况 (see it live)

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.

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


评论不用于扩展讨论;此对话已moved to chat
如果可以的话,+10,写得很好,解释得很好,也来自编译器编写者和程序员......唯一的批评:上面有反例会很好,看看标准禁止什么,这并不明显有点儿 :-)
很好的答案。我很遗憾最初的例子是用 C++ 给出的,这让像我这样只知道或关心 C 并且不知道 reinterpret_cast 可能做什么或 cout 可能意味着什么的人很难理解。 (可以提及 C++,但最初的问题是关于 C 和 IIUC,这些示例可以同样有效地用 C 编写。)
关于类型惩罚:因此,如果我将某个类型 X 的数组写入 file ,然后从该文件中将该数组读取到用 void* 指向的内存中,然后我将该指针转换为数据的真实类型以使用它 - 那就是未定义的行为?
为什么在 C++17 草案标准怎么说部分的 (11.2) 示例中 cip 是泛左值?它看起来像左值,是吗?它看起来与 C11 标准怎么说? 部分中的第二个示例相同
C
Community

这是 C++03 标准第 3.10 节中的严格别名规则(其他答案提供了很好的解释,但没有提供规则本身):

如果程序试图通过以下类型之一以外的左值访问对象的存储值,则行为未定义:对象的动态类型、对象的动态类型的 cv 限定版本、类型即有符号或无符号类型,对应于对象的动态类型,有符号或无符号类型,对应于对象的动态类型的 cv 限定版本,聚合或联合类型,包括以下之一其成员中的上述类型(递归地包括子聚合或包含联合的成员),作为对象动态类型的(可能是 cv 限定的)基类类型的类型,char 或 unsigned char 类型。

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

如果程序尝试通过非下列类型之一的泛左值访问对象的存储值,则行为未定义:对象的动态类型、对象的动态类型的 cv 限定版本、类型与对象的动态类型相似(如 4.4 中所定义),一种类型,即有符号或无符号类型,对应于对象的动态类型,一种有符号或无符号类型,对应于 cv 限定版本对象的动态类型,聚合或联合类型,在其元素或非静态数据成员中包括上述类型之一(递归地包括子聚合或包含联合的元素或非静态数据成员),类型是对象的动态类型的(可能是 cv 限定的)基类类型,char 或 unsigned char 类型。

两个变化很小:glvalue 代替 lvalue,以及聚合/联合情况的澄清。

第三个更改提供了更强的保证(放宽了强别名规则):类似类型的新概念现在可以安全地使用别名。

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

对象的存储值只能由具有以下类型 73) 或 88) 之一的左值表达式访问:与对象的有效类型兼容的类型,与对象的有效类型兼容的类型的限定版本对象,有符号或无符号类型,对应于对象的有效类型,有符号或无符号类型,对应于对象有效类型的限定版本,聚合或联合类型,包括一个其成员之间的上述类型(包括递归地,子聚合或包含联合的成员)或字符类型。 73) 或 88) 此列表的目的是指定对象可能或可能不会被别名的情况。


查看 C89 基本原理 cs.technion.ac.il/users/yechiel/CS/C++draft/rationale.pdf 第 3.3 节,其中讨论了它。
如果一个人有一个结构类型的左值,获取一个成员的地址,并将其传递给一个使用它作为指向成员类型的指针的函数,那将被视为访问成员类型的对象(合法),还是结构类型的对象(禁止)?很多代码都假设以这种方式访问结构是合法的,我认为很多人会对被理解为禁止此类行为的规则大喊大叫,但目前尚不清楚确切的规则是什么。此外,工会和结构的处理方式相同,但每个人的合理规则应该不同。
@supercat:结构规则的措辞方式,实际访问始终是原始类型。然后通过对原始类型的引用进行访问是合法的,因为类型匹配,通过对包含结构类型的引用进行访问是合法的,因为它是特别允许的。
@BenVoigt:在这种解释下,如果 S1S2 是以 int x; 作为它们的第一个字段的结构,并且不需要比 int 对齐更粗的东西,那么给定 void blah(S1 *p1, S2, *p2);` 编译器将不会允许对 p1->xp2->x 之间的别名进行任何假设。因为它们都可以识别类型为 int 的存储。我不认为那是故意的。
@BenVoigt:除非通过联合完成访问,否则我认为通用初始序列不起作用。请参阅 goo.gl/HGOyoK 了解 gcc 正在做什么。如果通过成员类型的左值(不使用联合成员访问运算符)访问联合类型的左值是合法的,那么即使使用指针修改 uwow(&u->s1,&u->s2) 也必须是合法的,并且将否定别名规则旨在促进的大多数优化。
p
phorgan1

严格别名不仅仅指指针,它也影响引用,我为 boost developer wiki 写了一篇关于它的论文,它很受欢迎,我把它变成了我的咨询网站上的一个页面。它完全解释了它是什么,为什么它让人们如此困惑以及如何处理它。 Strict Aliasing White Paper。特别是它解释了为什么联合是 C++ 的危险行为,以及为什么使用 memcpy 是唯一可跨 C 和 C++ 移植的修复程序。希望这会有所帮助。


“严格别名不仅仅指指针,它也会影响引用”实际上,它指的是左值。 “使用 memcpy 是唯一可移植的修复程序”听!
好纸。我的看法:(1)这个别名-“问题”是对不良编程的过度反应-试图保护不良程序员免受他/她的不良习惯的影响。如果程序员有良好的习惯,那么这种别名只是一种麻烦,可以安全地关闭检查。 (2) 编译器端优化只应在众所周知的情况下进行,并且在有疑问时应严格遵循源代码;简单地说,强迫程序员编写代码来迎合编译器的特性是错误的。更糟糕的是,使其成为标准的一部分。
@slashmais(1)“是对不良编程的过度反应”胡说八道。这是对坏习惯的拒绝。你做吧?你付出代价:不为你保证! (2) 知名案例?哪个?严格的别名规则应该是“众所周知的”!
@curiousguy:清除了一些混淆点后,很明显具有别名规则的 C 语言使程序无法实现与类型无关的内存池。有些程序可以使用 malloc/free,但其他程序需要更好地针对手头任务定制的内存管理逻辑。我想知道为什么 C89 的基本原理使用了这样一个糟糕的例子来说明别名规则的原因,因为他们的例子使得该规则看起来不会对执行任何合理的任务造成任何重大困难。
@curiousguy,大多数编译器套件都包括 -fstrict-aliasing 作为 -O3 上的默认值,并且这种隐藏的合同被强制用于从未听说过 TBAA 并像系统程序员那样编写代码的用户。我并不是说系统程序员听起来不诚实,但是这种优化应该被排除在 -O3 的默认选项之外,并且对于那些知道什么是 TBAA 的人来说应该是一种选择加入的优化。看着编译器“错误”被证明是违反 TBAA 的用户代码并不有趣,尤其是在用户代码中跟踪源代码级别的违规行为。
I
Ingo Blackman

作为 Doug T. 已经写过的内容的附录,这是一个简单的测试用例,它可能会使用 gcc 触发它:

检查.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 for x64 上运行:

movw    $5, (%rdi)
movq    $6, (%rsi)
movl    $.LC0, %edi
jmp puts

所以 if 条件完全从汇编代码中消失了。


如果您将第二个短 * j 添加到 check() 并使用它( *j = 7 ),那么优化就会消失,因为如果 h 和 j 实际上没有指向相同的值,则 ggc 不会。是的,优化真的很聪明。
为了让事情更有趣,使用指向不兼容但具有相同大小和表示的类型的指针(在某些系统上,例如 long long*int64_t*)。人们可能期望一个理智的编译器应该认识到如果 long long*int64_t* 存储相同,则它们可以访问相同的存储,但这种处理方式已不再流行。
Grr... x64 是 Microsoft 的约定。请改用 amd64 或 x86_64。
s
supercat

根据 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;
}

因为它使用 int 类型的左值来访问 struct S 类型的对象,而 int 不在可用于访问 struct S 的类型中。因为将结构和联合的所有非字符类型成员的所有使用视为未定义行为是荒谬的,所以几乎每个人都认识到至少在某些情况下,一种类型的左值可以用于访问另一种类型的对象.不幸的是,C 标准委员会未能定义这些情况是什么。

大部分问题是缺陷报告 #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 明显是从 struct 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,因为这两个指针的创建和使用之间存在重叠,这将使事情变得更加清晰,而无需添加“有效类型”或其他此类复杂性。


说得好,阅读某种或多或少是“标准委员会可以做的事情”的提案会很有趣,它实现了他们的目标而没有引入太多的复杂性。
@jrh:我认为这很简单。认识到 1. 为了在函数或循环的特定执行期间发生别名,在该执行期间必须使用两个不同的指针或左值来解决冲突时尚中的相同存储; 2. 认识到在一个指针或左值是从另一个新可见的派生的上下文中,对第二个的访问就是对第一个的访问; 3. 认识到该规则不适用于实际上不涉及别名的情况。
编译器识别新导出的左值的确切情况可能是实现质量问题,但任何远程体面的编译器都应该能够识别 gcc 和 clang 故意忽略的形式。
C
Chris Jester-Young

Type punning 通过指针强制转换(与使用联合相反)是打破严格别名的主要示例。


请参阅我的 answer here for the relevant quotes, especially the footnotes,但在 C 中始终允许通过联合进行类型双关语,尽管起初措辞不佳。你我想澄清你的答案。
@ShafikYaghmour:C89 明确允许实施者选择他们将或不会通过联合有效地识别类型双关语的情况。例如,如果程序员在写入和读取之间执行以下任一操作,则实现可以指定对一种类型的写入然后读取另一种类型被识别为类型双关语:(1)评估包含联合类型[如果在序列中的正确位置完成,则获取成员的地址将符合条件]; (2) 将指向一种类型的指针转换为指向另一种类型的指针,并通过该 ptr 访问。
@ShafikYaghmour:实现还可以指定,例如,整数和浮点值之间的类型双关语只有在代码在写入 fp 和读取为 int 之间执行 fpsync() 指令时才能可靠地工作,反之亦然[在具有单独整数和 FPU 的实现上管道和缓存,这样的指令可能很昂贵,但不像让编译器在每个联合访问上执行这种同步那样昂贵]。或者实现可以指定结果值将永远不可用,除非在使用通用初始序列的情况下。
@ShafikYaghmour:在 C89 下,实现可以禁止大多数形式的类型双关语,包括通过联合,但是指向联合的指针和指向其成员的指针之间的等价意味着在没有明确禁止的实现中允许类型双关。
M
Myst

看了很多答案,觉得有必要补充一下:

严格的别名(我会稍微描述一下)很重要,因为:

内存访问可能很昂贵(性能方面),这就是为什么数据在写回物理内存之前在 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 步非常慢,因为它需要访问物理内存。但是,需要防止 ab 指向同一内存地址的情况。

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

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

现在,通过满足 Strict Aliasing 规则,可以避免第 3 步,并且代码将运行得更快。

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

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

由于可能发生冲突(其中 ab 将加倍而不是加倍),因此以前无法进行此优化。


使用restrict 关键字,在第3 步,不应该只将结果保存到'b' 吗?听起来好像总和的结果也将存储在“a”中。它'b'是否需要再次重新加载?
@NeilB - 是的,你是对的。我们只是保存 b(不重新加载它)并重新加载 a。我希望现在更清楚了。
基于类型的别名可能在 restrict 之前提供了一些好处,但我认为后者在大多数情况下会更有效,并且放宽对 register 的一些限制将允许它填补一些情况 { 1} 无济于事。我不确定将标准视为完全描述程序员应该期望编译器识别别名证据的所有情况,而不是仅仅描述编译器必须假定别名的地方 即使没有特别证据它存在
请注意,尽管从主 RAM 加载非常慢(如果后续操作取决于结果,可能会使 CPU 内核停顿很长时间),但从 L1 缓存加载非常快,写入最近正在写入的缓存行也是如此由同一个核心。因此,除了第一次读取或写入地址之外的所有操作通常都相当快:reg/mem addr 访问之间的差异小于缓存/未缓存 mem addr 之间的差异。
@curiousguy - 虽然你是对的,但在这种情况下,“快”是相对的。 L1 缓存可能仍然比 CPU 寄存器慢一个数量级(我认为慢了 10 倍以上)。此外,restrict 关键字不仅可以最小化操作的速度,还可以最小化操作的数量,这可能是有意义的……我的意思是,毕竟最快的操作是根本没有操作 :)
g
godel9

严格的别名不允许不同的指针类型指向相同的数据。

This article 应该可以帮助您全面了解问题。


您也可以在引用之间以及在引用和指针之间使用别名。请参阅我的教程 dbp-consulting.com/tutorials/StrictAliasing.html
允许对同一数据有不同的指针类型。当通过一种指针类型写入相同的内存位置并通过另一种类型读取时,就会出现严格的别名。此外,允许使用一些不同的类型(例如 int 和包含 int 的结构)。
c
curiousguy

从技术上讲,在 C++ 中,严格的别名规则可能永远不适用。

注意间接的定义 (* operator):

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

同样来自the definition of glvalue

泛左值是一个表达式,它的求值决定了一个对象的身份,(...snip)

因此,在任何定义明确的程序跟踪中,glvalue 指的是一个对象。所以所谓的严格别名规则永远不适用。这可能不是设计师想要的。


C 标准使用术语“对象”来指代许多不同的概念。其中,专门分配给某种目的的字节序列,对可以写入或读取特定类型的值的字节序列的非必要排他引用,或实际具有的此类引用已经或将在某些情况下被访问。我认为没有任何明智的方式来定义术语“对象”,这将与标准使用它的所有方式一致。
@supercat 不正确。尽管您有想象力,但它实际上是相当一致的。在 ISO C 中,它被定义为“执行环境中的数据存储区域,其内容可以表示值”。在 ISO C++ 中有类似的定义。您的评论甚至比答案更无关紧要,因为您提到的只是表示对象内容的表示方式,而答案说明了一种与对象身份密切相关的表达式的 C++ 概念(glvalue)。并且所有的别名规则基本上都与身份相关,但与内容无关。
@FrankHB:如果声明 int foo;,左值表达式 *(char*)&foo 访问的是什么?那是 char 类型的对象吗?该对象是否与 foo 同时存在?写入 foo 会更改上述 char 类型对象的存储值吗?如果是这样,是否有任何规则允许使用 int 类型的左值访问 char 类型对象的存储值?
@FrankHB:在没有 6.5p7 的情况下,可以简单地说每个存储区域同时包含可以适合该存储区域的每种类型的所有对象,并且访问该存储区域同时访问所有对象。然而,以这种方式解释 6.5p7 中“对象”一词的使用将禁止对非字符类型的左值做任何事情,这显然是一个荒谬的结果,并且完全违背了规则的目的。此外,在 6.5p6 以外的任何地方都使用的“对象”概念具有静态编译时类型,但是......
sizeof(int) 为 4,声明 int i; 是否创建每个字符类型的四个对象 in addition to one of type int? I see no way to apply a consistent definition of "object" which would allow for operations on both *(char*)&i` 和 i。最后,标准中没有任何内容允许使用 volatile 限定的指针访问不符合“对象”定义的硬件寄存器。