15. 结构体与柔性数组:柔性数组的定义、内存分配、在变长数据包中的应用

说到柔性数组,我得先坦白一件事——我入行头两年,根本不知道C99还有这个特性。那时候做网络协议栈,每次要处理变长数据包,我都用固定大小的数组,要么浪费内存,要么硬编码一个最大长度。直到有一次,我在一个嵌入式项目里被内存占用逼得走投无路,才真正认识了柔性数组。

说白了,柔性数组就是结构体最后一个成员,一个没有指定长度的数组。它不占结构体本身的空间,但你可以通过动态内存分配,给它分配任意大小。嗯,这玩意儿在变长数据包处理中,简直是神器。

15.1 柔性数组的定义

先看个例子。假设我们要设计一个网络数据包,头部固定,但负载长度可变:

// 柔性数组的定义
struct packet {
    uint32_t len;      // 数据长度
    uint32_t type;     // 包类型
    uint32_t crc;      // 校验码
    uint8_t  data[];   // 柔性数组,不占结构体空间
};

注意这个 data[],它没有指定大小。这就是柔性数组。它必须放在结构体的最后一个成员位置。为什么?因为编译器需要保证前面的成员地址连续,而柔性数组的大小是运行时决定的。

核心要点:柔性数组不占用结构体的sizeof。sizeof(struct packet) 只计算 len、type、crc 这三个成员的大小。data 字段的偏移量就是结构体末尾。

我个人习惯用 data[] 这种写法,而不是 data[0]。虽然很多编译器都支持零长度数组,但 data[] 才是C99标准规定的写法。你想想看,如果哪天换了编译器,零长度数组可能就编译不过了。

15.2 内存分配

柔性数组的内存分配,必须用动态内存。你不能直接声明一个结构体变量然后往data里写数据——那样data指向的是栈上不确定的位置,会出大问题。

正确的做法是这样:

// 为柔性数组分配内存
struct packet *pkt = malloc(sizeof(struct packet) + data_len);
if (!pkt) {
    // 处理内存分配失败
    return NULL;
}

pkt->len = data_len;
pkt->type = 0x01;
pkt->crc = calc_crc(data, data_len);
memcpy(pkt->data, data, data_len);

看到没?malloc 的大小是 sizeof(struct packet) + data_len。前面是固定头部,后面是可变负载。这样分配出来的内存是连续的,data 直接跟在 crc 后面。

小技巧:我建议用 sizeof(struct packet) 而不是手动计算成员大小之和。因为编译器可能有对齐填充,手动算容易出错。用 sizeof 最保险。

释放的时候也简单:

free(pkt);  // 一次释放,干净利落

这就是柔性数组的好处——一次分配,一次释放。不像用指针成员,你得先释放内部指针,再释放结构体本身,稍不注意就内存泄漏。

15.3 在变长数据包中的应用

我在做物联网网关的时候,经常要处理各种传感器上报的数据。每个传感器的数据长度不一样,有的传温度(4字节),有的传图像(几KB)。用柔性数组来设计数据包,再合适不过了。

来看一个实际的数据包结构:

// 变长数据包结构
struct sensor_packet {
    uint8_t  src_id;       // 源设备ID
    uint8_t  pkt_type;     // 包类型
    uint16_t payload_len;  // 负载长度
    uint32_t timestamp;    // 时间戳
    uint8_t  payload[];    // 柔性数组,存放实际数据
};

接收端怎么处理?假设我们从串口收到一个数据包:

// 接收并解析变长数据包
void handle_sensor_packet(uint8_t *buffer, size_t buf_len) {
    // 先检查长度是否足够存放头部
    if (buf_len < sizeof(struct sensor_packet)) {
        // 包太短,丢弃
        return;
    }

    struct sensor_packet *pkt = (struct sensor_packet *)buffer;
    
    // 验证负载长度是否与缓冲区一致
    if (sizeof(struct sensor_packet) + pkt->payload_len != buf_len) {
        // 长度不匹配,可能数据损坏
        return;
    }

    // 现在可以安全地访问 payload 了
    process_payload(pkt->payload, pkt->payload_len);
}

这里有个坑,我曾经踩过——字节对齐问题。如果结构体成员之间有对齐填充,sizeof 可能比你想象的大。比如上面这个结构体,如果 uint32_t timestamp 要求4字节对齐,那 payload_len 后面可能会有2字节的填充。payload 的起始位置就不是紧跟在 payload_len 后面了。

避坑指南:我曾经在一个项目里,因为没考虑对齐,导致柔性数组的偏移量算错了。接收端解析出来的数据全是乱的。排查了一整天才发现是结构体对齐的问题。后来我养成了一个习惯——凡是涉及柔性数组的结构体,都用 __attribute__((packed)) 强制紧凑对齐。

// 使用 packed 避免对齐问题
struct __attribute__((packed)) sensor_packet {
    uint8_t  src_id;
    uint8_t  pkt_type;
    uint16_t payload_len;
    uint32_t timestamp;
    uint8_t  payload[];
};

但要注意,packed 会降低访问效率。如果你的平台对性能要求极高,可以考虑手动填充对齐,而不是用 packed。

15.4 柔性数组 vs 指针成员

有人可能会问:用指针成员不也能实现变长数据吗?比如这样:

// 用指针实现变长数据
struct packet_ptr {
    uint32_t len;
    uint32_t type;
    uint8_t  *data;  // 指针指向堆上的数据
};

嗯,确实可以。但区别在哪?我列个表你就明白了:

特性 柔性数组 指针成员
内存连续性 头部和负载连续 不连续,分两次分配
分配/释放次数 1次 2次
内存碎片
序列化/反序列化 直接memcpy整个结构体 需要分别处理头部和负载
sizeof 结果 只含头部 含指针本身

说白了,柔性数组更适合那些需要整体传输或存储的数据包。比如你要把整个包通过DMA发送出去,柔性数组一次memcpy就搞定。指针成员你得先发头部,再发负载,麻烦不说,还容易出错。

15.5 知识体系图

下面这张图总结了柔性数组的核心逻辑:

柔性数组核心知识体系 柔性数组 定义规则 内存分配 变长数据包 最后一个成员,不占sizeof C99标准,data[]写法 malloc(sizeof(struct) + len) 一次分配,一次释放 网络协议栈数据包 传感器变长数据上报 ⚠ 注意:对齐问题、packed属性、sizeof的正确使用

15.6 使用建议

最后,我总结几条实战经验:

  • 能用柔性数组就别用指针成员——内存连续性好,管理简单,性能也更高。
  • 注意对齐——如果结构体成员类型混搭(比如uint8_t和uint32_t混用),一定要检查sizeof是否符合预期。必要时用packed。
  • 不要直接声明柔性数组结构体的变量——比如 struct packet pkt; 然后往data里写数据,这是未定义行为。必须动态分配。
  • 计算长度时用sizeof——别自己手动加成员大小,对齐填充会让你算错。
  • 柔性数组只能有一个——而且必须在结构体末尾。这是语法规定,没得商量。

个人经验:我在一个低功耗无线传感器项目里,用柔性数组设计数据包,把每个包的内存开销降到了最低。因为传感器每次上报的数据长度不同,用固定数组要么浪费,要么不够。柔性数组刚好按需分配,一点不浪费。那批设备在野外跑了两年,没出过一次内存相关的问题。

好了,柔性数组就讲这么多。说白了,它就是C语言给变长数据场景准备的一个精巧工具。用好了,代码简洁高效;用不好,就是各种踩坑。记住上面那几条建议,基本就能避开我当年踩过的雷了。


公众号:蓝海资料掘金营,微信deep3321