引言
相信在大多数人开始学习编程时,老师都会谈到各种数据类型的size问题。比如说一个int类型的变量大小是4byte,一个char类型的变量是1byte。这些问题都很好解决,但是当谈到一个结构体的大小时,答案往往就不那么肯定了。这个问题我困惑了很久,查找了不少资料后终于有所体会。现在特意记录下来,谈一谈计算机中各种数据类型的size到底是怎样得到的,主要涉及到以下三个方面的知识
- 基本数据类型的大小
- 字节对齐
- 编译器的设置
基本数据类型的内存大小
类型 | 32位 | 64位 |
---|---|---|
char | 1 | 1 |
int | 4 | 大多数为4,8 |
long | 4 | 8 |
float | 4 | 4 |
double | 8 | 8 |
指针 | 4 | 8 |
实际上这些数据类型大多数都是根据编译器来规定的,满足以下规则就行
sizeof(char) == 1
sizeof(char) <= sizeof(short)
sizeof(short) <= sizeof(int)
sizeof(int) <= sizeof(long)
sizeof(long) <= sizeof(long long)
sizeof(char) * CHAR_BIT >= 8
sizeof(short) * CHAR_BIT >= 16
sizeof(int) * CHAR_BIT >= 16
sizeof(long) * CHAR_BIT >= 32
sizeof(long long) * CHAR_BIT >= 64
根据这份规则,我们就可以解释为什么在32位机下long 和int都是4字节,因为只要求满足
sizeof(short)<=sizeof(int)<=sizeof(long)
了解了基本的数据类型之后,更复杂的情况出现了
- 结构体(struct):比较复杂,要求对齐
- 联合(union):所有成员中最长的
- 枚举(enum):根据数据类型决定
我们发现,计算结构体的内存大小时,出现了一个新的名词——对齐
字节对齐
1. 什么是字节对齐
简单来说,字节对齐就是数据要按照一定的规则合理的存放在内存地址中,而不是顺序地一个接一个存放。
2. 为什么要求对齐
为什么会有对齐这个概念呢,这要从CPU存取数据说起,主要是两个原因。
第一个是硬件原因,C语言和编译器使得我们可以干预程序中数据单元存放的位置,但并不是所有硬件平台都支持访问任意地址上的任意数据,比如说,将一个奇数的地址转换成一个整型指针,这个指针是不可用的并且会报错。
第二个是性能要求,大量的数据结构(堆栈)都要求数据应当尽可能的对齐。比如32位的Intel处理器通过总线访问(包括读和写)内存数据。每个总线周期从偶地址开始访问32位内存数据,内存数据以字节为单位存放。如果一个32位的数据没有存放在4字节整除的内存地址处,那么处理器就需要2个总线周期对其进行访问,效率下降很多。位数的变化和不对齐会导致CPU在存取数据时会耗费更多的总线周期,这显然影响到我们程序运行的效率。
综合以上原因,在现代的处理器上(X86,ARM),C语言的每种类型,除了字符型以外,都有对齐要求;字符可以起始于任何字节地址,但是2字节的短整型short必须起始于一个偶数地址,4字节整型int或者浮点型float必须起始于被4整除的地址,以及8字节长整型long或者双精度浮点型dpuble必须起始于被8整除的地址。带符号与不带符号之间没有差别。这种要求也被称为类型的“自对齐”
3. 对齐的规则
我们先看一个简单的例子,(32位,X86,GCC编译器)
struct s1
{
int a; //4
char b; //1
int c; //4
} t1;
cout<<sizeof(t1)<<endl;
一般来看结构体 t1 中有3个数据成员,两个int 一个char,简单相加的话size应当是9 bytes,但实际的结果却是12 bytes,这当然让人困惑
在介绍规则之前,首先引入一个概念——偏移量。在一个结构体中,一个数据成员相对于结构体的偏移量是成员的地址和结构体变量地址的差。对t1中的第一个int a来说,它的地址就是结构体的首地址,偏移量为0;第二个char b的偏移量是第一个int a的偏移量加上a本身的大小,也就是0+4=4
现在介绍规则
- 所有数据成员的偏移量必须是自身长度的整数倍,若不满足编译器会在成员之间加填充字节(0是所有长度的整数倍)
- 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节
注解:
- 第一条规则实际上是让所有的数据成员都放在对齐的地址上,第二条规则是要求结构体本身也要对齐完整,这样创建结构体数组时也能保证结构体彼此对齐。
- 第二条规则说的是最宽基本类型,而不是像数组这样的构造类型。也就是说char a[8],这样的数组首地址可以放在奇数地址上,因为char类型的长度是1。
我们现在可以解释例子中的困惑了
struct s1
{
int a; //4
char b; //1
char padding[3];
int c; //4
};
a的偏移量是0,长度为4,没有问题;
b的偏移量为4,长度是1,偏移量4是长度1的整数倍,没有问题;
c的偏移量是5,长度是4,偏移量5不是长度4的整数倍,这里出现了问题。编译器为了对齐会在b与c中间填充3个字节char padding[3] ,使c的偏移量是8保证了c的对齐。如此计算下来结构体的总长度就是4+4+4=12 bytes
再看一个例子
struct s2
{
char a; //1+3
long b; //4
char c[7]; //7+1
};
long b是4字节而偏移量是1,所以要在a b之间填充3个字节使b对齐。
而数组c的基本类型长度是1,偏移量是8,没有问题顺序排列下来。
最后数组总长度是4+4+7=15 bytes,这并不是最长基础类型长度long的整数倍,所以要在最后填充一个字节,使总长度为16 bytes(16%4==0)
我们还可以测试更多的例子
struct s3
{
char a; //1+1
short b; //2
char c; //1+3
int d; //4
char e[3]; //3+1
} t3;
struct s4
{
char a[3]; //3
char b[3]; //3
} t4;
t3的长度是16,t4的长度是6
对于结构体中的指针也是相同的计算规则
struct s5
{
int a; //4
char *b; //4
int c; //4
char d[3];//3+1
} t5;
//t5的长度为16
如果换成64位系统,结果也是大同小异
struct foo1 {
char *p; // 8
char c; // 1
char padding[7]; // 7
long x; // 8
};
相信能细心看到这里的人已经能够准确计算一般情况下的结构体长度了,我们可以开始一些更进阶的操作
进阶1——改变结构体成员定义的顺序以节省空间
struct s6
{
char c;
int x;
char p;
} t6;
//t6的长度为12
如果我们变动一下声明的顺序,我们就可以节省一些空间
struct s6
{
int x;
char c;
char p;
} t6;
//t6的长度为8
这几个字节的节省对于结构体来说还是相当可观的
进阶2——嵌套结构体的长度计算
嵌套的结构体长度较为复杂,我们先来看一个例子
struct s1
{
int a; //4
struct
{
int b; //4
char c; //1+3
} s2;
char d[5]; //5+3
} t1;
cout<<sizeof(t1)<<endl;
cout<<sizeof(t1.s2)<<endl;
对于 t1.s2.c 成员来说,它在 s2 中要根据我们之前使用的第二条规则填充上3个字节,这是满足内部结构体规则。对于 s1.d 来说,也要填充上3个字节。
再来看一个外部影响的例子
struct s2
{
char a; //1+3
struct
{
short b; //2+2
long c; //4
} d;
long long e; //8+4
} t2;
cout<<sizeof(t2)<<endl;
cout<<sizeof(t2.d)<<endl;
先看内部结构体d,不难看出d的长度是8,那我们再来思考a后需要加多少个填充字节呢?
这里就存在一个很重要的概念,对于a而言内部结构体是展开的,决定其填充字节数的是c的长度。
也就是说
内部结构体中基本类型最长的长度影响了外部成员的填充字节,而不是整个结构体的长度
所以a后填充3个字节使d中的b和c都对齐,最后在e后填充4个字节使整个结构体长度是最长基本类型长度的倍数(24=8*3)
再看一个例子加深理解
struct s4 {
char a[7]; //7+1
struct s_inner {
char *p; //4
short x; //2+2
} inner;
} t4;
cout<<sizeof(t4)<<endl;
cout<<sizeof(t4.inner)<<endl;
inner的长度为8,t4的长度为16;a 的长度是7已经超过了inner中最长的基本类型长度4,但为了使inner对齐还是要填充1个字节
我们可以看出结构体的嵌套可能造成大量无效的填充字节
比如说下面这个结构
struct s4 {
char a; //1+3
struct s_inner {
char *p; //4
short x; //2+2
} inner;
} t4;
本身长度为12 bytes,就有5 bytes是无效的,空间利用率只有58.3%
进阶3——通过排序减少填充字节数
实际上这是对进阶1 的详细介绍
struct s4 {
char c; //1+3
struct s4 *p; //4
short x; //2+2
} t4;
cout<<sizeof(t4)<<endl;
struct s5 {
struct s5 *p; //4
short x; //2
char a; //1+1
} t5;
cout<<sizeof(t5)<<endl;
对于这个简单的链表结点结构体来说,t4 的长度为12,而 t5 的长度为8。就空间利用率而言,t5达到了87.5% 而 t4 只有 58.3%
现在应该发现了,虽然填充字节无法避免,但是我们可以按照基本类型长度递减的顺序排列我们的数据成员,就可以有效的减少填充字节数,甚至只出现尾部填充字节
编译器
最后来谈一谈编译器,通常我们写程序是不会改变编译器默认的对齐规则的,但是我们确实可以更改它
#pragma pack(1) //指示编译器将默认的对齐长度设置为1,
#pragma pack() //取消自定义对齐长度
看一个例子
struct s4 {
char c; //1+3
struct s4 *p; //4
short x; //2+2
} t4;
cout<<sizeof(t4)<<endl;
//正常编译情况下t4的长度为12
添加上述指令
#include <iostream>
using namespace std;
#pragma pack(1)
int main()
{
struct s4 {
char c; //1+3
struct s4 *p; //4
short x; //2+2
} t4;
cout<<sizeof(t4)<<endl;
//t4的长度为7
return 0;
}
虽然看上去填充字节被消除了,但实际上它严重降低了CPU的效率,让我们的程序运行的更慢,开销也会更大
GCC还提供了一份特别的语法帮助
__attribute((aligned (n))): 让所作用的结构成员对齐在n字节自然边界上。如果结构体中有成员的长度大于n,则按照最大成员的长度来对齐。
__attribute__ ((packed)): 取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐
我想不是特殊的需求,大家是不会用到这些的
参考资料
C语言字节对齐问题详解
失落的C语言结构体封装艺术