使用联合体进行类型转换的可移植性

3
我想用RGBA颜色值来表示一个32位的数字,使用联合体生成该数字的值是否可移植?考虑以下C代码;
union pixel {
    uint32_t value;
    uint8_t RGBA[4];
};

这个可以成功编译,我希望使用它来替代一堆函数,但这样安全吗?

1
你打算如何使用union,并希望实现什么结果? - Nate Eldredge
1
如果您将值(例如0x01234567)分配给“value”,则RGBA [0]中的数字取决于平台是大端(0x01)还是小端(0x67)。因此,在具有不同字节顺序的平台之间,它不具备可移植性。 - Jonathan Leffler
@JonathanLeffler,字节序是否是唯一的问题?如果是,那么它总是可以被解决的。 - QuestionLimitGoBrrrrr
@QuestionLimitGoBrrrrr,我认为这也是未指定的行为——只允许使用gcc扩展,但我目前无法在gcc手册中找到参考资料。不过,我在我的答案底部添加了一些链接供您查阅。 - Gabriel Staples
@EricPostpischil,你能帮我找到标准吗?我需要去买吗?我没有最终标准的副本。或者,你能在我链接的那个中找到这些词吗? - Gabriel Staples
显示剩余4条评论
1个回答

7

在C中使用Union进行“类型转换”是可以的,在gcc的C++中也是可以的(作为gcc [g++]扩展)。但是,通过联合体进行“类型转换”需要考虑硬件架构的字节序问题。

这被称为"类型转换", 由于字节序问题,它不是直接可移植的。然而,除此之外,这样做是没有问题的。 C标准在说明这是可以的方面并不十分明确,但显然是可以的。请阅读这些答案和来源:

  1. 类型共用体在C99中是否未指定,在C11中是否已指定?
  2. 共用体和类型游戏
  3. https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html#Type%2Dpunning - 在GCC C和C++中允许类型游戏

此外,C18草案N2176 ISO/IEC 9899:2017在“6.5.2.3结构和联合成员”一节的脚注97中指出:

如果一个成员曾经读取了联合对象的内容,而不是上次用于存储值的成员,则将该值的对象表示中的适当部分重新解释为新类型的对象表示,如6.2.6所述(有时称为“类型游戏”)。 这可能会产生陷阱表示。请参见此处的屏幕截图:

enter image description here

因此,拥有

typedef union my_union_u
{
    uint32_t value;
    /// A byte array large enough to hold the largest of any value in the union.
    uint8_t bytes[sizeof(uint32_t)];
} my_union_t;

作为将value转换为bytes的手段,在C语言中是可以的。在C++中,它作为GNU gcc扩展而工作(但不属于C++标准的一部分)。请参见@Christoph在此处回答中的解释

GNU对标准C++(和C90)的扩展明确允许使用union进行类型转换。其他不支持GNU扩展的编译器也可能支持union类型转换,但这并不是基础语言标准的一部分。


下载代码:您可以从我的eRCaGuy_hello_world存储库中下载并运行下面的所有代码:"type_punning.c"。C和C++的gcc构建和运行命令都在文件顶部的注释中。


因此,您可以像这样读取uint32_t value中的单个字节:

技巧1:基于联合的类型转换(这是“类型转换”):

这就是“类型转换”的意思:将一种类型写入联合中,然后读出另一种类型,从而使用联合执行类型“转换”。

my_union_t u;

// write to uint32_t value
u.value = 1234;

// read individual bytes from uint32_t value
printf("1st byte = 0x%02X\n", (u.bytes)[0]);
printf("2nd byte = 0x%02X\n", (u.bytes)[1]);
printf("3rd byte = 0x%02X\n", (u.bytes)[2]);
printf("4th byte = 0x%02X\n", (u.bytes)[3]);

示例输出:

  1. On a little-endian architecture:
    1st byte = 0xD2
    2nd byte = 0x04
    3rd byte = 0x00
    4th byte = 0x00
    
  2. On a big-endian architecture:
    1st byte = 0x00
    2nd byte = 0x00
    3rd byte = 0x04
    4th byte = 0xD2
    

您也可以使用原始指针从变量中获取字节,但这种技术也存在硬件架构字节序问题。

如果您想要的话,也可以使用原始指针来完成此操作,而无需使用联合体,如下所示:

技巧2:通过原始指针读取(这不是“类型转换”):

uint32_t value = 1234;
uint8_t *bytes = (uint8_t *)&value;

// read individual bytes from uint32_t value
printf("1st byte = 0x%02X\n", bytes[0]);
printf("2nd byte = 0x%02X\n", bytes[1]);
printf("3rd byte = 0x%02X\n", bytes[2]);
printf("4th byte = 0x%02X\n", bytes[3]);

样例输出:

  1. On a little-endian architecture:
    1st byte = 0xD2
    2nd byte = 0x04
    3rd byte = 0x00
    4th byte = 0x00
    
  2. On a big-endian architecture:
    1st byte = 0x00
    2nd byte = 0x00
    3rd byte = 0x04
    4th byte = 0xD2
    

您可以使用位掩码和位移来避免硬件架构字节序可移植性问题。

为了避免上述 联合体类型转换裸指针 方法存在的字节序问题,您可以使用以下类似方法。这样可以避免硬件架构之间的字节序差异:

技巧 3.1:使用位掩码和位移(这不是“类型转换”):

uint32_t value = 1234;

uint8_t byte0 = (value >> 0)  & 0xff;
uint8_t byte1 = (value >> 8)  & 0xff;
uint8_t byte2 = (value >> 16) & 0xff;
uint8_t byte3 = (value >> 24) & 0xff;

printf("1st byte = 0x%02X\n", byte0);
printf("2nd byte = 0x%02X\n", byte1);
printf("3rd byte = 0x%02X\n", byte2);
printf("4th byte = 0x%02X\n", byte3);

样例输出(以上技术与字节序无关!):
  1. On a all architectures: both big-endian AND little-endian:
    1st byte = 0xD2
    2nd byte = 0x04
    3rd byte = 0x00
    4th byte = 0x00
    

或:

技巧 3.2:使用便捷宏进行位掩码和位移操作:

#define BYTE(value, byte_num) ((uint8_t)(((value) >> (8*(byte_num))) & 0xff))

uint32_t value = 1234;

uint8_t byte0 = BYTE(value, 0);
uint8_t byte1 = BYTE(value, 1);
uint8_t byte2 = BYTE(value, 2);
uint8_t byte3 = BYTE(value, 3);

// OR

uint8_t bytes[] = {
    BYTE(value, 0), 
    BYTE(value, 1), 
    BYTE(value, 2), 
    BYTE(value, 3), 
};

printf("1st byte = 0x%02X\n", byte0);
printf("2nd byte = 0x%02X\n", byte1);
printf("3rd byte = 0x%02X\n", byte2);
printf("4th byte = 0x%02X\n", byte3);
printf("---------------\n");
printf("1st byte = 0x%02X\n", bytes[0]);
printf("2nd byte = 0x%02X\n", bytes[1]);
printf("3rd byte = 0x%02X\n", bytes[2]);
printf("4th byte = 0x%02X\n", bytes[3]);

示例输出(上述技术与字节序无关!):
  1. On a all architectures: both big-endian AND little-endian:
    1st byte = 0xD2
    2nd byte = 0x04
    3rd byte = 0x00
    4th byte = 0x00
    ---------------
    1st byte = 0xD2
    2nd byte = 0x04
    3rd byte = 0x00
    4th byte = 0x00
    
否则,如果架构是小端的,(my_pixel.RGBA)[0](u.bytes)[0]可能等于上面我定义的byte0,如果架构是大端的,则可能等于byte3
请参见下面的字节序图形:https://en.wikipedia.org/wiki/Endianness。请注意,在大端中,任何给定变量的最高有效字节(即低地址)存储在内存中的第一个位置,但在小端中,最低有效字节(在低地址处)存储在内存中的第一个位置。还要记住,字节顺序是指字节顺序,而不是位顺序(字节内的位顺序与字节序无关),每个字节都是2个十六进制字符或“nibble”,其中一个nibble是4个位。

enter image description here

根据上面的维基百科文章,网络协议通常使用大端字节序,而大多数处理器(如x86,大多数ARM等)通常是小端字节序(强调添加):
“大端序”是“网络协议套件”中的主导排序方式,在其中被称为网络顺序,传输最高有效字节。相反,“小端序”是处理器架构(x86,大多数ARM实现,基本RISC-V实现)及其关联内存的主导排序方式。

关于标准是否支持“类型切换”的更多注释

根据维基百科的“类型切换”文章,写入联合成员value但从RGBA[4]读取是“未指定的行为”。然而,@Eric Postpischil在他下面的评论中指出,维基百科是错误的。本答案顶部的其他参考资料也与维基百科现在的回答不一致。

Eric Postpischil的评论,我现在理解并同意,强调说(重点添加):

引用的文字,关于字节对应于存储在最后一个联合成员之外的成员,不适用于此情况。它适用于这样一种情况:例如,写入一个两字节的short成员和读取一个四字节的int成员。多出来的两个字节是未指定的。这使得C实现有权将store到short作为两字节存储(保留联合的其余字节不变),或者作为四字节存储(可能因为对于处理器而言更有效率)。在手头的情况下,我们有一个四字节的uint32_t成员和一个四字节的uint8_t [4]成员。

维基百科声称(截至2021年4月22日):

对于union:

union {
    unsigned int ui;
    float d;
} my_union = { .d = x };

访问已初始化其他成员变量 my_union.d 后,仍然访问 my_union.ui 是 C 语言中的类型转换 [4],其结果是未指定行为 [5](在 C++ 中则为未定义行为 [6])。从上述引用[5]中可得知:"未指定行为" 包括以下内容:

与最后存储的成员变量不同的联合成员对应的字节的值 (6.2.6.1)。

这意味着,如果您将数据存储到联合的一个成员中,但从另一个成员中读取,而这正是您想要使用该联合的情况,那么根据C标准,这是“未指定的行为”。

enter image description here

我认为gcc允许类型转换(将数据写入联合体的一个成员,但从联合体的另一个成员读取数据,作为一种“翻译”的形式),作为“gcc扩展”,但是如果在构建标志中使用-Wpedantic,C和C ++标准将禁止它。

另请参阅:

  1. 从我的这里下载并运行所有上面的代码:https://github.com/ElectricRCAircraftGuy/eRCaGuy_hello_world/blob/master/c/type_punning.c
  2. 实践中的联合、别名和类型混用:哪些有效,哪些无效?
  3. 联合和类型混用
  4. [我的repo] 我在我的eRCaGuy_hello_world repo的utilities.h文件中添加了READ_BYTE()作为宏。
  5. 我在哪里找到当前的C或C++标准文档?
  6. https://news.ycombinator.com/item?id=17263328
    1. 通过union进行类型混用在C99中是否未指定,在C11中是否已变得明确?<== 特别注意此处。显然,C标准在非常清晰方面做得不好。
  7. 更多答案:
    1. 答案1/3:使用联合和紧凑结构体
    2. 答案2/3:通过手动位移将结构体转换为字节数组
    3. 答案3/3:使用紧凑结构体和原始uint8_t指针

@M.M,是的,这需要我进一步研究。不过现在我会让它休息一下。 - Gabriel Staples
@M.M,注意:如果你在维基百科上看到了错误的信息,并且你已经进行了详细的研究以确保其准确性,请直接修正它。这就是维基百科存在的意义:一个任何人都可以编辑的维基。我也是一样。我经常编辑和添加https://cppreference.com(也是一个维基)和[Wikipedia.org](https://www.wikipedia.org/)的内容。 - Gabriel Staples
2
我已经花了足够多的时间在互联网上的争论上了,真的不想处理来自持有不同观点的人的竞争性编辑 :) 我认为解决方案是链接到一个规范的SO问题。 - M.M
1
@GabrielStaples:在C语言中,读取联合体(union)时,如果不是最后一个写入的联合体,则根据C 2018 6.5.2.3第3条和注释99重新解释新类型中的字节。这不是未指定或未定义的行为。 - Eric Postpischil
2
关于字节对应于联合成员的最后一个存储之外的成员的引用文本,在这种情况下不适用。它适用于例如写入两个字节的“short”成员并读取四个字节的“int”成员的情况。额外的两个字节是未指定的。这给了C实现许可将“short”存储实现为两个字节的存储(保留联合的其余字节不变)或四个字节的存储(可能因为对处理器而言更有效)。在这种情况下,我们有一个四字节的“uint32_t”成员和一个四字节的“uint8_t [4]”成员。 - Eric Postpischil
显示剩余5条评论

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