1. 联合体到底解决了什么问题
1.1 从一个真实的存储需求说起
写C语言的同学应该都有过这种经历:业务逻辑里需要保存一个“值”,但它的类型不确定——有时候是整数,有时候是浮点数,有时候是一个四字节的字符串。用结构体当然能解决问题,给每种类型都预留一个字段就行。但我当年第一次意识到联合体的价值,是在做串口协议解析的时候,传过来的数据帧里同一个字节区域,根据命令字不同,既能解析成传感器温度,又能解析成设备状态位,还得能当原始字节数组看。如果用结构体,那就得这样:
struct packet_data { uint8_t bytes[4]; float temperature; uint32_t status; };但问题来了:一帧数据里,同一个4字节区域只会以其中一种身份出现。你不可能同时既把它当温度又把它当状态位,这违背物理规律。结构体这种写法等于把4个字节的存储空间硬生生扩成了12个字节——bytes数组占4字节,temperature占4字节,status占4字节,各存各的,互不干扰。这在PC上当然无所谓,RAM以GB为单位计算,浪费几个字节没人心疼。但在单片机、嵌入式设备上,RAM可能只有几十KB甚至几KB,这种写法就是犯罪。
联合体(union)就是专门干这个的:同一块内存,多个解读视角。你把它当成一个4字节的存储块,代码里按照需要把它解释成温度、状态位或者原始字节数组。它不给你多分配空间,就那4个字节,谁用谁解释。省下的不只是那8个字节,而是整个设计思路发生了转变——你在用“这块数据的本质是什么”来思考,而不是“我要给所有可能性都预留位置”。
1.2 联合体和结构体的第一性对比
很多初学者分不清结构体(struct)和联合体(union),本质上是因为没抓住一个字:共存。结构体成员是共存的,所有成员同时有效,你给name赋值,number字段照样能读;联合体成员是互斥的,同一时刻只有一个成员是“有效”的,你给temperature赋了值,再看status,读出来的是那4个字节的另一种解释——可能是个垃圾值,也可能恰好是你上次写入的bit模式。
用生活类比来解释:结构体像一个人填信息表,姓名、年龄、电话、地址,每个格子都独立存在,填一个不影响另一个。联合体像一个多功能的储物间,你放自行车的时候它是车库,摆上床的时候它是卧室,堆满工具箱的时候它是工作间——空间还是那个空间,只是用途切换了。
内存分布上的差异更直观:
| 类型 | 内存占用 | 成员关系 |
|---|---|---|
| struct | 所有成员偏移量不同,各自占用专属内存 | 共存,同时有效 |
| union | 所有成员偏移量都从0开始,共享同一块内存 | 互斥,同一时刻只认一个 |
这个底层差异引出了所有联合体的“性格特征”——它的一切优点和坑,都源于“共享内存”这四个字。
2. 联合体的声明、定义与内存布局
2.1 基本语法与几种声明姿势
联合体的声明语法和结构体几乎是一个模子刻出来的,把struct换成union就行:
union data { int i; float f; char bytes[4]; };这个联合体叫data,里面有3个成员——一个int、一个float、一个char[4]数组。声明之后,你可以像使用结构体一样使用它:
int main(void) { union data d; d.f = 3.14f; printf("作为整数看: %d\n", d.i); printf("作为字节看: %02x %02x %02x %02x\n", (unsigned char)d.bytes[0], (unsigned char)d.bytes[1], (unsigned char)d.bytes[2], (unsigned char)d.bytes[3]); return 0; }代码看着简单,但行为非常有讲究:d.f = 3.14f写入的是浮点数的IEEE 754存储格式——符号位、指数位、尾数位。然后你用d.i去读同一块内存,得到的是一个以整数方式解读的bit模式;用d.bytes去读,得到的是那个浮点数从低地址到高地址的每一个字节。同一块内存,三种身份,这就是联合体的典型用法。
实际工程中,声明联合体往往还会配合typedef一起用,省得每次写union data d这串啰嗦的东西:
typedef union { int i; float f; char bytes[4]; } data_t;然后直接data_t d;就能用了。这种写法在协议解析、嵌入式驱动代码里非常普遍,基本是行业默认习惯。
2.2 匿名联合体:少写一层的语法糖
C11标准引入了匿名联合体和匿名结构体,让嵌套联合体的代码清爽不少。比如你要设计一个通用寄存器操作接口:
union register_t { uint32_t value; struct { uint16_t low; uint16_t high; }; struct { uint8_t b0; uint8_t b1; uint8_t b2; uint8_t b3; }; };这个设计里,内部那两个struct没有给名字,这就成了匿名结构体——它的成员可以直接通过外层联合体的变量访问,不需要再多写一层名称。用起来就是:
union register_t reg; reg.value = 0x12345678; reg.low = 0xABCD; // 直接改低16位 reg.b3 = 0xFF; // 直接改最高字节对比一下有名字的写法——reg.inner_stru.low = 0xABCD;——匿名版本省了多少代码一目了然。但要注意,匿名联合体/结构体只在C11及以后的标准里支持,如果你们项目的编译环境还在用C89/C99的古老标准,或者用的是某些只支持C99的嵌入式编译器,这个语法编译直接报错。我踩过这个坑,某款老编译器一看到匿名struct直接报missing name for struct member,最后老老实实改回带名字的写法。
2.3 内存布局与sizeof的计算规则
联合体大小的计算规则只有一句话:**联合体的sizeof等于最大成员的大小,且必须满足所有成员的对齐要求。**前者好理解,后者是很多人没注意到的暗坑。
比如这样的联合体:
union example { char c[9]; // 9字节 double d; // 8字节,另加对齐要求 };直观上看,最大成员是char数组,9字节,那sizeof是不是就是9?不是。double的默认对齐边界通常是8字节,所以这个联合体的大小必须被对齐到8的倍数,实际是16字节——9被凑到了16。如果你假设它是9字节,往里面写double然后猜测它在偏移几的位置,大概率会出错。这种对齐规则和结构体是一样的,都是编译器在背后调的参,C标准只规定了“对齐方式由实现定义”,没有给出具体数值,但绝大多数平台上遵循“按成员最大对齐值对齐”的原则。
还有一个更隐蔽的细节:联合体的对齐要求是所有成员对齐要求的最大值。如果里面有一个成员是uint64_t,那整个联合体就要求8字节对齐;如果有一个成员是int64_t同样如此。这在结构体里嵌套联合体时尤其容易引发布局变化。比如:
struct outer { uint8_t tag; union { uint32_t clean; uint64_t raw; } data; };这个结构体里,联合体data有8字节对齐要求,所以tag后面会填充7个字节,data从偏移8开始。如果你不知道这条规则,用offsetof或者手动算的时候就会掉坑里。我自己写序列化代码时,凡是涉及结构体里有联合体对齐要求比较高的,都会在代码里加一行_Static_assert来验证预期偏移量,这招建议每个人都用上。
2.4 大小端对联合体字节操作的影响
这一节值得单独拎出来讲,因为几乎所有涉及联合体取字节的操作都会在这里栽跟头。前面那个data_t的例子,在小端模式下读出来的是从低地址开始排列的原始字节;在大端模式下,bytes[0]对应的是最高有效字节——位置刚好反过来。
union { uint32_t value; uint8_t bytes[4]; } conv; conv.value = 0x01020304; // 小端模式: bytes[0]=0x04, bytes[1]=0x03, bytes[2]=0x02, bytes[3]=0x01 // 大端模式: bytes[0]=0x01, bytes[1]=0x02, bytes[2]=0x03, bytes[3]=0x04x86、ARM(默认小端配置)都是小端,网络字节序是大端,如果代码是要跨平台跑,就不能假设bytes[0]一定是最低字节。正确做法是不要企图用联合体去做跨平台的字节序转换——那属于另一种技巧,但至少要知道在目标平台上,联合体内的字节排列是跟大小端强相关的。
3. 联合体在嵌入式协议解析里的硬核实践
3.1 协议解析:一个字节区域的多种身份
回到开头说的串口协议场景。假设有这样的帧格式:帧头(1字节)、命令字(1字节)、数据负载(4字节)、校验和(1字节)。其中数据负载区,根据命令字的不同,可能是温度(float)、电机转速(uint32)、四路开关状态(bit位)。没有联合体的时候,代码可能写得像意大利面——先拷贝内存到临时数组,再用memcpy转成各种类型;有了联合体,直接定义一种视图:
typedef struct { uint8_t frame_header; uint8_t cmd; union { float temperature; uint32_t speed; uint8_t switch_state; uint8_t raw[4]; } payload; uint8_t checksum; } motor_frame_t;这段代码直接让“同一段字节根据命令字理解成不同含义”这件事变成声明式写法。收到一帧数据后,先memcpy到motor_frame_t结构体变量里(注意memcpy来源指针要对齐,不然ARM上会出总线错误),然后根据cmd的值访问payload的不同成员。payload.temperature、payload.speed、payload.raw指向的是同一块4字节内存,只是你选择了不同的解读方式。
这种设计的核心价值不只是省内存,而是把“这块数据的语义”和“这块数据的存储”解耦了。协议文档写“offset 3~6保存温度或速度”,代码里就用联合体表达这种或的关系,维护起来非常直白。
3.2 变体数据结构的经典实现
脱离嵌入式,联合体在通用场景下更广为人知的用途是实现变体结构(variant)。很多动态类型语言(比如JavaScript、Python)的变量底层原理就是类似的设计:一个类型标记加一个联合体,同一个存储块既能放整数又能放指针还能放浮点数。
用C模拟一套极简变体:
typedef enum { TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_BOOL } value_type_t; typedef struct { value_type_t type; union { int i; float f; const char *str; bool b; } value; } variant_t;用的时候:
variant_t make_int(int v) { variant_t var; var.type = TYPE_INT; var.value.i = v; return var; } variant_t make_string(const char *s) { variant_t var; var.type = TYPE_STRING; var.value.str = s; return var; }读取的时候必须先看type,再决定访问value的哪个成员——“先查标签,再解数据”,这是所有变体实现的不成文规则,也是联合体在类型不安全问题上的第一道防线。你永远不该在没有检查类型标签之前直接去访问value成员,那样和脱缰野马没区别。
这样的变体结构是很多嵌入式解释器、脚本引擎的基础设施,有些JavaScript引擎在内存受限的IoT设备上跑的简化版,核心就是这个结构。
3.3 结合结构体使用:联合套结构、结构套联合
联合体里套结构体,或者结构体里套联合体,这两种嵌套方向各有妙用。
联合体里套结构体的场景,最常见的就是8位、16位、32位数据之间的互相访问。设计一个便捷寄存器:
typedef union { uint32_t full; struct { uint16_t low_word; uint16_t high_word; } words; struct { uint8_t byte0; uint8_t byte1; uint8_t byte2; uint8_t byte3; } bytes; } dual_view_t;这是嵌入式开发里几乎人人都会写的结构。把32位数据full拆成两个16位或四个8位,全部在一个联合体里完成,不需要移位运算,方便到令人上瘾。
结构体里套联合体的场景前面已经展示过——带类型标签的变体、寄存器视图、协议帧。记住一个设计原则:联合体只负责“同一区域不同解读”,联合体自己不带“当前是谁”的元信息。需要元信息的时候,就在联合体外面包一个结构体,用枚举或者标记字段来记录当前该用哪种解读。这就是为什么嵌套组合总是以“结构体包联合体”为主流设计。
4. 联合体的坑,我一个个都踩过
4.1 未初始化的读取:读到的是整个联合体的历史遗留
联合体不像结构体有“部分成员没赋值、其他成员是默认值”这种温和概念。你给某个成员赋了值,其他成员立刻变成“旧数据”或者“不确定值”。更危险的是,如果你定义了一个联合体变量却完全没初始化,然后直接读了某个成员,读出来的是一段未知的bit模式——可能是栈上残留的垃圾。
前不久有个同事写的代码就栽在这上面:定义了一个联合体存串口数据,收到新帧之前先解析上一帧遗留的旧数据,结果整段逻辑彻底乱了。排查了半天,根因就是忘了在每帧处理前去刷新联合体内容。自从那次之后,我养成了一个习惯:凡是联合体变量,定义时立即用{0}或{}初始化。
union data d = {0}; // 将所有字节清零这个做法的好处不只是“确定值”,更重要的是清零后的联合体行为可预测——你读任何成员,拿到的是确定值,不是垃圾。排查问题时少一个“它可能带着上次的值”这种地狱变量。
4.2 改了A成员,B成员立刻可能变“垃圾”
还是那个共享内存的根本特性:联合体所有成员从偏移0开始重叠。你写了d.f = 2.5,然后再去读d.i,得到的通常不是上一个d.i的值,而是浮点数2.5的bit pattern按整数解读后的值。这个值可能非常大,也可能是负数,总之绝不是你之前存的那个127。
这在团队协作中经常引发神秘bug。A同事写了设置浮点数的代码,B同事在后面读整型做判断,两个人都觉得自己的逻辑没问题,但整个程序像疯了一样乱跳。教训是什么?联合体的不同成员之间没有任何“脏标记”机制,编译器不会提醒你“这个成员上次不是你这么写的”。代码的可维护性完全靠规矩约束:要么用带类型标签的包装结构体,要么约定好每个联合体成员的写入、读取时机,绝不交叉。
4.3 类型双关(Type Punning)的未定义行为问题
C语言里,通过联合体把某个类型的bits当成另一个类型解读,有个术语叫type punning。典型例子就是把float的原始bit导出来变成uint32_t查看它的构成。这在C语言中很常见,但在标准层面的态度有点微妙:C89/C99的标准文本对通过联合体做类型双关是否合法一直含糊其辞,直到C11才在脚注里明确承认“通过联合体进行type punning是允许的”。不过要注意,这只是针对联合体成员的访问规则;如果用指针强制转换的方式做type punning(比如*(uint32_t*)&float_var),在严格别名规则(strict aliasing rule)下依然是未定义行为,编译器激进优化后可能产生诡异结果。
实操建议是:如果你的目标是跨平台、跨编译器,尽量用memcpy做类型转换。现代编译器对memcpy的优化极其成熟,它最终生成的代码和直接指针转换几乎一样高效,而且标准严格定义它的行为。
4.4 联合体成员里有“非平凡类型”的坑
C和C++在这里有个重大分岔。C语言里联合体成员可以是结构体、联合体、数组、指针,一切皆可。但在C++里,如果联合体某个成员的类型有非平凡的构造函数、析构函数或拷贝赋值操作符,整个联合体的构造和析构就成了烫手山芋。C++标准只允许联合体成员是“简单可复制类型”。好在C11以后,C语言没有这种限制,但如果你用C++编译器编译C风格的联合体代码,并且成员里有struct带构造、析构,可能就会遇到麻烦——你赋值给一个成员,另一个成员(之前已经构造过)的析构函数不会被调用,从而引发资源泄漏;或者新成员构造时机不当。
所以我的习惯是:项目是纯C环境就放心用;如果项目是C/C++混合编译,联合体成员尽量只用基本类型、原生数组、无自定义构造/析构的结构体。凡是成员类型涉及字符串、动态数组、文件句柄这类需要管理生命周期的资源,统统不要放进联合体。
4.5 联合体对齐问题的实际现场
前面讲过对齐规则,但这里想给一个更具体的现场案例。某次我写一个数据包打包模块,联合体里有uint64_t成员,结构体如下:
struct header { uint8_t magic; uint8_t length; union { uint16_t quick; uint64_t extended; } info; };按内存对齐计算,magic在偏移0,length在偏移1,联合体info因为含uint64_t要8字节对齐,所以从偏移8开始,中间6个字节白白填充。结构体总大小是16字节(8偏移 + 8联合体大小)。但如果你在info里只使用uint16_t这个成员,另有一对一打包裸buffer的需求,那这6个填充字节就会让结构体直接memcpy出来的字节流和协议规定的紧凑格式对不上。
这种问题最常见的解法是干脆不用联合体,改用__attribute__((packed))或者手动移位打字节。如果结构体里有uint64_t成员,还要强制紧凑对齐,可能直接导致未对齐访问的硬件异常(尤其ARM Cortex-M系列)。
5. 联合体的底层原理与高级思考
5.1 联合体在汇编层面长什么样
联合体真正让人安心的地方在于,它不是一个需要运行时支持的高层抽象。你写了d.f = 3.14f,汇编层面就是一条写4字节到某个内存地址的指令;你写d.i,就是从同一地址读4字节按整数解释。换句话说,联合体的“多种类型身份”仅仅是编译期类型检查的概念,到运行时,它就是一段原始内存。
这也意味着,联合体零额外开销——没有类型标签、没有隐藏计数器、没有指针间接层。对比一下某些动态类型语言的运行时结构,一个变体类型往往要存一个类型描述符指针加一个值,至少8字节甚至更多。C的联合体保持了一贯的朴素:就用最大的那个成员的空间,你爱怎么解读随你。
5.2 联合体的对齐规则如何影响性能
对齐不只是布局问题,它对性能也有直接影响。CPU访问对齐地址只需要一次内存事务,未对齐访问需两次甚至引发异常。联合体成员的通用访问模式决定了它通常是按最大对齐值对齐的,所以正常访问联合体成员基本都能保证对齐高效。但在某些场景下,你可能用一个指针指向联合体内部的中段去做数据处理——比如把一个uint8_t buf[16]的中间8个字节当成联合体用——指针强制转换成对齐要求更高的类型,那就面临未对齐访问风险。
我见过的最极端的坑:在某个Cortex-M单片机上,有人把DMAC搬运完的缓冲区指针直接强转成uint32_t*去读,结果现场跑了一会儿就HardFault。根本原因就是DMAC的缓冲区来源地址只在2字节边界对齐,不满足4字节对齐要求。解决方法很简单——memcpy一下到对齐的局部变量即可,编译器生成的代码一样高效。
5.3 联合体与位域的联姻
联合体在位域场景下也有一席之地。比如一个寄存器视图,既希望以字节/字为单位整个访问,又希望每个bit位独立可见。位域加联合体的组合可以做到:
typedef union { uint8_t reg; struct { unsigned bit_enable : 1; unsigned bit_mode : 2; unsigned bit_speed : 3; unsigned bit_reserved : 2; } bits; } control_reg_t;这样硬件寄存器reg直接写整字节,软件逻辑里访问ctrl.bits.bit_enable来置位或读位。但位域有实现自定义特性——不同编译器对位的排列方向、位域在字节内的分布可能不同,尤其是大小端一起捣乱时。所以这段代码如果要跨编译器移植,必须仔细核对目标编译器的位域布局规则,搞不好还得用__attribute__((packed))或者预处理器层面做适配。
5.4 用联合体做类型转换是不是一个“好设计”
业界对联合体type punning的接受程度不一。有些项目(如某些TCP/IP协议栈实现)非常依赖union来解析网络报文——把整个报文视为一个大型联合体,根据报文类型访问不同成员,简洁高效。另一些项目(尤其是强调安全性的代码库)则刻意回避联合体,更愿意用显式的memcpy或逐字节解析,理由是联合体的隐式内存共享会让审查者难以追踪“哪段内存此时到底被谁占用”。
我的观点是:联合体本身无善恶,关键在于使用边界的明确性。如果你是解析外部数据的协议层,建议用联合体做视图映射,但必须在代码注释里写清楚当前联合体各成员的含义和生命周期,必要时加断言检查类型标签。而如果只是想在业务逻辑里随便把float当int玩一玩,那没必要,直接memcpy即可,让代码意图更直白。
5.5 编译优化对联合体的影响
现代编译器都知道联合体的成员共享内存,因此可以做很多积极的优化。比如:
union data d; d.f = 3.14f; return d.i;优化后的代码可能根本不会写内存,直接把浮点数的bit pattern算好放进寄存器,以整数形式返回。这在调试时会造成一种“我的代码怎么不按内存布局走”的错觉——你打断点试过就会发现,有些优化后的代码甚至不会在内存里保留联合体变量。合理的调试手段是编译时关掉优化,或者在关键逻辑处增加volatile。但也要注意,不应过度依赖“内存里具体长什么样”来推导行为,重点还是遵守标准定义访问方式。
6. 到底什么时候该用联合体
6.1 适合用联合体的几种场景
总结我这些年实际项目里的经验,适合联合体的场景非常聚焦:
- 硬件寄存器访问:需要整字读写和位拆分,联合体是不二之选
- 通信协议解析:同一数据区有多种语义,联合体直接映射
- 变体数据结构:类型不确定但可能值域有限,需要标记得类型再加联合体
- 内存受限系统:用联合体避免为不会同时用到的数据类型分别分配内存
- 数据序列化/反序列化:需要将多种类型的数据落成同一段缓冲区
在这些场景里,联合体的“共享内存”特性正好迎合了“一块数据多种身份”的天然需求,用起来既有性能又有表达力。
6.2 应避免用联合体的几种场景
反过来,这些情况我会刻意避开联合体:
- 成员有复杂的构造/析构/资源管理需求(C++尤其明显)
- 需要对不同语义的数据做持久化存储,且需要保留“历史版本”
- 类型种类太多又不固定,联合体塞不下所有可能成员
- 代码审查严谨、团队不熟悉联合体语义的场景,宁可多花几行显式逻辑
- 需要兼容极端旧编译器或特殊平台,对内存布局有非常强制要求时
6.3 与其他C惯用法的权衡
联合体不是唯一解。用手动位运算、memcpy、指针偏移、结构体加填充字节,都能做出类似的存储复用效果。联合体的核心价值是让编译器替你管理偏移计算和类型解释,降低出错的概率。代价是你把一部分语义安全交给了标准规定和编译器的实现质量。在高可靠性领域,你可以选择用更保守的写法,但代价是代码啰嗦、易错率上升——比如手写字节拼接逻辑,一不留神就把字节序搞反。
我个人的工程折中是:联合体用于“视图层”——也就是纯粹把某段内存以不同方式解读;数据的产生、校验、生命周期管理全部在联合体之外做。这样既享受了联合体的便利,又把风险控制在可审计的范围内。
最后说两句大实话
联合体是C语言里一个“小而深”的特性,语法上几句话能讲完,但要真正用好,牵扯到内存对齐、大小端、编译器优化、类型安全等一堆底层知识。我学它的过程中最深刻的体会是:不要把它当成一个“省内存的小技巧”,要当成一种“描述内存多种语义”的设计工具。一旦思维转变过来,你会在协议栈、驱动、序列化这些领域发现它的不可替代性。
如果你现在刚开始接触联合体,建议去翻一翻手边项目的源代码,找找有没有寄存器或者协议解析模块,看看别人是怎么用它组织代码的——比自己硬啃语法有用得多。真遇到那种“同一块数据要按照不同条件当成不同类型处理”的需求,联合体大概率就是你最好的答案;但也别迷信它,该用memcpy和标记结构体的地方,老老实实用,别为了炫技把代码搞复杂了。