引入

项目中业务模块产生的日志在通过本地 socket 发送给日志模块后,日志模块记录的内容总是会丢失前6个字节。经过排查,业务模块和日志模块使用的虽然都是 log_msg_t 结构体,但结构体中的变量类型定义却略有差异,如下所示:

  • 业务模块的结构体定义
1
2
3
4
5
6
7
8
9
10
typedef struct{
unsigned char type;
unsigned char level;
unsigned short size;
unsigned int pid;
unsigned int cds;
unsigned char imm;
unsigned char u8wf;
unsigned char data[];
} log_msg_t;
  • 日志模块的结构体定义
1
2
3
4
5
6
7
8
9
10
typedef struct{
unsigned char type;
unsigned char level;
unsigned short size;
unsigned int pid;
unsigned int cds;
unsigned char imm;
unsigned int u8wf;
unsigned char data[];
} log_msg_t;

显然,由于结构体中 u8wf 变量类型的不同,导致出现了内容丢失。但 unsigned char 的大小为1个字节,unsigned int 的大小为4个字节,怎么会出现丢失6个字节的内容呢,这与结构体的内存对齐有关。

内存对齐

为什么需要对齐

在 C 语言中,结构体的内存对齐是编译器为了提高 CPU 访问内存效率而采取的一种内存布局优化策略,是一种拿空间换时间的做法。

CPU 访问内存时并非逐个字节读取,而是按固定大小的 “块”(如 4 字节、8 字节)读取。如果数据的起始地址是块大小的整数倍(即 “对齐”),CPU 可以一次完成读取;否则可能需要多次读取,影响效率。

内存对齐规则

  1. 第一个成员在与结构体变量偏移量为 0 的地址处。

  2. 其他成员变量的起始地址必须是 min(该成员自身大小, 编译器默认对齐数) 的整数倍。

  3. 结构体的总大小必须是所有成员中最大对齐值的整数倍,即 min(结构体中最宽成员类型的大小, 编译器默认对齐数) 的整数倍。若不足,编译器会在最后一个成员之后添加填充字节以满足此要求。

练习

下面两个结构体的大小分别为多少?

1
2
3
4
5
6
7
8
9
10
11
typedef struct{
char c1;
int i;
char c2;
} S1;

typedef struct{
char c1;
char c2;
int i;
} S2;

S1 和 S2 的内存布局如下图所示:

相关函数

sizeof

  • 获取结构体的大小
1
2
printf("%ld\n", sizeof(S1));
printf("%ld\n", sizeof(S2));

offset 宏

  • 计算结构体中某变量相对于首地址的偏移
  • 头文件: #include<stddef.h>
1
2
3
printf("offsetof(S1, c1) = %ld\n", offsetof(S1, c1));
printf("offsetof(S1, i) = %ld\n", offsetof(S1, i));
printf("offsetof(S1, c2) = %ld\n", offsetof(S1, c2));

#pragma pack()

  • 修改默认对齐数(谨慎操作)
1
2
3
4
5
6
7
8
9
10
// 将默认对齐数修改为 8
#pragma pack(8)
typedef struct{
char c1;
int i;
char c2;
} S1;

// 恢复默认对齐数
#pragma pack()

TIPS

在设计结构体的时候要满足对齐规则,又要节省空间,如何做到呢?

  • 在定义结构体时,将大小相同或相近的成员声明在一起,并且按照从大到小(或从小到大)的顺序声明,可以最大限度地减少填充字节,节省内存。

可变长数组

此外,可以看到 log_msg_t 的最后一个元素为 data[], 且如果使用 sizeof(log_msg_t),可能会发现结果并不符合预期,这一切都与可变长数组的特点相关。

介绍

变长数组是在 C 语言的 C99 标准中引入的新特性。结构体中的最后一个元素允许是大小未知的数组。

比如:

1
2
3
4
struct S {
int n;
int arr[]; // 部分编译器可能会报错,可以将 arr[] 改为 arr[0]
};

特点

  1. 结构体中的可变长数组前面必须至少有一个其它类型的成员。

  2. 可变长数组必须是结构体的最后一个成员。

  3. 可变长数组不占用结构体的存储空间,使用 sizeof 计算结构体的大小不包含可变长数组成员。

  4. 结构体变量相邻的存储空间保存的是可变长数组的内容。

log_msg_t

因此,两个模块使用的log_msg_t结构体的内存布局如图所示:

可以看到两个结构体的 data 成员相对于起始地址的偏移量相差 6 个字节,这也就是为什么日志模块记录的内容总是会丢失前6个字节。

优势

使用指针

1
2
3
4
struct S {
int n;
int *arr;
};

那么在使用时就需要两次 malloc 和两次 free,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct S *ps = NULL;
// 动态分配结构体S的内存空间
ps = (struct S*)malloc(sizeof(struct S));
if (ps == NULL) {
return -1;
}

ps->n = 10;
// 为结构体中的数组成员分配内存
ps->arr = (int*)malloc(ps->n * sizeof(int));
if (ps->arr == NULL) {
free(ps);
ps = NULL;
return -1;
}

// do something

// 释放所有动态分配的内存
free(ps->arr);
ps->arr = NULL;
free(ps);
ps = NULL;

使用可变长数组

1
2
3
4
struct S {
int n;
int arr[0];
};

使用时只需要一次 malloc 和 free,

1
2
3
4
5
6
7
8
9
10
11
12
13
// 分配内存以容纳结构体S和10个整数的数组
struct S *ps = (struct S *)malloc(sizeof(struct S) + sizeof(int) * 10);
if (ps == NULL) {
return -1;
}

ps->n = 10;

// do something

// 释放分配的内存并置空指针
free(ps);
ps = NULL;

总结

使用指针:

  • 为了防止内存泄漏,如果分两次分配结构体和缓冲区的内存,当第二次 malloc 失败时,必须回滚释放第一次分配的结构体内存。
  • 进行了两次 malloc,需要对应两次 free,如果我们的代码是在一个给别人用的函数中,我们在函数里做了两次内存分配,并把整个结构体返回给用户;虽然用户调用 free 可以释放结构体,但用户并不知道结构体的成员也需要 free,造成内存泄露。
  • malloc 次数越多,产生的内存碎片就越多,内存的利用率就会降低。

使用变长数组:

  • 连续内存有利于提高访问速度,同时减少内存碎片