C语言入门指南:从0开始,手把手教你搞懂结构体

目录

 

前言:

一、 结构体到底是个啥?—— 给数据找个“家”

1.1 为什么我们需要结构体?

1.2 结构体的声明:设计你的“收纳盒”图纸

1.3 创建和初始化结构体变量:打造你的第一个“收纳盒”

方法一:按顺序初始化(最常用)

方法二:指定成员初始化(更灵活)

1.4 访问结构体成员:打开盒子,取出你要的东西

 

二、 进阶玩法:匿名结构与自我引用

2.1 匿名结构体:一次性使用的“神秘盒子”

2.2 结构体的自我引用:构建“链条”和“树”

什么是自我引用?

三、 深入理解:结构体内存对齐 —— 计算机的“强迫症”

3.1 为什么要内存对齐?—— 效率与兼容性的博弈

3.2 对齐规则详解 —— 四大黄金法则

法则一:首成员对齐起点

法则二:后续成员对齐数

法则三:整体大小对齐

法则四:嵌套结构体对齐

3.3 实战演练:亲手计算结构体大小

练习1:struct S1

练习2:struct S2

练习3:struct S3

练习4:嵌套结构体 struct S4

3.4 如何修改默认对齐数?—— #pragma pack

四、 结构体传参:如何优雅地传递“大盒子”

五、 高级技巧:位段 —— 把内存榨干到极致

5.1 什么是位段?—— 用比特(Bit)来定义成员

5.2 位段的内存分配细节

5.3 位段的致命缺点:跨平台问题

5.5 使用位段的注意事项

总结:

 

Happy Coding!


 

前言:

“结构体”是个里程碑式的知识点!掌握了它,你就不再是那个只会处理单个数字、单个字符的“初级玩家”了,你将正式踏入“自定义数据类型”的大门,开始构建属于你自己的、更复杂的数据世界。

我知道,刚接触“结构体”这个词,你可能会觉得有点抽象,甚至有点害怕。别担心!当年我第一次看到struct这几个字母时,脑子里也是一片空白。但今天,我会用最通俗、最接地气的方式,把这个看似高深的概念掰开了、揉碎了讲给你听。我会用生活中的例子、简单的比喻,把每一个专业术语都解释得明明白白。

这篇博客我会写得稍微长一点,目的就是让你一次学透,不留任何疑问。我们不赶进度,稳扎稳打,一步一个脚印。等你读完,我保证你会拍着桌子说:“原来结构体这么简单!”

好,废话不多说,咱们这就开始这场精彩的编程之旅吧!

 

一、 结构体到底是个啥?—— 给数据找个“家”

1.1 为什么我们需要结构体?

想象一下,你现在要管理一个班级的学生信息。每个学生都有哪些信息呢?名字、年龄、性别、学号...对吧?

在没学结构体之前,你会怎么存这些信息?是不是这样:

char name[20] = "张三"; // 名字
int age = 20;           // 年龄
char sex[5] = "男";     // 性别
char id[20] = "20230818001"; // 学号

这看起来好像没问题。但是,如果我要管理100个学生呢?难道我要定义400个变量吗?name1, name2, name3... age1, age2, age3... 这简直是一场灾难!不仅代码长得吓人,而且你根本分不清哪个名字对应哪个年龄、哪个学号。

问题的核心在于:这些数据是分散的、孤立的。它们明明是一个整体(一个学生),却被强行拆开,散落在程序的各个角落。

这就好比你搬家,把衣服、书本、锅碗瓢盆全都乱七八糟地塞进不同的纸箱里,每个箱子上还没贴标签。等到了新家,你想找一件T恤,得把所有箱子都翻一遍,效率极低,还容易出错。

结构体,就是为了解决这个问题而生的!它的核心思想就是:把相关的数据打包在一起,形成一个“数据包”或者叫“数据集合”。

你可以把结构体想象成一个定制化的收纳盒。这个盒子有好几个格子,每个格子放一种特定的东西。比如,一个“学生信息收纳盒”,第一个格子专门放名字,第二个格子放年龄,第三个格子放性别,第四个格子放学号。这样一来,所有关于“张三”的信息,都被整整齐齐地放在同一个盒子里,再也不怕弄混了!

1.2 结构体的声明:设计你的“收纳盒”图纸

知道了结构体是干什么的,下一步就是告诉计算机,我们要设计一个什么样的“收纳盒”。

在C语言里,我们用struct关键字来声明一个结构体。语法格式如下:

struct 结构体名 {
    成员1的类型 成员1的名字;
    成员2的类型 成员2的名字;
    成员3的类型 成员3的名字;
    // ... 可以有任意多个成员
};
// 注意:最后的分号 ; 千万不能丢!这是很多新手常犯的错误。

让我们用这个语法,来设计我们的“学生信息收纳盒”:

struct Stu { // Stu 是 Student 的缩写,你可以取任何你喜欢的名字
    char name[20]; // 名字,用一个能存20个字符的数组
    int age;       // 年龄,用一个整数
    char sex[5];   // 性别,用一个能存5个字符的数组(足够放“男”或“女”了)
    char id[20];   // 学号,同样用字符数组
}; // 分号!分号!分号!重要的事情说三遍!

这段代码就是在告诉计算机:“嘿,我要创建一种新的数据类型,名叫Stu。这种类型包含四个部分:一个叫name的字符串,一个叫age的整数,一个叫sex的字符串,和一个叫id的字符串。”

关键概念解释:

  • struct: 这是C语言的关键字,意思是“结构”。它标志着后面跟着的是一个结构体的定义。
  • Stu: 这是我们给这个结构体起的“标签”(tag)。就像你给收纳盒贴的标签一样,以后要用到这个类型的盒子,就喊它的名字Stu
  • 花括号 {}: 花括号里面的内容,就是这个“收纳盒”的内部构造图。它详细说明了这个盒子里有哪些“格子”(成员)。
  • 成员 (Member): name, age, sex, id 这些就是结构体的成员。它们是构成这个结构体的基本单元。每个成员都有自己的数据类型char[], int)和名字
  • 分号 ;: 在结构体定义结束的大括号后面,必须加上分号。这是C语言的语法规则,表示“定义完毕”。

现在,struct Stu 就成了一个全新的、由你自己定义的数据类型,就像系统自带的 int, char, float 一样。只不过,它更强大,因为它可以包含多种不同类型的数据!

1.3 创建和初始化结构体变量:打造你的第一个“收纳盒”

声明好结构体类型后,下一步就是动手制作具体的“收纳盒”了。在编程术语里,这叫做创建结构体变量

创建变量的语法非常简单,跟创建 int a;char c; 是一样的:

struct Stu s1; // 创建一个名为s1的Stu类型变量
struct Stu s2; // 再创建一个名为s2的Stu类型变量

现在,内存中就有了两个独立的“学生信息收纳盒”,分别叫s1s2。你可以把不同学生的信息分别放进这两个盒子里。

方法一:按顺序初始化(最常用)

这是最直观的方法,按照结构体定义时成员的顺序,依次给它们赋值。

#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);

    return 0;
}

运行结果:

你看,我们用一个大括号{},把要放进去的值按顺序列出来,中间用逗号,隔开。第一个值"张三"给了name,第二个值20给了age,以此类推。是不是超级清晰?

 

方法二:指定成员初始化(更灵活)

有时候,你可能不想按顺序来,或者只想初始化其中几个成员。这时候就可以用“指定成员初始化”的方法。

#include <stdio.h>

struct Stu {
    char name[20];
    int age;
    char sex[5];
    char id[20];
};

int main() {
    // 按照指定的顺序初始化,想先给谁赋值就先给谁
    struct Stu s2 = {.age = 18, .name = "李四", .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;
}

运行结果:

这种方法的好处是,你可以在初始化列表里“指名道姓”地说清楚哪个值给哪个成员,用.成员名 = 值的形式。这样即使你打乱了顺序,或者漏掉了一些成员(未初始化的成员会被自动设为0或空),程序也能准确无误地工作。

小贴士: 对于初学者,我推荐先熟练掌握“按顺序初始化”,因为它逻辑清晰,不易出错。等你玩熟了,再尝试“指定成员初始化”来提高灵活性。

 

1.4 访问结构体成员:打开盒子,取出你要的东西

盒子做好了,东西也放好了,那怎么拿出来用呢?

这里就要用到一个神奇的操作符:点操作符 . (英文句点)。

它的用法很简单:结构体变量名.成员名

#include <stdio.h>

struct Stu {
    char name[20];
    int age;
    char sex[5];
    char id[20];
};

int main() {
    struct Stu s = {"张三", 20, "男", "20230818001"};

    // 我想单独修改年龄
    s.age = 21; // 把s这个盒子里的age格子,从20改成21

    // 我想单独打印名字
    printf("这位同学的新年龄是:%d岁\n", s.age); // 输出:21岁

    return 0;
}

你看,s.age  就像一把钥匙,精准地打开了名为s的盒子里的那个叫   age 的小格子,你可以读取它的值,也可以修改它的值。

记住这个万能公式:

想操作哪个盒子里的哪个东西?就用 盒子名.东西名

 

二、 进阶玩法:匿名结构与自我引用

学完了基础,我们来玩点更酷的!这部分内容稍微有点抽象,但别怕,我会用最形象的例子带你飞。

2.1 匿名结构体:一次性使用的“神秘盒子”

有时候,你可能只需要一个结构体,并且只打算用一次。这时候,你可以创建一个“匿名结构体”。

什么叫“匿名”?就是没有名字(标签)!

// 匿名结构体类型
struct {
    int a;
    char b;
    float c;
} x; // 这里直接定义了一个变量x,它的类型是一个匿名结构体

// 你还可以这样定义数组或指针
struct {
    int a;
    char b;
    float c;
} arr[20], *p; // 定义了一个包含20个元素的数组arr,和一个指针p

你看,在struct后面,我们直接跟上了花括号定义成员,然后紧跟着就定义了变量x,中间没有Stu这样的标签。这就是匿名结构体。

那么问题来了:下面的代码合法吗?

struct {
    int a;
    char b;
    float c;
} x;

struct {
    int a;
    char b;
    float c;
} *p;

p = &x; // 这行代码合法吗?

答案是:不合法!会报错!

为什么?

虽然这两个匿名结构体的内部构造看起来一模一样(都是int a; char b; float c;),但对于编译器来说,它们是完全不同的两种类型

想象一下,你去宜家买了两个外观、尺寸、内部隔层都完全一样的收纳盒,但一个是蓝色的,一个是红色的。虽然它们功能一样,但在你心里,它们是两个不同的盒子。编译器也是这么想的:只要是匿名的,哪怕内容一样,它也认为是不同的类型。

所以,p是指向“红色盒子”的指针,你却想让它指向“蓝色盒子”x,这当然不行!

总结一下匿名结构体的特点:

  • 优点:方便快捷,适合定义那些只用一次的临时数据结构。
  • 缺点:无法重用。一旦定义完,你就不能再用这个类型去创建新的变量了。因为没有名字,你没法“称呼”它。
  • 重要规则:两个匿名结构体,即使成员列表完全相同,也被视为不同类型,不能互相赋值或取地址。

所以,除非你确定这个结构体只用一次,否则还是老老实实用带名字的结构体吧,省得给自己找麻烦。

2.2 结构体的自我引用:构建“链条”和“树”

这是一个非常重要的概念,尤其是在学习链表、树等数据结构时。它的应用场景非常多,比如QQ的好友列表、文件系统的目录结构等等。

什么是自我引用?

简单说,就是一个结构体里面,包含了指向“自己类型”的指针。

举个栗子🌰: 我们要定义一个“链表节点”。一个节点里有什么?一部分是它自己的数据(比如学生的成绩),另一部分是指向“下一个节点”的线索

// ❌ 错误示范!千万不能这么写!
struct Node {
    int data;        // 数据部分
    struct Node next; // 错误!不能直接包含一个同类型的结构体变量!
};

为什么错了?

这会导致一个可怕的“无限套娃”问题!Node里面包含一个Node,而这个被包含的Node里面又包含一个Node,如此循环下去...这个结构体的大小会变成无穷大!计算机的内存是有限的,这显然是不可能的。

✅ 正确的做法:使用指针!

// ✅ 正确示范!
struct Node {
    int data;           // 数据部分
    struct Node* next;  // 正确!包含一个指向同类型结构体的指针
};

这次我们用的是struct Node* next,是一个指针,而不是一个完整的结构体变量。

指针是什么? 指针就是一个“地址簿”,它只记录某个东西在哪里,而不是把那个东西本身搬过来。一个指针的大小是固定的(通常是4或8个字节),不管你指向的东西有多大。

所以,struct Node* next 的意思就是:“我这里有一张小纸条,上面写着另一个Node盒子的存放地址。” 这样,Node结构体本身的大小就是可控的:int data占4个字节 + 一个指针占8个字节 = 总共12个字节(具体大小取决于系统)。

通过这种方式,我们可以把一个个Node节点像链条一样串起来: 节点A -> 节点B -> 节点C -> NULL(NULL表示链条结束)

进阶挑战:typedef与自引用的结合

有时候,为了代码更简洁,我们会用typedef给结构体起一个别名。

// ❌ 错误示范!
typedef struct {
    int data;
    Node* next; // 错误!此时Node还没有被定义!
} Node;

这个代码会报错。因为在定义结构体成员Node* next的时候,编译器还不知道Node是谁。Node是在整个typedef语句的末尾才被定义出来的。

✅ 解决方案:给结构体加上标签!

// ✅ 正确示范!
typedef struct Node { // 先给结构体一个标签:Node
    int data;
    struct Node* next; // 现在可以用 struct Node* 了,因为标签已经声明了
} Node; // 最后再用 typedef 给它起个别名,也叫 Node

或者,你也可以把别名起得不一样:

// ✅ 另一种正确示范!
typedef struct Node {
    int data;
    struct Node* next;
} ListNode; // 别名叫 ListNode,这样就不会混淆了

这两种方式都是正确的。第一种方式虽然标签和别名都叫Node,但因为struct NodeNode在语法上是不同的符号,所以不会冲突。

掌握了自引用,你就解锁了数据结构的大门!链表、二叉树、图...这些听起来高大上的东西,底层都是靠这个“自我引用”的原理构建起来的。

 

三、 深入理解:结构体内存对齐 —— 计算机的“强迫症”

好了,前面我们学会了怎么声明、创建和使用结构体。现在,我们来探讨一个更深层次、也更“硬核”的话题:内存对齐

这个知识点是面试和笔试中的常客,很多同学在这里栽了跟头。别怕,我会用最通俗的方式,让你不仅知道“怎么做”,更明白“为什么”。

3.1 为什么要内存对齐?—— 效率与兼容性的博弈

首先,抛出一个问题:假设有一个结构体  S1

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

你觉得 sizeof(struct S1) 的结果是多少?是  1(char) + 4(int) + 1(char) = 6  吗?

如果你这么想,那就错了!实际结果很可能是 12

Why?! 这就是内存对齐在作怪。

内存对齐的根本原因有两个:

  1. 平台原因(兼容性):不是所有的硬件(CPU)都能随心所欲地访问内存。有些老旧的或特殊的CPU,只能在特定的内存地址(比如4的倍数、8的倍数)上读取特定大小的数据。如果你把一个4字节的int放在地址3的位置,CPU可能直接罢工,抛出一个硬件异常。内存对齐就是为了保证程序能在各种不同的硬件平台上稳定运行。
  1. 性能原因(速度):这是最主要的原因!现代CPU读取内存时,通常是以“块”为单位的,比如一次读取8个字节。如果数据是对齐的(比如一个8字节的double正好放在8的倍数地址上),CPU一次就能读完。但如果数据是不对齐的(比如一个double横跨了两个8字节块),CPU就需要读两次内存,然后再拼凑起来,效率直接腰斩!

总结一句话:内存对齐是典型的“空间换时间”策略。 我们牺牲了一点点内存空间(填充一些用不到的字节),换取了程序运行速度的巨大提升。在当今内存白菜价的时代,这点空间浪费绝对是值得的。

3.2 对齐规则详解 —— 四大黄金法则

明白了“为什么”,我们来看“怎么做”。内存对齐遵循一套严格的规则,我把它总结为四大黄金法则:

法则一:首成员对齐起点

结构体的第一个成员,永远从偏移量为0的地方开始存放。

这很好理解,第一个成员就是“排头兵”,站在队伍的最前面。

法则二:后续成员对齐数

从第二个成员开始,每个成员都要对齐到某个“对齐数”的整数倍地址处。

对齐数 = min(编译器默认对齐数, 该成员自身的大小)

这句话是核心,也是最难理解的。我们来拆解:

  • 成员自身的大小:比如char是1字节,short是2字节,int是4字节,double是8字节。
  • 编译器默认对齐数:不同的编译器和操作系统有不同的默认值。
    • Windows (VS):默认是8。
    • Linux (gcc):没有默认值,对齐数就是成员自身的大小。
  • min(a, b):取两者中较小的那个。

举个例子,在Windows VS环境下:

  • 一个char成员,对齐数 = min(8, 1) = 1。所以它可以放在任何地址。
  • 一个int成员,对齐数 = min(8, 4) = 4。所以它必须放在4的倍数地址上(如0, 4, 8, 12...)。
  • 一个double成员,对齐数 = min(8, 8) = 8。所以它必须放在8的倍数地址上。

法则三:整体大小对齐

整个结构体的总大小,必须是其内部所有成员“对齐数”中最大值的整数倍。

为什么要这样?是为了当定义结构体数组时,每个数组元素都能满足对齐要求。

法则四:嵌套结构体对齐

如果结构体里嵌套了另一个结构体,那么这个嵌套结构体的对齐要求,要看它内部成员的最大对齐数。整个大结构体的最终大小,也要符合所有成员(包括嵌套结构体内部成员)的最大对齐数。

3.3 实战演练:亲手计算结构体大小

光说不练假把式,我们来做几道经典例题,把规则彻底吃透!

练习1:struct S1

struct S1 {
    char c1; // 大小1,对齐数 min(8,1)=1
    int i;   // 大小4,对齐数 min(8,4)=4
    char c2; // 大小1,对齐数 min(8,1)=1
};

步骤分析:

  1. c1: 第一个成员,从偏移量0开始。占用1个字节,下一位是偏移量1。
  2. i: 对齐数是4。当前偏移量是1,不是4的倍数。所以需要填充3个字节(偏移量1->2->3->4),让i从偏移量4开始存放。i占用4个字节(4,5,6,7),下一位是偏移量8。
  3. c2: 对齐数是1。当前偏移量8是1的倍数,可以直接放。占用1个字节(偏移量8),下一位是偏移量9。
  4. 整体大小:目前占用了9个字节(0-8)。最大对齐数是max(1,4,1)=4。9不是4的倍数,所以需要填充到12(9->10->11->12)。12是4的倍数。

结论:sizeof(struct S1) = 12

练习2:struct S2

struct S2 {
    char c1; // 1, 对齐数1
    char c2; // 1, 对齐数1
    int i;   // 4, 对齐数4
};

步骤分析:

  1. c1: 偏移量0,占1字节,下一位1。
  2. c2: 对齐数1,偏移量1是1的倍数,直接放。占1字节(偏移量1),下一位2。
  3. i: 对齐数4。当前偏移量2,不是4的倍数。填充2个字节(2->3->4),让i从4开始。占4字节(4,5,6,7),下一位8。
  4. 整体大小:占用8字节。最大对齐数4,8是4的倍数,无需填充。

结论:sizeof(struct S2) = 8

对比发现: S1S2的成员完全一样,只是顺序不同,但大小却从12变成了8!这就是为什么我们在设计结构体时,应该把占用空间小的成员尽量集中在一起,可以有效减少内存浪费!

练习3:struct S3

struct S3 {
    double d; // 8, 对齐数 min(8,8)=8
    char c;   // 1, 对齐数 min(8,1)=1
    int i;    // 4, 对齐数 min(8,4)=4
};

步骤分析:

  1. d: 偏移量0,占8字节(0-7),下一位8。
  2. c: 对齐数1,偏移量8是1的倍数,直接放。占1字节(8),下一位9。
  3. i: 对齐数4。当前偏移量9,不是4的倍数。需要填充到12(9->10->11->12),让i从12开始。占4字节(12,13,14,15),下一位16。
  4. 整体大小:占用16字节。最大对齐数8(来自d),16是8的倍数,OK。

结论:sizeof(struct S3) = 16

 

练习4:嵌套结构体 struct S4

struct S3 { // 我们刚才算过,S3的大小是16,最大对齐数是8
    double d;
    char c;
    int i;
};

struct S4 {
    char c1;      // 1, 对齐数1
    struct S3 s3; // 嵌套S3,它的对齐数是S3内部的最大对齐数,即8
    double d;     // 8, 对齐数8
};

步骤分析:

  1. c1: 偏移量0,占1字节,下一位1。
  2. s3: 对齐数是8(因为它内部有double)。当前偏移量1,不是8的倍数。需要填充7个字节(1->2->...->8),让s3从8开始。s3本身占16字节(8-23),下一位24。
  3. d: 对齐数8。当前偏移量24是8的倍数,直接放。占8字节(24-31),下一位32。
  4. 整体大小:占用32字节。最大对齐数是max(1,8,8)=8,32是8的倍数,OK。

结论:sizeof(struct S4) = 32

 

3.4 如何修改默认对齐数?—— #pragma pack

有时候,你可能觉得编译器太“抠门”,填充了太多没用的字节,想自己控制对齐方式。C语言提供了#pragma pack指令

#include<stdio.h>

#pragma pack(1) // 设置默认对齐数为1,相当于取消对齐
struct S {
    char c1; // 1
    int i;   // 4
    char c2; // 1
};
#pragma pack() // 取消设置,恢复默认对齐

int main() {
    printf("%d\n", sizeof(struct S)); // 输出结果是6!
    return 0;
}

#pragma pack(1) 告诉编译器:“别管什么对不对齐了,给我紧紧地挨着放!” 所以c1, i, c2 三个成员连续存放,总大小就是1+4+1=6。

注意: 修改对齐数虽然能节省空间,但可能会降低程序运行速度,甚至在某些平台上导致程序崩溃。所以,除非有特殊需求(比如网络协议、文件格式要求严格的内存布局),否则不建议随意修改。

四、 结构体传参:如何优雅地传递“大盒子”

当你写的程序越来越复杂,结构体也会越来越大。这时候,如何把结构体作为参数传递给函数,就成了一个需要认真考虑的问题。

我们来看一个例子:

#include<stdio.h>

struct S {
    int data[1000]; // 一个包含1000个整数的数组
    int num;
};

void print1(struct S s) { // 传结构体本身
    printf("%d\n", s.num);
}

void print2(struct S* ps) { // 传结构体的地址
    printf("%d\n", ps->num);
}

int main() {
    struct S s = {{1,2,3,4}, 1000};

    print1(s);  // 传结构体
    print2(&s); // 传地址

    return 0;
}

这两个函数print1print2都能正常工作,打印出num的值。那么,哪个更好呢?

答案是:毫无疑问,选print2

为什么?

这涉及到函数调用的底层机制。当你调用一个函数时,参数需要被“压栈”,也就是复制一份放到一个临时的内存区域(栈)里。

  • print1(s): 传递的是整个结构体ss有多大?int data[1000]是4000字节,加上int num是4字节,总共4004字节!这意味着每次调用print1,系统都要吭哧吭哧地复制4004个字节的数据。这不仅慢,还浪费内存!
  • print2(&s): 传递的是结构体s的地址。一个地址(指针)有多大?在64位系统上是8个字节。无论你的结构体有多大,传递的永远是这8个字节。效率极高!

结论:

当结构体比较大时,一定要传递它的地址(指针),而不是传递结构体本身!

另外,你会发现,在print2函数内部,我们访问成员用的是ps->num,而不是ps.num

箭头操作符 -> 是专门用于“结构体指针”访问成员的。ps->num 等价于 (*ps).num,意思是“先通过指针ps找到它指向的结构体,然后再用点操作符访问其num成员”。

记住这个口诀:

  • 变量用点    .   : s.num
  • 指针用箭头  ->  : ps->num

 

五、 高级技巧:位段 —— 把内存榨干到极致

 

最后,我们来学习一个非常高阶但也非常实用的技巧:位段 (Bit Field)

5.1 什么是位段?—— 用比特(Bit)来定义成员

前面我们讲的结构体,成员的最小单位是字节(Byte)。比如char是1字节,int是4字节。

但有时候,我们想存储的信息非常小,根本用不着一个字节。比如:

  • 一个开关状态:开/关,只需要1个比特(0或1)就够了。
  • 一周的星期几:1-7,用3个比特(能表示0-7)就够了。
  • 一个月的日期:1-31,用5个比特(能表示0-31)就够了。

如果我们用char(8比特)来存这些数据,就有7、5、3个比特被白白浪费了!

位段,就是允许你直接以“比特(bit)”为单位来定义结构体成员,从而实现极致的内存压缩。

位段的声明和普通结构体类似,但要在成员名后面加一个冒号和数字,表示这个成员占多少个比特。

struct A {
    int _a : 2;  // _a 占2个比特
    int _b : 5;  // _b 占5个比特
    int _c : 10; // _c 占10个比特
    int _d : 30; // _d 占30个比特
};

这里的_a, _b等成员,它们的类型虽然是int,但实际上只使用了指定数量的比特位。

那么,sizeof(struct A)是多少呢?

答案是:8字节(64比特)

为什么?

因为位段的内存分配规则是:按需以4字节(int)或1字节(char)为单位开辟空间。

在这个例子里,前三个成员_a(2bit) + _b(5bit) + _c(10bit) = 17bit,小于32bit(4字节),所以它们共享第一个4字节的空间。

第四个成员_d需要30bit,剩下的空间(32-17=15bit)不够放,所以系统会再开辟一个新的4字节空间给它。

总共就是4 + 4 = 8字节。

5.2 位段的内存分配细节

我们用一个更详细的例子来观察位段是如何分配内存的

#include <stdio.h>

struct S {
    char a : 3; // a占3比特
    char b : 4; // b占4比特
    char c : 5; // c占5比特
    char d : 4; // d占4比特
};

int main() {
    struct S s = {0};
    s.a = 10; // 10的二进制是 1010,但我们只有3比特,所以只取低3位:010 -> 2
    s.b = 12; // 12的二进制是 1100,取低4位:1100 -> 12
    s.c = 3;  // 3的二进制是 00011,取低5位:00011 -> 3
    s.d = 4;  // 4的二进制是 00100,取低4位:0100 -> 4

    printf("Size of struct S: %zu\n", sizeof(s)); // 通常是2或3字节,取决于编译器

    return 0;
}

内存分配过程(简化版,实际情况更复杂):

  1. 系统分配一个char大小的空间(1字节,8比特)。
  2. a(3bit),剩余5bit。
  3. b(4bit),5bit > 4bit,放得下。剩余1bit。
  4. c(5bit),1bit < 5bit,放不下!舍弃剩余的1bit,重新分配一个新的char空间(8bit)。
  5. 在新的空间里放c(5bit),剩余3bit。
  6. d(4bit),3bit < 4bit,又放不下!再舍弃,再分配一个新的char空间。
  7. 在第三个空间里放d(4bit)。

所以,这个结构体S总共占用了3个字节。

注意: 位段的具体分配方式(是从左到右还是从右到左,舍弃的位是否利用等)是不确定的,依赖于编译器的实现。这也是位段最大的问题。

5.3 位段的致命缺点:跨平台问题

正因为位段的内存分配细节没有在C语言标准中严格规定,导致它存在严重的跨平台问题

主要体现在:

  1. 符号不确定性int位段是有符号还是无符号?不同编译器可能不同。
  2. 最大位数不确定:16位机器上,位段最大可能是16位;32位机器上可能是32位。如果你写了一个30位的位段,在16位机器上就可能出错。
  3. 分配方向不确定:成员是从内存的高位向低位分配,还是从低位向高位分配?标准没说。
  4. 剩余位处理不确定:当一个分配单元(如1字节)放不下下一个成员时,是舍弃剩余位,还是尽可能利用?也没说。

总结:位段虽然能节省空间,但移植性极差。 除非你写的程序只在特定的、已知的平台上运行(比如嵌入式开发、网络协议解析),否则应尽量避免使用位段。

5.5 使用位段的注意事项

由于位段成员共享同一个字节,它们的起始位置可能不是一个完整字节的开头。因此,位段成员是没有独立的内存地址的

后果:你不能对位段成员使用取地址符&

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 temp = 0;
    scanf("%d", &temp); // 给普通变量temp取地址,没问题
    sa._b = temp;       // 再把值赋给位段成员

    return 0;
}

总结:

到这里,我们这篇超长的“结构体完全指南”就接近尾声了。我们从最基础的“什么是结构体”,一路讲到了高阶的“位段”,涵盖了声明、初始化、内存对齐、传参等所有核心知识点。

我希望通过这篇博客,你能彻底扫清学习结构体路上的所有障碍。记住,编程是一个循序渐进的过程,不要期望一口吃成胖子。先把基础打牢,理解结构体的本质——把相关数据打包。然后再去攻克内存对齐、自引用这些难点

 

Happy Coding!

 

             

制作不易,点个赞呗

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值