28. 结构体与反射:C语言模拟反射、结构体元数据描述

反射,这个词在 Java、C# 里很常见。说白了,就是程序在运行时能「看」到自己长什么样——有哪些字段、什么类型、多大尺寸。C 语言没有内置反射,这是事实。但我在做嵌入式协议解析时,经常需要根据数据包类型动态访问结构体成员。没有反射?那就自己造一个。

嗯,今天我们就聊聊怎么用 C 语言模拟反射。核心思路就是:用元数据描述结构体

什么是结构体元数据

元数据,就是「描述数据的数据」。对于结构体来说,元数据包括:

  • 成员名称(字符串)
  • 成员类型(枚举或整数标识)
  • 成员偏移量(相对于结构体起始地址)
  • 成员大小(字节数)
  • 成员个数(结构体总共有几个字段)

有了这些信息,你就能在运行时遍历结构体、按名字访问成员、甚至序列化/反序列化。

核心思想:把结构体的「形状」用静态数据描述出来,运行时查表。

一个简单的元数据描述方案

我习惯先定义类型枚举:

typedef enum {
    FIELD_TYPE_INT8,
    FIELD_TYPE_UINT8,
    FIELD_TYPE_INT16,
    FIELD_TYPE_UINT16,
    FIELD_TYPE_INT32,
    FIELD_TYPE_UINT32,
    FIELD_TYPE_FLOAT,
    FIELD_TYPE_DOUBLE,
    FIELD_TYPE_STRING,   // 定长字符数组
    FIELD_TYPE_SUB_STRUCT // 嵌套结构体
} field_type_t;

然后定义字段描述结构体:

typedef struct {
    const char *name;      // 字段名
    field_type_t type;     // 字段类型
    size_t offset;         // 字段偏移量
    size_t size;           // 字段大小(字节)
    size_t count;          // 数组元素个数,非数组为1
} field_descriptor_t;

最后,用一个结构体描述整个结构体:

typedef struct {
    const char *struct_name;       // 结构体名称
    size_t struct_size;            // 结构体总大小
    size_t field_count;            // 字段个数
    const field_descriptor_t *fields; // 字段描述数组
} struct_metadata_t;

小技巧:偏移量可以用 offsetof 宏自动计算,别手写。我见过有人手写偏移量,结构体一改就全崩了。

实战:为结构体生成元数据

假设我们有这样一个结构体:

typedef struct {
    uint8_t id;
    uint16_t value;
    float ratio;
    char name[32];
} sensor_data_t;

对应的元数据定义如下:

#include <stddef.h>  // 为了 offsetof

#define FIELD_DESC(type, struct_type, member, array_count) \
    {#member, FIELD_TYPE_##type, offsetof(struct_type, member), \
     sizeof(((struct_type*)0)->member), array_count}

static const field_descriptor_t sensor_data_fields[] = {
    FIELD_DESC(UINT8,  sensor_data_t, id,    1),
    FIELD_DESC(UINT16, sensor_data_t, value, 1),
    FIELD_DESC(FLOAT,  sensor_data_t, ratio, 1),
    FIELD_DESC(STRING, sensor_data_t, name,  32),
};

const struct_metadata_t sensor_data_meta = {
    .struct_name = "sensor_data_t",
    .struct_size = sizeof(sensor_data_t),
    .field_count = 4,
    .fields = sensor_data_fields
};

你看,宏 FIELD_DESC 帮我们省了不少事。用 offsetofsizeof 自动计算,结构体增减字段时,元数据会自动更新——前提是你别忘了加一行。

注意:宏里的 FIELD_TYPE_##type 要求传入的 type 必须和枚举值完全匹配。我曾经因为大小写写错,查了半天 bug……

运行时反射:按名字访问成员

有了元数据,按名字查找字段就很简单了:

const field_descriptor_t* find_field(
    const struct_metadata_t *meta, 
    const char *field_name) 
{
    for (size_t i = 0; i < meta->field_count; i++) {
        if (strcmp(meta->fields[i].name, field_name) == 0) {
            return &meta->fields[i];
        }
    }
    return NULL;
}

找到字段描述后,就能通过偏移量读写数据:

void* get_field_ptr(void *struct_ptr, const field_descriptor_t *field) {
    return (uint8_t*)struct_ptr + field->offset;
}

举个例子:

sensor_data_t data = {.id = 5, .value = 1234, .ratio = 3.14f};
strcpy(data.name, "temperature");

const field_descriptor_t *fd = find_field(&sensor_data_meta, "value");
if (fd) {
    uint16_t *val_ptr = (uint16_t*)get_field_ptr(&data, fd);
    printf("value = %u\n", *val_ptr);
}

输出:value = 1234。你看,完全动态的。

更高级的玩法:自动序列化

有了元数据,写一个通用的序列化函数就水到渠成了:

void serialize_to_json(const void *struct_ptr, 
                       const struct_metadata_t *meta) {
    printf("{\n");
    for (size_t i = 0; i < meta->field_count; i++) {
        const field_descriptor_t *f = &meta->fields[i];
        void *ptr = (uint8_t*)struct_ptr + f->offset;
        
        printf("  \"%s\": ", f->name);
        
        switch (f->type) {
            case FIELD_TYPE_UINT8:
                printf("%u", *(uint8_t*)ptr);
                break;
            case FIELD_TYPE_UINT16:
                printf("%u", *(uint16_t*)ptr);
                break;
            case FIELD_TYPE_FLOAT:
                printf("%.2f", *(float*)ptr);
                break;
            case FIELD_TYPE_STRING:
                printf("\"%s\"", (char*)ptr);
                break;
            // 其他类型类似处理...
        }
        
        if (i < meta->field_count - 1) printf(",");
        printf("\n");
    }
    printf("}\n");
}

调用:

serialize_to_json(&data, &sensor_data_meta);

输出:

{
  "id": 5,
  "value": 1234,
  "ratio": 3.14,
  "name": "temperature"
}

你想想看,如果不用元数据,你得为每个结构体单独写一个序列化函数。有了反射模拟,一个函数通吃所有结构体。

知识体系图

下面这张图总结了结构体反射模拟的核心逻辑:

C语言结构体反射模拟 — 核心逻辑 结构体定义 sensor_data_t 元数据生成 struct_metadata_t 运行时反射 按名访问 / 遍历 元数据内部结构 • struct_name: "sensor_data_t" • struct_size: sizeof(...) • field_count: 4 • fields[]: 字段描述数组 每个字段: name, type, offset, size 典型应用场景 1. 动态序列化/反序列化 2. 协议解析(JSON/XML) 3. 通用打印/调试工具 4. 反射式单元测试

避坑指南

我踩过几个坑,分享给你:

  • 结构体对齐offsetof 已经考虑了对齐,但如果你手动计算偏移量,很容易算错。永远用 offsetof
  • 嵌套结构体:如果结构体里嵌套了另一个结构体,元数据要递归描述。我一般把子结构体的元数据指针也放到字段描述里。
  • 位域:C 语言位域无法用 offsetof 获取偏移量,反射模拟对位域基本无效。我的建议是:别在位域结构体上用反射。
  • const 成员:反射写入时要注意 const 限定符。虽然 C 语言可以强制转换绕过去,但行为未定义。我一般会在元数据里加一个 is_const 标志。

经验之谈:我在一个物联网项目里用这套方案处理了 50 多种传感器数据结构。每次新增传感器,只需要定义结构体和对应的元数据,序列化、日志打印、远程配置全部自动适配。省了至少 2000 行重复代码。

总结

C 语言没有原生反射,但我们可以用元数据模拟。核心就是:用静态描述符把结构体的「形状」记录下来,运行时查表操作。这套方法在嵌入式协议解析、通用序列化、调试工具中非常实用。

嗯,说白了就是「空间换时间,静态换动态」。虽然写元数据定义时多花了几分钟,但后面维护和扩展时,省下的时间是以小时计的。


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