联合体、枚举、位域与 typedef
上一篇我们彻底拆了结构体的内存布局,搞清楚了编译器会在你的字段之间塞填充字节这件事。这一篇我们要看的四个语言特性——联合体(union)、枚举(enum)、位域(bit-field)和 typedef——看起来像是结构体的"配角",但它们各自都有不可替代的用武之地。联合体让你在同一块内存上玩变戏法,枚举让你用有意义的名字代替魔法数字,位域让你按位精确控制内存布局,typedef 则让你给类型起别名、把复杂声明收拾干净。
这四个特性在嵌入式开发中几乎是形影不离的。如果你去看任何一块 MCU 的头文件(比如 STM32 的 stm32f1xx.h),你会发现寄存器的定义就是联合体+结构体+位域+typedef 的组合拳。搞懂它们,你才能读懂那些看上去密密麻麻的硬件抽象层代码。
第一步——用联合体在同一块内存上变戏法
搞清楚联合体的内存模型
联合体的定义语法和结构体几乎一模一样,唯一的区别是关键字从 struct 换成了 union。但它们的内存行为天差地别:结构体的每个成员各自占据独立的内存空间,而联合体的所有成员共享同一块起始地址相同的内存。联合体的大小等于其最大成员的大小(可能再加上一些对齐填充)。
#include <stdio.h>
#include <stdint.h>
typedef union {
uint8_t u8;
uint16_t u16;
uint32_t u32;
} IntUnion;
int main(void) {
printf("sizeof(IntUnion) = %zu\n", sizeof(IntUnion)); // 4
return 0;
}运行结果:
sizeof(IntUnion) = 4在当前 GCC x86_64 环境中,IntUnion 的大小是 4 字节。u8、u16、u32 三个成员的起始地址完全相同;写入某个成员后,其他成员看到的也是这块重叠存储中的字节,而不是三份彼此独立的数据。
联合体在同一时刻只有一个成员是有效的。写入一个成员后再读取另一个成员,在 C 标准中属于未定义行为(除了类型双关的例外)。你必须自己记住当前哪个成员是活跃的,编译器不会帮你检查。
用类型双关查看浮点数的二进制表示
虽然 C 标准说"写入一个成员后读取另一个成员是未定义行为",但有一个重要的例外:通过联合体进行类型双关在 C99 及以后是合法的。所谓类型双关,就是把同一块内存按不同的类型来解读:
#include <stdio.h>
#include <stdint.h>
typedef union {
float f;
uint32_t u;
} FloatBits;
int main(void) {
FloatBits fb;
fb.f = 3.14f;
printf("float 值: %f\n", fb.f); // 3.140000
printf("二进制表示: 0x%08X\n", fb.u); // 0x4048F5C3
return 0;
}运行结果:
float 值: 3.140000
二进制表示: 0x4048F5C3这在 C 中是完全合法的。但要注意,这在 C++ 中是未定义行为——C++ 标准不允许通过联合体进行类型双关。如果你在 C++ 代码中需要做类似的事情,应该使用 memcpy(编译器会优化掉)或者 std::bit_cast(C++20)。
组合联合体和结构体实现变体类型
联合体真正发挥威力的时刻是和结构体、枚举组合使用。单独的联合体没什么用——因为你不知道当前存的是哪个成员。但如果你加一个"标签"来记录当前类型,它就变成了一个有意义的变体类型:
展开代码收起代码共 31 行
#include <stdio.h>
#include <stdint.h>
typedef enum {
kValueTypeInt,
kValueTypeFloat,
kValueTypeString
} ValueType;
typedef struct {
ValueType tag;
union {
int32_t int_val;
float float_val;
const char* str_val;
} data;
} TaggedValue;
void print_value(const TaggedValue* v) {
switch (v->tag) {
case kValueTypeInt:
printf("int: %d\n", v->data.int_val);
break;
case kValueTypeFloat:
printf("float: %f\n", v->data.float_val);
break;
case kValueTypeString:
printf("string: %s\n", v->data.str_val);
break;
}
}这种"标签+联合体"的组合模式叫做 tagged union(标签联合体),是 C 语言中实现多态的基本手法。
第二步——用枚举给整数起名字
搞清楚枚举的本质
枚举让你定义一组命名的整数常量,语法很简单:
typedef enum {
kColorRed,
kColorGreen,
kColorBlue
} Color;
Color c = kColorGreen;
printf("%d\n", c); // 1枚举值默认从 0 开始递增。你可以显式指定值:
typedef enum {
kStatusOk = 0,
kStatusError = 1,
kStatusTimeout = 2,
kStatusBusy = 3,
kStatusInvalidArg = 4
} StatusCode;注意枚举的局限性
C 语言的枚举有一个让人又爱又恨的特点:枚举值本质上就是 int。这意味着你可以把任意整数赋给枚举变量,编译器不会报错:
Color c = 42; // 合法!但 42 不是任何枚举值
int x = kColorRed; // 合法!隐式转为 int这种宽松在 C 语言看来是"灵活性",但从类型安全的角度看就是灾难——编译器完全没办法帮你检查"这个值是不是合法的枚举值"。这也是 C++11 引入 enum class 的根本原因。
第三步——用位域按位分配内存
先看位域的基本语法
位域允许你在结构体中以位为单位来分配存储空间。语法是在字段名后面加冒号和位数:
typedef struct {
uint32_t enable : 1; // 1 位
uint32_t mode : 3; // 3 位(可表示 0-7)
uint32_t priority : 4; // 4 位(可表示 0-15)
uint32_t reserved : 24; // 24 位保留
} ControlReg; // 总计 32 位 = 4 字节访问位域成员的方式和普通结构体完全一样:
ControlReg reg = {0};
reg.enable = 1;
reg.mode = 5;
reg.priority = 3;用位域映射硬件寄存器
位域在嵌入式开发中最常见的应用就是映射硬件寄存器:
typedef struct {
volatile uint32_t enable : 1; // bit 0: 使能
volatile uint32_t tickint : 1; // bit 1: 中断使能
volatile uint32_t clksource : 1; // bit 2: 时钟源选择
volatile uint32_t reserved : 13; // bit 15:3 保留
volatile uint32_t countflag : 1; // bit 16: 计数标志
volatile uint32_t reserved2 : 15; // bit 31:17 保留
} SysTickCtrl;
volatile SysTickCtrl* systick_ctrl = (volatile SysTickCtrl*)0xE000E010;
systick_ctrl->enable = 1;
systick_ctrl->tickint = 1;
systick_ctrl->clksource = 1;注意位域的可移植性陷阱
位域用起来很爽,但它有一个你必须正视的代价:可移植性差。C 标准对位域有几个关键细节没有规定——位域的分配顺序(从低位向高位还是反过来)、对齐和填充规则,这些全部交给编译器实现。
位域用来映射硬件寄存器时,一定要用编译器提供的标准头文件(比如 STM32 的 CMSIS 头文件)作为参考。那些头文件里的寄存器结构体是经过厂商验证的,位域的分配方向和平台是一致的。自己手写位域映射硬件寄存器,在不同编译器之间很可能出问题。
位域 vs 手写位运算掩码
正因为位域的可移植性问题,很多嵌入式项目会完全避免使用位域,转而用手写的位运算掩码:
#define CTRL_ENABLE_MASK (1U << 0)
#define CTRL_MODE_MASK (0x7U << 1)
volatile uint32_t* ctrl_reg = (volatile uint32_t*)0xE000E010;
*ctrl_reg |= CTRL_ENABLE_MASK;
*ctrl_reg = (*ctrl_reg & ~CTRL_MODE_MASK) | (5U << 1);位运算掩码的优点是完全可移植、不依赖编译器行为,缺点是代码可读性差。实践中经常两者混用。
第四步——用 typedef 给类型起别名
先看基本用法
typedef 的核心功能很简单——给一个已有的类型创建一个新名字:
typedef uint32_t Timestamp;
typedef struct { float x; float y; } Point2D;
Timestamp now = 1700000000;
Point2D origin = {0.0f, 0.0f};简化函数指针声明
typedef 最实用的场景之一是简化函数指针的声明:
// 不用 typedef:声明一个包含 8 个函数指针的数组
void (*handlers[8])(int);
// 用 typedef:清晰得多
typedef void (*EventHandler)(int);
EventHandler handlers[8];typedef 和 #define 的区别
typedef 创建的是一个真正的类型别名,由编译器处理;而 #define 只是预处理器的文本替换:
typedef char* CharPtr;
#define CHAR_PTR char*
CharPtr a, b; // a 和 b 都是 char*
CHAR_PTR c, d; // 展开后是 char* c, d; — 只有 c 是 char*,d 是 char!typedef 名不能用于前向声明。解决方法是先写 typedef struct TagName TagName; 做前向声明,然后在后面的完整定义中使用 struct TagName { ... };。这种写法在实现链表、树等自引用数据结构时非常常见。另外,不要过度使用 typedef——好的 typedef 应该是增加信息量的(比如 Timestamp 比 uint32_t 更有意义),而不是单纯地隐藏信息。
C++ 衔接
enum class:类型安全的枚举(C++11)
enum class Color { kRed, kGreen, kBlue };
Color c = Color::kRed; // 必须加作用域限定
int x = c; // 编译错误!不能隐式转 int
int y = static_cast<int>(c); // OK,必须显式转换enum class 还可以指定底层类型:
enum class StatusCode : uint8_t { kOk = 0, kError = 1 };
static_assert(sizeof(StatusCode) == 1);std::variant:类型安全的联合体(C++17)
#include <variant>
using Value = std::variant<int, float, const char*>;
Value v1 = 42;
int x = std::get<int>(v1); // OK
// float f = std::get<float>(v1); // 抛出 std::bad_variant_accessC++ 中限制 union 的使用
如果 union 的成员拥有非平凡的构造函数、析构函数或拷贝操作(比如 std::string),你就必须手动管理这些成员的生命周期。所以在 C++ 中,优先用 std::variant。
std::bitset:替代手动位域
#include <bitset>
std::bitset<32> ctrl_reg(0);
ctrl_reg[0] = 1; // enable
bool enabled = ctrl_reg[0];using 替代 typedef(C++11)
using EventHandler = void (*)(int); // 比 typedef 更直观练习
练习 1:IEEE 754 浮点数分解
难度:进阶 · 用联合体拆开浮点的位
用联合体实现一个工具,把一个 float 值分解成 IEEE 754 格式的符号位、指数和尾数,并打印出来。
#include <stdio.h>
#include <stdint.h>
// 练习: 定义一个联合体,包含 float 和 uint32_t
// 练习: 实现分解函数
// void print_float_bits(float f) {
// // 提取符号位(1位)、指数(8位)、尾数(23位)
// // 提示:用位运算 & 和 >>
// }
int main(void) {
// 练习: 测试几个值:0.0f, -3.14f, 1.0f, 42.0f, 0.1f
return 0;
}参考答案
展开代码收起代码共 93 行
#include <stdio.h>
#include <stdint.h>
#include <string.h>
typedef union {
float f;
uint32_t u;
} FloatBits;
// float 的 IEEE 754 浮点数位规则
// 1 位符号 | 8 位指数 | 23 位尾数
// 正还是负 | 决定能表示多大、多小(范围) | 决定有多少位有效数字(精度)
void print_float_bits(float f)
{
FloatBits data;
data.f = f;
uint32_t sign = (data.u >> 31) & 0x1;
uint32_t exponent = (data.u >> 23) & 0xff;
uint32_t fraction = data.u & 0x7fffff;
printf("float = %f\n", f);
printf("bits = ");
for (int i = 31; i >= 0; --i)
{
printf("%u", (unsigned int)((data.u >> i) & 1u));
if (i == 31 || i == 23)
{
printf(" ");
}
}
printf("\n");
printf("1 位符号 = %u\n", (unsigned int)sign);
printf("8 位指数 = ");
for (int i = 7; i >= 0; --i)
{
printf("%u", (unsigned int)((exponent >> i) & 1u));
}
printf("\n");
printf("23 位尾数 = ");
for (int i = 22; i >= 0; --i)
{
printf("%u", (unsigned int)((fraction >> i) & 1u));
}
printf("\n");
}
int main(void)
{
char input[32] = {0};
char trailing_character;
float value = 0.0f;
printf("请输入 float:");
fflush(stdout);
if (fgets(input, sizeof(input), stdin) == NULL)
{
fputs("读取输入失败。\n", stderr);
return 1;
}
/* 缓冲区未读到换行时,确认是否还有未读取的字符,避免静默截断。 */
if (strchr(input, '\n') == NULL)
{
// 读取输入的第 32 个字符
int next_character = getchar();
if (next_character != '\n' && next_character != EOF)
{
while (next_character != '\n' && next_character != EOF)
{
next_character = getchar();
}
fputs("输入过长。\n", stderr);
return 1;
}
}
/* 第二个转换项用于拒绝数字后仍含有非空白字符的输入。 */
if (sscanf(input, "%f %c", &value, &trailing_character) != 1)
{
printf("输入无效!\n");
return 1;
}
print_float_bits(value);
return 0;
}编译运行:
gcc -std=c17 -Wall -Wextra -Wpedantic float_bits.c -o float_bits && ./float_bits在支持 IEEE 754 binary32 的 GCC x86_64 环境中,输入 -3.14 的结果如下:
请输入 float:-3.14
float = -3.140000
bits = 1 10000000 10010001111010111000011
1 位符号 = 1
8 位指数 = 10000000
23 位尾数 = 10010001111010111000011fgets 对 char input[32] 最多读取 31 个字符,并在末尾追加 \0。当输入行超过 31 个字符时,函数仍会返回成功,但换行符和剩余内容还留在输入流中;如果不检查 next_character 是否为 \n 或 EOF,即使输入过长仍会判断为完整读取。
练习 2:32 位硬件控制寄存器
难度:基础 · 位域加 union 映射寄存器
用位域定义一个 32 位硬件控制寄存器结构体,然后编写函数对其进行操作。
展开代码收起代码共 25 行
#include <stdio.h>
#include <stdint.h>
// 练习: 定义 ControlRegister 位域结构体
// 位分配:
// bit 0: enable (1位)
// bit 1: interrupt_enable (1位)
// bit 2: dma_enable (1位)
// bit 5:3 mode (3位)
// bit 9:6 speed (4位)
// bit 31:10 reserved (22位)
typedef union {
// 练习: 位域结构体视图
// 练习: uint32_t 整体视图
} ControlRegister;
// 练习: 实现 void print_register(ControlRegister reg)
// 练习: 实现 void set_mode(ControlRegister* reg, uint32_t mode)
int main(void) {
ControlRegister reg = {0};
// 练习: 测试各个操作
return 0;
}参考答案
展开代码收起代码共 59 行
#include <stdio.h>
#include <stdint.h>
// 位分配:
// bit 0: enable(1 位)
// bit 1: interrupt_enable(1 位)
// bit 2: dma_enable(1 位)
// bit 5:3 mode(3 位)
// bit 9:6 speed(4 位)
// bit 31:10 reserved(22 位)
typedef union {
struct {
uint32_t enable : 1;
uint32_t interrupt_enable : 1;
uint32_t dma_enable : 1;
uint32_t mode : 3;
uint32_t speed : 4;
uint32_t reserved : 22;
} bits;
uint32_t value;
} ControlRegister;
void print_register(ControlRegister reg)
{
printf("Register = 0x%08X\n", (unsigned int)reg.value);
printf(" enable: %u\n", (unsigned int)reg.bits.enable);
printf(" interrupt_enable: %u\n", (unsigned int)reg.bits.interrupt_enable);
printf(" dma_enable: %u\n", (unsigned int)reg.bits.dma_enable);
printf(" mode: %u\n", (unsigned int)reg.bits.mode);
printf(" speed: %u\n", (unsigned int)reg.bits.speed);
}
void set_mode(ControlRegister *reg, uint32_t mode)
{
if (reg == NULL) {
return;
}
// mode 仅占 3 位,因此只保留参数的低 3 位。
reg->bits.mode = mode & 0x7U;
}
int main(void)
{
ControlRegister reg = {0};
reg.bits.enable = 1U;
reg.bits.interrupt_enable = 1U;
reg.bits.dma_enable = 0U;
reg.bits.speed = 10U;
set_mode(®, 5U);
print_register(reg);
// 9:1001(取低 3 位)
set_mode(®, 9U); // 9 被截断为 3 位数值 1。
print_register(reg);
return 0;
}在本例的 GCC x86_64 布局中,声明顺序从低位向高位分配;从 32 位整数的高位到低位看,布局如下:
位编号 31 10 9 6 5 3 2 1 0
+-----------------------+---------+-------+---+---+---+
字段 | reserved (22) | speed 4 | mode 3| D | I | E |
+-----------------------+---------+-------+---+---+---+
位范围 [31:10] [9:6] [5:3] 2 1 0E=enable,占 bit 0I=interrupt_enable,占 bit 1D=dma_enable,占 bit 2
value 与 bits 共用同一段 32 位存储,因此可用 value 观察这些位的整体值。位域的实际分配方向、对齐和填充由实现定义;上图只描述这一示例已实测的 GCC x86_64 布局,不能直接当作跨编译器或跨目标平台的寄存器协议。
编译运行:
gcc -std=c17 -Wall -Wextra -Wpedantic bitfield_register.c -o bitfield_register && ./bitfield_register在 WSL Ubuntu 24.04(GCC 13.3,x86_64)中运行:
Register = 0x000002AB
enable: 1
interrupt_enable: 1
dma_enable: 0
mode: 5
speed: 10
Register = 0x0000028B
enable: 1
interrupt_enable: 1
dma_enable: 0
mode: 1
speed: 10练习 3:简单的 tagged union
难度:基础 · enum 加 union 加 tag 检查
用枚举和联合体实现一个可以存储 int、float 或字符串指针的 tagged union。
#include <stdio.h>
#include <stdint.h>
// 练习: 定义枚举类型标签
// 练习: 定义 tagged union 结构体
// 练习: 实现构造函数 make_int/make_float/make_string
// 练习: 实现 print_tagged_value 函数
// 练习: 实现 get_as_int/get_as_float/get_as_string 安全访问函数
// (检查 tag 是否匹配,不匹配则打印错误信息)
int main(void) {
// 练习: 创建三种类型的值,打印它们
// 练习: 尝试用错误的 tag 访问,验证安全检查
return 0;
}参考答案
展开代码收起代码共 166 行
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
/* 用于标识联合体中当前有效成员的类型标签。 */
typedef enum {
TAG_INT,
TAG_FLOAT,
TAG_STRING
} ValueTag;
/*
* 联合体的所有成员共用同一块存储空间。
* 读取时必须根据 ValueTag 选择与最后一次写入相对应的成员。
*/
typedef union {
int int_value;
float float_value;
const char *string_value;
} ValueData;
/* 将类型标签与实际数据绑定,构成一个可存放多种类型的值。 */
typedef struct {
ValueTag tag;
ValueData data;
} TaggedValue;
/* 构造一个保存 int 的带标签值。 */
TaggedValue make_int(int value)
{
TaggedValue result = { TAG_INT, { .int_value = value } };
return result;
}
/* 构造一个保存 float 的带标签值。 */
TaggedValue make_float(float value)
{
TaggedValue result = { TAG_FLOAT, { .float_value = value } };
return result;
}
/*
* 构造一个保存字符串指针的带标签值。
* 该函数不会复制字符串,调用方必须保证字符串在使用期间仍然有效。
*/
TaggedValue make_string(const char *value)
{
TaggedValue result = { TAG_STRING, { .string_value = value } };
return result;
}
/* 根据类型标签,以对应的格式输出当前有效成员。 */
void print_tagged_value(const TaggedValue *value)
{
/* 防止调用方传入空指针后解引用。 */
if (value == NULL) {
fprintf(stderr, "Error: value is NULL.\n");
return;
}
switch (value->tag) {
case TAG_INT:
printf("int: %d\n", value->data.int_value);
break;
case TAG_FLOAT:
printf("float: %.2f\n", value->data.float_value);
break;
case TAG_STRING:
/* %s 的实参不能是空指针,避免未定义行为。 */
if (value->data.string_value == NULL) {
fprintf(stderr, "Error: string value is NULL.\n");
return;
}
printf("string: %s\n", value->data.string_value);
break;
default:
/* 标签被破坏或未按约定初始化时,禁止读取联合体成员。 */
fprintf(stderr, "Error: unknown value tag.\n");
break;
}
}
/* 仅当值实际保存 int 时写入输出参数并返回 true。 */
bool get_as_int(const TaggedValue *value, int *out)
{
if ((value == NULL) || (out == NULL)) {
fprintf(stderr, "Error: value or output is NULL.\n");
return false;
}
if (value->tag != TAG_INT) {
fprintf(stderr, "Error: value is not an int.\n");
return false;
}
*out = value->data.int_value;
return true;
}
/* 仅当值实际保存 float 时写入输出参数并返回 true。 */
bool get_as_float(const TaggedValue *value, float *out)
{
if ((value == NULL) || (out == NULL)) {
fprintf(stderr, "Error: value or output is NULL.\n");
return false;
}
if (value->tag != TAG_FLOAT) {
fprintf(stderr, "Error: value is not a float.\n");
return false;
}
*out = value->data.float_value;
return true;
}
/* 仅当值实际保存字符串指针时写入输出参数并返回 true。 */
bool get_as_string(const TaggedValue *value, const char **out)
{
if ((value == NULL) || (out == NULL)) {
fprintf(stderr, "Error: value or output is NULL.\n");
return false;
}
if (value->tag != TAG_STRING) {
fprintf(stderr, "Error: value is not a string.\n");
return false;
}
*out = value->data.string_value;
return true;
}
int main(void)
{
/* 分别创建三种不同类型的带标签值。 */
TaggedValue int_value = make_int(42);
TaggedValue float_value = make_float(3.14f);
TaggedValue string_value = make_string("Hello, tagged union!");
int int_result = 0;
float float_result = 0.0f;
const char *text = NULL;
/* 输出时由类型标签决定应读取联合体的哪个成员。 */
print_tagged_value(&int_value);
print_tagged_value(&float_value);
print_tagged_value(&string_value);
/* 通过类型匹配的访问函数读取数据。 */
if (get_as_int(&int_value, &int_result)) {
printf("get_as_int: %d\n", int_result);
}
if (get_as_float(&float_value, &float_result)) {
printf("get_as_float: %.2f\n", float_result);
}
if (get_as_string(&string_value, &text)) {
printf("get_as_string: %s\n", text);
}
/* 以下两次调用用于演示类型不匹配时的错误处理。 */
(void)get_as_float(&int_value, &float_result);
(void)get_as_string(&float_value, &text);
return 0;
}编译运行:
gcc -std=c17 -Wall -Wextra -Wpedantic tagged_union.c -o tagged_union && ./tagged_union运行结果:
int: 42
float: 3.14
string: Hello, tagged union!
get_as_int: 42
get_as_float: 3.14
get_as_string: Hello, tagged union!
Error: value is not a float.
Error: value is not a string.TaggedValue 的 tag 和 data 必须一起维护:写入 data.float_value 后,只有 tag == TAG_FLOAT 时才可以读取它。make_string 只保存指针,不复制字符串,因此调用方必须保证字符串的存储期覆盖整个使用过程;三个 get_as_* 函数用 bool 加输出参数表示访问结果,避免把合法的 0、0.0f 或 NULL 与错误混为一谈。
参考资源
- cppreference:C union、C enumeration、C bit-field:C 语言规则与实现差异速查。
- cppreference:
std::variant(C++17)、std::bitset(C++98)、std::bit_cast(C++20):对应工具。