Skip to content

结构体、联合、枚举与内存对齐:把多个字段打包成一个整体 ​

引言:从一个名字对一个值,到一组字段一个整体 ​

前面 12 章咱们写的变量都是“一个名字对一个值”:int x、char c、double arr[10],类型再怎么变,一个变量只装一样东西。可现实里的数据几乎从来不是孤零零一个值:一个点是 x 和 y 两个坐标、一个学生是学号加姓名加成绩、一个网络包是包头加若干字段加载荷。您当然可以用三五个散装变量去凑(int id; char name[32]; double score;),可一旦要把“这个学生”整体传给一个函数、存进一个数组、从文件里读写,散装变量就抓瞎了,咱们没法把它们当一个东西搬来搬去。

C 给的答案就是结构体(struct):把多个、还可能是不同类型的字段,打包成一个整体,起一个名字,之后就能当一个值来传递、存储、操作。这一章咱们先把结构体本身搞通(怎么定义、初始化、用 . 和 -> 访问字段),再把它和内存扯上关系:您会看到编译器在咱们毫不知情时往字段之间塞了一堆叫“填充字节”(padding)的东西,让 sizeof(struct) 远比您以为的大,这是写 C 谁都要踩的一坑。最后把和结构体形影不离的三个小伙伴(联合 union、枚举 enum、位域 bitfield)一次讲清楚,它们在数据结构那一程的链表/树、系统编程那一程、以及任何一块 MCU 的头文件里都随处可见。

结构体基础:定义、初始化、. 和 -> ​

咱们定义一个结构体,用的是 struct 关键字加一对花括号,把字段一个个列在里面(§6.7.2.1)。结尾那个分号千万别漏,漏了它编译器报的错往往指向下一行,让您一头雾水找半天。一个最小的点(Point)真跑一遍,把定义、初始化、访问字段、结构体指针、结构体数组、还有链表节点惯用法一口气覆盖:

展开代码收起代码共 58 行
C
#include <stdio.h>

/* 命名一个 struct 标签:用变量时得带 struct 前缀 */
struct PointTag {
    int x;
    int y;
};

/* typedef 给 struct 起别名,用起来不用写 struct */
typedef struct {
    int x;
    int y;
} Point;

/* 链表节点惯用法:typedef 和 struct 标签同名,自引用用 struct Node* */
typedef struct Node {
    int data;
    struct Node* next; /* 这里还见不到 Node,只能写 struct Node* */
} Node;

int main(void) {
    /* 顺序初始化:按字段定义顺序填 */
    struct PointTag a = {3, 4};
    /* 指定初始化器(C99):.字段 = 值,顺序无关、未填的清零 */
    Point b = {.y = 10, .x = 20};
    Point zero = {0}; /* 全部清零 */

    printf("a = (%d, %d)\n", a.x, a.y);
    printf("b = (%d, %d)\n", b.x, b.y);
    printf("zero = (%d, %d)\n", zero.x, zero.y);

    /* 字段访问:变量名用 . */
    b.x = 99;
    printf("b.x 改后 = %d\n", b.x);

    /* 结构体指针:用 -> 箭头访问字段 */
    Point* p = &b;
    p->y = 88; /* 等价于 (*p).y = 88 */
    printf("p->y 改后 = %d, b.y = %d\n", p->y, b.y);

    /* 结构体数组 */
    Point pts[3] = {{1, 1}, {2, 2}, {3, 3}};
    for (int i = 0; i < 3; i++) {
        printf("pts[%d] = (%d, %d)\n", i, pts[i].x, pts[i].y);
    }

    /* typedef struct Node 自引用:搭一个 1 -> 2 -> 3 的小链表 */
    Node n3 = {3, NULL};
    Node n2 = {2, &n3};
    Node n1 = {1, &n2};
    Node* head = &n1;
    printf("链表: ");
    for (Node* cur = head; cur != NULL; cur = cur->next) {
        printf("%d ", cur->data);
    }
    printf("\n");
    return 0;
}
终端
$ gcc -std=c11 -Wall -Wextra struct_basic.c -o sb && ./sb
a = (3, 4)
b = (20, 10)
zero = (0, 0)
b.x 改后 = 99
p->y 改后 = 88, b.y = 88
pts[0] = (1, 1)
pts[1] = (2, 2)
pts[2] = (3, 3)
链表: 1 2 3

这里有几个要点揉在一起,咱们一个个拆。先看两种声明方式:struct PointTag { ... }; 定义了一个带标签(tag)的结构体类型,之后声明变量得老老实实写 struct PointTag a;(标签就是花括号前面那个名字);而 typedef struct { ... } Point; 直接给这个匿名结构体起了个别名 Point,之后写 Point b; 就行,清爽得多。两种在功能上等价,工程里 typedef 的写法更普遍,您翻任何一块 MCU 的 SDK,满眼都是 typedef struct { ... } XxxConfig;。

但这两种写法在自引用时会分叉,这是写链表/树这种“节点里有个指针指向同类型节点”的数据结构时必踩的一个点。看上面那个 Node:结构体里有个 next 指针,指向“另一个 Node”。可问题来了——typedef struct Node { ... } Node; 这一行的 Node 别名,要到这一行结束(那个分号写完)才生效;而 next 字段的声明在这一行中间,这时候 Node 这个名字编译器还没看见。所以 next 的类型只能写成 struct Node* next;(用还没结束的标签名,标签在花括号一打开就可见了),绝不能写 Node* next;,写后者编译器会一脸懵地问咱们“Node 是啥”。这是链表节点的标准写法:标签和 typedef 同名(typedef struct Node { ...; struct Node* next; } Node;),自引用用 struct Node*,外部用 Node,两不耽误。数据结构那一程的单链表全靠这一招。

初始化这块,指定初始化器(designated initializer,§6.7.9p6,咱们第 10 章数组那里见过 [2]=9 的数组版本)是 C99 给的大礼:Point b = {.y = 10, .x = 20}; 想填哪个字段写哪个、顺序随便、没填的字段自动清零。比起老式的顺序初始化 Point b = {20, 10};(必须记位置、结构体改字段顺序就全得跟着改),它自文档化、不依赖字段顺序、还顺带帮咱们清零,现代 C 代码里只要能写就优先写它。Point zero = {0}; 则是“全部清零”的惯用法(部分初始化、剩下的自动 0,§6.7.9p21),第 10 章数组见过一样的招。

字段访问靠两个运算符分庭抗礼:变量名直接访问用 .(点),指针访问用 ->(箭头)。p->y 就是 (*p).y 的语法糖,因为 . 的优先级比 * 高,写 *p.y 会被理解成 *(p.y),所以要么老实写 (*p).y、要么用箭头 p->y,后者才是您天天会写的样子。只要函数参数是结构体指针,您几乎必定在用 ->,传指针而不是传整个结构体,既能省掉一笔不小的拷贝开销,又能让函数改调用者的数据,这一点指针与内存那一程讲“用指针改调用者的变量”时已经反复强调过。pts[3] 那一段顺便演示了结构体数组:本质就是“数组的每个元素是一个结构体”,初始化时一层花括号套一层花括号,访问就 pts[i].x,后面数据结构那一程写链表、树、哈希表时,节点数组就是这么组织的。

内存对齐与填充:同一个 struct,字段顺序不同,sizeof 居然不一样 ​

到这儿事情就开始有意思了。先问您一个问题:下面这个结构体占多少字节?

C
typedef struct {
    char c;  /* 1 字节 */
    int  i;  /* 4 字节 */
    char d;  /* 1 字节 */
} A;        /* 直觉 1+4+1=6,实际? */

直觉上 1 + 4 + 1 = 6 字节,对吧?但真跑一遍您会大吃一惊——sizeof(A) 是 12。那多出来的 6 个字节去哪了?答案是编译器在字段之间和末尾塞了填充字节(padding)。咱们把同一个结构体的字段重排一下,真跑对比:

展开代码收起代码共 54 行
C
#include <stddef.h> /* offsetof */
#include <stdio.h>

/* 字段顺序乱:char / int / char */
typedef struct {
    char c; /* 1 字节 */
    int i;  /* 4 字节 */
    char d; /* 1 字节 */
} A;        /* 直觉 1+4+1=6,实际? */

/* 字段重排:int 放前、两个 char 挤后面 */
typedef struct {
    int i;
    char c;
    char d;
} B;

/* 三种类型混排:更夸张 */
typedef struct {
    char a;
    char* p; /* 8 字节 */
    char b;
    double z; /* 8 字节 */
    char c;
} C;

int main(void) {
    printf("sizeof(A) = %zu\n", sizeof(A));
    printf("  A.c offset = %zu\n", offsetof(A, c));
    printf("  A.i offset = %zu\n", offsetof(A, i));
    printf("  A.d offset = %zu\n", offsetof(A, d));

    printf("sizeof(B) = %zu\n", sizeof(B));
    printf("  B.i offset = %zu\n", offsetof(B, i));
    printf("  B.c offset = %zu\n", offsetof(B, c));
    printf("  B.d offset = %zu\n", offsetof(B, d));

    printf("sizeof(C) = %zu\n", sizeof(C));
    printf("  C.a offset = %zu\n", offsetof(C, a));
    printf("  C.p offset = %zu\n", offsetof(C, p));
    printf("  C.b offset = %zu\n", offsetof(C, b));
    printf("  C.z offset = %zu\n", offsetof(C, z));
    printf("  C.c offset = %zu\n", offsetof(C, c));

    /* 对照:字段大小之和 vs 实际 sizeof */
    printf("--- 字段之和 vs sizeof ---\n");
    printf("A 字段和 = %zu, sizeof = %zu, 浪费 %zu 字节\n",
           sizeof(char) + sizeof(int) + sizeof(char), sizeof(A),
           sizeof(A) - (sizeof(char) + sizeof(int) + sizeof(char)));
    printf("B 字段和 = %zu, sizeof = %zu, 浪费 %zu 字节\n",
           sizeof(int) + sizeof(char) + sizeof(char), sizeof(B),
           sizeof(B) - (sizeof(int) + sizeof(char) + sizeof(char)));
    return 0;
}
终端
$ gcc -std=c11 -Wall -Wextra alignment.c -o al && ./al
sizeof(A) = 12
  A.c offset = 0
  A.i offset = 4
  A.d offset = 8
sizeof(B) = 8
  B.i offset = 0
  B.c offset = 4
  B.d offset = 5
sizeof(C) = 40
  C.a offset = 0
  C.p offset = 8
  C.b offset = 16
  C.z offset = 24
  C.c offset = 32
--- 字段之和 vs sizeof ---
A 字段和 = 6, sizeof = 12, 浪费 6 字节
B 字段和 = 6, sizeof = 8, 浪费 2 字节

A 和 B 字段一模一样、只是顺序不同,A 是 12 字节、B 是 8 字节,A 白白浪费了一半空间。这件事的根,在处理器访问内存的方式上。CPU 访问内存不是一个字节一个字节读的,它更喜欢按 2、4、8 字节的边界来。一个 int(4 字节)如果放在地址是 4 的倍数的地方,CPU 一次就能读出来。要是它不幸跨了一个 4 字节边界(比如放在地址 1),CPU 可能得分两次读、再拼起来,性能打折。某些老架构(像 ARM7TDMI 那种)更狠,直接抛硬件异常给咱们看。所以编译器为了性能(有些时候是为了正确性),会主动在字段之间塞 padding,让每个字段都落在它“喜欢”的地址上。

对齐规则咱们只需要记两条(§6.7.2.1 + §6.2.5p2 那一片)。每个字段的起始偏移,必须是它自己“对齐要求”的整数倍:char 对齐 1(任何地址都行)、int 对齐 4、指针和 double 在 64 位机上对齐 8,基本类型的对齐要求通常就等于它的大小。另一条是结构体的总大小,必须是它最大成员对齐要求的整数倍,这是为了让结构体数组里每一个元素都能满足对齐(数组是紧密排列的,第 i 个元素的地址是 base + i*sizeof(结构体),要是结构体本身不对齐到最大成员,下一个元素的字段就可能歪掉)。

咱们照这两条拆 A:c 在偏移 0(对齐 1,任意地址都满足),占 1 字节;下一个是 i,对齐 4,但下一个可用偏移是 1、不是 4 的倍数,所以编译器塞 3 个字节 padding 让 i 从偏移 4 开始;d 在偏移 8(对齐 1,没问题);最后结构体最大对齐是 4(int),当前到偏移 9、得补到 12。所以 6 字节的实际数据、撑成了 12 字节,一半是 padding。再看重排后的 B:i 在偏移 0(天然对齐 4),c 在偏移 4、d 在偏移 5(都只要对齐 1),最后到偏移 6、补到 8(最大对齐 4 的倍数),只浪费 2 字节。诀窍很朴素:对齐要求大的字段往前放、小的往后挤,把 padding 摊到最少。更夸张的是 C:它有 char* 和 double(都 8 字节对齐),所以每个 char 后面都塞了 7 个字节 padding,字段和才 19 字节、sizeof 撑到 40。这就是为什么在内存敏感的场合(嵌入式、大规模结构体数组),咱们养成“按对齐从大到小排字段”的习惯能省下真金白银。

标准库给的 offsetof 宏(§7.19,定义在 <stddef.h>)是调试对齐问题的利器,它精确告诉咱们某个字段在结构体里的字节偏移。上面真跑里 A.i offset = 4、A.d offset = 8 一眼看穿 padding 在哪儿。设计二进制协议帧、排查“为什么 sizeof 跟咱们想的不一样”时,写完结构体就 offsetof 打一遍是值得养成的习惯。

您想亲手改字段顺序、看 sizeof 怎么跳?试试下面这个(点“运行”就行)。把 char c 挪到 int 后面、删掉某个字段、或者加一个 double,每次点运行看 sizeof 和 offsetof 怎么变,这就是“字段顺序影响内存占用”最直白的演示:

Compiler Explorer

亲手玩:字段顺序改 sizeof

struct A{char;int;char} = 12 字节、struct B{int;char;char} = 8 字节,字段一样、顺序不同,sizeof 差一半。咱们改字段顺序、加字段、删字段,看 sizeof 和 offsetof 怎么跳,亲手感受编译器在字段之间塞的 padding。

/demos/struct_alignment.c

C11 的对齐控制:_Alignas 与 alignof ​

C11 之前想手动控制对齐,只能靠编译器扩展(GCC 的 __attribute__((aligned(n)))、MSVC 的 __declspec(align(n)))。C11 终于把它标准化了:_Alignas 强制对齐、_Alignof 查询对齐,配上 <stdalign.h> 里更顺手的宏别名 alignas / alignof(§6.7.5 对齐说明符、§7.15 <stdalign.h>)。咱们真跑一遍:

展开代码收起代码共 34 行
C
#include <stdalign.h> /* alignas / alignof 宏 */
#include <stdio.h>

typedef struct {
    char c;
    int i;
    char d;
} Natural; /* 自然对齐:最大成员 int 对齐 4 */

int main(void) {
    /* alignof 查类型的对齐要求(标准用法,接类型名) */
    printf("alignof(char)   = %zu\n", alignof(char));                               /* 1 */
    printf("alignof(int)    = %zu\n", alignof(int));                                /* 4 */
    printf("alignof(double) = %zu\n", alignof(double));                             /* 8 */
    printf("alignof(Natural)= %zu  (struct 取最大成员的对齐)\n", alignof(Natural)); /* 4 */
    printf("---\n");

    /* _Alignas 写在变量声明里,把变量整体对齐拔高 */
    Natural plain;            /* 自然对齐 4 */
    _Alignas(16) Natural n16; /* 拔到 16 */
    _Alignas(32) char buf[4]; /* 缓冲区拔到 32(DMA 常见要求) */

    printf("plain 的地址 %% 4  == %lu\n", (unsigned long)&plain % 4);
    printf("n16  的地址 %% 16 == %lu  (n16 起始地址被按 16 对齐)\n", (unsigned long)&n16 % 16);
    printf("buf  的地址 %% 32 == %lu  (DMA 缓冲区按 32 对齐)\n", (unsigned long)buf % 32);
    printf("---\n");

    /* _Alignas 能拔高不能压低:_Alignas(2) int x; 会编译失败
       (int 自然对齐 4,不能降到 2) —— 真坑:别以为 alignas 万能。
       想要「更紧」的结构只能靠字段重排或 __attribute__((packed)),
       alignas 没法把已有类型的对齐往下压。 */
    printf("alignas 只能拔高,不能压低(详见正文编译报错)\n");
    return 0;
}
终端
$ gcc -std=c11 -Wall -Wextra alignas.c -o aa && ./aa
alignof(char)   = 1
alignof(int)    = 4
alignof(double) = 8
alignof(Natural)= 4  (struct 取最大成员的对齐)
---
plain 的地址 % 4  == 0
n16  的地址 % 16 == 0  (n16 起始地址被按 16 对齐)
buf  的地址 % 32 == 0  (DMA 缓冲区按 32 对齐)
---
alignas 只能拔高,不能压低(详见正文编译报错)

alignof(Natural) 是 4,印证了上面那条规则——结构体的对齐等于它最大成员的对齐(Natural 里 int 对齐 4)。_Alignas(16) Natural n16; 把变量 n16 的对齐从自然 4 拔到 16,咱们真跑出来它的地址确实是 16 的倍数;_Alignas(32) char buf[4]; 把一个缓冲区拔到 32 字节对齐,这种写法在嵌入式里给 DMA 准备缓冲区时极其常见(DMA 经常要求缓冲区起始地址是 16/32 字节对齐的,不然传输会出错)。

但这儿有个真坑,笔者一开始就栽了一下:_Alignas 只能拔高、不能压低。笔者以为写个 _Alignas(2) int x; 能把 int 的对齐从自然的 4 压到 2(想要更紧凑的布局),结果 gcc 当场报错:

C
int main(void) {
    _Alignas(2) int x; /* int 自然对齐 4,想压到 2 → 编译错误 */
    return 0;
}
终端
$ gcc -std=c11 -Wall -Wextra -c alignas_shrink.c
alignas_shrink.c: In function 'main':
alignas_shrink.c:2:21: error: '_Alignas' specifiers cannot reduce alignment of 'x'
    2 |     _Alignas(2) int x; /* int 自然对齐 4,压到 2 → 编译错误 */
      |                     ^

clang 一样拦:error: requested alignment is less than minimum alignment of 4 for type 'int'。所以别指望 alignas 帮咱们把结构体变紧,它只能往大了拔。想要“取消所有 padding、字段紧挨着排”(典型场景:二进制通信协议帧,字节流里没有 padding),得靠编译器扩展 __attribute__((packed)),但代价是访问未对齐字段在某些架构上会慢、甚至直接 fault(ARM7TDMI 之类)。更稳的做法是:通信层用 packed 结构体解析原始字节、立刻转成对齐的内部结构体用,解析和业务分开,这条经验嵌入式工程里几乎是标配。

联合 union:同一块内存,不同时间当不同类型 ​

结构体是每个字段各占一块内存,联合(union,§6.7.2.5)正好反过来:所有成员共享同一块起始地址相同的内存。它的大小等于“最大那个成员”的大小(再按最大成员对齐补齐)。一个 int/float 的联合咱们真跑一遍,您就懂它怎么“变戏法”:

展开代码收起代码共 37 行
C
#include <stdio.h>

/* union:所有成员共享同一块内存 */
typedef union {
    int i;   /* 4 字节 */
    float f; /* 4 字节 */
} IntOrFloat;

/* 大小 = 最大成员(可能补齐到最大成员的对齐) */
typedef union {
    char c; /* 1 字节 */
    int i;  /* 4 字节 */
} Mix;      /* sizeof = 4 */

int main(void) {
    printf("sizeof(IntOrFloat) = %zu\n", sizeof(IntOrFloat)); /* 4 */
    printf("sizeof(Mix)        = %zu\n", sizeof(Mix));        /* 4 */

    IntOrFloat u;
    u.f = 3.14f; /* 当 float 写 */
    printf("当 float 读: f = %f\n", u.f);
    /* 同一块内存当 int 读:看 float 的位模式(类型双关) */
    printf("当 int 读:   i = %d (0x%08X)\n", u.i, (unsigned)u.i);

    /* 反过来:写 int 再读 float */
    u.i = 0x4048F5C3; /* 这是 3.14f 的 IEEE754 位模式 */
    printf("写 0x4048F5C3 后读 float: f = %f\n", u.f);

    /* 同址实锤:&u.i 和 &u.f 是同一个地址 */
    printf("&u.i = %p\n", (void*)&u.i);
    printf("&u.f = %p\n", (void*)&u.f);

    /* 坑:同一时刻只有「最后一次写的成员」有意义 */
    u.i = 42;
    printf("写 u.i=42 后,u.f = %f (这个 float 毫无意义,位模式是 42 的)\n", u.f);
    return 0;
}
终端
$ gcc -std=c11 -Wall -Wextra union_demo.c -o un && ./un
sizeof(IntOrFloat) = 4
sizeof(Mix)        = 4
当 float 读: f = 3.140000
当 int 读:   i = 1078523331 (0x4048F5C3)
写 0x4048F5C3 后读 float: f = 3.140000
&u.i = 0x7fff679fcfe4
&u.f = 0x7fff679fcfe4
写 u.i=42 后,u.f = 0.000000 (这个 float 毫无意义,位模式是 42 的)

sizeof(IntOrFloat) = 4,int 和 float 各 4 字节,共享同一块,所以联合总共 4 字节(不是 8)。最震撼的是那一行 u.f = 3.14f; 之后读 u.i 得到 1078523331,写成十六进制正好是 0x4048F5C3,这就是 3.14f 在 IEEE 754 浮点表示下的真实位模式(符号 1 位 + 指数 8 位 + 尾数 23 位,合起来正好这 32 个比特)。换句话说,内存里那 4 个字节一直没变,咱们当 float 读它就解释成 3.14、当 int 读它就解释成 1078523331,值不一样是因为解读方式不一样,字节是同一份。&u.i 和 &u.f 打印出来是同一个地址(0x7fff679fcfe4),就是这件事的直接证据,这个地址每次运行因 ASLR 而不同,但两个一定相等。

这种“同一块内存换着类型读”的手法叫类型双关(type punning)。严格按 C 标准读,“写了一个成员再读另一个”原则上属于未定义行为,但 C99 起标准专门留了个口子(在联合那条附注里),允许通过联合做类型双关,所以咱们在无数 C 工程里能看到这种写法,而它跑得稳稳的。用途很实在:看浮点数的二进制位、把一个 32 位寄存器既当整体 uint32_t 又按位域拆开看、节省内存(同一块内存在不同时刻装不同类型的数据)。但坑也很明确:最后一行 u.i = 42; 之后读 u.f,得到一个毫无意义的 0.000000:因为这块内存现在的位模式是整数 42 的(0x0000002A),咱们硬当 float 解释就是个很小的数。联合在同一时刻只有一个成员是“有意义”的,咱们得自己记住现在写的是哪个,编译器不会帮咱们检查。所以单独的联合用处有限,真正发挥威力是和结构体、枚举组合成“tagged union”(标签联合体):加一个 enum 标签记下“当前存的是哪种类型”,再配 switch 按标签读,这就是 C 实现“一个变量多种类型”(多态)的经典手法。

枚举 enum:给整数起名字 ​

枚举(§6.7.2.2)让咱们给一组相关的整数起有意义的名字,语法很轻:

展开代码收起代码共 23 行
C
#include <stdio.h>

/* enum:给整数起名字,默认从 0 递增 */
typedef enum { RED, GREEN, BLUE } Color; /* RED=0, GREEN=1, BLUE=2 */

/* 自定义起始值 */
typedef enum {
    TEN = 10,
    ELEVEN, /* 11 */
    TWELVE  /* 12 */
} FromTen;

int main(void) {
    Color c = GREEN;
    printf("RED=%d GREEN=%d BLUE=%d\n", RED, GREEN, BLUE);
    printf("c = %d\n", c);
    printf("TEN=%d ELEVEN=%d TWELVE=%d\n", TEN, ELEVEN, TWELVE);

    /* enum 本质是 int:可以把非枚举值赋进去(类型安全漏洞) */
    Color bad = 42;
    printf("bad = %d (编译器不拦,但 42 不是任何 Color 值)\n", bad);
    return 0;
}
终端
$ gcc -std=c11 -Wall -Wextra enum_basic.c -o eb && ./eb
RED=0 GREEN=1 BLUE=2
c = 1
TEN=10 ELEVEN=11 TWELVE=12
bad = 42 (编译器不拦,但 42 不是任何 Color 值)

枚举值默认从 0 开始递增(RED=0, GREEN=1, BLUE=2),咱们可以用 = 值 显式指定起点,之后那个继续递增(TEN=10, ELEVEN=11, TWELVE=12)。比起满代码撒 0/1/2 这种“魔法数字”,Color c = GREEN; 自文档化、读起来一眼就懂,这就是枚举最大的价值。

但 C 的枚举有个让人又爱又恨的特点,您必须知道:枚举值本质上就是 int(§6.7.2.2p3,标准说枚举常量的类型是 int、枚举变量能装下枚举值的某个整数类型)。这意味着咱们完全可以把一个不属于任何枚举常量的整数塞进去,编译器一声不吭,上面真跑里最后那行 Color bad = 42; 就这么合法通过了、bad 打出 42,可 42 压根不是 RED/GREEN/BLUE 里的任何一个。C++ 后来搞了个 enum class 把这个口子堵了,C 没这福气。所以实践中常见的补救:给枚举末尾加个 *_COUNT 计数哨兵(比如 BLUE 之后再加一行 COLOR_COUNT,它的值正好是枚举项数),拿它做范围检查 if (c >= 0 && c < COLOR_COUNT);或者干脆把合法校验挡在运行期。靠类型系统是挡不住的,这是 C 枚举的宿命。

位域 bitfield:按位分配字段 ​

位域(§6.7.2.1p4 起)让咱们在结构体里以位为单位分配字段,语法是字段名后跟冒号和位数:

展开代码收起代码共 30 行
C
#include <stdio.h>

/* 位域:按位分配字段,基类型 unsigned(4 字节) */
typedef struct {
    unsigned a : 3; /* 3 位:可存 0..7 */
    unsigned b : 5; /* 5 位:可存 0..31 */
} Bits;             /* 共 8 位,但落在 unsigned 存储单元里 */

/* 位域基类型用 unsigned char(1 字节单元) */
typedef struct {
    unsigned char a : 3;
    unsigned char b : 5;
} BitsChar; /* 8 位正好 1 字节 */

int main(void) {
    printf("sizeof(Bits)     = %zu (基类型 unsigned,8 位落在 4 字节单元里)\n", sizeof(Bits));
    printf("sizeof(BitsChar) = %zu (基类型 unsigned char,8 位正好 1 字节)\n", sizeof(BitsChar));

    Bits x;
    x.a = 5;  /* 5 在 0..7 范围内 */
    x.b = 20; /* 20 在 0..31 范围内 */
    printf("x.a = %u, x.b = %u\n", x.a, x.b);

    /* 坑:3 位存不下 9,被截断(编译器还会警告) */
    x.a = 9; /* 9 = 0b1001,只留低 3 位 0b001 = 1 */
    printf("x.a = 9 后读到 %u (3 位装不下,截断成 1)\n", x.a);
    x.b = 40; /* 40 = 0b101000,5 位装 0b01000 = 8 */
    printf("x.b = 40 后读到 %u (5 位装不下,截断成 8)\n", x.b);
    return 0;
}
终端
$ gcc -std=c11 -Wall -Wextra bitfield.c -o bf
bitfield.c:25:11: warning: unsigned conversion from 'int' to 'unsigned char:3' changes value from '9' to '1' [-Woverflow]
   25 |     x.a = 9; /* 9 = 0b1001,只留低 3 位 0b001 = 1 */
      |           ^
bitfield.c:27:11: warning: unsigned conversion from 'int' to 'unsigned char:5' changes value from '40' to '8' [-Woverflow]
   27 |     x.b = 40; /* 40 = 0b101000,5 位装 0b01000 = 8 */
      |           ^~
$ ./bf
sizeof(Bits)     = 4 (基类型 unsigned,8 位落在 4 字节单元里)
sizeof(BitsChar) = 1 (基类型 unsigned char,8 位正好 1 字节)
x.a = 5, x.b = 20
x.a = 9 后读到 1 (3 位装不下,截断成 1)
x.b = 40 后读到 8 (5 位装不下,截断成 8)

咱们看 unsigned a : 3; 给 a 分 3 个比特(能存 0..7),unsigned b : 5; 分 5 个比特(能存 0..31),合起来 8 位——这就是位域的卖点:精确到位的紧凑存储,用来存标志位、协议头标志、硬件寄存器位段特别合适。3 位存 5 没问题,但 x.a = 9 就翻车了:9 = 0b1001 有 4 位,3 位装不下,编译器只留低 3 位 0b001 = 1,所以读回来是 1。gcc 和 clang 都会警告(-Woverflow / -Wbitfield-constant-conversion),这个坑编译器能帮咱们抓到一部分,但您得开 -Wall 才看得到。

这里有个一开始真把笔者搞懵的事:Bits 和 BitsChar 字段一模一样(都是 a:3, b:5、共 8 位),但 sizeof(Bits) 是 4、sizeof(BitsChar) 是 1。差别全在基类型:位域字段是装在“以基类型为单元”的存储里的:unsigned 是 4 字节,所以哪怕您只用 8 位,编译器也按 4 字节单元给咱们开;unsigned char 是 1 字节,8 位正好填满 1 字节。更深的坑还在后头:C 标准对位域的几个关键细节没有规定,位域是从低位往高位排还是反过来、跨存储单元边界时怎么处理、有符号位域怎么符号扩展,这些全交给编译器实现(§6.7.2.1 里明确写了“实现定义”)。所以位域的可移植性很差:您在 gcc/x86 上写的位域映射,换到另一个编译器或架构,位的排列可能完全不一样。这就是为什么很多嵌入式项目(尤其跨平台库)宁愿用第 6 章那套手写位运算掩码(#define CTRL_ENABLE (1U<<0) + |= &= ~),也不碰位域:位运算可移植、不依赖编译器;位域省事、但只在您这棵编译器树上靠得住。真要用位域映射硬件寄存器,咱们认准厂商给的标准头文件(像 STM32 的 CMSIS),那里的位域结构是厂商验证过和平台一致的,自己手写别瞎来。

柔性数组成员 FAM(C99):一次 malloc,装下结构体加可变尾巴 ​

最后一个,柔性数组成员(Flexible Array Member,§6.7.2.1p18,C99 引入)。它允许咱们在结构体末尾放一个大小不写的数组 data[],然后一次 malloc 把结构体和后面那段“可变长度的尾巴数据”一起分配。一个最小的缓冲区(Buf)咱们真跑:

展开代码收起代码共 34 行
C
#include <stdio.h>
#include <stdlib.h>

/* 柔性数组成员(C99):末尾的不定长数组 data[] */
typedef struct {
    int len;
    int data[]; /* 不占 struct 的 sizeof,只是一段「跟着结构体的尾巴内存」 */
} Buf;

int main(void) {
    int n = 5;
    /* 一次 malloc 把结构体 + n 个 int 的尾巴一起分配 */
    Buf* b = malloc(sizeof(Buf) + n * sizeof(int));
    if (b == NULL) {
        return 1;
    }
    b->len = n;
    for (int i = 0; i < n; i++) {
        b->data[i] = i * i; /* 0 1 4 9 16 */
    }

    printf("sizeof(Buf) = %zu (不含 data,只是一个 int 的 len)\n", sizeof(Buf));
    printf("实际分配 = %zu 字节 (sizeof(Buf) + %d*%zu)\n", sizeof(Buf) + (size_t)n * sizeof(int), n,
           sizeof(int));
    printf("b->len = %d\n", b->len);
    printf("b->data: ");
    for (int i = 0; i < b->len; i++) {
        printf("%d ", b->data[i]);
    }
    printf("\n");

    free(b); /* 一次 free 连结构体带尾巴一起释放 */
    return 0;
}
终端
$ gcc -std=c11 -Wall -Wextra fam.c -o fam && ./fam
sizeof(Buf) = 4 (不含 data,只是一个 int 的 len)
实际分配 = 24 字节 (sizeof(Buf) + 5*4)
b->len = 5
b->data: 0 1 4 9 16

咱们看 int data[]; 这个写法是柔性数组成员——它是“不完整类型的数组”,在结构体里不占 sizeof(sizeof(Buf) = 4,只是 len 那个 int)。它真正的含义是:“这个结构体的末尾,可能跟着一段连续的 int 内存,具体多少个由咱们 malloc 时决定”。用法就是那一行 malloc(sizeof(Buf) + n * sizeof(int)),结构体本体的字节数,加上 n 个 int 的尾巴,一次分配到位;之后 b->data[i] 就能像普通数组那样访问,b->data[0] 紧挨着 b->len 后面。free 也只要一次,连结构体带尾巴一起释放,不会漏。

FAM 解决的是“结构体带一段长度可变的尾巴数据”这个需求:网络包(定长包头 + 变长载荷)、变长字符串、动态数组,在没有 FAM 的年代,人们要么分两次 malloc(结构体一次、数据一次,释放也得两次、容易漏)、要么搞个叫“struct hack”的脏技巧(在结构体末尾放个 data[1]、再多分配点空间,靠越界访问凑),后者是未定义行为。C99 的 FAM 才是干净、标准的做法。数据结构那一程写动态数据结构、系统编程那一程写协议解析时,您会反复用到它。还有一点咱们务必记住:含柔性数组成员的结构体不能按值传递或整体拷贝,因为 sizeof 不知道尾巴多大,= 或传参会只拷 sizeof(Buf) 那点(尾巴丢光)。FAM 结构体永远只能通过指针操作。

小结 ​

C 把“一组相关字段”打包成一个整体的工具是结构体(§6.7.2.1,typedef struct { ... } Name;),声明变量后用 . 访问字段、用指针配 ->(箭头,(*p). 的语法糖)改字段;初始化优先用 C99 指定初始化器 .field = value(顺序无关、未填字段自动清零、自文档化);自引用的链表/树节点有标准写法 typedef struct Node { ...; struct Node* next; } Node;,结构体内部自引用必须用 struct Node*(typedef 别名这一行还没生效),外部才能用 Node。结构体最容易被忽视的真相是内存对齐与填充:每个字段要落在自己“对齐要求”(基本类型通常等于其大小)的整数倍偏移上,结构体总大小还得是最大成员对齐的整数倍,于是同一组字段、顺序不同,sizeof 能差出 50%(struct A{char;int;char} 字段和 6 但 sizeof 12、重排成 struct B{int;char;char} 就 8);offsetof(<stddef.h>,§7.19)能精确看到每个字段的偏移,排查对齐问题全靠它;诀窍是“对齐大的字段往前放”。C11 的 _Alignas(变量声明里写,如 _Alignas(16) Natural n16;)能把变量对齐拔高(给 DMA 缓冲区对齐到 16/32 常见),alignof/_Alignof 查类型对齐,但 alignas 只能拔高、不能压低(_Alignas(2) int x 直接编译失败),想取消 padding 得靠非标准的 __attribute__((packed))(代价:某些架构访问未对齐字段会慢或 fault)。联合 union(§6.7.2.5)让所有成员共享同一块内存、大小取最大成员,经典用法是“写 float 读 int”做类型双关看 IEEE 754 位模式(3.14f 的位模式是 0x4048F5C3=十进制 1078523331),但同一时刻只有一个成员有意义,咱们得自己记住,所以联合常配 enum 标签做成 tagged union 实现 C 的“多态”。枚举 enum(§6.7.2.2)给整数起名字(RED=0,GREEN=1...,可 =值 自定义起点),但本质是 int(Color bad=42 合法通过、编译器不拦类型安全漏洞)。位域 bitfield(unsigned a:3;)按位分配、能紧凑存标志位,但基类型决定 sizeof(unsigned 基的 8 位要占 4 字节、unsigned char 基才 1 字节),且位排列顺序、跨单元边界等都是实现定义、可移植性差,跨平台代码宁可用第 6 章的手写位运算掩码。柔性数组 FAM(C99 §6.7.2.1p18,struct Buf { int len; int data[]; };)在结构体末尾留不定长数组,一次 malloc(sizeof(Buf)+n*sizeof(int)) 把结构体和变长尾巴一起分配、一次 free 释放,是变长协议包/动态数据的标准做法(不能按值拷贝、只走指针)。到这里,C 语言基底这一卷就完整了,您不仅有了写出“能跑且知道为什么”的程序所需的全部地基,还把数据结构那一程要用的东西(链表 struct Node* next、二叉树 struct Node{left,right}、动态数组 realloc+FAM)都备齐了。接下来,咱们把这些复合类型和指针算术、动态内存全拧到一起,开始造真正的数据结构。

参考资源 ​

  • ISO/IEC 9899:2011 §6.7.2.1(structure、union、enumeration 与 bit-field 的定义,含 p4 位域、p18 柔性数组成员 FAM)、§6.7.2.2(枚举 enum,p3 枚举常量类型为 int)、§6.7.2.5(联合 union 的内存共享语义)、§6.7.5(C11 _Alignas 对齐说明符)、§7.15(<stdalign.h> 的 alignas/alignof 宏别名)、§7.19(<stddef.h> 的 offsetof 宏)、§6.5.3.4(sizeof 运算符)
  • K. N. King《C Programming: A Modern Approach》第 16 章 Structures、Unions、Enumerations(struct 定义/初始化/->、union 共享内存、enum 本质是 int、位域的可移植性陷阱)
  • Robert C. Seacord《Effective C》第 5 章(struct 内存布局与对齐、_Alignas/alignof、柔性数组成员 FAM、__attribute__((packed)) 的代价)
  • 第 2 章:整型家族与 sizeof(sizeof 运算符、size_t 与 %zu)、第 6 章:位运算与移位(手写位运算掩码,位域的可移植替代)、第 10 章:数组(指定初始化器 [2]=9、结构体数组)、指针与内存·第 3 章:用指针改调用者的变量(结构体指针 ->、传指针省拷贝)、指针与内存·第 10 章:复杂声明与 typedef(typedef 给类型起别名、右左法则)
  • 数据结构·第 1 章:单链表(typedef struct Node { ...; struct Node* next; } Node; 的完整实战)、数据结构·第 5 章:动态数组(realloc 扩容,与 FAM 的对照)、数据结构·第 6 章:二叉树(struct Node{left,right} 的自引用)

87bb5f2 · 87bb5f2 · 2026-09-21