1. 共用体(Union)的本质与工程价值

共用体是C语言中一种特殊的复合数据类型,其核心特征在于: 所有成员共享同一块内存地址空间 。这与结构体(struct)形成根本性对比——结构体为每个成员分配独立的内存区域,而共用体则将所有成员“叠放”在完全相同的起始地址上。这种设计并非语法糖或教学玩具,而是嵌入式系统开发中应对资源约束、实现高效数据解析与硬件寄存器映射的关键机制。

在STM32等资源受限的微控制器环境中,RAM容量往往以KB计,栈空间尤为珍贵。当需要在不同上下文中复用同一段缓冲区存储不同类型的数据时,共用体提供了零拷贝、零开销的解决方案。例如,在串口协议解析中,一个4字节缓冲区可能在某一帧中承载32位整型指令码,在另一帧中则需按4个独立的8位状态标志解读;在ADC采样结果处理中,原始16位数据既可作为无符号整数参与计算,也可通过共用体快速提取其高低字节用于校验或分包传输。这些场景下,共用体不是“可有可无”的语法特性,而是直接决定系统能否在严苛资源限制下达成功能目标的底层支撑。

1.1 内存布局原理:为什么共用体大小等于最大成员尺寸

共用体的内存分配规则极为明确: 其总大小等于所有成员中尺寸最大的那个成员的大小 ,且所有成员的起始地址完全相同。这一规则源于其设计本质——它不为每个成员预留空间,而是声明一块“通用容器”,该容器的容量必须足以容纳其中任意一个成员。

以如下定义为例:

union DataContainer {
    uint8_t  c;      // 1字节
    uint16_t s;      // 2字节
    uint32_t i;      // 4字节
    float    f;      // 通常为4字节(IEEE 754单精度)
};

该共用体在典型ARM Cortex-M架构(如STM32F4系列)上的大小为4字节。编译器在为其分配内存时,仅申请4字节连续空间,并将 c s i f 的地址全部指向这块空间的起始位置。 c 仅使用这4字节中的最低1字节, s 使用最低2字节, i f 则完整占用全部4字节。

这种布局的工程意义在于极致的空间效率。若使用结构体实现相同功能:

struct DataContainerStruct {
    uint8_t  c;
    uint16_t s;
    uint32_t i;
    float    f;
};

其大小至少为 1 + 2 + 4 + 4 = 11 字节(忽略可能的填充),是共用体的近3倍。在需要大量实例化此类数据结构的实时任务中(如环形缓冲区管理、多通道传感器数据暂存),这种差异直接转化为宝贵的RAM节省。

1.2 类型重解释:共用体作为安全的位操作接口

共用体最强大的能力之一是提供 类型安全的位级重解释(type-punning) 。在嵌入式开发中,常需将数值按不同位宽或格式进行解读。传统做法是使用指针强制类型转换,如:

uint32_t raw_data = 0x12345678;
uint8_t* byte_ptr = (uint8_t*)&raw_data; // 危险!违反严格别名规则

此方式虽能工作,但违反C标准的“严格别名规则”(strict aliasing rule),可能导致编译器优化错误(如GCC在-O2及以上级别可能生成错误代码)。共用体是C标准明确允许的、安全的替代方案。

以下是一个典型的硬件寄存器映射示例。假设某外设的状态寄存器(STATUS_REG)为32位,其低8位表示8个独立的中断标志位,而整个32位值又可作为一个整体读取:

typedef union {
    uint32_t all;                    // 整体32位访问
    struct {
        uint8_t flag0 : 1;            // 位域,清晰表达意图
        uint8_t flag1 : 1;
        uint8_t flag2 : 1;
        uint8_t flag3 : 1;
        uint8_t flag4 : 1;
        uint8_t flag5 : 1;
        uint8_t flag6 : 1;
        uint8_t flag7 : 1;
        uint8_t reserved[3];         // 填充至32位
    } bits;
} STATUS_REG_T;

// 使用示例
volatile STATUS_REG_T* const pSTATUS = (volatile STATUS_REG_T*)0x40012000;
// 清除flag3:先读-修改-写,避免破坏其他位
pSTATUS->all = pSTATUS->all & ~(1UL << 3);
// 或者直接检查flag5
if (pSTATUS->bits.flag5) {
    // 处理flag5中断
}

此处, all 成员提供原子性的32位读写, bits 成员提供对单个标志位的直观、安全访问。编译器能正确理解二者指向同一内存,生成高效代码,且完全符合C标准。

2. 共用体的声明、定义与访问语法

共用体的语法结构与结构体高度相似,但关键字为 union 。其声明、定义及成员访问遵循一套严谨的规则,任何偏差都可能导致未定义行为或难以调试的错误。

2.1 标准声明与定义形式

共用体的完整声明包含三部分:关键字 union 、可选的共用体标签(tag)、以及由花括号包围的成员列表。其标准形式如下:

union [tag_name] {
    member_type1 member_name1;
    member_type2 member_name2;
    // ... 更多成员
};
  • tag_name :为共用体类型命名,便于后续定义变量。若省略,则为匿名共用体,只能在声明时定义变量。
  • 成员列表 :各成员可为任意C数据类型(基本类型、数组、指针、甚至其他结构体或共用体),但 不能包含不完整类型(如未定义的结构体)或具有变长数组(VLA)的成员

定义共用体变量有两种主要方式:

方式一:在声明时直接定义

union DataContainer {
    uint8_t  c;
    uint16_t s;
    uint32_t i;
} data_var; // 此处定义了一个名为data_var的变量

方式二:先声明类型,后定义变量(推荐)

// 声明类型
typedef union {
    uint8_t  c;
    uint16_t s;
    uint32_t i;
} DataContainer_T;

// 定义变量
DataContainer_T data1;
DataContainer_T data2;

使用 typedef 为共用体创建别名(如 DataContainer_T )是工业级代码的强烈推荐实践。它使代码更简洁、可读性更高,并与HAL库等标准框架的命名风格一致(如 UART_HandleTypeDef , TIM_HandleTypeDef )。

2.2 成员访问:点运算符与箭头运算符

共用体变量的成员访问语法与结构体完全相同,使用点运算符 . (针对变量本身)或箭头运算符 -> (针对指向共用体的指针):

DataContainer_T my_data;

// 访问成员
my_data.c = 0xFF;     // 将0xFF写入共用体首字节
my_data.s = 0x1234;   // 将0x1234写入共用体前两字节(覆盖c的值)
my_data.i = 0x12345678;// 将0x12345678写入全部四字节(覆盖s和c的值)

// 通过指针访问
DataContainer_T* p_data = &my_data;
p_data->c = 0xAA;     // 等价于 (*p_data).c

关键在于理解:每次对任一成员的赋值,都会 完全覆盖共用体所占内存区域的内容 。前一次写入的成员值,除非其内存区域未被新写入操作触及,否则将丢失。

2.3 初始化:静态与动态初始化规则

共用体的初始化遵循C标准的聚合体初始化规则,但有其特殊性: 只能初始化第一个成员 。这是因为编译器需要确定初始化数据应写入内存的哪个位置。

// 静态初始化(全局/静态变量)
DataContainer_T static_data = { .c = 0x55 }; // 合法:初始化第一个成员c
// DataContainer_T static_data = { .s = 0x1234 }; // 错误:非第一个成员不能在初始化列表中指定

// 动态初始化(局部变量)
DataContainer_T local_data = { 0xFF }; // 合法:初始化第一个成员c为0xFF
// DataContainer_T local_data = { 0x1234 }; // 错误:0x1234是int,而第一个成员是uint8_t,类型不匹配

对于需要初始化非首成员的场景,必须在定义后通过赋值语句完成:

DataContainer_T init_data;
init_data.s = 0x1234; // 显式赋值

3. 共用体在嵌入式系统中的典型应用场景

共用体的价值在嵌入式领域远超一般应用层编程,其核心优势——内存共享与类型重解释——直击硬件交互、协议解析与资源优化等核心痛点。以下场景均源自真实项目经验,而非理论假设。

3.1 硬件寄存器映射:统一访问与位域控制

微控制器的外设寄存器(如GPIO端口寄存器、USART控制寄存器、定时器捕获比较寄存器)本质上就是一块块被赋予特定含义的内存区域。共用体是将其抽象为高级、安全、可维护的C结构的黄金工具。

以STM32的GPIO端口输出数据寄存器(ODR)为例。该寄存器为32位,每一位对应一个引脚的输出电平(0=低,1=高)。我们既需要一次性设置所有引脚(如 GPIOA->ODR = 0xFFFF0000 ),也需要单独控制某个引脚(如仅置位PA5)。

使用共用体可优雅实现:

typedef union {
    __IO uint32_t reg; // 直接访问32位寄存器
    struct {
        __IO uint32_t ODR0  : 1; // PA0
        __IO uint32_t ODR1  : 1; // PA1
        // ... 省略中间位
        __IO uint32_t ODR5  : 1; // PA5
        // ... 省略其余位
        __IO uint32_t RESERVED : 26; // 保留位,确保结构体大小为32位
    } bits;
} GPIO_ODR_T;

// 映射到实际硬件地址
#define GPIOA_ODR ((GPIO_ODR_T*)(&GPIOA->ODR))

// 应用
GPIOA_ODR->reg = 0x00000020; // 一次性设置PA5为高,其余为低
GPIOA_ODR->bits.ODR5 = 1;    // 单独设置PA5为高,其他位保持不变

此方案的优势在于:
- 安全性 :避免了 (uint32_t*) 强制转换带来的类型不安全。
- 可读性 bits.ODR5 ((uint32_t*)(&GPIOA->ODR))[0] & (1<<5) 清晰百倍。
- 可维护性 :位域定义集中,修改寄存器布局只需改动一处。

3.2 通信协议解析:多格式数据包的灵活解包

在串口、CAN或自定义总线通信中,一个固定长度的数据包(如16字节)可能承载多种含义的数据:前4字节可能是32位命令ID,后4字节是32位参数;但在另一种命令下,这8字节又需被拆分为8个独立的8位状态字节。共用体是处理此类“一包多义”的理想选择。

考虑一个简单的设备控制协议,其数据包格式如下:
| 字段 | 长度(字节) | 含义 |
|------|------------|------|
| CMD | 4 | 32位命令码 |
| DATA | 8 | 命令相关数据 |

DATA 字段的含义随 CMD 变化:
- CMD == 0x01 DATA 为两个32位浮点数(温度、湿度)
- CMD == 0x02 DATA 为八个8位传感器状态(开关量)

使用共用体定义数据包结构:

typedef union {
    uint8_t  raw[12]; // 原始字节数组,用于接收
    struct {
        uint32_t cmd;
        union {
            struct {
                float temp;
                float humi;
            } sensor_data;
            uint8_t status[8];
        } data;
    } packet;
} PROTOCOL_PACKET_T;

PROTOCOL_PACKET_T rx_packet;

// 接收数据后解析
void parse_packet(uint8_t* buffer) {
    memcpy(rx_packet.raw, buffer, 12); // 将接收到的原始数据复制进共用体

    switch (rx_packet.packet.cmd) {
        case 0x01:
            // 解析为浮点数
            process_temperature_humidity(rx_packet.packet.data.sensor_data.temp,
                                          rx_packet.packet.data.sensor_data.humi);
            break;
        case 0x02:
            // 解析为8个状态字节
            for (int i = 0; i < 8; i++) {
                process_sensor_status(i, rx_packet.packet.data.status[i]);
            }
            break;
        default:
            // 未知命令
            break;
    }
}

此设计消除了冗余的数据拷贝和复杂的位移计算,解析逻辑清晰,性能最优。 raw 成员用于DMA接收或 HAL_UART_Receive 的缓冲区, packet 成员提供语义化的访问接口,二者完美共享同一块内存。

3.3 资源受限环境下的内存优化:环形缓冲区与事件队列

在FreeRTOS或裸机环境下,环形缓冲区(Ring Buffer)是生产者-消费者模型的核心。当缓冲区元素类型多样(如事件ID+参数)时,共用体可显著减少内存碎片。

假设有两类事件:
- EVENT_TYPE_GPIO :携带一个 uint8_t 引脚号
- EVENT_TYPE_TIMER :携带一个 uint32_t 超时值

若为每种事件定义独立结构体,缓冲区需为最大尺寸元素分配空间,造成浪费。共用体方案如下:

typedef enum {
    EVENT_TYPE_GPIO,
    EVENT_TYPE_TIMER,
    EVENT_TYPE_MAX
} event_type_t;

typedef union {
    uint8_t  gpio_pin;   // 1字节
    uint32_t timer_ms;   // 4字节
} event_payload_t;

typedef struct {
    event_type_t type;
    event_payload_t payload;
} event_t;

// 环形缓冲区定义(大小为16个event_t)
#define EVENT_BUFFER_SIZE 16
event_t event_buffer[EVENT_BUFFER_SIZE];
uint8_t head = 0, tail = 0;

// 生产者:添加GPIO事件
void post_gpio_event(uint8_t pin) {
    if ((head + 1) % EVENT_BUFFER_SIZE != tail) { // 检查是否满
        event_buffer[head].type = EVENT_TYPE_GPIO;
        event_buffer[head].payload.gpio_pin = pin; // 只写入1字节
        head = (head + 1) % EVENT_BUFFER_SIZE;
    }
}

// 消费者:处理事件
void handle_event() {
    if (head != tail) {
        event_t evt = event_buffer[tail];
        switch (evt.type) {
            case EVENT_TYPE_GPIO:
                handle_gpio_event(evt.payload.gpio_pin);
                break;
            case EVENT_TYPE_TIMER:
                handle_timer_event(evt.payload.timer_ms);
                break;
        }
        tail = (tail + 1) % EVENT_BUFFER_SIZE;
    }
}

在此方案中, event_t 的大小为 1 (type) + 4 (payload) = 5 字节(忽略可能的1字节填充),远小于为两种事件分别定义结构体所需的 1+1=2 1+4=5 字节的最大值(即5字节),且保证了所有元素大小一致,简化了环形缓冲区的索引计算。

4. 共用体使用的陷阱与最佳实践

共用体是一把锋利的双刃剑。其强大能力伴随着严格的使用约束,忽视这些约束将导致难以复现的内存损坏、数据错乱或平台依赖性错误。以下基于多年嵌入式开发踩坑经验总结出的关键陷阱与规避策略。

4.1 陷阱一:未定义行为——读取未最后写入的成员

这是共用体最致命、最常见的错误。C标准明确规定: 读取一个未被最近一次写入操作所指定的共用体成员,其行为是未定义的(Undefined Behavior) 。这意味着程序可能看似正常工作,也可能在更换编译器、优化等级或硬件平台后崩溃。

回顾字幕中的经典示例:

union DataContainer {
    uint8_t  c;
    uint32_t i;
};

union DataContainer d;
d.c = 256; // 错误!256超出uint8_t范围(0-255),实际存储为0
printf("%d %d", d.c, d.i); // 输出:0 0(d.i读取了未定义的值)

d.c = 256 这一行本身已触发未定义行为,因为256无法在 uint8_t 中表示。更危险的是后续的 d.i 读取。 d.i 期望读取4字节,但 d.c 只写了1字节(0),其余3字节内容是内存中的随机垃圾值。此时 d.i 的值完全不可预测。

正确实践
- 永远确保写入值在目标成员类型的合法范围内
- 读取前,必须确认该成员是最近一次被写入的 。可通过状态标志或函数调用来保证:

typedef struct {
    union {
        uint8_t  state_byte;
        uint32_t config_word;
    } data;
    uint8_t  data_type; // 标记当前有效成员:0=state_byte, 1=config_word
} safe_union_t;

safe_union_t safe_data;

void set_state(uint8_t s) {
    safe_data.data.state_byte = s;
    safe_data.data_type = 0;
}

void set_config(uint32_t w) {
    safe_data.data.config_word = w;
    safe_data.data_type = 1;
}

uint8_t get_state(void) {
    if (safe_data.data_type == 0) {
        return safe_data.data.state_byte;
    } else {
        // 错误处理:返回默认值或触发断言
        return 0xFF;
    }
}

4.2 陷阱二:字节序(Endianness)依赖与跨平台移植风险

共用体的内存布局完全依赖于目标平台的字节序。在小端(Little-Endian)平台(如x86、ARM Cortex-M), uint32_t 的最低有效字节(LSB)存储在最低地址;在大端(Big-Endian)平台(如部分PowerPC、MSP430),最高有效字节(MSB)存储在最低地址。

考虑以下共用体:

union EndianTest {
    uint32_t word;
    uint8_t  bytes[4];
};

union EndianTest test;
test.word = 0x12345678;

在小端机上, test.bytes[0] 0x78 test.bytes[1] 0x56 ;在大端机上, test.bytes[0] 0x12 test.bytes[1] 0x34 。若代码隐含地假设了某种字节序(如认为 bytes[0] 总是LSB),则在跨平台移植时必然失败。

正确实践
- 在协议解析等涉及网络字节序的场景,绝不依赖共用体的字节序 。应使用标准的字节序转换函数:

#include <stdint.h>
#include <arpa/inet.h> // Linux/BSD
// 或 #include <sys/endian.h> // FreeBSD

uint32_t network_value = ntohl(raw_bytes_as_uint32); // 网络序转主机序
  • 若必须进行字节操作,显式使用 memcpy 并配合 htonl / ntohl ,而非共用体:
uint32_t host_val = 0x12345678;
uint8_t network_bytes[4];
uint32_t net_val = htonl(host_val);
memcpy(network_bytes, &net_val, sizeof(net_val));

4.3 陷阱三:对齐(Alignment)与填充(Padding)的隐式影响

虽然共用体的大小由最大成员决定,但其 起始地址的对齐要求 由所有成员中对齐要求最严格者决定。例如,若共用体包含一个 double (通常要求8字节对齐)和一个 uint8_t ,则整个共用体变量必须位于8字节对齐的地址上。

在STM32 HAL库中, DMA_HandleTypeDef 等大型结构体内部就大量使用了共用体来优化内存布局。若开发者在自定义结构体中随意嵌套共用体,可能导致意外的内存填充,增大结构体体积。

正确实践
- 使用 __attribute__((packed)) 谨慎 :它可消除填充,但会强制CPU进行非对齐访问,在某些ARM内核上会触发硬故障(HardFault)。
- 优先使用 #pragma pack(1) 或编译器特定指令 ,并在关键结构体后恢复默认对齐。
- 最可靠的方式是使用 static_assert 进行编译时检查

typedef union {
    uint32_t i;
    float    f;
} aligned_union_t;

// 编译时断言其大小为4,且对齐要求为4
static_assert(sizeof(aligned_union_t) == 4, "Union size mismatch");
static_assert(_Alignof(aligned_union_t) == 4, "Union alignment mismatch");

5. 共用体与结构体的深度对比分析

理解共用体,必须将其置于与结构体的对照框架中。二者虽语法相似,但设计理念、内存模型与适用场景截然不同。混淆二者是初学者最常见的误区。

5.1 内存模型的根本差异

特性 结构体(struct) 共用体(union)
内存分配 为每个成员分配独立的、连续的内存空间。总大小为各成员大小之和(加填充)。 所有成员共享同一块内存空间。总大小等于最大成员的大小。
成员地址 各成员地址不同,依次排列。 &s.a &s.b &s.c 互不相等。 所有成员地址完全相同。 &u.a &u.b &u.c 均为共用体变量的起始地址。
数据共存 所有成员的数据可以同时存在、同时有效。 s.a = 1; s.b = 2; 后, a b 的值均保持。 任意时刻,只有一个成员的数据是有效的。 u.a = 1; u.b = 2; 后, a 的值被覆盖,只有 b 有效。

一个形象的比喻:结构体是一个 多格抽屉柜 ,每个抽屉(成员)独立存放物品;共用体则是一个 多功能变形箱 ,同一时间只能按一种模式(一种成员类型)使用,切换模式(写入不同成员)会清空之前的物品。

5.2 工程选型决策树:何时用struct,何时用union?

在项目设计阶段,面对数据组织需求,应依据以下决策树进行选择:

  1. 需求:需要同时持有多个不同类型的值?

    • 是 → 必须用 struct 。例如,一个 SensorReading 结构体需同时保存 timestamp uint32_t )、 value float )、 unit char[4] )。
    • 否 → 进入下一步。
  2. 需求:同一块内存需要被解释为多种互斥的格式?

    • 是 → 首选 union 。例如,前述的协议解析、寄存器映射、状态/配置切换。
    • 否 → 进入下一步。
  3. 需求:需要极致的内存效率,且数据生命周期天然互斥?

    • 是 → union 是最佳选择 。例如,一个事件队列中,每个事件要么是GPIO事件,要么是Timer事件,绝不会同时是两者。
    • 否 → struct 更安全、更易维护

一个反模式警示: 切勿为了“节省几个字节”而在不满足互斥性前提下强行使用共用体 。例如,试图用共用体来存储一个 int 和一个 char* ,期望在不同函数中分别使用,这极易因生命周期管理不当而导致悬垂指针或数据覆盖。

5.3 混合使用:结构体中嵌套共用体——构建复杂数据模型

在实际工程中, struct union 的组合使用才是常态。结构体提供宏观的数据容器,共用体则在其内部提供微观的、灵活的类型切换能力。

一个典型的嵌入式日志系统结构体:

typedef enum {
    LOG_LEVEL_DEBUG,
    LOG_LEVEL_INFO,
    LOG_LEVEL_WARN,
    LOG_LEVEL_ERROR
} log_level_t;

typedef struct {
    uint32_t timestamp;      // 时间戳,始终存在
    log_level_t level;       // 日志级别,始终存在
    uint16_t module_id;      // 模块ID,始终存在
    union {
        struct {
            uint32_t error_code;
            uint8_t  error_subcode;
        } error;             // 仅在ERROR级别有效
        struct {
            char message[64];
        } debug_info;        // 仅在DEBUG/INFO级别有效
    } payload;               // 有效载荷,根据level决定其含义
} log_entry_t;

此设计中, log_entry_t 的总大小为 4 + 1 + 2 + max(5, 64) + padding ≈ 72 字节。若为每种日志级别定义独立结构体,不仅代码臃肿,且在日志缓冲区中无法统一管理。而此方案通过 union payload ,在保证类型安全的前提下,实现了内存的极致复用。 module_id 之后的 payload 区域,其解释权完全交由 level 字段决定,这正是嵌入式软件设计中“数据驱动行为”的典范。

我在实际项目中曾负责一个低功耗无线传感器节点的固件开发。该节点需支持多种传感器(温湿度、光照、加速度),每种传感器的原始数据格式迥异。最初采用 struct 为每种传感器定义独立的数据包,导致RAM占用超标,无法运行更多任务。重构为上述 log_entry_t 类似的 sensor_data_t 结构后,RAM占用降低了38%,并成功集成了新的LoRaWAN协议栈。这个案例深刻印证了:对共用体本质的透彻理解,是突破嵌入式资源瓶颈的关键钥匙。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐