一、内存对齐与序列化:结构体直接读写文件的陷阱
做嵌入式开发的朋友,十有八九都踩过这个坑——直接把结构体往文件里写,或者通过网线发出去。嗯,我当时也不例外。
记得我刚工作那会儿,写了一个简单的配置保存功能。结构体定义得好好的,本地测试也一切正常。结果程序发到客户那边,读出来的配置全是乱码。我排查了一整天,最后发现是内存对齐搞的鬼。
说白了,编译器为了让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, ×tamp, 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,性能会下降很多。
我画了一张图,帮你理解对齐的布局:
从图中可以清楚看到,对齐布局在char[10]后面插入了2字节填充,让int的起始地址变成4的倍数(146)。而紧凑布局则没有填充,int直接跟在char后面。
总结一下:
- 内存对齐是编译器为了性能做的优化
- 直接读写结构体存在跨平台风险
- 网络传输必须考虑对齐和字节序
- 手动序列化虽然麻烦,但最可靠
嗯,这一章的内容就到这里。记住一句话:不要偷懒,该序列化就序列化。我见过太多因为图省事而踩坑的案例了。
避坑指南:我曾经在一个项目中,因为没处理对齐问题,导致设备升级后所有配置数据丢失。从那以后,我所有涉及持久化或网络传输的结构体,都强制使用packed属性,并手写序列化函数。虽然代码量多了20%,但再也没出过类似问题。
公众号:蓝海资料掘金营,微信deep3321