摘要:结构体把不同类型的数据组织成一条记录。本文从三个平行数组讲起,覆盖结构体类型的声明、变量定义与初始化、成员访问和传参;再讲内存对齐、嵌套与自引用、结构体数组、位段和柔性数组。重点放在两件事:结构体在内存里怎样排,以及传值时到底复制了什么。
文章目录
- [1. 三个平行数组](#1. 三个平行数组)
- [2. 声明、定义与初始化](#2. 声明、定义与初始化)
- [3. 成员访问](#3. 成员访问)
- [4. 结构体传参](#4. 结构体传参)
- [5. 内存对齐](#5. 内存对齐)
- [6. 嵌套与自引用](#6. 嵌套与自引用)
- [7. 结构体数组](#7. 结构体数组)
- [8. 位段](#8. 位段)
- [9. 柔性数组](#9. 柔性数组)
- [10. 小结](#10. 小结)
1. 三个平行数组
要存三个学生的姓名、年龄和成绩,最容易想到的是三个数组:
c
#include <stdio.h>
int main(void)
{
char names[3][20] = {"张三", "李四", "王五"};
int ages[3] = {20, 18, 22};
int scores[3] = {90, 85, 88};
for (int i = 0; i < 3; i++)
{
printf("%s %d %d\n", names[i], ages[i], scores[i]);
}
return 0;
}
输出:
text
张三 20 90
李四 18 85
王五 22 88
三个数组靠相同的下标关联,names[1]、ages[1]、scores[1] 必须始终指向同一个学生。增加一个字段就要再加一个数组,插入或删除一个学生时,三个数组都要同步移动;只要有一处漏改,数据就对不上。
结构体(structure)把同一条记录的多个字段放进一个类型里,每个字段叫成员(member)。成员可以是不同类型,这一点和数组不同:数组要求所有元素同类型,结构体允许 char、int、数组、指针,甚至另一个结构体。

2. 声明、定义与初始化
结构体类型的一般写法是:
c
struct tag
{
member-list;
} variable-list;
struct 是关键字,tag 是结构体标签,member-list 是成员列表,variable-list 是声明类型时顺带定义的变量,平时可以省略。描述一个学生:
c
struct Stu
{
char name[20];
int age;
char sex[5];
char id[20];
}; // 分号不能丢
struct Stu 才是类型名,单独写 Stu s; 不合法;想省掉 struct 就得用 typedef。右花括号后面的分号也不能漏,漏了会把后面的代码一起带进声明里。
定义变量有两种方式。第一种是在声明类型的同时定义,第二种是先声明类型,再单独定义变量:
c
struct Stu
{
char name[20];
int age;
char sex[5];
char id[20];
};
struct Stu s1; // 全局变量
int main(void)
{
struct Stu s2; // 局部变量
return 0;
}
第二种更常用:类型只声明一次,变量可以在需要的地方定义,作用域和生命周期也更清楚。
初始化用 {} 把值括起来,可以按成员顺序写,也可以用 C99 的指定初始化器:
c
#include <stdio.h>
struct Stu
{
char name[20];
int age;
char sex[5];
char id[20];
};
int main(void)
{
struct Stu s1 = {"张三", 20, "男", "202818001"};
struct Stu s2 = {.age = 18, .name = "lisi", .id = "202818002", .sex = "女"};
struct Stu s3 = {"lisi", 18};
printf("[%s] [%d] [%s] [%s]\n", s1.name, s1.age, s1.sex, s1.id);
printf("[%s] [%d] [%s] [%s]\n", s2.name, s2.age, s2.sex, s2.id);
printf("[%s] [%d] [%s] [%s]\n", s3.name, s3.age, s3.sex, s3.id);
return 0;
}
输出:
text
[张三] [20] [男] [202818001]
[lisi] [18] [女] [202818002]
[lisi] [18] [] []
指定初始化器用 .成员名 = 值,顺序可以打乱,没写到的成员补 0。s3 只初始化了前两个成员,后面的 sex 和 id 都是空字符串。
「不完全初始化」和「完全不初始化」不是一回事。写了初始化器但没写全,剩下的成员按 0 补齐;局部变量如果连初始化器都没有,里面是不确定的值,用之前要逐个成员赋值。
结构体变量可以整体赋值,数组不行:
c
struct Stu s1 = {"张三", 20, "男", "202818001"};
struct Stu s2 = {0};
s2 = s1; // 逐成员复制
整体赋值复制的是每个成员的值,不是让两个变量指向同一块内存。改 s2 不会影响 s1。但如果成员是指针,复制的是指针本身,两个结构体会指向同一块内存,改一边两边都会变。
结构体里的数组成员也不能单独赋值,s1.name = "c"; 不合法,改字符串要逐个字符复制,或者调用字符串拷贝函数。结构体也不能直接用 == 比较,要比就逐个成员比。
用 typedef 可以省掉 struct 关键字:
c
#include <stdio.h>
typedef struct
{
int x;
int y;
} Point;
int main(void)
{
Point p = {3, 4};
printf("(%d, %d)\n", p.x, p.y);
return 0;
}
输出:
text
(3, 4)
这里声明的是一个匿名结构体(anonymous structure)类型,同时用 typedef 把类型名定为 Point。匿名结构体没有标签,通常只能配合 typedef 使用;即使两个匿名结构体的成员完全一样,编译器也会把它们当成不同的类型,不能互相赋值。
3. 成员访问
有结构体变量时,用点操作符 . 访问成员:
c
#include <stdio.h>
struct Stu
{
char name[20];
int age;
};
int main(void)
{
struct Stu s = {"张三", 20};
struct Stu *ps = &s;
printf("%s %d\n", s.name, s.age);
printf("%s %d\n", ps->name, ps->age);
printf("%s %d\n", (*ps).name, (*ps).age);
ps->age = 18;
printf("%s %d\n", s.name, s.age);
return 0;
}
输出:
text
张三 20
张三 20
张三 20
张三 18
有结构体指针时,用箭头操作符 -> 访问成员。ps->age 等价于 (*ps).age,-> 只是语法糖。
不能写成 *ps.age。. 的优先级高于 *,*ps.age 会先算 ps.age,而 ps 是指针,没有成员 age。想先解引用再加点,括号不能省。
| 写法 | 适用对象 | 等价写法 |
|---|---|---|
s.age |
结构体变量 | 无 |
ps->age |
结构体指针 | (*ps).age |
(*ps).age |
结构体指针 | ps->age |
结构体指针加减整数时,步长是整个结构体的大小,不是 1 个字节。ps + 1 跳过一整个 struct Stu,这一点和数组指针类似。
4. 结构体传参
结构体可以按值传给函数,也可以传地址:
c
#include <stdio.h>
typedef struct
{
char name[20];
int age;
} Student;
void ByValue(Student s)
{
printf("函数内 sizeof(s) = %zu\n", sizeof(s));
s.age = 100;
}
void ByAddress(Student *ps)
{
printf("函数内 sizeof(ps) = %zu\n", sizeof(ps));
ps->age = 200;
}
int main(void)
{
Student stu = {"zhangsan", 20};
printf("sizeof(stu) = %zu\n", sizeof(stu));
ByValue(stu);
printf("值传递后 age = %d\n", stu.age);
ByAddress(&stu);
printf("地址传递后 age = %d\n", stu.age);
return 0;
}
输出:
text
sizeof(stu) = 24
函数内 sizeof(s) = 24
值传递后 age = 20
函数内 sizeof(ps) = 8
地址传递后 age = 200
按值传参时,形参 s 是实参的一份副本,sizeof(s) 仍是整个结构体的 24 字节。函数里改 s.age 改的是副本,main 里的 stu.age 不受影响。
按地址传参时,形参 ps 是一个 8 字节的指针,函数里通过 ps->age 改的是原对象。结构体越大,两种方式的差距越明显:传值要复制整个结构体,传地址只传一个地址。
| 对比项 | 传值 Student s |
传址 Student *ps |
|---|---|---|
函数内 sizeof |
整个结构体大小 | 指针大小 |
| 操作对象 | 实参的副本 | 实参本身 |
| 改成员是否影响调用方 | 否 | 是 |
| 传递开销 | 随结构体大小增长 | 固定为指针大小 |
结构体传参优先传地址。只读时形参加 const,写成 void Print(const Student *ps),既防止函数误改,也让调用方一眼看出这是只读接口。结构体很小时传值也无妨,但接口统一传地址更稳妥,结构体以后变大时性能不会悄悄变差。
5. 内存对齐
结构体的大小不等于成员大小之和。看两个成员相同、顺序不同的结构体:
c
#include <stdio.h>
#include <stddef.h>
struct S1
{
char c1;
char c2;
int i;
};
struct S2
{
char c1;
int i;
char c2;
};
#pragma pack(1)
struct S3
{
char c1;
int i;
char c2;
};
#pragma pack()
int main(void)
{
printf("sizeof(S1) = %zu, sizeof(S2) = %zu, sizeof(S3) = %zu\n",
sizeof(struct S1), sizeof(struct S2), sizeof(struct S3));
printf("S1: c1=%zu c2=%zu i=%zu\n",
offsetof(struct S1, c1), offsetof(struct S1, c2),
offsetof(struct S1, i));
printf("S2: c1=%zu i=%zu c2=%zu\n",
offsetof(struct S2, c1), offsetof(struct S2, i),
offsetof(struct S2, c2));
return 0;
}
输出:
text
sizeof(S1) = 8, sizeof(S2) = 12, sizeof(S3) = 6
S1: c1=0 c2=1 i=4
S2: c1=0 i=4 c2=8
S1 和 S2 的成员都是 char、char、int,只是顺序不同,大小一个是 8 字节,一个是 12 字节。多出来的字节是填充字节(padding),由内存对齐(memory alignment)规则插入。

对齐规则有四条:
- 第一个成员放在偏移 0 处。
- 从第二个成员开始,每个成员放到「对齐数」的整数倍偏移处。对齐数取编译器默认对齐数和成员自身大小中的较小者;数组成员按元素类型的大小算,
char[20]的对齐数是 1,int[3]的对齐数是 4。 - 结构体总大小必须是最大对齐数的整数倍,不够就在末尾补填充字节。
- 嵌套结构体时,内层结构体按自己的最大对齐数对齐,整体大小仍要取所有最大对齐数的整数倍。
S1 的排布是:c1 在 0,c2 在 1,i 的对齐数是 4,所以从 4 开始,2 到 3 填 2 字节;总大小 8,正好是 4 的倍数。S2 的排布是:c1 在 0,i 从 4 开始,1 到 3 填 3 字节;c2 在 8,末尾再补 3 字节凑到 12。
offsetof 定义在 <stddef.h>,用来查成员离结构体起始地址的偏移。算 sizeof 不要靠猜,用 offsetof 和 sizeof 读出来最可靠。
对齐的原因是性能和平台限制。很多硬件平台只能从特定地址读取特定类型的数据;现代 CPU 按对齐块批量读内存,一个 4 字节整数如果跨了两个对齐块,就要读两次再拼接。对齐用少量填充字节换更少的内存访问,是拿空间换时间。
成员顺序会影响填充量。把小成员集中在一起,通常能少一些填充:
c
struct A
{
char c1;
int i;
char c2;
}; // 12 字节
struct B
{
char c1;
char c2;
int i;
}; // 8 字节
成员一样,B 比 A 少 4 字节。设计结构体时,可以按成员大小从大到小排,或者把小成员放在一起。
#pragma pack(n) 可以修改默认对齐数,#pragma pack() 恢复默认。上面的 S3 用 #pragma pack(1) 后没有填充,大小是 6。
这个指令常用于网络协议、文件格式这类要求字节布局精确可控的场景。代价是成员可能落在非对齐地址上,某些平台访问会变慢甚至触发异常,日常代码不要为了省几个字节随手 pack(1)。
不同平台和编译器的默认对齐数可能不同,同一个结构体的 sizeof 也可能不同。不要写死结构体大小,也不要用 memcmp 整体比较两个结构体,填充字节里的值是不确定的。
6. 嵌套与自引用
结构体的成员可以是另一个结构体。描述学生时,生日本身有年、月、日三个字段,可以先定义一个日期结构体:
c
#include <stdio.h>
struct Date
{
int year;
int month;
int day;
};
struct Student
{
char name[20];
struct Date birthday;
char id[20];
};
int main(void)
{
struct Student s = {"aoyun", {2008, 8, 8}, "2026010102"};
printf("%s %d-%d-%d %s\n", s.name, s.birthday.year,
s.birthday.month, s.birthday.day, s.id);
return 0;
}
输出:
text
aoyun 2008-8-8 2026010102
struct Student 里有一个 struct Date 成员,初始化时用嵌套的大括号:外层对应 Student 的成员,内层的 {2008, 8, 8} 对应 birthday 的三个成员。访问嵌套成员用连续的 .,写成 s.birthday.year。
嵌套结构体在内存里是实实在在占空间的。birthday 按 struct Date 的大小和对齐数放在 Student 内部,sizeof(struct Student) 包含它,不是存一个指针。
结构体还可以引用自己,用来构造链表、树这样的结构,这叫自引用(self-referential structure)。自引用必须用指针:
c
#include <stdio.h>
typedef struct Node
{
int data;
struct Node *next;
} Node;
int main(void)
{
Node n2 = {2, NULL};
Node n1 = {1, &n2};
for (Node *p = &n1; p != NULL; p = p->next)
{
printf("%d ", p->data);
}
printf("\n");
return 0;
}
输出:
text
1 2
next 是指向 struct Node 的指针,大小固定为 8 字节(64 位平台)。每个节点保存下一个节点的地址,最后一个节点的 next 是 NULL,遍历到 NULL 就结束。

如果写成 struct Node next;,结构体里包含一个和自己同样大的成员,大小会无穷大,编译器直接报错。自引用只能通过指针,指针的大小是固定的,结构体的大小才能确定。
typedef 和自引用一起用时,结构体内部还没完成 typedef,所以要写 struct Node *next,不能写 Node *next;typedef 结束后,外部就可以用 Node 了。
7. 结构体数组
一个学生用一个结构体变量,多个学生就用结构体数组(array of structures):
c
#include <stdio.h>
typedef struct
{
char name[20];
int age;
} Stu;
int main(void)
{
Stu s[3] = {{"张三", 20}, {"李四", 18}, {"王五", 22}};
for (int i = 0; i < 3; i++)
{
printf("%s %d\n", s[i].name, s[i].age);
}
return 0;
}
输出:
text
张三 20
李四 18
王五 22
结构体数组的元素在内存里连续存放,每个元素是一个完整的结构体。s[i] 是第 i 个结构体变量,s[i].name 是它的成员。数组长度可以用 sizeof(s) / sizeof(s[0]) 算出来,前提是 s 还在数组的作用域里,没有被传参退化。
结构体数组适合存表格型数据。要排序时,可以直接用《指针》一篇里的 qsort,比较函数按某个成员比较即可。
8. 位段
有时一个字段只需要几个 bit,用 int 存会浪费空间。位段(bit-field)把成员声明成「类型 成员名 : 位数」:
c
#include <stdio.h>
struct Flags
{
unsigned int read : 1;
unsigned int write : 1;
unsigned int mode : 3;
};
int main(void)
{
struct Flags f = {0};
f.read = 1;
f.write = 0;
f.mode = 5;
printf("sizeof(struct Flags) = %zu\n", sizeof(struct Flags));
printf("read = %u, write = %u, mode = %u\n", f.read, f.write, f.mode);
return 0;
}
输出:
text
sizeof(struct Flags) = 4
read = 1, write = 0, mode = 5
read 占 1 bit,write 占 1 bit,mode 占 3 bit,三个字段一共 5 bit,但它们按 unsigned int 的存储单元分配,所以整个结构体占 4 字节,而不是 1 字节。

位段有几个限制:
- 标准规定成员类型是
int、unsigned int、signed int,C99 还允许_Bool;现代编译器通常还支持char、short等类型,但这是编译器扩展。 - 位段成员不能取地址,因为一个字节内部的 bit 没有独立地址;也不能对单个位段成员用
sizeof。 - 不能用
scanf直接给位段成员输入值,因为scanf需要地址。先用一个普通变量接住输入,再赋值给位段成员。 - 位段的内存分配方向、跨存储单元的方式由编译器实现决定,不同编译器可能不同,不要依赖字节序列。
无符号位段能表示的范围是 0 到 2 n − 1 2^n - 1 2n−1。把 10 赋给 3 位的 unsigned int 位段,存进去的是 10 的低 3 位,也就是 2,编译器通常会给出截断警告。赋值前要确认范围。
位段适合表示标志位和权限组合。比如一个权限字段只需要读、写、执行三个 bit,用位段可以塞进一个整数里;网络协议里的 IP 头部也大量使用位段来描述版本号、首部长度等小字段。但跨平台传输时,位段的布局不可移植,通常改为按字节手动拼装。
9. 柔性数组
结构体的最后一个成员可以写成未知大小的数组,叫柔性数组(flexible array member):
c
#include <stdio.h>
#include <stdlib.h>
struct Flex
{
int n;
int a[];
};
int main(void)
{
int n = 5;
struct Flex *pf = (struct Flex *)malloc(sizeof(struct Flex) + (size_t)n * sizeof(int));
if (pf == NULL)
{
return 1;
}
pf->n = n;
for (int i = 0; i < n; i++)
{
pf->a[i] = i * i;
}
printf("sizeof(struct Flex) = %zu\n", sizeof(struct Flex));
printf("pf->a[4] = %d\n", pf->a[4]);
free(pf);
pf = NULL;
return 0;
}
输出:
text
sizeof(struct Flex) = 4
pf->a[4] = 16
a[] 不占结构体本身的空间,sizeof(struct Flex) 只有 n 的 4 字节。申请时写 sizeof(struct Flex) + n * sizeof(int),一次拿到「头部 + 数组」的连续内存;pf->a[i] 就落在这块内存的数组部分。

柔性数组必须满足两个语言层面的条件:必须是最后一个成员,前面至少有一个其他成员。使用时通常用 malloc 按「结构体大小 + 数组长度」分配。老代码里常见的 int a[0]; 是编译器扩展,C99 的标准写法是 int a[];。
也可以把结构体设计成「成员指针」的版本:
c
struct Flex2
{
int n;
int *a;
};
这种版本要申请两次:先 malloc 结构体,再 malloc 数组,让 a 指向它。释放时也要两次,先释放 a,再释放结构体。柔性数组版本只需要一次 malloc 和一次 free,数据连续,少一次间接访问,也不容易漏释放。
| 对比项 | 柔性数组 | 成员指针 |
|---|---|---|
| 申请次数 | 1 次 | 2 次 |
| 释放次数 | 1 次 | 2 次 |
| 内存是否连续 | 连续 | 不连续 |
| 扩容 | realloc 整个结构体 |
单独 realloc 数组成员 |
| 使用方是否需要知道内部指针 | 不需要 | 需要 |
柔性数组不能单独扩容,要调整元素个数时,用 realloc 重新调整整个结构体的空间,并同步更新记录元素个数的字段。动态内存的申请、释放和 realloc 的用法,在《指针》一篇有完整说明。
10. 小结
- 结构体把不同类型的数据组织成一条记录;
struct Stu才是类型名,右花括号后的分号不能丢。 - 初始化支持按顺序、指定初始化器和完全初始化;不完全初始化时缺的成员补 0;结构体能整体赋值,但不能直接用
==比较。 - 成员访问用
.,结构体指针用->;结构体传参优先传地址,只读接口用const指针。 - 内存对齐用填充字节换访问效率;对齐数取默认对齐数和成员大小的较小者,总大小补到最大对齐数的整数倍,调整成员顺序和使用
#pragma pack都会改变大小。 - 嵌套结构体初始化用嵌套大括号,访问用连续的
.;自引用必须用指针;结构体数组的元素连续存放,适合存表格型数据。 - 位段用「类型 成员名 : 位数」声明,能省空间,但布局由编译器实现决定,不能取地址,也不能单独
sizeof,跨平台性差。 - 柔性数组必须是最后一个成员,
sizeof不含它;一次malloc分配头部和数组,一次free释放全部,数据连续。