C语言类型的本质(三):sizeof 问题

首先强调一点,sizeof是C语言的关键字和操作符,不是一个函数,它一般用来计算目标空间占用为多少个字节,目标可以是类型或者变量,当目标是类型的时候:

code c
sizeof(int);  // 4
// 实际上编译器获取的是int的空间占用属性
int->空间占用;  // 4

当目标是变量时:

code c
int x = 5;
sizeof(x);  // 4
// 等价于
x  = {
    空间占用: 4,
    存储格式: int存储格式,
    读取格式: int读取格式,
    存储空间指针: 指向第一步分配好的空间,
}
x->空间占用

上文提到过,变量的属性都是继承自格式的属性,所以sizeof(x)sizeof(int)一定是相等的。

sizeof在使用中有很多陷阱,其中最混乱的就是涉及到指针时的结果,搞清这个问题只要记住一句话:sizeof计算时不会溯源,溯源的意思是根据目标指针寻找指向的内存,通过下面几个例子说明:

1. 普通变量
code c
char a = 'c';
sizeof(a);  // 1
seizof(&a); // 8

a的格式是char&a的格式是char *,指针的含义通俗来讲就是一个存储地址,任何类型的存储地址格式都是一样的,所以任意类型的指针大小都是一样的,这一点很重要,正是因为指针类型大小的高度统一,才为后来的类型转换提供了无限的可能

2. 结构体
code c
typedef struct {
   char a;
   char b;
}Test;
Test test;
Test * p = (Test *)malloc(sizeof(Test));sizeof(Test);  // 2sizeof(test);  // 2sizeof(p);  // 8sizeof(*p);  // 2sizeof(&p); // 8

①和②同普通类型一样,就是返回的结构体格式的空间占用,变量test继承自Test格式,二者必然相等。③式正是上文说到过的sizeof计算时不会溯源,变量p指向一块手动开辟的内存,这块内存的大小为2,但是sizeof只会访问p->空间占用,不会去访问计算p指向的内存块,所以得到的是指针类型的空间占用。④式中*p即访问的内存块,所以会返回内存块的大小2。⑤式是一个二重指针,即指针的指针,上文提到过任意类型的指针大小都一样,所以无论多少重指针,它的类型都是指针,大小也就都一样。

除了上面的问题,使用sizeof操作结构体时还有一个无法避免的问题——内存对齐,执行内存对齐主要是为了加快cpu的访问速度,也有一些考虑到平台移植的因素,先来熟悉下内存对齐的规则:

结构((或联合)的数据成员,第一个数据成员放在偏移量为0的地方,以后每个数据成员的offset必须是#pragma pack(n)指定的n和数据成员(如果是复合类型则为成员的子成员长度)的自身长度中较小的那个数的倍数。
数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack(n)指定的数值和结构(或联合)最大数据成员(如果是复合类型则为成员的子成员长度)长度中,比较小的那个进行。

code c
typedef struct {
    int x;
    dpuble y;
    char z[10];
} Example;
sizeof(Example);  // 32

根据第一条规则,第一个数据成员x放在偏移量为0的地方,所以【0 - 3】存放x,数据成员y存放时,起始偏移为4,但是4不是8的倍数,这时候【4 - 7】就被废弃了,y从8开始存放,占据【8 - 15】,数据成员z存放时,起始偏移为16,16是1的倍数可以存放,所以z占据【16 - 25】,此时一共占据了26个字节,根据第二条规则,执行自身对齐,最大的数据成员为y,所以结构体长度应为8的倍数,故【26 - 31】被废弃,总长度32字节。

当程序中使用了#pragma pack(n)时,会对对齐结果产生一定的影响:

code c
#pragma pack(4)
typedef struct {
    int x;
    dpuble y;
    char z[10];
}Example;
sizeof(Example);  // 24

【0 - 3】依旧存放数据成员x,存放数据成员y时,y的长度为8,指定的n为4,所以对齐取4,起始偏移为4,是4的倍数,所以【4 - 9】存放y,存放数据成员z时,起始偏移为10,n > 1,所以【10 - 19】存放z,此时一共占据20字节,根据第二条规则,n < 8,自身对齐时使用4,故【20 - 23】被废弃,总长度24字节。

#pragma pack(1)指定为1时,相当于不执行内存对齐。

3. 数组
code c
char a[10];
char * p1 = a;
char (* p2)[10] = a;sizeof(a);  // 10sizeof(p1);  // 8sizeof(p2);  // 8sizeof(*p1);  // 1sizeof(*p2);  // 10

①式返回数组a的空间占用,回顾前文知道该值为1 * 10,这里再强调一次,变量a的类型为char[10],并非char。②式和③式就是普通的指针类型,所以sizeof返回的都是8,其中p2是一个比较特殊的指针,这里说它特殊只是语法特殊,使用方法和普通指针无异,它们的格式结构如下:

code c
p1 = {
    目标格式: char格式,
    空间占用: 指针->空间占用,  // 8
    存储格式: 指针->存储格式,
    读取格式: 指针->读取格式,
    存储空间指针: 指向编译器为指针类型分配的8个字节(变量a的首地址),
}
p2 = {
    目标格式: char[10]格式,
    空间占用: 指针->空间占用,  // 8
    存储格式: 指针->存储格式,
    读取格式: 指针->读取格式,
    存储空间指针: 指向编译器为指针类型分配的8个字节(变量a的首地址),
}

从上面可以看出来,p1p2除了目标格式不同,其它无异。前面介绍数组时,提到过a == &a[0],取址符返回的就是a->存储空间指针,通过这可以判断p1 == p2,并且二者都是数组a的首地址,这里假设a的首地址为0,当使用*解析p1p2地址时得到的边界地址计算如下:

code c
// p1
首地址 = p1->存储空间指针指向的内存块中存储的值(a的首地址0)
尾地址 = 首地址 + p1->目标格式->空间占用(就是char的空间占用1)
// *p1得到的内存块为【0 - 1】

// p2
首地址 = p2->存储空间指针指向的内存块中存储的值(a的首地址0)
尾地址 = 首地址 + p2->目标格式->空间占用(char[10]的空间占用 1 * 10 = 10)
// *p2得到的内存块为【0 - 10】

所以④式返回1,⑤式返回10。