在上一篇中,我们通过在结构体里嵌入函数指针,成功实现了 “多态”。 但是,我们留下了一个严重的 RAM 隐患。对于资源极其紧张的单片机(比如只有 2KB RAM 的 Cortex-M0),上一篇的写法可能是“致命”的。
这一篇,我们将深入 C 语言的底层,手动复现 C++ 的核心机制 —— 虚函数表 (V-Table),用“架构设计”来换取宝贵的 RAM 空间。
在上一篇中,我们定义了这样的结构体来实现多态:
struct Sensor_t {
char name[10];
float (*read)(struct Sensor_t *self); // 4字节 RAM
void (*init)(struct Sensor_t *self); // 4字节 RAM
void (*reset)(struct Sensor_t *self); // 4字节 RAM
// ... 假设还有 5 个标准接口
};
这种写法逻辑很完美,但 工程落地很糟糕。
一、 算算这笔 RAM 账
假设你正在做一个智能大棚项目,需要部署 100 个温湿度传感器。
如果你使用上面的结构体:
-
RAM 消耗:每个对象里存了 8 个函数指针。
100 个对象 x 8个指针 x 4 字节 = 3200 Bytes
-
痛点:对于一个 STM32F030(4KB RAM)或者 8051 来说,光存这些指针,内存就爆了,连栈都开不出来。
-
浪费:最讽刺的是,对于这 100 个同类型的传感器(比如都是 DHT11),这 800 个函数指针的值是 完全一样 的!它们都指向
DHT11_Read,DHT11_Init...
既然是一样的,为什么要重复存 100 遍?
二、 解决方案:提炼“虚表” (The V-Table)
我们需要把这些 “不变的函数指针” 剥离出来,放到一个单独的表中。
因为这些表在编译后就不会变了,我们可以把它加上 const 修饰符,强制链接器把它放到 Flash (RO-Data) 里,而不占用宝贵的 RAM。
这个表,在 C++ 里叫 虚函数表 (Virtual Function Table, vtable);在 Linux 内核驱动里叫 操作集 (Operations, ops)。
2.1 第一步:定义接口表 (The Ops Struct)
我们把所有的函数指针拿出来,单独定义一个结构体。
// sensor_ops.h
// 前向声明
struct Sensor_t;
// 定义操作集 (V-Table)
typedef struct {
// 这一组函数指针,代表了“Sensor”这个类的标准行为
void (*init)(struct Sensor_t *self);
float (*read)(struct Sensor_t *self);
void (*reset)(struct Sensor_t *self);
} Sensor_Ops_t;
2.2 第二步:改造对象 (The Object)
现在的 Sensor_t 对象里,不再存储那一堆函数指针了,而是只存 一个指针,指向那个表。
// sensor.h
struct Sensor_t {
char name[10]; // 对象的属性(每个对象不同)
// 【核心变化】
// 只存一个指向 Ops 表的指针!
// 无论 Ops 里有多少个函数,这里永远只占 4 字节。
const Sensor_Ops_t *ops;
void *private_data; // 私有数据
};
三、 实例化:Flash 与 RAM 的完美分离
现在我们来看看,在 main.c 或驱动文件里怎么写。
3.1 定义具体的驱动表 (In Flash)
我们在驱动文件(如 dht11.c)里,定义一个 static const 的表。
// dht11_driver.c
// 具体函数的实现
static float DHT11_Read(struct Sensor_t *self) { ... }
static void DHT11_Init(struct Sensor_t *self) { ... }
static void DHT11_Reset(struct Sensor_t *self) { ... }
// 【关键优化】
// 加了 const,这块数据会被直接烧录到 Flash 中
// 运行时完全不占用 RAM
static const Sensor_Ops_t dht11_ops = {
.init = DHT11_Init,
.read = DHT11_Read,
.reset = DHT11_Reset,
};
// 对外只暴露这个 Ops 表的地址,或者提供一个绑定函数
const Sensor_Ops_t* Get_DHT11_Ops(void) {
return &dht11_ops;
}
3.2 初始化对象 (In RAM)
// main.c
int main() {
// 实例化 100 个对象
struct Sensor_t sensors[100];
// 初始化第一个
sensors[0].ops = Get_DHT11_Ops(); // 指针指向 Flash 里的表
// 初始化第二个
sensors[1].ops = Get_DHT11_Ops(); // 指向同一个 Flash 地址
// ...
}
四、 调用的变化:多了一层“跳板”
使用 V-Table 后,调用的语法会稍微变繁琐一点点(这就是 C++ 帮我们隐藏掉的细节)。
之前的调用: s->read(s);
现在的调用: s->ops->read(s);
我们可以写一个 内联函数 (Inline Wrapper) 来让调用变优雅:
// sensor.h
static inline float Sensor_Read(struct Sensor_t *s) {
// 安全检查:防止空指针
if (s && s->ops && s->ops->read) {
return s->ops->read(s);
}
return 0.0f;
}
// main.c
val = Sensor_Read(&sensors[0]); // 看起来和普通函数没区别了!
五、 效果对比:降维打击
让我们重新算一下那笔账。假设有 100 个对象,每个对象包含 8 个接口函数。
| 方案 | 方案 A:函数指针在对象内 | 方案 B:虚函数表 (V-Table) |
| RAM 占用 | 100 × 8 × 4 = 3200 Bytes | 100 × 1 × 4 = 400 Bytes |
| Flash 占用 | 0 (代码逻辑除外) | 1 × 8 × 4 = 32 Bytes (存那个表) |
| 初始化速度 | 慢 (要赋值 800 次指针) | 极快 (只赋值 100 次指针) |
| 可维护性 | 差 (容易漏赋值某个函数) | 优 (编译期检查结构体初始化) |
结论: 通过引入 Ops 结构体,我们用微不足道的 Flash 空间(32字节),换回了巨量的 RAM 空间(2800字节)。 在嵌入式开发中,Flash 往往是富余的,而 RAM 永远是紧缺的。 这种交易简直是一本万利。
六、 进阶伏笔:父类如何访问子类?
到目前为止,我们的 Sensor_t 看起来很完美。 但在写 DHT11_Read 的具体实现时,你会发现一个巨大的问题:
static float DHT11_Read(struct Sensor_t *self) {
// 问题来了:
// self 是一个通用的 Sensor_t 指针。
// 但是 DHT11 驱动需要知道具体的 GPIO 引脚号(Pin)。
// Pin 存在哪里?Sensor_t 里没有 Pin 变量啊!
// 我们上一期预留了一个 void *private_data,可以用它:
dht11_config_t *cfg = (dht11_config_t *)self->private_data;
HAL_GPIO_ReadPin(cfg->port, cfg->pin); // 可以工作,但不够优雅
}
用 void* 强转虽然可行,但它是 “弱类型” 的,不安全。 而且,如果我想实现 “继承”(比如 DHT11 继承自 Sensor),让 DHT11 结构体直接 包含 Sensor 的所有属性,该怎么做?
Linux 内核里大量使用的 container_of 宏和“结构体嵌套”技术,正是为了解决这个问题。
/*******************************************
* Description:
* 本文为作者《嵌入式开发基础与工程实践》系列文之一。
* 关注我即可订阅后续内容更新,采用异步推送机制。
* 转发本文可视为广播分发,有助于信息传播至更多节点。
*******************************************/

被折叠的 条评论
为什么被折叠?



