前言
C语言本身就支持一些类型,比如char,short,float,double,int,long,long,char*,int*,float*,double*等,像这种C语言本身就有的类型被称为“内置类型”;对于较简单的对象,仅仅这几个类型还勉强够用,但要是研究更为复杂的对象,仅仅这几个类型就不够用了,于是就有了自定义类型,自定义类型是以这些内置类型为基础,定义的全新类型。包括结构体(struct),枚举(enum),联合体(union);下面我们将详细讲讲结构体。
入门
结构体是一些数据的集合,这些数据的类型既可以是某些内置类型也可以是别的结构体
结构体的基础定义如下:
struct 结构体名
{
数据类型 成员名1;
数据类型 成员名2;
...
};
创建结构体变量的格式如下:
struct 结构体名 变量名;
也可以将二者结合:
//定义并创建一个基于该结构体类型的变量
struct 结构体名
{
数据类型 成员名1;
数据类型 成员名2;
...
}变量名1;
//定义并创建两个基于该结构体类型的变量
struct 结构体名
{
数据类型 成员名1;
数据类型 成员名2;
...
}变量名1,变量名2;
...
比如,去定义一个用来描述学生的结构体:
struct student
{
char name[20];//姓名
int age;//年龄
char sex[6];//性别
char id[20];//学号
};
现在有三个编号分别为s1,s2,s3,的学生,就可以这样创建对应的变量(这里s1和s2是全局变量,s3是局部变量):
struct student
{
char name[20];//姓名
int age;//年龄
char sex[6];//性别
char id[20];//学号
}s1,s2;
int main()
{
struct student s3;
return 0;
}
变量创建好了,接下来就是初始化了,有顺序和乱序两种:
struct student
{
char name[20];//姓名
int age;//年龄
char sex[6];//性别
char id[20];//学号
};
int main()
{
struct student s1 = {"lihua", 16, "man", "202331400786"};//顺序
struct student s2 = {.age = 19, .name = "xiaonan", .id = "202134934706", .sex = "woman"};//乱序
struct student s3;//不初始化
return 0;
}
甚至可以结构体定义,变量创建初始化一块写:
struct teacher
{
char name[20];
int age;//年龄
char sex[6];
char id[20];
}t = { "liangjin", 32, "woman", "201902331647" };
如果结构体的成员中有其他结构体,这样初始化(就是把整个大括号写进去):
struct student
{
char name[20];//姓名
int age;//年龄
char sex[6];//性别
char id[20];//学号
};
struct teacher
{
char name[20];
int age;//年龄
char sex[6];
char id[20];
};
struct people
{
int number;
struct teacher t;
struct student s;
};
int main()
{
//t = { "liangjin", 32, "woman", "201902331647" }
// s1 = { "lihua", 16, "man", "202331400786" }
struct people p = {2, { "liangjin", 32, "woman", "201902331647" } , { "lihua", 16, "man", "202331400786" } };
return 0;
}
想要访问结构体中的成员,有两种方法,分别是直接访问和间接访问
前面初始化用的".“(点操作符)就是直接访问(嵌套结构体多用几个点);
如果有结构体的指针,考虑到解引用号”*“和点操作符”.“一起用太麻烦了,就可以使用”->",这是间接访问;
#include<stdio.h>
struct student
{
char name[20];//姓名
int age;//年龄
char sex[6];//性别
char id[20];//学号
};
typedef struct teacher
{
char name[20];
int age;//年龄
char sex[6];
char id[20];
}teacher;
struct people
{
int number;
struct teacher t;
struct student s;
};
typedef struct
{
char mame[20];
char author[20];
float price;
}b;
int main()
{
//t = { "liangjin", 32, "woman", "201902331647" }
// s1 = { "lihua", 16, "man", "202331400786" }
struct people p = { 2, { "liangjin", 32, "woman", "201902331647" } , { "lihua", 16, "man", "202331400786" } };
struct student s = { .age = 19, .name = "xiaonan", .id = "202134934706", .sex = "woman" };
printf("%s %s %d %s\n", s.sex, s.name, s.age, s.id);
printf("%d %s %s\n", p.number, p.s.name, p.t.name);
printf("%d\n", (*(&p)).number);
printf("%d\n", (&p)->number);
return 0;
}
进阶
定义结构体时其实也可以没有结构体名,这种定义方式被称为“匿名定义”,由于没有名字(或许用“代称”这个词更形象),只有一种创建对应变量的形式(结构体定义,变量创建写一块),正常情况下只能用一次(那种不正常的情况其实是换了一种形式命名结构体),之后再想用,因为没有名字,所以没有东西来指代这个结构体,这个结构体也就无法使用了。
//初始化没有要求,依据上面说的方式初始化就行了,上面的方法都能用
struct
{
char mame[20];
char author[20];
float price;
}book;
匿名定义还易出现下面的错误
struct
{
char mame[20];
char author[20];
float price;
}book;
struct
{
char mame[20];
char author[20];
float price;
}*p;//创建结构体指针
int main()
{
p = &book;
return 0;
}
因为没有名字,所以就算这两个匿名结构体里面长得一模一样,编译器还是会把它们看成不同的结构体,所以,编译器会报警,说它们类型不一样,无法把地址赋过去。
不正常的情况就是用关键字typedef对这个匿名结构体重命名(不还是加了一个名字了吗):
typedef struct
{
char mame[20];
char author[20];
float price;
}b;
int main()
{
b book;
return 0;
}
既然匿名结构体可以用typedef重命名,非匿名结构体更可以了,而且这样的话创建变量时就不用再写struct了(好像很多教科书都喜欢这样写)
typedef struct teacher
{
char name[20];
int age;//年龄
char sex[5];
char id[20];
}teacher;
typedef struct
{
char mame[20];
char author[20];
float price;
}b;
int main()
{
b book;
teacher t;
return 0;
}
题外话
我学嵌入式的时候发现大家都喜欢用#define进行类型重命名,#define与其说是类型重命名,倒不如说是文本替换;如果用它来进行类型重命名有时候容易出问题,如果是非嵌入式领域的类型重命名,最好用typedef,至于嵌入式吗,好像#define别的用处还挺多的,那就随意了。
会出什么问题呢?比如下面这道题
#define INT_PTR int*
typedef int*int_ptr;
INT_PTR a,b;
int_ptr c,d;
问:哪个变量不是指针
答案是b,预处理的#define是查找替换,所以替换过后的语句是“int*a,b;”,其中b只是一个int变量,如果要让b也是指针,必须写成“int *a, *b;”。而typedef没有这个问题,所以c、d都是指针。
还有下面这道题(这是后来补的,如果看不懂可以先跳过):
题目内容:
有如下宏定义和结构定义
#define MAX_SIZE A+B
struct _Record_Struct
{
unsigned char Env_Alarm_ID : 4;
unsigned char Para1 : 2;
unsigned char state;
unsigned char avail : 1;
}*Env_Alarm_Record;
struct _Record_Struct *pointer = (struct _Record_Struct*)malloc(sizeof(struct _Record_Struct) * MAX_SIZE);
当A=2, B=3时,pointer分配( )个字节的空间。
A.20
B.15
C.11
D.9
答案选D
解析:结构体向最长的char对齐,前两个位段元素一共4+2位,不足8位,合起来占1字节,最后一个单独1字节,一共3字节。另外,#define执行的是查找替换, sizeof(struct _Record_Struct) * MAX_SIZE这个语句其实是3*2+3,结果为9,故选D;我第一次写算出来是15,因为我是按3*(2+3)来做的。
结构体的自引用
之后我们会学数据结构,数据结构指的是数据在内存中的存储和组织的结构
数据结构有多种,包括线性数据结构,树形数据结构,图……,
线性数据结构可以细分为顺序表,链表,栈,队列……
树形数据结构可以细分为二叉树……
其中链表就和结构体的自引用有些关系;
现在假设内存中要存五个数:12345;可以直接一次性开辟五个连续的整型空间把它们存进去,就像数组那样,这种就叫做顺序表(顺序表实质就是数组); 还有一种方法:找五处不连续的空间,把12345分别放进去,光把数值放进去还不行,这五处空间并不连续,如果光放数值,那么找到1之后就不好找2了,找到2之后就不好找3了……所以每个空间除了存放数值外,还必须要再添一位“向导”,这个向导会告诉我们下一个数据到底在哪,这样找到1,就相当于找到2,找到2,就相当于找到3……这种方式就叫做链表。我们把链表中的每处空间称为一个节点,一个节点包括两个部分:数据域和指针域,数据域就是用来存放数值的,指针域就是向导,指引我们下一个数据在哪里。我们就可以用结构体的自引用来实现一个节点。
结构体的自引用?听起来就是结构体自己引用自己,自己包含自己,就像
struct Node
{
int data;
struct Node next;
};
真的是这样吗?
让我们想想,这种定义方式叫做嵌套定义,还有哪些嵌套定义呢?我们之前学过递归,递归的要求是什么?
- 递归存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件。
类比递归发现,这种“结构体自引用”写法连限制条件都没有,更别提接近条件了实际上这种写法会导致结构体变量的大小无穷大,自然会出问题。(看见我前面的那个加粗的“别的”吗?)
前面都说过了是指针域吗,在结构体的自引用中,除了数值,另一个成员应该是结构体的指针而非结构体:
struct Node
{
int data;//数据域
struct Node* next;//指针域
};
结构体自引用的重命名这样写:
typedef struct Node
{
int data;
struct Node* next;
}Node;
//或者这样:
struct Node
{
int data;
struct Node* next;
};
int main()
{
typedef struct Node Node;
return 0;
}
有人这样重命名:
typedef struct Node
{
int data;
Node* next;
}Node;
对吗?不对。重命名是分号后才完成的。分号前不能把struct省略。
另外还要注意,匿名结构体没有自引用。原因还是没有名字,无法指代。
结构体内存对齐
变量都是有大小的,那一个结构体变量的大小是如何计算的呢?把里面的成员全加起来吗?
#include<stdio.h>
typedef struct test1
{
char c1;
int i;
char c2;
}test1;
int main()
{
test1 t = { 0 };
printf("%zd\n", sizeof(t));
return 0;
}
嗯,从结果来看应该不是这样。实际上,结构体的大小遵从着一些规则,我们将其称为“内存对齐”。我们先讲对齐规则,再说说它是怎么来的。
对齐规则
- 结构体的第⼀个成员对齐到和结构体变量起始位置偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
- 结构体总大小为最大对齐数(结构体中每个成员变量都有⼀个对齐数,所有对齐数中最大的)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
注:
- 对齐数=编译器默认的⼀个对齐数与该成员变量大小的较小值。
- VS 中默认的值为 8;
- Linux中gcc没有默认对齐数,对齐数就是成员自身的大小;
- 如果成员中存在数组的话,先把数组第一个元素存进去,然后其它元素跟在第一个元素后面连续存就行,数组的对齐数就是元素的对齐数。
现在再来看之前的那个例子:
#include<stdio.h>
typedef struct test1
{
char c1;
int i;
char c2;
}test1;
int main()
{
test1 t = { 0 };
printf("%zd\n", sizeof(t));
return 0;
}
#include<stdio.h>
typedef struct test2
{
char c1;
char c2;
int i;
}test2;
int main()
{
test2 t = { 0 };
printf("%zd\n", sizeof(t));
return 0;
}
#include<stdio.h>
typedef struct test3
{
double d;
char c;
int i;
}test3;
int main()
{
test3 t = { 0 };
printf("%zd\n", sizeof(t));
return 0;
}
#include<stdio.h>
typedef struct test3
{
double d;
char c;
int i;
}test3;
typedef struct test4
{
char c1;
test3 t3;
double d;
}test4;
int main()
{
test4 t = { 0 };
printf("%zd\n", sizeof(t));
return 0;
}
#include<stdio.h>
typedef struct test1
{
char c1;
char c2;
int i[2];
}test1;
int main()
{
test1 t1 = { 0 };
printf("%zd\n", sizeof(t1));
return 0;
}
为什么存在内存对齐
你可以直接看下面重点:
首先我们知道计算机有很多总线,比如根据功能和规范,总线可分为:
数据总线:用于传输实际操作的数据。
地址总线:用于指示数据在内存中的地址位置。
控制总线:用于控制各种设备的操作,发送各类控制信号。
扩展总线:用于主机与外部设备之间的连接,如PCI总线。
局部总线:直接或者通过桥接器与中央处理器相连,比如PCI总线和ISA总线等。
……
其中地址总线和数据总线是CPU和内存联系的纽带,简单来说,他们的使用过程如下:
- 当CPU要读取内存中的数据时,它首先将要访问的内存地址通过地址总线发送给内存控制器。
- 接收到地址后,内存控制器找到对应的存储单元,并将其中的数据通过数据总线发送回CPU。
- 当CPU要向内存写入数据时,它将要写入的内存地址通过地址总线发送给内存控制器,并将要写入的数据通过数据总线发送给内存控制器。
- 内存控制器接收到地址和数据后,将数据写入指定的存储单元。
在整个过程中,地址总线宽度决定了可寻址的内存范围,而数据总线的宽度决定了系统一次可处理的数据量,它们共同影响了系统的性能。
计算机一次可处理的数据量长度被称为“字”,计算机一次只能处理一定数量的数据,这个数量就是计算机的字长度。字长可以因硬件和架构的不同而变化。
以一些常见的计算机硬件为例:
在32位系统中,字长为4字节(32位)。
在64位系统中,字长为8字节(64位)。
上面是铺垫,下面是重点:(好像不看上面的也行)
为了提高CPU调用内存中数据的速度,内存会把自身划分成大小基于某个固定字节数量倍数的小区域,这样当需要某处的数据时,就可以更方便地找到它,如果要打个比方的话,现在我们将整个内存比作一栋楼,每层就相当于我前面说的小区域,每个房间就相当于一个单位字节空间,如果我要找一个位于302号房间的数据,我就可以直接略过1楼2楼,直接从3楼找起,找3楼的第二个房间就行了;如果没有这种对齐策略的话,就相当于这栋楼只有一层,而这一层却有这栋楼的全部房间,这样的话,如果我要找302房间的数据的话,我就要从一楼的第一个房间开始,一个一个房间往后数,直到找到房间302。内存的这种划分策略就被称为内存对齐。
而结构体是内存中的一个缩影,内存中的规则,自然也适用于结构体。现在我们把结构体看成一个小内存,如果结构体中的成员没有内存对齐,就很可能会出现一种状况,这个成员一部分在3层,另一部分在4层,我如果要访问这个数据,就得先爬到3楼,找到3楼的数据之后,再爬到4楼找另一部分,而且之后还要把这两部分数据碎片拼成一个整体,你就说麻不麻烦费不费时吧。
这种原因被称为性能原因,在一众的教科书中,它是这样描述的:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器
需要作两次内存访问;而对齐的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字
节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,
那么就可以用⼀个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可
能被分放在两个8字节内存块中。
不过这并不意味着CPU只能进行对齐的内存访问,实际上,在许多现代硬件平台和系统架构中,CPU可以进行非对齐的内存访问。这基本上意味着CPU可以从任何地址开始读取数据。但非对齐内存访问费时费力,所以CPU主要还是进行对齐的内存访问。不过也确实有些老机子,只能进行对齐的内存访问。
这就是另一个原因,被称为平台原因(移植原因),那些教科书应该是这样写的:
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特
定类型的数据,否则抛出硬件异常。
至于VS为什么把默认对齐数当成8字节,个人认为,可能是由于现在64位计算机是主流。
如果想节省一些空间,可以考虑把对齐数小的集中在一块放:
#include<stdio.h>
typedef struct test1
{
char c1;
int i;
char c2;
}test1;
typedef struct test2
{
char c1;
char c2;
int i;
}test2;
int main()
{
test1 t1 = { 0 };
test2 t2 = { 0 };
printf("t1:%zd\nt2:%zd", sizeof(t1), sizeof(t2));
return 0;
}
修改默认对齐数
当你认为VS的默认对齐数不太好时,可以通过#pragma这个预处理指令修改默认对齐数:
#include<stdio.h>
#pragma pack(1) //设置默认对⻬数为1
typedef struct test1
{
char c1;
int i;
char c2;
}test1;
#pragma pack() //取消设置的对⻬数,还原为默认
int main()
{
test1 t1 = { 0 };
printf("%zd\n", sizeof(t1));
return 0;
}
正常情况下,我们一般把默认对齐数修改为2的次方数,比如1,2,4之类。
结构体传参
不知道你有没有看过我的另一篇文章:函数栈帧的创建与销毁
里面有个思想,就是如果调用某个有参数的函数,那就有个名为压栈的操作,压栈就是把实参临时拷贝一份传给调用的函数,试想一下,结构体本身就有些空间的浪费,如果将结构体变量本体当做实参传入函数的话,那空间的浪费就更甚了,所以,如果调用的函数要用到上一级函数中的结构体的话,我们实际都是把结构体指针作为形参的。
#include<stdio.h>
typedef struct test1
{
char c1;
char c2;
int i[2];
}test1;
void print(test1* p)
{
int j = 0;
printf("%c\n", p->c1);
printf("%c\n", p->c2);
for (; j < 2; j++)
{
printf("%d ", p->i[j]);
}
}
int main()
{
test1 t1 = { 'h','k',{1,2} };
print(&t1);
return 0;
}
位段
结构体实现位段
经过上面的学习,你应该已经明白,结构体的内存对齐是一种拿空间换时间的做法。
怎么在内存对齐的情况下尽可能节省空间呢?除了我们之前说的把对齐数小的成员放一块之外,还有一种究极的空间节省方案,那就是位段。
有些时候,我们要存储的数据根本用不着int甚至是char这么大的空间,只要用几个比特位(“位段”里的位指的就是这个)就行了,比如就1,我用一位也存的下呀,为什么非要用char int。
结构体实现位段的格式:
struct 结构体名
{
数据类型 成员名1:位数;
数据类型 成员名2:位数;
...
};
注意:
- 数据类型最好一样,位数不能超过超过前面数据类型的大小,比如对于char,位数不要超过8。
- 类型是char或者int家族(short,long,long long,unsigned int);C99标准后,也支持其他类型。
- 位段的成员名后边有⼀个冒号和⼀个数字。
#include<stdio.h>
struct S
{
char _a : 3;
char _b : 4;
char _c : 5;
char _d : 4;
};
int main()
{
struct S s = { 0 };
s._a = 10;
s._b = 12;
s._c = 3;
s._d = 4;
printf("%zd",sizeof(s));
return 0;
}
题外话:我这里的"_"是为了强调它们是结构体里的成员,防止结构体成员与其他变量重名。视情况使用。
位段如何开辟内存?
特别注意:以下图片中的数字指的是比特位,而非地址,地址是针对单位字节来说的。同样的,大小端字节序是对大于单位字节序
的数据来说的,这里没有它俩的事。另外,以下说的步骤是逻辑步骤,是用来帮助理解的,而实际是,编译器会一次性创建好这个
结构体的内存。
它会依据前面的数据类型先开辟出一个单位大小的空间,如果前面的数据类型是char,单位大小就是一个字节,8比特位;如果是int家族,单位大小就是int大小,4字节,32比特位,我们的示例是char,所以先创建8比特位。
然后再遵循某种顺序把数据存进去,比如从左向右或者从右向左,这里我们使用的VS 2022,使用的是从右向左存。
_a是三个比特位,存入的是10(二进制八位补码为:0000 1010),存不下,就把低处的三比特位存入了。
然后再看:_b是4比特位,存入的是12(二进制八位补码为:0000 1100),就把低处的四比特位存入了。
随后,_c是5比特位,结果就剩一比特位了,我们是把_b拆成两部分,一部分一比特位,另一部分4比特位,不把这剩下的一比特位浪费掉,还是直接跳过呢?不同的编译器有不同的选择,但VS是直接掉过不用的,在这种情况下,由于剩下一比特位没有被覆写,所以就是0(因为结构体初始化为0),之后会再创建一个单位大小的空间。
_c是5比特位,存入的是3(二进制八位补码:0000 0011),存入低处五比特位。
剩下三比特位,而_d是四比特位,不够,剩下的三比特位跳过,没被覆写都是0,并且再创建一个单位大小空间。
_d是4比特位,存入的是4(二进制八位补码为:0000 0100),依旧存入低处四位。剩下4位,没有被覆写,还是0。
所以,结构体的大小为24比特,即3字节。存储内容为0110 0010 0000 0011 0000 0100,换算成十六进制就是0x62 03 04。
从刚才的这些过程中,你应该已经认识到,由于C语言标准没有对位段做出严格要求,所以在位段的创建过程中存在着许多不确定性,比如在单位空间中,是从左往右还是从右往左存,剩余位数不够了,这部分是应该浪费掉还是用掉,这些问题,不同的编译器厂商都有不同的解决方案,并且有些不确定性还未在刚才的演示过程中体现,比如,在32位和64位机器中,int是4字节的,而在16 位机器中int是2字节的,对于32位和64位机器来说,允许出现int _i:30;但在16位机器中,这样写明显是错的。
这就是我为什么称它为一种究极的空间解决方案,我承认,它确实省空间,但平台移植性很差,尽量不要写位段。
位段初始化
由于位段是以比特位为单位的,而地址是对单位字节来说的,所以位段没有地址这回事,自然不能用取地址符,如果想要初始化(赋值),要这样写:
#include<stdio.h>
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
int main()
{
struct A sa = { 0 };
//这是错误的写法
/*scanf("%d", &sa._b);*/
//正确的⽰范
int b = 0;
scanf("%d", &b);
sa._b = b;
return 0;
}
位段的主要应用场景
下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个比特位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小⼀些,对网络的畅通是有帮助的。