11.1 柔性数组成员
柔性数组成员 (Flexible Array Member, FAM) 由 C99 引入,它让一个结构体对象可以由固定的头部和长度可变的尾部组成。网络报文、内存块头部、字符串池等常采用这种布局。
1. 基本规则
柔性数组成员必须是结构体的最后一个成员,类型写成不带长度的数组声明。该结构体必须至少还有一个具名成员,因此“只含柔性数组成员”不合法。
struct packet {
size_t len;
unsigned char data[];
};2
3
4
sizeof(struct packet) 不包含 data 的实际存储区;它只计算固定头部,并可能包含对齐填充。
2. 正确分配方式
带柔性数组成员的对象通常通过一次动态分配获得,分配的大小为头部大小加上尾部数据的长度。头部大小可以用 sizeof(struct packet) 或 offsetof(struct packet, data) 计算;做加法之前要检查是否溢出。
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
struct packet {
size_t len;
unsigned char data[];
};
struct packet *packet_create(const unsigned char *src, size_t n) {
if (n > SIZE_MAX - sizeof(struct packet)) {
return NULL;
}
struct packet *p = malloc(sizeof(struct packet) + n);
if (p == NULL) {
return NULL;
}
p->len = n;
if (n != 0) {
memcpy(p->data, src, n);
}
return p;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
与“头部中保存一个指向另一块存储的指针”的做法相比,这种布局只需要一次分配和一次释放,头部与数据在内存中连续存放,局部性也更好。
3. 使用边界
柔性数组成员不是普通数组对象,不能单独赋值;只有为尾部实际分配了存储,才能访问 data[i]。
4. 赋值与复制
对包含柔性数组成员的结构体对象赋值时,只复制 sizeof(struct packet) 范围内的头部,不复制尾部数据。需要完整复制时,应按头部大小加尾部长度重新分配,再复制全部数据,而不能依赖结构体赋值。
5. 嵌套与容器设计
包含柔性数组成员的结构体不能再作为其他结构体的成员,也不能作为数组元素。设计容器时,通常把它放在最外层,需要多个这样的对象时分别分配,再通过指针引用它们。
6. 习题
6.1 巩固
下面几个声明中,有些不符合柔性数组成员的规则:
struct a {
unsigned char data[];
};
struct b {
unsigned char data[];
unsigned int id;
};
struct c {
unsigned int id;
unsigned char kind;
unsigned char data[];
};
struct d {
unsigned int id;
struct c inner;
};
struct c table[4];2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(1) [1.3] 逐个判断哪些声明违反了柔性数组成员的规则,分别违反了哪一条。用 gcc -std=c17 -Wall -Wextra -pedantic -c 编译,对照编译器对每一条给出的诊断:有的报 error,有的只在 -pedantic 下报 warning,说明这两类写法的性质有什么不同。
(2) [2.2] 对 struct c *p 写出 sizeof p->data 和 p->data = NULL,记录编译器的报错。这两条限制和 data 的类型(不完整的数组类型)有什么关系?若尾部长度在类型里就固定为 16 字节,成员和结构体的声明各要怎么改?
(3) [2.3] 只保留 struct c,写程序打印 sizeof(struct c) 与 offsetof(struct c, data)。两者相等吗?不相等时,差出来的字节是尾部填充:结构体的大小必须是其对齐值的整数倍。按 n 字节的尾部申请存储时,两种算法得到的长度各是多少?哪一种恰好是所需的最小长度,哪一种会多申请几个字节?
下面的克隆函数按 offsetof 计算分配长度,却用结构体赋值复制内容:
#include <stddef.h>
#include <stdlib.h>
struct message {
size_t len;
char kind;
unsigned char data[];
};
struct message *message_clone(const struct message *src) {
struct message *copy = malloc(offsetof(struct message, data) + src->len);
if (copy == NULL) {
return NULL;
}
*copy = *src;
return copy;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
结构体赋值只处理 sizeof(struct message) 个字节,与 src->len 无关。sizeof 比 offsetof 多出来的那几个字节本是尾部填充,赋值却把它们落在了尾部数据区的位置上。用下面的实验看赋值写了哪些位置:
- 源对象和目标对象都按
offsetof(struct message, data) + 32分配。 - 源对象整块填
0xEE,目标对象整块填0x23。 - 源对象设置
kind = 'M',data的 32 个字节依次写成(unsigned char)i,len取 3。执行*copy = *src。 - 从目标对象起始地址开始,打印
offsetof(struct message, data) + 20个字节。
(1) [3.2] 先打印 sizeof(struct message) 与 offsetof(struct message, data),记下差值 k。运行实验,找出最后一个被改写的字节在哪个位置;从 offsetof 开始被改写的那几个字节,在源对象里对应 data 的哪几个元素?改写结束的位置与 len 有没有关系?
(2) [3.3] 把 len 改成 16 再做一遍。目标对象里 data[k] 到 data[15] 还是 0x23 吗?两次赋值改写的字节数相同吗?改写范围由 len 决定还是由结构体类型决定?
(3) [4.3] 回到函数原来的分配长度。设赋值实际写入了 m 字节(由 (1)(2) 的实验得出,GCC 上 m 等于 sizeof(struct message)):len = 3 时赋值越过了分配区的末尾几个字节?len = 16 时又有几个尾部字节落在分配区内却没有被复制?重写 message_clone,给出两种正确的写法并各自验证:一种按 offsetof(struct message, data) 复制头部、再按 len 复制尾部;一种把分配长度改成 sizeof(struct message) + src->len,保留结构体赋值,再单独用 memcpy 复制尾部。比较两者每个对象多占的字节数,以及各自容易漏掉哪一步。
6.2 编程
实现带尾部数据的报文对象:
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
struct packet {
uint32_t id;
uint16_t len;
unsigned char data[];
};
struct packet *packet_create(uint32_t id, const void *src, size_t n);
struct packet *packet_append(struct packet *p, const void *src, size_t n);
void packet_destroy(struct packet *p);2
3
4
5
6
7
8
9
10
11
12
13
要求:
packet_create一次分配offsetof(struct packet, data) + n字节,写入id和len,复制尾部。n == 0时允许src == NULL。n超过UINT16_MAX时返回NULL。packet_append用realloc扩容并返回新指针。成功时len增加n;失败时返回NULL,原对象仍然有效。调用方不能直接写p = packet_append(p, …),否则失败时连原指针也丢了。- 追加后总长超过
UINT16_MAX时拒绝,不改变对象。n == 0时直接返回原指针。 - 复制尾部一律用
memcpy,不要用结构体赋值。 packet_destroy(NULL)安全;每个对象只释放一次。
用下面的序列验收:
packet_create(7, "abc", 3)。- 追加
"defg"4 字节。 - 再追加 1000 个字节
'x'。 - 创建
packet_create(9, NULL, 0)。 - 用 65530 个字节
'y'创建对象,再尝试追加 10 字节,记录返回值确认拒绝。
输出应与下面一致:
create: id=7 len=3 data=abc
append: id=7 len=7 data=abcdefg
grow: id=7 len=1007 first=a last=x
empty: id=9 len=0
limit: rejected=1 len=655302
3
4
5
把带有柔性数组成员的报文对象写入字节流,再读回来。接口如下:
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
struct frame {
uint32_t seq;
uint16_t len;
unsigned char payload[];
};
size_t frame_wire_size(const struct frame *f);
bool frame_encode(
const struct frame *f,
unsigned char *out,
size_t capacity,
size_t *out_size
);
struct frame *frame_decode(const unsigned char *buf, size_t size);
void frame_destroy(struct frame *f);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
线上格式为:seq 四字节,大端序;len 两字节,大端序;随后是 len 个字节的载荷。
要求:
frame_wire_size返回offsetof(struct frame, payload) + f->len。frame_encode恰好写出frame_wire_size个字节,seq和len按大端序逐字节写。不要按sizeof(struct frame)整体复制头部,sizeof比offsetof多出的那部分是多字节成员之后的尾部填充,把它写进字节流,接收方的长度字段就对不上了。容量不够时返回false,*out_size不变。frame_decode只在size恰好等于头部长度加len时成功,否则返回NULL。成功时按offsetof加len分配,写入头部字段并复制载荷。frame_destroy(NULL)安全。- 测试长度为 0、3、300 的三种载荷:编码后再解码,
seq、len和载荷逐字节相同;把字节流截去 1 字节和多出 1 字节,解码都应返回NULL。
长度为 3、seq = 0x01020304、载荷为 AA BB CC 时,编码结果应为 9 字节:
01 02 03 04 00 03 AA BB CC报告 sizeof(struct frame) 与 offsetof(struct frame, payload) 的差,说明这部分字节为什么不能写进字节流。
6.3 综合与挑战
设计一个固定容量的分配区 (arena),用来存放数量不定、长度各异的记录。记录本身用柔性数组成员保存内容:
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
struct record {
uint16_t len;
uint8_t kind;
unsigned char data[];
};
struct arena {
unsigned char *base;
size_t capacity;
size_t used;
size_t count;
};
struct arena *arena_create(size_t capacity);
struct record *arena_add(struct arena *a, uint8_t kind, const void *src, size_t n);
const struct record *arena_get(const struct arena *a, size_t index);
void arena_destroy(struct arena *a);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
要求:
arena_create一次申请capacity字节作为记录区,capacity == 0时返回NULL。arena_destroy(NULL)安全;记录不单独释放。arena_add把新记录紧接在上一条之后放置,起始地址必须满足struct record的对齐要求。本条记录占用offsetof(struct record, data) + n字节,下一条记录的起点要把这个长度向上取整到sizeof(struct record):结构体类型的大小一定是其对齐值的整数倍,所以malloc返回的起点加上若干倍的sizeof(struct record)之后仍然对齐。用sizeof取整会浪费几个字节,换来的是不需要另查对齐值。向上取整可以写成(x + a - 1) / a * a。n超过UINT16_MAX、剩余空间不足或参数无效时返回NULL,used和count保持不变。arena_get按下标返回记录。记录长度不同,不能用base + index * sizeof(struct record)计算地址,要从前一条记录的长度推出下一条的位置。下标越界时返回NULL。- 写测试程序:用
arena_create(32)建分配区,依次加入kind = 1、载荷abc的记录,kind = 2、空载荷的记录,kind = 3、载荷0123456789的记录。逐条打印偏移、kind、len和内容,确认每个偏移都是sizeof(struct record)的整数倍;再加入一条 16 字节载荷的记录,确认返回NULL,used和count都没有变化。在 x86-64 的 GCC 上sizeof(struct record)是 4,三条记录的偏移依次是 0、8、12,共占 28 字节,剩余 4 字节放不下第四条;换一个平台先打印sizeof,按它的值推算期望的偏移。 - 写完后把
arena_get里推出下一条位置的那段循环删掉,改用a->base + index * sizeof(struct record)取地址,重新运行上面的测试,记录读出的kind、len与内容错在哪里,说明为什么带柔性数组成员的记录不能按下标等距寻址。把data换成unsigned char *data的指针版本需要几次分配、每条记录多占多少字节?哪种布局更适合这种定长区、随机下标的容器?