前置知识: C

布局深水区:成员排序、跨平台与序列化陷阱

9 min高级

把 220 的对齐规则用于工程:成员排序前后 sizeof 对照实验、64 位指针与 32 位平台的布局差异、位域为什么不可移植、存档与网络传输为什么必须显式编码而不是 memcpy 结构体。

前置知识

分工说明:220 与 230 合讲结构体的内存布局。220 是主教学,解决「为什么有对齐、规则是什么」;本篇是深水区,解决「规则怎么变成工程决策」——排序省多少内存、换平台布局怎么变、位域为什么别碰、序列化为什么禁用 memcpy。位域的语法归 位域,本篇只谈布局风险。

学习目标

读完本文你将能够:

  1. 用三条对齐规则设计成员顺序,并用前后 sizeof 对照验证收益;
  2. 说出为什么「64 位指针 8 字节」让含指针的结构体布局跨平台不稳定;
  3. 解释位域的布局为什么标准不保证,判断它能不能进文件格式与协议;
  4. 论证序列化必须逐字段显式编码,并写出小端序的按字节编码函数。

预计 45 到 60 分钟,含 2 组实验、1 道预测题与 1 道挑战题。

1. 问题引入:同一份存档,换台机器全是乱码

排行榜的每条记录你决定直接存进文件——整个结构体一把写进去,多省事:

c
struct Record {
    char   name[8];
    char   level;
    double score;
    int    combo;
};

在你的开发机上一切正常。换一台同事的 32 位 ARM 板子读这份存档,score 是天文数字。没有崩溃、没有报错,只是数据全错——比崩溃难查十倍。

这一篇解决三类「布局不稳」:成员排布的浪费与优化(第 2 节)、平台差异(第 3 节)、以及绕开一切布局问题的正解——显式编码(第 5 节)。

2. 成员排序优化:白捡的内存省多少

按 220 的三条规则手工推算 Record:name 占 0 到 7,level 占 8,score 要求 8 的倍数、前面补 7、占 16 到 23,combo 占 24 到 27,总大小补到 8 的倍数,即 32。把成员按对齐值从大到小重排,先自己推算再运行验证:

c
/* reorder.c:同一批成员,两种排法 */
#include <stdio.h>

struct RecordBad {
    char   name[8];
    char   level;
    double score;
    int    combo;
};

struct RecordGood {
    double score;     /* 8 */
    int    combo;     /* 4 */
    char   name[8];   /* 8 个 char,对齐值 1 */
    char   level;     /* 1 */
};

int main(void) {
    printf("未排序 %zu 字节\n", sizeof(struct RecordBad));
    printf("排序后 %zu 字节\n", sizeof(struct RecordGood));
    return 0;
}

预期输出:

text
未排序 32 字节
排序后 24 字节

24 字节:score 占 0 到 7,combo 占 8 到 11,name 占 12 到 19,level 占 20,尾部补 3 到 24。单条省 25%。乘上规模才知道利害:百万玩家的榜单,32 字节方案 32 MB,24 字节方案 24 MB——游戏实体、粒子池这类结构体,排序是零成本优化。逻辑相关成员要不要挨着,是团队约定,见 结构体与联合体。

3. 跨平台差异:64 位指针 8 字节只是起点

「对齐值由类型决定」有个前提——类型的大小得先定下来,而大小是平台说了算:

类型64 位 Linux/macOS64 位 Windows32 位平台
指针、size_t8 字节8 字节4 字节
long8 字节4 字节4 字节

两个结论:

  1. 任何含指针的结构体,布局都可能变。struct Node { int value; struct Node *next; } 在 32 位平台是 8 字节,64 位是 16 字节——不仅指针变大,后面成员的偏移全部跟着挪。带指针的结构体永远不该直接进文件;
  2. long 在两种 64 位平台就不一样(LP64 与 LLP64 的差异),一个 long 成员就能让同一份代码在 Windows 和 Linux 排出不同布局。

亲手验证:

c
/* ptrsize.c */
#include <stdio.h>

struct Node { int value; struct Node *next; };

int main(void) {
    printf("指针 %zu 字节,Node %zu 字节\n", sizeof(void *), sizeof(struct Node));
    return 0;
}

本机(64 位)预期输出:

text
指针 8 字节,Node 16 字节

同一份代码在 32 位平台运行,两行变成 4 和 8。要写「哪都能编译」的代码,答案不是背平台表,而是用 <stdint.h> 的定宽类型(uint32_t 永远 4 字节)。整型家族的系统讲解在 数据类型详解。

4. 位域:布局交给了实现

位域把「这个成员只占 3 个位」写进声明,看似紧凑布局的完美工具:

c
struct Flags {
    unsigned visible : 1;
    unsigned damaged : 3;
    unsigned moved   : 4;
};

坑在布局:标准只保证「位够用」,不规定各位怎么排——从高位还是低位开始填、能不能跨存储单元边界、填充位放哪,全部由实现自便,gcc 和 MSVC 排出的字节可能不同。

结论是一条纪律:位域只用于本机内存内的标志集合;一旦进文件格式或网络协议,立刻换成移位与掩码手写的位运算。位域的完整语法与掩码写法见 位域。

5. 序列化:为什么禁止 memcpy 结构体

回到第 1 节的存档事故。把结构体的内存映像原样写盘,等于同时托付了四样不该托付的东西:

  1. padding:填充字节跟着进文件,是浪费,内容更不可复现;
  2. 成员偏移:任何平台差异、成员顺序调整,都会让读方与写方错位;
  3. 字节序:多字节整数的字节排列依平台而定,x86 是小端(低位字节在前),许多网络设备是大端。协议约定见 网络基础与协议,Socket 转换函数见 Socket 网络编程;
  4. 指针:写进文件的是进程内地址,对任何其他进程都是垃圾。

正解是显式编码:逐字段、按定宽类型、按约定字节序写。最小的完整示例——32 位分数按小端拆成 4 个字节:

c
/* encode.c:把 32 位分数显式编码进字节缓冲区 */
#include <stdio.h>
#include <stdint.h>

void put_u32le(uint8_t *out, uint32_t v) {
    out[0] = (uint8_t)(v);
    out[1] = (uint8_t)(v >> 8);
    out[2] = (uint8_t)(v >> 16);
    out[3] = (uint8_t)(v >> 24);
}

int main(void) {
    uint32_t score = 0x12345678;
    uint8_t buf[4];
    put_u32le(buf, score);
    for (int i = 0; i < 4; i++) {
        printf("%02x ", buf[i]);
    }
    printf("\n");
    return 0;
}

预期输出:

text
78 56 34 12

逐字节看清了「小端」:最低位字节 0x78 排在最前。这段代码在任何平台、任何编译器上产生同一份字节——这就是「可复现」的全部含义。

修改实验一:把四次移位改成 v >> 24 在前、v 在后(大端编码),先预测字节序列再运行,预期输出 12 34 56 78。解码就是反着拼:v = (uint32_t)buf[0] | (uint32_t)buf[1] << 8 | (uint32_t)buf[2] << 16 | (uint32_t)buf[3] << 24;。存档与协议从此和结构体布局解耦,读方哪怕是另一种语言,也只需要这份字节约定。

6. 常见错误与调试实录

一个把第 2、3、5 节一次踩全的真实案例:跨平台排行榜直接 memcpy 存档,结构体里还混了 long。

c
/* load_bug.c:memcpy 存档 + long 成员,换平台就读崩数据 */
struct EntryBad {
    char name[8];
    long score;       /* 64 位 Linux 是 8 字节,64 位 Windows 是 4 字节 */
    uint8_t level;
};

void load_entry(FILE *f, struct EntryBad *e) {
    fread(e, sizeof *e, 1, f);    /* 按本机布局硬套文件里的字节 */
}

症状:写档机(64 位 Linux,score 占 8 字节)生成的文件,拿到 64 位 Windows 读,score 只读到 4 字节、level 整体错位;Windows 写、Linux 读则反向爆炸。没有任何报错——因为文件里根本没有布局约定。修复就是第 5 节:定宽类型 + 显式编码,逐字段读回组装,例如 8 字节分数的解码:

c
/* load_fix.c:文件格式里只有字节,没有结构体 */
#include <stdio.h>
#include <stdint.h>

static int get_s64le(FILE *f, int64_t *out) {
    uint8_t b[8];
    if (fread(b, 1, 8, f) != 8) return 0;
    uint64_t v = 0;
    for (int i = 0; i < 8; i++) {
        v |= (uint64_t)b[i] << (8 * i);   /* 小端:低位字节在前 */
    }
    *out = (int64_t)v;
    return 1;
}

get_s64le 是 put_u32le 的放大逆向版;name 原样读 8 字节、level 读 1 字节,逐字段组装回本机结构体。这份文件在 32 位 ARM、Windows、Linux 上读出的每个字节都一致。

7. 实际项目中的使用场景

  • 游戏存档、地图与资源文件:显式编码 + 文件头写版本号,老存档靠版本字段走不同解码路径;
  • 网络协议(游戏同步包、物联网上报):逐字段编码是唯一的路,统一字节序的约定见 Socket 网络编程;
  • 嵌入式与驱动的寄存器映射反而常用 #pragma pack 与位域贴硬件手册——读的是本机寄存器,不跨机器。跨机器与贴硬件,是布局决策的分水岭。

8. 小练习

预测题(5 分钟):按三条对齐规则先写答案,再运行验证:

c
struct Log {
    char   kind;      /* 1 字节 */
    double stamp;     /* 8 字节 */
    char   tag[3];    /* 3 字节 */
};
struct LogGood {
    double stamp;
    char   tag[3];
    char   kind;
};
printf("%zu %zu\n", sizeof(struct Log), sizeof(struct LogGood));

参考答案(先写再看):24 16。前者 kind 占 0,stamp 补 7 占 8 到 15,tag 占 16 到 18,补到 24;后者 stamp 占 0 到 7,tag 占 8 到 10,kind 占 11,补到 16。

挑战题(半小时,不看答案先动手):给 encode.c 配一个反向的 get_u32le,并写往返自检:对 0、1、0xFF、0x12345678、0xFFFFFFFF 五个值,编码后立即解码并断言还原。提示两级如下。

提示(思路方向):解码是把 4 个字节按权值重新或(OR)回来;断言用 <assert.h> 的 assert,遍历五个值。

展开(关键 API):uint32_t v = (uint32_t)in[0] | (uint32_t)in[1] << 8 | (uint32_t)in[2] << 16 | (uint32_t)in[3] << 24;,移位前把每个字节转成 uint32_t,防止在整型提升中出问题。

验收清单:五个断言全部通过;printf("%02x") 打印 0x12345678 的编码结果为 78 56 34 12;把 put 与 get 放到两台不同架构的机器上运行,字节序列一致。

9. 与之前和之后的知识的关系

  • 往前:内存对齐 的三条规则是本篇所有 sizeof 推算的工具;内存深水区 的所有权问题在序列化里变成「编码与解码各管一段字节」;
  • 往后:字节序的传输侧约定与 htonl 一族转换函数在 Socket 网络编程 展开;位域与位运算的完整工具箱见 位域。

10. 官方文档

11. 自我检查

  • 能对 Log 这类结构体手工推算两个排序方向的 sizeof,并说出尾部填充的去向;
  • 能向同事解释为什么含指针或 long 的结构体不能直接写进存档;
  • 能不看资料写出 put_u32le,并说出每个字节转 uint32_t 的原因;
  • 面对「结构体直接 fwrite 行不行」,能给出四个理由(padding、偏移、字节序、指针)。

本章总结

布局工程三件事:成员按对齐值降序排,是零成本的内存与缓存优化;类型大小因平台而异,含指针与 long 的结构体布局天然不可靠,跨场景一律用定宽类型;文件与网络只认字节——逐字段显式编码、约定字节序,绕开 padding、偏移与平台的全部变数。位域与 memcpy 结构体都只在本机内存内安全,出界即换位运算与编码函数。口诀:内存里信编译器,文件里只信字节。

下一步

进入 位域:本篇把位域列为布局疑犯,下一篇拆开它——语法、掩码手写方案与真实寄存器场景。