C筑基——深入理解内存对齐

1 前言

11月份的时候,同事维护的项目出现了内存对齐问题:传递数据的一方使用了 #pragma pack(4) 修改了结构体的对齐方式,而接收方数据的一方并没有对这个结构体使用同样的对齐方式,造成了获取数据时失败的问题。

本文主要说明:

  • 为什么要有内存对齐?
  • 内存对齐原则
  • 如何修改默认对齐方式?

2 正文

2.1 为什么要有内存对齐?

单字或者双字操作数的存储跨越了 4 字节边界,或者一个四字节操作数的存储跨越了 8 字节边界,被认为是未对齐的。为了访问未对齐的内存,处理器需要作两次内存;而如果内存是对齐的,处理器仅需要一次内存访问。

处理器在访问内存时,每次读取一定的长度(这个长度就是处理器的默认对齐系数,或者是默认对齐系数的整数倍)。这个长度就是指上段中描述的 4 字节边界或者 8 字节边界。

需要说明的是,开发者并不需要直接负责如何进行内存对齐,编译器默认会对标准数据类型,结构中的成员数据进行内存对齐。开发者需要学习编译器进行内存对齐的原则,以便解决实际开发中的问题。

2.2 内存对齐原则

2.2.1 基本数据类型是自然对齐的

如果一个变量的内存地址正好是这个变量的长度的整数倍,那么这个变量就是自然对齐的。

这里通过代码来说明 charshortintdouble 类型变量是自然对齐的:

#include <stdio.h>
#include <stdlib.h>

#define mark(num) (num) == 0 ? "√" : "×"

int main(void)
{
    printf("sizeof(char): %zd, sizeof(short): %zd, sizeof(int): %zd, sizeof(double): %zd\n", sizeof(char), sizeof(short), sizeof(int), sizeof(double));
    char a = 'a';
    short b = 10086;
    int c = 1008611;
    double d = 3.1415926;
    long long aAddr = &a;
    long long bAddr = &b;
    long long cAddr = &c;
    long long dAddr = &d;
    printf("类型\t\t被 sizeof(char) 整除\t被 sizeof(short) 整除\t被 sizeof(int) 整除\t被 sizeof(double) 整除\n");
    printf("char 地址\t\t%s\t\t\t%s\t\t\t%s\t\t\t%s\n",
    mark(aAddr % sizeof(char)), mark(aAddr % sizeof(short)), mark(aAddr % sizeof(int)), mark(aAddr % sizeof(double)));
    printf("short 地址\t\t%s\t\t\t%s\t\t\t%s\t\t\t%s\n",
    mark(bAddr % sizeof(char)), mark(bAddr % sizeof(short)), mark(bAddr % sizeof(int)), mark(bAddr % sizeof(double)));
    printf("int 地址\t\t%s\t\t\t%s\t\t\t%s\t\t\t%s\n",
    mark(cAddr % sizeof(char)), mark(cAddr % sizeof(short)), mark(cAddr % sizeof(int)), mark(cAddr % sizeof(double)));
    printf("double 地址\t\t%s\t\t\t%s\t\t\t%s\t\t\t%s\n\n",
    mark(dAddr % sizeof(char)), mark(dAddr % sizeof(short)), mark(dAddr % sizeof(int)), mark(dAddr % sizeof(double)));
    return 0;
}

首先使用 sizeof 运算符获取charshortintdouble 类型的长度,并打印出来;然后声明了charshortintdouble 类型的变量,并打印各自地址被类型长度整除的情况。

打印结果:

sizeof(char): 1, sizeof(short): 2, sizeof(int): 4, sizeof(double): 8
类型            被 sizeof(char) 整除    被 sizeof(short) 整除   被 sizeof(int) 整除     被 sizeof(double) 整除
char 地址               √                       ×                       ×                       ×
short 地址              √                       √                       ×                       ×
int 地址                √                       √                       √                       ×
double 地址             √                       √                       √                       √

从打印结果可以看到,
char 类型变量的地址可以被 sizeof(char),即 1 所整除;
short 类型变量的地址可以被 sizeof(short),即 2 所整除;
int 类型变量的地址可以被 sizeof(int),即 4 所整除;
double 类型变量的地址可以被 sizeof(char),即 8 所整除。

对于这些自然对齐的变量,一次读取 8 个字节长度的处理器,只需要一次内存访问就可以取出变量的值。

2.2.2 包含基本数据类型成员的结构体

对于包含基本数据类型成员的结构体,内存对齐原则是:

  • 成员只能存储在这个成员的长度的整数倍的地址上;
  • 结构体的长度是它的最大成员长度的整数倍。

为了更好地理解,我们通过两个结构体来进一步说明:

typedef struct  
{
    char a;
    short b;
    int c;
} TestStruct1;

typedef struct  
{
    char a;
    int c;
    short b;
} TestStruct2;

问:TestStruct1TestStruct2 的长度是否一样?分别是多少?为什么?

我们先来看一下这两个结构体,区别只是 short b;int c; 这两行语句的次序不一样而已。按照直观的理解,TestStruct1TestStruct2 的长度应该是一样的。

我们打印一下二者的长度:

int main(void)
{
    printf("sizeof(TestStruct1) = %zd, sizeof(TestStruct2) = %zd\n", sizeof(TestStruct1), sizeof(TestStruct2));
    return 0;
}

打印结果:

sizeof(TestStruct1) = 8, sizeof(TestStruct2) = 12
套用结构体内存对齐原则来分析

对于结构体 TestStruct1

typedef struct  
{
    char a; // 成员长度为 1
    short b; // 成员长度为 2
    int c; // 成员长度为 4
} TestStruct1;

结构体的最大成员长度是 4 个字节,所以结构体的长度一定是 4 的整数倍。
char a 占据 1 个字节,需要存储在可以被 1 整除的地址上,所以它相对于结构体起始地址的偏移量为 0;
short b 占据 2 个字节,需要存储在可以被 2 整除的地址上,所以它相对于结构体起始地址的偏移量为 2;
int c 占据 4 个字节,需要存储在可以被 4 整除的地址上,所以它相对于结构体起始地址的偏移量为 4。
使用表格表示如下:
在这里插入图片描述
所以,TestStruct1 的长度是 8 个字节。

对于结构体 TestStruct2

typedef struct  
{
    char a;
    int c;
    short b;
} TestStruct2;

结构体的最大成员长度是 4 个字节,所以结构体的长度一定是 4 的整数倍。
char a 占据 1 个字节,需要存储在可以被 1 整除的地址上,所以它相对于结构体起始地址的偏移量为 0;
int c 占据 4 个字节,需要存储在可以被 4 整除的地址上,所以它相对于结构体起始地址的偏移量为 4;
short b 占据 2 个字节,需要存储在可以被 2 整除的地址上,所以它相对于结构体起始地址的偏移量为 8。
使用表格表示如下:
在这里插入图片描述
所以,TestStruct2 的长度是 12 个字节。

分析结果与打印结果是一致的。

使用 gdb 查看这两个结构体的成员内存位置

为了更深入地理解这两个结构体的成员内存位置,我们再使用 来分析一下:

先对 memory_alignment.c 程序进行修改如下:

int main(void)
{
    TestStruct1 ts1;
    ts1.a = 0x11;
    ts1.b = 0x2222;
    ts1.c = 0x33333333;
    TestStruct2 ts2;
    ts2.a = 0x11;
    ts2.c = 0x33333333;
    ts2.b = 0x2222;
    return 0;
}

分别对各自的成员,使用相应的 16 进制整数进行赋值。这是为了更方便地在内存中查看成员占据的字节数。

使用 gcc 以调试模式编译程序:

$ gcc -g memory_alignment.c

生成的可执行程序是 a.out。

使用 gdb 调试 a.out,在 return 0; 这行打断点,并 run

$ gdb a.out
GNU gdb (Ubuntu 8.1.1-0ubuntu1) 8.1.1
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
---Type <return> to continue, or q <return> to quit---
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from a.out...done.
(gdb) break 29
Breakpoint 1 at 0x68f: file memory_alignment.c, line 29.
(gdb) run
Starting program: /home/wangzhichao/c-examples/_11_memory_management/a.out 
sizeof(TestStruct1) = 8, sizeof(TestStruct2) = 12

Breakpoint 1, main () at memory_alignment.c:29
29          return 0;
(gdb) 

显示 ts1ts2 的地址,并用 x 命令查看对应地址的内存(关于 x 命令的使用,可以查看GDB查看内存命令(x命令) 用gdb查看指定地址的内存内容):

(gdb) display &ts1
1: &ts1 = (TestStruct1 *) 0x7fffffffde9c
(gdb) x/8xb 0x7fffffffde9c
0x7fffffffde9c: 0x11    0x55    0x22    0x22    0x33    0x33    0x33    0x33
(gdb) display &ts2
2: &ts2 = (TestStruct2 *) 0x7fffffffdea4
(gdb) x/12xb 0x7fffffffdea4
0x7fffffffdea4: 0x11    0x7f    0x00    0x00    0x33    0x33    0x33    0x33
0x7fffffffdeac: 0x22    0x22    0x00    0x00
(gdb) 

x/8xb 0x7fffffffdea0 表示以 16 进制格式输出从 0x7fffffffdea0 向后的 8 个字节上的内容;
x/12xb 0x7fffffffdea8 表示以 16 进制格式输出从 0x7fffffffdea8 向后的 12 个字节上的内容。

这些与表格表示出的内存位置是一致的:
对于 ts1:
在这里插入图片描述
对于 ts2:
在这里插入图片描述

结构体类型变量是自然对齐的吗?

在 2.2.1 小节中说过,如果一个变量的内存地址正好是这个变量的长度的整数倍,那么这个变量就是自然对齐的。

那么,结构体类型变量的内存地址是不是这个结构体类型长度的整数倍呢?

我们直接通过程序来验证吧:

int main(void)
{
    TestStruct1 ts1;
    TestStruct2 ts2;
    printf("TestStruct1 ts1 的地址是否可以被其长度整数:%lld\n", (long long)&ts1 % sizeof(TestStruct1));
    printf("TestStruct1 ts2 的地址是否可以被其长度整数:%lld\n", (long long)&ts2 % sizeof(TestStruct2));
    return 0;
}

多次运行程序,打印如下:

$ ./a.out 
TestStruct1 ts1 的地址是否可以被其长度整数:4
TestStruct1 ts2 的地址是否可以被其长度整数:8
$ ./a.out 
TestStruct1 ts1 的地址是否可以被其长度整数:4
TestStruct1 ts2 的地址是否可以被其长度整数:0
$ ./a.out 
TestStruct1 ts1 的地址是否可以被其长度整数:4
TestStruct1 ts2 的地址是否可以被其长度整数:4

所以,可以知道:结构体变量不是自然对齐的。

2.2.3 数组类型

数组类型是依据其元素类型对齐:如果第一个元素可以对齐,那么后面的元素自然也可以对齐;如果第一个元素不可以对齐,那么后面的元素无法保证对齐。

如果数组元素是基本类型,那么这个数组后面的元素是可以对齐的;
如果数组元素是构造类型,那么这个数组的元素就不保证对齐了。

2.3 修改编译器的默认对齐方式(系数)

编译器的默认对齐方式(系数)

使用 #pragma pack(show) 指令,在 warning 信息里面会打印默认对齐方式(系数)。
遗憾的是,在 Linux gcc 环境下,这条指令并不生效:

$ gcc -g memory_alignment.c 
memory_alignment.c:4:9: warning: unknown action ‘show’ for#pragma pack’ - ignored [-Wpragmas]
 #pragma pack(show)
         ^~~~

在 Android Studio 或者 CLion中,把鼠标放在 pack 上,可以显示出来默认对齐方式(系数):
在这里插入图片描述
另外,使用 clang 环境的在线编辑器,也可以显示出来默认对齐方式(系数)。

需要说明的是,不同的编译器的默认对齐系数可能是不一样的。

为什么要修改编译器的默认对齐系数?

一般情况下,不建议修改编译器的默认对齐系数;但是,当我们开发好的结构作为 API 的一部分提供给第三方使用的时候,第三方开发者可能将编译器的默认对齐系数改变或者第三方开发者的编译器默认对齐系数与我们的不同,这就会造成重大的数据问题。解决办法是:和第三方开发者约定使用一样的对齐系数。

如何修改编译器的默认对齐系数?

使用 #pragma pack(n) 指令,其中 n 就是设置的对齐系数。

需要注意的是,对齐系数的取值可以是 0,1,2,4,8,16 中的一个。当取 0 时,表示恢复默认对齐系数。

示例代码如下:

#include <stdio.h>
#include <stdlib.h>

#pragma pack(4) // 设置对齐系数为 4

typedef struct  
{
    char a;
    short b;
    int c;
} TestStruct1;

typedef struct  
{
    char a;
    int c;
    short b;
} TestStruct2;

int main(void)
{
    printf("sizeof(TestStruct1) = %zd, sizeof(TestStruct2) = %zd\n", sizeof(TestStruct1), sizeof(TestStruct2));
    TestStruct1 ts1;
    ts1.a = 0x11;
    ts1.b = 0x2222;
    ts1.c = 0x33333333;
    TestStruct2 ts2;
    ts2.a = 0x11;
    ts2.c = 0x33333333;
    ts2.b = 0x2222;
    return 0;
}

设置不同的对齐系数,打印信息如下:

对齐系数打印信息
1sizeof(TestStruct1) = 7, sizeof(TestStruct2) = 7
2sizeof(TestStruct1) = 8, sizeof(TestStruct2) = 8
4sizeof(TestStruct1) = 8, sizeof(TestStruct2) = 12
8sizeof(TestStruct1) = 8, sizeof(TestStruct2) = 12
16sizeof(TestStruct1) = 8, sizeof(TestStruct2) = 12

从表格中,可以看到:当对齐系数小于 4 时,结构体的长度与默认对齐系数下的结构体长度不同。这是为什么呢?

为什么修改了对齐系数会影响结构体的长度?

这是因为对齐系数的修改,影响内存对齐原则。

具体来说,考虑到对齐系数的内存对齐原则是:

  • 成员只能存储在 min(这个成员的长度, 对齐系数) 的整数倍的地址上;
  • 结构体的长度是 min(它的最大成员长度, 对齐系数) 的整数倍。

在 2.2.2 中,我们没有考虑对齐系数的影响,是因为默认的对齐系数不小于基本数据类型的长度,也就是说,默认情况下,对齐系数不会对包含基本数据类型成员的结构体的内存对齐原则产生作用。

套用完整的内存对齐原则,以及配合使用 gdb 查看内存位置来分析,得到表格如下:

TestStruct1:
在这里插入图片描述
TestStruct2:
在这里插入图片描述
可以看到:

  • 表格中得到的结构体长度与代码打印的结构体长度是一致的;
  • 较小的对齐系数可以产生更加紧凑的结构体,更加小的结构体长度;
  • 较小的对齐系数使得基本数据类型成员不再是自然对齐的了,这会增加一定的内存访问,降低性能。

发送方和接收方对同一个结构体使用不同的对齐系数例子

发送方,send_data.c

#include <stdio.h>
#include <stdlib.h>

#pragma pack(1) // 对齐系数是 1

typedef struct  
{
    char a;
    int c;
    short b;
} TestStruct2;

int main(void)
{
    FILE * fp = NULL;

    fp = fopen("data", "wb");
    TestStruct2 ts2;
    ts2.a = 0x11;
    ts2.b = 0x2222;
    ts2.c = 0x33333333;
    fwrite(&ts2, sizeof(TestStruct2), 1, fp);
    printf("send success\n");
    fclose(fp);
    return 0;
}

接收方 receive_data.c

#include <stdio.h>
#include <stdlib.h>

#pragma pack(1) // 对齐系数也是1

typedef struct  
{
    char a;
    int c;
    short b;
} TestStruct2;

int main(void)
{
    FILE * fp = NULL;

    fp = fopen("data", "r");
    TestStruct2 ts2;
    fread(&ts2, sizeof(TestStruct2), 1, fp);
    printf("receive: a = %#x, b = %#x, c = %#x\n", ts2.a, ts2.b, ts2.c);
    fclose(fp);
    return 0;
}

发送方和接收方的对齐系数都是 1。
先运行发送方,将结构体数据写入到文件中;再运行接收方,从文件中读取结构体数据到结构体中。
运行打印如下:

$ gcc send_data.c 
$ ./a.out 
send success
$ gcc receive_data.c 
$ ./a.out 
receive: a = 0x11, b = 0x2222, c = 0x33333333
$ 

可以看到,当发送方和接收方的对齐系数相同时,接收方可以正常获取数据。

现在把接收方程序的对齐系数改为 4,再次运行接收方程序,打印如下:

$ gcc receive_data.c 
$ ./a.out 
receive: a = 0x11, b = 0x7fff, c = 0xff222233

可以看到,当发送方和接收方的对齐系数不相同时,接收方获取了错误的数据。

为了找到数据出错的原因,我们使用 gdb 查看发送方和接收方的内存:
发送方内存:

(gdb) display &ts2
1: &ts2 = (TestStruct2 *) 0x7fffffffdea9
(gdb) x/7xb 0x7fffffffdea9
0x7fffffffdea9: 0x11    0x33    0x33    0x33    0x33    0x22    0x22

接收方内存:

(gdb) display &ts2
1: &ts2 = (TestStruct2 *) 0x7fffffffde9c
(gdb) x/12xb 0x7fffffffde9c
0x7fffffffde9c: 0x11    0x33    0x33    0x33    0x33    0x22       0x22    0xff
0x7fffffffdea4: 0xff    0x7f    0x00    0x00
(gdb)

把这些信息放在表格里面:
在这里插入图片描述
可以看到,接收方可以完成获取发送方发送的 7 个字节的数据,但是,接收方获取成员数据的偏移量与发送方写入成员数据的偏移量不同,造成了 intshort 这两个类型的数据读取了一部分垃圾值。

3 最后

本文到这里就草草结束了,虽然有些啰嗦,但是还是希望可以帮助到大家。

参考

  1. 字节对齐专题
  2. GDB查看内存命令(x命令) 用gdb查看指定地址的内存内容
  3. C语言深度解剖 3.6.8 #pragma pack
  4. 深度刨析为何要内存对齐
  5. 深入理解C语言内存对齐
  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

willwaywang6

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值