在C和C++里,联合体(union)是一种特殊的数据类型,它允许在相同的内存位置存储不同的数据类型。和结构体把成员依次排列、各自占用独立空间不同,联合体的所有成员从同一个地址开始,任意时刻只能安全地保存其中一个成员的值。编译器为联合体分配的空间大小,等于其最宽成员所占的字节数,再按对齐要求补齐。这种特性让联合体成为做类型双关、硬件寄存器映射以及协议数据解析时的常用工具。

一、union关键字的基本定义与语法
使用union关键字可以定义一个联合体类型。语法形式上它和struct非常接近,只是把关键字换成了union。下面给出一个最简单的例子,定义一个能够用不同方式解释同一段内存的联合体。
#include <stdio.h>
union Data {
int i;
float f;
char c[4];
};
int main() {
union Data d;
d.i = 0x3f800000;
printf("as int: %dn", d.i);
printf("as float: %fn", d.f);
printf("as char[0]: %02xn", (unsigned char)d.c[0]);
return 0;
}
在上面代码中,d.i、d.f和d.c共享同一块4字节内存。给d.i写入一个十六进制值后,再用d.f读取,就相当于把这块内存按照float的编码规则解释,得到1.0。这种方式比用指针强制转换更不容易触发严格的别名规则问题,但依然要小心平台字节序。
联合体也可以定义匿名联合体,或者作为结构体的成员嵌套使用。匿名联合体在C11和C++中都得到支持,访问其成员时不需要写外层联合体变量名,看起来就像结构体的直接成员一样,适合做寄存器字段拆分。
struct Packet {
unsigned int raw;
union {
unsigned char bytes[4];
struct {
unsigned int a : 8;
unsigned int b : 8;
unsigned int c : 8;
unsigned int d : 8;
} fields;
};
};
这段定义里,Packet同时拥有原始整型raw和内部匿名联合体。程序可以通过bytes数组按字节访问,也能通过fields位域字段访问,二者背后都是同一段内存。这样的写法在驱动开发和网络协议处理中非常普遍。
二、联合体与结构体内存布局的差异
很多人初学时会把联合体和结构体搞混。结构体struct的每个成员都有自己独立的偏移地址,总大小是各成员大小之和再加上填充字节。联合体union则所有成员偏移都为0,总大小是最宽成员大小并做对齐补齐。可以用一段代码直观对比。
#include <stdio.h>
struct S {
int a;
char b;
double c;
};
union U {
int a;
char b;
double c;
};
int main() {
printf("sizeof struct S: %zun", sizeof(struct S));
printf("sizeof union U: %zun", sizeof(union U));
return 0;
}
在常见的64位Linux环境里,struct S因为对齐通常占用16字节,而union U只占用8字节,也就是double的大小。可见联合体在节省内存方面有明显优势,代价是同一时间只能可靠地使用其中一个成员。
需要强调的是,写入一个成员后再读取另一个成员,标准称之为类型双关。C99之后允许通过联合体做这种操作,行为是明确实现的;但反过来用指针强转类型去读,则可能违反严格别名规则,被编译器优化搞错。因此联合体比指针转换更安全可控。
| 对比项 | 结构体 struct | 联合体 union |
|---|---|---|
| 成员存储 | 各自独立空间 | 共享同一空间 |
| 总大小 | 成员之和加填充 | 最大成员加对齐 |
| 同时有效成员 | 全部有效 | 仅最后写入者有效 |
| 典型用途 | 聚合不同数据 | 类型双关、省内存 |
三、联合体的典型使用场景
联合体最常见的用法之一是协议解析。例如网络收到的4字节数据,既想当整型算长度,又想按字节拆开看头标志,用联合体就很自然。下面示例把一个32位状态字拆成两个16位字段。
#include <stdio.h>
#include <stdint.h>
union Status {
uint32_t value;
struct {
uint16_t low;
uint16_t high;
} part;
};
int main() {
union Status s;
s.value = 0x12345678;
printf("low: %x, high: %xn", s.part.low, s.part.high);
return 0;
}
在小端机器上,s.part.low会得到0x5678,high得到0x1234。这种写法直观且不需要移位运算,但正因依赖字节序,在跨平台通信时要特别小心,必要时应显式转换字节序而不是依赖联合体。
另一个场景是硬件寄存器映射。很多MCU的手册会把一个32位寄存器分成不同位域,用联合体配合位域结构体,可以既用整型写整个寄存器,又用位域改其中几位。这种方式在裸机开发中大量存在,能减少繁琐的掩码操作。
volatile union Reg {
uint32_t all;
struct {
uint32_t enable : 1;
uint32_t mode : 3;
uint32_t reserved : 28;
} bit;
} *const UART_CTRL = (union Reg *)0x40001000;
上述代码把地址0x40001000映射为寄存器联合体,写UART_CTRL->all可以整体配置,写UART_CTRL->bit.enable可以只改使能位。注意这里用了volatile避免编译器优化掉寄存器访问,且地址映射只适合确定硬件平台的裸机或驱动代码。
四、使用联合体时的注意事项
联合体的最大陷阱是类型双关带来的未初始化和字节序问题。如果先写int成员,再读float成员,得到的值依赖于int的位模式如何被解释为IEEE754浮点,这通常不是你想要的“转换”,而是重新解释。若真要做数值转换,应当用赋值而非联合体。
union Bad {
int i;
float f;
};
// 错误认知:以为这样能把int转成float
union Bad b;
b.i = 10;
// b.f 并不是 10.0,而是把10的位模式当浮点解释
此外,包含非平凡构造函数的C++类对象不能作为联合体成员,除非联合体本身也提供相应构造和析构,否则编译会报错。C++11起允许带非平凡成员的联合体,但必须手动管理活动成员的生命周期,否则容易资源泄漏。
最后要注意对齐。联合体大小由最宽且对齐要求最高的成员决定,如果里面放了SIMD向量类型,可能导致整个联合体按16或32字节对齐,在栈上或数组里会放大内存占用。在内存紧张的环境,应当用static_assert检查联合体大小是否符合预期。
#include <assert.h>
union Check {
char a[8];
long long b;
};
static_assert(sizeof(union Check) == 8, "unexpected union size");
通过静态断言可以在编译期发现联合体尺寸变化,避免后续代码基于错误假设访问内存。总体来看,联合体是贴近硬件、节省空间的好工具,但只在明确需要共享内存语义时使用,不要把它当普通类型转换的捷径。