零基础入门C语言之深入结构体
在阅读本文章之前,建议读者先阅读本专栏内前面的文章。
目录
前言
一、结构体类型的复习
二、结构体内存对齐
三、结构体传参与实现位段
总结
前言
本篇文章主要讲解一些关于结构体的知识。
一、结构体类型的复习
我们之前在学习操作符的时候,稍微提到过一些关于结构体的知识,这里稍微再复习一下。首先结构体是一些值的集合,这些值都称为成员变量,这些成员变量可以是不同类型的变量。我们通过举一个创建学生结构体的例子来为大家回顾一下结构体的声明:
struct Stu
{char name[20];//名字int age;//年龄char sex[5];//性别char id[20];//学号
}; //分号不能丢
然后是结构体变量的创建和初始化:
#include <stdio.h>
struct Stu
{char name[20];//名字int age;//年龄char sex[5];//性别char id[20];//学号
};
int main()
{//按照结构体成员的顺序初始化struct Stu s = { "张三", 20, "男", "20230818001" };printf("name: %s\n", s.name);printf("age : %d\n", s.age);printf("sex : %s\n", s.sex);printf("id : %s\n", s.id);//按照指定的顺序初始化struct Stu s2 = { .age = 18, .name = "lisi", .id = "20230818002", .sex =
"⼥" };printf("name: %s\n", s2.name);printf("age : %d\n", s2.age);printf("sex : %s\n", s2.sex);printf("id : %s\n", s2.id);return 0;
}
其运行结果如下:

我们为大家介绍一种结构体的特殊声明方式,也就是省略结构体名字的匿名声明,我们键入如下代码:
#include <stdio.h>struct {int a;char b;float c;
}x = { 1, 'a', 3.14 };int main()
{printf("a: %d\n", x.a);printf("b: %c\n", x.b);printf("c: %f\n", x.c);return 0;
}
关于这种命名方式,我们需要注意它只能够使用一次,第二次使用的时候就无法访问了。我们再键入如下的代码看看会出现什么结果:
#include <stdio.h>struct {int a;char b;float c;
}x = { 1, 'a', 3.14 };struct {int a;char b;float c;
}*px;
int main()
{px = &x;printf("a: %d\n", px->a);printf("b: %c\n", px->b);printf("c: %f\n", px->c);return 0;
}
其结果如下:

可以看到打印出了上面的结果,但是如果我们观察生成信息的话,可以发现下面的信息:

我们发现编译器在给我们报错,说两种类型不兼容,这是因为虽然说两种匿名声明的结构体成员变量完全相同,但由于是匿名声明,所以编译器无法判断这两者是否是同种结构体变量,因而会发生报错。
接下来,我们需要介绍一个非常重要的概念,就是结构体自引用,这个概念我们在链表中会进行实际使用,那时候我们会详细讲解。所谓结构体自引用,其实就是一个结构体变量能够访问到另一个同种结构体的变量。我们就利用链表的例子来进行讲解:
struct Node
{int data;struct Node* next;
};
可以看到,在我们一个正常的结构体变量当中,不仅会有一个值,还会有一个指向同种结构体类型的指针,那么这就是结构体进行了自引用。
二、结构体内存对齐
在对上面的结构体知识进行复习之后,我们可以深入探讨一下关于结构体的大小和结构体内存对齐的问题。我们首先键入一段代码来研究一下:
#include <stdio.h>struct S {char c1;int i;char c2;
}s;int main()
{printf("%zd\n", sizeof(s));return 0;
}
那么这个代码大概的输出会是什么呢?按照我们这个计算来看,应该输出的是1+4+1=6字节,那我们输出结果试一试:

怎么是12字节?这是因为结构体在存储数据的时候并非是按照我们所设想的将所有的成员变量紧密排在一起,而是有着自己的一些规则。我们下面来介绍一下这些规则:
结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处,其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处,其中对齐数 = 编译器默认的一个对齐数与该成员变量大小的较小值(VS 中默认的值为8,Linux中gcc没有默认对齐数,对齐数就是成员自身的大小);结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍;如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
按照上面的要求,我们再来看一下我们给出代码的结果是怎么来的,首先我们画出示意图:

我们以0地址为结构体变量s起始的位置,也就是我们画出的第一条横线,其余字节均相对这个位置发生偏移,那么我们首先先来存储成员变量c1,它的字节数是1,VS的对齐数是8,我们找到两者的较小值,那就是1,所以c1的对齐数是1,我们知道1肯定是1的整数倍,所以c1就可以存入其中。那么下一个成员变量i,它是一个整型变量,占有4个字节,因此它的对齐数应该为4,而4的整数倍最接近的就只能是4,所以我们就需要从4开始存储变量i。第三个成员变量c2,它的存储规则与c1一样,故而不再赘述。最后我们要保证结构体整体大小是全部对齐数最大值的整数倍,而最接近4整数倍的就是12,所以整个结构体的大小就应该是12字节。
如果读者掌握了上面的代码,那么我们将代码稍微改变一下,结果会发生改变吗?
#include <stdio.h>struct S {char c1;char c2;int i;
}s;int main()
{printf("%zd\n", sizeof(s));return 0;
}
其运行结果如下:

其示意图如下:

相信你应该能看懂是如何得到结果的了,那么让我们继续看看下一个代码:
#include <stdio.h>struct S
{double d;char c;int i;
}s;int main()
{printf("%zd\n", sizeof(s));return 0;
}
其运行结果如下:

分析示意图如下:

上面的例子都只是设计了我们说到的前面的规则,我们下面来举一个专门应用第四个规则,也就是结构体嵌套的例子:
#include <stdio.h>struct S
{double d;char c;int i;
}s;struct S1
{char c1;struct S s;double d;
}s1;int main()
{printf("%zd\n", sizeof(s1));return 0;
}
其结果如下:

首先我们在上面提到了如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。那么读者可以先进行思考,然后看下我给出的示意图:

首先存放c1时,情况与之前讲解相同,不再赘述。然后我们要存放一个结构体s,我们在上面算出它的大小是16个字节,它需要在内部成员变量的最大对其数的整数倍开始存储,它内部成员变量最大对齐数是8,那么我们就从8开始进行存储,而对于最后d变量,规则与前面相同,在最后存放结束后,我们要让整个结构体的大小为结构体内成员变量最大值,也就是16的整数倍,我们发现目前是32,符合规则,所以最后输出32。
我们在分析上面过程的时候可以发现,内存对齐的过程是很容易浪费空间的,那么我们为什么还要让这种机制存在呢?首先是平台原因 (移植原因),不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。其次是性能原因,数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。 总体来说,结构体的内存对齐是拿空间来换取时间的做法。所以在设计结构体时,我们尽量要让占用空间小的成员集中在一起,这样能尽可能的节省空间。
如果说你不想让默认对齐数为8,我们可以通过#pragma这个预处理指令来修改编译器的默认对齐数,如下面的代码:
#include <stdio.h>
#pragma pack(1)//设置默认对⻬数为1
struct S
{char c1;int i;char c2;
};
#pragma pack()//取消设置的对⻬数,还原为默认
int main()
{//输出的结果是什么?printf("%d\n", sizeof(struct S));return 0;
}
其结果如下:

这个结果很明显就很好算了。
三、结构体传参与实现位段
我们首先来看下下面这段代码:
#include <stdio.h>
struct S
{int data[1000];int num;
};
struct S s = {{1,2,3,4}, 1000};
//结构体传参
void print1(struct S s)
{printf("%d\n", s.num);
}
//结构体地址传参
void print2(struct S* ps)
{printf("%d\n", ps->num);
}
int main()
{print1(s); //传结构体print2(&s); //传地址return 0;
}
那么这两个函数哪个好些?答案是首选第二个,这是因为函数传参的时候,参数需要压栈,会有时间和空间上的系统开销。当我们传递一个结构体对象的时候,结构体过大,参数压栈的系统开销就比较大,会导致性能下降,所以传递地址是更好的选择。
接下来我们来讲解一下结构体实现位段的能力。首先我们需要弄明白什么是位段?位段的声明和结构提示类似的,但是会有以下两个不同:位段的成员必须是int、unsigned int、signed int这三种,但在C99中位段成员也可以包括其他类型;位段的成员名后面有一个冒号和一个数字。我们举下面这个例子:
struct A
{int _a:2;int _b:5;int _c:10;int _d:30;
};
那么到底什么是位段呢?位段允许将数据成员按照二进制位为单位来分配存储空间,从而更精细地控制内存使用,尤其适合处理需要紧凑存储的小数值或标志位。也就是说,它冒号后的数字代表着分配的二进制位数,它专门用来节省我们的内存。
位段在分配内存时,因为其成员可以是int、unsigned int、signed int和char等类型,所以它在空间上需要按照以4个字节(int)或1个字节(char)的方式来开辟。但是位段涉及了很多不稳定的因素,所以是不可跨平台的,注重可移植的程序应该避免使用。我们键入如下的代码:
#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\n", sizeof(s));
}
我们先画出内存分配示意图:

其遵循以下规则:申请到的一块内存中,从左向右使用或者从右向左使用并不确定,而是要根据编译器来决定,在VS中是从右向左的顺序;剩余的空间不足下一个成员使用时会被丢弃。
按照我们上面的规则,运行代码结果应该为3,结果也确实如此:

那我们具体来看看位段到底存在哪些跨平台的问题呢?首先int 位段被当成有符号数还是无符号数是不确定的;其次位段中最大位的数目不能确定(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题);然后位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义;最后当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。总的来说,与结构体相比,位段可以达到相同的效果,并且能够很好节省空间,只是有跨平台问题。
下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小一些,对网络的畅通是有帮助的。

最后我们需要强调一个在位段使用过程中的注意事项,因为位段的几个成员共有同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配一个地址,一个字节内部的bit位是没有地址的。所以不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段的成员输入值,只能是先输入放在一个变量中,然后赋值给位段的成员。
#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;
}
总结
本文系统讲解了C语言结构体的相关知识,主要包括三个方面:结构体类型的声明、初始化和自引用机制;结构体内存对齐规则及其原因,分析了不同情况下的内存分配方式;位段的定义、实现及其跨平台问题。文章通过示例代码详细演示了结构体的各种特性,解释了内存对齐带来的空间与时间权衡,并特别指出了位段在节省内存方面的优势及其使用注意事项。最后总结了结构体参数传递的最佳实践,建议使用指针传参以提高性能。全文内容丰富实用,适合C语言学习者深入了解结构体相关的核心概念。
