35、联合体:union的类型双关与未定义行为

联合体(union)在C语言里是个很有意思的东西。说白了,它让一块内存能装不同类型的值,但同一时刻只能用一个。很多嵌入式工程师喜欢拿它做“类型双关”——就是通过一种类型写进去,再用另一种类型读出来。嗯,这个操作其实很危险。

union的基本规则

先看一个最简单的例子:

union data {
    uint32_t u32;
    float    f;
    uint8_t  bytes[4];
};

这个union的大小是4字节。你写u32,读f,或者反过来,都是操作同一块内存。但问题来了:C标准明确说,通过一个成员写入,再通过另一个成员读取,结果是未定义的(除了某些特殊情况)。

⚠ 核心警告:除了“共用体首个成员”和“unsigned char数组”这两种情况外,其他类型的双关都是未定义行为。编译器可以优化掉你的代码,或者生成你完全想不到的结果。

我踩过的坑

我在项目中遇到过这样一个bug。当时做传感器数据解析,用union把4个字节转成float:

union {
    uint8_t raw[4];
    float   value;
} sensor_data;

// 从I2C总线读取4个字节
read_sensor(sensor_data.raw);

// 然后直接读float
float result = sensor_data.value;  // 危险!

这段代码在gcc -O0下跑得好好的。一开-O2优化,结果就全乱了。为什么?因为编译器认为你只写了raw,没写value,所以value的值是“未初始化的”,直接优化掉了。

后来我查了汇编才明白——编译器根本没生成从raw拷贝到value的指令。它直接用了栈上残留的垃圾值。

什么时候是安全的?

C标准其实留了两个后门:

场景 是否安全 说明
通过首个成员写入,再读首个成员 ✅ 安全 所有编译器都支持
通过unsigned char数组成员写入,再读其他成员 ✅ 安全(C99起) 用于内存别名访问
通过非首个成员写入,再读其他成员 ❌ 未定义 编译器可以自由发挥
通过指针强制转换做类型双关 ❌ 未定义 违反strict aliasing规则
💡 实用技巧:如果你一定要做类型双关,用memcpy是最安全的。现代编译器对memcpy的优化非常好,不会产生额外开销。

避坑指南

我曾经接手过一个通信协议栈的代码,里面大量使用union做数据包解析:

typedef union {
    struct {
        uint8_t  type;
        uint16_t length;
        uint8_t  payload[256];
    } fields;
    uint8_t raw[259];
} packet_t;

这个设计看起来挺巧妙,但实际用起来问题不断。因为不同成员的字节对齐方式不一样,结构体内部可能有填充字节。你写raw再读fields,读出来的type可能不是你想的那个值。

我的建议是:

  • 能用memcpy就别用union双关。编译器会优化掉的,不用担心性能。
  • 如果非要用union,只通过unsigned char数组做桥梁。
  • 别依赖union做协议解析。字节序、对齐、填充,每个坑都能让你调试一整天。
  • 打开-Wstrict-aliasing警告。gcc和clang都能帮你发现潜在问题。

正确的做法

把float转成4个字节,安全写法是这样的:

float value = 3.14f;
uint8_t bytes[4];

memcpy(bytes, &value, sizeof(value));
// 现在bytes里存的是value的字节表示

// 反过来
float result;
memcpy(&result, bytes, sizeof(result));

这段代码在任何优化级别下都是正确的。而且现代编译器会把它优化成一条mov指令,跟union的性能完全一样。

核心知识体系

下面这张图总结了union类型双关的核心要点:

union类型双关核心知识 union类型双关 ✅ 安全场景 • 首个成员读写 • unsigned char数组桥梁 ❌ 危险场景 • 非首个成员双关 • 指针强制转换 • 依赖字节序/对齐 🔧 最佳实践 • 优先用memcpy • 打开编译警告 • 避免协议解析 核心原则:能用memcpy就别用union双关

总结

union本身是个好工具,但类型双关就像走钢丝。你看着代码跑得挺好,换个编译器版本、换个优化选项,可能就崩了。我个人习惯是:只在两种情况下用union——要么是节省内存(比如一个变量在不同时刻存不同类型),要么是通过unsigned char数组做内存访问。其他情况,老老实实用memcpy。

嗯,记住一句话:未定义行为不是“可能出错”,而是“编译器有权做任何事”。包括让你的程序崩溃、产生错误结果,甚至删除你硬盘上的文件(理论上)。别去赌编译器的行为。


专注资料整理