3. 分层架构设计原则:依赖倒置、接口隔离、单一职责在嵌入式中的应用

做嵌入式系统架构,说白了就是在跟「耦合」打架。我见过太多项目,外设驱动跟业务逻辑缠在一起,换一个MCU就得重写大半代码。为什么会这样?因为一开始就没想清楚分层这件事。

这一章,我们聊聊三个最核心的设计原则——依赖倒置、接口隔离、单一职责。它们不是纸上谈兵,是真正能帮你把代码拆开、解耦、跑起来的东西。

3.1 单一职责原则:一个模块只做一件事

单一职责,英文叫 Single Responsibility Principle,简称 SRP。意思很简单:一个模块、一个函数、一个文件,只应该有一个让它改变的理由。

我在项目中遇到过这样的代码:一个 GPIO 初始化函数,里面既配置了引脚模式,又设置了中断优先级,还顺带初始化了一个定时器。你想想看,如果后面要改中断优先级,你得去 GPIO 的代码里找;如果换 MCU,GPIO 的驱动全得重写,因为职责混在一起了。

核心判断标准: 问自己一个问题——「这个模块如果改了,会影响多少个不相关的功能?」如果答案超过1个,说明职责没有分离。

举个例子,一个典型的 UART 驱动,应该拆成三层:

层级 职责 变更原因
硬件抽象层 (HAL) 直接操作寄存器,收发字节 MCU 换型号、寄存器地址变化
驱动层 (Driver) 封装成 open/read/write/close 接口 外设协议升级、缓冲区策略调整
应用层 (App) 调用驱动接口,处理业务逻辑 产品需求变化、用户交互方式改变

每一层只关心自己的事。HAL 层不知道什么叫「协议」,驱动层不知道什么叫「业务」,应用层不知道什么叫「寄存器」。这样改起来才痛快。

我的习惯: 写每个 .c 文件之前,先在文件头注释里写一句话:「这个文件负责什么?」如果一句话说不清楚,说明职责太杂了,拆。

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;

调用方需要什么,就依赖什么接口。不需要的,连看都不用看。这样换驱动的时候,影响范围最小。

避坑指南: 我曾经在一个项目里把 SPI Flash 驱动和 EEPROM 驱动合并成一个「存储接口」,结果发现两者的时序、页大小、擦除逻辑完全不同。强行合并导致代码里全是 if-else,最后不得不拆开。接口隔离不是「为了好看」,是为了减少耦合。

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 三个原则如何协同工作

这三个原则不是孤立的。它们一起构成了分层架构的骨架:

  • 单一职责 告诉你每个模块应该有多大、多专。
  • 接口隔离 告诉你模块之间应该怎么通信、通信的粒度多大。
  • 依赖倒置 告诉你依赖的方向应该怎么走、谁依赖谁。

我习惯把它们画成一张图,每次做架构设计时都拿出来对照:

分层架构三原则协同关系 应用层 (App Layer) 单一职责:只处理业务逻辑,不关心硬件细节 抽象接口层 (Interface Layer) 接口隔离:按职责拆分,每个接口只暴露必要方法 驱动层 (Driver Layer) 依赖倒置:实现抽象接口,不直接暴露给应用 硬件抽象层 (HAL Layer) 单一职责:只操作寄存器,不包含协议逻辑 关键设计要点 1. 依赖方向:应用层 → 抽象接口 ← 驱动层(依赖反转) 2. 接口粒度:每个接口只包含调用方真正需要的方法 3. 模块边界:每个模块只做一件事,变更原因唯一

3.5 实战中的常见误区

原则听起来都对,落地的时候容易跑偏。我总结几个常见坑:

误区一:过度抽象
我曾经见过一个项目,把 GPIO 的拉高拉低都抽象成了接口,结果代码里全是函数指针跳转,性能下降不说,调试起来也费劲。抽象要有度,不是所有东西都需要接口。对于频繁调用的底层操作,直接调用反而更清晰。
误区二:接口膨胀
接口隔离不是说「接口越小越好」,而是「接口刚好够用」。如果一个接口只有两个方法,但调用方只用其中一个,说明这个接口还是太大了。反过来,如果一个接口有五个方法,调用方五个都用到了,那就没问题。
误区三:职责划分过细
单一职责不是让你把代码拆成碎片。一个文件里只有三行代码,那叫过度设计。合理的粒度是:一个模块大概 100-300 行,能独立完成一个完整的功能单元。

3.6 我的建议:从一个小模块开始实践

如果你刚开始接触这些原则,别想着一下子重构整个项目。找一个最简单的模块——比如一个 LED 驱动、一个按键扫描——用这三个原则重新设计一遍。

步骤很简单:

  1. 先确定这个模块的「单一职责」是什么。
  2. 然后定义它对外提供的「接口」,只暴露必要的方法。
  3. 最后让上层依赖接口,底层实现接口,完成「依赖倒置」。

做完一个小模块,你就能体会到分层架构的好处。下次再做新项目,自然就会按这个思路来。

一个小技巧: 写代码的时候,把「如果换 MCU,这个文件需要改几行?」作为衡量标准。如果答案是 0,说明你的分层做得不错。如果答案是「全部重写」,嗯,该回头看看这三个原则了。