6. 结构体与内存对齐:结构体定义、初始化、嵌套,内存对齐规则与#pragma pack的使用
结构体这东西,说白了就是C语言里用来“打包数据”的工具。你想想看,一个学生有学号、姓名、成绩,如果每个都单独定义变量,那代码得多乱?结构体就是把这些零散的东西装进一个盒子里。
但很多人用结构体时只关注怎么定义、怎么赋值,却忽略了一个关键问题——内存对齐。我见过不少项目,就因为没注意对齐,导致结构体大小跟预期差了一大截,甚至引发通信协议解析错误。嗯,今天咱们就把这块彻底讲透。
结构体的定义与初始化
先看最基本的定义方式:
// 方式一:直接定义结构体类型
struct Student {
char name[32];
int id;
float score;
};
// 方式二:定义类型的同时声明变量
struct Point {
int x;
int y;
} p1, p2;
// 方式三:使用typedef简化
typedef struct {
char brand[20];
int year;
double price;
} Car;
我个人习惯用第三种方式,少写一个struct关键字,代码看着清爽。但要注意,typedef后的匿名结构体不能自引用,比如链表节点就不能这么搞。
初始化也有几种玩法:
// 顺序初始化
struct Student s1 = {"张三", 1001, 89.5};
// 指定初始化(C99起支持)
struct Student s2 = {.name = "李四", .score = 92.0, .id = 1002};
// 部分初始化,未指定的字段自动为0
struct Student s3 = {"王五"}; // id=0, score=0.0
小技巧:指定初始化在结构体字段很多时特别好用。你不用记住每个字段的顺序,而且代码可读性高。我在维护一个老项目时,看到那种几十个字段按顺序初始化的代码,头都大了。
结构体嵌套
结构体里套结构体,很常见的需求。比如描述一个员工,既有个人信息,又有部门信息:
typedef struct {
int year;
int month;
int day;
} Date;
typedef struct {
char name[20];
Date birth;
char dept[30];
double salary;
} Employee;
嵌套初始化时,可以一层层来:
Employee e1 = {"赵六", {1990, 5, 15}, "研发部", 15000.0};
// 或者用指定初始化更清晰
Employee e2 = {
.name = "钱七",
.birth = {.year = 1992, .month = 8, .day = 20},
.dept = "测试部",
.salary = 12000.0
};
访问嵌套成员用点号链式操作:e1.birth.year。这没什么难的,但要注意——嵌套结构体在内存中是怎么排列的?这就引出了核心问题。
内存对齐规则
为什么需要内存对齐?因为CPU读取内存不是按字节来的,而是按字(比如4字节、8字节)来读。如果数据没对齐,CPU可能要读两次才能拿到完整数据,性能就下来了。
对齐规则其实就三条:
- 每个成员的对齐值:等于该成员类型的大小。比如int是4字节,double是8字节。
- 结构体的总对齐值:等于所有成员中最大的对齐值。
- 成员偏移量:必须是该成员对齐值的整数倍。不够就填充空白字节。
看个例子就明白了:
typedef struct {
char a; // 1字节,偏移0
int b; // 4字节,偏移必须是4的倍数 → 实际偏移4
short c; // 2字节,偏移必须是2的倍数 → 实际偏移8
} Test1;
// 结构体大小:a(1) + 填充(3) + b(4) + c(2) + 填充(2) = 12字节
// 为什么最后还要填充2字节?因为结构体总大小必须是最大对齐值(4)的倍数
我刚开始学的时候也觉得这规则绕,后来画了个图就清楚了:
看到没?char a只占1字节,但int b要从偏移4开始,中间白白浪费了3字节。这就是对齐的代价。
改变对齐方式:#pragma pack
有时候我们不想让编译器自动对齐。比如你要把一个结构体通过串口发出去,或者写入文件,希望它严格按照字节排列,没有填充。这时候就用 #pragma pack。
#pragma pack(1) // 按1字节对齐,即取消对齐
typedef struct {
char a;
int b;
short c;
} PackedTest;
#pragma pack() // 恢复默认对齐
// 此时 sizeof(PackedTest) = 1 + 4 + 2 = 7 字节
警告:取消对齐后,结构体成员可能不在自然对齐的地址上。CPU访问未对齐的数据会变慢,有些ARM平台甚至会直接触发异常。我曾经在STM32上调试一个通信协议,用了pack(1)后程序跑飞了,查了半天才发现是未对齐访问导致的。
pack的值可以是1、2、4、8、16。规则是:每个成员的对齐值取 min(自身大小, pack值)。比如pack(2)时,int本来要对齐到4,现在只对齐到2。
| pack值 | char(1) | short(2) | int(4) | double(8) |
|---|---|---|---|---|
| 默认 | 1 | 2 | 4 | 8 |
| pack(1) | 1 | 1 | 1 | 1 |
| pack(2) | 1 | 2 | 2 | 2 |
| pack(4) | 1 | 2 | 4 | 4 |
优化结构体布局
既然对齐会带来填充,那能不能减少浪费?当然可以。把大的成员往前放,小的往后放,往往能省空间。
// 浪费版:12字节
typedef struct {
char a; // 1 + 3填充
int b; // 4
char c; // 1 + 3填充
} Bad;
// 紧凑版:8字节
typedef struct {
int b; // 4
char a; // 1
char c; // 1 + 2填充
} Good;
你看,只是调整了成员顺序,就从12字节降到了8字节。我在做嵌入式开发时,有个结构体要存几千个实例,优化后省了几十KB的RAM——这在资源受限的MCU上可是实打实的收益。
核心要点:
- 结构体大小不是成员大小的简单相加,要考虑对齐填充
- 默认对齐规则:成员偏移量是自身大小的整数倍,总大小是最大对齐值的整数倍
- #pragma pack可以改变对齐方式,但会影响性能,谨慎使用
- 调整成员顺序可以优化内存占用,大成员优先
- 跨平台通信时,务必确认双方的对齐方式一致
最后说一句,结构体对齐这事,平时写小demo可能感觉不到。但一旦涉及到协议解析、数据持久化、跨平台通信,对齐问题就会冒出来咬你一口。我建议你写代码时养成好习惯——明确结构体的对齐方式,不要依赖编译器的默认行为。尤其是在团队协作中,加个注释说明对齐意图,能省去后面很多排查时间。
公众号:蓝海资料掘金营,微信deep3321