C 迷你系列(三)内存对齐

Python022

C 迷你系列(三)内存对齐,第1张

我们以这个例子为说明:

从以上例子中我们知道每个类型占用的字节大小,最后得出总的内存大小是 24 字节。如果说总的类型大小是 15 个字节的话,那么多出来的 9 个字节就是填充后的内存大小了,但是这个 24 字节是怎么算出来的呢?

说了这么多,对不对呢?我们来验证一下:

比较幸运的是,地址跟我们例子里说明的一样都是 0 为起始地址,从地址上我们验证我们之前的分析是正确的。当然有一点需要注意的是,为了说明方便,这里地址用的是十进制,即 0x16,其实这是错的,因为地址是 16 进制。

我们在分析的最后有提到过总的内存大小与结构体最大成员内存的整除关系,这是什么意思呢?其实就是说整个结构体是否需要内存填充进行对齐。我们看下边一个例子:

我们在结构体最后添加一个 short 类型的成员,结果整个内存变成了 32 字节,也就是说增加了 6 字节填充,这是为什么呢?因为增加了 short 之后,整个内存变成了 26 字节,但是 26 字节不能被结构体中最大的 double 8 字节整除,所以还需要在结构体最后补充 6 字节才行。

从上述例子中,我们发现增加了 short 之后,整个内存变成了 32 字节。那么我们思考一下,结构体中字段不变,我们把它们的顺序变更一下,你认为它们的空间会发生变化吗?

如上图,我们把最后的 short 往上移动了一下,让 e 在 d 的上边。猜猜内存如何变化?

它的内存就又变成了 24 字节,为什么,就是因为 e 提升之后,e 与 d 之间的内存填充发生了变化,如图:

所以,我们发现顺序也会影响内存的大小,这个需要特殊注意。

那么有没有办法破坏这种填充,让程序员自己决定该如何填充呢?答案是肯定的,C 灵活的地方就在于你可以“任意”改变游戏规则。

总的内存大小就是实际的字段大小了,但是这里取字段地址的时候会提示警告信息: Taking address of packed member 'xx' of class or structure 'p' may result in an unaligned pointer value。

#pragma pack(n) 其中 n 代表按照 n 字节进行填充,你可以随意指定,怎么样?是不是很灵活?当然,你要为你自己的程序负责哈!最后,#pragma pack() 用来取消当前设置的对齐方式,也就是说作用域限制在当前结构体。

最终内存图:

在C语言程序开发中,有时有经验的程序员会提起“内存对齐”一词,事实上,这也是C语言中结构体的 size 不等于它所有成员 size 之和的原因(C语言中的结构体的size,并不等于它所有成员size之和,为什么?),那么,C语言程序为什么要“内存对齐”呢?

C语言编译器在处理代码时,常常会将一些变量的内存对齐,这其实主要是因为底层处理器的限制。对于多数处理器而言,每次访问的数据并不是越少越好:例如,有的处理器每次访问 4 个字节数据,要比访问 1 个字节数据效率高得多。

针对这样的情况,一些C语言编译器会将代码中的变量地址对齐,目的就是让处理器能够更加高效的访问这些变量。甚至有些严格的处理器或者系统,在处理未进行内存对齐的数据时,根本无法正常运行(bus error 等)。

现代处理器一般都有多个级别的高速缓存,处理器访问这些高速缓存里的数据的效率要比访问内存里的数据效率高得多(就像处理器访问内存里的数据,比访问磁盘里的数据效率高得多一样。)

就像上面介绍以的一样,一般来说,CPU 总是以字大小(32 位处理器上常常为 4 个字节)访问数据,所以如果数据没有内存对齐,CPU 访问这些数据时,可能就需要执行更多次的读取操作才行。在这样的机器上,读取 2 个字节数据往往比读取 4 个字节数据慢得多。

事实上,本节只是粗浅讨论,处理器的内存系统比这里描述的要复杂得多,涉及的内容也要复杂得多。不过,我们至少已经知道,在C语言程序中坚持内存对齐还是有很多好处的。

对于程序而言,一个变量的数据存储范围是在一个寻址步长范围内的话,这样一次寻址就可以读取到变量的值,如果是超出了步长范围内的数据存储,就需要读取两次寻址再进行数据的拼接,效率明显降低了。例如一个double类型的数据在内存中占据8个字节,如果地址是8,那么好办,一次寻址就可以了,如果是20呢,那就需要进行两次寻址了。这样就产生了数据对齐的规则,也就是将数据尽量的存储在一个步长内,避免跨步长的存储,这就是内存对齐。在32位编译环境下默认4字节对齐,在64位编译环境下默认8字节对齐。

现代处理器一般都有多个级别的高速缓存,处理器访问这些高速缓存里的数据的效率要比访问内存里的数据效率高得多(就像处理器访问内存里的数据,比访问磁盘里的数据效率高得多一样。)。

就像上面介绍以的一样,一般来说,CPU 总是以字大小(32 位处理器上常常为 4 个字节)访问数据,所以如果数据没有内存对齐,CPU 访问这些数据时,可能就需要执行更多次的读取操作才行。在这样的机器上,读取 2 个字节数据往往比读取 4 个字节数据慢得多。

访问范围提高

对于任意给定的地址空间,如果体系架构可以确定 2 个 LSB 总是 0(例如 32 位机器),那么它可以访问 4 倍多的内存(2 个位能够表示 4 个不同状态)。从一个地址中去掉 2 个 LSB,将得到 4 字节的内存对齐,或者说“跨距”,因为地址每增加一,它就有效的增加 bit 2,而不是 bit 0。(鉴于低 2 位总是 00)

这甚至会影响系统的物理设计:如果地址总线的需要少 2 位,CPU 上的管脚就可以少 2 个。

前面提到 CPU 每次访问数据的宽度是一个字,如果C语言程序中的数据总是内存对齐的,那么 CPU 访问数据总是原子性的,这对于许多无锁数据结构和其他并发需求的正确操作至关重要。

规则:

1、数据成员对齐规则: 结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,比较小的那个进行。

2、 结构(或联合)的 整体对齐规则: 在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。

3、 结合1、2可推断:当#pragma pack的n值等于或超过所有数据成员长度的时候,这个n值的大小将不产生任何效果。

#pragma pack 其实就是指定内存对齐系数,如1,2,4,8,16。xcode默认的对齐系数是8.

求以下两个结构体的字节大小

打印结果

分析如下

首先demoStruct1,起始offset为0

a,int类型,4个字节,<8,按4对齐,存放位置是[0,3]

b,int类型,4个字节,<8,按4对齐,存放位置是[4,7]

c,char类型,1个字节,<8,按1对齐,存放位置是[8,8]

d,double类型,8个字节,=8,按8对齐,当前位置不够,先补齐[9,15],然后存放,存放位置是[16,23]

e[7],char数组类型,7个char的字节,<8,按1对齐,存放位置是[24,30]

最后,补齐为8的整数倍,即补上[31,31]

综合:使用位置是[0,31] 占用字节数为32

再分析demoStruct2,起始offset为0

a,int类型,4个字节,<8,按4对齐,存放位置是[0,3]

b,int类型,4个字节,<8,按4对齐,存放位置是[4,7]

c,char类型,1个字节,<8,按1对齐,存放位置是[8,8]

e[7],char数组类型,7个char的字节,<8,按1对齐,存放位置是[9,15]

d,double类型,8个字节,=8,按8对齐,存放位置是[16,23]

综合:使用位置是[0,23] 占用字节数为24

适当的调整变量类型或位置,有助于提升对内存的利用率