资讯动态

C语言共用体union:嵌入式内存复用与协议解析实战

发布时间:2026/10/18 7:48:37 来源:尧图企业网站定制
做嵌入式开发的人应该都体会过内存不够用的感觉。跑一个简单的数据采集任务随手定义了五六种报文结构编译一看RAM占用飙上去几百字节。某次调试串口协议时我盯着一个结构体发呆——不同消息类型下后面一大段数据其实永远不会同时使用但结构体却老老实实给每个字段都分了空间。这种浪费正是C语言共用体union存在的意义。共用体是C语言里最容易被低估的语法特性。它能让多个变量共用同一块内存让同一个数据区在不同时刻扮演不同角色。这篇文章我会把共用体的本质、典型应用场景、实战写法以及踩过的大小端、初始化、数据残留等坑全部梳理一遍。适合刚学完C语言语法但不知道怎么用的人也适合已经写了多年代码想回过头系统整理“共用体到底该怎么用”的朋友。如果你正在做嵌入式、网络协议解析或者内存受限的底层开发这篇内容对你尤其有用。1. 共用体的内存模型与设计价值1.1 共用体的本质完全共享同一段内存共用体和结构体最大的不同在于内存的分配方式。结构体是每个成员各占各的空间想用多少分多少共用体则是所有成员共享同一个起始地址整个联合体的内存大小只取决于最长的那一个成员。看这段代码union data { uint8_t byte_val; uint16_t half_val; uint32_t word_val; float float_val; };这里sizeof(union data)的结果在大多数平台上都是4字节。也就是说byte_val、half_val、word_val、float_val都从同一个地址开始。往word_val里写入0x12345678再去读byte_val拿到的是最低字节0x78读half_val拿到的是0x5678具体顺序受大小端影响这点后面会详细说。这类比起来就像一间临时办公室谁进来谁用同一时间只允许一个人办公。结构体则像一个大平层每个人都有自己的工位彼此空间独立互不干扰。注意共用体的大小由最长成员决定但还会受到字节对齐的影响。如果成员里有结构体编译器可能在该结构体尾部填充字节以满足对齐要求所以union的实际大小不一定正好等于“最长成员的大小”可以打印sizeof确认。1.2 结构体与共用体的核心差异把二者的差异放到一张表里就非常清楚了对比项共用体union结构体struct内存分布所有成员从同一地址开始共享内存成员按声明顺序依次排列各自独立内存大小约等于最长成员的大小含对齐所有成员大小之和加填充字节成员关系同一时刻只能使用其中一个成员所有成员可以同时存在、同时使用典型用途内存复用、协议解析、类型转换组织多个相关联的字段修改影响修改一个成员会影响其他成员修改一个成员不影响其他成员选结构体还是共用体从来不是代码风格问题而是数据模型问题。如果多个数据在逻辑上不可能同时出现或者你希望同一个内存区域在不同时间表示不同类型就适合用共用体。反过来如果数据需要同时保留、同时访问就不要强行共用否则等于自己给自己埋雷。2. 典型应用场景什么时候真正用得上共用体2.1 内存受限设备临时数据缓冲与跨类型复用嵌入式设备的内存通常以KB甚至字节为单位计算。一个传感器节点可能同时连接温度、湿度、气压、光照等多路数据源但同一时刻只会读取一种传感器数据。如果每种传感器的数据结构都独立保存在全局变量里内存浪费很明显。处理这类问题我常用的写法是定义一个容纳“当前采集结果”的共用体union sensor_value { int16_t temperature; // 温度单位 0.1℃ uint16_t humidity; // 湿度单位 0.1%RH uint32_t pressure; // 气压单位 Pa uint16_t light; // 光照强度单位 Lux };这种场景下不同传感器的读数永远不会同时生效。传感器设置一个“当前类型”的标记数据到来后写入对应的成员读取时按同一标记解释数据。整个采集缓冲区只占用4字节而不是三四个结构体零零散散加起来十几字节。我在一个模拟项目里实测过原本用多个全局数组保存不同类型传感器的暂存数据占用约40字节改成共用体之后缓冲区加类型标记一共不到8字节节省下来的RAM可以分配给通信协议栈做更深的队列。嵌入式环境里几十字节的节省可能就意味着系统稳定性上一个台阶。2.2 通信协议解析同一个缓冲区多种读法串口、SPI、网络报文处理是共用体最高频的应用场景之一。通信协议一般分为“帧头类型长度载荷”的结构载荷部分在不同报文类型下含义完全不同。如果不使用共用体常规做法是定义多个结构体再手动用memcpy把载荷拷贝到各自的结构体里代码啰嗦且容易出错。用共用体结构体嵌套可以实现“一个缓冲区多种视角”的效果typedef struct { uint8_t type; uint8_t length; uint16_t payload[8]; } protocol_frame_t; typedef struct { uint8_t type; uint8_t length; uint16_t temperature; uint16_t humidity; } temp_frame_t; union frame { protocol_frame_t raw; temp_frame_t temp_data; // 其他类型的专用结构体继续往后加 };接收数据时先把字节流完整填充到union frame的raw成员解析时根据type字段直接按对应的专用结构体读取。整个过程不需要额外申请缓冲区也不需要memcpy读哪个成员由协议类型决定代码读起来非常直白。这里有个实操细节值得注意如果协议帧里带校验字段必须在完成整帧校验之后再去访问共用体的具体成员。因为校验期间你访问的可能是脏数据一旦共用体里的垃圾值恰好在某个类型对应的字段上“看起来合法”就会跳过真正需要处理的问题。3. 进阶场景类型双关与寄存器模型3.1 用共用体做浮点与字节流互转设备之间传浮点数据本质上还是要转成字节传输。比如一个温度值30.25在单片机里是float类型要通过串口/WiFi模块发给上位机就得把浮点数的4个字节拆出来。网上很多教程直接用指针强转uint8_t *p (uint8_t *)value;这在许多编译器上能用但容易触发别名问题代码也不够直观。用共用体就优雅得多union float_byte { float value; uint8_t bytes[4]; }; union float_byte f; f.value 30.25f; // 发送 f.bytes[0] ~ f.bytes[3]写入浮点成员再按字节数组成员读取本质上就是“同一块内存的不同解释方式”。上位机收到字节后也可以按同样的联合体结构先填充字节数组再读浮点成员完成还原。提示C标准规范层面用共用体做类型双关的行为在不同标准版本下描述并不完全一致但在主流的嵌入式编译器比如GCC系列中这是广泛使用且被支持的写法。如果项目需要严格兼容某一套标准建议在工程文档里明确写清楚使用的编译器特性避免后续迁移时产生分歧。3.2 寄存器位字段模拟直接操作硬件寄存器做底层驱动时经常要访问一个32位寄存器的某些位域。比如某控制寄存器bit0到bit3是模式位bit4是使能位bit8到bit15是状态位。直接用移位运算写起来又费眼神又容易错。寄存器组的建模非常适合“共用体位域结构体”的组合union ctrl_reg { uint32_t reg; // 整体访问 struct { uint32_t mode : 4; // bit0 ~ bit3 uint32_t enable : 1; // bit4 uint32_t reserved : 3; // bit5 ~ bit7 uint32_t status : 8; // bit8 ~ bit15 uint32_t reserved2: 16; // bit16 ~ bit31 } bits; };需要读整个寄存器时直接访问reg成员需要改某一位时操作bits成员里的字段。驱动代码可读性提升一个量级。这种写法最大的坑是位域的可移植性。位域的分配方向、填充规则、以及bit0在第一字节的高位还是低位都依赖具体编译器和处理器架构。因此寄存器模型里的共用体位域只能用于特定平台跨平台复用时需要逐个字段验证。我的习惯是凡是涉及位域的共用体都要在代码注释里写明目标平台、编译器开关、以及验证用的寄存器值。4. 真实项目中的三个实战拆解4.1 设备状态事件上报我在某跨平台系统里做过一个事件上报模块。设备会上报多种状态事件比如按键按下、温度越限、通信异常、低电量警告每种事件携带的载荷不同。按传统结构体写法每种事件一个结构体再加上一个公共的事件类型字段代码管理很繁琐。我最后用一个“带类型标记的共用体”解决了typedef enum { EVENT_KEY_PRESS, EVENT_TEMP_OVER_LIMIT, EVENT_COMM_ERROR, EVENT_LOW_POWER } event_type_t; typedef struct { uint16_t key_code; } key_event_data_t; typedef struct { int16_t temperature; int16_t limit_value; } temp_event_data_t; typedef struct { uint16_t error_code; } comm_event_data_t; typedef struct { uint8_t battery_level; } power_event_data_t; typedef struct { event_type_t type; union { key_event_data_t key; temp_event_data_t temperature; comm_event_data_t comm; power_event_data_t power; } data; } device_event_t;每个事件节点固定占用内存是所有事件载荷里最大的那个而不是所有事件之和。事件队列很深的时候这种节省是积少成多的。处理时根据type取对应的data成员逻辑也清晰。这种方式在通用嵌入式领域有个专用名词叫“tagged union”很多高级语言里也有类似思路C语言用枚举共用体就实现了。4.2 多类型配置参数的统一存储设备往往有一堆配置参数整数类型的采样周期、浮点类型的校准系数、布尔类型的开关标志。这些参数要保存到Flash里也需要在内存中暂存。如果为每种类型单独开数组浪费空间同时增加读写代码。我建造了一个通用的配置项结构typedef enum { CFG_TYPE_U8, CFG_TYPE_I16, CFG_TYPE_FLOAT, CFG_TYPE_BOOL } cfg_type_t; typedef struct { cfg_type_t type; union { uint8_t val_u8; int16_t val_i16; float val_float; bool val_bool; } value; } cfg_item_t;每次从Flash加载配置时先读类型标记再按类型填充对应的共用体成员。保存配置时反过来。一个配置项的内存占用固定最小代码不用为每种类型写一套重复的加载逻辑。这类代码里最容易出问题的是序列化和反序列化的字节长度。如果直接对cfg_item_t做memcpy整体读写不同编译器会对联合体做不同填充导致Flash里存的字节数和上位机解析时不一致。我的处理方式是每个字段独立打包写入禁止对包含枚举的联合体结构直接做整块二进制拷贝。4.3 通信模块的接收缓冲区复用我把之前做过的某通信模块接收处理再拿出来说说。模块通过串口接收主机的多条命令不同命令的响应长度和格式完全不同。开始时我为每条命令都申请了一个独立的接收缓冲软件复杂度直线上升每个缓冲区都要维护自己的读指针、写指针、解析状态。后面改成一块共用的接收缓冲区配合命令类型标记统一解释typedef union { uint8_t raw_buffer[64]; version_info_t version; device_info_t device; sensor_data_t sensor; } response_buffer_t;新指令到来时先把原始数据全部收进raw_buffer等完整帧收齐后根据指令类型将raw_buffer中的数据解释为对应的响应结构。一块64字节的缓冲区走天下代码的解析入口统一到同一个函数。实际测试下来逻辑更简单出问题的概率反而更小。5. 共用体的坑与问题排查技巧5.1 大小端问题同一个union在不同平台读出来不一样这是共用体最容易踩的坑。用一个union { uint16_t val; uint8_t byte[2]; }在小端平台写入0x1234byte[0]是0x34byte[1]是0x12在大端平台则完全相反。如果用共用体解析网络协议或者跨平台传输数据不做字节序转换结果必然错乱。排查这类问题时我通常写一个小测试函数往union里写入已知值再逐个字节打印出来。确认平台字节序后统一走以下方案uint16_t read_le16(const uint8_t *p) { return (uint16_t)p[0] | ((uint16_t)p[1] 8); }对于协议报文我尽量不用“写结构体后直接读整个union”的方式而是用移位拼装字节。虽然代码稍微长一点但完全绕过大小端问题各种平台行为一致。注意如果你确实需要直接在硬件寄存器上使用联合体位域那必须按照具体芯片手册的字节序来定义位域的排列顺序不要试图写一套“通用”的寄存器联合体。5.2 访问未初始化的成员共用体不会在创建时清空内存。定义一个局部共用体变量里面可能是上次函数留下的栈垃圾。如果直接读某个成员拿到的是未知数据。我踩过的典型场景是先用浮点成员存了数值后来忘了先写字节数组成员直接读了字节结果发出了一组毫无逻辑的数据。排查了半小时最后发现是共用体成员没有先行写入读取的字节完全是上一次的残留。推荐做法是两条局部共用体变量务必先整体清零memset(u, 0, sizeof(u));访问任何成员之前必须先明确写入对应成员养成“先写后读”的习惯5.3 修改一个成员导致其他成员失效共用体的所有成员共享内存往一个成员写入数据会覆盖其他成员的内容。设计上这是特性用起来却容易忽略。如果代码里先读取了A成员的值保存在临时变量之后再改写了B成员回头再访问A成员已经不再是原来的值。解决这个问题不能“小心一点”就算完而是要把共用体的互斥性写进代码结构里。比如用带类型标记的union保证访问A成员前一定先设置或检查类型标记避免无意识混用。代码审查时看到“一个union的两个成员在同一个函数里被交替使用”就要警惕是不是有隐含的逻辑错误。5.4 未用sizeof验证内存大小初学者经常对union大小做错误假设。以为有几个成员就分配几个成员的空间结果memcpy时复制了过大或过小的长度导致缓冲区越界或数据不完整。我建议开发时直接打印实际sizeof确认printf(sizeof(union data) %lu\n, (unsigned long)sizeof(union data));尤其在联合体成员包含嵌套结构体时对齐填充可能让union比预期大几个字节。提前验证比上线后再排查省心得多。6. 工程实践建议与个人经验6.1 判断项目里是否真的需要共用体共用体不是用来炫技的选它之前先问自己三个问题这些数据是否永远不会同时有效是否真的面临内存或缓冲区的约束代码可读性是否因为共用体而明显提升如果三个问题里有两个以上回答是否定那就老老实实用结构体或独立变量。为了省几个字节而引入极易出错的共享内存机制多数情况下不划算。6.2 推荐的使用模式带类型标记的封装工程上我强烈推荐使用“标记共用体”的统一封装模式。哪怕现在只有一个类型也提前把枚举类型标记写进去。后续扩展时只需在枚举和联合体里各增加一项改动集中且可控。封装时再配合访问函数尽量避免外部代码直接操作union内部成员void set_temp_cfg(cfg_item_t *item, float temp); float get_temp_cfg(const cfg_item_t *item);这样的好处是后续如果需要调整内部存储结构只要对外接口不变调用方完全无感。6.3 我的个人体会做了几年底层开发我对共用体的态度是“该用就用但不滥用”。它在内存受限场景下的价值是实打实的尤其在嵌入式设备里一个精心设计的union能省下几十字节RAM还能让协议解析代码简洁许多。但共用体也确实是C语言里最容易出隐性bug的地方大小端、初始化、类型混用随便一个不注意都能让人排查到怀疑人生。我自己的工作习惯是所有使用union的地方都补上详尽的注释说明当前哪个成员有效依据是什么标记所有跨通信边界的union一律加字节序转换函数所有局部union一律先memset初始化。坚持这几条之后因为共用体导致的bug数量骤降。如果你正准备在项目里引入union建议先把文中这些场景和坑过一遍。先在测试工程里写几个最小用例验证你平台上的sizeof、大小端、位域行为心里有底之后再动正式代码。这样你踩的坑会比当年的我少得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑