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 帮我们省了不少事。用 offsetof 和 sizeof 自动计算,结构体增减字段时,元数据会自动更新——前提是你别忘了加一行。
注意:宏里的 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"
}
你想想看,如果不用元数据,你得为每个结构体单独写一个序列化函数。有了反射模拟,一个函数通吃所有结构体。
知识体系图
下面这张图总结了结构体反射模拟的核心逻辑:
避坑指南
我踩过几个坑,分享给你:
- 结构体对齐:
offsetof已经考虑了对齐,但如果你手动计算偏移量,很容易算错。永远用offsetof。 - 嵌套结构体:如果结构体里嵌套了另一个结构体,元数据要递归描述。我一般把子结构体的元数据指针也放到字段描述里。
- 位域:C 语言位域无法用
offsetof获取偏移量,反射模拟对位域基本无效。我的建议是:别在位域结构体上用反射。 - const 成员:反射写入时要注意 const 限定符。虽然 C 语言可以强制转换绕过去,但行为未定义。我一般会在元数据里加一个
is_const标志。
经验之谈:我在一个物联网项目里用这套方案处理了 50 多种传感器数据结构。每次新增传感器,只需要定义结构体和对应的元数据,序列化、日志打印、远程配置全部自动适配。省了至少 2000 行重复代码。
总结
C 语言没有原生反射,但我们可以用元数据模拟。核心就是:用静态描述符把结构体的「形状」记录下来,运行时查表操作。这套方法在嵌入式协议解析、通用序列化、调试工具中非常实用。
嗯,说白了就是「空间换时间,静态换动态」。虽然写元数据定义时多花了几分钟,但后面维护和扩展时,省下的时间是以小时计的。
公众号:蓝海资料掘金营,微信deep3321