什么是严格的混叠规则?

What is the strict aliasing rule?

提问人:Benoit 提问时间:9/19/2008 最后编辑:NathanOliverBenoit 更新时间:5/16/2022 访问量:291278

问:

当被问及 C 语言中常见的未定义行为时,人们有时会提到严格的别名规则。
他们在说什么?

C++ C 未定义行为 严格别名类型 关语

评论

8赞 Shafik Yaghmour 4/22/2018
可能还想看看我最近写的一篇文章 什么是严格锯齿规则 以及我们为什么关心?.它涵盖了许多这里没有涵盖的材料,或者在某些领域采用了更现代的方法。
0赞 Jan Schultke 9/1/2023
大多数严格的混叠冲突都是违反 C++ 标准中的 eel.is/c++draft/basic.lval#11。(或C标准中的相应规则)

答:

14赞 Jason Dagit 9/19/2008 #1

严格别名不允许使用不同的指针类型来访问相同的数据。

本文应帮助您全面详细了解该问题。

评论

5赞 phorgan1 8/16/2011
可以在引用之间以及引用和指针之间设置别名。请参阅我的教程 dbp-consulting.com/tutorials/StrictAliasing.html
5赞 M.M 1/13/2015
允许对同一数据具有不同的指针类型。严格别名的用武之地是指通过一种指针类型写入相同的内存位置并通过另一种指针类型读取。此外,允许使用一些不同的类型(例如 以及一个包含 的结构。intint
21赞 C. K. Young 9/19/2008 #2

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

评论

1赞 Shafik Yaghmour 7/7/2014
有关相关引文,请参阅我的回答,尤其是脚注,但 C 语言中一直允许通过联合进行类型双关语,尽管一开始措辞不佳。你想澄清你的答案。
1赞 supercat 3/23/2017
@ShafikYaghmour:C89 明确允许实现者选择他们通过联合来识别类型双关语或不会有用识别的情况。例如,如果程序员在写入和读取之间执行以下任一操作,则实现可以指定对一种类型的写入和对另一种类型的读取被识别为类型双关语:(1) 计算包含联合类型的左值 [如果在序列中的正确点进行,则获取成员的地址将符合条件];(2) 将指向一种类型的指针转换为指向另一种类型的指针,并通过该 PTR 进行访问。
1赞 supercat 3/23/2017
@ShafikYaghmour:实现还可以指定,例如,整数和浮点值之间的类型双关语只有在代码执行以 fp 写入和以 int 读取之间执行指令时才能可靠地工作,反之亦然 [在具有单独的整数和 FPU 管道和缓存的实现中,这样的指令可能很昂贵,但不如让编译器在每个联合访问上执行此类同步那么昂贵]。或者,实现可以指定除非在使用通用初始序列的情况下,否则结果值将永远不会可用。fpsync()
1赞 supercat 3/23/2017
@ShafikYaghmour:在 C89 下,实现可以禁止大多数形式的类型双关语,包括通过联合,但指向联合的指针和指向其成员的指针之间的等价性意味着在未明确禁止的实现中允许类型双关语。
285赞 Niall 9/19/2008 #3

我找到的最好的解释是 Mike Acton 的《理解严格混叠》。它有点专注于 PS3 开发,但这基本上只是 GCC。

来自文章:

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

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

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

评论

8赞 jiggunjer 7/15/2015
那么,将同一内存与 2 种不同类型的变量合法使用的规范方法是什么?还是每个人都只是复制?
5赞 davmac 9/6/2015
迈克·阿克顿(Mike Acton)的页面有缺陷。至少,“通过工会铸造(2)”的部分是完全错误的;他声称合法的代码不是。
29赞 supercat 6/14/2016
@davmac:C89的作者从来没想过要强迫程序员跳过重重障碍。我发现这种想法非常奇怪,即仅以优化为目的的规则应该以这样的方式解释,即要求程序员编写冗余复制数据的代码,希望优化器能够删除冗余代码。
11赞 AnT stands with Russia 11/27/2017
@curiousguy:错误。首先,联合背后的最初概念思想是,在任何时候,给定的联合对象中只有一个成员对象“活动”,而其他成员对象根本不存在。因此,没有您似乎认为的“同一地址上的不同对象”。其次,每个人都在谈论的混叠冲突是关于将一个对象作为不同的对象进行访问,而不是简单地两个具有相同地址的对象。只要没有类型化访问,就没有问题。这是最初的想法。后来,允许通过工会进行双关语。
3赞 chux - Reinstate Monica 5/9/2020
例外范围大于 --> 适用于任何字符类型。char *
694赞 36 revs, 21 users 73%Doug T. #4

遇到严格别名问题的典型情况是将结构(如设备/网络消息)覆盖到系统字大小的缓冲区(如指向 s 或 s 的指针)上。当您通过指针转换将结构覆盖到此类缓冲区上,或将缓冲区覆盖到此类结构上时,很容易违反严格的别名规则。uint32_tuint16_t

因此,在这种设置中,如果我想向某些东西发送消息,我必须有两个不兼容的指针指向同一个内存块。然后,我可能会天真地编写这样的代码:

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 第 7 1 段允许的其他类型之一的对象设置别名是未定义的行为。不幸的是,你仍然可以以这种方式编码,可能会得到一些警告,让它编译良好,只是在你运行代码时出现奇怪的意外行为。

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

要了解为什么此行为未定义,我们必须考虑严格的别名规则会给编译器带来什么。基本上,使用此规则,它不必考虑插入指令来刷新每次循环运行的内容。相反,在优化时,通过一些令人讨厌的关于混叠的未强制假设,它可以省略这些指令,在循环运行之前加载并加载到 CPU 寄存器中一次,并加快循环的主体速度。在引入严格别名之前,编译器必须处于一种偏执状态,即任何先前的内存存储都可以更改其内容。因此,为了获得额外的性能优势,并假设大多数人不键入双关指针,引入了严格的别名规则。buffbuff[0]buff[1]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。如果是另一个单独编译的 API 的一部分,它可能有加载 buff 内容的指令。再说一次,也许你在 C++ 中,这是编译器认为它可以内联的一些模板化标头实现。或者,它可能只是您为了方便而在 .c 文件中编写的内容。无论如何,未定义的行为可能仍然随之而来。即使我们知道引擎盖下发生的一些事情,它仍然违反了规则,因此无法保证明确定义的行为。因此,仅仅通过包装一个采用我们的单词分隔缓冲区的函数并不一定有帮助。SendMessage

那么我该如何解决这个问题呢?

  • 使用联合。大多数编译器都支持这一点,而不会抱怨严格的别名。这在 C99 中是允许的,在 C11 中是明确允许的。

      union {
          Msg msg;
          unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)];
      };
    
  • 您可以在编译器中禁用严格别名(gcc 中的 f[no-]strict-aliasing))

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

初学者要当心

当两种类型相互叠加时,这只是一个潜在的雷区。您还应该了解字节序单词对齐方式,以及如何通过正确打包结构来处理对齐问题。

脚注

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

  • 与对象的有效类型兼容的类型,
  • 与对象的有效类型兼容的类型的限定版本,
  • 一种类型,该类型是对应于对象的有效类型,是有符号或无符号类型,
  • 一种类型,该类型是与对象的有效类型的限定版本相对应的有符号或无符号类型,
  • 在其成员中包含上述类型之一的聚合或联合类型(以递归方式包括子聚合或包含联合的成员),或者
  • 字符类型。

评论

22赞 Matthieu M. 11/12/2010
我似乎是在战斗之后来的。可以用得很远吗?我倾向于使用 而不是作为 的基础类型,因为我的字节没有签名,我不希望签名行为的怪异性(尤其是 wrt 溢出)unsigned char*char*unsigned charcharbyte
31赞 Thomas Eding 6/2/2011
@Matthieu:签名对别名规则没有区别,所以使用是可以的。unsigned char *
31赞 R. Martinho Fernandes 9/6/2011
从工会成员那里读到与上一个写信的人不同的行为,这难道不是未定义的行为吗?
29赞 R. Martinho Fernandes 9/22/2011
博洛克斯,这个答案完全是倒退的。它显示为非法的示例实际上是合法的,而它显示为合法的示例实际上是非法的。
9赞 M.M 1/13/2015
这个例子不清楚。它显示别名为 .但是,它是否与 兼容是由实现定义的。如果是 的 typedef,则代码实际上没有严格的别名冲突,否则会。我建议对其进行编辑,使这两种类型明显不兼容。unsigned intuint32_tunsigned intuint32_tuint32_tunsigned int
51赞 phorgan1 6/20/2011 #5

严格别名不仅指针,它还会影响引用,我为 boost developer wiki 写了一篇关于它的论文,它非常受欢迎,以至于我把它变成了我的咨询网站上的一个页面。它完全解释了它是什么,为什么它让人们如此困惑以及如何应对它。严格混叠白皮书。特别是,它解释了为什么联合是 C++ 的危险行为,以及为什么使用 memcpy 是 C 和 C++ 之间唯一可移植的修复程序。希望这对您有所帮助。

评论

4赞 Yakov Galka 11/10/2014
论文的“另一个破碎版本,引用了两次”部分毫无意义。即使有一个序列点,它也不会给出正确的结果。也许您的意思是使用轮班操作员而不是轮班分配?但是,代码定义明确,并且做了正确的事情。
6赞 slashmais 2/2/2015
好纸。我的看法是:(1)这种混叠的“问题”是对糟糕编程的过度反应——试图保护糟糕的程序员免受他/她的坏习惯的影响。如果程序员有良好的习惯,那么这种混叠只是一个麻烦,可以安全地关闭检查。 (2)编译器端优化只应在众所周知的情况下进行,如有疑问,应严格遵循源代码;简单地说,强迫程序员编写代码来迎合编译器的特质是错误的。更糟糕的是,让它成为标准的一部分。
5赞 curiousguy 8/16/2015
@slashmais (1) “是对糟糕编程的过度反应”胡说八道。这是对坏习惯的拒绝。你这样做吗?你付出了代价:对你没有保证!(2)众所周知的案例?哪些?严格的混叠规则应该是“众所周知的”!
6赞 supercat 11/21/2015
@curiousguy:在澄清了一些混淆点之后,很明显,带有别名规则的 C 语言使程序无法实现与类型无关的内存池。某些类型的程序可以通过 malloc/free 来完成,但其他类型的程序需要更好地针对手头的任务定制内存管理逻辑。我想知道为什么 C89 的理由使用如此糟糕的例子来说明混叠规则的原因,因为他们的例子使规则看起来不会在执行任何合理的任务时造成任何重大困难。
6赞 kchoi 7/15/2016
@curiousguy,大多数编译器套件在 -O3 上都默认包含 -fstrict-aliasing,并且这种隐藏的契约被强加给从未听说过 TBAA 并像系统程序员一样编写代码的用户。我并不是要让系统程序员听起来不诚实,但这种优化应该被排除在默认的 -O3 选项之外,并且应该对于那些知道 TBAA 是什么的人来说是一种选择加入的优化。查看编译器“错误”并不好玩,这些错误被证明是违反 TBAA 的用户代码,尤其是跟踪用户代码中的源代码级别违规。
153赞 Ben Voigt 8/10/2011 #6

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

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

  • 对象的动态类型,
  • 对象动态类型的 CV 限定版本,
  • 一种类型,该类型是与对象的动态类型相对应的有符号或无符号类型,
  • 一种类型,该类型是对应于对象的动态类型的 CV 限定版本的有符号或无符号类型,
  • 在其成员中包含上述类型之一的聚合或联合类型(以递归方式包括子聚合或包含联合的成员),
  • 一个类型,该类型是对象的动态类型的(可能是 CV 限定的)基类类型,
  • a 或 type。charunsigned char

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

如果程序尝试通过以下类型之一以外的 glvalue 访问对象的存储值,则行为未定义:

  • 对象的动态类型,
  • 对象动态类型的 CV 限定版本,
  • 类似于对象的动态类型(如 4.4 中所定义)的类型,
  • 一种类型,该类型是与对象的动态类型相对应的有符号或无符号类型,
  • 一种类型,该类型是对应于对象的动态类型的 CV 限定版本的有符号或无符号类型,
  • 在其元素或非静态数据成员中包含上述类型之一的聚合或联合类型(以递归方式包括子聚合或包含联合的元素或非静态数据成员),
  • 一个类型,该类型是对象的动态类型的(可能是 CV 限定的)基类类型,
  • a 或 type。charunsigned char

有两个小变化:glvalue 而不是 lvalue,以及对聚合/联合情况的澄清。

第三个变化提供了更强的保证(放宽了强锯齿规则):现在对别名安全的类似类型的新概念。


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

对象的存储值只能由左值访问 具有以下类型之一的表达式:73) 或 88)

  • 与对象的有效类型兼容的类型,
  • 与有效类型兼容的类型的限定版本 对象,
  • 一种类型,该类型是与 对象的有效类型,
  • 一种类型,该类型是与 对象有效类型的限定版本,
  • 包含上述类型之一的聚合或联合类型 其成员中的类型(包括递归的 子聚合或包含的联合),或
  • 字符类型。

73) 或 88) 此列表的目的是指定对象可能别名或可能不会别名的情况。

评论

1赞 phorgan1 1/5/2012
请看 C89 Rationale cs.technion.ac.il/users/yechiel/CS/C++draft/rationale.pdf 第 3.3 节,其中谈到了这一点。
2赞 supercat 11/28/2015
如果有一个结构类型的左值,获取成员的地址,并将其传递给使用它作为指向成员类型的指针的函数,这是否被视为访问成员类型的对象(合法)或结构类型的对象(禁止)?很多代码都认为以这种方式访问结构是合法的,我认为很多人会对被理解为禁止此类行为的规则大喊大叫,但目前尚不清楚确切的规则是什么。此外,工会和结构的处理方式相同,但每个工会和结构的合理规则应该不同。
3赞 Ben Voigt 11/28/2015
@supercat:结构规则的措辞方式,实际访问始终是原始类型。然后,通过对基元类型的引用进行访问是合法的,因为类型匹配,而通过对包含结构类型的引用进行访问是合法的,因为它是特别允许的。
1赞 supercat 11/28/2015
@BenVoigt:根据这种解释,如果 和 是以第一域为第一个域的结构,并且不需要比对齐更粗糙的结构,那么给定 );' 编译器将不允许对 和 之间的锯齿做出任何假设。因为它们都可以识别 类型的存储。我不认为这是本意。S1S2int x;intvoid blah(S1 *p1, S2, *p2p1->xp2->xint
2赞 supercat 11/29/2015
@BenVoigt:我不认为通用的初始序列是有效的,除非通过联合完成访问。请参阅 goo.gl/HGOyoK,了解 gcc 在做什么。如果通过成员类型的左值(不使用 union-member-access 运算符)访问联合类型的左值是合法的,那么即使使用指针进行修改,也需要合法,这将否定别名规则旨在促进的大多数优化。wow(&u->s1,&u->s2)u
37赞 Ingo Blackman 5/14/2013 #7

作为 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 版本)这会输出“严格混叠问题”,因为编译器假设“h”不能与“check”函数中的“k”相同。因此,编译器会优化 away 并始终调用 printf。gcc -O2 -o check check.cif (*h == 5)

对于那些感兴趣的人,这里是由 gcc 4.6.3 生成的 x64 汇编程序代码,在 ubuntu 12.04.2 for x64 上运行:

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

因此,if 条件从汇编程序代码中完全消失了。

评论

0赞 philippe lhardy 12/31/2013
如果在 check() 中添加第二个短 * j 并使用它 ( *j = 7 ),则优化会消失,因为 ggc 不会,如果 h 和 j 不是实际值,则不会指向相同的值。是的,优化真的很聪明。
2赞 supercat 3/23/2017
为了让事情更有趣,请使用指向不兼容但具有相同大小和表示形式的类型的指针(在某些系统上确实如此,例如 和 *)。人们可能会认为,如果一个理智的编译器存储相同,那么它们应该能够访问相同的存储,但这种处理方式已不再流行。long long*int64_tlong long*int64_t*
2赞 S.S. Anne 7/31/2019
咕噜咕噜......x64 是 Microsoft 的约定。请改用 amd64 或 x86_64。
24赞 supercat 4/27/2017 #8

根据 C89 的基本原理,该标准的作者不希望要求编译器提供如下代码:

int x;
int test(double *p)
{
  x=5;
  *p = 1.0;
  return x;
}

应该要求在赋值和 return 语句之间重新加载 的值,以便允许可能指向 的可能性,并且赋值可能会因此改变 的值。编译器应该有权假定在上述情况下不会有别名的概念是没有争议的。xpx*px

不幸的是,C89 的作者编写规则的方式,如果从字面上理解,甚至会使以下函数调用 Undefined Behavior:

void test(void)
{
  struct S {int x;} s;
  s.x = 1;
}

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

大部分问题是由缺陷报告 #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*pinttestpstruct Ssp

如果代码稍有更改...

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

在这里,标记行上的 和 的访问之间存在混叠冲突,因为在执行时存在另一个引用,该引用将用于访问同一存储ps.x

如果缺陷报告 028 说原始示例调用了 UB,因为这两个指针的创建和使用之间存在重叠,那么这将使事情变得更加清晰,而无需添加“有效类型”或其他此类复杂性。

评论

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

在阅读了许多答案之后,我觉得有必要补充一些东西:

严格混叠(我稍后会描述)很重要,因为

  1. 内存访问可能很昂贵(性能方面),这就是为什么数据在写回物理内存之前在 CPU 寄存器中操作的原因

  2. 如果两个不同 CPU 寄存器中的数据将被写入相同的内存空间,那么当我们用 C 语言编码时,我们无法预测哪些数据会“存活”。

    在汇编中,我们手动编写 CPU 寄存器的加载和卸载代码,我们将知道哪些数据保持不变。但是C(谢天谢地)把这个细节抽象出来了。

由于两个指针可以指向内存中的同一位置,因此这可能会导致处理可能的冲突的复杂代码

这种额外的代码很慢,并且会损害性能,因为它会执行额外的内存读/写操作,这些操作既慢又(可能)不必要。

严格别名规则允许我们避免冗余的机器代码,在这种情况下,假设两个指针不指向同一个内存块应该是安全的(另请参阅关键字)。restrict

严格别名表示,可以安全地假设指向不同类型的指针指向内存中的不同位置。

如果编译器注意到两个指针指向不同的类型(例如,an 和 a ),它将假定内存地址不同,并且不会防止内存地址冲突,从而导致机器代码速度更快。int *float *

例如

假设以下函数:

void merge_two_ints(int *a, int *b) {
  *b += *a;
  *a += *b;
}

为了处理以下情况(两个指针都指向同一个内存),我们需要对将数据从内存加载到 CPU 寄存器的方式进行排序和测试,因此代码可能最终如下所示:a == b

  1. load 和 from memory。ab

  2. 搭。ab

  3. 保存重新加载ba

    (从 CPU 寄存器保存到内存,并从内存加载到 CPU 寄存器)。

  4. 搭。ba

  5. 保存(从 CPU 寄存器)到内存。a

步骤 3 非常慢,因为它需要访问物理内存。但是,需要防止指向同一内存地址的实例。ab

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

  1. 这可以通过两种方式告诉编译器,即使用不同的类型来指向。即:

    void merge_two_numbers(int *a, long *b) {...}
    
  2. 使用关键字。即:restrict

    void merge_two_ints(int * restrict a, int * restrict b) {...}
    

现在,通过满足严格别名规则,可以避免步骤 3,并且代码的运行速度将大大加快。

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

  1. load 和 from memory。ab

  2. 搭。ab

  3. 将结果保存到 和 。ab

这种优化以前是不可能完成的,因为可能存在碰撞(其中 和 将增加三倍而不是两倍)。ab

评论

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

注意

本文摘自我的“什么是严格混叠规则,我们为什么关心?

什么是严格锯齿?

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

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

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

初步示例

让我们看一些例子,然后我们可以准确地讨论标准所说的内容,检查一些进一步的例子,然后看看如何避免严格的锯齿并捕获我们遗漏的违规行为。这是一个不应该令人惊讶的例子(现场示例):

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

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

0
1

这可能出乎意料,但完全有效,因为我们调用了未定义的行为。float 不能有效地别名 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 段中规定如下:

对象的存储值只能由具有以下类型之一的左值表达式访问: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 有一个扩展并且允许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 标准草案是怎么说的

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

如果程序尝试通过以下类型之一以外的 glvalue 访问对象的存储值,则行为未定义: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++ 中未定义行为参见实时示例):

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 方法或 union 方法相同的代码,用于类型双关语。检查生成的代码,我们看到它只使用 register mov(实时编译器资源管理器示例)。

C++20 和 bit_cast

在 C++20 中,我们可能会获得 bit_cast在提案的链接中提供实现),它提供了一种简单而安全的方式来键入双关语,并且可以在 constexpr 上下文中使用。

下面是一个示例,说明如何使用 bit_cast 将 pun a unsigned int 键入为 float,(实时查看):

std::cout << bit_cast<float>(0x447a0000) << "\n"; //assuming sizeof(float) == sizeof(unsigned int)

如果 ToFrom 类型的大小不同,则需要我们使用中间结构15。我们将使用一个包含 sizeof( unsigned int ) 字符数组的结构体(假设 4 字节无符号 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,它可以捕获未对齐的负载和存储。尽管这些不是直接的严格别名冲突,但它们是严格别名冲突的常见结果。例如,以下情况在使用 clang 使用 -fsanitize=address 构建时将生成运行时错误

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

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

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。此清理程序在影子内存段中添加类型检查信息,并检查访问以查看它们是否违反别名规则。该工具可能应该能够捕获所有别名冲突,但可能会产生较大的运行时开销。

评论

0赞 Bhargav Rao 8/6/2018
评论不用于扩展讨论;此对话已移至 Chat
9赞 Gabriel 11/30/2018
如果可以的话,+10,写得很好,解释得很好,也来自双方,编译器编写者和程序员......唯一的批评:如果上面有反例就好了,看看标准禁止什么,它不明显:-)
4赞 Gro-Tsen 11/15/2019
很好的答案。我唯一遗憾的是,最初的例子是用 C++ 给出的,这使得像我这样只知道或关心 C 而不知道可能做什么或可能意味着什么的人来说很难理解。(提到C++是可以的,但最初的问题是关于C和IIUC的,这些例子可以同样有效地用C编写。reinterpret_castcout
0赞 Michael IV 4/9/2020
关于类型排序:因此,如果我将某个 X 类型的数组写入文件,然后从该文件中读取该数组到指向 void* 的内存中,然后我将该指针转换为数据的实际类型以使用它 - 这是未定义的行为?
0赞 Bogdan 12/17/2020
为什么 C++17 标准草案怎么说部分的 (11.2) 示例中的 glvalue?它看起来像左值,是吗?它看起来与 C11 标准怎么说的部分中的第二个示例相同?cip
-5赞 curiousguy 7/9/2018 #11

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

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

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

也来自 glvalue 的定义

glvalue 是一个表达式,其计算结果决定了 一个对象,(...剪)

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

评论

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