C语言学习:自定义类型-结构体

一、结构体类型的声明与基础使用

1.1 结构体回顾

结构体(struct)是不同类型数据的集合 ,这些数据称为成员变量

1.1.1 结构的声明

通用语法:

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

示例:描述一个学生信息

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

1.1.2 结构体变量的创建和初始化

两种初始化方式:

复制代码
#include <stdio.h>

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

int main() {
    // 1. 按成员顺序初始化
    struct Stu s = {"张三", 20, "男", "20230818001"};
    
    // 2. 按指定成员初始化(推荐,顺序无关)
    struct Stu s2 = {.age = 18, .name = "lisi", .id = "20230818002", .sex = "女"};
    
    printf("name: %s\n", s.name);
    printf("age : %d\n", s.age);
    return 0;
}

1.2 结构体的特殊声明(匿名结构体)

声明时可以省略结构体标签 (tag),但有明显限制(只能使用一次不能进行定义):

复制代码
// 匿名结构体1
struct {
    int a;
    char b;
    float c;
} x;

// 匿名结构体2
struct {
    int a;
    char b;
    float c;
} a[20], *p;

⚠️ 关键点 :编译器会把这两个匿名结构体当成完全不同的类型,所以下面的代码是非法的

复制代码
p = &x; // 类型不匹配,编译报错

匿名结构体如果不配合**typedef重命名**,基本只能使用一次


1.3 结构体的自引用(链表节点核心)

错误写法(自己放自己)

复制代码
struct Node {
    int data;
    struct Node next; // ❌ 结构体包含自身变量,大小会无限递归
};

问题:结构体里不能包含自身类型的成员变量,否则结构体大小无法确定。

正确写法(指针自引用)

复制代码
struct Node {
    int data;
    struct Node* next; // ✅ 指向同类型结构体的指针
};

指针的大小是固定的 (32 位系统 4 字节,64 位系统 8 字节),不会导致结构体大小无限递归

匿名结构体 + typedef 的坑

下面的代码是错误的:

复制代码
typedef struct {
    int data;
    Node* next; // ❌ 此时Node还未被定义
} Node;

解决方法:必须给结构体加上标签

复制代码
typedef struct Node {
    int data;
    struct Node* next; // ✅ 标签Node已经声明,可以使用
} Node;

二、核心:结构体内存对齐规则

这是 C 语言高频考点,也是计算结构体大小的唯一标准,记住这 4 条就够了:

  1. 第一个成员 :直接放在结构体起始地址偏移量为 0)。
  2. 后续成员 :对齐到「对齐数」的整数倍地址处。
    • 对齐数 =编译器默认对齐数成员自身大小较小值
    • VS 默认对齐数是 8 ;Linux(GCC)默认无对齐数对齐数就是成员自身大小。
  3. 结构体总大小 :必须是「最大对齐数」的整数倍所有成员的对齐数中取最大值)。
  4. 嵌套结构体 :嵌套的结构体成员,要对齐到自己内部最大对齐数的整数倍处整体结构体大小 要对齐到所有最大对齐数含嵌套结构体的的整数倍

2.1 练习题逐题解析(VS 环境,默认对齐数 8)

练习 1:struct S1

复制代码
struct S1 {
    char c1;  // 大小1,对齐数1 → 偏移0
    int i;    // 大小4,对齐数4 → 偏移需为4的倍数 → 偏移4(填充3字节)
    char c2;  // 大小1,对齐数1 → 偏移8
};
  • 成员占的总字节:1 + 4 + 1 = 6
  • 最大对齐数:4(来自 int i)
  • 总大小需为 4 的倍数 → 最终 sizeof(struct S1) = 12

练习 2:struct S2

复制代码
struct S2 {
    char c1;  // 大小1,对齐数1 → 偏移0
    char c2;  // 大小1,对齐数1 → 偏移1
    int i;    // 大小4,对齐数4 → 偏移需为4的倍数 → 偏移4(填充2字节)
};
  • 成员占的总字节:1 + 1 + 4 = 6
  • 最大对齐数:4(来自 int i)
  • 总大小需为 4 的倍数 → 最终**sizeof(struct S2) = 8**

💡 对比:S1S2 成员完全一样 ,只是顺序不同大小差了 4 字节! 这就是**「空间优化」的关键。**


练习 3:struct S3

复制代码
struct S3 {
    double d; // 大小8,对齐数8 → 偏移0
    char c;   // 大小1,对齐数1 → 偏移8
    int i;    // 大小4,对齐数4 → 偏移需为4的倍数 → 偏移12(填充3字节)
};
  • 成员占的总字节:8 + 1 + 4 = 13
  • 最大对齐数:8(来自 double d)
  • 总大小需为 8 的倍数 → 最终 sizeof(struct S3) = 16

练习 4:嵌套结构体 struct S4

复制代码
struct S4 {
    char c1;       // 大小1,对齐数1 → 偏移0
    struct S3 s3;  // 嵌套结构体S3,其内部最大对齐数为8 → 偏移需为8的倍数 → 偏移8(填充7字节)
    double d;      // 大小8,对齐数8 → 偏移24
};
  • 成员占的总字节:1 + 16(S3 大小) + 8 = 25
  • 最大对齐数:8(来自 double d 和 S3 的最大对齐数)
  • 总大小需为 8 的倍数 → 最终 sizeof(struct S4) = 32

2.2 为什么要内存对齐?(用空间换取时间)

内存对齐本质是用空间换时间,核心原因有 2 个:

  1. 平台原因(移植性) :不是所有硬件都能访问任意地址的数据,部分平台只能在特定地址读取特定类型的数据,否则会抛出硬件异常。
  2. 性能原因 :处理器访问未对齐的内存时 ,需要执行两次内存访问 ;而对齐的内存只需要一次访问 。比如读取一个 double 类型(8 字节),如果地址是 8 的倍数一次就能读完 ;如果不是,可能要分两次读取跨内存块的数据。(提高效率

2.3 结构体空间优化技巧

核心原则让占用空间小的成员尽量集中在一起

  • 反例(S1):char → int → char → 总大小 12 字节
  • 正例(S2):char → char → int → 总大小 8 字节
  • 效果**:成员完全相同,顺序调整后节省了 4 字节**!

2.3 修改默认对齐数:#pragma pack(最好为1 2 4 8这类数)

可以通过预处理指令修改编译器默认对齐数,在特殊场景下节省空间。

复制代码
#include <stdio.h>

#pragma pack(1) // 设置默认对齐数为1
struct S {
    char c1;  // 对齐数=min(1,1)=1 → 偏移0
    int i;    // 对齐数=min(1,4)=1 → 偏移1
    char c2;  // 对齐数=min(1,1)=1 → 偏移5
};
#pragma pack() // 取消自定义对齐数,恢复默认

int main() {
    // 此时总大小为1+4+1=6字节,无需填充
    printf("%d\n", sizeof(struct S)); // 输出6
    return 0;
}

⚠️ 注意修改默认对齐数会牺牲部分性能 ,一般只在空间极度紧张的场景(如嵌入式开发)使用。

三、结构体传参:为什么推荐传地址?

3.1 对比两种传参方式:

复制代码
struct S {
    int data[1000]; // 4000字节
    int num;        // 4字节
};
struct S s = {{1,2,3,4}, 1000};

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

// 2. 传地址调用
void print2(struct S* ps) {
    printf("%d\n", ps->num);
}

int main() {
    print1(s);    // 传结构体
    print2(&s);   // 传地址
    return 0;
}

输出的结果都是 100

3.2 两种方式的核心区别

方式 开销 特点 适用场景
传值调用(print1) 极高 会拷贝整个结构体副本 ,栈空间开销大,性能差 结构体极小 (如只有 1-2 个成员),且不希望修改原数据
传地址调用(print2) 极低 只传一个指针(8 字节) ,栈开销极小,性能高 绝大多数场景尤其是结构体较大时

3.3 关键结论与补充

  1. 性能差异struct S 大小是 4000 + 4 = 4004 字节,传值调用时,每次调用都要压入 4004 字节的栈帧;而传地址只压入 8 字节指针,性能差距非常明显。

  2. 修改原数据 :传值调用无法修改原结构体内容,只能操作副本;传地址可以直接修改原数据(配合const还能保证只读安全)。

  3. 最佳实践 :结构体传参优先传地址 ,如果只是读取数据,可以用const struct S* ps保证安全,既高效又不会误修改。

    // 优化后的安全只读版本
    void print2(const struct S* ps) {
    printf("%d\n", ps->num);
    // ps->num = 2000; // 编译报错,无法修改原数据
    }

四、什么是位段?

位段结构体的特殊形式,核心作用是用更少的内存存储数据。它的声明和普通结构体类似,但有两个关键区别:

  1. 成员类型必须是 int / unsigned int / signed int (C99 支持 char 等其他整型)
  2. 成员名后必须跟一个冒号和数字,表示该成员占用的二进制位数

示例:

复制代码
struct A {
    int _a : 2;  // 占用2个bit
    int _b : 5;  // 占用5个bit
    int _c : 10; // 占用10个bit
    int _d : 30; // 占用30个bit
};

4.1 位段的内存分配与大小计算

1. 分配规则

  • 位段会按「基础类型大小」分批开辟空间:int 类型按 4 字节(32bit)开辟,char 类型按 1 字节(8bit)开辟。
  • 同一批的位段成员会被打包进同一个基础类型的空间中,直到空间用完,才会开辟新的空间。

2. 计算 struct A 的大小

我们按 VS 环境的规则计算:

  • 第 1 批:_a(2bit) + _b(5bit) + _c(10bit) = 17bit ,小于 32bit → 存进第一个 int(4 字节)
  • 第 2 批:_d(30bit) ,需要新开辟一个 int(4 字节)
  • 总大小:4 + 4 = 8 字节

3. struct S 内存布局示例(VS 环境)

复制代码
struct S {
    char a:3;  // 占3bit
    char b:4;  // 占4bit
    char c:5;  // 占5bit
    char d:4;  // 占4bit
};
  • 第 1 个 char(8bit):a(3bit) + b(4bit) = 7bit ,剩余 1bit 空间不够放下 c(5bit)开辟新的 char
  • 第 2 个 char(8bit):c(5bit) ,剩余 3bit 空间不够放下**d(4bit)** ,开辟新的 char
  • 第 3 个 char(8bit):d(4bit)
  • 总大小:3 字节

VS 中实际存储:

  • a=10(二进制 1010取低 3 位 010
  • b=12(二进制 1100取低 4 位 1100
  • c=3(二进制 0011取低 5 位 00011
  • d=4(二进制 0100取低 4 位 0100
  • 最终按低地址到高地址打包成 3 个字节:0x62 0x03 0x04

4.2 位段的跨平台问题(⚠️ 重点避坑)

位段的实现标准并未完全统一存在很多不确定因素,直接影响可移植性:

  1. 符号性不确定int 类型的位段是否被当作有符号数,由编译器决定
  2. 最大位数限制 :位段的最大位数不能超过基础类型的总位数,比如 16 位机器上 int 是 16bit,定义 :27 会报错。
  3. 分配方向不确定:位段是从左到右分配,还是从右到左分配,标准没有定义。
  4. 剩余空间处理不确定:当前基础类型空间剩余的 bit 不够放下下一个成员时,是直接开辟新空间,还是跳过剩余空间再开辟,由编译器决定。

4.3 位段的应用场景

位段最经典的应用场景是网络协议(如 IP 数据报) :IP 数据报的头部有很多字段只需要几个 bit 就能表示,比如:

  • 版本号4bit
  • 首部长度4bit
  • 标志位3bit
  • 片偏移13bit使用位段可以把这些字段打包进同一个字节 / 字中,大幅减少数据报的大小,提升网络传输效率。

4.4 位段使用的注意事项

  1. 无法对位段成员取地址& 运算符不支持位段成员 ,因为它们没有独立的内存地址不能用 scanf 直接输入值 ,只能先存到普通变量中再赋值。

    复制代码
    struct A sa = {0};
    scanf("%d", &sa._b); // ❌ 错误:位段成员没有地址
    
    int b = 0;
    scanf("%d", &b);
    sa._b = b; // ✅ 正确:先输入到普通变量,再赋值
  2. 位段成员的取值范围有限 :比如 unsigned int a:3 只能存储 0~7(2³-1)的值,超出范围会溢出,需要手动处理。

  3. 不适合跨平台通用代码:如果你的代码需要在不同编译器 / 平台上运行,尽量避免使用位段。


4.5 总结

特性 说明
优点 大幅节省内存空间,适合嵌入式、网络协议等内存敏感场景
缺点 可移植性差,存在跨平台兼容性问题,无法直接取地址
核心规则 按基础类型大小分批打包,同一批成员共用基础类型的空间
相关推荐
kkeeper~1 小时前
0基础C语言积跬步之深入理解指针(5上)
c语言·开发语言·算法
2301_792674861 小时前
java学习(day34)
java·开发语言·学习
枫叶丹41 小时前
【HarmonyOS 6.0】Device Security Kit 深度解读:应用进程信息安全审计查询能力
开发语言·华为·harmonyos
skywalk81631 小时前
全面评估这门中文语言的情况,看它离一个可以实际产业落地的编程语言还有多远距离!
开发语言·编程
東隅已逝,桑榆非晚1 小时前
深⼊理解指针(6)
c语言·笔记
代码村新手1 小时前
C++-模板进阶
开发语言·c++
接着奏乐接着舞1 小时前
java jvm知识点
java·开发语言·jvm
Shadow(⊙o⊙)1 小时前
qt中自定义槽函数 内部继承逻辑、GUI+CLI协同1.0
开发语言·前端·c++·qt
摇滚侠1 小时前
Java 基础面试题 真正的 offer 偏方 Java 基础 Java 高级
java·开发语言