联合体 union 有什么用?三种真实用法和一个大坑
一句话: union 让同一块内存有多个视图——同一段字节,既能当字节数组收,又能当结构体字段读,还能当整型算;它在 C 里是合法的类型双关工具,代价是必须自己管好字节序和位序。
适合谁读:写过"用一堆移位把字节流拼成 int"的代码、或者接手别人的协议解析代码看不懂 union 的嵌入式开发者。
先做这一步:用之前确认三件事
失败时下一步:字段值整体错位(高字节跑到低地址)→ 十有八九是字节序假设反了;个别字段错位 → 先查结构体对齐与填充。
三种真实用法
| ① 协议帧解析 | 收一帧字节 → 直接读字段,省掉手工移位拼接 | 必须处理字节序;结构体要有 packed 或按 4 字节对齐设计 |
| ② 寄存器/位域映射 | 一个 32 位寄存器按位定义成多个字段 | 位域的位序是实现定义,跨编译器/架构不可移植 |
| ③ 字节序探测与转换 | 判断当前平台大小端、把网络序转成主机序 | 探测用 1 字节 + 多字节成员各写一次再读 1 字节成员 |
用法一:协议帧解析(最常用)
收到一帧原始字节,想按字段读,不必手工移位:
#include <stdint.h>
typedef union {
uint8_t raw[12]; /* 收包时直接往里灌 */
struct {
uint8_t head; /* 帧头 */
uint8_t cmd; /* 命令码 */
uint16_t addr; /* 地址 */
uint32_t value; /* 数值 */
uint16_t crc; /* 校验 */
uint16_t tail; /* 帧尾 */
} f;
} frame_t;
void on_frame(const uint8_t *buf) {
frame_t fr;
for (int i = 0; i < 12; i++) fr.raw[i] = buf[i];
/* 直接读:fr.f.value,无需移位拼接 */
}
这里有个必须核对的前提:上面的结构体在 32 位平台上很可能被编译器插入填充字节(head+cmd 占 2 字节,addr 要 2 字节对齐,OK;但整体对齐可能让 sizeof(struct) 变成 12 的倍数)。写完之后第一件事是打印 sizeof(frame_t) 和每个字段的 offsetof,跟自己画的字节图对一遍。对不上就调整字段顺序,或者把整个结构体声明成按最大对齐打包。
用法二:寄存器位域映射
typedef union {
uint32_t reg;
struct {
uint32_t enable : 1; /* 第 0 位 */
uint32_t mode : 2; /* 第 1~2 位 */
uint32_t reserved : 5; /* 第 3~7 位 */
uint32_t period : 12; /* 第 8~19 位 */
uint32_t : 12; /* 高位保留 */
} bits;
} ctrl_reg_t;
写法很直观,但这是三种用法里风险最高的:
- 位序是实现定义。同样是"第 0 位",不同编译器在大小端平台上的实际落位可能不同;某些编译器的位域分配方向与直觉相反。
- 宽度不能超过底层类型,且跨类型的位域布局不可依赖。
- 结论:位域映射适合内部自用、单一工具链的项目;要发给别人跨编译器编译、或者需要严格对应硬件手册位图时,改用位操作宏更稳妥(reg |= BIT(3) 这类写法零歧义)。
用法三:字节序探测
union { uint32_t v; uint8_t b[4]; } endian_test;
endian_test.v = 0x01020304u;
/* 小端: b[0]=0x04, b[3]=0x01;大端: b[0]=0x01, b[3]=0x04 */
工程里更实用的是转换:收到网络序(大端)的 32 位数据时,先读成整型再做字节交换,比手工逐字节移位清楚得多:
static uint32_t bswap32(uint32_t x) {
return ((x & 0x000000FFu) << 24) | ((x & 0x0000FF00u) << 8)
| ((x & 0x00FF0000u) >> 8) | ((x & 0xFF000000u) >> 24);
}
一个必须知道的坑:C 与 C++ 的规则不同
- C99 起:读 union 中"非最后写入"的成员是合法的(值按新类型重新解释,即类型双关),前提是新类型不违反对齐等约束。所以上面的写法在 C 里可用。
- C++ 里:读非活跃成员是未定义行为。同一份代码用 C++ 编译,优化等级一高就可能被"优化掉"。
- 因此:union 型双关只写在 .c 里;C++ 工程里改用 memcpy 到目标类型(现代编译器会把它优化成零开销)。
实测对比:同一段 12 字节数据,用 struct 直接读与用逐字节移位拼接,得到的结果一致;但把 frame_t 换成 C++ 编译并开 -O2 后,直接读成员的行为开始不稳定——这就是"C 合法、C++ 未定义"的实际差别。
有用的话点个收藏,下次写协议解析先打印 sizeof 和 offsetof。有问题欢迎评论区交流。
相关文章:位操作怎么写才不出错?置位、清位、翻转、取位一次讲清——不想用位域时,位操作宏是更可移植的替代;配置参数表里字段长度混着来?上位机解析迟早出事——union 的字段布局设计,本质是同一类"内存布局要一次定对"的问题。


