内存布局与字节序:大端与小端、结构体内存对齐、字节序转换函数

各位同学,咱们今天聊点实在的。序列化说白了,就是把内存里的数据结构,变成一串能存能传的字节流。反序列化就是反过来。这中间有个关键问题:不同机器、不同编译器,对同一块内存的理解可能完全不同。我当年第一次做跨平台通信时,就被这个坑得够呛。今天咱们就把内存布局和字节序这两个核心问题彻底讲透。

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。完美。

注意: 这些函数只处理 16 位和 32 位整数。对于 64 位整数,你需要自己实现转换,或者用 be64toh() / htobe64()(Linux 特有)。另外,浮点数不能直接用这些函数,需要先做联合体或指针转换,但那是另一个话题了。

4. 知识体系总览

下面这张图,是我自己总结的内存布局与字节序的核心逻辑。你看一眼,就能把今天的内容串起来:

内存布局与字节序知识体系 字节序 内存对齐 转换函数 大端序 (Big-Endian) 小端序 (Little-Endian) 网络字节序 = 大端 自然对齐规则 #pragma pack 手动序列化 htons / htonl ntohs / ntohl 64位 / 浮点处理 核心原则:序列化时统一用网络字节序,手动逐字段处理,避免直接 memcpy 结构体 跨平台通信 = 字节序转换 + 对齐控制 + 明确协议

嗯,这张图把今天的内容都串起来了。你写代码时,脑子里要有这张图,就不会犯低级错误。

最后说一句:序列化没有银弹。不同的场景,你得选择不同的策略。但理解内存布局和字节序,是所有方案的基础。今天的内容就到这,下次咱们聊聊更高级的序列化协议。


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