4. C++接口实现机制

做嵌入式久了,你会发现一个很有意思的现象:很多项目的代码腐烂,不是从算法开始的,而是从接口开始的。

我见过一个项目,一个传感器驱动接口里塞了二十多个方法。有读温度的、读湿度的、读气压的、配置采样率的、校准的、自检的、甚至还有格式化内部Flash的。你想想看,一个温湿度传感器,它需要格式化Flash吗?不需要。但接口就这么写了,因为“方便”。

这就是我们今天要聊的接口隔离原则(ISP)。说白了,就是别让你的接口变成一个大杂烩。

4.1 什么是接口隔离原则?

接口隔离原则的核心思想很简单:客户端不应该被迫依赖它不使用的方法。

用大白话说:你开了一家餐厅,菜单上写了100道菜。但你的厨房只做5道。那剩下的95道菜,就是“接口污染”。客户点了你做不出来,客户不点你又写在上面占地方。

在嵌入式系统里,这个问题更严重。因为我们的资源有限,Flash空间、RAM、CPU时间都很宝贵。一个臃肿的接口,意味着:

  • 更多的虚函数表占用
  • 更多的代码链接体积
  • 更多的维护成本
  • 更多的测试用例

核心原则:接口应该小而专,而不是大而全。每个接口只服务于一个特定的客户端或一组紧密相关的客户端。

4.2 一个反面教材:万能传感器接口

先来看一个我早期项目里踩过的坑。当时做一个环境监测站,需要支持多种传感器:温度、湿度、PM2.5、风速、风向。

我当时的想法是:“统一接口,方便扩展”。于是写出了这样的代码:

// 反面教材:万能传感器接口
typedef struct {
    int32_t (*init)(uint8_t addr, uint32_t speed);
    int32_t (*deinit)(void);
    int32_t (*read_temperature)(float *temp);
    int32_t (*read_humidity)(float *hum);
    int32_t (*read_pressure)(float *pres);
    int32_t (*read_pm25)(uint16_t *pm25);
    int32_t (*read_pm10)(uint16_t *pm10);
    int32_t (*read_wind_speed)(float *speed);
    int32_t (*read_wind_direction)(uint16_t *dir);
    int32_t (*self_test)(uint8_t *result);
    int32_t (*calibrate)(float offset, float scale);
    int32_t (*set_sampling_rate)(uint32_t rate);
    int32_t (*get_firmware_version)(char *ver);
} SensorInterface_t;

这个接口有13个方法。看起来“通用”对吧?但问题来了:

  • 温湿度传感器不需要 read_pm25 和 read_wind_speed
  • PM2.5传感器不需要 read_temperature 和 read_humidity
  • 风速传感器不需要 calibrate 和 self_test

每个传感器驱动都要实现这13个方法。不用的方法怎么办?要么返回错误码,要么空实现。结果就是:

  • 代码里到处都是 return -ENOTSUP;
  • 测试覆盖率永远上不去
  • 新人接手时一脸懵:“这个传感器到底支持哪些功能?”

我曾经在这个项目上吃了大亏。一个风速传感器驱动,因为要实现 calibrate 方法,结果在调用时误触发了校准模式,导致现场数据全部偏移。排查了整整两天,最后发现是接口设计的问题。

4.3 正确的做法:按职责拆分接口

那应该怎么做?我个人习惯是:按功能职责拆分,而不是按设备类型拆分。

还是上面那个环境监测站的例子。我们可以把接口拆成这样:

// 正确的做法:按职责拆分

// 基础传感器接口:所有传感器都需要
typedef struct {
    int32_t (*init)(void);
    int32_t (*deinit)(void);
    int32_t (*self_test)(uint8_t *result);
} SensorBase_t;

// 温度测量接口:只有温度传感器需要
typedef struct {
    int32_t (*read_temperature)(float *temp);
    int32_t (*set_sampling_rate)(uint32_t rate);
} TemperatureSensor_t;

// 湿度测量接口:只有湿度传感器需要
typedef struct {
    int32_t (*read_humidity)(float *hum);
} HumiditySensor_t;

// 颗粒物测量接口:只有PM传感器需要
typedef struct {
    int32_t (*read_pm25)(uint16_t *pm25);
    int32_t (*read_pm10)(uint16_t *pm10);
} ParticulateMatterSensor_t;

// 风速风向接口:只有风速传感器需要
typedef struct {
    int32_t (*read_wind_speed)(float *speed);
    int32_t (*read_wind_direction)(uint16_t *dir);
} WindSensor_t;

这样拆分之后,每个传感器驱动只需要实现它真正需要的接口。温湿度传感器只需要实现 SensorBase_t、TemperatureSensor_t 和 HumiditySensor_t。风速传感器只需要实现 SensorBase_t 和 WindSensor_t。

小技巧:接口拆分时,可以问自己一个问题:“如果这个接口里去掉一个方法,会不会有客户端受影响?”如果不会,那这个方法就不应该在这个接口里。

4.4 接口隔离的粒度控制

接口不是拆得越细越好。拆得太细,会导致接口数量爆炸,管理成本上升。我见过一个项目,一个I2C驱动拆了7个接口,每个接口就1-2个方法。结果就是:

  • 接口文件比实现文件还多
  • 组合接口时,代码变得非常啰嗦
  • 新人学习成本极高

那怎么把握这个度?我一般遵循三个原则:

原则 说明 例子
客户端驱动 接口应该由使用方定义,而不是实现方 业务层需要什么,接口就提供什么
功能内聚 一个接口里的方法应该属于同一功能域 温度相关的放一起,风速相关的放一起
3-5方法原则 一个接口的方法数控制在3-5个 超过5个考虑拆分,少于3个考虑合并

嗯,这里要注意:3-5方法原则不是硬性规定。有些接口天生就只需要1-2个方法,比如一个只读温度的接口。这时候强行合并反而不好。

4.5 接口隔离在C语言中的实现技巧

在C语言里实现接口隔离,我推荐用“结构体组合”的方式。还是用上面的例子:

// 温湿度传感器的具体实现
typedef struct {
    SensorBase_t base;              // 基础接口
    TemperatureSensor_t temp;       // 温度接口
    HumiditySensor_t hum;           // 湿度接口
    uint8_t i2c_addr;              // 私有数据
    uint8_t initialized;
} SHT30_Device_t;

// 初始化时,把函数指针赋值
int32_t SHT30_Init(SHT30_Device_t *dev, uint8_t addr) {
    dev->i2c_addr = addr;
    dev->initialized = 0;
    
    // 基础接口
    dev->base.init = SHT30_BaseInit;
    dev->base.deinit = SHT30_BaseDeinit;
    dev->base.self_test = SHT30_SelfTest;
    
    // 温度接口
    dev->temp.read_temperature = SHT30_ReadTemp;
    dev->temp.set_sampling_rate = SHT30_SetRate;
    
    // 湿度接口
    dev->hum.read_humidity = SHT30_ReadHum;
    
    return 0;
}

这样做的好处是:

  • 每个接口都是独立的,可以单独测试
  • 客户端只依赖它需要的接口
  • 新增传感器类型时,不需要修改现有接口

4.6 接口隔离与硬件抽象层(HAL)的关系

很多人会把接口隔离和HAL搞混。其实它们解决的是不同层次的问题:

概念 关注点 典型问题
HAL 屏蔽硬件差异,提供统一访问 STM32的HAL库 vs 标准外设库
ISP 避免客户端依赖不需要的方法 传感器接口里塞了不相关的方法

在实际项目中,我通常的做法是:

  1. 先用HAL把硬件差异封装掉
  2. 再在HAL之上,按ISP原则设计业务接口
  3. 业务层只依赖业务接口,不直接操作HAL

这样分层之后,每一层的职责都很清晰。硬件变了,只改HAL层。业务变了,只改接口定义。互不干扰。

4.7 接口隔离的避坑指南

最后,分享几个我踩过的坑:

坑1:接口设计时过度考虑“未来扩展”。我曾经为一个传感器预留了5个“未来可能用到”的方法。结果项目结束了,一个都没用上。白白增加了代码体积和测试工作量。

坑2:为了“统一”而强行合并接口。比如把I2C和SPI的接口合并成一个“总线接口”。结果就是:I2C的接口里多了CS引脚控制,SPI的接口里多了从机地址配置。两边都不舒服。

坑3:接口拆分后,忘记更新文档。我见过一个项目,接口拆了,但文档还是老的。新人看文档以为某个传感器支持所有功能,结果调用了不存在的接口,编译都过不去。

嗯,接口隔离这件事,说白了就是“少即是多”。接口越小,越容易理解,越容易测试,越容易维护。别让你的传感器驱动接口承载“宇宙”,它只需要承载它该做的事情。

接口隔离原则(ISP)核心逻辑 反面教材:万能接口 SensorInterface_t(13个方法) read_temperature read_humidity read_pm25 / read_pm10 read_wind_speed / read_wind_direction self_test / calibrate / set_sampling_rate get_firmware_version ← 每个传感器都要实现所有方法 ← 大量空实现 / 返回错误码 重构 正确做法:按职责拆分接口 SensorBase_t init / deinit / self_test TemperatureSensor_t read_temperature / set_sampling_rate HumiditySensor_t read_humidity ParticulateMatterSensor_t read_pm25 / read_pm10 WindSensor_t read_wind_speed / read_wind_direction ← 每个传感器只实现需要的接口 ← 接口数量控制在3-5个方法 ← 新增传感器类型无需修改现有接口 核心原则:客户端不应该被迫依赖它不使用的方法