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

5

我目前对严格别名规则背后的原理感到好奇。我知道在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;
}

我可能错了,但据我所知,编译器应该可以完美而轻松地跟踪类型转换并避免对显式转换的类型进行优化(就像它在处理受影响的值调用时避免同类型指针的优化一样)。那么,我错过了哪些严格别名规则问题,使得编译器无法轻松地自动检测到可能的优化呢?

2
你看过关于严格别名的规范问答及其含义吗?其中的原因基本上是“因为它允许更强大的优化”;这通常是原因。有符号整数溢出也是如此。 - Jonathan Leffler
@JonathanLeffler 是的,我想是这样,我没有考虑过没有优化的编译器,而是一种能够检测到这种优化不可能的编译器。 - Julius
你有在非x86架构系统上的经验吗?那些对不同数据类型,如doublelong,有严格对齐限制的系统需要将它们放在8字节边界上,否则你的进程可能会被类似SIGBUS的东西杀死。这并不是严格别名问题,但它涉及到许多相同的根本问题。 - Andrew Henle
2
uint64_t 根据定义几乎总是适合于 uint32_t 的对齐方式,所以这里肯定不是问题。 - Antti Haapala -- Слава Україні
2
标记的那一行不是严格别名违规。违规出现在下一行。 - M.M
显示剩余2条评论
4个回答

13

由于在这个例子中,所有代码都可以被编译器看到,因此编译器可以理论上确定请求的内容并生成所需的汇编代码。然而,演示一个不需要严格别名规则的情况并不能证明不存在其他需要它的情况。

考虑代码是否包含:

foo(&val, ptr)

如果声明 foovoid foo(uint64_t *a, uint32_t *b);,那么在 foo 内部,编译器无法知道 ab 指向同一对象(的部分)。

接下来有两个选择:第一个是允许别名,这种情况下编译器不能依赖于 *a*b 是不同的这一事实进行优化。例如,每次写入 *b 时,编译器必须生成汇编代码重新加载 *a,因为它可能已经改变了。不允许像保持一个寄存器中的 *a 副本这样的优化。

第二个选择是禁止别名(具体而言,不定义程序执行此类操作的行为)。在这种情况下,编译器可以依赖于 *a*b 是不同的这一事实进行优化。

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


谢谢,那有点讲得通。不过,LTO也许能解决这个问题,不是吗? - Julius
@Julius:在这样一个简单的情况下,我们可以看到foo(&val, ptr)给出了两个指向同一对象的指针。然而,请考虑指针可能以各种方式计算。我预计,在翻译时(包括链接)确定两个指针是否可能指向同一对象的一般问题是不可计算的。(可能类似于停机问题:如果我们能够计算它,我们可以编写一个程序,只有在检查代码报告代码未通过相同地址时才传递相同地址。) - Eric Postpischil
我明白了。思考一下... 我想计算甚至可以在运行时进行,这可能是无法预测的。不过,我仍然希望有一个不那么限制性的规则 :-) 谢谢! - Julius
1
@Julius “编译器更聪明”以便能够优雅地检测和处理更明显的情况?有多明显?您想要编写支持的情况的规范吗? - curiousguy
1
@curiousguy:如果规则写成只适用于在访问中使用的lvalue与任何适当类型的东西之间没有可见关系的情况下,但明确指出在可能有用的情况下识别这种关系的能力是实现质量问题,那么有人能毫不掩饰地说clang和gcc的实际行为与真正努力以优质方式行事的任何努力一致吗? - supercat
显示剩余4条评论

6

它允许编译器在不需要限定修饰符的情况下优化变量重新加载。

示例:

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

使用 gcc -O3 编译(意味着 -fstrict-alising)在 x86_64 上:

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

这可以在处理大型数组时帮助很多,否则可能会导致许多不必要的重新加载。

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


“restrict” 限定符可以比基于类型的别名更有效,如果一个人要么不需要将 restrict 限定的指针与其他任何东西进行比较,要么正在使用一个不会荒谬地处理这种比较的编译器。 - supercat
@supercat 如果restrict能表达所有基于类型的别名所能表达的内容,那么就不需要基于类型的别名了,但我担心它不能,因此基于类型的别名可能不会消失(对于委员会接下来提出什么,我并不是太关心:D)。考虑扩展我的例子,您将取一个指向两个结构head和head_and_body的指针,其中您可能希望在头之间允许别名,但不在头和身体之间允许别名:https://gcc.godbolt.org/z/WfaEcx16T。我认为这不能用'restrict'表达。 - Petr Skocik
@supercat 我并不太喜欢基于类型的别名。使用memcpy来规避它似乎与C语言不太相符。但我会将memcpy隐藏在一个便捷的宏中,这样看起来就不那么糟糕了(比如,不会像我在调用函数来强制执行整数转换一样)。 - Petr Skocik
标准故意允许为某些专门任务设计的编译器以使它们不适用于其他许多任务的方式处理代码。它还允许实现作为“符合语言扩展”的形式,在比标准规定的范围更广泛的情况下有意义地行为。为特定任务设计的优化器不需要程序员跳过荒谬的障碍来防止无意义的“优化”,即使标准允许这样做。坚持要求程序员跳过这些障碍的编译器编写者... - supercat
不必要的花招应该被认为是更感兴趣玩弄程序员,而不是生产优质产品。 - supercat

1
编程语言的规范是为了支持标准化委员会成员认为合理、符合常识的实践。使用不同类型非常不同的指针来别名同一对象被认为是不合理的,编译器不应该费力去实现这种可能性。
这样的代码:
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;
}

粗体文本在我看来并不适用于C89。C89规则的主要动机是支持现有实现的行为。 - M.M
即使如此,现有的实现也只反映了这些编译器设计者对“合理、常识性实践”的直觉。 - curiousguy
1
根据已发布的理论文档,C标准的作者并不试图规定实现必须做什么才能适用于任何特定目的,而是期望市场推动编译器编写者生产支持各种“流行扩展”的高质量实现,而不考虑标准是否需要它们。 - supercat
2
@curiousguy:现有的实现不仅反映了设计者的直觉,也反映了他们的客户。此外,并不存在适用于所有实现的固定的“合理、常识性的做法”概念。在低级操作系统代码中可能代表“常识性做法”的一些结构,在高端数值计算代码中可能是不合适的。标准的作者们预期编译器编写者会比标准编写者更了解哪些结构和做法是针对他们的目标平台和预期应用领域的典型的。 - supercat

0
N1570页6.5p7的脚注清楚地说明了规则的目的:用于指出何时可能发生别名。至于为什么规则被写成禁止像你这样的结构,尽管按照书面上的说法不涉及别名(因为所有使用uint32_t*的访问都是在可见地从uint64_t派生出来的情况下进行的),最有可能的原因是标准的作者认识到,任何真正努力制作适用于低级编程的高质量实现的人都会支持像你这样的结构(作为“流行扩展”),无论标准是否规定。这个原则在处理像以下结构这样的结构时更明确地体现出来:
unsigned mulMod65536(unsigned short x, unsigned short y)
{ return (x*y) & 65535u; }

根据Rationale,常见的实现将以等效于无符号算术的方式处理短无符号值的操作,即使结果介于“INT_MAX+1u”和“UINT_MAX”之间,除非满足某些条件。 当强制转换结果为“unsigned”时,没有必要制定特殊规则使编译器将涉及短无符号类型的表达式视为无符号,因为--根据标准的作者--常见的实现即使没有这样的规则也会这样做。
标准从未打算完全指定应该对声称适用于任何特定目的的优质实现期望什么。 实际上,它甚至不要求实现适用于任何有用的目的(Rationale甚至承认可能存在一个“符合”实现,其质量如此之低,以至于不能有意义地处理除单个人造和无用程序之外的任何内容)。

我对这个观点的一个问题是,“显然新鲜派生”的定义不是正式定义(据我所知),而正式规范对于推理程序代码和编译器代码都非常有用。 - curiousguy
1
@curiousguy:作者可能将脚注设为非规范性的,以避免编写关于别名的精确定义,并且因为某些边界情况的处理(例如编译器应该假定从volatile对象检索或通过整数转换产生的指针)应视为实现质量问题。虽然作者可以要求实现处理某些明显的情况,同时将其他情况留作QOI问题,但我认为他们没有考虑到实现者会将标准用作忽略明显情况的借口的想法。 - supercat
1
@curiousguy:标准的作者们出版了一份说明文件,阐述了他们的意图。他们公开承认存在“符合性”实现,这些实现是刻意制作得如此低劣以至于毫无用处的。他们不要求实现必须按照理念文档中描述的C语言精神进行代码处理的事实,并不意味着不应该期望高质量的实现也能够这样做。 - supercat
2
@curiousguy:如果程序员采取“不要在没有明显表现的情况下对内存进行任何奇怪的操作”的态度,而编译器编写者则采取“尽力注意可能发生奇怪事情的证据”的态度,这些原则可能比更详细的标准更容易和有效地解决大多数问题。人们最响亮地抱怨“-fstrict-aliasing”行为的情况是那些gcc和clang的作者对跨类型访问模式的证据漠不关心的情况。 - supercat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接