2. 嵌入式系统与C++概述

接口隔离原则,英文叫 Interface Segregation Principle,简称 ISP。它是 SOLID 五大原则里最容易被忽视、但嵌入式开发中最容易踩坑的一个。

说白了就一句话:客户端不应该被迫依赖它用不到的接口。你想想看,一个驱动接口里塞了十几个方法,你的模块只用到其中两个,却要因为接口变动而重新编译、甚至改代码——这种事我遇到过太多次了。

什么是接口隔离?先看一个反面教材

先别急着背定义。我们来看一个我早期项目里的真实案例。

当时做一款工业数据采集器,需要支持多种传感器:温度、湿度、压力、流量。我一开始设计了一个万能传感器接口:

// 胖接口 —— 我当年犯的错
typedef struct {
    int32_t (*init)(void);
    int32_t (*read_temperature)(float *val);
    int32_t (*read_humidity)(float *val);
    int32_t (*read_pressure)(float *val);
    int32_t (*read_flow)(float *val);
    int32_t (*self_test)(void);
    int32_t (*calibrate)(float param);
    int32_t (*power_down)(void);
    int32_t (*power_up)(void);
} sensor_if_t;

看着挺完整对吧?问题来了——温度传感器不需要读湿度,压力传感器不需要读流量。但每个传感器驱动都得实现这 9 个函数指针。结果呢?

  • 不支持的接口返回 -1 或 0,调用方还得判断返回值
  • 新增一个传感器类型,所有用到这个接口的模块都要重新编译
  • 接口文档越来越长,新人根本搞不清哪些是必须实现的
我曾经踩过的坑:有一次为了赶进度,我在压力传感器驱动里把 read_humidity 直接返回 0。结果测试同事以为湿度正常,导致一批数据作废。从那以后,我对胖接口深恶痛绝。

胖接口的四大危害

我总结了一下,胖接口在嵌入式系统里主要有四个致命伤:

危害 具体表现 后果
编译耦合 接口变更导致所有实现模块重新编译 构建时间从秒级变成分钟级
实现负担 每个实现类都要写一堆空函数 代码冗余,维护成本翻倍
语义模糊 调用方不清楚哪些接口可用 运行时错误难以排查
测试困难 Mock 对象需要实现全部接口 单元测试覆盖率上不去

你想想看,一个只有 64KB Flash 的 MCU,光接口的空函数就占了 2KB——这还不算最严重的。最严重的是,每次改接口都要全量回归测试,项目后期根本不敢动。

如何识别胖接口?三个检查方法

我在做代码评审时,一般用三个方法快速识别胖接口:

  1. 计数法:一个接口里超过 5 个方法,就要警惕了。超过 8 个,基本就是胖接口。
  2. 使用率法:统计每个接口方法被调用的次数。如果有超过 30% 的方法调用次数为 0,说明接口太胖了。
  3. 语义聚类法:把接口方法按功能分组。如果一组方法属于不同业务领域,就该拆分。
我的个人习惯:在代码里加一个接口使用率统计宏,编译时自动输出每个接口方法的调用次数。这样在代码评审时直接看数据说话,比凭感觉靠谱多了。

ISP 在嵌入式中的落地场景

嵌入式系统里,ISP 最典型的落地场景有三个。我一个个说。

场景一:传感器驱动拆分

回到刚才的传感器例子。按照 ISP,应该拆成多个小接口:

// 温度传感器接口 —— 只做温度相关的事
typedef struct {
    int32_t (*init)(void);
    int32_t (*read_temperature)(float *val);
    int32_t (*self_test)(void);
} temp_sensor_if_t;

// 湿度传感器接口 —— 只做湿度相关的事
typedef struct {
    int32_t (*init)(void);
    int32_t (*read_humidity)(float *val);
    int32_t (*self_test)(void);
} humidity_sensor_if_t;

// 可校准设备接口 —— 只有需要校准的传感器才实现
typedef struct {
    int32_t (*calibrate)(float param);
} calibratable_if_t;

这样一来,温度传感器驱动只需要实现 temp_sensor_if_t,压力传感器驱动只需要实现 pressure_sensor_if_t。需要校准功能的传感器,额外实现 calibratable_if_t 就行。

场景二:通信协议栈分层

我记得在一个物联网网关项目里,通信协议栈一开始也是胖接口:一个接口里同时包含了数据收发、连接管理、加密解密、心跳检测。后来按照 ISP 拆成四层:

  • 物理层接口:只负责字节收发
  • 链路层接口:只负责帧封装和校验
  • 网络层接口:只负责路由和寻址
  • 应用层接口:只负责业务数据解析

每一层只依赖自己需要的接口,上层不关心下层的实现细节。这样换一个无线模块,只需要改物理层,其他三层纹丝不动。

场景三:外设驱动抽象

嵌入式里最常见的场景就是外设驱动。比如 SPI Flash、I2C EEPROM、SD 卡,它们都支持读写,但细节完全不同。用 ISP 的思路:

// 只读接口 —— 适合只读存储器
typedef struct {
    int32_t (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
} read_only_if_t;

// 读写接口 —— 适合可读写存储器
typedef struct {
    int32_t (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
    int32_t (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
    int32_t (*erase)(uint32_t addr, uint32_t len);
} read_write_if_t;

// 可穿戴设备接口 —— 适合需要掉电保护的存储器
typedef struct {
    int32_t (*power_down)(void);
    int32_t (*power_up)(void);
} power_managed_if_t;

一个只读的存储器,只需要实现 read_only_if_t。一个需要掉电保护的 SPI Flash,可以实现 read_write_if_t + power_managed_if_t。接口小,职责清晰,测试也简单。

核心原则:接口越小,耦合越弱,复用性越强。但也不是越小越好——接口粒度要跟业务场景匹配。我一般控制在 3-5 个方法,超过 5 个就考虑拆分。

ISP 落地时的避坑指南

讲几个我实际踩过的坑,你遇到了可以绕开走:

  • 不要过度拆分:每个接口只有一个方法,那是函数指针,不是接口。接口要有业务语义。
  • 不要为了 ISP 而 ISP:如果某个接口在可预见的未来只有一种实现,胖一点也没关系。关键是看变化点。
  • 注意接口的稳定性:拆出来的小接口一旦发布,就不要轻易改。接口是契约,改契约要付出代价。
我曾经犯过的错:在一个项目里把接口拆得太细,结果一个传感器驱动要组合 5 个小接口,代码反而更复杂了。后来我总结了一个经验:接口拆分的粒度,以「一个普通工程师看代码 5 分钟能理解」为准。

ISP 知识体系总览

下面这张图把 ISP 的核心逻辑串起来了。你可以把它当作一个快速参考:

接口隔离原则知识体系 接口隔离原则(ISP)知识体系 客户端不应依赖它不用的接口 胖接口四大危害:编译耦合 · 实现负担 · 语义模糊 · 测试困难 识别胖接口:计数法(>5个方法) · 使用率法(>30%未调用) · 语义聚类法 嵌入式三大落地场景 传感器驱动拆分 温度/湿度/压力独立接口 通信协议栈分层 物理/链路/网络/应用独立 外设驱动抽象 只读/读写/电源管理分离 避坑指南:不过度拆分 · 不为了 ISP 而 ISP · 注意接口稳定性 接口粒度以「5分钟能理解」为准

嗯,ISP 其实不难理解。难的是在实际项目中克制住「加一个方法又不会死」的冲动。每次想往接口里加方法时,先问自己一句:这个接口的所有实现类,真的都需要这个方法吗?