3. 分层架构设计原则:高内聚低耦合、接口隔离、依赖倒置
做嵌入式开发久了,你会发现一个规律:代码腐烂的速度,往往和项目复杂度成正比。刚写完时逻辑清晰,三个月后自己都不想看。为什么?因为模块之间缠得太死了。
我刚开始做单片机项目时,也喜欢把所有逻辑塞进一个 main.c。后来一个带协议解析的项目让我吃了大亏——改一个传感器驱动,结果 UI 显示也跟着崩。从那以后,我彻底转向了分层架构。
分层不是花架子,它是让你在项目后期还能睡个安稳觉的保障。这一章,我们就来聊聊三个核心原则:高内聚低耦合、接口隔离、依赖倒置。
3.1 高内聚与低耦合:模块的「分」与「合」
说白了,高内聚就是一个模块只干一件事,并且把这件事干好。低耦合就是模块之间尽量少说话,说话也只通过约定的接口。
| 概念 | 含义 | 反面例子 |
|---|---|---|
| 高内聚 | 模块内部功能紧密相关,职责单一 | 一个函数既做 CRC 校验又刷新 LCD 屏幕 |
| 低耦合 | 模块间依赖最小化,修改互不影响 | 协议层直接调用 GPIO 寄存器操作硬件 |
举个例子。一个典型的单片机项目,我会这样划分模块:
- 硬件抽象层(HAL):封装 MCU 外设寄存器操作,如 SPI 读写、GPIO 控制
- 协议解析层:处理数据帧的组包、校验、解析,不关心数据从哪来
- 业务逻辑层:根据解析结果执行具体动作,如控制电机、更新显示
- 应用层:协调各模块工作,处理用户交互
每一层只依赖它的下一层,绝不跨层调用。这样,哪天你要换 MCU,只需要重写 HAL 层,上面的代码基本不用动。
3.2 接口隔离:别让模块知道太多
接口隔离原则,用大白话说就是:不要强迫模块依赖它用不到的东西。
我见过不少代码,一个结构体里塞了十几个函数指针,结果每个使用者只用到其中两三个。一旦修改这个结构体,所有模块都得重新编译——这就是典型的「胖接口」问题。
正确的做法是:每个模块只暴露它真正需要暴露的接口。比如:
// 不好的做法:一个头文件暴露所有
#include "hal_all.h"
void hal_spi_write(uint8_t *data, uint16_t len);
void hal_spi_read(uint8_t *data, uint16_t len);
void hal_gpio_set(uint8_t pin, uint8_t level);
void hal_timer_start(uint32_t period);
// ... 还有几十个函数
// 好的做法:按模块拆分接口
// spi_interface.h
typedef struct {
void (*write)(uint8_t *data, uint16_t len);
void (*read)(uint8_t *data, uint16_t len);
} spi_if_t;
// gpio_interface.h
typedef struct {
void (*set)(uint8_t pin, uint8_t level);
uint8_t (*get)(uint8_t pin);
} gpio_if_t;
这样,协议解析层只需要依赖 spi_if_t,它根本不知道 GPIO 和 Timer 的存在。接口越小,依赖越轻,代码越容易测试。
3.3 依赖倒置:让高层决定规则
依赖倒置原则听起来有点绕,其实核心就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象接口。
你想想看,传统的分层是「上层调用下层」。但这样有个问题:如果底层硬件变了,上层逻辑也得跟着改。依赖倒置就是把这个关系反过来——让上层定义接口,下层去实现这个接口。
来看一个实际例子。假设我们要实现一个数据采集系统:
// 定义抽象接口(由业务层定义)
typedef struct {
uint8_t (*init)(void);
uint8_t (*read_sample)(uint16_t *value);
void (*power_down)(void);
} sensor_if_t;
// 业务层代码只依赖这个接口
void data_collector_run(sensor_if_t *sensor) {
sensor->init();
uint16_t sample;
if (sensor->read_sample(&sample) == 0) {
// 处理采样数据
}
sensor->power_down();
}
// 底层实现:温度传感器
static uint8_t temp_init(void) { /* 初始化温度传感器 */ }
static uint8_t temp_read(uint16_t *v) { /* 读取温度值 */ }
static void temp_power_down(void) { /* 关闭传感器 */ }
const sensor_if_t temp_sensor = {
.init = temp_init,
.read_sample = temp_read,
.power_down = temp_power_down
};
看到没?业务层 data_collector_run 根本不关心底层是温度传感器还是压力传感器。只要实现了 sensor_if_t 接口,就能无缝接入。这就是依赖倒置的威力。
3.4 模块划分与接口定义实战
理论说完了,我们来看一个具体的模块划分案例。这是一个典型的物联网网关项目:
| 模块名 | 职责 | 对外接口 |
|---|---|---|
| uart_drv | UART 收发,DMA 管理 | uart_send(), uart_recv_cb_register() |
| protocol_parser | Modbus/自定义协议解析 | parser_feed_byte(), parser_get_frame() |
| data_manager | 数据缓存、去重、持久化 | data_store(), data_query() |
| network_mgr | Wi-Fi/以太网连接管理 | net_connect(), net_send() |
| app_main | 业务编排,状态机调度 | app_init(), app_run() |
每个模块的接口定义,我习惯放在单独的头文件中,并且只暴露必要的函数和类型。内部实现细节全部用 static 隐藏。
下面这张图展示了我们这套分层架构的整体关系:
嗯,这张图基本概括了我们这一章的核心思想。每一层只和相邻层通信,接口由上层定义,下层实现。这样,即使底层硬件全部换掉,上层的业务逻辑代码一行都不用改。