35、联合体:union的类型双关与未定义行为
联合体(union)在C语言里是个很有意思的东西。说白了,它让一块内存能装不同类型的值,但同一时刻只能用一个。很多嵌入式工程师喜欢拿它做“类型双关”——就是通过一种类型写进去,再用另一种类型读出来。嗯,这个操作其实很危险。
union的基本规则
先看一个最简单的例子:
union data {
uint32_t u32;
float f;
uint8_t bytes[4];
};
这个union的大小是4字节。你写u32,读f,或者反过来,都是操作同一块内存。但问题来了:C标准明确说,通过一个成员写入,再通过另一个成员读取,结果是未定义的(除了某些特殊情况)。
我踩过的坑
我在项目中遇到过这样一个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规则 |
避坑指南
我曾经接手过一个通信协议栈的代码,里面大量使用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。
嗯,记住一句话:未定义行为不是“可能出错”,而是“编译器有权做任何事”。包括让你的程序崩溃、产生错误结果,甚至删除你硬盘上的文件(理论上)。别去赌编译器的行为。