目录
- 关于对齐
- 数据对齐
- 结构体对齐原则
- 原理分析
关于对齐
- 对齐方式:表示的是一个类型的对象存放的内存地址应满足的条件
- 好处:对齐的数据在读写上有性能优势
- 对于不对齐的结构体,编译器会自动补齐以提高CPU的寻址效率
数据对齐
- 四个函数/描述符
- offsetof(X, x):X为结构体名(也可为对象名,实测对象名是不可以的),x为结构体成员,返回x在X中的偏移(以0开始),按字节的,按位是不行的——带虚函数的类计算不了偏移?
- offsetof的实现:
#define offset(s, m)((size_t)&reinterpret_cast<char const volatile&>(((s*)0)->m))
- 你可能会迷惑,这样强制转换后的结构指针怎么可以用来访问结构体字段?呵呵,其实这个表达式根本没有也不打算访问m字段。ANSI C标准允许任何值为0的常量被强制转换成任何一种类型的指针,并且转换结果是一个NULL指针,因此((s*)0)的结果就是一个类型为s的NULL指针。如果利用这个NULL指针来访问s的成员当然是非法的,但&(((s)0)->m)的意图并非想存取s字段内容,而仅仅是计算当结构体实例的首址为((s*)0)时m字段的地址。聪明的编译器根本就不生成访问m的代码,而仅仅是根据s的内存布局和结构体实例首址在编译期计算这个(常量)地址,这样就完全避免了通过NULL指针访问内存的问题。又因为首址的值为0,所以这个地址的值就是字段相对于结构体基址的偏移。
- offsetof的实现:
- alignof(X):查询X的对齐字节,由最大元素类型(,基础类型,如果有嵌套结构体,取该结构体中的最大基础类型比较)的字节数决定;数组的对齐值由其元素决定;如alignof(char)就是1
- 参数X:自定义类型或内置类型或者变量,不完整类型编译不过
- 返回std::size_t类型值
- __alignof是Microsoft的运算符
- 引用与其引用的数据b对齐值相同
- 对齐描述符alignas:重新设定结构体的对齐方式;既可以接收常量表达式,也可以接受类型作为参数;
- 使用常量表达式作为alignas的操作符时结果必须是以2的幂次作为对齐值,设定的对齐值如果小于默认对齐则会忽视
- C++11标准中规定了一个“基本对齐值”,一般情况下等于平台支持的最大标量数据类型的对齐值,可以通过alignof(std::max_align_t)来查询其值 (#include)
- 在C++11之前,使用编译器的扩展来描述对齐方式,如gnu的__attribute__((aligned(8)))
- C++11对于对齐的支持不限于alignof操作符和alignas描述符,STL中的内建函数std::align函数来动态指定的对齐方式调整数据块的位置(待验证)
- offsetof(X, x):X为结构体名(也可为对象名,实测对象名是不可以的),x为结构体成员,返回x在X中的偏移(以0开始),按字节的,按位是不行的——带虚函数的类计算不了偏移?
alignas(x) char c //指定c的对齐是x
struct alignas(x) XX { A xx; } // 指定结构体对齐字节为x
- 通过属性对其(属性是对语言中的实体对象如函数变量等附加的一些额外注解信息,用来实现功能或代码优化):
- linux:通过GNU的关键字__attribute__来声明:attribute((attribute-list)),详细参考gcc在线文档: https://gcc.gnu.org/onlinedocs/
- windows:__declspec,如控制变量的对齐方式:__declspec(align(x))
- C++11的通用属性:[[ attribute_list]]
- 语法上,通用属性可以作用于类型,变量,名称,代码块等。对于作用于声明的通用属性,既可以写在声明的起始处,也可写在声明的标识符之后;对作用于整个语句的通用属性,应该写在语句起始处
- 现有C++11标准中只预定义了两个通用属性:
1. [[noreturn]]:用于标识不会返回的函数(不会返回的函数和返回void不同,不会返回的函数在被调用完成后后续代码不会再被执行),主要用于标识那些不会将控制流返回给原调用函数的函数,如终止应用程序的函数,异常抛出函数等,好处有利于编译器进行优化,但要谨慎使用——待验证
2. [[carries_dependency]]:和并行情况下的编译期优化有关,主要是用来解决弱内存模型平台上使用memory_order_consume内存顺序枚举问题
结构体对齐原则
- 结构体成员变量是基本类型:结构体中的第一个成员位置在偏移量0,之后每个变量的偏移量必须是它本身字节数的整数倍;
- 如果结构体中嵌套结构体,那么嵌套结构体成员的偏移量必须是它最大成员的字节数的整数倍。注意:
1. 不是按结构体整体的大小偏移;
2. 不是展开后偏移,整个结构体的偏移量是包括嵌套结构体在内的最大成员的字节数 - 如果是继承关系,目测和嵌套结构体一样
- 结构体的总大小为结构的字节边界数(即该结构中占用最大空间的类型所占用的字节数)的整数倍,这样在处理数组时可以保证每一项都边界对齐。,在最后还会根据需要自动填充空缺的字节
- 强制对齐:编译器提供了#pragma pack(n)来设定变量以n字节对齐方式:
- 如果n大于等于该变量所占用的字节数,那么偏移量必须满足默认的对齐方式;
- 如果n小于该变量的类型所占用的字节数,那么偏移量为n的倍数,不用满足默认的对齐方式。
- 结构的总大小也有个约束条件,分下面两种情况:如果n大于所有成员变量类型所占用的字节数,那么结构的总大小必须为占用空间最大的变量占用的空间数的倍数;否则必须为n的倍数。
- 注意:这个预编译命令会不论大小都会改变对齐值,alignas只会改大不会改小
#pragma pack(push) //保存对齐状态
#pragma pack(4) //设定为4字节对齐,n=1,2,4,8,16改变系统的对齐系数
struct test
{
char m1;
double m4;
int m3;
};
#pragma pack(pop) //恢复对齐状态
验证结果:
- 在linux下测试都有效;
- 单独使用#pragma pack(x)在vs下测试会使后面定义的结构体都按x对齐;
原理分析
C语言,包括其他的编程语言都会有内存对齐机制,否则编译出来的软件无法正常运行。在内存中,所有的数据都是按字节为最小单位存储的,存储单位称为存储单元,所以也叫内存是由很多存储单元组成的,这些存储单元(字节)都有固定的地址标示着(这里说的非虚拟模式下),在我们程序员眼里内存就是一个一个字节组成的,这些字节对应的地址都是连续并排好的,但是在CPU中不是,在CPU看来内存是一段一段的,每段大小取决于CPU的寻址位宽!
比如在C语言里申请了short和两个char变量:
Shor a;
Char b;
Char c;
在内存中对应的起始地址为:0x01(a),0x03(b),0x04©
在程序员眼里在内存中的存储是这样的:
但是在一个寻址位宽为32位的CPU上是这样的:
Short占用16个bit位char占用8个bit位所以16+8+8=32 刚好组成一段
但是如果在c语言中我们这样定义了三个变量:
Short a;(起始地址:0x01)
Char b;(起始地址:0x03)
Short c;(起始地址:0x04)
那么如果C语言编译器不为我们自动对齐在内存中就会这样:
可以看到最后一个short的地址被分两个段存储了,也就是说如果CPU要读取变量c那么必须先将第一个段里的数据全部读取出来送到寄存器里(这里假定通用寄存器为32位),然后将第一个寄存器里的0x01到0x03的数据剔除掉,将0x04上的bit位数据挪移到寄存器的低位上,然后在去读取第二个段上的地址数据也送到寄存器里,然后将0x06到0x08上的数据剔除掉,这样就将额外的数据剔除掉了,但是这样就会读取两次,浪费更多的时间1,为什么不直接从0x04开始读取?注意CPU不可以从某段的段偏移上读取只能从某段的起始地址开始读取!这是虚拟内存中的概念(详细参见虚拟内存的映射关系),不然的话就会造成读取时出现数据越界的情况!
假如0x05地址是栈的尾地址,如: 0x04 0x05 0x06 0x07
那么在一个32位的CPU下寻址时就会发生越界的情况,访问到其他进程下的内存则会被操作系统里内核中断代码捕捉会被视为恶意代码会立即被中断,当然部分CPU也并不会全部都给你按上面的方式来读取,假如遇到了上图那样的地址存储方式,CPU会直接报硬件中断,直接罢工不读取,然后由操作系统捕捉这个硬件中断直接咔擦掉你的程序!在调试情况下调试器捕捉这个消息时会中断!
所以为了解决这一问题,编译器使用了一种叫做空间交换法的方式来对齐内存,也就是说增加额外的内存,换取时间上的效率,同时也避免部分CPU的硬件中断!
下面是C语言内存对齐后的地址:
可以看到char为了对齐short增加了一个字节,而short为了对齐32位寻址增加了两个字节,这就是空间上的时间交换法,这样的话CPU读取时只需要一次就可以读取完!注意增加的字节只是为了对齐寻址,额外的字节是不允许被赋值的,虽然说可以赋值,但是当我们赋值是编译器不会允许我们向额外的地址传递数据!注意以上内存对齐是发生在结构体当中的,不光是结构体,在函数体里所有的变量包括代码段内存全部都会被内存对齐,函数体里的对齐方式不同于结构体,博主也没有去深刻的去查函数体里的对齐方式,这里也不必要做过多的了解因为编译器都会帮我们做好!只是在个别面试时会面试到关于内存对齐的基础题!
这种方法就是空间上的时间转换,牺牲额外的空间换取时间上的效率,毕竟在这个内存越来越大,价格越来越便宜的年代,牺牲额外的内存换取效率也是一件可行之事!
每个编译器里都有一个内存对齐的模数,这个摸数取决于你的CPU位宽!我们可以通过# #pragma pack(n)来强制改变它,n的可取值范围是:n=1,2,4,8,16(字节),当然这里也不是特别建议去改变它的默认寻址宽度,因为超出的话CPU没有办法一次性读取完还是会分段裁剪来读,过小的话CPU也是要去分两次或者更多的次数然后裁剪的读取,所以建议为默认值
以上原理分析部分转发自博客:https://blog.csdn.net/bjbz_cxy/article/details/78418828