第1章:结构体与联合体——那些让你半夜惊醒的未定义行为

大家好,我是老李。做嵌入式开发快二十年了,踩过的坑比吃过的盐还多。今天咱们聊聊结构体和联合体——这两个看似人畜无害,实则暗藏杀机的C语言特性。

说实话,我见过太多工程师在这上面翻车了。有人因为结构体对齐问题,在通信协议解析时数据全乱套;有人用memcmp比较结构体,结果死活对不上;还有人滥用联合体类型双关,编译器一优化程序直接跑飞。嗯,这些坑我都踩过,今天一次性讲清楚。

核心观点:结构体和联合体的未定义行为,90%源于你对内存布局的假设与编译器实际行为不一致。别信直觉,信标准。

1.1 结构体填充和对齐——编译器在背后搞的小动作

先看个例子。你写了个结构体:

struct Packet {
    char type;      // 1字节
    int length;     // 4字节
    short crc;      // 2字节
};

你觉得它占多少字节?7个?错。在我的ARM编译器上,它占12字节。为什么?因为对齐。

说白了,CPU读取内存不是按字节来的,而是按字(4字节或8字节)读取。如果int放在奇数地址上,CPU得读两次再拼起来,性能直接腰斩。所以编译器会在成员之间插入填充字节,让每个成员都对齐到它的自然边界。

我个人习惯用offsetof宏来检查偏移量:

#include <stddef.h>
printf("type offset: %zu\n", offsetof(struct Packet, type));
printf("length offset: %zu\n", offsetof(struct Packet, length));
printf("crc offset: %zu\n", offsetof(struct Packet, crc));
printf("total size: %zu\n", sizeof(struct Packet));

输出结果会让你大吃一惊。type在0偏移,length在4偏移(中间填了3个字节),crc在8偏移,最后还要填充到12字节(因为最大成员是4字节,结构体总大小必须是4的倍数)。

警告:不同编译器、不同架构的对齐规则不一样。你在x86上调试好的代码,放到ARM上可能就崩了。我在项目中遇到过,一个通信协议结构体在PC上跑得好好的,移植到STM32上数据全错——就是因为对齐方式变了。

怎么解决?两个办法:

  • 按成员大小降序排列:把大的成员放前面,小的放后面。这样填充最少。
  • 使用#pragma pack:强制取消对齐。但要注意,这可能导致性能下降,甚至在某些架构上引发硬件异常(比如ARM上访问未对齐的地址会触发fault)。
#pragma pack(1)  // 按1字节对齐
struct PacketPacked {
    char type;
    int length;
    short crc;
};
#pragma pack()   // 恢复默认对齐
// 现在sizeof是7字节,但访问length可能很慢

1.2 结构体比较的陷阱——memcmp不是万能的

很多人比较两个结构体是否相等,直接上memcmp。我曾经也这么干过,直到被坑了一次。

为什么不行?因为填充字节。结构体里的填充字节是未初始化的。你给成员赋了相同的值,但填充字节可能是随机值。memcmp比较的是整个内存区域,包括这些垃圾数据,结果当然不相等。

struct Point {
    int x;
    int y;
    char label;
};

struct Point a = {1, 2, 'A'};
struct Point b = {1, 2, 'A'};

// 你以为相等?不一定!
if (memcmp(&a, &b, sizeof(struct Point)) == 0) {
    printf("相等\n");  // 可能永远不会执行
} else {
    printf("不相等\n"); // 大概率走这里
}

正确的做法是逐成员比较:

if (a.x == b.x && a.y == b.y && a.label == b.label) {
    printf("相等\n");
}

或者,如果你一定要用memcmp,先memset整个结构体为零再赋值:

struct Point a, b;
memset(&a, 0, sizeof(a));
memset(&b, 0, sizeof(b));
a.x = 1; a.y = 2; a.label = 'A';
b.x = 1; b.y = 2; b.label = 'A';
// 现在memcmp可能工作了,但依然不推荐

避坑指南:我曾经在写一个网络协议栈时,用memcmp比较两个TCP选项结构体,结果总是失败。查了两天才发现是填充字节在作怪。从那以后,我写结构体比较函数一律逐成员比较,绝不偷懒。

1.3 联合体的未定义行为——类型双关的雷区

联合体(union)允许多个成员共享同一块内存。很多人用它来做类型转换,比如:

union FloatBytes {
    float f;
    uint32_t bytes;
};

union FloatBytes u;
u.f = 3.14f;
printf("0x%08X\n", u.bytes); // 读取float的字节表示

这在C语言里是未定义行为。标准说,你只能读取最近写入的那个成员。读取其他成员,结果由实现定义——说白了,编译器可以给你任何结果,甚至让程序崩溃。

为什么?因为有些架构上,float和int的表示方式不同,或者存在陷阱表示(trap representation)。你读出来的值可能是个无效的整数,触发硬件异常。

那怎么办?用memcpy:

float f = 3.14f;
uint32_t bytes;
memcpy(&bytes, &f, sizeof(bytes));
printf("0x%08X\n", bytes); // 安全,且行为由标准定义

现代编译器(GCC、Clang)对union类型双关的支持比较好,但你不能依赖这个。我建议你把它当成未定义行为来对待——安全第一。

我的经验:在写一个浮点运算库时,我用union来提取float的指数和尾数。GCC下跑得好好的,换到IAR编译器上,优化一开结果全乱。后来全部改成memcpy + 位运算,问题解决。记住:可移植性比炫技重要

1.4 位域的跨平台问题——比特级别的噩梦

位域(bit-field)用来精确控制比特位,在嵌入式里很常见。但它的跨平台问题多到你怀疑人生。

struct Flags {
    unsigned int a : 1;
    unsigned int b : 2;
    unsigned int c : 3;
};

问题来了:

  • 位域的顺序:是从低位到高位,还是从高位到低位?标准没说,由实现定义。大端和小端架构上可能完全相反。
  • 位域的存储单元:是int还是unsigned int?标准只要求是整数类型,但具体用多大由编译器决定。
  • 位域能否跨存储单元边界:有些编译器允许,有些不允许,会导致填充或截断。

举个例子,你在小端x86上定义了一个位域结构体,解析一个网络协议(大端)的比特字段,结果全反了。

警告:位域是C语言里最不跨平台的特性之一。如果你要写可移植代码,别用位域。用宏和位运算代替。

// 用位运算代替位域,保证跨平台一致性
#define FLAG_A_MASK  0x01
#define FLAG_B_MASK  0x06
#define FLAG_C_MASK  0x38

#define GET_FLAG_A(x) (((x) & FLAG_A_MASK) >> 0)
#define GET_FLAG_B(x) (((x) & FLAG_B_MASK) >> 1)
#define GET_FLAG_C(x) (((x) & FLAG_C_MASK) >> 3)

这样写,不管是大端还是小端,不管编译器怎么对齐,结果都是一致的。代价是多写几行宏,但换来的是安心。

知识体系总览

下面这张图总结了本章的核心知识点和它们之间的关系:

结构体与联合体未定义行为知识体系 结构体与联合体 结构体填充与对齐 结构体比较陷阱 联合体未定义行为 位域跨平台问题 编译器填充规则 #pragma pack offsetof宏 memcmp陷阱 逐成员比较 类型双关 memcpy替代 位运算替代 端序问题 核心原则:不依赖未定义行为,追求可移植性

这张图把四个核心问题串起来了。你看,结构体填充、比较陷阱、联合体类型双关、位域跨平台——它们都有一个共同点:编译器行为与你的直觉不一致。解决思路也很统一:要么用标准提供的方法(如memcpy),要么用宏和位运算自己控制。

好了,这一章的内容就到这里。记住我今天说的:结构体和联合体不是洪水猛兽,但你要尊重它们背后的规则。别偷懒,别想当然,该逐成员比较就逐成员比较,该用位运算就用位运算。你的代码会感谢你的。


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