严格的别名规则背后的基本原理是什么?

What is the rationale behind the strict aliasing rule?

我目前想知道严格别名规则背后的基本原理。我知道 C 中不允许使用某些别名,其目的是允许优化,但令我惊讶的是,在定义标准时,这是优于跟踪类型转换的首选解决方案。

因此,显然以下示例违反了严格的别名规则:

uint64_t swap(uint64_t val)
{
    uint64_t copy = val;
    uint32_t *ptr = (uint32_t*)© // strict aliasing violation
    uint32_t tmp = ptr[0];
    ptr[0] = ptr[1];
    ptr[1] = tmp;
    return copy;
}

我可能是错的,但据我所知,编译器应该能够完美而简单地追踪类型转换并避免对显式转换的类型进行优化(就像它避免对相同类型进行此类优化一样-类型指针)在用受影响的值调用的任何东西上。

那么,我错过了哪些编译器无法轻松解决以自动检测可能的优化的严格别名规则问题?

由于在此示例中所有代码对编译器都是可见的,因此假设编译器可以确定所请求的内容并生成所需的汇编代码。但是,证明一种理论上不需要严格别名规则的情况并不能证明没有其他情况需要它。

考虑代码是否包含:

foo(&val, ptr)

其中 foo 的声明是 void foo(uint64_t *a, uint32_t *b);。然后,在可能位于另一个翻译单元中的 foo 内部,编译器将无法知道 ab 指向同一对象的(部分)。

那么有两种选择:一,语言可能允许别名,在这种情况下,编译器在翻译foo时,无法根据*a和[=18]进行优化=] 不同。例如,每当向 *b 写入某些内容时,编译器必须生成汇编代码以重新加载 *a,因为它可能已更改。不允许在使用时在寄存器中保留 *a 的副本等优化。

第二个选择,二,是禁止别名(具体来说,如果程序这样做,则不定义行为)。在这种情况下,编译器可以根据 *a*b 不同的事实进行优化。

C 委员会选择了选项二,因为它提供了更好的性能,同时又不会过度限制程序员。

它允许编译器优化变量重新加载,而不需要您限制限定您的指针。

示例:

int f(long *L, short *S)
{
    *L=42;
    *S=43;
    return *L;
}

int g(long *restrict L, short *restrict S)
{
    *L=42;
    *S=43;
    return *L;
}

在 x86_64 上编译时禁用了严格的别名 (gcc -O3 -fno-strict-aliasing) :

f:
        movl    , %eax
        movq    , (%rdi)
        movw    %ax, (%rsi)
        movq    (%rdi), %rax ; <<*L reloaded here cuz *S =43 might have changed it
        ret
g:
        movl    , %eax
        movq    , (%rdi)
        movw    %ax, (%rsi)
        movl    , %eax     ; <<42 constant-propagated from *L=42 because *S=43 cannot have changed it  (because of `restrict`)
        ret

在 x86_64 上用 gcc -O3 编译(隐含 -fstrict-alising):

f:
        movl    , %eax
        movq    , (%rdi)
        movw    %ax, (%rsi)
        movl    , %eax   ; <<same as w/ restrict
        ret
g:
        movl    , %eax
        movq    , (%rdi)
        movw    %ax, (%rsi)
        movl    , %eax
        ret

https://gcc.godbolt.org/z/rQDNGt

当您使用大型数组时,这会很有帮助,否则可能会导致大量不必要的重新加载。

指定编程语言以支持标准化委员会成员认为合理的常识实践。使用非常不同类型的不同指针来为同一对象取别名被认为是不合理的,编译器不应该为了使之成为可能

这样的代码:

float f(int *pi, float *pf) {
  *pi = 1;
  return *pf;
}

当与持有相同地址的pipf一起使用时,其中*pf意味着重新解释最近写入的*pi的位,被认为是不合理因此,尊敬的委员会成员(以及在他们之前的C语言的设计者)认为在一个稍微复杂的例子中要求编译器避免常识性程序转换是不合适的:

float f(int *pi, double *pf) {
  (*pi)++;
  (*pf) *= 2.;
  (*pi)++;
}

这里允许两个指针指向同一个对象的极端情况会使增量融合无效的任何简化;假设不发生这种别名允许将代码编译为:

float f(int *pi, double *pf) {
  (*pf) *= 2.;
  (*pi) += 2;
}

N1570 p6.5p7 的脚注清楚地说明了规则的目的:说明什么时候可以使用别名。至于为什么写规则是为了禁止像你的 这样不涉及别名的构造 (因为使用 uint32_t* 的所有访问都是在它所在的上下文中执行的明显是从 uint64_t 派生出来的,这很可能是因为该标准的作者认识到,任何真正努力生产适合低级编程的高质量实现的人都会支持像您这样的结构(作为 "popular extension") 无论标准是否强制执行。同样的原则在结构方面显得更加明确,例如:

unsigned mulMod65536(unsigned short x, unsigned short y)
{ return (x*y) & 65535u; }

根据基本原理,即使结果介于 INT_MAX+1uUINT_MAX,除非适用某些条件。当结果被强制为 unsigned 时,没有必要制定特殊规则让编译器将涉及短无符号类型的表达式视为无符号,因为——根据标准的作者——常见的实现 即使没有这样的规则也要这样做.

该标准从未打算完全指定对声称适用于任何特定目的的质量实施所期望的一切。事实上,它甚至不要求实现适用于任何有用的目的(基本原理甚至承认 "conforming" 实现质量如此差的可能性,除了一个人为的和无用的之外无法有意义地处理任何东西程序)。