起因

写 C 程序迟早遇到“定长头 + 变长体”的数据:通信报文(包头+变长 payload)、动态字符串、对象池里带附属缓冲的节点。C89 时代大家用一个 hack 解决,C99 把它扶正——柔性数组(flexible array member)。这篇把它是什么、为什么这么设计、用的时候注意什么讲清楚。

需求

  • 结构体能表达“头部 + N 个同类型元素”,一次 malloc 分配到位
  • 访问方式像普通数组成员,sizeof 行为可预期

技术实现原理

标准示例

C99及以上标准支持,标准示例如下:

1
2
3
4
5
typedef struct st_type
{
int i;
int a[];
}type_a;

从 a[1] hack 说起:柔性数组解决的是同一个老问题

C89 里的惯用写法是把最后一个成员声明成 int a[1];,然后 malloc(sizeof(type) + (n-1)*sizeof(int))。这个 hack 能跑,但有先天毛病:sizeof 把那 1 个元素算进结构体,n 的计算要减 1,很容易差一;而且 a[1] 在语法上是“真数组”,被嵌套、被拷贝时编译器不会拦你。GCC 为此开了 a[0] 的扩展。C99(C99 specification 6.7.2.1.16)把它正式化:结构体最后一个成员可以是不完整数组类型 int a[];,这就是柔性数组,规则明确、可移植。

为什么 sizeof(*p) 仍然只是 4

初始大小为sizeof(i),0个元素的数组没有占用空间,而后我们可以进行变长操作了。 通过如下表达式给结构体分配内存:

1
type_a *p = (type_a*)malloc(sizeof(type_a)+100*sizeof(int));

但是这时候我们再用 sizeof(*p) 测试结构体的大小,发现仍然为 4。因为在定义这个结构体的时候,模子的大小就已经确定不包含柔性数组的内存大小。

柔性数组只是编外人员,不占结构体的编制。 只是说在使用柔性数组时需要把它当作结构体的一个成员,仅此而已。 再说白点,柔性数组其实与结构体没什么关系,只是“挂羊头卖狗肉”而已,算不得结构体的正式成员。

从内存布局看更直白:malloc 返回的一整块里,前 sizeof(type_a) 字节是“编制内”成员,后面紧跟的就是柔性数组的地盘。p->a[i] 被编译成“从 p 出发偏移 offsetof(type_a,a)、再加 i*sizeof(int)”——与访问普通成员是同一套寻址,柔性数组没有引入任何新的布局规则,它只是给了“结构体后面紧跟一串元素”一个合法的成员访问语法。

对比指针成员:为什么推荐柔性数组

变长数据也可以写成 int *a; 再单独 malloc 一块。两者对比:

维度 柔性数组 int a[] 指针成员 int *a
分配次数 1 次 malloc 2 次(结构体、数组各一次)
释放 1 次 free 2 次 free
额外开销 指针本身 4/8 字节
内存局部性 头和数组连续,cache 友好 两块内存,可能相距很远
重新扩容 realloc 整块 只 realloc 数组那块

规则边界(C99 怎么规定的)

  • 柔性数组必须是结构体的最后一个成员;
  • 结构体里必须还有至少一个其他具名成员(只有 int a[]; 一个成员的结构体不合法);
  • 含柔性数组的结构体不能给柔性数组初始化、不能作为数组元素类型、不能按值拷贝/赋值——编译器只认 sizeof 覆盖的那一段。

详细介绍请搜索:

c99(C99 specification 6.7.2.1.16)中引入的 flexible array member

踩坑记录与注意事项

  • sizeof 不含柔性数组,拷贝同样不含:按值赋值、传参、memcpy 都只复制 sizeof 那一段,变长数据要用 sizeof(type_a)+n*sizeof(int) 显式带上——“结构体赋值丢数据”的根因就在这;
  • 只 malloc(sizeof(type_a)) 就写 a[i] 是越界:柔性数组的地盘还没分配,越界写直接踩坏堆元数据;
  • realloc 扩容后要回写指针p = realloc(p, ...) 后旧指针作废,其它别名继续用就是未定义行为;
  • 有尾部 padding 的结构体要留心:比如 int i; char c; double a[]; 时 sizeof 含对齐填充,直接用 sizeof + n*sizeof(elem) 是保守安全的做法,自己拿 offsetof 算偏移时容易漏;
  • 二进制序列化别直接 fwrite(p):只会写出头部,payload 长度要单独维护并补写;
  • C++ 标准里没有柔性数组:g++ 一般当扩展接受,写跨 C/C++ 的公共头文件时要么 #ifdef __cplusplus 分路,要么改用指针成员方案。