第23篇 结构体

一、自定义类型:结构体底层理论与声明规范

1.1 结构体类型回顾与声明

在C语言的基础数据类型(如int, char, float)无法满足复杂数据描述需求时,我们需要自定义类型。结构体(Structure)允许我们将不同类型的数据聚合在一起,形成一个有机的整体。

1.1.1 结构体基础定义与语法 结构体本质上是一系列值的集合,这些值被称为成员变量。与数组不同,结构体的成员可以是完全不同的数据类型。

根据课件内容,描述一个学生信息(姓名、年龄、性别、学号)的标准声明如下:

cpp 复制代码
#include <stdio.h>

struct Stu
{
    char name[20]; // 名字
    int age;       // 年龄
    char sex[5];   // 性别
    char id[20];   // 学号
}; // 分号不能丢

1.1.2 硬件视角的内存布局初探 从电子信息专业的硬件视角来看,结构体不仅仅是代码逻辑的封装,更是内存中一段连续存储空间的抽象。当我们定义上述struct Stu时,编译器会根据成员的排列,在内存(RAM)中规划出一块特定的区域。每个成员变量对应内存中特定的偏移地址,这种映射关系是程序能够正确读写数据的基础。

1.2 结构体变量的创建与初始化

结构体变量的创建即是在内存中分配上述规划的空间。初始化则是对这片空间进行赋值。

1.2.1 顺序初始化与指定初始化 C语言支持按照成员定义的顺序进行初始化,也支持C99标准下的指定成员初始化(乱序初始化)。

cpp 复制代码
int main(void)
{
    // 1. 按照结构体成员的顺序初始化
    struct Stu s1 = { "ZhangSan", 20, "Male", "2023001" };

    // 2. 按照指定的顺序初始化(推荐,可读性更强)
    struct Stu s2 = { .age = 18, .name = "LiSi", .id = "2023002", .sex = "Female" };

    printf("Name: %s, Age: %d\n", s1.name, s1.age);
    printf("Name: %s, Age: %d\n", s2.name, s2.age);

    return 0;
}

1.2.2 匿名结构体与类型唯一性 在声明结构体时,可以省略标签(tag),这被称为匿名结构体。

cpp 复制代码
struct
{
    int a;
    char b;
} x;

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

注意 :虽然上述两个匿名结构体的成员看起来一模一样,但在编译器眼中,它们是完全不同的两个类型。因此,执行 p = &x; 是非法的。匿名结构体类型如果没有通过 typedef 重命名,基本上只能使用一次。

1.3 结构体的自引用

在构建链表、树等数据结构时,结构体需要包含指向自身类型的指针。

1.3.1 错误的自引用方式

cpp 复制代码
struct Node
{
    int data;
    struct Node next; // 错误:会导致无限递归定义,大小无法确定
};

如果结构体包含自身类型的变量,sizeof(struct Node) 将包含另一个 struct Node,而后者又包含另一个......这将导致结构体大小无穷大,这是不合理的。

1.3.2 正确的指针自引用 正确的做法是使用结构体指针,因为指针的大小是固定的(32位系统为4字节,64位系统为8字节)。

cpp 复制代码
struct Node
{
    int data;
    struct Node* next; // 正确:指针大小固定
};

1.3.3 Typedef与自引用的陷阱 在使用 typedef 简化类型名时,需注意作用域问题:

cpp 复制代码
// 错误示范
typedef struct
{
    int data;
    Node* next; // 错误:Node 是重命名后的名字,在结构体内部尚未生效
} Node;

// 正确示范
typedef struct Node
{
    int data;
    struct Node* next; // 正确:使用 struct Node 标签
} Node;

二、结构体内存对齐:底层规则与性能优化

2.1 内存对齐规则详解

计算结构体大小时,不能简单地将成员大小相加,必须遵循内存对齐规则。这是为了平衡硬件访问效率与存储空间。

2.1.1 对齐规则推导

  1. 首地址对齐:第一个成员位于结构体变量起始位置偏移量为0的地址处。
  2. 成员对齐 :其他成员变量要对齐到"对齐数"的整数倍地址处。
    • 对齐数 = min(编译器默认对齐数, 该成员大小)
    • VS环境下默认对齐数为8,Linux GCC下默认对齐数为成员自身大小。
  3. 总大小对齐:结构体总大小必须是"最大对齐数"(所有成员对齐数中的最大值)的整数倍。
  4. 嵌套对齐:若嵌套结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,整体大小包含嵌套结构体的最大对齐数。

2.1.2 内存布局实战演练 让我们分析以下结构体的大小(假设默认对齐数为8):

cpp 复制代码
struct S1
{
    char c1; // 占1字节,对齐数1,位于偏移0
    // 浪费3字节 (偏移1-3)
    int i;   // 占4字节,对齐数4,位于偏移4-7
    char c2; // 占1字节,对齐数1,位于偏移8
    // 总大小为9,最大对齐数为4,需补齐到4的倍数 -> 12
};

结论sizeof(struct S1) 为 12 字节。

2.1.3 优化空间布局 通过调整成员顺序,可以节省空间:

cpp 复制代码
struct S2
{
    char c1; // 占1字节,位于偏移0
    char c2; // 占1字节,位于偏移1
    // 浪费2字节 (偏移2-3)
    int i;   // 占4字节,位于偏移4-7
    // 总大小为8,最大对齐数为4,8是4的倍数
};

结论sizeof(struct S2) 为 8 字节。虽然成员相同,但顺序不同导致空间节省了4字节。

2.2 硬件视角的对齐原因

为什么存在内存对齐?这主要源于硬件层面的限制与优化:

  1. 平台兼容性(移植原因):并非所有硬件平台都能访问任意地址上的任意数据。某些硬件只能在特定地址边界读取特定类型的数据,否则会抛出硬件异常。
  2. 性能优化(空间换时间)
    • 未对齐访问 :如果数据跨越了两个内存块(例如一个int分布在地址3-6),处理器可能需要进行两次内存访问才能读取完整数据。
    • 对齐访问:对齐的数据可以在一次内存周期内读取完毕。
    • 硬件通识 :内存对齐本质上是拿空间换取时间的做法。
2.3 修改默认对齐数

使用 #pragma pack 指令可以修改编译器的默认对齐数,常用于网络协议包处理或节省空间。

cpp 复制代码
#include <stdio.h>

#pragma pack(1) // 设置默认对齐数为1,即取消对齐,紧凑排列
struct S
{
    char c1;
    int i;
    char c2;
};
#pragma pack() // 取消设置,还原默认

int main(void)
{
    // 1 + 4 + 1 = 6
    printf("Size of S: %zu\n", sizeof(struct S));
    return 0;
}

三、结构体传参:传值与传址的底层差异

3.1 传值调用与栈帧开销

在讲解函数栈帧时我们提到,函数调用会在栈区开辟空间。结构体传参同样遵循这一规则。

3.1.1 传值调用的性能损耗

cpp 复制代码
struct S
{
    int data[1000];
    int num;
};

void print1(struct S s) // 传值
{
    printf("%d\n", s.num);
}

底层分析(结合函数栈帧生命周期)

当调用 print1() 时,从栈帧创建到销毁的完整过程如下:

  1. 参数压栈与栈帧创建 :系统会在栈区为被调函数 print1() 开辟新的栈帧,并在这个栈帧中为形参 s 分配一块与原结构体大小完全相同的内存空间(约4004字节)。随后,系统会将实参 s 的每一个字节完整地拷贝(memcpy)到这块新空间中。
  2. 函数执行与数据隔离 :程序控制权转移至 updateNum 函数内部。此时函数内部对形参 s 的任何操作(如修改 num 的值),都是针对栈上这份独立的拷贝 进行的,完全不会影响 main 函数栈帧中的原始结构体变量。
  3. 栈帧销毁 :当 updateNum 函数执行完毕(return)时,系统会直接销毁整个栈帧。这意味着那块为了传参而临时拷贝的、占用数千字节的大块内存会被瞬间释放。

结论 :传值调用不仅涉及昂贵的数据拷贝 ,还会大量占用栈空间。如果结构体很大,这种拷贝会消耗大量的时间和空间资源,导致性能显著下降。

3.2 传址调用与指针效率

3.2.1 传址调用的优势

cpp 复制代码
void print2(struct S* ps) // 传址
{
    printf("%d\n", ps->num);
}

int main(void)
{
    struct S s = {{0}, 100};
    print2(&s); // 仅传递地址
    return 0;
}

底层分析 : 调用 print2(&s) 时,压栈的仅仅是结构体的地址。在32位系统中仅占4字节,64位系统中占8字节。相比于拷贝整个结构体,传递地址的开销微乎其微。

结论 :结构体传参时,首选传址调用

四、结构体位段:比特级的存储控制

4.1 位段的概念与声明

位段(Bit-field)允许我们指定成员变量占用的比特位数,常用于节省空间或对应硬件寄存器/网络协议。

4.1.1 位段语法规范

  1. 成员必须是 int, unsigned int, signed intchar(C99起支持)。
  2. 成员名后跟冒号和数字(位数)。
cpp 复制代码
struct A
{
    int _a : 2;
    int _b : 5;
    int _c : 10;
    int _d : 30;
};
4.2 位段成员的底层存储类型(Char )

刚才(C99起支持),提到位段成员可以是 char 类型。从底层原理来看:

  • ASCII码存储:char 类型在内存中本质上是以ASCII码值(整数)存储的。
  • 整数特性:位段操作的是二进制位,而 char 在计算机底层被视为一个8位的整数。因此,编译器允许对 char 类型进行位段切割,将其视为一个长度为8的位域容器。
4.3 位段的内存分配与跨平台问题

位段的空间分配涉及很多不确定因素,因此位段是不跨平台的

4.3.1 内存分配规则(依赖编译器)

  • 位段按照 int(4字节)或 char(1字节)的方式开辟空间。
  • 跨平台风险
    1. int 位段被当成有符号还是无符号是不确定的。
    2. 最大位数限制不确定(16位机器最大16,32位机器最大32)。
    3. 成员是从左向右分配还是从右向左分配,标准未定义。
    4. 当剩余位不足以容纳下一个成员时,是舍弃剩余位还是利用,标准未定义。

4.3.2 位段的应用场景 尽管存在跨平台问题,位段在特定场景非常有用,如网络协议(IP数据报)和硬件寄存器控制,因为它们能精确控制比特位,极大节省传输带宽或存储空间。

协议字段 位数 说明
版本号 4 仅需4位即可表示
首部长度 4 仅需4位
服务类型 8 ...
4.4 位段使用的注意事项

4.4.1 地址与取址操作符 位段的成员共享同一个字节,其起始位置可能不是字节的起始位置(例如从第3个bit开始)。内存中每个字节分配一个地址,但字节内部的bit位是没有独立地址的

因此,不能对位段成员使用 & 操作符

cpp 复制代码
struct A sa = {0};
// scanf("%d", &sa._b); // 错误:无法获取位段成员的地址

// 正确做法
int temp = 0;
scanf("%d", &temp);
sa._b = temp;

五、全章节逻辑闭环总结

本章从结构体的声明出发,深入探讨了其底层的内存布局与性能优化策略。

  1. 声明与初始化:掌握结构体的基本语法、匿名结构体的限制以及正确的自引用方式(指针)。
  2. 内存对齐 :理解了结构体大小并非成员简单相加,而是受对齐规则约束。这是硬件为了空间换时间的优化策略。通过调整成员顺序(如将小类型集中),可以有效节省内存。
  3. 函数传参 :结合函数栈帧知识,明确了结构体传参应首选传址调用,以避免大规模数据拷贝带来的性能损耗。
  4. 位段 :学习了如何利用位段进行比特级控制。虽然位段能极大节省空间(如网络协议),但因其跨平台兼容性差无法取地址,使用时需格外谨慎。