C语言结构体:自定义数据类型,让变量打包出行

本章目标:掌握结构体声明与初始化 + 理解内存对齐的来龙去脉 + 学会结构体正确传参 + 理解自引用原理


引入:数组的局限,就是结构体的舞台

学到现在,我们接触的数据类型都是"单一类型":

  • int a → 一个整数
  • double b → 一个浮点数
  • int arr[10] → 一组整数(同一种类型

但现实中的数据,往往不是同质化的。比如描述一个学生:

姓名 (字符串)、年龄 (整数)、性别 (字符)、身高 (浮点数)、学号(字符串)

用数组?不行------数组要求所有元素类型完全一致。

用 5 个独立变量?勉强能跑,但当你要管理一个班 50 个学生的时候......

c 复制代码
// 用独立变量的地狱
char name1[20], name2[20], ...;   // 50个名字变量?
int age1, age2, age3...;          // 50个年龄变量?
char sex1[5], sex2[5]...;         // 50个性别变量?

这根本没法维护,每个学生都要对应 5 个变量,改一个学生的信息要找半天。

结构体(struct)就是为了解决这类问题而生的:把不同类型的数据"打包"成一个逻辑整体。


第一部分:结构体的声明

1.1 基本语法

c 复制代码
struct 结构体标签
{
    成员1的类型  成员1的名字;
    成员2的类型  成员2的名字;
    成员3的类型  成员3的名字;
    ...
};   // 注意:末尾分号不能丢!丢了就是语法错误!

声明一个"学生"结构体:

c 复制代码
struct Stu
{
    char  name[20];   // 姓名(字符数组)
    int   age;        // 年龄(整数)
    char  sex[5];      // 性别(字符数组,"男"或"女")
    char  id[20];      // 学号(字符数组)
};                     // ← 这个分号是必须的!

1.2 声明的同时定义变量

c 复制代码
// 声明类型 + 定义变量,一行搞定
struct Point
{
    int x;
    int y;
} p1, p2;   // 同时定义两个 Point 类型的变量

1.3 匿名结构体(强烈不推荐,但面试必问)

c 复制代码
struct    // ← 没有标签名字,叫"匿名结构体"
{
    int a;
    char b;
    float c;
} x;      // 只在声明时定义了这个唯一的变量 x

⚠️ 提醒:匿名结构体看起来省事,但有两个严重问题:

问题一 :匿名结构体只能定义这一次。之后想再创建同类型的变量?不行,因为没有类型名

问题二(更坑) :成员完全相同的两个匿名结构体,在编译器眼里是两种完全不同的类型,不能互相赋值:

c 复制代码
struct { int a; } x;
struct { int a; } y;
y = x;  // ❌ 编译错误!类型不兼容!

所以面试官问"能不能用匿名结构体"时,标准答案是:尽量别用。给结构体加个标签名(tag),又简单又不踩坑。


第二部分:结构体变量的创建和初始化

2.1 三种创建变量的方式

c 复制代码
// 方式一:声明类型,再定义变量
struct Stu s1;        // 创建了一个 Stu 类型的变量 s1

// 方式二:声明时直接定义
struct Stu s2 = {"张三", 20, "男", "20230001"};

// 方式三:用 typedef 取别名(最常用)
typedef struct Stu
{
    char name[20];
    int age;
    char sex[5];
    char id[20];
} Stu;  // Stu 现在就是 struct Stu 的别名

Stu s3 = {"李四", 22, "女", "20230002"};  // 简洁多了!

2.2 初始化:顺序初始化

按成员声明的顺序依次赋值:

c 复制代码
struct Stu s = { "张三", 20, "男", "20230818001" };
//           { name,  age,  sex,      id     }(按顺序)

2.3 初始化:指定成员初始化(C99 新特性)

不按顺序也行,用 .成员名 指定:

c 复制代码
struct Stu s = {
    .age = 18,                    // 年龄放第一个,OK
    .name = "李四",
    .id   = "20230818002",
    .sex  = "女"
};

💡 实战建议:用指定初始化,代码可读性更好。当成员很多、或者成员类型相似(比如都是 int)时,指定初始化能避免搞错顺序而出 bug。

2.4 访问结构体成员:点号 .

c 复制代码
struct Stu s = {"张三", 20, "男", "20230818001"};

// 读成员
printf("姓名:%s\n", s.name);    // 张三
printf("年龄:%d\n", s.age);      // 20

// 写成员
s.age = 21;                       // 修改年龄
strcpy(s.name, "王五");           // 修改名字(数组不能直接赋值)

🔑 记忆口诀 :小数点 . = "的",s.name 就是"s 的 name"。


第三部分:结构体的自引用(链表基础)

3.1 错误示范:直接嵌套自己

c 复制代码
struct Node
{
    int data;           // 数据
    struct Node next;   // ❌ 错误!自身包含自身,大小无穷大!
};

为什么不行?

假设 struct Node 包含一个 int data(4字节)和一个 struct Node next

要计算 struct Node 的大小 = 4 + (struct Node 的大小)

→ struct Node 的大小 = 4 + (4 + (4 + (4 + ...))) → 无穷大

编译器直接报错:field has incomplete type

3.2 正确做法:用指针自引用

c 复制代码
struct Node
{
    int data;                  // 4 字节
    struct Node *next;         // 8 字节(64位系统)
};
// sizeof(struct Node) = 4 + 8 = 12 字节 ✅(编译器自动对齐到 16)

💡 核心区别:指针的大小是固定的(4 字节或 8 字节),不会随着指向的内容变大。用指针指向自身,结构体的大小就能确定了。

3.3 链表节点的完整示例

c 复制代码
#include <stdio.h>
#include <stdlib.h>

struct Node {
    int data;
    struct Node *next;   // 指向下一个节点
};

int main()
{
    // 创建三个节点:1 → 2 → 3 → NULL
    struct Node *n1 = malloc(sizeof(struct Node));
    struct Node *n2 = malloc(sizeof(struct Node));
    struct Node *n3 = malloc(sizeof(struct Node));

    n1->data = 1; n1->next = n2;
    n2->data = 2; n2->next = n3;
    n3->data = 3; n3->next = NULL;

    // 遍历链表
    struct Node *p = n1;
    while (p != NULL) {
        printf("%d ", p->data);  // 输出:1 2 3
        p = p->next;
    }

    // 释放内存
    free(n1); free(n2); free(n3);
    return 0;
}

3.4 typedef 场景下的自引用陷阱

错误代码:

c 复制代码
typedef struct      // 匿名结构体 + typedef
{
    int data;
    Node *next;    // ❌ 编译错误!Node 在这里还没定义完
} Node;

原因分析:

复制代码
1. typedef 开始定义
2. 碰到 Node* next; 尝试使用 Node 类型
3. 但是 Node 是 typedef 给"后面"匿名结构体起的别名
4. typedef 还没执行完,Node 还不存在
5. → 编译错误

正确写法(给结构体加标签):

c 复制代码
typedef struct ListNode   // ✅ 有标签名
{
    int data;
    struct ListNode *next;  // ✅ 用标签名引用自身,不依赖 typedef
} Node;                      // typedef 给整个类型起了别名 Node

🔑 记忆口诀 :自引用用指针,typedef 别用匿名,标签名记得写上


第四部分:结构体内存对齐(核心难点)

4.1 什么是内存对齐?

先看一个奇怪的现象:

c 复制代码
struct S1 { char c1; int i; char c2; };  // char + int + char
struct S2 { char c1; char c2; int i; };  // char + char + int

printf("%d\n", sizeof(struct S1));  // 12
printf("%d\n", sizeof(struct S2));  // 8

同样都是 1 + 4 + 1 = 6 字节的成员,为什么大小不一样?

答案就是:内存对齐(Memory Alignment)

4.2 什么是内存对齐?(通俗解释)

内存对齐:结构体成员在内存中不是紧凑排列的,而是按一定规则"隔开"存放。每个成员的起始地址必须是某个值(对齐数)的整数倍。

💡 高铁座位比喻:想象高铁车厢,每个座位的位置是固定的(1车1座、1车2座......)。如果硬要把"两个人"塞进同一个座位,检票员会要求换座位------CPU 也有类似的"座位规则",数据必须坐在自己的对齐位置上,读取才高效。

4.3 四大对齐规则(VS 环境,默认对齐数 = 8)

规则 内容
规则一 第 1 个成员的偏移量(相对于结构体起始地址)= 0
规则二 从第 2 个成员起:起始地址对齐到「自身大小 」和「默认对齐数 」的较小值
规则三 结构体总大小 = 所有成员对齐数中最大 的值的整数倍
规则四 嵌套结构体:内层结构体成员对齐到其内部最大对齐数的整数倍

🔑 默认对齐数:VS 默认 = 8,Linux gcc 没有默认对齐数(就是成员自身大小)。大多数场景下差异不大。

4.4 练习一:char + int + char(S1 = 12 字节)

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

手把手推演:

复制代码
偏移量(相对结构体起始地址):

偏移 0:      [  c1  ]  ← 规则一:第1个成员偏移=0,占1字节
偏移 1-3:    [  pad  ]  ← 规则二:i 对齐到 4,所以 1-3 要填充 3 字节
偏移 4-7:    [  i   ]  ← i 占 4 字节(4,5,6,7)
偏移 8:      [  c2  ]  ← c2 占 1 字节
偏移 9-11:   [  pad  ]  ← 规则三:总大小=最大对齐数(4)的倍数
                               目前 9 字节 → 补到 12(4的倍数)

sizeof(S1) = 12 字节 ✅

4.5 练习二:char + char + int(S2 = 8 字节)

c 复制代码
struct S2
{
    char c1;   // 大小=1,对齐数=1
    char c2;   // 大小=1,对齐数=1
    int  i;    // 大小=4,对齐数=4
};

手把手推演:

复制代码
偏移量:

偏移 0:      [  c1  ]  ← 第1个成员,偏移=0,占1字节
偏移 1:      [  c2  ]  ← c2 对齐数=min(1,8)=1 → 偏移1 OK(1是1的倍数)
偏移 2-3:    [  pad  ]  ← i 对齐到 4 → 偏移2 不是 4 的倍数 → 补到 4
偏移 4-7:    [  i   ]  ← i 占 4 字节

目前 8 字节,最大对齐数 = 4 → 8 是 4 的倍数 ✅
sizeof(S2) = 8 字节 ✅

💡 性能优化核心经验 :把相同类型的成员写在一起,可以减少填充字节。S1 和 S2 成员完全一样,但 S1 占 12 字节,S2 占 8 字节------优化了 33% 的空间!

4.6 练习三:double + char + int(S3)

c 复制代码
struct S3
{
    double d;   // 大小=8,对齐数=8
    char  c;    // 大小=1,对齐数=1
    int   i;    // 大小=4,对齐数=4
};

手把手推演:

复制代码
偏移量:

偏移 0-7:    [  d  ]  ← 第1个成员,偏移=0,占8字节
偏移 8:      [  c  ]  ← c 对齐数=min(1,8)=1 → 偏移8 OK
偏移 9-11:   [  pad  ]  ← i 对齐到 4 → 9 不是 4 的倍数 → 补到 12
偏移 12-15:  [  i  ]  ← i 占 4 字节

目前 16 字节,最大对齐数 = 8 → 16 是 8 的倍数 ✅
sizeof(S3) = 16 字节 ✅

4.7 练习四:嵌套结构体(S4)

c 复制代码
struct S3
{
    double d;   // 大小=8,对齐数=8
    char  c;    // 大小=1,对齐数=1
    int   i;    // 大小=4,对齐数=4
};              // S3 内部最大对齐数 = 8

struct S4
{
    char    c1;      // 大小=1,对齐数=1
    struct S3 s3;    // S3 本身对齐到 S3.max_align = 8
    double  d;       // 大小=8,对齐数=8
};

手把手推演 S4:

复制代码
偏移量:

偏移 0:      [  c1  ]  ← 第1个成员,偏移=0,占1字节
偏移 1-7:    [  pad  ]  ← s3 对齐数=8 → 偏移1 不是 8 的倍数 → 补到 8
偏移 8-15:   [  d  ]  ← S3.d,占 8 字节(8~15)
偏移 16:     [  c  ]  ← S3.c,占 1 字节
偏移 17-19:  [  pad  ]  ← S3.i 对齐到 4 → 偏移17 不是 4 → 补到 20(但 S3 总大小 16,已对齐8倍数)
             ← S3 结束于偏移 23(16 字节,已是8倍数 OK)
偏移 24-31:  [  d  ]  ← S4.d,占 8 字节

目前 32 字节,最大对齐数 = 8 → 32 是 8 的倍数 ✅
sizeof(S4) = 32 字节 ✅

第五部分:修改默认对齐数

5.1 #pragma pack 基本用法

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

#pragma pack(1)   // 把对齐数改为 1(完全按1字节紧凑排列)
struct S
{
    char c1;   // 偏移 0,占 1 字节
    int  i;    // 偏移 1,占 4 字节
    char c2;   // 偏移 5,占 1 字节
};
#pragma pack()    // 恢复默认对齐数(VS=8)

int main()
{
    printf("%d\n", sizeof(struct S));  // 输出 6(没有填充)
    return 0;
}

5.2 #pragma pack(4) 的效果

c 复制代码
#pragma pack(4)   // 对齐数改为 4
struct S
{
    char c1;   // 偏移 0,占 1 字节
    int  i;    // 偏移 4(min(4,4)),占 4 字节(4~7)
    char c2;   // 偏移 8,占 1 字节
    // 补到 12(最大对齐数4的倍数)
};
#pragma pack()

printf("%d\n", sizeof(struct S));  // 输出 12

5.3 什么时候用 #pragma pack?

场景 说明
网络协议数据包 协议规定的字段顺序和大小必须精确,不能有多余填充
文件格式解析 BMP、PNG、MP3 等文件格式有固定的二进制布局
硬件寄存器映射 硬件的寄存器地址是固定的,不能有偏移

⚠️ 提醒#pragma pack 会影响整个文件的作用范围,记得用完后 pack() 恢复默认,否则后面的所有结构体都会被影响。


第六部分:结构体传参------传值 vs 传地址

6.1 两种传参方式

c 复制代码
struct S
{
    int data[1000];   // 4000 字节
    int num;          // 4 字节
};

struct S s = {{1,2,3,4}, 1000};

// 传值:整个结构体(4004字节)压栈
void print1(struct S s)
{
    printf("%d\n", s.num);   // 只读,可以
}

// 传地址:只压一个指针(8字节,64位系统)
void print2(struct S *ps)
{
    printf("%d\n", ps->num);  // 用 -> 访问指针指向的结构体成员
}

6.2 传值的问题:性能开销

函数调用时参数是怎么传递的?

大多数情况下,函数参数是通过**栈(stack)**传递的。当调用 print1(s) 时:

复制代码
print1(s) 传值:        print2(&s) 传地址:
整个 s(4004字节)      指针 ps(8字节)
全部压入栈中            只需要把地址压栈
↓                       ↓
调用开销:大             调用开销:小

栈的空间是有限的。如果结构体很大(几千字节),频繁传值会导致:

  1. 栈空间消耗大(甚至栈溢出)
  2. 拷贝操作耗时(复制 4004 字节 vs 复制 8 字节)
  3. 性能显著下降

6.3 -> 操作符:访问指针指向的结构体成员

c 复制代码
struct Stu {
    char name[20];
    int age;
};

struct Stu s = {"张三", 20};
struct Stu *p = &s;

// 两种等价写法:
printf("%s\n", s.name);    // 普通变量:用 . 点号
printf("%s\n", p->name);   // 指针:用 -> 箭头
printf("%d\n", s.age);
printf("%d\n", p->age);

🔑 记忆口诀. = "的"(直接访问),-> = "指向的"(间接通过指针访问)。


第七部分:经典面试题精讲

面试题一:什么是内存对齐?为什么需要内存对齐?

参考答案

定义:结构体成员在内存中按一定规则存放,起始地址必须是对齐数的整数倍,不是想放哪儿就放哪儿。

为什么需要(两大原因)

① 性能原因(核心):CPU 访问内存不是逐字节的,而是以"字"为单位读取(32位系统 = 4字节,64位系统 = 8字节)。

复制代码
对齐访问(int 在地址 4):
地址    0  1  2  3  4  5  6  7
数据    ..  ..  ..  .. 12 34 56 78
                ────────────────
                一次读取,4字节全拿到 ✅

不对齐访问(int 在地址 5):
地址    0  1  2  3  4  5  6  7  8
数据    ..  ..  ..  .. 12 34 56 78 ..
             ────────────────  ────
             第一次读(4~7)  第二次读(8~11)→ 合并 ✅ 但慢!

② 硬件兼容性:某些 RISC 架构(早期 SPARC、MIPS)根本不支持不对齐访问,会触发总线错误(Bus Error)直接崩溃。


面试题二:计算下面结构体的大小

c 复制代码
struct S5
{
    char   a;                  // 1字节,对齐数=1
    struct {
        int   b;              // 4字节,对齐数=4(内层 max)
        char  c;             // 1字节,对齐数=1
    } inner;
    double d;                  // 8字节,对齐数=8(整体 max)
};

手把手推演:

复制代码
偏移量:

偏移 0:      [  a  ]  ← 第1个成员,偏移=0,占1字节
偏移 1-3:    [  pad  ]  ← inner 对齐到 inner.max=4 → 1 不是 4 的倍数 → 补到 4
偏移 4-7:    [  b  ]  ← inner.b,4字节
偏移 8:      [  c  ]  ← inner.c,1字节
偏移 9-11:   [  pad  ]  ← inner 自身大小=9 → 对齐到 max=4 → 补到 12
             ← inner 结束于偏移 11,总大小 12(4的倍数 OK)
偏移 12-19:  [  d  ]  ← S5.d,对齐数=8,偏移 12 是 8 的倍数,占 8 字节

目前 20 字节,最大对齐数 = 8 → 补到 24(8 的倍数)

sizeof(S5) = 24 字节 ✅

面试题三:结构体自引用为什么用指针而不用结构体本身?

参考答案

结构体包含自身类型变量会导致大小无穷大,因为:

复制代码
sizeof(Node) = sizeof(int) + sizeof(Node)
           = 4 + (4 + (4 + (4 + ...))) → ∞

结构体包含自身指针:指针大小固定(4 或 8 字节),所以:

复制代码
sizeof(Node) = sizeof(int) + sizeof(Node*)
           = 4 + 8 = 12 字节(编译器对齐后可能是 16)✅

面试题四:下面代码的输出是什么?

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

#pragma pack(2)
struct X {
    char a;     // 0      1字节
    int  b;    // 2~5    4字节(对齐到2)
    char c;     // 6      1字节
};
#pragma pack()

int main()
{
    printf("%d\n", sizeof(struct X));  // ?
    return 0;
}

推演

复制代码
偏移 0:      [  a  ]  ← 第1个,偏移=0,1字节
偏移 1:      [  pad  ]  ← b 对齐到 min(4,2)=2 → 偏移1 不是 2 的倍数 → 补到 2
偏移 2-5:    [  b  ]  ← b,4字节(2,3,4,5)
偏移 6:      [  c  ]  ← c,1字节

目前 7 字节,最大对齐数 = 2 → 补到 8(2 的倍数)

sizeof(X) = 8 ✅

答案:8


本章总结

结构体三步走:

c 复制代码
// ① 声明类型(加标签,不用匿名)
struct Stu { char name[20]; int age; char sex[5]; char id[20]; };

// ② 创建变量
struct Stu s = { "张三", 20, "男", "20230818001" };

// ③ 访问成员(.点号)
printf("%s", s.name);

内存对齐规则速记:

复制代码
第一条:第1个成员偏移 = 0
第二条:其他成员对齐到「自身大小」和「默认对齐数」的较小值
第三条:总大小 = 最大对齐数的整数倍
第四条:嵌套结构体对齐到自身最大对齐数

性能优化口诀:

成员按大小从大到小排(double > int > char),填充最少空间最省。

结构体传参:

复制代码
传值   → 拷贝整个结构体 → 栈开销大 → ❌
传地址 → 拷贝指针(4~8字节)→ 栈开销小 → ✅

易错点速查:

陷阱 正确做法
匿名结构体成员相同但类型不兼容 避免匿名结构体,加标签
自引用用了结构体变量 struct Tag *next 指针
memset 设 int 数组为非 0/-1 只用 memset 设 0 和 -1
成员顺序随意导致浪费空间 相同类型排一起,按大小降序

  • 结构体:把不同类型的变量打包成团,内存对齐让 CPU 读得更快,成员排好队省空间
  • 同样3个成员,顺序不同,大小从12变成8------优化内存,从学会排队开始
相关推荐
ycjunhua1 小时前
Spring AI 2.0 GA:ToolCallingAdvisor 重构与 Java Agent 新范式
java·人工智能·spring
老一岁1 小时前
c问题总结(1)
c语言·开发语言
开源必胜1 小时前
Future的异常处理以及CompletableFuture传播链、异常处理
java
一木 之林1 小时前
三、内存管理、指针与堆栈
c语言·jvm·c++
cxr8282 小时前
数学的本质:关系、模式与不变量的结构世界
人工智能·算法·机器学习
笨笨饿2 小时前
#110_SCH3000 平台图传系统配置指南:从 DVP 摄像头到 UVC/WiFi 双通道传输
c语言·stm32·单片机·嵌入式硬件·物联网·vim
程与留2 小时前
06_Qt 常用数据类型与容器:QString、QList、QVector、QMap 的性能与实现分析
数据结构·qt
林森lsjs2 小时前
队列 FIFO 原理 & 手撕两种队列实现(链表 + 循环数组)—数据结构陆
java·开发语言·数据结构·队列
何以解忧,唯有..2 小时前
Python time 模块详解:时间处理与日期操作指南
开发语言·python