【问题标题】:Is this use of unions strictly conforming?这种联合的使用是否严格符合?
【发布时间】:2018-02-22 15:35:02
【问题描述】:

给定代码:

struct s1 {unsigned short x;};
struct s2 {unsigned short x;};
union s1s2 { struct s1 v1; struct s2 v2; };

static int read_s1x(struct s1 *p) { return p->x; }
static void write_s2x(struct s2 *p, int v) { p->x=v;}

int test(union s1s2 *p1, union s1s2 *p2, union s1s2 *p3)
{
  if (read_s1x(&p1->v1))
  {
    unsigned short temp;
    temp = p3->v1.x;
    p3->v2.x = temp;
    write_s2x(&p2->v2,1234);
    temp = p3->v2.x;
    p3->v1.x = temp;
  }
  return read_s1x(&p1->v1);
}
int test2(int x)
{
  union s1s2 q[2];
  q->v1.x = 4321;
  return test(q,q+x,q+x);
}
#include <stdio.h>
int main(void)
{
  printf("%d\n",test2(0));
}

整个程序中存在一个联合对象--q。其活动成员设置为v1,然后设置为v2,然后再次设置为v1。代码仅在q.v1 或结果指针上使用地址运算符,当该成员处于活动状态时,同样q.v2。由于p1、p2和p3都是同一类型,所以使用p3-&gt;v1访问p1-&gt;v1,使用p3-&gt;v2访问p2-&gt;v2应该是完全合法的。

我没有看到任何可以证明编译器无法输出 1234 的理由,但是包括 clang 和 gcc 在内的许多编译器会生成输出 4321 的代码。我认为正在发生的事情是他们认为 p3 上的操作实际上不会更改内存中任何位的内容,它们可以完全被忽略,但我在标准中没有看到任何可以证明忽略p3 用于将数据从p1-&gt;v1 复制到p2-&gt;v2 和反之亦然。

标准中是否有任何内容可以证明这种行为是合理的,或者编译器根本不遵循它?

【问题讨论】:

  • 如果代码是unsigned x 而不是unsigned short x,你看到同样的问题吗?
  • @chux:是的。早期版本的代码还测试了将对象的字节复制到 unsigned char 类型的两个变量,然后将它们写回(编译器也不支持),使用两个字节比四个字节更方便。问题是编译器完全优化了p3 上的操作并丢失了由此提供的别名相关信息。
  • 我怀疑unsigned 会以与unsigned short 类似的方式失败。使用unsigned,我们可以搁置任何通常的促销问题 - 这不应该影响这一点。
  • @chux:虽然unsigned short 可以升级为int 或unsigned,但将32767u 及以下值强制转换为int 完全由标准在所有实现中定义。
  • @curiousguy:我刚刚发布了另一个令人讨厌的问题,它涉及内存写入的重新排序,而不是读取与写入的排序。我发现后一个特别好奇,因为它不涉及编译器优化读取和写入,该读取和写入本应强制其他一些读取和写入的顺序,但经过调整,以便将有条件写入变成无条件写入使用有条件选择的值写入。

标签: c gcc clang strict-aliasing


【解决方案1】:

我没有阅读标准,但是在严格混叠模式下使用指针(即使用-fstrict-alising)是危险的。见gcc online doc:

特别注意这样的代码:

union a_union {
  int i;
  double d;
};

int f() {
  union a_union t;
  t.d = 3.0;
  return t.i;
}

从不同的工会成员而不是最近写入的成员(称为type-punning)阅读的做法很常见。即使使用-fstrict-aliasing,也允许使用类型双关语,前提是通过联合类型访问内存。因此,上面的代码按预期工作。请参阅结构联合枚举和位域实现。但是,此代码可能不会:

int f() {
   union a_union t;
   int* ip;
   t.d = 3.0;
   ip = &t.i;
   return *ip;
}

同样,通过获取地址、转换结果指针和取消引用结果的访问具有未定义的行为,即使转换使用联合类型,例如:

int f() {
  double d = 3.0;
  return ((union a_union *) &d)->i;
}

-fstrict-aliasing 选项在 -O2、-O3、-Os 级别启用。

在第二个例子中找到类似的东西吧?

【讨论】:

  • 请注意,我的示例仅在写入 该成员 后获取联合成员的地址,并在写入另一个成员之前放弃该指针。问题是 gcc 和 clang 都试图应用两个相互冲突的优化:省略将读取一个联合成员然后将完全相同的位模式写入另一个的代码将是孤立的一个很好的优化,但会绊倒以后的优化。可悲的是,C 标准的作者没有更好地说明编译器通常会假设没有别名当没有任何证据时...
  • ...但质量编译器不应忽视有用案例中的别名证据。虽然标准没有明确规定获取工会成员的地址(“活跃”或“不活跃”)应被视为别名的证据,但最可能的原因是他们认为这是显而易见的。如果编译器识别出将联合成员地址作为别名的证据,那么省略读写序列就无关紧要了。
  • @supercat 您正在获取联合成员的地址(即,从“联合 s1s2”中提取“结构 s2”的地址),根据上面提供的示例,这是非法的.
  • @supercat 我阅读了您在其他答案中提供的几乎所有 cmets,但我仍然不清楚您在争论什么。你想说啥?你认为 gcc 和 clang 都做错了吗?或者你只是在指责 C 标准?
  • clang 和 gcc 的行为在这里明显不符合要求。此外,已发布的标准基本原理明确指出,与其试图强制执行使实现有用所需的一切,它期望如果实现按照标准的要求去做,他们自然会做其他必要的事情使它们有用。为了有效地维护标准的实现,它必须有一种方法来适应可能改变联合的活动类型或存储的有效类型的操作,而不知道该操作是否......
【解决方案2】:

对于标准的严格解释,此代码可能不符合。让我们重点关注著名的§6.5p7的文本:

一个对象的存储值只能由具有以下之一的左值表达式访问 以下类型:
— 与对象的有效类型兼容的类型,
— 与对象的有效类型兼容的类型的限定版本,
— 对应于有效类型的有符号或无符号类型 对象,
— 一种类型,它是有符号或无符号类型,对应于 对象的有效类型,
— 一种聚合或联合类型,其中包含上述类型之一 成员(递归地包括子聚合或包含联合的成员),或
— 一种字符类型。

(强调我的)

您的函数 read_s1x() 和 write_s2x() 与我在上面标记的粗体在您的整个代码的上下文中执行了相反。仅凭这一段,您就可以断定这是不允许的:指向union s1s2 的指针将被允许将指向struct s1 的指针作为别名,但反之则不行。

当然,这种解释意味着如果您在test() 中手动“内联”这些函数,则代码必须按预期工作。对于i686-w64-mingw32,gcc 6.2 确实是这种情况。


添加两个支持上述严格解释的论点:

  • 虽然总是允许使用char * 为任何指针起别名,但字符数组不能使用任何其他类型的别名。

  • 考虑到(此处不相关)§6.5.2.3p6:

    一个特殊的保证是为了简化联合的使用:如果联合包含 几个结构共享一个共同的初始序列(见下文),如果联合 对象当前包含这些结构之一,允许检查常见的 其中任何一个的初始部分在声明联合的完整类型的任何地方 可见。

    (再次强调我的)——典型的解释是 可见 直接在相关函数的范围内,而不是“在翻译单元的某个地方”......所以这个保证不'不包括一个函数,该函数接受一个指向 structs 之一的指针,它是 union 的成员。

【讨论】:

  • 将&amp; 运算符应用于结构或联合成员会生成该成员类型的指针。此外,如果结构或联合成员是数组,则不可能通过使用其构成类型的指针对该成员 except 执行任何操作。虽然我想人们可以以这样的方式阅读标准,说将地址运算符应用于结构或联合成员将产生一个实际上不能用于任何目的的指针,除非它首先转换为字符类型, 允许实现处理...的结果似乎更合理
  • ...&amp; 运算符的类型与void* 以外的任何指针类型不兼容,除非它应用于字符类型的成员(在这种情况下,它自然会产生char*)。碰巧的是,虽然我没有在所有编译器上测试所有这些,但其他控制有效类型的方法(例如将对象的所有单个字节读取到类型为 unsigned char 的离散对象,然后写入所有对象的单个字节)在 gcc 和 clang 上也失败。我认为问题在于编译器缺乏任何可能的代码概念......
  • ...更改对象的有效类型或联合的活动成员,但不需要生成任何实际的机器代码加载或存储。
【解决方案3】:

这与符合或不符合无关 - 它是优化“陷阱”之一。您的所有数据结构都已优化,并且您将相同的指针传递给优化的数据,因此执行树被简化为值的简单 printf。

  sub rsp, 8
  mov esi, 4321
  mov edi, OFFSET FLAT:.LC0
  xor eax, eax
  call printf
  xor eax, eax
  add rsp, 8
  ret

要更改它,您需要使此“转移”功能易于产生副作用并强制执行实际分配。它将强制优化器不减少执行树中的那些节点:

int test(union s1s2 *p1, union s1s2 *p2, volatile union s1s2 *p3)
/* ....*/

main:
  sub rsp, 8
  mov esi, 1234
  mov edi, OFFSET FLAT:.LC0
  xor eax, eax
  call printf
  xor eax, eax
  add rsp, 8
  ret

这是一个非常简单的测试,只是人为地让它变得更复杂了。

【讨论】:

  • 当然使代码更复杂会导致编译器正确处理它。然而,我在标准中没有看到任何内容允许符合标准的编译器需要这种无用的代码,并且质疑优化器要求程序员添加他们知道无用的代码的合理性。
  • @supercat 使用此登录名更改或减少代码(例如不调用函数)的任何优化都不符合。这就是陷阱——所有的局部变量,没有使用它们等等。以这种方式编写代码并允许优化,程序员应该考虑到这些影响。
  • 将您的代码更改为:int test(union s1s2 *p1, union s1s2 *p2, union s1s2 *p3) { if (read_s1x(&amp;p1-&gt;v1)) { unsigned short temp; temp = p3-&gt;v1.x; p3-&gt;v2.x = temp; write_s2x(&amp;p2-&gt;v2,1234); temp = p3-&gt;v2.x; p3-&gt;v1.x = temp; printf("%d\n",p3-&gt;v2.x); } return read_s1x(&amp;p1-&gt;v1); }
  • 我发布的程序被故意设计为“诱饵”编译器进行非法优化,但这并不能使它们的优化符合要求。如果编译器编写者想要指定某些优化使代码不符合标准,并接受与此类优化不兼容并不意味着代码“损坏”,而仅仅意味着代码和优化器不适合彼此,那将没关系,尽管更好的编译器应该尽量与现有代码兼容。
  • 问题是标准中是否有任何东西可以证明似乎很普遍的行为是正当的,或者标准中与别名有关的部分是否应该被视为毫无意义,因为无论如何都没有人遵循它们?
【解决方案4】:

我相信你的代码是合规的,GCC和Clang的-fstrict-aliasing模式存在缺陷。

我找不到 C 标准的正确部分,但是当我在 C++ 模式下编译你的代码时也会出现同样的问题,我确实找到了 C++ 标准的相关段落。

在 C++ 标准中,[class.union]/5 定义了在联合访问表达式上使用运算符 = 时会发生什么。 C++ 标准规定,当内置运算符= 的成员访问表达式中涉及联合时,联合的活动成员将更改为表达式中涉及的成员(如果类型具有普通构造函数,但因为这是 C 代码,它确实有一个简单的构造函数)。

注意write_s2x不能更改联合的活动成员,因为联合不涉及赋值表达式。您的代码不会假设会发生这种情况,所以没关系。

即使我使用位置 new 来显式更改哪个联合成员处于活动状态,这应该是对编译器的一个提示,即活动成员已更改,GCC 仍然会生成输出 4321 的代码。

这看起来像 GCC 和 Clang 的错误,假设活动联合成员的切换不能在这里发生,因为它们无法识别 p1、p2 和 p3 都指向同一个对象的可能性。

GCC 和 Clang(以及几乎所有其他编译器)支持对 C/C++ 的扩展,您可以在其中读取联合的非活动成员(得到任何潜在的垃圾值),但前提是您在涉及联合的成员访问表达式。 如果 v1 不是活动成员,read_s1x 将不会在此实现特定规则下定义为行为,因为联合不在成员访问表达式中。但是因为v1 是活跃成员,所以这无关紧要。

这是一个复杂的案例,我希望我的分析是正确的,作为一个不是编译器维护者或委员会成员的人。

【讨论】:

  • 我认为许多编译器似乎遇到的根本问题是它们的中间代码缺少指令,这些指令会迫使编译器表现得好像它可以访问任何类型 T 的任意对象,而没有实际执行这种访问的代码。让编译器生成通过p3 实际读写的机器代码会很愚蠢,但我认为编译器的中间代码没有任何其他方式来表达标准规定的语义。
  • 如果代码对 p3 做了一些需要编译器实际执行访问的操作,编译器在识别别名时将没有问题。如果编译器决定优化不应生成任何机器指令但会影响对象的有效类型或活动成员的代码,则会出现问题。使用char-type 访问时也会出现类似问题。严格别名的支持者声称,别名问题的解决方案是使用字符指针或联合,但如果编译器优化了这些东西,这将无济于事。
  • @PeterJ_01:据我所见,大多数此类示例都涉及代码,使用对标准的充分扭曲的解释,可能会被视为调用 UB。我的目标是提供一个示例,其行为由标准明确、明确且不可否认地定义。
  • @PeterJ_01:不仅仅是 gcc 和 clang。 Godbolt 上的许多编译器的行为方式相同,这表明该行为是设计使然,这让我想知道编译器编写者是否在解释标准以证明他们的行为是合理的。
  • @Myria:顺便说一下,虽然特定示例旨在尽可能简单地显示问题,但问题可能会出现在实际代码中。例如,如果代码检查一个数组元素,使用循环将数组中的所有内容转换为具有相同表示的另一种类型,将某些事物作为该新类型进行操作,然后使用另一个循环将所有内容转换回来,循环转换可能会得到优化。我认为问题在于标准用对象而不是左值来描述有效类型,但没有办法......
猜你喜欢
  • 1970-01-01
  • 2013-08-27
  • 2015-03-15
  • 2012-08-13
  • 2012-05-22
  • 2019-10-31
  • 1970-01-01
  • 2011-10-31
  • 2016-12-03
相关资源
最近更新 更多