C语言入门(二十五):自定义类型:结构体

目录

[1. 结构体类型的声明](#1. 结构体类型的声明)

[1.1 结构体回顾](#1.1 结构体回顾)

[1.1.1 结构的声明](#1.1.1 结构的声明)

[1.1.2 结构体变量的创建和初始化](#1.1.2 结构体变量的创建和初始化)

[1.2 结构的特殊声明](#1.2 结构的特殊声明)

[1.3 结构的⾃引⽤](#1.3 结构的⾃引⽤)

2.结构体内存对齐

[2.1 对⻬规则](#2.1 对⻬规则)

[2.2 为什么存在内存对⻬?](#2.2 为什么存在内存对⻬?)

[2.3 修改默认对⻬数](#2.3 修改默认对⻬数)

[3. 结构体传参](#3. 结构体传参)

[4. 结构体实现位段](#4. 结构体实现位段)

[4.1 什么是位段](#4.1 什么是位段)

[4.2 位段的内存分配](#4.2 位段的内存分配)

[4.3 位段的跨平台问题](#4.3 位段的跨平台问题)

[4.4 位段的应⽤](#4.4 位段的应⽤)

[4.5 位段使⽤的注意事项](#4.5 位段使⽤的注意事项)


1. 结构体类型的声明

前⾯我们在学习操作符的时候,已经学习了结构体的知识,这⾥稍微复习⼀下。

1.1 结构体回顾

结构是⼀些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。

1.1.1 结构的声明

结构原型如下:

cpp 复制代码
 struct tag
 {
 member-list;
 }variable-list;

描绘一本书:

注意:最后面的分号不能丢

cpp 复制代码
描绘一本书
struct Book
{
	char name[20];
	char author[20];
	float price;
	char id[13];

};
1.1.2 结构体变量的创建和初始化
cpp 复制代码
//描绘一本书
struct Book
{
	char name[20];
	char author[20];
	float price;
	char id[13];

};

//结构体的创建变量和初始化
int main()
{
	struct Book b1 = { "你好世界","小龙",100.5,"G430481\n" }; //根据结构体的顺序来排布
	struct Book b2 = { .id = "A430481",.author = "大龙",.name = "不好世界",.price = 200.5 }; //随便排序

	printf("%s %s %f %s", b1.name, b1.author, b1.price, b1.id);
	printf("%s %s %f %s", b2.name, b2.author, b2.price, b2.id);


	return 0;
}

输出结果:

1.2 结构的特殊声明

在声明结构的时候,可以不完全的声明。

⽐如:

cpp 复制代码
结构体的特殊声明(匿名结构体)
struct   //这里的名字可以省略
{
	char i;
	int a;
	float b;

}s = {'x',100,100.5}; //写到了这里,此时的s就是变量

int main()
{
	printf("%c %d %lf\n", s.i, s.a, s.b);
	return 0;
}

输出结果:

上⾯的两个结构在声明的时候省略掉了结构体标签(tag)。

那么问题来了?

cpp 复制代码
 
在上⾯代码的基础上,下⾯的代码合法吗?
p = &x;

会警告:

编译器会把上⾯的两个声明当成完全不同的两个类型,所以是⾮法的。 匿名的结构体类型,如果没有对结构体类型重命名的话,基本上只能使⽤⼀次

1.3 结构的⾃引⽤

在结构中包含⼀个类型为该结构本⾝的成员是否可以呢?

⽐如,定义⼀个链表的节点:

cpp 复制代码
 struct Node
{
	int data;
	struct Node next;
};

上述代码正确吗?如果正确,那 **sizeof(struct Node)**是多少?

仔细分析,其实是不⾏的,因为⼀个结构体中再包含⼀个同类型的结构体变量,这样结构体变量的⼤ ⼩就会⽆穷的⼤,是不合理的。

正确的⾃引⽤⽅式:

要加一个***** 号

cpp 复制代码
 struct Node
{
	int data;
	struct Node* next;
};

在结构体⾃引⽤使⽤的过程中,夹杂了tpyedef对匿名结构体类型重命名,也容易引⼊问题,看看下⾯的代码,可⾏吗?

cpp 复制代码
 typedef struct
 {
    int data;
    Node* next;
 }Node;

答案是不⾏的,因为Node是对前⾯的匿名结构体类型的重命名产⽣的,但是在匿名结构体内部提前使 ⽤Node类型来创建成员变量,这是不⾏的

解决⽅案如下:定义结构体不要使⽤匿名结构体了

cpp 复制代码
 typedef struct Node
 {
   int data;
   struct Node* next;
 }Node;

2.结构体内存对齐

我们已经掌握了结构体的基本使⽤了。

现在我们深⼊讨论⼀个问题:计算结构体的⼤⼩。

这也是⼀个特别热⻔的考点:结构体内存对⻬

2.1 对⻬规则

⾸先得掌握结构体的对⻬规则:

  • 结构体的第⼀个成员对⻬到和结构体变量起始位置偏移量为0的地址处
  • 其他成员变量要对⻬到某个数字(对⻬数)的整数倍的地址处。

对⻬数=编译器默认的⼀个对⻬数与该成员变量⼤⼩的较⼩值。

V S 中默认的值为 :8

Linux中gcc没有默认对⻬数,对⻬数就是成员⾃⾝的⼤⼩

  • 结构体总⼤⼩为最⼤对⻬数(结构体中每个成员变量都有⼀个对⻬数,所有对⻬数中最⼤的)的 整数倍。
  • 如果嵌套了结构体的情况,嵌套的结构体成员对⻬到⾃⼰的成员中最⼤对⻬数的整数倍处,结构 体的整体⼤⼩就是所有最⼤对⻬数(含嵌套结构体中成员的对⻬数)的整数倍。

练习1:

cpp 复制代码
struct S
{
	char c1; //原本占1个字节,第一个结构体成员变量对齐起始位置0,
	int i; //原本占4个字节1,对齐后占4个字节,它的存放位置必须是4的倍数,所以从4开始往后存放4个字节
	char c2; //原本占1个字节,对齐后占1个字节,它的存放位置必须是4的倍数,

};
int main()
{
	struct S b1 = { 0 };
	printf("%zd\n", sizeof(b1)); //12

	return 0;
}

输出结果:

解析:

  1. 第一个成员在偏移量0处

  2. 其他成员对齐数 = min(自身大小, 默认对齐数8)

  3. 结构体总大小 = 最大对齐数(成员对齐数的最大值)的整数倍

第一步:char c1

  • 大小:1字节

  • 对齐数 = min(1, 8) = 1

  • 偏移量:0

  • 占用:偏移0

第二步:int i

  • 大小:4字节

  • 对齐数 = min(4, 8) = 4

  • 下一个可用偏移是1,但1不是4的倍数

  • 需要填充3字节(偏移1-3)

  • 从偏移4开始存放

  • 占用:偏移4-7(4个字节)

第三步:char c2

  • 大小:1字节

  • 对齐数 = min(1, 8) = 1

  • 下一个可用偏移是8,8是1的倍数

  • 占用:偏移8

当前布局:

  • 偏移0: c1

  • 偏移1-3: 填充(3字节)

  • 偏移4-7: i(4字节)

  • 偏移8: c2

  • 已占用到偏移8,共9个字节

结构体总大小计算:

最大对齐数 = max(1, 4, 1) = 4

总大小必须是4的整数倍

  • 当前9字节,不是4的倍数

  • 需要再填充3字节到12字节(偏移9-11)

最终内存布局(共12字节):

cpp 复制代码
字节0:  c1
字节1:  填充
字节2:  填充
字节3:  填充
字节4-7:  int i(4字节)
字节8:  c2
字节9:  填充
字节10: 填充
字节11: 填充

练习2:

cpp 复制代码
struct S
{
	char c1; //1 ,对齐1,但是第一个结构体成员会存放在起始位置0处,然后往后存放字节
	char c2; //1 ,对齐1
	int i; //4 ,对齐4

};
int main()
{
	struct S b1 = { 0 };
	printf("%zd\n", sizeof(b1)); //8

	return 0;
}

输出结果:

这里的分析和上面的一模一样,这里我就不在和大家解析了.

练习3:

cpp 复制代码
struct S
{
	double d; //8
	char c; //1
	int i; //4

};
int main()
{
	struct S b1 = { 0 };
	printf("%zd\n", sizeof(b1)); //16

	return 0;
}

输出结果:

解析还是和上面一模一样

练习4:(嵌套结构体

cpp 复制代码
struct S3
{
	double d;
	char c;
	int i;

};
struct S4
{
	char c1;
	struct S3 s3;
	double d;

};
int main()
{
	struct S4 s4 = { 0 };
	printf("%zd\n", sizeof(s4)); //16

	return 0;
}

输出结果:

解析:

嵌套结构体规则

  1. 结构体成员的对齐数 = min(成员自身大小, 默认对齐数8)

  2. 嵌套的结构体成员 的对齐数 = min(该结构体的最大对齐数, 默认对齐数8)

  3. 结构体总大小 = 最大对齐数(成员对齐数最大值)的整数倍

成员分析(默认对齐数=8)

  1. double d(大小8)

    • 对齐数 = min(8, 8) = 8

    • 偏移0-7

  2. char c(大小1)

    • 对齐数 = 1

    • 下一个偏移8是1的倍数 → 偏移8

    • 占用偏移8

  3. int i(大小4)

    • 对齐数 = min(4, 8) = 4

    • 下一个偏移9,但9不是4的倍数

    • 填充3字节(偏移9-11),使偏移达到12(12是4的倍数)

    • 占用偏移12-15

struct S3 当前占用:0-15 共16字节

计算S3总大小

  • S3的最大对齐数 = max(8, 1, 4) = 8

  • 当前已用16字节,16是8的倍数 ✓

  • sizeof(S3) = 16

内存布局图:

cpp 复制代码
字节0-7:   double d
字节8:     char c
字节9-11:  填充
字节12-15: int i

再分析 struct S4

成员分析(默认对齐数=8)

  1. char c1(大小1)

    • 对齐数 = 1

    • 偏移0

  2. struct S3 s3

    • 关键点 :嵌套结构体的对齐数不是它的大小16,而是S3的最大对齐数

    • S3的最大对齐数 = 8(来自double d)

    • 所以s3的对齐数 = min(8, 8) = 8

    • 下一个偏移是1,但1不是8的倍数

    • 填充7字节(偏移1-7),使偏移达到8

    • s3占用偏移8-23(因为S3大小=16)

  3. double d(大小8)

    • 对齐数 = 8

    • 下一个偏移24是8的倍数 ✓

    • 占用偏移24-31

struct S4 当前占用:0-31 共32字节

计算S4总大小

  • S4的最大对齐数 = max(1, 8, 8) = 8

  • 当前已用32字节,32是8的倍数 ✓

  • sizeof(S4) = 32

总的图解内存布局

cpp 复制代码
struct S4 内存布局(32字节):
字节0:      c1
字节1-7:    填充(7字节)
字节8-23:   struct S3(16字节)
字节24-31:  double d(8字节)

其中struct S3内部布局:
字节8-15:   double d(S3的d)
字节16:     char c(S3的c)
字节17-19:  填充(3字节)
字节20-23:  int i(S3的i)

2.2 为什么存在内存对⻬?

⼤ 部分的参考资料都是这样说的:

1. 平台原因(移植原因):

不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定 类型的数据,否则抛出硬件异常

2. 性能原因:

数据结构(尤其是栈)应该尽可能地在⾃然边界上对⻬。原因在于,为了访问未对⻬的内存,处理器需要 作两次内存访问;⽽对⻬的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字节,则地 址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对⻬成8的倍数,那么就可以 ⽤⼀个内存操作来读或者写值了。否则,我们可能需要执⾏两次内存访问,因为对象可能被分放在两 个8字节内存块中。

总体来说:结构体的内存对⻬是拿空间来换取时间的做法。

那在设计结构体的时候,我们既要满⾜对⻬,⼜要节省空间,如何做到:

让占⽤空间⼩的成员尽量集中在⼀起

cpp 复制代码
struct S1
 {
 char c1;
 int i;
 char c2;
 };

struct S2
 {
 char c1;
 char c2;
 int i;
 };

S1 和 S2 类型的成员⼀模⼀样,但是 S1 和 S2 所占空间的⼤⼩有了⼀些区别

2.3 修改默认对⻬数

#pragma pack这个预处理指令,可以改变编译器的默认对⻬数。

cpp 复制代码
#pragma pack (1) //设置对齐数为1
struct S
{
	char c1;
	int i;
	char c2;
};
#pragma pack () ///取消设置的对⻬数,还原为默认

int main()
{
	printf("%zd\n", sizeof(struct S));
	return 0;
}

输出结果:

结构体在对⻬⽅式不合适的时候,我们可以⾃⼰更改默认对⻬数。

关键知识点:#pragma pack 指令

作用:修改编译器的内存对齐规则

  • #pragma pack(n):设置新的对齐数为 n(n=1,2,4,8,16...)

  • #pragma pack():恢复默认对齐数8

  • 生效范围 :从声明位置开始,直到遇到另一个 #pragma pack() 或文件结束

对齐规则变化

  • 未设置时:成员对齐数 = min(自身大小, 默认对齐数8)

  • 设置后:成员对齐数 = min(自身大小, n)

1. 设置 #pragma pack(1) 的含义

对齐数设为 1 表示:每个成员必须放在偏移量是 1 的倍数的位置

  • 由于 1 是所有整数的因数,所以不需要任何填充

  • 成员直接按顺序紧密排列

布局过程

  1. char c1

    • 对齐数 = min(1, 1) = 1

    • 偏移量:0(因为0是1的倍数)

  2. int i

    • 对齐数 = min(4, 1) = 1

    • 下一个可用偏移:1

    • 1是1的倍数 ✓

    • 占用偏移:1-4(4个字节)

  3. char c2

    • 对齐数 = min(1, 1) = 1

    • 下一个可用偏移:5

    • 5是1的倍数 ✓

    • 占用偏移:5

3. 结构体总大小计算

  • 当前占用:偏移0-5,共6个字节

  • 最大对齐数 = max(1, 1, 1) = 1

  • 总大小必须是1的倍数:6是1的倍数 ✓

  • 最终大小 = 6字节

内存布局图(紧密排列,无填充):

cpp 复制代码
字节0: char c1
字节1: int i(第1字节)
字节2: int i(第2字节)
字节3: int i(第3字节)
字节4: int i(第4字节)
字节5: char c2

3. 结构体传参

第一个代码(传参,传值)

cpp 复制代码
struct S
{
	int arr[1000];
	int n;
	double d;

};
void print1(const struct S tmp)
{
	int i = 0;
	for (i = 0; i < 5; i++)
	{
		printf("%d ", tmp.arr[i]);
	}
	printf("\n");
	printf("%d \n", tmp.n); //访问值的时候,打印结构体用  . 号
	printf("%0.2lf", tmp.d);
}
int main()
{
	struct S s={{1,2,3,4,5},100,3.15 };

	print1(s);

	return 0;
}

输出结果:

第二个代码(传参,传地址)比上面的代码更加有效率

cpp 复制代码
struct S
{
	int arr[1000];
	int n;
	double d;

};
void print2(const struct S * ps)
{
	int i = 0;
	for (i = 0; i < 5; i++) 
	{
		printf("%d ", ps->arr[i]);
	}
	printf("\n");
	printf("%d \n", (*ps).n);  //访问指针的时候,打印结构体 用  -> 号 ps->n == (*ps).n
	printf("%0.2lf ",ps->d);
}
int main()
{
	struct S s = { {1,2,3,4,5},100,3.15 };

	print2(&s);

	return 0;
}

第二个代码比第一个代码更加好,更加有效率

原因:

  • 函 数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
  • 如果传递⼀个结构体对象的时候,结构体过⼤,参数压栈的的系统开销⽐较⼤,所以会导致性能的下 降。

结论:结构体传参的时候,要传结构体的地址。

4. 结构体实现位段

结构体讲完就得讲讲结构体实现位段的能⼒。

4.1 什么是位段

这里的是指二进制

位段的声明和结构是类似的,有两个不同:

  • 位段的成员必须是 int 、 unsigned int 或 signed int ,在C99中位段成员的类型也可以 选择其他类型。
  • 位段的成员名后边有⼀个冒号和⼀个数字。

像这种**'' : 数字 ''**这种结果的表达式,称之为 位段式结构

例如:int a : 2; 表示成员 a 只占用 2 个比特位。

cpp 复制代码
struct S
{
	int _a : 2;  //前面加 _ 是为了好区分,这里可加可不加
	int _b : 5;
	int _c : 10;
	int _d : 30;
};
int main()
{
	printf("%zd\n", sizeof(struct S)); //输出8
	return 0;
}

输出结果:

解析:

位段内存分配规则

知识点

  1. 位段类型决定存储单元大小 :这里所有成员都是 int 类型

    • 在32位系统中,int 通常为4字节(32位)

    • 位段的存储以该类型的大小为分配单位

  2. 分配顺序

    • 从第一个成员开始,在当前存储单元(4字节)中按需分配比特位

    • 如果当前单元剩余空间不足,会开辟新的存储单元

具体分配过程

第一个存储单元(4字节 = 32位)

  1. a:2 → 占用 2 位,剩余 30 位

  2. b:5 → 占用 5 位,剩余 25 位

  3. c:10 → 占用 10 位,剩余 15 位

  4. d:30 → 需要 30 位,但当前单元只剩 15 位,不够!

需要第二个存储单元

  • 因为 d 需要30位,第一个单元放不下

  • 编译器会分配第二个存储单元(又一个4字节)给 d

  • 注意:即使第二个单元只用了一部分(30位),整个单元(4字节)都被占用

4. 总大小计算

  • 第一个存储单元:4字节(完全使用)

  • 第二个存储单元:4字节(用了30位,但整个单元被分配)

  • 总大小 = 4 + 4 = 8字节

内存布局示意图

cpp 复制代码
存储单元1(4字节 = 32位):
[2位a][5位b][10位c][15位未使用]

存储单元2(4字节 = 32位):
[30位d][2位未使用]

4.2 位段的内存分配

位段的成员可以是 int unsigned int signed int或者是 char等类型

位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使⽤位段。

位段的空间上是按照需要以4个字节(int)或者1个字节(char)的⽅式来开辟的。

cpp 复制代码
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\n", sizeof(s)); //输出3
	return 0;
}

输出结果:

解析:

  1. 分配原则

    • 从第一个成员开始,在当前单元内按需分配比特位

    • 如果当前单元剩余空间不够放下一个成员,开启新单元

    • 一个位段成员不能跨单元存储

  2. 位段的实际取值

    • 如果赋值超过位段能表示的范围,会截断高位

    • 例如:char a : 3 最大存储值为 7(二进制111)

    • 赋值 10(二进制1010) → 截断为 010(二进制)→ 值2

成员规格

cpp 复制代码
char a : 3;  // 需要3位
char b : 4;  // 需要4位
char c : 5;  // 需要5位
char d : 4;  // 需要4位

总需求:3 + 4 + 5 + 4 = 16位 = 2字节

分配步骤

第一字节(8位)

  1. a:3 → 占用3位,剩余5位

  2. b:4 → 需要4位,剩余5位够用 → 占用4位,剩余1位

  3. c:5 → 需要5位,但只剩1位,不够!

开启第二字节

  • c:5 从第二字节开始分配

  • 第二字节:c:5 → 占用5位,剩余3位

  • d:4 → 需要4位,但只剩3位,不够!

开启第三字节

  • d:4 从第三字节开始分配

  • 第三字节:d:4 → 占用4位,剩余4位未使用

最终内存布局

cpp 复制代码
字节1:[3位a][4位b][1位未使用]
字节2:[5位c][3位未使用]
字节3:[4位d][4位未使用]

总大小:3个字节(虽然总需求只有16位,但分配方式导致需要3个字节)

成员赋值与截断分析

cpp 复制代码
struct S s = { 0 };
s.a = 10;  // 二进制: 1010,但a只有3位
s.b = 12;  // 二进制: 1100
s.c = 3;   // 二进制: 0011
s.d = 4;   // 二进制: 0100

1. s.a = 10

  • 10的二进制:1010(4位)

  • a 只有3位,保留低3位:010

  • 实际存储值 :二进制010 = 十进制2

2. s.b = 12

  • 12的二进制:1100(4位)

  • b 正好4位,完整存储:1100

  • 实际存储值 :二进制1100 = 十进制12

3. s.c = 3

  • 3的二进制:0011(4位)

  • c 有5位,存储为:00011

  • 实际存储值 :二进制00011 = 十进制3

4. s.d = 4

  • 4的二进制:0100(4位)

  • d 有4位,存储为:0100

  • 实际存储值 :二进制0100 = 十进制4

4.3 位段的跨平台问题

  1. int 位段被当成有符号数还是⽆符号数是不确定的。

  2. 位段中最⼤位的数⽬不能确定。(16位机器最⼤16,32位机器最⼤32,写成27,在16位机器会 出问题。

  3. 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。

  4. 当⼀个结构包含两个位段,第⼆个位段成员⽐较⼤,⽆法容纳于第⼀个位段剩余的位时,是舍弃 剩余的位还是利⽤,这是不确定的

总结

跟结构相⽐,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在

4.4 位段的应⽤

下图是⽹络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要⼏个bit位就能描述,这⾥ 使⽤位段,能够实现想要的效果,也节省了空间,这样⽹络传输的数据报⼤⼩也会较⼩⼀些,对⽹络 的畅通是有帮助的。

4.5 位段使⽤的注意事项

位段的⼏个成员共有同⼀个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位 置处是没有地址的。内存中每个字节分配⼀个地址,⼀个字节内部的bit位是没有地址的。

所以不能对位段的成员使⽤&操作符,这样就不能使⽤scanf直接给位段的成员输⼊值,只能是先输⼊ 放在⼀个变量中,然后赋值给位段的成员。

cpp 复制代码
struct S
{
	int a : 2;  
	int b : 5;
	int c : 10;
	int d : 30;
};

int main()
{
	struct S s = { 0 };
	//scanf("%d", &(s.b)); //这个代码是错误的,因为位段是不允许取地址的

	//正确的写法
	int b = 0;
	scanf("%d", &b); //先创建一个变量,然后在取地址,这样就可以了
	s.b = b;

	return 0;

}

以上就是我们的全部的内容了,谢谢大家!!!

相关推荐
Yan-英杰30 分钟前
openEuler 25.09 VM虚拟机实测:性能与安全双维度测评
服务器·开发语言·科技·ai·大模型
兩尛31 分钟前
HJ52 计算字符串的编辑距离
java·开发语言·算法
武子康33 分钟前
Java-183 OSS 上传实战:Java 原生与 Spring Boot 集成
java·开发语言·spring boot·分布式·spring·阿里云·oss
ALex_zry35 分钟前
系统编程的基石:补码循环溢出与Rust变量绑定的深度探索
开发语言·后端·rust
Molesidy40 分钟前
【QT】【C++】基于QT的多线程分别管理GUI和运算任务
开发语言·c++·qt
yenggd42 分钟前
samba服务配置原理
服务器·开发语言·php
会员果汁44 分钟前
优先级队列-C语言
c语言·数据结构·算法
你不是我我1 小时前
【Java 开发日记】阻塞队列有哪些?拒绝策略有哪些?
java·开发语言
2201_757830871 小时前
反射的概念
java·开发语言