这种使用联合体的方式是否严格符合规范?

18

考虑以下代码:

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。由于p1p2p3都是相同类型,因此使用p3->v1访问p1->v1应该是完全合法的,并且使用p3->v2访问p2->v2也是合法的。
我没有看到任何可以证明编译器不能输出1234的东西,但是许多编译器(包括clang和gcc)生成输出4321的代码。我认为发生了什么是他们决定p3上的操作实际上不会改变内存中的任何位的内容,它们可以完全被忽略,但我没有看到标准中有任何可以证明忽略p3被用来从p1->v1复制数据到p2->v2和反之亦然的事实的东西。
标准中是否有任何可以证明这种行为的东西,还是编译器只是没有遵循标准?

如果代码是 unsigned x 而不是 unsigned short x,你是否看到了相同的问题? - chux - Reinstate Monica
@chux: 是的。代码的早期版本还测试了将对象的字节复制到两个类型为“unsigned char”的变量中,然后将它们写回(编译器也不支持),用两个字节比四个更方便。问题是编译器完全优化了对p3的操作,并因此失去了提供的别名相关信息。 - supercat
我怀疑unsigned会以与unsigned short类似的方式失败。使用unsigned,我们可以放置任何“通常晋升”问题 - 这不应该影响到这个。 - chux - Reinstate Monica
一个潜在的“严格别名”问题的例子,没有类型游戏! - curiousguy
1
@curiousguy:我刚刚发布了另一个恶意代码,它涉及到重新排序内存写入,而不是读取与写入的顺序。我发现后者特别好奇,因为它并不涉及编译器优化应该强制执行其他读取和写入顺序的读取和写入的情况,而是被调整为将应该是有条件的写入转换为具有有条件选择值的无条件写入。 - supercat
显示剩余2条评论
4个回答

9
我认为你的代码符合规范,但是GCC和Clang的-fstrict-aliasing模式存在缺陷。我找不到C标准的正确部分,但在C++模式下编译您的代码时,我遇到了相同的问题,并且找到了相关的C++标准段落。
在C++标准中,[class.union]/5定义了当使用联合访问表达式的运算符=时会发生什么。 C++标准规定,当一个联合体涉及内置运算符=的成员访问表达式时,联合体的活动成员将更改为涉及表达式的成员(如果类型具有平凡的构造函数,则因为这是C代码,它确实具有平凡的构造函数)。
请注意,write_s2x不能更改联合体的活动成员,因为联合体未涉及赋值表达式。 您的代码并未假设会发生这种情况,因此可以通过。
即使我使用放置new来明确更改哪个联合成员是活动成员,这应该是对编译器活动成员已更改的提示,GCC仍然生成输出4321的代码。
这看起来像是GCC和Clang的错误,他们假设活动联合成员在这里不能更改,因为他们未能识别p1p2p3都指向同一对象的可能性。
GCC和Clang(以及几乎所有其他编译器)支持C / C ++的扩展,您可以读取联合的非活动成员(得到任何可能的垃圾值作为结果),但仅当您在涉及该联合的成员访问表达式中进行此访问时。如果v1不是活动成员,则根据这个特定于实现的规则,在该联合体不在成员访问表达式内的情况下,read_s1x将不被定义行为。但是,因为v1是活动成员,所以这应该没有关系。
这是一个复杂的案例,我希望我的分析是正确的,作为一个不是编译器维护者或委员会成员的人。

如果代码对p3进行某些需要编译器实际执行访问的操作,编译器将没有问题识别别名。问题出现在编译器决定优化代码时,这些代码不应生成任何机器指令,但会影响对象的有效类型或活动成员。使用char类型访问时也会出现类似的问题。严格别名的支持者声称解决别名问题的方法是使用字符指针或联合体,但如果编译器优化掉了这些东西,那就没有帮助了。 - supercat
2
@PeterJ_01:从我所见,大多数这样的示例涉及代码,可能会使用足够扭曲的标准解释来视为调用UB。我的目标是有一个例子,其行为明确、明确且无可否认地由标准定义。 - supercat
@supercat,你已经决定允许优化,那么你应该了解其影响,或者禁用优化后再编译。 - 0___________
2
@PeterJ_01:这不仅仅是gcc和clang的问题。godbolt上的许多编译器都表现出类似的方式,这表明这种行为是有意设计的,这让我想知道编译器作者是否在解释标准时采用了这样的方式来证明他们的行为。 - supercat
1
@Myria: 顺便说一句,虽然这个特定的示例是人为的,以尽可能简单地显示问题,但问题在现实世界的代码中可能会出现。例如,如果代码检查一个数组元素,使用一个循环将数组中的所有内容转换为具有相同表示的另一种类型,对一些东西进行某种新类型的操作,然后再使用另一个循环将所有内容转换回来,执行转换的循环可能会被优化掉。我认为问题在于标准是根据对象而不是lvalue描述Effective Types,但没有办法... - supercat
显示剩余15条评论

3
使用严格的标准解释,这段代码可能不符合规范。让我们关注一下著名的§6.5p7的文本:
“只有以下类型的lvalue表达式才能访问对象的存储值:
- 与对象的有效类型兼容的类型
- 对象的有效类型兼容的限定版本的类型
- 是与对象的有效类型相对应的带符号或无符号类型
- 是与对象的有效类型的限定版本相应的带符号或无符号类型
- 包括前述类型之一在其成员中(包括子聚合或包含的联合的成员,递归地)的聚合或联合类型,或者
- 字符类型。”
(强调是我的)
你的函数read_s1x()和write_s2x()在整个代码的上下文中做了与我强调部分相反的操作。仅凭这一段文字,你可以得出结论:允许将指向union s1s2的指针别名为指向struct s1的指针,但反过来则不允许。
当然,这种解释意味着如果你在test()中手动内联这些函数,则代码必须按预期工作。在这里,对于i686-w64-mingw32的gcc 6.2而言确实如此。
添加两个支持严格解释的参数:
  • 虽然任何指针都可以用char *来别名,但字符数组不能被其他类型别名。

  • 考虑(这里不相关的)§6.5.2.3p6

    为了简化联合的使用,有一个特殊的保证:如果联合包含多个结构体,它们共享一个公共的初始序列(见下文),并且如果联合对象当前包含其中之一,那么允许在任何声明联合完成类型可见的地方查看它们中任何一个的公共初始部分。

    (再次强调是我的)- 典型的解释是“可见”意味着直接在相关函数的作用域内,而不是“翻译单元中的某个地方”...所以这个保证不包括接受指向union成员之一的struct指针的函数。


1
&运算符应用于结构体或联合体成员会产生该成员类型的指针。此外,如果结构体或联合体成员是一个数组,除非使用其组成类型的指针,否则无法对该成员进行任何操作。虽然我想人们可能可以以这种方式阅读标准,即将取地址运算符应用于结构体或联合体成员将产生一个指针,但实际上除非首先将其转换为字符类型,否则该指针无法用于任何目的,但更合理的做法似乎是允许实现来处理... - supercat
&运算符在除了成员为字符类型(这种情况下自然会生成char *)以外的任何指针类型上都不兼容,具有不兼容性。尽管我尚未在所有编译器上测试过它们,但控制有效类型的其他方法(例如将对象的所有单个字节读取为类型为“ unsigned char”的离散对象,然后写入对象的所有单个字节)也无法在gcc和clang上实现。我认为问题是编译器缺乏任何可能的代码概念... - supercat
更改对象的有效类型或联合的活动成员,但不需要生成任何实际的机器代码加载或存储。 - supercat

0

我没有阅读标准,但在严格别名模式下玩指针(即使用-fstrict-alising)是很危险的。请参考gcc在线文档

特别注意像这样的代码:

union a_union {
  int i;
  double d;
};

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

从不同于最近写入的联合成员读取(称为“类型转换”)是常见的。即使使用了-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下启用。 在第二个例子中找到类似的内容了吗?

1
请注意我的示例,它只在写入该成员后获取联合成员的地址,并在写入另一个成员之前放弃该指针。问题是gcc和clang都尝试应用两个冲突的优化:省略将读取一个联合成员然后将完全相同的位模式写入另一个成员的代码将是一种很好的优化,但会使后续优化出现问题。悲剧的是,C标准的作者没有更好地说明编译器通常可以假设没有别名当没有证据时... - supercat
...但是高质量的编译器不应该忽视有用情况下的别名证据。虽然标准并没有明确说明获取联合成员(“活动”或非“活动”)的地址应被认为是别名的证据,但最可能的原因是他们认为这是显而易见的。如果编译器将获取联合成员地址的行为视为别名的证据,则读写序列的省略将无关紧要。 - supercat
@supercat,您正在获取联合体成员的地址(即从“union s1s2”中获取“struct s2”的地址),根据上面提供的示例,这是非法的。 - walkerlala
@supercat,我已经阅读了你在其他答案中提供的几乎所有评论,但我仍然不清楚你在争论什么。你的观点是什么?你认为gcc和clang都做错了吗?还是你只是在责怪C标准? - walkerlala
这里clang和gcc的行为明显不符合标准。此外,标准的公开理由明确指出,它期望如果实现按照标准要求做到了,它们自然会做其他必要的事情来使它们有用,而不是试图强制实现所有需要使实现有用的东西。为了有效地维护标准,实现必须有一种方式来适应可能改变联合的活动类型或存储的有效类型的操作,而不知道该操作是否... - supercat
实际上是否会更改它。具有此功能的实现可以通过将联合成员地址视为具有这种语义来最轻松地维护标准。我认为标准的作者没有考虑到实现可能会寻求避免这样做,而是使用更复杂且不太有用的方式来满足标准的要求。 - supercat

-2

这不是关于符合或不符合的问题 - 这是优化“陷阱”之一。所有数据结构都已经被优化掉了,你将同一个指针传递给优化掉的数据,因此执行树被简化为仅打印该值。

  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

这只是一个相当简单的测试,只是人工地制造了一些复杂性。


1
当然,让代码变得更加复杂可能会导致编译器正确处理它。然而,我在标准中没有看到任何允许符合标准的编译器要求这种无用代码的内容,并且质疑需要程序员添加他们知道是无用的代码的优化器的理智性。 - supercat
1
@supercat使用此登录,任何优化都更改或减少代码(例如不调用函数)都不符合规范。这是一个陷阱 - 所有本地变量,没有使用它们中的任何一个等。以这种方式编写代码并允许优化,程序员应考虑这些影响。 - 0___________
将您的代码更改为: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; printf("%d\n",p3->v2.x); } return read_s1x(&p1->v1); } - 0___________
我发布的程序是故意编写的,旨在“诱骗”编译器进行非法优化,但这并不意味着它们的优化是符合规范的。如果编译器编写者想要指定某些优化使代码不符合规范,并接受这种不兼容性并不意味着代码“损坏”,而仅仅意味着代码和优化器不适合彼此,那就没问题了,尽管更好的编译器应该尝试最大限度地与现有代码兼容。 - supercat
@supercat 我知道这是故意的滥用 :). 没有一个理智的人会写这样的东西。这篇文章的实际原因是什么?是Bug报告吗?还是其他原因?逻辑上的答案是:任何代码优化都可能有一些副作用,程序员应该牢记这一点。 - 0___________
1
问题是标准中是否有任何东西可以证明似乎普遍存在的行为是合理的,还是应该将与别名相关的标准部分视为毫无意义,因为没有人遵循它们? - supercat

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