C语言中关于union的一个问题——将一个类型存储为另一种类型并进行读取,这是由具体实现定义的吗?

39

我正在阅读K&R关于C语言联合体的内容,据我理解,联合体中的单个变量可以存储多种类型之一。如果将某些东西作为一个类型存储,并作为另一个类型提取,则结果纯粹是由实现定义的。

现在请检查此代码片段:

#include<stdio.h>

int main(void)
{
  union a
  {
     int i;
     char ch[2];
  };

  union a u;
  u.ch[0] = 3;
  u.ch[1] = 2;

  printf("%d %d %d\n", u.ch[0], u.ch[1], u.i);

  return 0;
}

输出:

3 2 515

我正在将值分配给 u.ch,但是从 u.chu.i 中检索。这是实现定义的吗?还是我做了什么很愚蠢的事情?

我知道这可能对其他人来说似乎非常初级,但我无法找出输出背后的原因。

谢谢。


4
512等于256乘以2再加3。在Intel处理器上,低位字节在前,所以ch[0]是一个2字节整数的高位字节。顺便说一下,你正在将数字值分配给char变量。我至少希望收到有关此事的警告。 - Wim ten Brink
5
@Workshop Alex 你的意思是 u.ch[0]=3;?为什么会出现警告呢?char 只是整型类型中最短的一种,为什么不能接受用十进制写的值呢?同样地,也没有阻止使用 int x='c';。在你的解释中,“signed char”和“unsigned char”哪一个应该为ASCII码保留,另一个有什么用途? - Pascal Cuoq
@Alex:这是C语言,而C++有更强的类型检查。在C中,将整数赋值给“char”变量是完全有效的。实际上,一个字面上的字符就是一个“int”。尝试一下,在C和C++中都可以使用以下代码:printf("sizeof literal char: %d\n", (int)sizeof 'X'); - pmg
5
在C++中,将整数分配给char变量也是完全有效的。 :-) - Emerick Rogul
6个回答

33
这是未定义行为。 u.iu.ch 存储在同一内存地址上。因此,写入其中一个并从另一个读取的结果取决于编译器、平台、架构,有时甚至取决于编译器的优化级别。因此,u.i 的输出结果可能不总是515
示例
例如,在我的机器上,gcc-O0-O2产生了两个不同的答案。
  1. Because my machine has 32-bit little-endian architecture, with -O0 I end up with two least significant bytes initialized to 2 and 3, two most significant bytes are uninitialized. So the union's memory looks like this: {3, 2, garbage, garbage}

    Hence I get the output similar to 3 2 -1216937469.

  2. With -O2, I get the output of 3 2 515 like you do, which makes union memory {3, 2, 0, 0}. What happens is that gcc optimizes the call to printf with actual values, so the assembly output looks like an equivalent of:

    #include <stdio.h>
    int main() {
        printf("%d %d %d\n", 3, 2, 515);
        return 0;
    }
    

    The value 515 can be obtained as other explained in other answers to this question. In essence it means that when gcc optimized the call it has chosen zeroes as the random value of a would-be uninitialized union.

写入一个联合成员并从另一个成员读取通常没有太多意义,但有时对于使用严格别名编译的程序可能是有用的


1
我几乎相信这种行为是实现定义的,但问题的根源让我想到其他可能性。你真的在编译器中尝试过这段代码吗? - whacko__Cracko
好的,在 -O2 情况下,gcc将常量2、3和515传递到printf的堆栈上,这是它认为union 包含的内容(该union已被优化掉)。但是在 -O0 的情况下并非如此! - Alex B
16
这个回答是错误的。在 C 1999 和 C 2011 中,读取联合体成员时不仅仅最后存储的成员是未定义的。字节会被重新解释为新类型的成员。具体细节是由实现定义的,而不是未定义的。这可能导致一个陷阱表示,引起未定义的行为,但这是新值的结果,而不是联合体成员访问的结果,并且根据涉及的具体类型,可能被标准完全定义。 - Eric Postpischil
3
为此,C 1999 版本特别针对此问题进行了更改,这是在技术勘误3中完成的,详情请参见 该缺陷报告 - Eric Postpischil
在普通平台上,这是未指定的行为,而不是未定义的行为。请参见6.2.6.1/7(我认为C99和C11也是如此)(如果u.i存在可能的陷阱表示,则可能在平台上未定义)。 - M.M
显示剩余6条评论

20
这个问题的答案取决于历史背景,因为语言规范随时间而改变。而这个问题恰好是受到这些变化影响的一个问题。
你说你正在阅读《C程序设计语言》(K&R)。该书最新版本(截至目前)描述了C语言的第一个标准化版本 - C89/90。在那个版本的C语言中,写一个联合体成员并读取另一个成员是未定义行为,而不是 实现定义的(这是另一回事),但是是未定义的行为。在这种情况下,与语言标准相关的部分是6.5 / 7。
现在,在C语言发展的某个后期时点(应用了技术勘误3的C99语言规范),使用联合体进行类型切换突然变得合法,即先写入联合体的一个成员,再读取另一个成员。
请注意,尝试这样做仍可能导致未定义的行为。如果您读取的值恰巧对于您读取它的类型来说是无效的(所谓的“捕获表示”),则行为仍然是未定义的。否则,您读取的值是实现定义的。
您的具体示例在从intchar [2]数组的类型切换方面相对安全。在C语言中,重新解释任何对象的内容为char数组总是合法的(同样是6.5 / 7)。
然而,反过来就不是这样了。将数据写入您联合体的char [2]数组成员,然后将其作为int读取可能会创建一个捕获表示,并导致未定义行为。即使您的char数组长度足以覆盖整个int,潜在的危险仍然存在。
但是在您的特定情况下,如果intchar [2]大,则读取的int将覆盖数组末尾之外未初始化的区域,这又会导致未定义行为。

4
你确定这是正确的吗?你可以通过使用另一个intmemcpy来创建一个有效的int,该方法是将它作为unsigned char单位组装起来(表示方式)。我认为只要确保能够创建有效的表示方式,自己进行组装同样有效。请注意,(非常普遍的)条件INT_MIN==-(2^(CHAR_BIT*sizeof(int)-1))可以确保所有表示方式都是有效的。 - R.. GitHub STOP HELPING ICE
1
这让我非常困惑。显然,C99的原文和J附录中都指出,读取与最后一个存储成员不同的成员是未指定的行为(而不是未定义的)。 TC3根据DR283(http://www.open-std.org/jtc1/sc22/wg14/www/docs/dr_283.htm)更改了主文本,以指定对应于最后存储到成员的字节是实现定义的(但J附录发生了什么?),而C1x最终更改了主文本和J附录。 任何人都可以访问TC3吗? - ninjalj
4
你是正确的。TC3注释82表示:“如果用于访问联合对象内容的成员与上次存储对象值所使用的成员不同,则将该值的对象表示中的适当部分重新解释为新类型的对象表示,如6.2.6所述(有时称为“类型游戏”过程)。这可能是一个陷阱表示。” 这个答案是错误的;不一定会导致未定义行为,如果读取的联合成员不是最后一个存储的成员。 - Eric Postpischil
@ninjalj:我回答这个问题时考虑的是K&R书中描述的语言(因为OP提到了K&R书),即C89/90。在C89/90中,这样做是未定义的。在C99+TC3中,它变成了实现定义。我更新了答案以反映这种区别。 - AnT stands with Russia
2
@AnT:在C89标准下,该行为是实现定义的。DR#028的作者似乎认为这样的操作会引发未定义的行为,但是C89草案的3.3.2.3节说:“除一种情况外,如果在将值存储在对象的另一个成员之后访问联合对象的成员,则行为是实现定义的。”我不确定任何人如何能够将其解释为行为是未定义的,因为它似乎非常明确地表明了它不是。 - supercat
显示剩余2条评论

9
输出结果的原因在于你的计算机存储整数采用小端字节序:最不重要的字节先存储。因此,字节序列[3,2,0,0]表示整数3+2*256=515。
这个结果依赖于具体的实现和平台。

我非常喜欢您的回答。谢谢。 - whacko__Cracko
3
从技术上讲,它是未定义的,而不是实现定义的。这些术语在标准中具有不同的含义。 - Steve Jessop
@SteveJessop即使在其“int”缺乏陷阱表示的平台上也未定义? C99 TC3允许类型转换。 - Damian Yerrick

5

这是与实现相关的,不同平台/编译器的结果可能会有所不同,但似乎正在发生以下情况:

515的二进制表示为

1000000011

在数字前面填充零以使其成为两个字节(假设是16位整数):

0000001000000011

这两个字节是:
00000010 and 00000011

这是23

希望有人解释为什么它们被颠倒了——我猜测字符并没有颠倒,而是整数采用了小端字节序。

联合体分配的内存量等于存储最大成员所需的内存量。在这种情况下,您有一个长度为2的int和char数组。假设int是16位,char是8位,两者都需要相同的空间,因此联合体分配了两个字节。

当您将三(00000011)和二(00000010)分配给char数组时,联合体的状态为0000001100000010。当您从该联合体读取int时,它会将整个内容转换为一个整数。假设采用小端字节序表示,其中LSB存储在最低地址处,则从联合体读取的int将是0000001000000011,这是515的二进制表示。

注意:即使int是32位,这也是正确的——请查看Amnon的回答


这是一个错误 - 我想说小端,但我打成了大端。现在发生的情况是,即使你的int是32位,也会出现这种情况。请看更新。 - Amarghosh
你会如何解释以下代码?int main(void) { union a{ int i; char ch[3]; }; union a u; u.ch[0] = 3; u.ch[1] = 2; u.ch[2] = 2; printf("%d %d %d\n",u.ch[0],u.ch[1],u.i); return 0; } - whacko__Cracko
如果你得到了其中一个:or 131842,我想我知道发生了什么事情。否则就是:(。 - Amarghosh
我的猜测是你的整数是16位的。在我的32位整数编译器上,我得到了预期的值131587(该编译器将 sizeof int 打印为4)。 - Amarghosh
在试卷中规定整数的大小为2个字节。 - whacko__Cracko
显示剩余4条评论

5
这样的代码输出将取决于您的平台和C编译器实现。从您的输出可以看出,您正在运行此代码的系统是小端字节序(可能是x86)。如果您将515放入i中并在调试器中查看它,您会发现最低位字节是3,内存中的下一个字节是2,这恰好对应于您放入ch中的内容。
如果您在大端字节序系统上执行此操作,则可能会得到770(假设为16位int)或50462720(假设为32位int)。

你如何解释这段代码:#include <stdio.h>int main(void) { union a{ int i; char ch[3]; }; union a u; u.ch[0] = 3; u.ch[1] = 2; u.ch[2] = 2; printf("%d %d %d\n",u.ch[0],u.ch[1],u.i); return 0; } - whacko__Cracko

4
如果您在32位系统上,则int为4个字节,但您只初始化了2个字节。访问未初始化的数据是未定义的行为。
假设您使用的是16位int的系统,则您正在进行的操作仍然是实现定义的。如果您的系统是小端序,则u.ch [0]将对应于u.i的最低有效字节,而u.ch1将是最高有效字节。在大端序系统上,则相反。此外,C标准不强制实现使用二进制补码来表示有符号整数值,尽管二进制补码是最常见的。显然,整数的大小也是实现定义的。
提示:如果使用十六进制值,则更容易看出发生了什么。在小端序系统上,十六进制中的结果将为0x0203。

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