内存布局与字节序:大端与小端、结构体内存对齐、字节序转换函数
各位同学,咱们今天聊点实在的。序列化说白了,就是把内存里的数据结构,变成一串能存能传的字节流。反序列化就是反过来。这中间有个关键问题:不同机器、不同编译器,对同一块内存的理解可能完全不同。我当年第一次做跨平台通信时,就被这个坑得够呛。今天咱们就把内存布局和字节序这两个核心问题彻底讲透。
1. 字节序:大端与小端
先问个问题:一个 32 位的整数 0x12345678,在内存里是怎么放的?
你可能会说,这还不简单,从低地址到高地址依次是 12 34 56 78 呗。嗯,这是大端序(Big-Endian)的存法。但很多处理器,比如 x86、ARM(默认),用的是小端序(Little-Endian):低地址存低位字节。所以实际内存里看到的是 78 56 34 12。
我刚开始做嵌入式时,用 STM32 采集传感器数据,通过串口发给 PC 上位机。上位机收到的数据全是乱的。查了半天,才发现是字节序没统一。从那以后,我写通信协议都会在文档里明确标注:网络字节序 = 大端序。
- 大端序:高字节存低地址(符合人类阅读习惯)
- 小端序:低字节存低地址(符合 CPU 处理习惯)
- 网络传输标准:大端序(Big-Endian)
来看个代码示例,直观感受一下:
#include <stdio.h>
#include <stdint.h>
void check_endian() {
uint32_t test = 0x12345678;
uint8_t *p = (uint8_t *)&test;
if (p[0] == 0x78) {
printf("小端序\n");
} else if (p[0] == 0x12) {
printf("大端序\n");
} else {
printf("未知字节序\n");
}
}
int main() {
check_endian();
return 0;
}
这段代码我用了很多年,简单粗暴。你可以在自己的开发板上跑一下,看看结果。
2. 结构体内存对齐
字节序搞定了,你以为就万事大吉了?不,还有个大坑等着你——结构体内存对齐。
先看个例子:
#include <stdio.h>
#include <stddef.h>
struct Test {
char a; // 1 字节
int b; // 4 字节
short c; // 2 字节
};
int main() {
printf("sizeof(struct Test) = %zu\n", sizeof(struct Test));
printf("offset of a: %zu\n", offsetof(struct Test, a));
printf("offset of b: %zu\n", offsetof(struct Test, b));
printf("offset of c: %zu\n", offsetof(struct Test, c));
return 0;
}
你猜猜结果是多少?很多人以为 1 + 4 + 2 = 7 字节。但实际在大多数 32 位编译器上,输出是 12 字节。为什么?因为编译器在 a 后面填充了 3 个字节,让 b 对齐到 4 字节边界;c 后面又填充了 2 个字节,让整个结构体大小是 4 的倍数。
我个人习惯在定义结构体时,按成员大小从大到小排列,这样能减少填充,节省内存。比如上面的结构体,改成这样:
struct TestOptimized {
int b; // 4 字节
short c; // 2 字节
char a; // 1 字节
// 后面填充 1 字节,总大小 8 字节
};
你看,从 12 字节降到 8 字节,省了 33%。在嵌入式设备上,这点内存可能很宝贵。
memcpy 结构体到发送缓冲区。结果因为对齐填充,接收方解析出来的数据全是错的。后来我改用 #pragma pack(1) 强制 1 字节对齐,或者手动逐字段序列化。记住:不要直接 memcpy 结构体用于跨平台通信。
3. 字节序转换函数
好,现在我们知道字节序和对齐的问题了。那怎么解决?C 标准库提供了一组函数,专门做字节序转换:
| 函数 | 作用 | 平台 |
|---|---|---|
htons() |
主机字节序 → 网络字节序(16位) | POSIX / Windows |
htonl() |
主机字节序 → 网络字节序(32位) | POSIX / Windows |
ntohs() |
网络字节序 → 主机字节序(16位) | POSIX / Windows |
ntohl() |
网络字节序 → 主机字节序(32位) | POSIX / Windows |
这些函数在 <arpa/inet.h>(Linux)或 <winsock2.h>(Windows)里。它们会自动判断当前主机是大端还是小端,然后做相应转换。如果主机本身就是大端,那这些函数就是空操作,什么也不做。
来看一个实际用法:
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h> // Linux
void serialize_uint32(uint32_t value, uint8_t *buf) {
uint32_t net_val = htonl(value);
memcpy(buf, &net_val, sizeof(net_val));
}
uint32_t deserialize_uint32(const uint8_t *buf) {
uint32_t net_val;
memcpy(&net_val, buf, sizeof(net_val));
return ntohl(net_val);
}
int main() {
uint32_t original = 0x12345678;
uint8_t buffer[4];
serialize_uint32(original, buffer);
printf("序列化后: %02x %02x %02x %02x\n",
buffer[0], buffer[1], buffer[2], buffer[3]);
uint32_t restored = deserialize_uint32(buffer);
printf("反序列化后: 0x%08x\n", restored);
return 0;
}
这段代码在小端机上输出:12 34 56 78(网络字节序),然后恢复成 0x12345678。完美。
be64toh() / htobe64()(Linux 特有)。另外,浮点数不能直接用这些函数,需要先做联合体或指针转换,但那是另一个话题了。
4. 知识体系总览
下面这张图,是我自己总结的内存布局与字节序的核心逻辑。你看一眼,就能把今天的内容串起来:
嗯,这张图把今天的内容都串起来了。你写代码时,脑子里要有这张图,就不会犯低级错误。
最后说一句:序列化没有银弹。不同的场景,你得选择不同的策略。但理解内存布局和字节序,是所有方案的基础。今天的内容就到这,下次咱们聊聊更高级的序列化协议。