4、为什么存在内存对齐?
经过了两道例题和两道练习题的训练,相信你对如何计算结构体的大小一定是心中有数了,但在阅读的过程中你是否有疑惑为什么会存在这个【结构体内存对齐】呢?有什么实际意义吗?
① 平台原因(移植原因)
- 不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
② 性能原因
- 假设下面有一个结构体,内部有两个成员变量
c
和i
,然后要在内存中存储它们,我分为了两种,一个是【无内存对齐】,呈现的是紧密存放;一个是【内存对齐】,需要考虑到最大对齐数 - 然后在32位平台下去分别访问结构体中的成员,假设现在读取数据的时候一次性读四个字节。
- 首先看到的是【无内存对齐】的结构体内存分布,读一次就能读到
c
,但是若要全部读取完i
,就还需要再读取一次,那访问到所有的成员变量就需要两次; - 接下去看到的是【内存对齐】的结构体内存分布,因为内存对齐的缘故,所有两个成员变量
c
和i
互不干扰,此时再看到成员变量i,从它的初始地址处开始读取,一次读4个字节,那么读1次就刚刚好可以读完这个变量了,而不是像上面那样还需要再读一次
- 所以原因就在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
总体来说:
结构体的内存对齐是拿==空间来换取时间==的做法
了解了为什么会存在内存对齐之后,我们再回到一开始的这两个结构体,你是否有想过为什么两个结构体的成员变量都一模一样但是大小却是一个【12】,一个【8】呢?
- 没错,就是你想到的它们所存放的位置不一样罢了。因为要存在内存对齐,所以若两个对齐数大的成员变量定义在一起的话为了满足规则就可能会浪费很多空间的内存。
- 但若是两个对齐数较小甚至相同规定的变量定义在一块的话,可能它们就是挨着放的,占用的空间少了↓,那最后结构体的大小就变小了
struct S1 { char c1; int i; char c2; }; struct S2 { char c1; char c2; int i; };
- 所以,那在设计结构体的时候,我们既要满足对齐,又要节省空间,就要让占用空间小的成员尽量集中在一起
5、如何修改默认对齐数
之前我们见过了 #pragma 这个预处理指令
#pragma comment
,用来链接函数的静态库。这里我们再次使用,可以改变我们的默认对齐数
- 用法很简单
#pragma pack(1)
就可以设置默认对齐数为1,#pragma pack()
就可以取消设置的默认对齐数,还原为默认。到它为止的默认对齐数还是被修改后的对齐数 - 接下去就来看下面这个修改完默认对齐数后的结构体,它的大小会是多少呢?
#pragma pack(1)//设置默认对齐数为1 struct S1 { char c1; int i; char c2; }; #pragma pack()//取消设置的默认对齐数,还原为默认 int main() { //输出的结果是什么? printf("%d\n", sizeof(struct S1)); return 0; }
- 可以看到,若是默认的对齐数设置为1的话,那其实可以看出每个成员变量的对齐数就都是1了,那么也就不存在浪费的现象,因为任何数都是1的整数倍,所以3个成员变量的内存分布如下,大小即为【6】
运行结果如下:
可以通过【offsetof】再来验证一下
结论:
- 结构在对齐方式不合适的时候,我么可以自己更改默认对齐数
6、实战演练
✍一道百度笔试题: offsetof 宏的实现
在上面的每一个结构体计算后,我都使用到了
offsetof
这个宏,和我画出来的内存分布图完全就是一致的,那它的原理到底是怎样的呢?马上来探究一下:mag:
曾经有一年的百度笔试题就考到了有关offsetof
的实现原理
👉 ==【原题】:写一个宏,计算结构体中某变量相对于首地址的偏移,并给出说明==
- 那要如何去实现呢?如果对宏不是很了解的读者可以看看 详解程序环境和预处理
- 我们通过上面的结构体S1进行讲解。列出3个成员变量放置的初始地址,其实【offsetof】计算的也就是每个变量在内存中的起始地址相较于首地址偏移了多少,那将它们进行一个相减就可以得出
0
、4
、8
这三个结果
- 但是上面的这些地址太复杂了,都是十六进制的,接下去我们来将
c1
这块地址设置为0,那么
- 【c1】相对于自己的偏移量就是
&c1 - 0
- 【i】相对于自己的偏移量就是
&i - 0
- 【c2】相对于自己的偏移量就是
&c2 - 0
- 但其实这可以看出,虽然每个成员变量各自的偏移量为他们的地址减去首地址,但是可以看出这减了和没减有什么区别呢?所以可以得出它们三者的偏移量其实就是他们各自的初始地址
知道了上面这些我们就可以使用【宏】来实现每个成员变量偏移量的计算了
#define OFFSETOF(m_type, m_name) (int)&(((m_type *)0)->m_name)
- 不过相信你一定看不懂上面这个宏,所以我会来一步步讲解一下
m_type
是结构体变量;m_name
是结构体成员
- 首先是地址为0的这个地方要放置结构体成员,但是0是一个整型,所以我们使用强制类型转换将0转换成一个结构体的指针,那么在外部传入结构体成员变量的时候就符合类型了
#define OFFSETOF(m_type, m_name) (m_type *)0 printf("%d\n", OFFSETOF(struct S1, c1));
- 那既然这是一个结构体指针的话,就可以访问到其内部的结构体成员变量,也就是这个
m_name
#define OFFSETOF(m_type, m_name) ((m_type *)0)->m_name
- 那么在上面说到过,每个结构体成员变量的地址就是它相对于起始位置的偏移量
#define OFFSETOF(m_type, m_name) &(((m_type *)0)->m_name)
- 但是呢,在打印的时候可以看出对于偏移量而言都是第一个整数,所以还要对取到的地址偏移转换为整型,便是最后的结果
#define OFFSETOF(m_type, m_name) (int)&(((m_type *)0)->m_name)
下面是流程图:
下面是运行结果:
💬两道高频面试题
结构体怎么对齐? 为什么要进行内存对齐?
- 结构体内存对齐存在对应的规则,规则如下
- 第一个成员在与结构体变量偏移量为0的地址处
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处==对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值==
- 【VS中默认的值为8、Linux环境默认不设对齐数(对齐数是结构体成员自身的大小)】
- 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
- 为什么要进行内存对齐呢?原因有两个,一个是平台本身的原因,任意地址上的任意数据是不能随意访问的,如果不正确访问可能会造成硬件异常。第二个就是性能原因,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问
如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?
- 可以的,只需要使用一个预处理指令
#pragma pack(3)
便可以将默认对齐数修改为3,其他的也是同理,因为结构体默认对齐数发生了变化,此时就会导致结构体大小发生变化
小结
【总结一下】
- 在模块主要是介绍了如何去计算一个结构体的大小,最重要、最核心的还是开头的4条规则,我们再来回顾一下
- 第一个成员在与结构体变量偏移量为0的地址处
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处==对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值==
- 【VS中默认的值为8、Linux环境默认不设对齐数(对齐数是结构体成员自身的大小)】
- 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
- 有了规则之后,将它们灵活地运用到实际的题目中,只要掌握了方法,就感觉其实计算结构体的大小也没有那么复杂,就是对于【嵌套结构体】的规则有些复杂,要考虑到另一个结构体中的最大对齐数
- 接下去,我们就谈到了为什么在计算这些结构体的时候会存在内存对齐的现象,对于了设置与不设置内存对齐便观察到这是【空间换时间】的做法
- 谈了很久的
offsetof()
,但是不清楚原理是什么👉这不,百度笔试题就考到了,于是我们就去自己通过一个宏实现了一下这个偏移量的求解,虽然过程很复杂,但是在我一步步的细讲下,相信聪明的你一定有所理解😁在理解了结构体内存对齐的各方面之后,面对两道面试题也是毫不畏惧💪
五、结构体传参
最后我们再来说说有关结构体的传参
直接上代码💻
struct S { int data[1000]; int num; }; struct S s = { {1,2,3,4}, 1000 }; //结构体传参 void print1(struct S s) { printf("%d\n", s.num); } //结构体地址传参 void print2(struct S* ps) { printf("%d\n", ps->num); } int main() { print1(s); //传整个结构体 print2(&s); //传地址 return 0; }
- 可以看到,这里我对于结构体的传参使用了两种方式,一种是直接将整个结构体传过去,一个则是将这个结构体的地址传过去,然后在形参中用指针进行接收
上面的 print1 和 print2 函数哪个好些?
- 答案是:首选print2函数
- 原因:函数传参的时候,参数是需要压栈的。如果传递一个结构体对象的时候,因为形参是实参的一份临时拷贝,实参传递过来的结构体有多大,形参也要开辟一块同样大小的空间来存放这个结构体,此时若是结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。
- 但若是我们只传递这个结构体的地址过去的话,函数内部便可以使用结构体指针访问到所有的内容,以便节省开销
——> 如果不了解这一块的可以看看我的函数栈帧一文
【总结一下】: 结构体传参的时候,要传结构体的地址