3. 分层架构设计原则:依赖倒置、接口隔离、单一职责在嵌入式中的应用
做嵌入式系统架构,说白了就是在跟「耦合」打架。我见过太多项目,外设驱动跟业务逻辑缠在一起,换一个MCU就得重写大半代码。为什么会这样?因为一开始就没想清楚分层这件事。
这一章,我们聊聊三个最核心的设计原则——依赖倒置、接口隔离、单一职责。它们不是纸上谈兵,是真正能帮你把代码拆开、解耦、跑起来的东西。
3.1 单一职责原则:一个模块只做一件事
单一职责,英文叫 Single Responsibility Principle,简称 SRP。意思很简单:一个模块、一个函数、一个文件,只应该有一个让它改变的理由。
我在项目中遇到过这样的代码:一个 GPIO 初始化函数,里面既配置了引脚模式,又设置了中断优先级,还顺带初始化了一个定时器。你想想看,如果后面要改中断优先级,你得去 GPIO 的代码里找;如果换 MCU,GPIO 的驱动全得重写,因为职责混在一起了。
举个例子,一个典型的 UART 驱动,应该拆成三层:
| 层级 | 职责 | 变更原因 |
|---|---|---|
| 硬件抽象层 (HAL) | 直接操作寄存器,收发字节 | MCU 换型号、寄存器地址变化 |
| 驱动层 (Driver) | 封装成 open/read/write/close 接口 | 外设协议升级、缓冲区策略调整 |
| 应用层 (App) | 调用驱动接口,处理业务逻辑 | 产品需求变化、用户交互方式改变 |
每一层只关心自己的事。HAL 层不知道什么叫「协议」,驱动层不知道什么叫「业务」,应用层不知道什么叫「寄存器」。这样改起来才痛快。
3.2 接口隔离原则:不要强迫别人依赖你不需要的东西
接口隔离原则,Interface Segregation Principle,ISP。大白话就是:你提供的接口,调用方用多少就给多少,别塞一堆用不上的东西。
我记得有一次做传感器驱动,同事写了一个「万能传感器接口」,里面包含了初始化、读取、写入、校准、休眠、唤醒、自检……十几个函数。结果每个传感器只用其中三四个,剩下的全是空实现或者返回错误码。代码臃肿不说,还容易误导人——调用方以为支持校准,其实根本没实现。
正确的做法是什么?把接口拆小:
// 不好的做法:一个大接口
typedef struct {
void (*init)(void);
void (*read)(uint8_t *buf, uint32_t len);
void (*write)(uint8_t *buf, uint32_t len);
void (*calibrate)(void);
void (*sleep)(void);
void (*wakeup)(void);
void (*self_test)(void);
} sensor_if_t;
// 好的做法:按职责拆分
typedef struct {
void (*init)(void);
void (*read)(uint8_t *buf, uint32_t len);
} sensor_reader_t;
typedef struct {
void (*init)(void);
void (*write)(uint8_t *buf, uint32_t len);
} sensor_writer_t;
typedef struct {
void (*calibrate)(void);
} sensor_calibrator_t;
调用方需要什么,就依赖什么接口。不需要的,连看都不用看。这样换驱动的时候,影响范围最小。
3.3 依赖倒置原则:高层不依赖底层,都依赖抽象
依赖倒置原则,Dependency Inversion Principle,DIP。这是分层架构的基石。它的核心思想:
- 高层模块不应该依赖低层模块,两者都应该依赖抽象。
- 抽象不应该依赖细节,细节应该依赖抽象。
你想想看,传统的嵌入式代码是什么样?应用层直接调用 HAL 层的函数:
// 传统做法:应用层直接依赖 HAL
void app_task(void) {
hal_uart_send(USART1, "Hello", 5); // 直接调用 HAL
hal_gpio_toggle(GPIOA, GPIO_PIN_5);
}
这样写有什么问题?如果换 MCU,HAL 层的函数名、参数、甚至头文件都变了,应用层代码全得改。这就是「高层依赖低层」的后果。
依赖倒置的做法是:定义抽象接口,让应用层依赖接口,底层驱动去实现接口。
// 抽象接口定义(放在公共头文件里)
typedef struct {
void (*send)(const uint8_t *data, uint32_t len);
void (*toggle)(void);
} io_interface_t;
// 应用层只依赖接口
void app_task(io_interface_t *io) {
io->send("Hello", 5);
io->toggle();
}
// 底层驱动实现接口
static void uart_send(const uint8_t *data, uint32_t len) {
// STM32 HAL 实现
HAL_UART_Transmit(&huart1, data, len, 100);
}
static void gpio_toggle(void) {
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}
const io_interface_t stm32_io = {
.send = uart_send,
.toggle = gpio_toggle
};
换 MCU 的时候,只需要重新实现 io_interface_t 这个结构体里的函数指针,应用层代码一行都不用改。这就是依赖倒置的力量。
3.4 三个原则如何协同工作
这三个原则不是孤立的。它们一起构成了分层架构的骨架:
- 单一职责 告诉你每个模块应该有多大、多专。
- 接口隔离 告诉你模块之间应该怎么通信、通信的粒度多大。
- 依赖倒置 告诉你依赖的方向应该怎么走、谁依赖谁。
我习惯把它们画成一张图,每次做架构设计时都拿出来对照:
3.5 实战中的常见误区
原则听起来都对,落地的时候容易跑偏。我总结几个常见坑:
我曾经见过一个项目,把 GPIO 的拉高拉低都抽象成了接口,结果代码里全是函数指针跳转,性能下降不说,调试起来也费劲。抽象要有度,不是所有东西都需要接口。对于频繁调用的底层操作,直接调用反而更清晰。
接口隔离不是说「接口越小越好」,而是「接口刚好够用」。如果一个接口只有两个方法,但调用方只用其中一个,说明这个接口还是太大了。反过来,如果一个接口有五个方法,调用方五个都用到了,那就没问题。
单一职责不是让你把代码拆成碎片。一个文件里只有三行代码,那叫过度设计。合理的粒度是:一个模块大概 100-300 行,能独立完成一个完整的功能单元。
3.6 我的建议:从一个小模块开始实践
如果你刚开始接触这些原则,别想着一下子重构整个项目。找一个最简单的模块——比如一个 LED 驱动、一个按键扫描——用这三个原则重新设计一遍。
步骤很简单:
- 先确定这个模块的「单一职责」是什么。
- 然后定义它对外提供的「接口」,只暴露必要的方法。
- 最后让上层依赖接口,底层实现接口,完成「依赖倒置」。
做完一个小模块,你就能体会到分层架构的好处。下次再做新项目,自然就会按这个思路来。