第16章 共用体与枚举:C语言里的“多面手”与“命名师”

说实话,C语言里有些特性,平时用得不多,但一旦遇到合适的场景,那真是“一招鲜,吃遍天”。共用体(union)和枚举(enum)就是这样的存在。我刚开始学的时候也觉得它们有点鸡肋,直到在项目中踩过坑、省过内存,才真正体会到它们的妙处。今天咱们就好好聊聊这两个家伙,顺带把位域也捎上。

16.1 共用体union:同一块内存,不同的“面孔”

共用体,说白了就是让多个变量共享同一块内存空间。你想想看,如果有一个数据,有时候是整数,有时候是浮点数,但不会同时出现,那何必为它准备两份独立的内存呢?共用体就是干这个的。

16.1.1 定义与基本用法

定义共用体的语法和结构体很像,只是关键字换成了 union

union Data {
    int i;
    float f;
    char str[20];
};

这里 union Data 的三个成员共享同一块内存。这块内存的大小,由最大的成员决定——也就是 char str[20] 的20个字节。我习惯把共用体看作一个“多用途容器”,你往里面放什么,它就变成什么。

使用的时候,和结构体一样用点号或箭头:

union Data data;
data.i = 10;
printf("data.i = %d\n", data.i);

data.f = 3.14;
printf("data.f = %f\n", data.f);

注意!当你给 data.f 赋值后,之前存的 data.i 就被覆盖了。这就是共用体的核心特性——同一时刻只能保存一个成员的值。

⚠️ 重要提醒: 共用体成员之间会互相覆盖。你永远只能读取最近一次写入的那个成员。读取其他成员,得到的是垃圾数据。

16.1.2 我在项目中用过的场景

我记得有一次做嵌入式通信协议解析,数据包里的某个字段,根据类型不同,可能是8位整数、16位整数,或者是一个浮点数。如果用结构体,得定义三个独立变量,浪费内存不说,代码还啰嗦。用共用体就清爽多了:

union PacketPayload {
    uint8_t  byteVal;
    uint16_t wordVal;
    float    floatVal;
};

解析时根据类型标签,往对应的成员里写数据,然后读取。内存只占最大成员的大小,在资源紧张的MCU上,这省下来的每一字节都很珍贵。

16.1.3 共用体与结构体的嵌套

共用体和结构体可以组合使用,这在协议解析中非常常见。比如一个数据帧,头部固定,负载可变:

struct Frame {
    uint8_t type;
    uint8_t length;
    union {
        uint8_t  raw[8];
        struct {
            int16_t x;
            int16_t y;
        } point;
        float value;
    } payload;
};

这样,根据 type 字段,你可以用不同的方式解读 payload。我个人觉得,这种设计比用一堆指针强制转换要安全得多,至少编译器会帮你做类型检查。

16.2 枚举enum:给数字起个“人话”名字

枚举,说白了就是给整数常量起个名字。你写 #define RED 0 也能达到类似效果,但枚举更整洁,而且有作用域限制。

16.2.1 定义与基本用法

enum Color {
    RED,    // 默认0
    GREEN,  // 默认1
    BLUE    // 默认2
};

默认情况下,枚举值从0开始递增。你也可以手动指定:

enum Status {
    OK = 0,
    ERROR = -1,
    TIMEOUT = 1
};

使用枚举变量时,代码的可读性会大幅提升:

enum Color c = RED;
if (c == GREEN) {
    printf("绿色\n");
}

你想想看,如果直接写 if (c == 1),过两个月你自己都看不懂这1代表什么。枚举就是帮你消除这种“魔法数字”的。

16.2.2 枚举在状态机中的应用

我在做嵌入式状态机时,特别喜欢用枚举。比如一个简单的按键扫描状态机:

enum KeyState {
    KEY_IDLE,
    KEY_PRESSED,
    KEY_RELEASED
};

enum KeyState state = KEY_IDLE;

void key_scan(void) {
    switch (state) {
        case KEY_IDLE:
            if (key_pin_read() == 0) {
                state = KEY_PRESSED;
            }
            break;
        case KEY_PRESSED:
            if (key_pin_read() == 1) {
                state = KEY_RELEASED;
            }
            break;
        case KEY_RELEASED:
            // 处理按键释放事件
            state = KEY_IDLE;
            break;
    }
}

用枚举定义状态,switch-case 结构一目了然。我曾经见过有人用 #define 定义状态,结果因为宏没有作用域,不小心和别的模块冲突了,查了半天bug。枚举就没有这个问题。

💡 小技巧: 枚举值在调试器中显示为名字,而不是数字。这对调试状态机非常有帮助。我习惯在枚举定义末尾加一个 MAX_STATE,用来做边界检查。

16.3 位域:精确控制每一位

位域是C语言里一个比较“底层”的特性,它允许你以位为单位定义结构体成员。这在硬件寄存器操作中特别有用。

16.3.1 定义与语法

struct BitField {
    unsigned int a : 1;  // 占1位
    unsigned int b : 3;  // 占3位
    unsigned int c : 4;  // 占4位
};

这里 a 只占1位,b 占3位,c 占4位。整个结构体通常只占一个 unsigned int 的空间(具体取决于编译器)。

16.3.2 实际应用:硬件寄存器操作

我记得在做STM32驱动时,控制寄存器的各个位就是通过位域来操作的。比如一个控制寄存器的定义:

struct ControlReg {
    unsigned int enable : 1;
    unsigned int mode   : 2;
    unsigned int irq_en : 1;
    unsigned int        : 2;  // 保留位,不命名
    unsigned int speed  : 2;
};

然后你可以直接操作这些位域,就像操作普通结构体成员一样:

volatile struct ControlReg* reg = (volatile struct ControlReg*)0x40021000;
reg->enable = 1;
reg->mode = 2;

编译器会自动生成移位和掩码操作,比你手动写 REG |= (1 << 0) 要直观得多。

⚠️ 注意: 位域的内存布局是“实现定义”的。不同编译器可能从不同方向分配位(大端小端问题)。跨平台代码要小心。我一般只在针对特定MCU的驱动代码里用位域。

16.4 知识体系总览

下面这张图帮你理清本章的核心脉络:

第16章 共用体、枚举与位域 共用体 union 同一内存,不同解读 节省内存,协议解析利器 与结构体嵌套使用 枚举 enum 消除魔法数字 状态机首选 调试友好,有作用域 位域 bit-field 精确控制每一位 硬件寄存器操作 注意跨平台兼容性 核心思想:用最合适的工具,表达最清晰的数据

16.5 避坑指南与经验总结

讲到这里,我得分享几个我亲身踩过的坑:

  • 共用体赋值后立即读取其他成员:我曾经在调试一个通信协议时,往共用体的 float 成员写了值,然后去读 int 成员,想当然地以为能得到整数部分。结果当然是乱码。记住,共用体不是“类型转换器”,它只是让你用不同的视角看同一块内存。
  • 枚举值重复:如果手动指定枚举值时不小心重复了,编译器不会报错(至少默认情况下不会)。我建议在枚举定义后加一个静态断言来检查唯一性。
  • 位域的跨平台问题:位域的顺序和填充方式,不同编译器可能不同。如果你写的是库代码,要提供给不同平台使用,最好别用位域,改用移位宏。

📌 核心要点回顾:

  • 共用体:多个成员共享内存,同一时刻只存一个值。适合协议解析、变体数据。
  • 枚举:给整数起名字,提升代码可读性。状态机的好搭档。
  • 位域:按位定义结构体成员,硬件驱动开发利器。注意跨平台兼容性。

嗯,这一章的内容就到这里。共用体、枚举和位域,虽然不像指针、结构体那样天天用,但用对地方,能让你的代码更优雅、更高效。下次写代码时,不妨想想:这里能不能用枚举代替魔法数字?这个数据结构能不能用共用体省点内存?


公众号:蓝海资料掘金营,微信deep3321