2. 嵌入式系统基础
模块化设计,说白了就是「拆」的艺术。但怎么拆,拆到什么程度,拆完怎么拼回去——这里面的门道,我做了十几年嵌入式才慢慢摸透。
今天咱们先聊三个最核心的原则:高内聚低耦合、接口隔离(ISP)、依赖倒置(DIP)。这三个东西,你理解透了,代码结构基本就稳了。
2.1 高内聚与低耦合:模块设计的黄金法则
先问个问题:你改了一个模块的代码,结果三个不相关的模块跟着编译报错,这种事遇到过没?
我遇到过,而且不止一次。早期做车载ECU项目时,一个传感器驱动改了内部数据结构,结果上层应用、诊断模块、日志模块全崩了。那感觉,真叫一个酸爽。
2.1.1 高内聚:一个模块只做一件事
高内聚,就是模块内部的元素之间关联紧密,对外暴露的接口尽量少。说白了,一个模块只干一件事,并且把这件事干好。
| 内聚类型 | 描述 | 嵌入式示例 | 评价 |
|---|---|---|---|
| 功能内聚 | 模块内所有元素共同完成一个功能 | UART驱动:只负责收发数据 | ✅ 最佳 |
| 顺序内聚 | 一个元素的输出是另一个的输入 | ADC采样→滤波→转换 | ⚠️ 可接受 |
| 通信内聚 | 操作同一数据结构的多个函数 | 队列的push/pop/peek | ⚠️ 可接受 |
| 逻辑内聚 | 通过参数控制执行不同逻辑 | 一个函数处理所有外设初始化 | ❌ 应避免 |
| 偶然内聚 | 毫无关联的代码凑在一起 | 一个.c文件里放了定时器、GPIO、CRC | ❌ 必须重构 |
2.1.2 低耦合:模块之间只通过接口说话
耦合度,衡量的是模块之间的依赖程度。耦合越低,一个模块改动对其他模块的影响就越小。
嵌入式系统里,最常见的耦合问题就是全局变量满天飞。我曾经接手过一个项目,一个全局结构体被20多个文件直接读写。改一个字段,全工程都要回归测试——这哪是模块化,分明是意大利面条。
| 耦合类型 | 特征 | 嵌入式反面案例 | 解耦方案 |
|---|---|---|---|
| 数据耦合 | 通过参数传递简单数据 | — | ✅ 理想 |
| 标记耦合 | 传递结构体指针 | 到处传递全局配置结构体 | 只传递需要的字段 |
| 控制耦合 | 传递控制标志改变对方行为 | 传一个mode参数让驱动做不同事 | 拆成多个独立函数 |
| 外部耦合 | 依赖外部环境(文件、硬件) | 直接操作寄存器地址 | 加硬件抽象层 |
| 公共耦合 | 多个模块共享全局数据 | 全局变量、全局结构体 | 封装成模块私有 |
| 内容耦合 | 一个模块直接修改另一个模块内部数据 | extern暴露内部变量 | 只暴露函数接口 |
2.2 接口隔离原则(ISP):不要强迫别人用他们不需要的东西
ISP的核心思想:客户端不应该被迫依赖它们不使用的接口。说白了,接口要小而专,不要大而全。
嵌入式里最常见的反例:一个「万能外设驱动接口」,里面塞了SPI、I2C、UART、GPIO的所有操作函数。你的模块只需要SPI,却要链接整个驱动库——这不合理。
2.2.1 嵌入式C中的ISP实践
看个例子。假设我们有一个存储模块,需要操作EEPROM和Flash:
// ❌ 违反ISP:一个接口包含所有操作
typedef struct {
void (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
void (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
void (*erase)(uint32_t addr, uint32_t len); // Flash需要,EEPROM不需要
void (*page_program)(uint32_t addr, const uint8_t *buf); // Flash需要
void (*lock)(void); // 某些器件需要
void (*unlock)(void);
} Storage_Interface_t;
EEPROM驱动被迫实现了erase、page_program、lock/unlock——虽然这些函数内部是空操作。这代码,看着就难受。
按ISP重构:
// ✅ 符合ISP:拆成多个专用接口
typedef struct {
void (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
void (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
} Storage_RW_Interface_t;
typedef struct {
void (*erase)(uint32_t addr, uint32_t len);
void (*page_program)(uint32_t addr, const uint8_t *buf);
} Storage_Flash_Interface_t;
typedef struct {
void (*lock)(void);
void (*unlock)(void);
} Storage_Protect_Interface_t;
这样,EEPROM驱动只需要实现Storage_RW_Interface_t,Flash驱动可以实现全部三个。各取所需,互不干扰。
1. 这个接口的所有方法,调用者真的都需要吗?
2. 有没有方法会让调用者感到困惑?
3. 如果只保留最核心的3个方法,功能还能跑吗?
如果答案是否定的,那就拆。
2.3 依赖倒置原则(DIP):依赖抽象,不要依赖具体实现
DIP的定义:高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。
听着有点绕?我换个说法:你的业务逻辑代码,不要直接调用硬件操作。中间加一层抽象,让业务逻辑只跟抽象接口打交道。
2.3.1 嵌入式中的DIP典型应用
看一个实际场景:数据采集系统需要把传感器数据存到不同的存储介质。
// ❌ 违反DIP:高层直接依赖低层实现
void DataCollector_Process(void) {
SensorData_t data;
Sensor_Read(&data);
// 直接调用具体存储实现
EEPROM_Write(0x1000, (uint8_t*)&data, sizeof(data));
// 或者
SDCard_Write("data.bin", (uint8_t*)&data, sizeof(data));
// 每次换存储介质都要改这里
}
这种代码,换个存储方案就得改业务逻辑。我见过一个项目,因为从EEPROM换成Flash,改了17个文件——就因为到处直接调用EEPROM_Write。
按DIP重构:
// 定义抽象接口
typedef struct {
void (*init)(void);
void (*write)(uint32_t addr, const uint8_t *data, uint32_t len);
void (*read)(uint32_t addr, uint8_t *data, uint32_t len);
} Storage_Interface_t;
// 高层模块只依赖抽象
void DataCollector_Init(Storage_Interface_t *storage) {
// 保存接口指针
storage->init();
}
void DataCollector_Process(Storage_Interface_t *storage) {
SensorData_t data;
Sensor_Read(&data);
// 只通过抽象接口操作
storage->write(0x1000, (uint8_t*)&data, sizeof(data));
}
// 低层模块实现具体细节
static void EEPROM_Init(void) { /* EEPROM初始化 */ }
static void EEPROM_Write(uint32_t addr, const uint8_t *data, uint32_t len) { /* EEPROM写入 */ }
static void EEPROM_Read(uint32_t addr, uint8_t *data, uint32_t len) { /* EEPROM读取 */ }
Storage_Interface_t eeprom_storage = {
.init = EEPROM_Init,
.write = EEPROM_Write,
.read = EEPROM_Read
};
// 使用时
DataCollector_Init(&eeprom_storage);
DataCollector_Process(&eeprom_storage);
2.4 三个原则的关系与SVG框架图
这三个原则不是孤立的。高内聚低耦合是目标,ISP和DIP是实现手段。ISP帮你把接口拆细,DIP帮你把依赖关系倒过来——最终都是为了降低耦合、提高内聚。
2.5 实战中的常见误区
说了这么多原则,再聊聊实际落地时容易踩的坑。
- 过度抽象:为了解耦,每个模块都套三层接口。结果代码量翻倍,可读性暴跌。我的建议:先写具体实现,稳定后再抽象。
- 接口膨胀:接口设计时总想着「万一以后要用」,塞了一堆方法。结果90%的方法从来没人调用。记住:接口是契约,不是功能清单。
- 忽略性能:函数指针调用确实比直接调用慢一点。在中断服务函数或高频循环里,该直接调用就直调,别为了原则牺牲实时性。
2.6 小结
高内聚低耦合是目标,ISP帮你把接口拆得恰到好处,DIP帮你把依赖关系理顺。这三个原则,你可以在下一个项目中立刻用起来:
- 写一个新模块前,先想清楚它「只做哪一件事」
- 设计接口时,问自己「调用者真的需要这个方法吗」
- 业务逻辑里,不要直接new一个具体对象,通过接口去拿
做到这三点,你的代码结构就已经超过80%的嵌入式工程师了。