一、内存对齐与序列化:结构体直接读写文件的陷阱

做嵌入式开发的朋友,十有八九都踩过这个坑——直接把结构体往文件里写,或者通过网线发出去。嗯,我当时也不例外。

记得我刚工作那会儿,写了一个简单的配置保存功能。结构体定义得好好的,本地测试也一切正常。结果程序发到客户那边,读出来的配置全是乱码。我排查了一整天,最后发现是内存对齐搞的鬼。

说白了,编译器为了让CPU访问更快,会在结构体成员之间插入一些填充字节。这些填充字节,就是罪魁祸首。

1.1 对齐规则:编译器偷偷塞的“填充物”

先看一个简单的例子:

struct Student {
    char name[10];   // 10字节
    int age;         // 4字节
    float score;     // 4字节
};

你猜这个结构体占多少字节?直觉告诉我,10+4+4=18字节。但实际呢?

printf("sizeof(struct Student) = %zu\n", sizeof(struct Student));
// 输出:sizeof(struct Student) = 20

为什么会多出2字节?因为int类型要求4字节对齐。name数组占10字节后,下一个int的起始地址必须是4的倍数。所以编译器在name后面塞了2个填充字节。

核心规则:每个成员的起始地址,必须能被它自身的大小整除。结构体的总大小,必须是最大成员对齐值的整数倍。

我整理了一张常见类型的对齐要求表:

类型 大小(字节) 对齐要求
char 1 1字节对齐
short 2 2字节对齐
int 4 4字节对齐
float 4 4字节对齐
double 8 8字节对齐
指针 8(64位) 8字节对齐

1.2 直接读写文件的陷阱

很多人图省事,会这样写:

// 写入
struct Student stu = {"张三", 25, 88.5};
fwrite(&stu, sizeof(stu), 1, fp);

// 读取
struct Student stu2;
fread(&stu2, sizeof(stu2), 1, fp);

这种做法在单机、同平台下没问题。但一旦涉及跨平台、跨编译器,就麻烦了。

我曾经接手过一个项目,需要在Windows和Linux之间交换数据。Windows用MSVC,Linux用GCC。两个编译器的对齐策略不一样,同样的结构体,sizeof结果不同。结果就是:Windows写出来的文件,Linux读出来全是错的。

警告:不要直接用fwrite/fread读写结构体!除非你能保证:

  • 读写双方使用相同的编译器
  • 相同的对齐设置(#pragma pack)
  • 相同的字节序(大端/小端)

1.3 解决方案:手动序列化

那正确的做法是什么?我个人习惯是写一个序列化函数,逐个成员处理:

void serialize_student(const struct Student *s, FILE *fp) {
    // 先写字符串长度+内容
    uint8_t len = strlen(s->name);
    fwrite(&len, 1, 1, fp);
    fwrite(s->name, 1, len, fp);
    
    // 写int,注意字节序
    int age = htonl(s->age);
    fwrite(&age, sizeof(age), 1, fp);
    
    // 写float,同样处理
    float score = s->score;
    // 简单起见,这里直接写,实际也要考虑字节序
    fwrite(&score, sizeof(score), 1, fp);
}

虽然代码多了点,但保证了跨平台兼容性。你想想看,这点工作量跟排查线上bug比起来,哪个更划算?

二、使用对齐结构体进行网络传输

网络传输比文件读写更复杂。因为两端可能完全异构——不同的CPU架构、不同的操作系统、不同的编译器。

2.1 网络协议中的对齐问题

我参与过一个物联网项目,设备端用ARM Cortex-M3,服务器端用x86。设备上报的数据包结构体是这样的:

struct __attribute__((packed)) SensorData {
    uint8_t  type;      // 1字节
    uint16_t value;     // 2字节
    uint32_t timestamp; // 4字节
    uint8_t  status;    // 1字节
};

注意这里用了__attribute__((packed)),这是GCC的扩展语法,告诉编译器不要填充,按实际大小来。ARM端用这个没问题,但服务器端如果用MSVC,就得用#pragma pack(1)

小技巧:跨平台网络传输,我建议统一使用#pragma pack(1),并配合固定宽度的整数类型(uint8_t、uint16_t等)。这样能最大程度避免对齐问题。

2.2 字节序:另一个隐藏的坑

对齐问题解决了,字节序又来了。ARM Cortex-M3默认是小端,x86也是小端。但如果设备换成PowerPC(大端),或者某些网络协议规定用大端,那就得做转换。

网络传输的标准做法是:统一使用网络字节序(大端)。

// 发送端
uint32_t timestamp = htonl(sensor->timestamp);
uint16_t value = htons(sensor->value);
send(sock, &timestamp, 4, 0);
send(sock, &value, 2, 0);

// 接收端
uint32_t timestamp = ntohl(recv_timestamp);
uint16_t value = ntohs(recv_value);

说白了,就是发送前转成大端,收到后转回本机字节序。这套流程虽然啰嗦,但稳得很。

2.3 实战:一个完整的网络数据包处理

下面是我在实际项目中用过的模式,分享给你:

// 协议定义(1字节对齐)
#pragma pack(1)
typedef struct {
    uint8_t  version;    // 协议版本
    uint16_t length;     // 数据长度
    uint32_t seq_num;    // 序列号
    uint8_t  payload[0]; // 变长数据
} PacketHeader;
#pragma pack()

// 序列化函数
void pack_header(PacketHeader *hdr, uint8_t *buf) {
    buf[0] = hdr->version;
    *(uint16_t*)(buf+1) = htons(hdr->length);
    *(uint32_t*)(buf+3) = htonl(hdr->seq_num);
}

// 反序列化函数
void unpack_header(const uint8_t *buf, PacketHeader *hdr) {
    hdr->version = buf[0];
    hdr->length = ntohs(*(uint16_t*)(buf+1));
    hdr->seq_num = ntohl(*(uint32_t*)(buf+3));
}

这里有个细节:payload[0]是零长度数组,用于表示变长数据。这是C语言中处理变长协议的标准手法。

2.4 内存对齐的底层原理

你可能想问:为什么编译器非要搞对齐?直接紧凑排列不好吗?

原因在于CPU访问内存的方式。大多数CPU读取int时,要求地址是4的倍数。如果不对齐,CPU需要两次内存访问才能读完一个int,性能会下降很多。

我画了一张图,帮你理解对齐的布局:

内存对齐 vs 紧凑布局对比 对齐布局(默认) char[10] 填充 int float 总大小:10 + 2(填充) + 4 + 4 = 20字节 紧凑布局(packed) char[10] int float 总大小:10 + 4 + 4 = 18字节(无填充) int对齐到4的倍数

从图中可以清楚看到,对齐布局在char[10]后面插入了2字节填充,让int的起始地址变成4的倍数(146)。而紧凑布局则没有填充,int直接跟在char后面。

总结一下:

  • 内存对齐是编译器为了性能做的优化
  • 直接读写结构体存在跨平台风险
  • 网络传输必须考虑对齐和字节序
  • 手动序列化虽然麻烦,但最可靠

嗯,这一章的内容就到这里。记住一句话:不要偷懒,该序列化就序列化。我见过太多因为图省事而踩坑的案例了。

避坑指南:我曾经在一个项目中,因为没处理对齐问题,导致设备升级后所有配置数据丢失。从那以后,我所有涉及持久化或网络传输的结构体,都强制使用packed属性,并手写序列化函数。虽然代码量多了20%,但再也没出过类似问题。


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