【问题标题】:What is the rationale behind the strict aliasing rule?严格别名规则背后的基本原理是什么?
【发布时间】:2019-05-26 06:54:36
【问题描述】:

我目前想知道严格别名规则背后的基本原理。我知道在 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;
}

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

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

【问题讨论】:

  • 您是否查看过关于严格别名的规范问答及其含义。原因基本上是“因为它允许更强大的优化”;这是通常的原因。与有符号整数溢出相同。
  • @JonathanLeffler 是的,我想是的,我想的不是没有优化的编译器,而是一个检测何时无法进行这种优化的编译器。
  • 您对非 x86 系统有任何经验吗?对不同数据类型有严格对齐限制的那些,例如doublelong 必须在8 字节边界上,以免您的进程被SIGBUS 之类的东西杀死?从技术上讲,这不是一个严格的混叠问题,但它涉及到许多相同的潜在问题。
  • uint64_t 几乎按照定义与uint32_t @AndrewHenle 对齐,所以这里肯定不是问题。
  • 标记的行不是严格的别名违规。违规行为在下一行。

标签: c language-lawyer c11 strict-aliasing


【解决方案1】:

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

考虑代码是否包含:

foo(&val, ptr)

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

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

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

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

【讨论】:

  • 谢谢,这很有道理。不过 LTO 或许能够解决这个问题,不是吗?
  • @Julius:在这样一个简单的例子中,我们可以看到foo(&val, ptr) 被赋予了两个指向同一个对象的指针。但是,请考虑可能以各种方式计算指针。我希望在翻译时(包括链接)确定两个指针是否指向同一个对象的一般问题是不可计算的。 (可能类似于停机问题:如果我们可以计算它,我们可以编写一个传递相同地址的例程当且仅当检查该代码的代码报告该代码没有传递相同的地址。)
  • 我明白了。考虑一下……我猜计算甚至可以在运行时完成,这甚至可能无法预测。不过,我仍然希望限制较少的规则 :-) 谢谢!
  • @Julius "编译器更聪明" 以便它可以检测和优雅地处理更明显的情况?有多明显?您想去编写您希望看到支持的案例的规范吗?
  • @curiousguy:如果规则被写成它只适用于用于访问的左值与任何适当类型的东西之间没有可见关系的情况,但明确指出能力在可能有用的情况下识别这种关系是实施质量问题,任何人都可以直截了当地说 clang 和 gcc 的实际行为与任何以高质量方式行事的善意努力一致吗?
【解决方案2】:

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

例子:

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

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

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 限定符可以比基于类型的别名更有效,如果一个人不需要比较一个限制限定指针与其他任何东西的相等性,或者正在使用一个不'不要毫无意义地对待这种比较。
  • @supercat 如果restrict 可以表达所有基于类型的别名就更好了'然后不会离开(不是我很关心委员会在这一点上接下来会提出什么:D)。考虑对我的示例进行扩展,在该示例中,您将指针指向两个结构 head 和 head_and_body,您可能希望允许在头部之间但不允许在头部和主体之间进行别名:gcc.godbolt.org/z/WfaEcx16T。我认为restrict 无法表达这一点。
  • @supercat 我不太喜欢基于类型的别名。必须用 memcpy 而不是强制转换来规避它似乎是不合 C 的。但是我将 memcpy 隐藏在一个便利宏中,所以它看起来并不那么糟糕(即,看起来我调用一个函数并不是为了做一个,例如整数转换)。
  • 该标准故意允许为某些专门任务设计的编译器以不适合许多其他任务的方式处理代码。它还允许实现作为“符合语言扩展”的一种形式,在比标准规定的范围更广的情况下表现得更有意义。设计为适合特定任务的优化器不需要程序员跳过荒谬的圈子来防止无意义的“优化”,即使标准允许它这样做。坚持让程序员跳过的编译器作者...
  • ...不必要的箍应该被认为是对玩程序员比对生产优质产品更感兴趣。
【解决方案3】:

指定的编程语言支持标准化委员会成员认为合理的常识性实践。使用非常不同类型的不同指针给同一个对象起别名被认为是不合理的,并且编译器不应该不遗余力地使之成为可能

这样的代码:

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 即使这样,现有的实现也只反映了这些编译器设计者的直觉,WRT 什么是“合理的、常识性的做法”。
  • 根据已发布的基本原理文档,C 标准的作者并没有试图指定实现必须做的所有事情以适合任何特定目的,而是希望市场推动编译器编写者生成支持各种“流行扩展”的高质量实现,而不管标准是否需要它们。
  • @curiousguy:现有的实现不仅反映了设计师的直觉,也反映了客户的直觉。此外,没有统一适用于所有实现的“合理、常识性实践”的固定概念。一些在低级操作系统代码中可能代表“常识实践”的结构在高端数字运算代码中可能不合适。标准的作者希望编译器编写者比标准编写者更了解他们的目标平台和预期应用领域的典型构造和实践。
【解决方案4】:

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

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

根据基本原理,普通实现将以等同于无符号算术的方式处理对短无符号值的操作即使结果介于 INT_MAX+1uUINT_MAX 之间,除非适用某些条件.当结果被强制转换为unsigned 时,不需要有一个特殊的规则来使编译器将涉及短无符号类型的表达式视为无符号,因为-根据标准的作者--commonplace implementations 即使没有这样的规则

该标准从未打算完全指定声称适用于任何特定目的的高质量实施的所有内容。实际上,它甚至不要求实现适用于任何有用的目的(理由甚至承认质量如此差的“符合”实现的可能性,除了单个人为的和无用的程序之外无法有意义地处理任何东西) .

【讨论】:

  • 我对这个 POV 的一个问题是“明显新鲜派生”没有正式定义 (AFAIK),正式规范对于推理程序代码和编译器代码都非常有用。
  • @curiousguy:作者可能使脚注不规范以避免必须编写别名的精确定义,并且因为处理一些边界情况(例如编译器应该假设从volatile 对象或通过整数转换生成)应视为实施质量问题。虽然作者本可以要求实现处理某些明显的情况,而将其他情况留作 QOI 问题,但我认为他们不会认为实施者会使用标准作为忽略明显情况的借口。
  • @curiousguy:该标准的作者发布了一份说明他们意图的理由文件。他们公开承认一种“符合”的实现的可能性,这种实现被设计成质量很差,以至于毫无用处。他们不需要以与基本原理中描述的 C 精神一致的方式处理代码的实现这一事实绝不意味着无论如何不应期望高质量的实现这样做。
  • @curiousguy:如果程序员采取“不要对内存做任何奇怪的事情而不明显地表明正在发生的事情”的态度,而编译器编写者采取“真诚地努力注意证据”的态度可能会发生一些奇怪的事情”,这些原则可能比更详细的标准更容易和更有效地解决大多数问题。人们对-fstrict-aliasing 行为大声抱怨的情况是gcc 和clang 的作者肆意无视跨类型访问模式的证据。
猜你喜欢
  • 1970-01-01
  • 2011-06-17
  • 2015-10-15
  • 2017-02-25
  • 1970-01-01
  • 2012-03-07
  • 2017-03-09
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多