c语言 - 结构体、位域、共用体、枚举、内存对齐

c语言 - 结构体、位域、共用体、枚举、内存对齐

结构体

数组可以存放一组相同数据类型的集合,但是如果一组数据类型不同的集合如何存放呢?那就是结构体(struct)

结构体是一种集合,它里面包含了多个变量或数组,它们的类型可以相同也可以不同,这样的变量或数组都称为结构体的成员(member)

Student 为结构体名,它包含了 3 个成员:name、age、score,结构体成员的定义和变量的定义没什么不同,只是不能初始化,注意花括号后面的分号,不能省略,因为这是一个完整的语句!

结构体也是一种数据类型,是我们自定义的一种数据类型,它可以包含多个其他类型的数据

像 int、char、float 等 C 语言本身提供的数据类型,我们称之为基本数据类型(基本类型),不能再拆解;
而结构体可以包含多个基本数据类型的数据,也可以包含其它的结构体,我们称之为构造数据类型(复合类型)。

注意,对于基本类型,我们用 int、short、char 等 C 语言本身提供的标识符表示,而对于我们自定义的结构体数据类型,以上面的为例子,我们用struct Student来描述这种构造类型。

它们都是描述数据类型的标识符,没有区别,只是有的是 C 语言自带的,有的是我们自定义的。

还有一点要注意:
上面的代码只是定义了一个新的数据类型,它本身不占用内存,只有用它来定义变量时,才会给变量分配内存!就如同 int 一样,int 本身是不占用空间的,只有执行 int i; 才会分配 4 个字节的内存给变量 i。

下面我们来用我们自定义的类型来定义变量:
struct Student stu1, stu2;

也可以在定义结构体的同时定义变量:

也可以省略结构体名,相当于一个匿名的结构体,如果没有结构体名,在之后的代码中就不能再用它来定义变量了,除了在定义结构体的同时定义的变量

理论上讲,结构体中的成员在内存中是连续的,但是实际上在编译器的具体实现中,各个成员之间可能会存在缝隙,这是因为C语言内存对齐的原因

什么是内存对齐,为什么要对齐?

  • 现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定变量的时候经常在特定的内存地址访问,这就需要各类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
  • 对齐的作用和原因:各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台的要求对数据存放进行对齐,会在存取效率上带来损失。
    比如有些平台每次读都是从偶地址开始,如果一个 int 型(假设为32位)如果存放在偶地址开始的地方,那么一个读周期就可以读出,而如果存放在奇地址开始的地方,就可能会需要 2 个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该 int 数据。显然在读取效率上下降很多,这也是空间和时间的博弈。

“内存对齐”应该是编译器的“管辖范围”。
编译器为程序中的每个“数据单元”安排在适当的位置上。
但是C语言的一个特点就是太灵活,太强大,它允许你干预“内存对齐”

对齐规则
每个特定平台上的编译器都有自己默认的“对齐系数”,我们可以通过预处理指令#pragma pack(n), n=1, 2, 4, 8, 16...来改变这一系数,这个 n 就是对齐系数

  • 数据成员对齐规则:结构(struct)联合(union)的数据成员,第一个数据成员放在 offset 为 0 的地方,以后的每个数据成员的对齐按照#pragma pack(n)指定的 n 值和该数据成员本身的长度 len = sizeof(type) 中,较小的那个进行,如果没有显示指定n值,则以len为准,进行对齐
  • 结构/联合整体对齐规则:在数据成员对齐完成之后,结构/联合本身也要对齐,对齐按照#pragma pack(n)指定的n值和该结构/联合最大数据成员长度max_len_of_members中,较小的那个进行,如果没有显示指定n值,则以max_len_of_members为准,进行对齐
  • 结合1、2可推断:当n值均超过(或等于)所有数据成员的长度时,这个n值的大小将不产生任何效果

内存对齐的例子

你肯定会想,占用的大小为 sizeof(char) + sizeof(double) + sizeof(int) = 1 + 8 + 4 = 13 字节,然而:

我们按照上面的对齐规则来分析一下:
首先是成员a,类型为char,长度为1,放在偏移量为 0 的地方,然后偏移量变为了 1
然后是成员b,类型为double,长度为8,要放在偏移量为 8 的整数倍的地方,所以就放在 8 上,然后偏移量变为了 8 + 8 = 16
最后是成员c,类型为int,长度为4,要是 4 的整数倍,16 刚好是它的倍数,偏移量变为了 16 + 4 = 20
最后,整个结构体也要对齐,成员中最大的长度为 8,而要它的整数倍,那就是 24 了,所以整个结构体的长度就是 24 个字节

再来看定义了#pragma pack(n)的情况:

根据对齐规则:
对于成员a,对齐数为 1,因为 1 小于 4,放在偏移量为 0 的位置上,然后长度变为 1
对于成员b,对齐数为 4,因为 4 小于 8,放在偏移量为 4 的位置上,长度变为 4 + 8 = 12
对于成员c,对齐数为 4,两个数相等,放在偏移量为 12 的位置上,长度变为 12 + 4 = 16
最后是结构体,最大的成员长度 8,大于 4,所以取 4,刚好整除,所以就是 16 字节

成员的获取与赋值

除了可以对成员一一赋值,也可以在定义的时候进行整体赋值,这和数组非常相似,注意,只能在定义的时候进行整体赋值,其他时候只能一一赋值!数组也是一样的道理

再来看看下面这个例子;

好像没什么问题,对吧,我们再来改一下,加了个循环:

段错误?为什么?
这是一个很多初学者都会遇到的一个问题,包括我自己,我们来一步步分析:

先来分析第一个例子:

很奇怪,为什么我们第一个例子,在运行的时候并没有发生任何异常,因为恰好它指向的内存是该指针有权限(读和写)的一块内存
如果运气不好,指向了一块只读的内存比如常量区,或者指向了代码区,这是会致命的!

为了看到效果,我们在外边加了一个 for 循环,所以问题一下子就出来了

那如何解决这个问题?那就要在使用这个指针之前,为其分配内存空间,让它指向一块有意义的内存

结构体数组

所谓结构体数组,就是数组的每个元素都是一个结构体
定义结构体数组:

使用也很简单:
int myAge = stus[1].age

结构体指针

但是每次都用*p取数据,太麻烦了,我们可以直接用->来通过指针直接获取结构体的成员,这也是->在c语言中唯一的用途

结构体数组指针的应用

结构体指针作为函数参数传递
结构体变量名代表的是整个集合本身,作为函数参数时传递的整个集合,也就是所有成员,而不是像数组一样被编译器转换成一个指针
如果结构体成员较多,尤其是成员为数组时,传送的时间和空间开销会很大,影响程序的运行效率
所以最好的办法就是使用结构体指针,这时由实参传向形参的只是一个地址,非常快速

如:计算全班总成绩、平均成绩、成绩低于140的人数

枚举 Enum

在实际编程中,有些数据的取值往往是有限的,只能是非常少量的整数,并且最好为每个值都取一个名字,以方便在后续代码中使用,比如一个星期只有七天,一年只有十二个月,一个班每周有六门课程等

以一周7天为例,我们可以用#define来给每天指定一个名字:

#define命令虽然能解决问题,但也带来了不小的副作用,导致宏名过多,代码松散,看起来总有点不舒服
C语言提供了一种枚举(Enum)类型,能够列出所有可能的取值,并给它们取一个名字

定义枚举类型:enum NAME{var1, var2, var3...};

例如:列出一个星期有几天:enum week{ Mon, Tues, Wed, Thurs, Fri, Sat, Sun };
可以看到,我们仅仅给出了名字,却没有给出名字对应的值,这是因为枚举值默认从 0 开始,往后逐个加 1 (递增)
也就是说,week 中的 Mon、Tues …… Sun 对应的值分别为 0、1 …… 6

我们也可以给每个名字指定一个值:enum week{ Mon = 1, Tues = 2, Wed = 3, Thurs = 4, Fri = 5, Sat = 6, Sun = 7 };
更为简单的方法是只给第一个名字指定值:enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun };
这两种方式都是等效的,推荐使用后者

枚举是一种类型,通过它可以定义枚举变量:enum week a, b, c;

也可以在定义枚举类型的同时定义变量:enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun } a, b, c;
然后就可以把列表中的值赋给变量:a = Mon, b = Wed, c = Fri;

判断用户输入的是星期几:

注意,枚举列表中的 Mon、Tues、Wed 标识符的作用范围是全局的(严格来说是main()函数内部),不能再定义与他们相同名字的变量
Mon、Tues、Wed 等都是常量,不能对它们赋值,只能将它们的值赋给其他的变量

枚举和宏其实非常类似:宏在预处理阶段将名字替换成对应的值,枚举在编译阶段将名字替换成对应的值。我们可以将枚举理解为编译阶段的宏

对于上面的代码,在编译的某个时刻会变成类似下面的样子:

Mon、Tues、Wed 这些名字都被替换成了对应的数字
这意味着,Mon、Tues、Wed 等都不是变量,它们不占用数据区(常量区、全局数据区、栈区和堆区)的内存,而是直接被编译到命令里面,放到代码区,所以不能用 & 取得它们的地址,这就是枚举的本质

case 关键字后面必须是一个整数,或者是结果为整数的表达式,但不能包含任何变量,正是由于 Mon、Tues、Wed 这些名字最终会被替换成一个整数,所以它们才能放在 case 后面

枚举类型变量需要的是一整数,它的长度应该和 int 相同:

共用体

还有一种和结构体类似的构造类型,叫做共用体(union),也叫做联合、联合体

区别在于:结构体的各个成员会占用不同的内存,互相之间没有影响;而共用体的所有成员占用同一段内存,修改一个成员会影响其余所有成员

结构体占用的内存大于等于所有成员占用的内存的总和(成员之间可能会存在缝隙)
共用体占用的内存等于最长的成员占用的内存
共用体使用了内存覆盖技术,同一时刻只能保存一个成员的值,如果对新的成员赋值,就会把原来成员的值覆盖掉

共用体也是一种自定义类型,也可以用来创建变量

联合体 Data 中,最长的成员为double: 8字节,所以成员 c、i、f 共用这 8 个字节的内存,在同一个时刻,只能保存一个值

看下面这个例子:

因为赋的值超过了 ch、sh 所能容纳的大小,所以高位会被截去,gcc也会警告,我们先不管
对于16进制数0x120x1占用4个bit,0x2占用4个bit,所以两个16进制数的字符就是一个字节长
从这个例子中可以知道,我这台电脑是小端字节序

  • 小端字节序:是指将数据的低位(如 0x1234 的 0x34)存放在内存的低地址,而数据的高位(如 0x1234 的 0x12)存放在内存的高地址上
  • 大端字节序:是指将数据的高位(如 0x1234 的 0x12)存放在内存的低地址,而数据的低位(如 0x1234 的 0x34)存放在内存的高地址上
    一般而言:x86架构的cpu是小端模式,51单片机是大端模式,很多arm、dsp也是小端模式(部分arm可以由硬件来选择大小端)

共用体一般在单片机中应用较多,对于pc机,经常使用到的一个实例是:
现有一张关于学生信息和教师信息的表格,学生信息包括姓名、编号、性别、职业、分数,教师的信息包括姓名、编号、性别、职业、教学科目
f 和 m 分别表示女性和男性,s 表示学生,t 表示教师
可以看出,学生和教师所包含的数据是不同的。现在要求把这些信息放在同一个表格中,并设计程序输入人员信息然后输出

如果把每个人的信息都看作一个结构体变量的话,那么教师和学生的前 4 个成员变量是一样的,第 5 个成员变量可能是 score 或者 course。
当第 4 个成员变量的值是 s 的时候,第 5 个成员变量就是 score;当第 4 个成员变量的值是 t 的时候,第 5 个成员变量就是 course。

经过上面的分析,我们可以设计一个包含共用体的结构体,请看下面的代码:

位域

有些数据在存储时并不需要占用一个完整的字节,只需要占用一个或几个二进制位即可。
例如开关只有通电和断电两种状态,用 0 和 1 表示足以,也就是用一个二进位。
正是基于这种考虑,C语言又提供了一种叫做位域的数据结构

在结构体定义时,我们可以指定某个成员变量所占用的二进制位数(Bit),这就是位域 请看下面的例子:

成员a占用4个字节,成员b占用4个bit,成员c占用1个bit
因为占用的内存有限,所以数值稍大一些就会发生溢出:

C语言标准规定,位域的宽度不能超过它所依附的数据类型的长度
通俗地讲,成员变量都是有类型的,这个类型限制了成员变量的最大长度,后面的数字不能超过这个长度

我们可以这样认为,位域技术就是在成员变量所占用的内存中选出一部分位宽来存储数据

C语言标准还规定,只有有限的几种数据类型可以用于位域。
在 ANSI C 中,这几种数据类型是 int、signed int 和 unsigned int(int 默认就是 signed int);到了 C99,_Bool 也被支持了

但编译器在具体实现时都进行了扩展,额外支持了 char、signed char、unsigned char 以及 enum 类型,所以上面的代码虽然不符合C语言标准,但它依然能够被编译器支持

C语言标准并没有规定位域的具体存储方式,不同的编译器有不同的实现,但它们都尽量压缩存储空间

位域的对齐规则
1) 当相邻成员的类型相同
如果它们的位宽之和小于类型的 sizeof 大小,那么后面的成员紧邻前一个成员存储,直到不能容纳为止
如果它们的位宽之和大于类型的 sizeof 大小,那么后面的成员将从新的存储单元开始,其偏移量为类型大小的整数倍

2) 当相邻成员的类型不同
不同的编译器有不同的实现方案,GCC 会压缩存储,而 VC/VS 不会

3) 如果成员之间穿插着非位域成员,那么不会进行压缩

通过上面的分析,我们发现位域成员往往不占用完整的字节,有时候也不处于字节的开头位置,因此使用 & 获取位域成员的地址是没有意义的
C语言也禁止这样做,地址是字节(Byte)的编号,而不是位(Bit)的编号

无名位域
位域成员可以没有名称,只给出数据类型和位宽,无名位域一般用来作填充或者调整成员位置,因为没有名称,无名位域不能使用

typedef

C语言允许为一个数据类型起一个新的别名,就像给人起“绰号”一样
起别名的目的不是为了提高程序运行效率,而是为了编码方便
例如有一个结构体的名字是 Student,要想定义一个结构体变量就得这样写:
struct Student stu;
前面的struct看起来好多余,但是不加上又会报错
我们可以用 typedef 给来给它取个名字 Student:
typedef struct Student Student;

怎么理解typedef struct Student Student;
首先,typedef可以看作是一个修饰符,是一个关键字,抛开它,剩下struct Student Student;
这不就是定义一个结构体变量的写法嘛,后面那个Student可以看作是定义的变量名,前面的struct Student是数据类型
只不过在typedef的修饰下,这个Student有了新的意义,它表示一种数据类型,即struct Student这个结构体类型

同样可以用 typedef 给数组、指针、指针数组、函数指针、二维数组指针、二级指针等定义别名:
typedef int int_array[20];;数组int [20]
typedef int *int_ptr;:指针int *,(可指向一个整数或一个整型数组)
typedef int *ptr_array[20];:指针数组int * [20]
typedef int (*func_ptr)(int, int);:函数指针,指向原型为int func(int, int);的函数
typedef int (*array_ptr)[20];:二维数组指针,指向类型为int [20]的数组
typedef int **ptr_ptr;:二级指针

typedef#define的区别
typedef 在表现上有时候类似于 #define,但它和宏替换之间存在一个关键性的区别
正确思考这个问题的方法就是把 typedef 看成一种彻底的“封装”类型,声明之后不能再往里面增加别的东西

比如:typedef long long int llong;之后不能出现类似unsigned llong var;但是宏却可以

在连续定义几个变量的时候,typedef 能够保证定义的所有变量均为同一类型,而 #define 则无法保证

因为 #define仅仅是宏展开,字符替换而已,而typedef是一种封装类型

const修饰符

有时候我们希望定义这样一种变量,它的值不能被改变,在整个作用域中都保持固定
例如,用一个变量来表示班级的最大人数,或者表示缓冲区的大小
为了满足这一要求,可以使用const关键字对变量加以限定
const int MAX = 100;
这样 MAX 的值就不能更改了,在后续的代码中,如果尝试更改它的内容,编译器会报错
我们经常将const变量称为常量,定义const常量的格式为:
const type name = value;
或者type const name = value;
推荐使用前者

由于常量一旦被创建后其值就不能再改变,所以常量必须在定义的同时赋值(初始化),后面的任何赋值行为都将引发错误

const和指针一起用时

  • const int *ptr:表示指针指向的数据是只读的,但是指针本身可以改变指向
  • int const *ptr:同上
  • int *const ptr:表示指针本身是只读的,但是指针指向的数据是可以读写的

const和函数形参
在C语言中,单独定义 const 变量没有明显的优势,完全可以使用#define命令代替
const 通常用在函数形参中,如果形参是一个指针,为了防止在函数内部修改指针指向的数据,就可以用 const 来限制

在C语言标准库中,有很多函数的形参都被 const 限制了

我们自己在定义函数时也可以使用 const 对形参加以限制,例如查找字符串中某个字符出现的次数:

根据 strnchr() 的功能可以推断,函数内部要对字符串 str 进行遍历,不应该有修改的动作,用 const 加以限制,不但可以防止由于程序员误操作引起的字符串修改,还可以给用户一个提示,函数不会修改你提供的字符串,请你放心

const 和非 const 类型转换
当一个指针变量 str1 被 const 限制时,并且类似const char *str1这种形式,说明指针指向的数据不能被修改
如果将 str1 赋值给另外一个未被 const 修饰的指针变量 str2,就有可能发生危险
因为通过 str1 不能修改数据,而赋值后通过 str2 能够修改数据了,意义发生了转变,所以编译器不提倡这种行为,会给出错误或警告

也就是说,const char *char *是不同的类型,不能将const char *类型的数据赋值给char *类型的变量
但反过来是可以的,编译器允许将char *类型的数据赋值给const char *类型的变量

这种限制很容易理解,char *指向的数据有读取和写入权限,而const char *指向的数据只有读取权限,降低数据的权限不会带来任何问题,但提升数据的权限就有可能发生危险

不过,我们依旧可以通过指针修改 const 变量的值,因为 const 仅仅是写给编译器看的,只要能够获得它的地址就能修改: