前言: 在前三期(句柄篇、封装篇、内存篇)中,我们通过引入“句柄”和“不透明指针”,成功地把数据封装进了黑盒子里。 今天,我们面临一个新的挑战:如何把**逻辑(函数)**也封装进对象里?
这不仅仅是代码风格的改变,这是嵌入式**“硬件抽象层 (HAL)”** 设计的核心思想。掌握了它,你就能写出那类 “换了芯片,业务代码一行都不用改” 的高内聚代码。
一、 痛点:被 Switch-Case 支配的恐惧
假设我们正在开发一个智能家居网关,产品经理要求支持三种不同的传感器:
-
DHT11 (单总线温湿度)
-
SHT30 (I2C 温湿度)
-
ADC (光敏电阻电压)
如果你还停留在“面向过程”的思维里,你的读取函数大概率会写成这样:
// 定义传感器类型
typedef enum { SENSOR_DHT11, SENSOR_SHT30, SENSOR_ADC } SensorType;
// 传感器句柄
typedef struct {
SensorType type;
// ... 其他私有数据 ...
} Sensor_t;
// 统一读取函数
float Sensor_Read(Sensor_t *s) {
// 典型的“面向过程”:调用者必须知道所有底层的细节
if (s->type == SENSOR_DHT11) {
return DHT11_Read_OneWire(); // 调底层 A
}
else if (s->type == SENSOR_SHT30) {
return SHT30_Read_I2C(); // 调底层 B
}
else if (s->type == SENSOR_ADC) {
return ADC_Get_Voltage(); // 调底层 C
}
return 0.0f;
}
这种写法有两个致命问题:
-
耦合度极高:
Sensor_Read这个上层业务函数,竟然需要包含所有底层驱动的头文件!如果底层驱动有几十个,头文件引用就会极其混乱。 -
违反“开闭原则”:如果你明天买了个新传感器(比如 BME280),你必须去修改
Sensor_Read函数,加一个else if。改代码就有风险,这就叫“由于扩展功能而导致旧功能崩溃”。
二、 救星:把函数变成“变量”
怎么解决? 我们要让 Sensor_t 这个结构体自己 “知道” 该怎么读取数据,而不是让外面的函数去判断。
C 语言允许我们定义 函数指针。我们可以把“读取动作”存到结构体里,让对象自带方法。
2.1 定义带“动作”的结构体
// sensor.h
// 1. 前向声明
struct Sensor_t;
// 2. 定义接口原型
// 注意:第一个参数通常是对象指针自己 (this 指针)
// 这样底层驱动才能知道自己在操作哪个设备
typedef float (*Sensor_Read_Ops)(struct Sensor_t *self);
// 3. 定义类
struct Sensor_t {
char name[10];
// 【关键点】这个变量存的不是数,而是函数的地址!
Sensor_Read_Ops read;
// 私有数据指针 (配合上一期的不透明指针使用)
void *private_data;
};
2.2 具体化实例 (Instantiate)
现在,我们在初始化各种传感器时,把对应的底层函数填进去。
// main.c
// 底层驱动函数 A (符合 Sensor_Read_Ops 签名)
float DHT11_Driver(struct Sensor_t *self) {
printf("Reading DHT11 for %s...\n", self->name);
return 25.5f;
}
// 底层驱动函数 B
float SHT30_Driver(struct Sensor_t *self) {
printf("Reading SHT30 via I2C for %s...\n", self->name);
return 26.0f;
}
int main() {
// 创建传感器对象:在初始化时绑定具体的“驱动函数”
struct Sensor_t s1 = { .name = "LivingRoom", .read = DHT11_Driver };
struct Sensor_t s2 = { .name = "BedRoom", .read = SHT30_Driver };
// ...
}
三、 见证奇迹:多态调用
现在,我们的上层业务逻辑(App层)发生了翻天覆地的变化:
// 业务层代码
void App_Task_Monitor(struct Sensor_t *s) {
// 【核心变化】
// 1. 我不需要判断 s 是什么类型
// 2. 我也不需要写 switch-case
// 3. 我只管调用 s->read(),它自己知道该跑哪个驱动!
float val = s->read(s);
printf("Sensor [%s] Value: %.1f\n", s->name, val);
}
int main() {
// 定义对象
struct Sensor_t s1 = { "S1", DHT11_Driver };
struct Sensor_t s2 = { "S2", SHT30_Driver };
// 统一调用
App_Task_Monitor(&s1); // 自动调用 DHT11 代码
App_Task_Monitor(&s2); // 自动调用 SHT30 代码
}
这样做的好处是什么?
-
完全解耦:
App_Task_Monitor再也不需要引用dht11.h或sht30.h了。它只认识标准的Sensor_t接口。 -
极致扩展:如果明天来了个 BME280,你只需要定义一个新的
struct Sensor_t s3 = {..., BME280_Driver}。App_Task 函数一行代码都不用改!
这就是 多态 (Polymorphism) —— 同一个接口 (s->read()),在不同对象上表现出不同的行为。
四、 现实中的尴尬:RAM 的隐忧
细心的嵌入式工程师可能已经发现了一个隐患。
上面的写法在对象很少时没问题。但如果你的系统比较复杂,Sensor_t 结构体里不止一个函数,而是有一组函数(接口表):
struct Sensor_t {
// ... 数据 ...
void (*init)(void);
float (*read)(void);
void (*write)(float);
void (*sleep)(void);
void (*reset)(void);
};
如果你有 100 个传感器对象:
-
RAM 消耗 = 100 个对象 × 5 个指针 × 4 字节 = 2000 字节!
-
关键是:对于所有的 DHT11 对象来说,这 5 个函数指针的值是完全一样的(都指向 DHT11_xxx 函数)。我们为什么要重复存 100 遍一模一样的数据?
怎么优化? 能不能把这组函数指针提取出来,存到 Flash 里(只存一份),每个对象只存一个指向 Flash 的“索引指针”?
这就是 C++ 编译器在幕后为你做的 “虚函数表 (V-Table)” 技术。 下一期,我们将手动实现它,让你的 C 代码既具备面向对象的灵活性,又拥有嵌入式级别的 RAM 优化。
/*******************************************
* Description:
* 本文为作者《嵌入式开发基础与工程实践》系列文之一。
* 关注我即可订阅后续内容更新,采用异步推送机制。
* 转发本文可视为广播分发,有助于信息传播至更多节点。
*******************************************/
575

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



