4、数据类型与字节对齐:不同平台下基本数据类型的大小(sizeof)、字节序(大端/小端)问题及处理

跨平台开发,说白了就是让你的代码能在不同机器上跑得一样好。但现实很骨感——同样的 int,在 A 平台占 4 字节,到 B 平台可能就变成 2 字节了。我早年做嵌入式开发时,就吃过这个亏。一个结构体在 x86 上调试得好好的,移植到 ARM 上直接崩了。查了一整天,最后发现是字节对齐搞的鬼。

这一章,咱们就把数据类型的大小、字节序、对齐这三个坑,一个一个填平。

4.1 基本数据类型的大小——别信直觉,信 sizeof

C 标准只规定了各类型的最小范围,没规定具体字节数。比如 int 至少 16 位,但实际可能是 16、32 甚至 64 位。你想想看,这要是直接写死 4,换个平台不就炸了?

类型 典型 32 位平台 典型 64 位平台 常见嵌入式平台
char 1 字节 1 字节 1 字节
short 2 字节 2 字节 2 字节
int 4 字节 4 字节 2 或 4 字节
long 4 字节 8 字节 4 字节
long long 8 字节 8 字节 8 字节
float 4 字节 4 字节 4 字节
double 8 字节 8 字节 8 字节
指针 4 字节 8 字节 2 或 4 字节

核心原则:永远不要假设基本类型的大小。用 sizeof 获取,用 <stdint.h> 中的定长类型(如 int32_tuint64_t)来保证跨平台一致性。

// 错误做法:假设 int 是 4 字节
int buffer[1024];
memset(buffer, 0, 4096);  // 换个平台就错了

// 正确做法:用 sizeof
int buffer[1024];
memset(buffer, 0, sizeof(buffer));

// 更安全的做法:用定长类型
#include <stdint.h>
int32_t buffer[1024];
memset(buffer, 0, sizeof(buffer));

我的习惯:只要涉及跨平台,我优先用 int32_tuint16_t 这类定长类型。结构体里的字段更是如此——你永远不知道下一个平台会是什么。

4.2 字节序——大端与小端的爱恨情仇

字节序,说白了就是多字节数据在内存里怎么排。大端(Big-Endian)把高位字节放低地址,小端(Little-Endian)把低位字节放低地址。x86 是小端,网络协议大多是大端。

为什么会这样?历史原因。不同厂商各玩各的,最后就分裂了。我当年做网络协议栈时,就遇到过从网络收到一个 uint32_t,直接当本地整数用,结果数值完全不对——忘了做字节序转换。

// 检测当前平台的字节序
#include <stdio.h>
#include <stdint.h>

int is_little_endian() {
    uint16_t test = 0x0102;
    uint8_t *p = (uint8_t *)&test;
    return p[0] == 0x02;  // 低地址存低位,就是小端
}

int main() {
    if (is_little_endian()) {
        printf("小端平台\n");
    } else {
        printf("大端平台\n");
    }
    return 0;
}

我曾经踩过的坑:在 x86 上写了一个二进制文件解析器,一切正常。把文件拿到 PowerPC 机器上读,全乱码了。原因就是文件里存的整数是小端序,而 PowerPC 是大端。从那以后,我所有跨平台二进制格式都统一用网络字节序(大端)。

处理字节序的标准做法:

  • 网络传输:htonl()htons()ntohl()ntohs() 系列函数
  • 文件存储:统一用大端序,或者文件头里标记字节序
  • 内存共享:用联合体(union)做字节序无关的访问
// 字节序无关的 uint32_t 读写
#include <stdint.h>

uint32_t read_be32(const uint8_t *buf) {
    return ((uint32_t)buf[0] << 24) |
           ((uint32_t)buf[1] << 16) |
           ((uint32_t)buf[2] << 8)  |
           ((uint32_t)buf[3]);
}

void write_be32(uint8_t *buf, uint32_t val) {
    buf[0] = (val >> 24) & 0xFF;
    buf[1] = (val >> 16) & 0xFF;
    buf[2] = (val >> 8)  & 0xFF;
    buf[3] = val & 0xFF;
}

4.3 字节对齐——结构体的隐藏开销

字节对齐,说白了就是编译器为了让 CPU 访问更快,会在结构体成员之间插入填充字节。你写个结构体,实际占用的内存可能比你想象的大得多。

// 看看这个结构体实际占多大
struct misaligned {
    char   a;  // 1 字节
    int    b;  // 4 字节
    short  c;  // 2 字节
};

// 你以为 sizeof = 1 + 4 + 2 = 7?
// 实际上在 32 位平台上,sizeof = 12!

为什么会这样?因为 int 需要 4 字节对齐,所以 a 后面会填充 3 个字节。然后 short 后面再填充 2 个字节,让整个结构体大小是 4 的倍数。

成员 偏移 大小 填充
char a 0 1 3 字节(对齐 int)
int b 4 4 0
short c 8 2 2 字节(对齐结构体)
总计 12

优化技巧:把大的成员往前放,小的往后放,可以减少填充。我习惯在定义结构体时,按成员大小降序排列——这样通常能得到最小的内存占用。

// 优化后的结构体
struct aligned {
    int    b;  // 4 字节,放前面
    short  c;  // 2 字节
    char   a;  // 1 字节
    // 只需要填充 1 字节,总共 8 字节
};

// sizeof = 8,比之前省了 4 个字节

4.4 跨平台数据结构的实战策略

好了,理论说完了,咱们看看实际怎么干。我个人总结了一套「三板斧」:

  1. 用定长类型:所有跨平台结构体,字段类型用 int32_tuint16_t 等,别用 intlong
  2. 手动控制对齐:#pragma pack__attribute__((packed)) 取消自动填充
  3. 序列化/反序列化:写专门的函数,把结构体转成字节流,统一用大端序
// 跨平台安全的网络包结构体
#include <stdint.h>

#pragma pack(push, 1)  // 取消自动对齐
typedef struct {
    uint32_t magic;     // 魔数,大端序
    uint16_t length;    // 数据长度,大端序
    uint8_t  type;      // 包类型
    uint8_t  reserved;  // 保留字节
    uint8_t  data[0];   // 柔性数组,实际数据
} PacketHeader;
#pragma pack(pop)

// 序列化:主机字节序转网络字节序
void packet_serialize(PacketHeader *hdr, uint8_t *buf) {
    uint32_t magic = htonl(hdr->magic);
    uint16_t len   = htons(hdr->length);
    memcpy(buf, &magic, 4);
    memcpy(buf + 4, &len, 2);
    buf[6] = hdr->type;
    buf[7] = hdr->reserved;
}

注意:#pragma pack 虽然好用,但会降低内存访问效率。如果性能敏感,建议在结构体里手动填充对齐字节,而不是依赖编译器。

4.5 知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个检查清单——写跨平台代码时,对着过一遍,基本不会漏。

跨平台数据类型核心问题 数据类型大小 sizeof 获取实际大小 使用 <stdint.h> 定长类型 避免假设 int/long 大小 字节序(Endianness) 大端 vs 小端检测 htonl/ntohl 转换 统一网络字节序(大端) 字节对齐 结构体成员对齐规则 #pragma pack 控制 按大小降序排列优化 最终方案:序列化 + 定长类型 + 统一字节序 跨平台数据结构 = 手动控制 + 显式转换

嗯,到这里,数据类型和字节对齐的核心内容就讲完了。记住一句话:跨平台不是靠运气,是靠规范。每次写结构体之前,先想想它会在什么平台上跑,再动手。

本章核心要点:

  • sizeof<stdint.h> 消除类型大小差异
  • 网络传输和文件存储统一用大端序
  • 手动控制结构体对齐,避免隐式填充
  • 写序列化/反序列化函数,彻底隔离平台差异

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