C 语言结构体内存布局问题

作者 boborz 日期 2017年04月25日 阅读次数

引言

C语言结构体内存布局是一个老生常谈的问题,网上也看了一些资料,有些说的比较模糊,有些是错误的。本人借鉴了前人的文章,经过实践,总结了一些规则,如有错误,希望指正,不胜感激。

实际环境

  • 系统环境 macOS Sierra(10.12.4)
  • IDE Xcode(8.3)

概述

影响结构体内存布局有位域#pragma pack预处理宏两个情况,下面分情况说明。

正常情况

结构体字节对齐的细节和具体的编译器实现相关,但一般来说遵循3个准则:

  1. 结构体变量的首地址能够被其最宽基本类型成员的大小(sizeof)所整除。
  2. 结构体每个成员相对结构体首地址的偏移量offset都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节。
  3. 结构体的总大小sizeof为结构体最宽基本成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

下面的demo会为大家解释以上规则:

代码

struct student {
  char name[5];
  double weight;
  int age;
};
struct school {
  short age;
  char name[7];
  struct student lilei;
};
int main(int argc, const char * argv[]) {
  @autoreleasepool {
    // insert code here...
    struct student lilei = {"lilei",112.33,20};
    printf("size of struct student: %lu\n",sizeof(lilei));
    printf("address of student name: %u\n",lilei.name);
    printf("address of student weight: %u\n",&lilei.weight);
    printf("address of student age: %u\n",&lilei.age);
    
    struct school shengli = {70,"shengli",lilei};
    printf("size of struct school: %lu\n",sizeof(shengli));
    printf("address of school age: %u\n",&shengli.age);
    printf("address of school name: %u\n",shengli.name);
    printf("address of school student: %u\n",&shengli.lilei);
  }
  return 0;
}

输出结果

解释规则

  1. 编译器在给结构体开辟空间时,首先找到结构体中最宽的基本数据类型,然后寻找内存地址能被该基本数据类型所整除的位置,做为结构体的首地址。(在本demo中struct school 包含 struct student,所以最宽的基本数据类型为doublesizeof(double)81606416152/8 = 2008020191606416112/8 = 200802014)。
  2. 为结构体的每一个成员开辟空间之前,编译器首先检查预开辟空间首地址相对于结构体首地址的偏移是否是本成员大小的整数倍,若是,则存放本成员,反之,则在本成员和上一个成员之间填充字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节(这也是为什么struct student weight成员的首地址是1606416160而不是1606416157但有很重要的一点要注意,这里的成员为基本数据类型,不包括char类型数组和结构体成员,char类型数组按1字节对齐,结构体成员存储的起始位置要从自身内部最大成员大小的整数倍地址开始存储,比如struct a里有struct b成员,b里有char,int,double等成员,那b存储的起始位置应该从8的整数倍开始。通过struct school成员内存分布可以看出来,school.name的首地址是1606416114,而不是1606416119school.student的首地址是1606416128,能被8整除,不能被24整除)。
  3. 结构体的总大小包括填充字节,最后一个成员出了满足上面两条之外,还必须满足第三条,否则必须在最后填充一定字节以满足要求(这也是为什么struct student占用字节数为24而不是20的原因)。

内存分布

student

school

扩展

细心的朋友可能发现&shengli.lilei(等效于shengli.lilei.name)的数值并不等于lilei.name,也就是说struct school shengli里的成员struct student lileistruct student lilei并不是指向同一块内存空间,是值拷贝开辟的一块新的内存空间,也就是说struct是值类型而不是引用类型数据结构。还有通过内存地址可以发现两个结构体变量的内存空间是在内存栈上连续分配的。

位域

结构体使用位域的主要目的是压缩存储,位域成员不能单独被取sizeof值。C99规定int,unsigned int,bool可以作为位域类型,但编译器几乎都对此做了扩展,允许其它类型存在。结构体中含有位域字段,除了要遵循上面3个准则,还要遵循以下4个规则:

  1. 如果相邻位域字端的类型相同,且位宽之和小于类型的sizeof大小,则后一个字段将紧邻前一个字段存储,直到不能容纳为止。
  2. 如果相邻位域字段的类型相同,但位宽之和大于类型的sizeof大小,则后一个字段将从新的存储单元开始,其偏移量为其类型大小的整数倍。
  3. 如果相邻的位域字段的类型不同,则各编译器的具体实现有差异,VC6采取不压缩方式,Dev-C++采取压缩方式。
  4. 如果位域字段之间穿插着非位域字段,则不进行压缩。

下面的demo会为大家解释以上规则:

代码

typedef struct A {
  char f1:3;
  char f2:4;
  char f3:5;
  char f4:4;
}a;
typedef struct B {
  char  f1:3;
  short f2:13;
}b;
typedef struct C {
  char f1:3;
  char f2;
  char f3:5;
}c;
typedef struct D {
  char f1:3;
  char :0;
  char :4;
  char f3:5;
}d;
typedef struct E {
  int f1:3;
}e;
int main(int argc, const char * argv[]) {
  @autoreleasepool {
    // insert code here... 
    printf("size of struct A: %lu\n",sizeof(a));
    printf("size of struct B: %lu\n",sizeof(b));
    printf("size of struct C: %lu\n",sizeof(c));
    printf("size of struct D: %lu\n",sizeof(d));
    printf("size of struct E: %lu\n",sizeof(e));
  }
  return 0;
}

输出结果

解释规则

  1. struct A中所有位域成员类型都为char,第一个字节只能容纳f1f2f3从下一个字节开始存储,第二个字节不能容纳f4,所以f4也要从下一个字节开始存储,因此sizeof(a)结果为3
  2. struct B中位域成员类型不同,进行了压缩,因此sizeof(b)结果为2(不压缩方式没有进行验证,很抱歉)。
  3. struct C中位域成员之间有非位域类型成员,不进行压缩,因此sizeof(c)结果为3。
  4. struct D中有无名位域成员,char f1:33bitchar :0移到下1个字节(移动单位和具体位域类型有关,short移到下2个字节,int移到下4个字节),char :44bit,然后不能容纳char f3:5,所以要存到下1个字节,因此sizeof(d)结果为3
  5. 可能有人会疑惑,为什么sizeof(e)结果为4,不应该是只占用1个字节么?不要忘了上面提到的准则3

注意事项

  1. 位域的地址不能访问,因此不允许将&运算符用于位域。不能使用指向位域的指针也不能使用位域的数组(数组是种特殊指针)。
  2. 位域不能作为函数的返回结果。
  3. 位域以定义的类型为单位,且位域的长度不能超过所定义类型的长度。例如定义int a:33是不被允许的。
  4. 位域可以不指定位域名,但不能访问无名的位域。无名的位域只用做填充或调整位置,占位大小取决于该类型。例如char:0表示整个位域向后推一个字节,即该无名位域后的下一个位域从下一个字节开始存放,同理short:0int:0分别代表整个位域向后推两个和四个字节。当空位域的长度为具体数值N时(例如 int:2),该变量仅用来占N位。

pragma pack预处理宏

编译器的#pragma pack指令也是用来调整结构体对齐方式的,不同编译器名称和用法略有不同。使用伪指令#pragma pack(n),编译器将按照n个字节对齐,其取值为1、2、4、8、16,默认是8,使用伪指令#pragma pack(),取消自定义字节对齐方式。如果设置#pragma pack(1),就是让结构体没有填充字节,实现空间“无缝存储”,这对跨平台传输数据来说是友好和兼容的。结构体中含有#pragma pack预处理宏,除了要遵循上面3个准则,还要遵循以下2个规则:

  1. 对于结构体成员存放的起始地址的偏移量,如果n大于等于该成员类型所占用的字节数,那么偏移量必须满足默认的对齐方式,如果n小于该成员类型所占用的字节数,那么偏移量为n的倍数,不用满足默认的对齐方式。即是说,结构体成员的偏移量应该取二者的最小值,公式如下:
    offsetof(item) = min(n, sizeof(item))
  2. 对于结构体的总大小,如果n大于所有成员类型所占用的字节数,那么结构的总大小必须为占用空间最大成员占用空间数的倍数,否则必须为n的倍数。

用法

#pragma pack(push)  //packing stack入栈,设置当前对齐方式
#pragma pack(pop)   //packing stack出栈,取消当前对齐方式
#pragma pack(n)     //n=1,2,4,8,16保存当前对齐方式,设置按n字节对齐
#pragma pack()      //等效于pack(pop)
#pragma pack(push,n)//等效于pack(push) + pack(n)

代码

#pragma pack(4)

typedef struct F {
  int f1;
  double f2;
  char f3;
}f;

#pragma pack()
#pragma pack(16)

typedef struct G {
  int f1;
  double f2;
  char f3;
}g;
int main(int argc, const char * argv[]) {
  @autoreleasepool {
    // insert code here...
    printf("size of struct D: %lu\n",sizeof(f));
    printf("size of struct E: %lu\n",sizeof(g));
  }
  return 0;
}

输出结果

解释规则

  1. struct F设置的对齐方式为4min(4, sizeof(int)) = 4,f14个字节,偏移量为0min(4, sizeof(double)) = 4f24个字节,偏移量为4min(4, sizeof(char)) = 1f31个字节,偏移量为12,最后整个结构体满足准则3sizeof(f) = 16
  2. struct G设置的对齐方式为16,比结构体中所有成员类型都要大,相当于没有生效,因此sizeof(f) = 24

总结

位域#pragma pack预处理宏的结构体在遵循3个准则的前提下,有自己的相应规则也要遵守。结构体成员在排列时数据类型要遵循从小到大排列,这样能尽可能的节省空间。

参考链接

http://blog.sina.cn/dpool/blog/s/blog_671d96d00100hhv9.html?vt http://c.biancheng.net/cpp/html/469.html http://hubingforever.blog.163.com/blog/static/17104057920122256134681/

如有任何知识产权、版权问题或理论错误,还请指正。

转载请注明原作者及以上信息。